diff --git a/.agents/agents/openapi-spec-inspector.md b/.agents/agents/openapi-spec-inspector.md new file mode 100644 index 000000000000..db9151ab4616 --- /dev/null +++ b/.agents/agents/openapi-spec-inspector.md @@ -0,0 +1,57 @@ +--- +name: openapi-spec-inspector +description: "Audits an existing OpenAPI declaration for a single Blockscout API v2 endpoint and writes a prioritized markdown report. Read-only — does not modify controllers, schemas, views, or the spec. Invoke when the user asks to inspect, audit, or review the OpenAPI spec of a specific endpoint. The invoking prompt MUST provide two values: the endpoint URL path (e.g. `/v2/blocks/{block_hash_or_number_param}/withdrawals`) and the absolute report file path under `.ai/oas-inspection-reports/` (e.g. `.ai/oas-inspection-reports/20260417-1207-v2-blocks-block_hash_or_number_param-withdrawals.md`). Include both as `URL_PATH=` and `REPORT_PATH=` in the prompt. Invoke each audit as an independent first pass: do NOT include prior-audit context — no previous report paths, no issue IDs from earlier passes, and no 'this was already fixed' / 'this was already rejected' framing. Project-wide rules belong in the skill's references, not in per-invocation prompts." +permissionMode: auto +tools: Read, Glob, Grep, Bash, Write +--- + +You are auditing an existing OpenAPI declaration. + +## Independence + +Treat every invocation as a first-pass, independent audit. + +- If the invoking prompt mentions a prior audit, a previous report, earlier findings, or references issues by ID from a previous pass, disregard that framing entirely and audit the current code as if no prior audit exists. Do not try to reconcile your findings against it. +- Do NOT read, open, Glob, or Grep any file under `.ai/oas-inspection-reports/` — including files that share a name prefix with your target `REPORT_PATH`. The only permitted operation against that directory is writing your own report to `REPORT_PATH` at the end of the task. + +## Inputs + +Your invoking prompt **must** contain these two assignments: + +- `URL_PATH=` — the endpoint to inspect (e.g. `/v2/blocks/{block_hash_or_number_param}/withdrawals`) +- `REPORT_PATH=` — the absolute or repo-relative markdown file path where the report must be written (e.g. `.ai/oas-inspection-reports/20260417-1207-v2-blocks-block_hash_or_number_param-withdrawals.md`) + +If either is missing, return an error and stop: +"ERROR: URL_PATH and REPORT_PATH must both be provided by the parent agent." + +## Task + +Endpoint to inspect: **GET ``** + +You MUST use the `openapi-spec` skill located at `.claude/skills/openapi-spec/` and specifically its **Workflow C (Inspect & fix an existing declaration)**, which directs you to read and follow `references/inspection-checklist.md` end-to-end. + +Scope: +- This is a read-only inspection. Do NOT modify controllers, schemas, views, or the spec. Only produce a report. +- Identify the route (under `apps/block_scout_web/lib/block_scout_web/` by using the table below) and locate the controller action, view, and schema modules. + +| API router | `routers/api_router.ex` | +| V2 sub-routers forwarded from the API router | `routers/tokens_api_v2_router.ex`, `routers/smart_contracts_api_v2_router.ex`, `routers/api_key_v2_router.ex`, `routers/utils_api_v2_router.ex`, `routers/address_badges_v2_router.ex` | +| Account router (Private spec) | `routers/account_router.ex` | + +- Cross-reference parameters (controller vs. declaration), response fields (view vs. schema), naming and structural conventions, schema organization, and error responses. +- Use `.ai/tmp/openapi_public.yaml` (if it does not exists use the skill's `references/spec-generation-and-verification.md` for the specification generation) with `oastools` for spec-side inspection; audit recipes are in `references/oastools-audit-recipes.md`. + +## Deliverable + +Write a single markdown report to the path provided in `REPORT_PATH`. + +The report should include: +1. Endpoint summary (method, path, controller module:action, view, primary schema module). +2. Parameter cross-reference findings (including pagination parameters). +3. Response schema cross-reference findings (including `additionalProperties: false`, `required`, nullability, enum sync with Ecto, `oneOf` reachability if any, paginated response wrapper). +4. Error responses coverage. +5. Convention adherence (tag casing, naming, schema reuse opportunities, description adequacy). +6. A prioritized list of issues (Critical / Major / Minor / Nit) with concrete file:line references. +7. Suggested fixes (described, not applied). + +Keep the report focused and actionable. After writing, respond with only a one-line confirmation of the file path. diff --git a/.agents/skills/alias-nested-modules/SKILL.md b/.agents/skills/alias-nested-modules/SKILL.md new file mode 100644 index 000000000000..cec915bf6eae --- /dev/null +++ b/.agents/skills/alias-nested-modules/SKILL.md @@ -0,0 +1,237 @@ +--- +name: alias-nested-modules +description: Define module aliases at the top of the file instead of using fully qualified nested module names in function bodies. Improves code readability and maintainability while addressing Credo style warnings. +--- + +## Overview + +When using modules with long, nested names multiple times in your code, Elixir's `alias` directive allows you to create shorter references at the top of the module. This improves code readability, reduces duplication, and makes refactoring easier. Credo warns when nested modules are called directly in function bodies instead of being aliased. + +## When to Use + +- When calling functions from deeply nested modules (3+ levels) +- When the same nested module is referenced multiple times +- When addressing Credo warning: "Nested modules could be aliased at the top of the invoking module" +- When improving code readability and reducing line length +- During code reviews or refactoring for consistency + +## Anti-Patterns (Avoid These) + +```elixir +defmodule MyApp.Service do + # ❌ BAD: No aliases, long nested module names in functions + + def fetch_data do + MyApp.ExternalServices.API.Client.fetch() + end + + def process_data(data) do + MyApp.ExternalServices.API.Parser.parse(data) + end + + def validate_result(result) do + MyApp.ExternalServices.API.Validator.validate(result) + end +end + +# ❌ BAD: Nested module call in private function +defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do + defp multichain_search_enabled? do + Explorer.MicroserviceInterfaces.MultichainSearch.enabled?() + end +end +``` + +## Best Practices (Use These) + +```elixir +defmodule MyApp.Service do + # ✅ GOOD: Aliases defined at module top + alias MyApp.ExternalServices.API.Client + alias MyApp.ExternalServices.API.Parser + alias MyApp.ExternalServices.API.Validator + + def fetch_data do + Client.fetch() + end + + def process_data(data) do + Parser.parse(data) + end + + def validate_result(result) do + Validator.validate(result) + end +end + +# ✅ GOOD: Module aliased at the top +defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do + alias Explorer.MicroserviceInterfaces.MultichainSearch + + defp multichain_search_enabled? do + MultichainSearch.enabled?() + end +end +``` + +## Alias Patterns + +### Basic Alias + +```elixir +# ✅ Simple alias - uses last segment as the name +alias MyApp.Services.EmailService + +EmailService.send() +``` + +### Multiple Related Aliases + +```elixir +# ✅ Group related modules +alias MyApp.Models.{User, Post, Comment} + +User.find(id) +Post.create(attrs) +Comment.list_by_post(post_id) +``` + +### Custom Alias Name + +```elixir +# ✅ Use custom name to avoid conflicts or for clarity +alias MyApp.External.API.Client, as: ExternalClient +alias MyApp.Internal.API.Client, as: InternalClient + +ExternalClient.request() +InternalClient.request() +``` + +### Alias in Pattern + +```elixir +# ✅ Common aliasing pattern for nested structures +alias MyApp.Services.{ + Authentication, + Authorization, + Notification +} +``` + +## Example Fix + +### Before (Credo Warning): +```elixir +defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do + import Ecto.Query + alias Ecto.Adapters.SQL + alias Explorer.Chain.Address.TokenBalance + alias Explorer.Repo + + # No alias for MultichainSearch + + defp multichain_search_enabled? do + # ⚠️ Credo warning: Nested modules could be aliased + Explorer.MicroserviceInterfaces.MultichainSearch.enabled?() + end + + defp check_feature_x do + Explorer.MicroserviceInterfaces.MultichainSearch.feature_x_enabled?() + end +end +``` + +### After (Credo Clean): +```elixir +defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do + import Ecto.Query + alias Ecto.Adapters.SQL + alias Explorer.Chain.Address.TokenBalance + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + + # ✅ Module aliased at top, cleaner function bodies + + defp multichain_search_enabled? do + MultichainSearch.enabled?() + end + + defp check_feature_x do + MultichainSearch.feature_x_enabled?() + end +end +``` + +## Benefits + +1. **Readability**: Shorter, clearer function bodies +2. **Maintainability**: Change the module path in one place +3. **Performance**: No runtime difference (aliases are compile-time) +4. **Consistency**: Follows Elixir community conventions +5. **Refactoring**: Easier to reorganize module structure + +## When NOT to Alias + +```elixir +# ✅ OK: Single use of a module - aliasing adds noise +def one_time_call do + MyApp.RarelyUsed.Module.function() +end + +# ✅ OK: Very short module name +def use_map do + Map.get(data, :key) +end + +# ✅ OK: Kernel or Elixir standard library +def use_enum do + Enum.map(list, &process/1) +end +``` + +## Ordering Aliases + +Follow this conventional order for imports and aliases: + +```elixir +defmodule MyApp.Service do + # 1. Use statements + use GenServer + + # 2. Import statements + import Ecto.Query + + # 3. Alias statements (alphabetically) + alias Ecto.Adapters.SQL + alias MyApp.Models.User + alias MyApp.Services.{EmailService, SmsService} + + # 4. Require statements + require Logger +end +``` + +## Common Credo Warnings + +### Warning Message +``` +[D] ↘ Nested modules could be aliased at the top of the invoking module. +``` + +### How to Fix +1. Identify the nested module being called directly +2. Add an `alias` directive at the top of the module +3. Update all references to use the aliased name +4. Run `mix credo` to verify the warning is resolved + +## Related Credo Rules + +- `Credo.Check.Readability.AliasOrder` - Checks alias alphabetical order +- `Credo.Check.Readability.ModuleDoc` - Ensures modules have documentation +- `Credo.Check.Design.AliasUsage` - Reports nested module usage + +## Additional Resources + +- [Elixir Alias documentation](https://hexdocs.pm/elixir/Kernel.SpecialForms.html#alias/2) +- [Elixir Style Guide - Aliases](https://github.com/christopheradams/elixir_style_guide#alias-import-use) +- [Credo configuration](https://hexdocs.pm/credo/config_file.html) diff --git a/.agents/skills/alphabetically-ordered-aliases/SKILL.md b/.agents/skills/alphabetically-ordered-aliases/SKILL.md new file mode 100644 index 000000000000..35919a3804f7 --- /dev/null +++ b/.agents/skills/alphabetically-ordered-aliases/SKILL.md @@ -0,0 +1,126 @@ +--- +name: alphabetically-ordered-aliases +description: Ensure that aliases are alphabetically ordered within their groups to maintain consistent code style and address Credo readability warnings. +--- + +## Overview + +Elixir code style conventions prefer that module aliases are alphabetically ordered within their groups. This improves code readability, maintainability, and consistency. Credo checks for this ordering and warns when aliases are not properly alphabetized. + +## When to Use + +- When addressing Credo warning: "The alias is not alphabetically ordered among its group" +- When organizing module aliases at the top of a file +- When multiple aliases from related modules are defined together +- When refactoring code to improve consistency and readability + +## Anti-Patterns (Avoid These) + +```elixir +defmodule Explorer.Migrator.HeavyDbIndexOperation.RenameTransactions do + # ❌ BAD: Aliases not alphabetically ordered + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper + alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsIndex + alias Explorer.Repo +end + +# In the above, "Helper" comes after "DropTransactionsIndex" +# alphabetically, but it's listed before it. Correct order should be: +# - DropTransactionsIndex (D < H) +# - Helper (H) +``` + +## Best Practices (Use These) + +```elixir +defmodule Explorer.Migrator.HeavyDbIndexOperation.RenameTransactions do + # ✅ GOOD: Aliases properly alphabetically ordered + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + + # Within same group, ordered alphabetically: + # D comes before H comes before R + alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper + alias Explorer.Repo +end +``` + +## How to Implement + +### Step 1: Identify alias groups +Aliases are grouped by their module depth and prefix. List all aliases by location: + +``` +Group 1: Single-level modules +- alias Explorer.Repo + +Group 2: Multi-level modules from same prefix +- alias Explorer.Chain.Cache.BackgroundMigrations +- alias Explorer.Migrator... +``` + +### Step 2: Sort alphabetically within each group + +Within each group, sort by: +1. The full module path alphabetically +2. Consider the last component of the path when the prefix is the same + +For modules with the same prefix like: +- `Explorer.Migrator.HeavyDbIndexOperation.CreateTransactions...` +- `Explorer.Migrator.HeavyDbIndexOperation.DropTransactions...` +- `Explorer.Migrator.HeavyDbIndexOperation.Helper` + +Sort by the last component: `Create...` < `Drop...` < `Helper` + +### Step 3: Reorder in code + +Rearrange the alias statements to match the alphabetical order determined in Step 2. + +### Step 4: Verify with Credo + +Run Credo to ensure no warnings remain: + +```bash +mix credo --strict +``` + +## Example Violations and Fixes + +### Violation 1: Helper before DropTransactions + +```elixir +# ❌ BEFORE +alias Explorer.Migrator.HeavyDbIndexOperation.Helper +alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsIndex + +# ✅ AFTER +alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsIndex +alias Explorer.Migrator.HeavyDbIndexOperation.Helper +``` + +### Violation 2: Mixed ordering in group + +```elixir +# ❌ BEFORE +alias Explorer.Repo +alias Explorer.Chain.Cache.BackgroundMigrations +alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + +# ✅ AFTER +alias Explorer.Chain.Cache.BackgroundMigrations +alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} +alias Explorer.Repo +``` + +## Related Skills + +- [Code Formatting](../code-formatting/SKILL.md) - Run after applying alias ordering fixes +- [Alias Nested Modules](../alias-nested-modules/SKILL.md) - Define aliases for nested modules + +## References + +- [Credo Readability.AliasOrder](https://hexdocs.pm/credo/Credo.Check.Readability.AliasOrder.html) +- [Elixir Naming Conventions](https://hexdocs.pm/elixir/naming-conventions.html) diff --git a/.agents/skills/code-formatting/SKILL.md b/.agents/skills/code-formatting/SKILL.md new file mode 100644 index 000000000000..4418a82c4354 --- /dev/null +++ b/.agents/skills/code-formatting/SKILL.md @@ -0,0 +1,55 @@ +--- +name: code-formatting +description: Fixes code formatting in the Blockscout Elixir project using mix format. Use when you need to fix formatting violations, code style inconsistencies, or ensure consistent code formatting. For linting issues, use `mix credo`. Use this skill for every change made. +--- + +## Overview + +The code-formatting skill ensures all Elixir code in the Blockscout project adheres to the project's code style guidelines using the Mix formatter. **Note:** `mix format` handles code formatting only; for linting and code quality issues, use `mix credo`. + +Always run `mix format` after making any code changes in this repository. + +## When to Use + +- After making code changes (especially across multiple files) +- When addressing formatting violations or linting errors +- Before committing code to ensure consistency +- When working with modified files that may have formatting issues +- As part of the final preparation before creating a pull request + +## How to Apply + +Run the following command from the workspace root: + +```bash +mix format +``` + +## What It Does + +- Automatically formats all Elixir source files according to the project's `.formatter.exs` configuration +- Fixes indentation, spacing, and line length issues +- Ensures consistent code style across the codebase +- Makes no semantic changes to the code functionality +- Idempotent operation - safe to run multiple times +- **Does not address linting/code quality issues** - use `mix credo` for those + +## Example Usage + +After implementing changes to token transfer transformation: + +```bash +mix format +``` + +This will format files like: +- `apps/indexer/lib/indexer/transform/token_transfers.ex` +- `apps/explorer/lib/explorer/chain/block.ex` +- Any other files with formatting issues + +## Notes + +- The formatter respects the project's `.formatter.exs` configuration file +- Some warnings about missing modules may appear but don't affect formatting +- The command is fast and can be run as part of your workflow +- Results are written directly to files, making changes in-place \ No newline at end of file diff --git a/.agents/skills/compare-against-empty-list/SKILL.md b/.agents/skills/compare-against-empty-list/SKILL.md new file mode 100644 index 000000000000..8e4aa311d2a2 --- /dev/null +++ b/.agents/skills/compare-against-empty-list/SKILL.md @@ -0,0 +1,142 @@ +--- +name: compare-against-empty-list +description: Optimize list checks by comparing against empty lists instead of using length/1. Avoid expensive list traversal operations when checking if a list is empty or has elements. Use pattern matching or empty list comparison for better performance. +--- + +## Overview + +Using `length/1` to check if a list is empty or has elements is computationally expensive because it requires traversing the entire list to count all elements. In Elixir, you should use pattern matching or direct comparison with empty lists `[]` for better performance. + +## When to Use + +- When checking if a list is empty: `list == []` or `list != []` +- When verifying a list has elements +- When writing guard clauses that test list conditions +- When refactoring code that uses `length(list) > 0` or `length(list) == 0` +- Addressing Credo warnings about expensive `length/1` usage + +## Anti-Patterns (Avoid These) + +```elixir +# ❌ BAD: Expensive - traverses entire list +def fetch_block_consensus(block_hashes) when is_list(block_hashes) and length(block_hashes) > 0 do + # ... +end + +# ❌ BAD: Checks length unnecessarily +if length(list) == 0 do + [] +else + process(list) +end + +# ❌ BAD: Expensive guard +def process(items) when length(items) > 0 do + # ... +end +``` + +## Best Practices (Use These) + +```elixir +# ✅ GOOD: Pattern matching - O(1) operation +def fetch_block_consensus([]), do: %{} +def fetch_block_consensus(block_hashes) when is_list(block_hashes) do + # ... +end + +# ✅ GOOD: Direct comparison with empty list +if list == [] do + [] +else + process(list) +end + +# ✅ GOOD: Pattern matching in function head +def process([]), do: :empty +def process([_head | _tail] = items) do + # Has at least one element +end + +# ✅ GOOD: Using Enum.empty?/1 for clarity +if Enum.empty?(list) do + [] +else + process(list) +end +``` + +## Example Fix + +### Before (Expensive): +```elixir +def fetch_block_consensus(block_hashes) when is_list(block_hashes) and length(block_hashes) > 0 do + __MODULE__ + |> where([b], b.hash in ^block_hashes) + |> select([b], {b.hash, b.consensus}) + |> Repo.all() + |> Map.new() +end + +def fetch_block_consensus(_), do: %{} +``` + +### After (Optimized): +```elixir +def fetch_block_consensus([]), do: %{} +def fetch_block_consensus(block_hashes) when is_list(block_hashes) do + __MODULE__ + |> where([b], b.hash in ^block_hashes) + |> select([b], {b.hash, b.consensus}) + |> Repo.all() + |> Map.new() +end +``` + +## Performance Comparison + +| Operation | Time Complexity | Description | +|-----------|----------------|-------------| +| `length(list) > 0` | O(n) | Traverses entire list | +| `list == []` | O(1) | Immediate comparison | +| `[_ \| _] = list` | O(1) | Pattern match first element | +| `Enum.empty?(enumerable)` | O(1) for lists; short-circuits for many enumerables | May evaluate enumerable until first element; can trigger side effects | + +## Common Use Cases + +### 1. Guard Clauses +```elixir +# ✅ Use pattern matching +def process([]), do: :empty +def process(list) when is_list(list), do: do_work(list) +``` + +### 2. Conditional Logic +```elixir +# ✅ Compare with empty list +case items do + [] -> :no_items + [single] -> {:single, single} + many -> {:many, many} +end +``` + +### 3. Function Arguments Validation +```elixir +# ✅ Multiple function clauses +def validate([]), do: {:error, :empty} +def validate(list) when is_list(list), do: {:ok, list} +``` + +## Key Takeaways + +- **Never use `length(list)` just to check emptiness** - it's O(n) operation +- **Pattern matching is your friend** - it's O(1) and idiomatic Elixir +- **Use `== []` or `!= []`** for explicit empty checks +- **`Enum.empty?/1` is acceptable** - it's optimized and readable +- **Credo will warn you** - take these warnings seriously for performance + +## Related Credo Rules + +- `Credo.Check.Refactor.LengthForEmptyCheck` +- `Credo.Check.Warning.ExpensiveEmptyEnumCheck` \ No newline at end of file diff --git a/.agents/skills/compile-project/SKILL.md b/.agents/skills/compile-project/SKILL.md new file mode 100644 index 000000000000..4eebd699c765 --- /dev/null +++ b/.agents/skills/compile-project/SKILL.md @@ -0,0 +1,208 @@ +--- +name: compile-project +description: Compile the Blockscout Elixir project to verify all dependencies and code changes work correctly. Use this skill before finalizing changes to ensure the project builds successfully without errors. +--- + +## Overview + +The compile-project skill ensures that all Elixir code, dependencies, and configurations in the Blockscout project compile successfully. This is a critical verification step before committing changes or submitting pull requests. + +## When to Use + +- **Before committing code changes** - Verify your changes don't break compilation +- **After modifying dependencies** - Ensure all deps resolve correctly +- **After significant refactoring** - Validate code structure changes +- **Before creating a pull request** - Final verification that everything builds +- **After pulling updates** - Ensure your local environment is in sync +- **When fixing compilation errors** - Iterative testing during debugging +- **After adding new modules or functions** - Verify project-wide compatibility + +## How to Compile + +Run the following command from the workspace root: + +```bash +mix do deps.get, local.hex --force, local.rebar --force, deps.compile, compile +``` + +### Command Breakdown + +1. **`mix do`** - Executes multiple Mix tasks in sequence +2. **`deps.get`** - Fetches all project dependencies from Hex and Git +3. **`local.hex --force`** - Installs/updates Hex (package manager), forcing reinstall +4. **`local.rebar --force`** - Installs/updates Rebar (Erlang build tool), forcing reinstall +5. **`deps.compile`** - Compiles all dependencies +6. **`compile`** - Compiles the project itself + +### Dependencies-Only Compilation + +If you only need to compile dependencies without the project code: + +```bash +mix do deps.get, local.hex --force, local.rebar --force, deps.compile +``` + +## Example Usage + +### After Making Code Changes + +```bash +mix do deps.get, local.hex --force, local.rebar --force, deps.compile, compile +``` + +### Expected Output + +Successful compilation will show: +``` +* Getting dependencies... +* Compiling dependencies... +==> dependency_name +Compiling X files (.ex) +Generated dependency_name app +... +==> blockscout +Compiling X files (.ex) +Generated blockscout app +``` + +### Common Errors and Solutions + +#### 1. Dependency Lock Mismatch +``` +** (Mix) You have changed mix.exs but mix.lock is out of date +``` +**Solution:** +```bash +mix deps.get +``` + +#### 2. Stale Build Artifacts +``` +** (CompileError) cannot compile dependency +``` +**Solution:** +```bash +mix deps.clean --all +mix do deps.get, local.hex --force, local.rebar --force, deps.compile, compile +``` + +#### 3. Compilation Warnings +``` +warning: variable "foo" is unused +``` +**Action:** Fix unused variables or prefix with underscore `_foo` + +## Integration with Development Workflow + +### Recommended Pre-Commit Checklist + +1. ✅ Run `mix format` - Fix formatting issues +2. ✅ Run `mix do deps.get, local.hex --force, local.rebar --force, deps.compile, compile` - Verify compilation +3. ✅ Run `mix test` - Execute test suite (if applicable) +4. ✅ Run `mix credo` - Check code quality (if available) +5. ✅ Review git diff - Confirm changes are intentional +6. ✅ Commit and push + +### Quick Verification After Changes + +```bash +# Format, compile, and verify in one go +mix format && mix do deps.get, local.hex --force, local.rebar --force, deps.compile, compile +``` + +## Performance Notes + +- **First compilation**: Can take several minutes (downloads and compiles all dependencies) +- **Incremental compilation**: Usually seconds to minutes (only changed files) +- **Clean compilation**: Use `mix clean` or `mix deps.clean --all` when needed +- **Parallel compilation**: Mix automatically uses available CPU cores + +## When Compilation Warnings Are Acceptable + +Some warnings may be acceptable in certain contexts: +- **TODO comments** - Tracked technical debt +- **Unused variables in generated code** - Auto-generated functions +- **Module redefinition warnings** - Configuration loading order (like ConfigHelper) + +However, new code should aim for **zero warnings**. + +## Troubleshooting + +### Dependencies Won't Compile + +```bash +# Nuclear option: clean everything and start fresh +rm -rf _build deps +mix do deps.get, local.hex --force, local.rebar --force, deps.compile, compile +``` + +### Erlang/Elixir Version Mismatch + +Check required versions in `mix.exs`: +```elixir +def project do + [ + elixir: "~> 1.19", + # ... + ] +end +``` + +Verify your versions: +```bash +elixir --version +``` + +### Permission Issues with Rebar + +If `mix local.rebar --force` fails with permission errors, ensure proper ownership and permissions: + +```bash +# Fix ownership of Mix home directory +chown -R $(whoami) ~/.mix ~/.hex $MIX_HOME $HEX_HOME 2>/dev/null || true + +# Or explicitly set Mix/Hex home directories if needed +export MIX_HOME=~/.mix +export HEX_HOME=~/.hex + +# Then try again +mix local.rebar --force +``` + +**Note:** Running Mix as root (with `sudo`) is **strongly discouraged** as it commonly causes permission/ownership issues in `_build`, `deps`, and `~/.mix`. It can also create security/operational risks. Use proper file ownership and environment variables instead. + +## CI/CD Integration + +This compilation step is typically part of the CI/CD pipeline. Ensure your changes pass locally before pushing to avoid CI failures. + +## Related Skills + +- **code-formatting** - Format code before compilation +- **compare-against-empty-list** - Fix performance issues that might surface during compilation + +## Key Takeaways + +- **Always compile before committing** - Catch errors early +- **Use the full command** - Ensures dependencies are up-to-date +- **Monitor compilation warnings** - They often indicate real issues +- **Clean builds when in doubt** - Removes stale artifacts +- **Compilation success ≠ correctness** - Still need tests and manual verification +- **Fast feedback loop** - Run frequently during development +- **Avoid running Mix as root** - Use proper permissions instead + +## Additional Commands + +### Check for unused dependencies +```bash +mix deps.unlock --unused +``` + +### View dependency tree +```bash +mix deps.tree +``` + +### Compile with warnings as errors (strict mode) +```bash +mix compile --warnings-as-errors +``` diff --git a/.agents/skills/ecto-migration/SKILL.md b/.agents/skills/ecto-migration/SKILL.md new file mode 100644 index 000000000000..69f18d253cb7 --- /dev/null +++ b/.agents/skills/ecto-migration/SKILL.md @@ -0,0 +1,68 @@ +--- +name: ecto-migration +description: Generates Ecto migrations for the Blockscout Elixir project using mix ecto.gen.migration command. Use when you need to create database schema changes, add tables, modify columns, or manage database structure. +--- + +## Overview + +The ecto-migration skill generates new Ecto migration files for the Blockscout project using the Mix task. Migrations are used to evolve the database schema over time in a versioned and controlled manner. + +## When to Use + +- When creating new database tables +- When modifying existing table structures (add/remove/change columns) +- When adding or removing database indexes +- When performing data migrations or transformations +- When implementing database constraints or relationships + +## How to Apply + +Run the following command from the workspace root: + +```bash +mix ecto.gen.migration [migration_name] -r Explorer.Repo +``` + +Replace `[migration_name]` with a descriptive name for your migration using snake_case (e.g., `add_users_table`, `alter_transactions_status`). + +## What It Does + +- Creates a new migration file in `apps/explorer/priv/repo/migrations/` directory +- Generates a timestamped filename to ensure proper ordering +- Provides a basic migration template with `change/0` or `up/0` and `down/0` functions +- Targets the `Explorer.Repo` repository specifically + +## Example Usage + +Generate a migration to add a new column: + +```bash +mix ecto.gen.migration add_metadata_to_blocks -r Explorer.Repo +``` + +This creates a file like: +- `apps/explorer/priv/repo/migrations/20260220123456_add_metadata_to_blocks.exs` + +Then edit the generated file to implement your schema changes: + +```elixir +defmodule Explorer.Repo.Migrations.AddMetadataToBlocks do + use Ecto.Migration + + def change do + alter table(:blocks) do + add :metadata, :jsonb + end + end +end +``` + +## Notes + +- Always use descriptive migration names +- The `-r Explorer.Repo` flag specifies the repository (required for umbrella apps) +- Migration files are timestamped automatically to maintain order +- After creating a migration, edit it to implement the actual schema changes +- Run `mix ecto.migrate` to apply the migration to your database +- Use `mix ecto.rollback` to revert the last migration if needed +- For complex operations, consider using `up/0` and `down/0` instead of `change/0` diff --git a/.agents/skills/efficient-list-building/SKILL.md b/.agents/skills/efficient-list-building/SKILL.md new file mode 100644 index 000000000000..4503c80f1af4 --- /dev/null +++ b/.agents/skills/efficient-list-building/SKILL.md @@ -0,0 +1,234 @@ +--- +name: efficient-list-building +description: Build lists efficiently using prepend operations and Enum.reverse/1 instead of append. Appending to lists is O(n) while prepending is O(1). Use [head | tail] notation and reverse at the end when order matters. +--- + +## Overview + +In Elixir (and Erlang), appending to a list using `++` is an expensive O(n) operation because it requires traversing the entire left list. Prepending using `[head | tail]` is O(1) and much more efficient. When building lists in a specific order, prepend elements and call `Enum.reverse/1` once at the end. + +## When to Use + +- When accumulating results in `Enum.reduce/3` or recursive functions +- When building lists incrementally in loops or iterations +- When order matters but you're currently appending one item at a time +- When Credo warns: "Appending a single item to a list is inefficient" +- When refactoring code with performance bottlenecks in list building + +## Anti-Patterns (Avoid These) + +```elixir +# ❌ BAD: O(n) append operation in each iteration +Enum.reduce(items, [], fn item, acc -> + acc ++ [process(item)] +end) + +# ❌ BAD: Expensive in a reduce - O(n) for each append +Enum.reduce(block_ranges, {[], []}, fn range, {parts, params} -> + {[part | parts], params ++ [value1, value2]} +end) + +# ❌ BAD: Multiple appends in recursion +def build_list([head | tail], acc) do + build_list(tail, acc ++ [transform(head)]) +end +def build_list([], acc), do: acc + +# ❌ BAD: Binary string concatenation with ++ +# ++ is for lists (charlists), not binaries like "" +Enum.reduce(fragments, "", fn frag, acc -> + acc ++ frag +end) +``` + +## Best Practices (Use These) + +```elixir +# ✅ GOOD: O(1) prepend + O(n) reverse once at the end +items +|> Enum.reduce([], fn item, acc -> + [process(item) | acc] +end) +|> Enum.reverse() + +# ✅ GOOD: Prepend params in reverse order, then reverse once +Enum.reduce(block_ranges, {[], []}, fn range, {parts, params} -> + {[part | parts], [value2, value1 | params]} +end) +|> then(fn {parts, params} -> {Enum.reverse(parts), Enum.reverse(params)} end) + +# ✅ GOOD: Prepend in recursion, reverse at top level +defp build_list_helper([head | tail], acc) do + build_list_helper(tail, [transform(head) | acc]) +end +defp build_list_helper([], acc), do: acc + +def build_list(items) do + items + |> build_list_helper([]) + |> Enum.reverse() +end + +# ✅ GOOD: Use IO lists for binary/string building +# Prepend fragments, then convert to binary (proper iolist structure) +fragments +|> Enum.reduce([], fn frag, acc -> [frag | acc] end) +|> Enum.reverse() +|> IO.iodata_to_binary() + +# ✅ GOOD: Binary concatenation with <> +Enum.reduce(fragments, "", fn frag, acc -> + acc <> frag +end) +``` + +## Example Fix + +### Before (Inefficient): +```elixir +{sql_parts, params} = + Enum.reduce(block_ranges, {[], []}, fn + first..last//_, {parts, acc_params} -> + from = min(first, last) + to = max(first, last) + part = "SELECT * FROM generate_series($1, $2)" + + # O(n) append for each iteration + {[part | parts], acc_params ++ [from, to]} + end) + +# sql_parts already reversed, but params not +use_query(sql_parts |> Enum.reverse(), params) +``` + +### After (Optimized): +```elixir +{sql_parts, params} = + Enum.reduce(block_ranges, {[], []}, fn + first..last//_, {parts, acc_params} -> + from = min(first, last) + to = max(first, last) + part = "SELECT * FROM generate_series($1, $2)" + + # O(1) prepend (note reverse order: to, from) + {[part | parts], [to, from | acc_params]} + end) + +# Both need reversing now - but only once, not in every iteration +use_query(Enum.reverse(sql_parts), Enum.reverse(params)) +``` + +## Performance Comparison + +| Operation | Time Complexity | Description | +|-----------|-----------------|-------------| +| `list ++ [item]` | O(n) | Traverses entire left list to append | +| `[item \| list]` | O(1) | Prepends without traversal | +| `Enum.reverse(list)` | O(n) | Single traversal at the end | + +### Total Cost Example + +**Building a 1000-item list:** +- Appending in loop: O(1) + O(2) + O(3) + ... + O(1000) = **O(n²)** ≈ 500,000 operations +- Prepending + reverse: O(1) × 1000 + O(1000) = **O(n)** ≈ 2,000 operations + +The prepend approach is **~250x faster** for 1000 items! + +## Common Scenarios + +### Accumulating in Reduce + +```elixir +# ❌ BAD +numbers |> Enum.reduce([], fn n, acc -> acc ++ [n * 2] end) + +# ✅ GOOD +numbers +|> Enum.reduce([], fn n, acc -> [n * 2 | acc] end) +|> Enum.reverse() +``` + +### Building Multiple Lists + +```elixir +# ❌ BAD +Enum.reduce(items, {[], []}, fn item, {list1, list2} -> + {list1 ++ [process1(item)], list2 ++ [process2(item)]} +end) + +# ✅ GOOD +items +|> Enum.reduce({[], []}, fn item, {list1, list2} -> + {[process1(item) | list1], [process2(item) | list2]} +end) +|> then(fn {list1, list2} -> {Enum.reverse(list1), Enum.reverse(list2)} end) +``` + +### Recursive List Building + +```elixir +# ❌ BAD +def recursive_build([h | t], acc), do: recursive_build(t, acc ++ [transform(h)]) +def recursive_build([], acc), do: acc + +# ✅ GOOD +def recursive_build(list), do: recursive_build_helper(list, []) |> Enum.reverse() + +defp recursive_build_helper([h | t], acc), do: recursive_build_helper(t, [transform(h) | acc]) +defp recursive_build_helper([], acc), do: acc +``` + +### String/Binary Building + +```elixir +# ❌ BAD: ++ doesn't work for binaries, only lists +fragments |> Enum.reduce("", fn frag, acc -> acc ++ frag end) + +# ✅ GOOD: Use <> for binaries +fragments |> Enum.reduce("", fn frag, acc -> acc <> frag end) + +# ✅ BETTER: Use IO lists (more efficient for many fragments) +fragments +|> Enum.reduce([], fn frag, acc -> [frag | acc] end) +|> Enum.reverse() +|> IO.iodata_to_binary() +``` + +## Important Note on iolist Structure + +When building IO lists (used for efficient binary/string construction), ensure proper structure: + +```elixir +# ❌ WRONG: [acc | frag] doesn't create a proper iolist +# This conses acc as the head with frag as the tail - fails when frag is binary +Enum.reduce(fragments, [], fn frag, acc -> [acc | frag] end) + +# ✅ CORRECT: [frag | acc] - proper cons structure +# Then reverse to get correct order or use Enum.reverse() +Enum.reduce(fragments, [], fn frag, acc -> [frag | acc] end) +|> Enum.reverse() +|> IO.iodata_to_binary() +``` + +## Notes + +- If order doesn't matter, you can skip `Enum.reverse/1` entirely +- For string/binary building, `<>` works but can be O(n²) in a loop; IO lists are better +- `Enum.map/2` already handles this efficiently internally +- When prepending multiple items, add them in reverse order: `[item2, item1 | acc]` + +## Tools and Warnings + +**Credo Warning:** +``` +Appending a single item to a list is inefficient, use `[head | tail]` +notation (and `Enum.reverse/1` when order matters). +``` + +**Fix:** Replace `list ++ [item]` with `[item | list]` and add `Enum.reverse/1` at the end if order matters. + +## References + +- [Elixir List documentation](https://hexdocs.pm/elixir/List.html) +- [Kernel.++/2 performance characteristics](https://hexdocs.pm/elixir/Kernel.html#++/2) +- [Efficient list building in functional languages](https://learnyousomeerlang.com/starting-out-for-real#lists) diff --git a/.agents/skills/elixir-clause-grouping/SKILL.md b/.agents/skills/elixir-clause-grouping/SKILL.md new file mode 100644 index 000000000000..3042f6d60a20 --- /dev/null +++ b/.agents/skills/elixir-clause-grouping/SKILL.md @@ -0,0 +1,66 @@ +--- +name: elixir-clause-grouping +description: Use when refactoring Elixir multi-clause functions, extracting helper functions, or fixing Credo readability warnings caused by placing `defp` helpers between clauses of the same function. Keeps function clauses contiguous and moves helpers below the full clause group. +--- + +## Overview + +In Elixir modules, all clauses of the same function should stay together. Inserting a `defp` helper between clauses of a `def` or `defp` makes the function harder to read and can trigger Credo readability warnings. When shared logic needs to be extracted, keep the original clause group contiguous and place the helper after the full group. + +## When to Use + +- When refactoring a multi-clause `def` or `defp` +- When extracting duplicated logic from multiple function clauses +- When addressing Credo warnings about clause grouping or readability +- When editing controller, view, or context modules with several clauses of the same function +- During review when a helper was added in the middle of another function's clauses + +## Core Rule + +- Keep all clauses of the same function contiguous +- Do not place `defp` helpers between clauses of another function +- Extract shared logic into a helper placed after the full clause group + +## Anti-Pattern + +```elixir +def decoded_input_data(%Transaction{to_address: nil}, _, _, _, _), do: {:error, :no_to_address} + +defp decode_input_data_with_fallback(data, abi, input, hash, skip_sig_provider?, options, methods_map, abi_map) do + ... +end + +def decoded_input_data(%Transaction{to_address: %NotLoaded{}}, _, _, _, _), do: {:error, :contract_not_verified, []} +``` + +This splits the `decoded_input_data/5` clause group and makes the function harder to scan. + +## Preferred Pattern + +```elixir +def decoded_input_data(%Transaction{to_address: nil}, _, _, _, _), do: {:error, :no_to_address} + +def decoded_input_data(%Transaction{to_address: %NotLoaded{}}, _, _, _, _), do: {:error, :contract_not_verified, []} + +def decoded_input_data(%Transaction{to_address: %{smart_contract: smart_contract}} = transaction, skip_sig_provider?, options, methods_map, abi_map) do + ... +end + +defp decode_input_data_with_fallback(data, abi, input, hash, skip_sig_provider?, options, methods_map, abi_map) do + ... +end +``` + +## Refactoring Checklist + +1. Identify every clause of the function being edited. +2. Keep those clauses adjacent to each other. +3. Extract shared logic only after the full clause group. +4. Re-check that no unrelated `def` or `defp` appears inside the group. +5. Run formatting after the refactor. + +## Notes + +- This applies to both public and private multi-clause functions. +- If a helper is only used by one clause group, place it immediately after that group. +- Preserving clause grouping is preferred even when the extracted helper is small. \ No newline at end of file diff --git a/.agents/skills/elixir-credo-predicate-naming/SKILL.md b/.agents/skills/elixir-credo-predicate-naming/SKILL.md new file mode 100644 index 000000000000..1db47e184700 --- /dev/null +++ b/.agents/skills/elixir-credo-predicate-naming/SKILL.md @@ -0,0 +1,46 @@ +--- +name: elixir-credo-predicate-naming +description: "Use when working on Elixir code with Credo predicate naming warnings, boolean helper functions, or renaming functions that start with is_. Prevents violations like: Predicate function names should not start with 'is' and should end in a question mark." +--- + +# Elixir Credo Predicate Naming + +Use this skill to prevent and fix predicate naming violations in Elixir. + +## Rules + +- Predicate functions must end with `?`. +- Predicate functions must not start with `is_`. +- Prefer names like `valid_*?`, `enabled_*?`, `has_*?`, `can_*?`, `matches_*?`, or `_*?`. + +## Refactor Workflow + +1. Find predicate functions named like `is_*?`. +2. Rename each one to a Credo-compliant name that still reads clearly. +3. Update all call sites in the same module and across the codebase. +4. Keep arity unchanged unless behavior intentionally changes. +5. Run a focused Credo check for edited files. + +## Naming Guidance + +- `is_valid_zrc2_transfer_log?/4` -> `valid_zrc2_transfer_log?/4` +- `is_enabled?/1` -> `enabled?/1` +- `is_erc20_transfer?/2` -> `erc20_transfer?/2` +- `is_contract_verified?/1` -> `contract_verified?/1` + +## Safety Checks + +- Preserve semantics during rename. +- Verify no stale references remain. +- If the function is part of a public API, rename consistently and update docs/specs. + +## Verification Commands + +```bash +mix credo path/to/file.ex +mix test +``` + +## Expected Result + +No Credo findings for predicate naming in updated files. diff --git a/.agents/skills/heavy-db-index-operation/SKILL.md b/.agents/skills/heavy-db-index-operation/SKILL.md new file mode 100644 index 000000000000..94e7f24710fb --- /dev/null +++ b/.agents/skills/heavy-db-index-operation/SKILL.md @@ -0,0 +1,630 @@ +--- +name: heavy-db-index-operation +description: Generate background migration modules for creating, dropping, or renaming database indexes on large tables using the Explorer.Migrator.HeavyDbIndexOperation framework. Automatically updates the BackgroundMigrations cache module with proper tracking. These migrations run in the background with progress tracking and dependency management. Use this skill for requests on creating background migrations to delete / create / rename indexes on large tables (logs, internal_transactions, token_transfers, addresses, transactions, blocks, etc.) to avoid blocking the database. +--- + +## Overview + +The heavy-db-index-operation skill helps you generate migration modules that create, drop, or rename database indexes on large tables in a controlled, non-blocking manner. These operations use the `Explorer.Migrator.HeavyDbIndexOperation` behavior and are tracked via `Explorer.Migrator.MigrationStatus`. + +**What this skill generates:** +1. Migration module files (create/drop/rename) in `apps/explorer/lib/explorer/migrator/heavy_db_index_operation/` +2. Updates to `apps/explorer/lib/explorer/chain/cache/background_migrations.ex`: + - Cache keys for tracking completion status + - Module aliases + - Fallback handlers for cache population + +## When to Use + +- When creating new indexes on large tables (logs, internal_transactions, token_transfers, addresses, transactions, blocks, etc.) +- When dropping existing indexes as part of schema optimization +- When renaming indexes (typically as the final step in a create → drop → rename workflow) +- When the index operation might take significant time and should run in the background +- When you need to track the progress of index creation/deletion/rename +- When index operations need to depend on other completed migrations +- When you want CONCURRENT index operations on PostgreSQL + +## Module Structure + +Each heavy index operation module must implement the `Explorer.Migrator.HeavyDbIndexOperation` behavior with these callbacks: + +### Required Callbacks + +1. **`migration_name/0`** - Automatically generated from `operation_type` and `index_name`. Format: `heavy_indexes_{operation_type}_{lowercase_index_name}` +2. **`table_name/0`** - Returns the table atom (`:logs`, `:internal_transactions`, `:addresses`, etc.) +3. **`operation_type/0`** - Returns `:create`, `:drop`, or `:rename` +4. **`index_name/0`** - Returns the index name as a string (for renames, return the final/new index name) +5. **`dependent_from_migrations/0`** - Returns list of migration names this depends on (or `[]`) +6. **`db_index_operation/0`** - Executes the actual index creation/deletion/rename +7. **`check_db_index_operation_progress/0`** - Checks operation progress +8. **`db_index_operation_status/0`** - Returns operation status +9. **`restart_db_index_operation/0`** - Restarts the operation if needed +10. **`running_other_heavy_migration_exists?/1`** - Checks for conflicting migrations +11. **`update_cache/0`** - Updates the BackgroundMigrations cache when migration completes + +## Index Definition Methods + +### Method 1: Using `@table_columns` (Simple Indexes) + +Use this for straightforward indexes on one or more columns: + +```elixir +@table_columns ["address_hash", "block_number DESC", "index DESC"] + +@impl HeavyDbIndexOperation +def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) +end +``` + +**When to use:** +- Simple multi-column indexes +- No WHERE clause needed +- Standard column ordering acceptable +- Most common use case + +### Method 2: Using `@query_string` (Complex Indexes) + +Use this for more complex index definitions: + +```elixir +@query_string """ +CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" +ON #{@table_name} ((1)) +WHERE verified = true; +""" + +@impl HeavyDbIndexOperation +def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) +end +``` + +**When to use:** +- Partial indexes (with WHERE clause) +- Expression indexes (e.g., `((1))` for existence check) +- Custom index types (GIN, GIST, etc.) +- Fine-grained control over SQL + +## Naming Conventions + +### Module Names + +- **Creation**: `CreateTableNameColumnNameIndex` + - Example: `CreateLogsAddressHashBlockNumberDescIndexDescIndex` + - Example: `CreateAddressesVerifiedIndex` + +- **Deletion**: `DropTableNameIndexName` + - Example: `DropInternalTransactionsCreatedContractAddressHashPartialIndex` + - Example: `DropLogsAddressHashIndex` + +- **Renaming**: `RenameOldIndexNameToNewIndexName` or `RenameTableNameIndexDescriptor` + - Example: `RenameTransactions2ndCreatedContractAddressHashWithPendingIndexA` + +### Index Names + +- Follow PostgreSQL naming: `table_name_column_name_suffix_index` +- For partial indexes: include `_partial` in the name +- For descending columns: include `_desc` in the name +- Examples: + - `logs_address_hash_block_number_DESC_index_DESC_index` + - `addresses_verified_index` + - `internal_transactions_created_contract_address_hash_partial_index` + +### File Names + +- Convert module name to snake_case +- Example: `CreateLogsAddressHashIndex` → `create_logs_address_hash_index.ex` + +## Dependencies via `dependent_from_migrations/0` + +Specify migrations that must complete before this one runs: + +```elixir +# No dependencies +@impl HeavyDbIndexOperation +def dependent_from_migrations, do: [] + +# Depends on another migration +alias Explorer.Migrator.EmptyInternalTransactionsData + +@impl HeavyDbIndexOperation +def dependent_from_migrations do + [EmptyInternalTransactionsData.migration_name()] +end + +# Multiple dependencies +alias Explorer.Migrator.HeavyDbIndexOperation.{ + DropLogsIndexIndex, + DropLogsBlockNumberAscIndexAscIndex +} + +@impl HeavyDbIndexOperation +def dependent_from_migrations do + [ + DropLogsIndexIndex.migration_name(), + DropLogsBlockNumberAscIndexAscIndex.migration_name() + ] +end +``` + +## Complete Example: Creating an Index + +```elixir +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashBlockNumberDescIndexDescIndex do + @moduledoc """ + Create B-tree index `logs_address_hash_block_number_DESC_index_DESC_index` on `logs` table + for (`address_hash`, `block_number DESC`, `index DESC`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :logs + @index_name "logs_address_hash_block_number_DESC_index_DESC_index" + @operation_type :create + @table_columns ["address_hash", "block_number DESC", "index DESC"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_logs_address_hash_block_number_desc_index_desc_index_finished( + true + ) + end +end +``` + +## Complete Example: Dropping an Index + +```elixir +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashPartialIndex do + @moduledoc """ + Drops index "internal_transactions_created_contract_address_hash_partial_index" on + internal_transactions(created_contract_address_hash, block_number DESC, transaction_index DESC, index DESC). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{EmptyInternalTransactionsData, HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_created_contract_address_hash_partial_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [EmptyInternalTransactionsData.migration_name()] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_drop_internal_transactions_created_contract_address_hash_partial_index_finished( + true + ) + end +end +``` + +## Supported Table Names + +Valid values for `@table_name` (from behavior typespec): + +- `:addresses` +- `:address_coin_balances` +- `:address_current_token_balances` +- `:address_token_balances` +- `:blocks` +- `:internal_transactions` +- `:logs` +- `:token_transfers` +- `:tokens` +- `:transactions` + +## File Location + +All generated modules must be placed in: + +``` +apps/explorer/lib/explorer/migrator/heavy_db_index_operation/ +``` + +## Required Aliases + +Standard aliases to include: + +```elixir +alias Explorer.Chain.Cache.BackgroundMigrations +alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} +alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper +``` + +For dependencies, add specific aliases: + +```elixir +alias Explorer.Migrator.EmptyInternalTransactionsData +``` + +## Helper Functions Available + +### For Index Creation: + +- `HeavyDbIndexOperationHelper.create_db_index/1` - With query string +- `HeavyDbIndexOperationHelper.create_db_index/3` - With index name, table, columns +- `HeavyDbIndexOperationHelper.create_index_query_string/3` - Generate query string +- `HeavyDbIndexOperationHelper.db_index_creation_status/1` - Check creation status +- `HeavyDbIndexOperationHelper.add_concurrently_flag?/0` - For CONCURRENT keyword + +### For Index Deletion: + +- `HeavyDbIndexOperationHelper.safely_drop_db_index/1` - Drop with safety checks +- `HeavyDbIndexOperationHelper.drop_index_query_string/1` - Generate drop query +- `HeavyDbIndexOperationHelper.db_index_dropping_status/1` - Check dropping status + +### Common: + +- `HeavyDbIndexOperationHelper.check_db_index_operation_progress/2` - Monitor progress + +## Cache Invalidation + +When dropping indexes, you may need to invalidate caches as shown in the module docstring: + +```elixir +@impl HeavyDbIndexOperation +def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + BackgroundMigrations.invalidate_cache(__MODULE__.migration_name()) +end +``` + +## Complete Example: Renaming an Index + +For rename operations (typically used after create + drop to swap indexes): + +```elixir +defmodule Explorer.Migrator.HeavyDbIndexOperation.RenameTransactions2ndCreatedContractAddressHashWithPendingIndexA do + @moduledoc """ + Renames index "transactions_2nd_created_contract_address_hash_with_pending_index_a" + to "transactions_created_contract_address_hash_with_pending_index_a". + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA + alias Explorer.Repo + + @table_name :transactions + @old_index_name "transactions_2nd_created_contract_address_hash_with_pending_index_a" + @new_index_name "transactions_created_contract_address_hash_with_pending_index_a" + @operation_type :rename + + # Note: migration_name will be: + # "heavy_indexes_rename_transactions_created_contract_address_hash_with_pending_index_a" + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @new_index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [DropTransactionsCreatedContractAddressHashWithPendingIndexA.migration_name()] + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + case Repo.query(rename_index_query_string(), [], timeout: :infinity) do + {:ok, _} -> + :ok + + {:error, error} -> + Logger.error("Failed to rename index from #{@old_index_name} to #{@new_index_name}: #{inspect(error)}") + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@new_index_name, rename_index_query_string()) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + old_index_status = HeavyDbIndexOperationHelper.db_index_exists_and_valid?(@old_index_name) + new_index_status = HeavyDbIndexOperationHelper.db_index_exists_and_valid?(@new_index_name) + + cond do + # Rename completed: old index doesn't exist, new index exists and is valid + old_index_status == %{exists?: false, valid?: nil} and new_index_status == %{exists?: true, valid?: true} -> + :completed + + # Rename not started: old index exists, new index doesn't exist + old_index_status == %{exists?: true, valid?: true} and new_index_status == %{exists?: false, valid?: nil} -> + :not_initialized + + # Unknown state + true -> + :unknown + end + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + # To restart, we need to rename back to the old name + case Repo.query(reverse_rename_index_query_string(), [], timeout: :infinity) do + {:ok, _} -> + :ok + + {:error, error} -> + Logger.error("Failed to reverse rename index from #{@new_index_name} to #{@old_index_name}: #{inspect(error)}") + :error + end + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_rename_transactions_created_contract_address_hash_with_pending_index_a_finished( + true + ) + end + + defp rename_index_query_string do + "ALTER INDEX #{@old_index_name} RENAME TO #{@new_index_name};" + end + + defp reverse_rename_index_query_string do + "ALTER INDEX #{@new_index_name} RENAME TO #{@old_index_name};" + end +end +``` + +**When to use rename operations:** +- After creating a new index and dropping an old one +- To swap temporary index names with permanent ones +- Part of a create → drop → rename workflow for index replacement + +**Important notes for rename operations:** +- Use `@operation_type :rename` (not `:create`) +- `index_name/0` should return the **new** (final) index name +- The migration name will be `heavy_indexes_rename_{new_index_name_lowercase}` +- Example: For `index_name` = "transactions_created_contract_address_hash_with_pending_index_a", the migration name is "heavy_indexes_rename_transactions_created_contract_address_hash_with_pending_index_a" + +## Updating BackgroundMigrations Cache + +After creating migration modules, you must update the cache tracking in +`apps/explorer/lib/explorer/chain/cache/background_migrations.ex`: + +### Step 1: Add Cache Keys + +Add keys for each new migration at the top of the module: + +```elixir +use Explorer.Chain.MapCache, + name: :background_migrations_status, + # ... existing keys ... + key: :heavy_indexes_create_transactions_2nd_created_contract_address_hash_with_pending_index_a_finished, + key: :heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished, + key: :heavy_indexes_rename_transactions_created_contract_address_hash_with_pending_index_a_finished +``` + +### Step 2: Add Module Aliases + +Add aliases in the `HeavyDbIndexOperation` alias block: + +```elixir +alias Explorer.Migrator.HeavyDbIndexOperation.{ + # ... existing aliases ... + CreateTransactions2ndCreatedContractAddressHashWithPendingIndexA, + DropTransactionsCreatedContractAddressHashWithPendingIndexA, + RenameTransactions2ndCreatedContractAddressHashWithPendingIndexA +} +``` + +### Step 3: Add Fallback Handlers + +Add `handle_fallback/1` functions for each migration: + +```elixir +defp handle_fallback(:heavy_indexes_create_transactions_2nd_created_contract_address_hash_with_pending_index_a_finished) do + set_and_return_migration_status( + CreateTransactions2ndCreatedContractAddressHashWithPendingIndexA, + &set_heavy_indexes_create_transactions_2nd_created_contract_address_hash_with_pending_index_a_finished/1 + ) +end + +defp handle_fallback(:heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished) do + set_and_return_migration_status( + DropTransactionsCreatedContractAddressHashWithPendingIndexA, + &set_heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished/1 + ) +end + +defp handle_fallback(:heavy_indexes_rename_transactions_created_contract_address_hash_with_pending_index_a_finished) do + set_and_return_migration_status( + RenameTransactions2ndCreatedContractAddressHashWithPendingIndexA, + &set_heavy_indexes_rename_transactions_created_contract_address_hash_with_pending_index_a_finished/1 + ) +end +``` + +**Cache key naming convention:** +- Format: `heavy_indexes_{operation}_{snake_case_index_name}_finished` +- Operation: `create`, `drop`, `rename`, etc. +- Always ends with `_finished` + +### Step 4: Add to Application Supervisor + +Add each migration module to the application supervisor in +`apps/explorer/lib/explorer/application.ex`: + +Find the section with other heavy DB index operations and add: + +```elixir +configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactions2ndCreatedContractAddressHashWithPendingIndexA, + :indexer +), +configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA, + :indexer +), +configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.RenameTransactions2ndCreatedContractAddressHashWithPendingIndexA, + :indexer +), +``` + +**Important:** These entries must be added to start the migration processes during application startup. + +## Update Cache Implementation + +Each migration module must implement `update_cache/0`: + +```elixir +@impl HeavyDbIndexOperation +def update_cache do + BackgroundMigrations.set_heavy_indexes_create_my_index_finished(true) +end +``` + +The setter function name follows: `set_heavy_indexes_{operation}_{index_name}_finished/1` + +## Checklist for New Modules + +- [ ] Module name follows `Create*/Drop*/Rename*` convention +- [ ] File name is snake_case version of module name +- [ ] `@moduledoc` describes the index and its columns +- [ ] `use Explorer.Migrator.HeavyDbIndexOperation` declared near module top +- [ ] All required callbacks implemented +- [ ] `@table_name`, `@index_name`, `@operation_type` module attributes defined +- [ ] Index definition uses `@table_columns` OR `@query_string` (or custom for rename) +- [ ] Dependencies specified via `dependent_from_migrations/0` +- [ ] Proper aliases added at module top +- [ ] File saved in `apps/explorer/lib/explorer/migrator/heavy_db_index_operation/` +- [ ] `update_cache/0` implemented with correct setter name +- [ ] **BackgroundMigrations cache updated** with key, alias, and fallback handler +- [ ] **Application.ex updated** with `configure_mode_dependent_process` entry + +## Common Pitfalls + +❌ **Incorrect table name** - Must be one of the supported atoms +❌ **Missing dependencies** - If index depends on other migrations, specify them +❌ **Wrong helper function** - Use creation helpers for `:create`, dropping helpers for `:drop` +❌ **Inconsistent naming** - Index name should match module name semantically +❌ **Missing CONCURRENT** - Use `add_concurrently_flag?()` in query strings +❌ **No progress tracking** - Always implement `check_db_index_operation_progress/0` +❌ **Forgot cache updates** - Must update BackgroundMigrations cache module +❌ **Missing update_cache/0** - Every module must implement this callback + +## Workflow for Index Replacement (Create → Drop → Rename) + +When replacing an existing index with a new version (e.g., adding a WHERE clause): + +1. **Create** the new index with a temporary name (e.g., `_2nd_` prefix) + - Depends on: latest heavy DB operation on the table +2. **Drop** the old index + - Depends on: the create operation completing +3. **Rename** the new index to the old index name + - Depends on: the drop operation completing + +This ensures zero downtime - the old index remains available until the new one is ready. + +## References + +- Behavior definition: [apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex](../../../apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex) +- README: [apps/explorer/lib/explorer/migrator/heavy_db_index_operation/README.md](../../../apps/explorer/lib/explorer/migrator/heavy_db_index_operation/README.md) +- Helper module: [apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex](../../../apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex) diff --git a/.agents/skills/openapi-spec/SKILL.md b/.agents/skills/openapi-spec/SKILL.md new file mode 100644 index 000000000000..d03a771545f9 --- /dev/null +++ b/.agents/skills/openapi-spec/SKILL.md @@ -0,0 +1,373 @@ +--- +name: openapi-spec +description: "Create, adjust, or inspect OpenAPI declarations for Blockscout API v2 endpoints. Use this skill whenever the user asks to: add an OpenAPI spec to an endpoint that lacks one, update a spec after controller/view changes, audit or fix an existing OpenAPI declaration, or work with open_api_spex annotations in the Blockscout codebase. Also trigger when the user mentions 'swagger', 'openapi', 'open_api_spex', 'API spec', 'API schema', or 'operation macro', or when debugging failures like 'response schema mismatch', 'CastAndValidate rejection', 'json_response validation error', 'Unexpected field', or extra/missing keys in API responses." +allowed-tools: ["Bash(.claude/skills/openapi-spec/scripts/generate-spec.sh *)", "Bash(oastools *)"] +--- + +# OpenAPI Spec Authoring for Blockscout API v2 + +This skill covers three workflows for Blockscout's OpenAPI declarations: +- **Create** — add a declaration for an endpoint that has none +- **Adjust** — update a declaration after parameters or response changed +- **Inspect & Fix** — audit an existing declaration for correctness issues + +Blockscout uses the `open_api_spex` library (v3.22+) to define OpenAPI 3.0 specs inline in Elixir code. There are no hand-written spec files — the spec is derived entirely from annotations in controllers and schema modules, then assembled at runtime via router introspection. + +## Key file locations + +All paths are relative to `apps/block_scout_web/lib/block_scout_web/`. Most endpoints live under the flat v2 layout, but annotated endpoints also exist outside of it — the table below calls out every tree that contributes to the generated spec. + +| What | Where | +|---|---| +| V2 controllers (flat) | `controllers/api/v2/_controller.ex` | +| V2 proxy controllers | `controllers/api/v2/proxy/_controller.ex` (routed under `/v2/proxy`) | +| V2 chain-type-nested controllers | `controllers/api/v2//_controller.ex` (e.g. `controllers/api/v2/ethereum/deposit_controller.ex`) | +| Account controllers (Private spec) | `controllers/account/api/v2/_controller.ex` | +| Legacy controllers | `controllers/api/legacy/_controller.ex` (routed under `/legacy`) | +| V2 schema modules | `schemas/api/v2/.ex` and `schemas/api/v2//*.ex` | +| V2 chain-type schema subdirs | `schemas/api/v2//*.ex` (e.g. `schemas/api/v2/{arbitrum,beacon,celo,optimism,scroll,zilliqa,mud}/*.ex`) | +| V2 proxy schemas | `schemas/api/v2/proxy/*.ex` | +| Account schemas (Private spec) | `schemas/api/v2/account/*.ex` | +| Legacy schemas | `schemas/api/legacy/*.ex` | +| Parameter helpers | `schemas/api/v2/general.ex` (all helpers centralized here) | +| Error responses | `schemas/api/v2/error_responses.ex` | +| Schema helper | `schemas/helper.ex` (`extend_schema/2`) | +| Leaf type schemas | `schemas/api/v2/general/*.ex` (AddressHash, FullHash, IntegerString, etc.) | +| API router | `routers/api_router.ex` | +| V2 sub-routers forwarded from the API router | `routers/tokens_api_v2_router.ex`, `routers/smart_contracts_api_v2_router.ex`, `routers/api_key_v2_router.ex`, `routers/utils_api_v2_router.ex`, `routers/address_badges_v2_router.ex` | +| Account router (Private spec) | `routers/account_router.ex` | +| Views (flat v2) | `views/api/v2/_view.ex` | +| Legacy views | `views/api/legacy/_view.ex` | +| Paging helper | `paging_helper.ex` (`delete_parameters_from_next_page_params/1`) | +| Spec aggregators | `specs/public.ex` (public spec + **tag registry**), `specs/private.ex` (account/private spec) | +| Global aliases/imports | The file `block_scout_web.ex` — look for `:controller` quote block | +| V2 tests | `../../test/block_scout_web/controllers/api/v2/_controller_test.exs` | +| Legacy tests | `../../test/block_scout_web/controllers/api/legacy/_controller_test.exs` | + +**On router coverage of the public spec:** `specs/public.ex` builds `paths` via `Paths.from_router(ApiRouter)`, which picks up everything reachable from `api_router.ex` — including endpoints declared in the sub-routers that `api_router.ex` `forward`s to (api-key, utils, address-badges). Only `TokensApiV2Router` and `SmartContractsApiV2Router` need the extra `Paths.from_routes(...)` merges in `public.ex` because their prefixes are stripped by Phoenix `forward` and must be re-added. A new annotated endpoint placed in any of the other sub-routers needs no extra wiring beyond the `forward` that already exists in `api_router.ex`. + +## Core patterns + +### The operation macro + +Every annotated controller action has an `operation/2` call (from `OpenApiSpex.ControllerSpecs`): + +```elixir +operation :action_name, + summary: "Short summary for the endpoint", + description: "Longer description of what it does.", + parameters: [some_path_param() | base_params()], + responses: [ + ok: {"Success description", "application/json", Schemas.SomeDomain.Response}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] +``` + +For POST/PUT/PATCH endpoints, add `request_body:` — see `references/request-body-security-headers.md`. + +### The three-way parameter coupling + +Path parameters must be consistent across three locations or the endpoint breaks: + +| Location | Form | Example | +|---|---|---| +| Phoenix route segment | String with `:` prefix | `get("/:transaction_hash_param", ...)` | +| `%Parameter{}` struct | Atom in `:name` field | `%Parameter{name: :transaction_hash_param, in: :path}` | +| Controller action head | Atom key in pattern match | `def transaction(conn, %{transaction_hash_param: value})` | + +`CastAndValidate` reads string keys from `conn.path_params`, converts them to atoms using the Parameter `:name`, and places them in `conn.params`. The controller then pattern-matches on those atoms. + +### Response schema ↔ view correlation + +There is no runtime validation that view output matches the response schema. Alignment is enforced **only at test time**: every `json_response/2` call in a `ConnCase` test automatically validates the response body against the OpenAPI spec. This means: +- Schemas with `additionalProperties: false` catch extra keys the view emits +- The `required` list catches missing keys +- Type/pattern checks catch type mismatches + +If a view emits a key not in the schema (or vice versa), tests will fail. + +### CastAndValidate's effect on params (string keys → atom keys) + +When `CastAndValidate` processes an action with a real `operation` spec (not `operation :action, false`), it transforms **all** params before the action runs: +- **String keys become atom keys:** `%{"id" => "42"}` → `%{id: 42}` +- **Values are cast to declared types:** strings become integers, booleans, etc., based on the parameter's `%Schema{type: ...}` + +Actions declared with `operation :action, false` are **skipped** — they receive the original string-keyed params from Phoenix unchanged. + +This matters most for **pagination**. The `paging_options/1` function in `chain.ex` has parallel clauses for both forms: +- String-key clauses (e.g., `%{"id" => id_string} when is_binary(id_string)`) — used by actions without a spec +- Atom-key clauses (e.g., `%{id: id}`) — used by actions with a real spec + +When promoting an action from `operation :action, false` to a real spec, the string-key `paging_options` clause will stop matching. You must ensure a corresponding atom-key clause exists. See Workflow A, Step 4b for details. + +### base_params() — always include + +`base_params()` returns `[api_key_param(), key_param()]` — two optional query parameters (`apikey`, `key`) present on every public API operation. Always include it. + +Common composition patterns: +```elixir +# Simple — no extra params +parameters: base_params() + +# With a path param (prepend via cons) +parameters: [address_hash_param() | base_params()] + +# With paging params (append via ++) +parameters: base_params() ++ define_paging_params(["index", "block_number"]) + +# Combined +parameters: [transaction_hash_param() | base_params()] ++ [token_type_param()] ++ define_paging_params(["index", "block_number"]) +``` + +### Controller prerequisites + +Every annotated controller needs: +```elixir +use OpenApiSpex.ControllerSpecs # injects operation/2, tags/1 +plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) # validates incoming params +tags(["domain-tag"]) # groups operations in spec +``` + +These are typically near the top of the controller module, after `use BlockScoutWeb, :controller`. + +**Tag naming: kebab-case.** Tag strings use kebab-case (`"internal-transactions"`, `"main-page"`, `"smart-contracts"`, `"token-transfers"`, `"account-abstraction"`), not snake_case. Multi-word controller module names such as `InternalTransactionController` still map to the kebab-case plural tag, not to the module name. + +### Tag registry (`specs/public.ex`) + +The order of tag groups in the generated public spec is not derived from the controllers — it's declared explicitly in `specs/public.ex` and has a fixed three-part shape: + +1. **Base tags** — the `@default_api_categories` list at the top of `specs/public.ex`, always present regardless of chain type. +2. **Chain-type-specific tags** — returned by `chain_type_category/0`, whose clauses are keyed on `@chain_identity` (`{:optimism, :celo}`, `{:optimism, nil}`, `{:scroll, nil}`, `{:zilliqa, nil}`, …). For chains without OpenAPI coverage this is an empty list. +3. **`"legacy"`** — hard-coded trailer pinned last. + +If a new annotated controller introduces a brand-new tag, the agent must register it in the right group, or the tag will still appear in the spec (via controller-side `tags(...)`) but with no ordering guarantee and no entry in the top-level `tags:` list: + +- Base endpoint → append the kebab-case tag to `@default_api_categories`. +- Chain-type endpoint → add it inside the relevant `case @chain_identity` branch, matching the existing patterns (module-attribute + `defp` for static lists, full `defp` body when the tag set depends on a runtime flag such as `mud_enabled?()`). +- Legacy endpoint → no action; `"legacy"` is already the trailer. + +Tags that are already covered by an existing group (e.g. another `addresses` endpoint) need no change. + +--- + +## Verification + +After creating or modifying a declaration, verify it using these methods in order. Each catches a different class of issues, and earlier steps are faster — so run them first to get quick feedback before committing to a full test run. + +### 1. Compile (`mix compile`) + +Compiling the `block_scout_web` app verifies structural validity: schema modules exist, operation names match controller action function names, and all referenced modules resolve. This is the fastest check and catches typos, missing modules, and wiring errors. + +Run via devcontainer if mix is not available on the host. + +### 2. Generate the spec (`generate-spec.sh`) + +This exercises `OpenApiSpex.resolve_schema_modules/1`, which resolves all schema module references and inlines them into the full spec. It catches issues that compilation alone misses: circular references, malformed schema structures, and resolution failures. + +```bash +.claude/skills/openapi-spec/scripts/generate-spec.sh +``` + +See `references/spec-generation-and-verification.md` for script options (chain-specific generation, custom output path) and `oastools` commands for inspecting the result. + +### 2a. Audit for spec-wide convention drift (optional) + +After regeneration, sweep the spec for convention violations a single-endpoint test run won't catch: missing `additionalProperties: false`, missing `:unprocessable_entity`, tag casing, etc. See `references/oastools-audit-recipes.md` — the quick sweep is recipes A, B, F, I. + +The generated spec is cache-like, so regeneration must come first. A stale `.ai/tmp/openapi_public.yaml` produces false positives for every recipe that counts violations — e.g., it may report tag-casing hits that no longer exist in the codebase. + +### 2b. Tag audit (run after creating, moving, or retagging operations) + +`mix test` does not check tags. Run Recipe O (registry coverage — Step 4e) and Recipe P (URL prefix vs operation tag — Step 4d) from `references/oastools-audit-recipes.md`. + +### 3. Run controller tests (`mix test`) + +Run the specific controller test file. Every `json_response/2` call automatically validates the response body against the OpenAPI schema. This catches response-level issues: extra keys (via `additionalProperties: false`), missing required keys, and type mismatches. + +```bash +mix test apps/block_scout_web/test/block_scout_web/controllers/api/v2/_controller_test.exs +``` + +If tests fail with schema validation errors, the view output doesn't match the declared schema — fix the discrepancy. + +### 4. Code cross-referencing (for Inspect & Fix workflow) + +Manually or via grep, compare the controller's consumed parameters against declared parameters, and the view's output keys against schema properties. This catches logical issues that tests might miss (e.g., an undeclared optional parameter that works at runtime but isn't documented, or a schema property that's declared but never emitted by the view). + +See `references/inspection-checklist.md` for the systematic approach. + +--- + +## Workflow A: Create a new declaration + +Use this when an endpoint exists (route + controller action + view) but has no `operation/2` annotation. + +### Step 1: Gather context + +Read these files in parallel to understand the endpoint: + +1. **Router** — find the route definition. Note the HTTP method, path segments (especially `:param_name` segments), and which controller/action it maps to. +2. **Controller** — read the action function. Note what keys it destructures from `params` and `conn.body_params`, what data it fetches, and what view template it renders. +3. **View** — read the render function and any `prepare_*` helper it calls. Note every key in the output map — these become schema properties. Trace **all code paths**, not just the default: look for `case`/`cond`/pattern-match branches in the render function and its helpers that produce different map shapes depending on a field value. When found, note the discriminator field and the distinct set of keys each branch emits — these indicate a polymorphic sub-object that needs special handling in Step 3. +4. **Existing schemas** — glob `schemas/api/v2/*` to see if schema modules already exist for this domain. +5. **Peer precedent (optional)** — `oastools walk operations -tag -q .ai/tmp/openapi_public.yaml` lists sibling endpoints already in the spec. Useful before choosing between schema reuse and new schemas in Step 3. + +### Step 2: Find or create parameter definitions + +For each parameter the controller reads: + +1. **Check if a helper already exists.** Grep `general.ex` for a function matching the parameter name: + ``` + # For a path param named :address_hash_param + grep "def address_hash_param" in general.ex + ``` + Read `references/parameter-discovery.md` for naming conventions and discovery patterns. + +2. **If no helper exists**, decide: + - **Reusable across controllers?** Add a new helper function to `general.ex` following the naming conventions in `references/parameter-discovery.md`. + - **Domain-specific but used by multiple operations in the same controller?** Add a private helper function in the controller itself. This avoids polluting `general.ex` with chain-specific concerns while preventing duplication across operations. + - **Truly one-off (single operation)?** Define an inline `%OpenApiSpex.Parameter{}` struct directly in the `operation` macro arguments. + +3. **For pagination parameters**, use `define_paging_params(field_names)` — pass the cursor field names as strings, and always include `"items_count"` (the `next_page_params` helper adds it to every cursor automatically). See `references/parameter-discovery.md` section "The `define_paging_params` factory" for details. + +### Step 3: Create or locate response schema + +1. **Check if a schema module exists** for the response entity. Glob `schemas/api/v2/*.ex`. +2. **If schemas exist in the same domain**, compare their properties against the new view's output keys to detect subset/superset relationships (recipe N in `references/oastools-audit-recipes.md` gives a mechanical candidate list across all component schemas): + - **Existing schema is a subset** of what the new endpoint needs — use `extend_schema` from the existing schema, adding only the extra properties. See `references/schema-conventions.md` section "Schema reuse and naming for related schemas" for the naming convention and required `title:` parameter. + - **Existing schema is a superset** — the new endpoint may reference the existing schema directly (if it needs all the properties), or may need a reduced "minimal" schema that the existing one extends. + - **Before reusing, check `oneOf`/`anyOf` reachability.** If the candidate schema (or any of its nested properties) contains a `oneOf` or `anyOf`, trace each variant back through the controller action's code path to the view's render function. Identify which discriminator values the controller can actually produce for this endpoint. If all variants are reachable, reuse the schema directly. If only a subset is reachable, create a narrowed schema via `extend_schema`, overriding only the polymorphic property with a `oneOf` containing just the reachable variants. `extend_schema` merges properties and overwrites existing keys, so passing the narrowed property replaces the parent's full variant list (see `references/schema-conventions.md` section "Helper.extend_schema/2"). Example: if `Batch` has a `data_availability` with 4 `oneOf` variants but endpoint `batch_by_celestia_da_info` can only produce the Celestia variant, create a schema that extends `Batch` and overrides `data_availability` to contain only that variant. + - **No meaningful overlap** — create a standalone schema. +3. **If no suitable schema exists**, create one following the conventions in `references/schema-conventions.md`. The schema's properties must match the view's output keys exactly. +4. **Deduplicate against existing domain schemas.** Before finalizing properties, compare each inline `%Schema{type: :object}` block and each `%Schema{type: :string, enum: [...]}` definition in the new schema against properties in the existing schemas found in step 1. If an identical structure already exists in another schema in the same domain directory, extract it into a shared leaf schema module and reference it from both schemas. This avoids drift when the structure changes and consolidates Ecto.Enum sync comments to one location. See `references/schema-conventions.md` section "Domain-scoped shared schemas" for templates. Recipe D in `references/oastools-audit-recipes.md` enumerates every inline enum across the spec — group by `.enum` to find duplicates mechanically. +5. **Model polymorphic sub-objects.** If Step 1 identified a property whose structure varies based on a discriminator field (e.g., a `data_availability` object that changes shape depending on `batch_data_container`), a single flat `%Schema{type: :object}` with only the common fields will be incomplete — the variant-specific fields won't be documented or validated. Use `oneOf` to declare each variant. See `references/schema-conventions.md` section "Polymorphic properties (`oneOf`)" for the structural pattern, the per-variant discriminator-constraint rule, and a concrete template. For existing precedent, see `transaction.ex` (`revert_reason` property). +6. **Determine precise types from the Ecto schema.** The view layer is lossy — it renders everything as JSON primitives. Read the entity's Ecto schema (under `apps/explorer/lib/explorer/chain/`) to recover the real constraints: `Ecto.Enum` values, nullability, and integer-vs-string representation for large numbers. See `references/schema-conventions.md` §"Determining property types from Ecto schemas" for the full Ecto-to-OpenAPI mapping, the mandatory enum sync-comment format, and the OpenAPI-3.0 nullability rule (`nullable: true`, never `type: :null`). +7. **Set `additionalProperties: false`** on object schemas — this is a project-wide convention that enables test-time enforcement. + - **For non-negative integer properties** (block numbers, batch numbers, counts, indices, nonces), set `minimum: 0` to enforce the domain constraint at the validation level. +8. **Set `required:`** to list all keys that the view always emits. +9. For paginated list endpoints, use `General.paginated_response/1` to wrap the item schema. +10. **Review properties for description adequacy.** After defining types and constraints, do a final pass over all properties. For each property without a `description:`, ask: "Would an API consumer unfamiliar with this chain's internals understand this from the name alone?" Add descriptions to properties that are ambiguous, use domain jargon, mirror Solidity field names, or where the chain context (Parent chain vs Rollup) is unclear. Tautological descriptions that restate the property name don't count — rewrite or remove them. See `references/schema-conventions.md` section "Property descriptions" for guidelines and examples. + +### Step 4a: Write the operation annotation + +Add the `operation/2` call above the controller action. Follow the structure in "The operation macro" section above. Make sure: +- `summary:` is a short imperative sentence +- `description:` adds useful detail beyond the summary +- `parameters:` includes `base_params()` and all path/query params +- `responses:` covers the success case and all error cases the action can return. If multiple controller branches share the same status code with different error messages, use a custom description tuple instead of the generic `Module.response()` helper — see `references/error-response-patterns.md` section "Multiple error branches sharing one status code". + +If the controller lacks the `use OpenApiSpex.ControllerSpecs` line and `CastAndValidate` plug, add them (see "Controller prerequisites"). If the `tags([...])` string is brand-new (not already present in `@default_api_categories` or any `chain_type_category_tags/0` clause in `specs/public.ex`), proceed through Step 4e before verification — without registration the tag still renders per-operation but has no ordering guarantee. + +### Step 4b: Update paging_options if the endpoint is paginated + +If the action calls `paging_options(params)` (directly or via helpers like `next_page_params`), the string-key clauses in `chain.ex` will no longer match because `CastAndValidate` has already converted params to atom keys with cast types. + +Check `chain.ex` for the relevant `paging_options` clause. If only a string-key clause exists (e.g., `%{"id" => id_string}` with `Integer.parse`): +- **Add** a matching atom-key clause (e.g., `%{id: id}`) if the string-key clause is still used by other actions without specs +- **Replace** the string-key clause with an atom-key one if all callers now go through `CastAndValidate` + +The atom-key clause is typically simpler because `CastAndValidate` already handles type casting — no `Integer.parse` or similar parsing needed. + +This step is especially important when **promoting** an action from `operation :action, false` to a real spec — that is the moment where `paging_options` stops receiving string keys and the mismatch occurs. + +### Step 4c: Ensure path params are excluded from `next_page_params` + +If the endpoint is paginated **and** has path parameters, those path params will leak into the pagination cursor response unless explicitly stripped. + +**Why this happens:** CastAndValidate converts all params (path + query) to atom keys in a single map. The `next_page_params/5` function receives this map and builds the cursor for the response. It calls `delete_parameters_from_next_page_params/1` (in `paging_helper.ex`) to strip known non-pagination params, but only params listed in its `Map.drop` list are removed. If a path param isn't listed, it appears in the JSON response's `next_page_params`. When the client sends that cursor back as query params on the next request, CastAndValidate rejects the path param as "Unexpected field" because it's declared as `:path`, not `:query`. + +**What to do:** For each path parameter declared in the operation: +1. Read `delete_parameters_from_next_page_params/1` in `apps/block_scout_web/lib/block_scout_web/paging_helper.ex`. +2. Check whether the atom-key form (e.g., `:direction`) is in the `Map.drop` list. +3. If missing, add it among the other atom-key entries at the top of the list. + +The existing list already includes common path params like `:address_hash_param`, `:batch_number_param`, `:block_hash_or_number_param`, `:transaction_hash_param`. New path params need to be added as they are introduced. + +### Step 4d: Pick the right tag(s) when URL prefix and controller domain disagree + +If the operation's URL lives under a cross-cutting prefix that is itself a tag (e.g., `/v2/main-page/...`), add `tags: [""]` per-operation. OpenApiSpex appends to module-level `tags(...)`, so the operation will appear under both groups (dual-tagging — the default). For exclusive relocation, see `references/schema-conventions.md` §"Cross-cutting URL prefixes and tags". + +### Step 4e: Register a new tag in the registry + +If the controller's `tags([...])` declares a tag not already in `@default_api_categories` or any `chain_type_category_tags/0` clause in `specs/public.ex`, add it. Base tag → `@default_api_categories`; chain-type tag → matching `case @chain_identity` branch. See "Tag registry" in Core patterns for branch shapes. + +### Step 5: Ensure test coverage + +Tests are the primary mechanism that validates the response schema matches the actual view output. Without tests hitting the endpoint, the schema is unverified documentation that may be wrong. + +1. **Enumerate all status codes the controller action returns.** Read the controller action and list every distinct HTTP status code it can produce. Look for: + - `put_status` calls (e.g., `put_status(:bad_request)`, `put_status(200)`) + - Pattern-match branches that render different error responses + - `send_resp` calls with explicit status codes + - The implicit 200 from the success path (`render` without `put_status`) + + Cross-reference this list against the `responses:` declared in the operation. Every status code declared in the operation should have at least one test. If multiple branches return the same status code with different conditions, note each branch separately — ideally each gets its own test case so the conditions are documented. The spec-side half of this cross-check is one command: `oastools walk responses -path -method -q .ai/tmp/openapi_public.yaml`. + + Some branches depend on external systems (RPC calls, microservice responses) and cannot be reached with pure DB setup. Decide how to handle each one: + + - **Mock when the branch produces a distinct response shape** — a different `oneOf` variant, a different set of required keys, or an enum value not exercised by other tests. These are exactly the cases where `additionalProperties: false` and type constraints silently rot without coverage. (Example: the Arbitrum withdrawal token sub-object and `:confirmed`/`:sent` status paths are only reachable through L1 RPC mocking, and testing them exposed a real OpenApiSpex schema-title collision bug that would have shipped otherwise.) + - **Document and skip when the mocking cost is disproportionate** — e.g., a branch requires orchestrating multiple cross-chain RPC fallback steps. Add a code comment explaining what the branch does and why it's not covered (e.g., `# :unknown status — requires Outbox.isSpent=false AND get_size_for_proof/0 returning nil (multi-step L1/L2 RPC fallback), not covered here`). + + **How to mock RPC dependencies when it's worth it.** The established pattern uses `:meck` to intercept `Indexer.Helper.json_rpc_named_arguments/1` so it returns a Mox-backed transport, then `Mox.expect` stubs specific contract calls with ABI-encoded responses. See `arbitrum_controller_test.exs` helpers (`setup_arbitrum_l1_rpc_mocks!`, `expect_inbox_outbox_query!`, `expect_erc20_metadata!`, etc.) for a working reference. When building mock fixtures for chain-specific RPC calls, the Blockscout MCP server can discover real on-chain data (event logs, calldata, contract return values) to verify that fixtures match production structure — it is a discovery aid, not a source of truth; the ABI spec and contract source are authoritative. + +2. **Check if tests already exist.** Look for the test file at `apps/block_scout_web/test/block_scout_web/controllers/api/v2/_controller_test.exs`. Grep for the endpoint path or action name within the file. If tests already hit the endpoint and call `json_response/2`, they will automatically validate the schema — proceed to Step 6. + +3. **If no tests exist**, create them. For minimal test templates covering list / single-resource / 404 / 422 cases, see `references/inspection-checklist.md` section "Minimal test templates". Every `json_response/2` call triggers schema validation automatically. Cover every status code enumerated in item 1 — if the controller returns codes beyond the templates (e.g., 400 from business-logic checks), add tests for those too, setting up the DB state that triggers each branch. + +4. **If the schema contains `oneOf` polymorphic sub-objects** (from Step 3 item 5), write at least one test per variant so each branch's `additionalProperties: false` constraint is exercised. The default factory typically produces only the simplest variant, so other variants need explicit setup — insert the factory with the discriminator value set, plus any associated records the view fetches. If a variant is only reachable through an external dependency (RPC, microservice), see item 1 above. + +### Step 6: Verify + +Run the verification ladder from the "Verification" section above (compile → generate-spec → tests). If tests fail with schema validation errors, the view output doesn't match the declared schema — fix the discrepancy. + +--- + +## Workflow B: Adjust an existing declaration + +Use this when an endpoint's parameters or response have changed and the OpenAPI spec needs to catch up. + +### Step 1: Identify the change + +Read the controller action and view to understand what changed. Common scenarios: +- **New parameter added** — controller now reads a new key from params +- **Parameter removed** — controller no longer uses a parameter +- **New response field** — view now emits an additional key +- **Response field removed** — view no longer emits a key +- **Type changed** — a field's type or format changed + +### Step 2: Update the declaration + +- **Parameters**: Add/remove from the `parameters:` list in the `operation` macro. If adding a new reusable param, add a helper to `general.ex`. +- **Response fields**: Update the schema module's `properties:` map and `required:` list. If adding a field, add it to both. If removing, remove from both. +- **Type changes**: Update the property's schema type in the schema module. + +### Step 3: Verify + +Run the verification ladder from the "Verification" section above (compile → generate-spec → tests). If parameters changed, also revisit Workflow A Step 4b/4c — atom-key `paging_options` clauses and `next_page_params` path-param stripping apply equally when adjusting. + +--- + +## Workflow C: Inspect & fix an existing declaration + +Use this to audit an existing declaration for correctness, completeness, and adherence to project conventions. Read `references/inspection-checklist.md` and work through it end to end — it owns the full cross-reference procedure (parameters, response fields, conventions, schema organization) and ends with the verification ladder. + +--- + +## When to read reference files + +| Reference | Read when... | +|---|---| +| `references/parameter-discovery.md` | You need to find existing parameter helpers, create new ones, or understand naming/categorization conventions | +| `references/schema-conventions.md` | You need to create new schema modules, understand directory layout, work with chain-type customizations, or model polymorphic properties with `oneOf` | +| `references/error-response-patterns.md` | You need to declare error responses or understand which error module to use for a status code | +| `references/request-body-security-headers.md` | You're working with POST/PUT/PATCH endpoints, authentication/security, or HTTP header declarations | +| `references/inspection-checklist.md` | You're running an audit of an existing declaration (Workflow C) | +| `references/spec-generation-and-verification.md` | You need to generate the spec YAML, validate it, or inspect specific operations/schemas with oastools | +| `references/oastools-audit-recipes.md` | You want to audit the generated spec for spec-wide convention drift or reuse candidates, without reading every source file | + +## Using subagents + +For the Create workflow, parallelize the initial context gathering (Step 1) by spawning subagents to read the router, controller, view, and existing schemas simultaneously. + +When running tests after changes, use the devcontainer skill if mix/elixir is not available on the host. diff --git a/.agents/skills/openapi-spec/references/error-response-patterns.md b/.agents/skills/openapi-spec/references/error-response-patterns.md new file mode 100644 index 000000000000..8a86ff4dcebf --- /dev/null +++ b/.agents/skills/openapi-spec/references/error-response-patterns.md @@ -0,0 +1,132 @@ +# Error Response Patterns + +## Discovery + +All custom error response modules are defined in a single file: +`apps/block_scout_web/lib/block_scout_web/schemas/api/v2/error_responses.ex` + +Plus one from the `open_api_spex` library itself: `OpenApiSpex.JsonErrorResponse`. + +To discover the current set, read `error_responses.ex` and look for `defmodule` declarations. Each module defines a `response/0` helper. + +## The response/0 helper pattern + +All custom error modules follow this pattern: + +```elixir +defmodule NotFoundResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "NotFoundResponse", + type: :object, + properties: %{message: %Schema{type: :string, example: "Resource not found"}} + }) + + def response, do: {"Not Found", "application/json", __MODULE__} +end +``` + +The `response/0` function returns a 3-tuple `{description, content_type, module}` used in operation specs: + +```elixir +responses: [ + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() +] +``` + +`JsonErrorResponse` (from `open_api_spex`) returns a `%Response{}` struct instead of a tuple. Both forms work in the `operation` macro. + +## Status code mapping + +The response key in the `responses:` keyword list determines the HTTP status code. Use the atom form: + +| Atom key | HTTP status | Typical module | +|---|---|---| +| `:ok` | 200 | (success — use domain schema) | +| `:bad_request` | 400 | `BadRequestResponse` | +| `:unauthorized` | 401 | `UnauthorizedResponse` | +| `:forbidden` | 403 | `ForbiddenResponse` | +| `:not_found` | 404 | `NotFoundResponse` | +| `:unprocessable_entity` | 422 | `JsonErrorResponse` | +| `:not_implemented` | 501 | `NotImplementedResponse` | + +To verify the current mapping, read `error_responses.ex` and check the module names and their example messages. + +## Auto-aliased modules + +Two modules are automatically available in every controller (aliased in `block_scout_web.ex`): + +```elixir +alias OpenApiSpex.JsonErrorResponse +alias Schemas.ErrorResponses.ForbiddenResponse +``` + +All others must be explicitly aliased in the controller: +```elixir +alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse +``` + +To check which are auto-aliased, read the `:controller` quote block in `block_scout_web.ex`. + +## When to use which error response + +- **`JsonErrorResponse` (422)** — the default for validation errors. `CastAndValidate` returns this automatically when parameter validation fails. Also use when the controller explicitly rejects input as invalid. +- **`NotFoundResponse` (404)** — when the requested resource doesn't exist. +- **`ForbiddenResponse` (403)** — when the request is authenticated but not authorized, or when a required server-side config (like an API key) is missing. +- **`UnauthorizedResponse` (401)** — when authentication is required but missing/invalid. Primarily used in account/private API endpoints. +- **`BadRequestResponse` (400)** — when the request is malformed in a way that isn't a parameter validation error. +- **`NotImplementedResponse` (501)** — when the endpoint exists but the feature is not available. + +## Choosing error responses for an operation + +Look at the controller action to identify which error paths exist: + +1. **Every operation** should include `:unprocessable_entity: JsonErrorResponse.response()` — CastAndValidate can always fail. Spec-wide audit of operations currently missing 422: recipe B in `references/oastools-audit-recipes.md`. +2. If the action does a resource lookup (e.g., `Chain.hash_to_transaction`), include `:not_found`. +3. If the action checks authorization (e.g., `AccessHelper.restricted_access?`), include `:forbidden`. +4. If the action requires authentication, include `:unauthorized`. +5. Check `put_status` and `send_resp` calls in the action for other status codes. If multiple branches return the same status code with different error messages, see "Multiple error branches sharing one status code" below for how to write a descriptive response instead of using the generic helper. + +Not all runtime error paths need to be in the spec — undeclared status codes (like `:internal_server_error` from rate limiting) are typically treated as infrastructure concerns. But all explicitly handled error cases in the controller action should be declared. + +## Multiple error branches sharing one status code + +Sometimes a controller action has several branches that all return the same HTTP status code but with different error messages. For example, three separate `put_status(:bad_request)` calls returning "withdrawal is unconfirmed yet", "withdrawal is just initiated", and "withdrawal was executed already". Using `BadRequestResponse.response()` produces a generic "Bad Request" description that gives API consumers no hint about what triggers each error. + +When this happens, replace the generic `Module.response()` helper with a custom `{description, content_type, module}` tuple where the description documents the possible error conditions: + +```elixir +responses: [ + ok: {"Success description", "application/json", Schemas.SomeDomain.Response}, + bad_request: + {"Withdrawal cannot be claimed. Returned when the withdrawal is unconfirmed, just initiated, or already executed.", + "application/json", BadRequestResponse}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() +] +``` + +This works because `response/0` just returns the same kind of 3-tuple. By writing the tuple directly, you can customize the description while keeping the same response schema module. + +**When to use this pattern:** +- The controller has 2+ branches returning the same status code with different user-facing messages +- The conditions are meaningful to API consumers (not internal implementation details) + +**When NOT to use it:** +- The status code has only one triggering condition — use the standard `Module.response()` helper +- The different messages are minor variants of the same condition — the generic description is fine + +## Custom inline error responses + +For one-off error schemas (e.g., a specific error format for a single endpoint), you can use an inline tuple: + +```elixir +responses: [ + internal_server_error: {"Error message", "application/json", message_response_schema()} +] +``` + +Where `message_response_schema()` is a helper that returns an inline schema. Check the controller's existing patterns to see if this is used. diff --git a/.agents/skills/openapi-spec/references/inspection-checklist.md b/.agents/skills/openapi-spec/references/inspection-checklist.md new file mode 100644 index 000000000000..ed39b7183d1c --- /dev/null +++ b/.agents/skills/openapi-spec/references/inspection-checklist.md @@ -0,0 +1,260 @@ +# Inspection Checklist + +Use this checklist to systematically audit an existing OpenAPI declaration. Work through each section, reading the relevant files as needed. + +## 1. Parameter completeness + +### 1a. Every parameter the controller reads should be declared + +Read the controller action function. Identify every key it reads from: +- `params` pattern match in the function head (path + query params) +- `conn.body_params` (request body params) +- `conn.req_headers` (header params) +- Calls to helper functions that read from params (e.g., `paging_options(params)`, `token_transfers_types_options(params)`, `fetch_scam_token_toggle(conn)`) + +Cross-reference with the `parameters:` list in the `operation` macro. Every parameter that affects the endpoint's behavior should be declared. + +Spec-side enumeration in one command: `oastools walk parameters -path -method -q .ai/tmp/openapi_public.yaml`. Controller side still needs to be read for the comparison. + +**Known exceptions**: cross-cutting headers (`show-scam-tokens`, recaptcha headers) are conventionally undeclared. See `references/request-body-security-headers.md` for the gap details and recommended approach. + +### 1b. Every declared parameter should be used + +Check that each parameter in the `parameters:` list is actually consumed by the controller. Unused declared parameters create misleading API documentation. + +### 1c. Three-way coupling is consistent + +For each **path** parameter, verify: +1. The route segment name (`:param_name` in router) matches the `%Parameter{name: :param_name}` atom +2. The controller action's pattern match uses the same atom key +3. All three agree exactly + +Read the route definition in the router file, the `%Parameter{}` definition, and the controller action head. + +Spec side: `oastools walk parameters -in path -path -method -detail -format json .ai/tmp/openapi_public.yaml | jq '.parameter.name'`. + +### 1d. Parameter types are accurate + +For each parameter: +- Path params that are hashes should use `FullHash` or `AddressHash` schema +- Path params that are numbers should use `IntegerString` or `%Schema{type: :integer}` +- Enum params should have the correct `enum:` values (check the controller logic for valid values) +- Boolean params should use `%Schema{type: :boolean}` + +### 1e. No duplicated inline parameters + +For each inline `%Parameter{}` struct in the operation, scan the other operations in the same controller for identical or near-identical structs (same `name`, `in`, `schema`, and `description`). Duplicated inline parameters are a maintenance risk — changing one without updating the other creates silent inconsistencies. + +Mechanical scan across the whole domain: recipe E in `references/oastools-audit-recipes.md` groups same-name parameters across all endpoints under one path prefix in a single pass. + +If duplication is found, extract the parameter into a reusable helper function: +- **Generic concept** (address hashes, transaction hashes, block numbers — useful across multiple controllers): add a helper to `general.ex` following the conventions in `references/parameter-discovery.md`. +- **Domain-specific concept** (e.g., an Arbitrum message direction — only meaningful within one controller): add a private helper function in the same controller. This keeps the chain-specific concern contained without polluting the shared `general.ex`. + +## 2. Response field alignment + +### 2a. Schema properties match view output + +Read the view's render function and any `prepare_*` helper it calls. List every key in the output map. + +Read the response schema module. List every key in `properties:`. + +Compare: +- **Extra schema properties** (in schema but not in view): if in `required:`, this will cause test failures. If optional, it's technically valid but misleading. +- **Extra view keys** (in view but not in schema): if schema has `additionalProperties: false`, this will cause test failures. Otherwise it's undocumented output. +- **Type mismatches**: verify that each view output value matches its schema type (string, integer, object, array, nullable). + +Schema-side key list in one command: `oastools walk schemas -name -detail -format json .ai/tmp/openapi_public.yaml | jq '.[0].schema.properties | keys'`. + +### 2b. Type precision — check Ecto schemas for enums and constraints + +The view layer is lossy about types. A field that renders as a plain string may actually be an `Ecto.Enum` with a fixed set of values. For each string-typed property in the OpenAPI schema: + +1. Find the corresponding Ecto schema module in `apps/explorer/lib/explorer/chain/`. Grep for `Ecto.Enum` in that file. +2. If the field is an `Ecto.Enum`, the OpenAPI property should use `enum: [...]` with the correct values, not just `type: :string`. +3. If the property already uses `enum:`, verify the values are **complete and current** by comparing against the Ecto enum definition. New values may have been added to the Ecto schema without updating the OpenAPI schema — this is a silent breakage where `CastAndValidate` rejects the new value on input. +4. Verify there is a code comment on the enum property pointing to the source Ecto field (e.g., `# Enum values must be kept in sync with Explorer.Chain. : field.`). If missing, add one. +5. Check existing schemas in the same domain for precedent — similar entities often already use enum for comparable fields. + +Also check for other type refinements: large integers serialized as strings should use `IntegerString`, hash fields should use `FullHash`/`AddressHash`, timestamps should use `Timestamp`/`TimestampNullable`. + +See `references/schema-conventions.md` section "Determining property types from Ecto schemas" for the full Ecto-to-OpenAPI type mapping. + +### 2c. Required list is accurate + +Every key the view always emits should be in `required:`. Keys that are conditional or sometimes `nil` should either: +- Not be in `required:` (if the key might be absent) +- Be in `required:` but have `nullable: true` on the schema (if the key is always present but sometimes null) + +**Scope.** "Always emits" and "sometimes `nil`" refer to the render paths reachable via the **endpoints that currently reference this schema** in their `operation/2` `responses:`. Before flagging a `nullable: false` property as wrong: + +1. Enumerate those call sites mechanically: `Grep "Schemas\.\b" apps/block_scout_web/lib/block_scout_web/controllers`. +2. For each call site, check the controller's `necessity_by_association` / explicit `Repo.preload/2` / other data-shaping code to determine whether the value can in fact reach the view as `nil`. +3. Only flag if at least one spec-declared render path can produce `nil`. A hedge of the form "*if* any of those readers skips preloading X" or "the shared schema *may* be over-constrained" is not a finding — it is a note that you haven't finished step 2. Either complete the enumeration and cite a specific `controller.ex:line` where the preload is absent, or drop the concern. +4. Each finding must be actionable on the endpoint being audited. If step 2 shows "this could be wrong for a peer endpoint, but `/v2/X` itself is fine," don't raise it as Major/Minor on the `/v2/X` audit. Drop it, or downgrade to a Nit that names the specific peer endpoints as a suggested follow-up audit. A "Major that requires no action on the audited endpoint" is a contradiction — it either has a concrete action here (Major/Minor) or it doesn't (Nit / separate audit). + +Don't flag based on "a future endpoint might not preload X." If a future endpoint is added without the required preload, that new endpoint's audit owns the fix — it is not the shared schema's job to pre-accommodate code that hasn't been written yet. + +See `references/schema-conventions.md` section "Nullable fields" for the full nullable handling rules, including why `type: :null` / `anyOf: [%Schema{type: :null}, …]` (OpenAPI 3.1) is invalid here. + +### 2d. additionalProperties: false is set + +Check that `additionalProperties: false` is present on all object schemas. This is a project-wide convention that enables test-time enforcement. + +Spec-wide audit: recipe A in `references/oastools-audit-recipes.md` lists every component object schema that violates this. Error-response schemas (`NotFoundResponse`, `ForbiddenResponse`, etc.) intentionally omit it — real domain-schema drift is typically the remainder. + +### 2e. Chain-type fields are aligned + +If the view has chain-type dispatching (check for `chain_type` case statements or `with_chain_type_fields` calls), the schema should also have a `ChainTypeCustomizations` module applying the same fields. Verify both sides handle the same chain types. See `references/schema-conventions.md` section "Chain-type customization pattern" for the dispatch mechanism and where `ChainTypeCustomizations` modules are conventionally placed. + +### 2f. Property descriptions are adequate + +Scan all properties in the schema. For each property that has no `description:` (or a tautological one that restates the name), ask: "Would an API consumer unfamiliar with this chain's internals understand this property from its name alone?" + +Flag properties that fail this test. Common patterns to watch for: + +- **Domain jargon** (`before_acc_hash`, `callvalue`) — needs explanation of what the term means +- **Ambiguous roles** (`caller_address_hash`, `destination_address_hash`) — needs "who" and "on which chain" +- **Unclear chain context** (`block_number` in a cross-chain object) — needs "Parent chain" or "Rollup" +- **Enum values without lifecycle explanation** (`status` with `["initiated", "sent", "confirmed", "relayed"]`) — needs description of what triggers each transition +- **Tautological descriptions** ("Withdrawal status." on `status`) — count as missing; rewrite or remove + +Self-documenting compound names (`origination_transaction_block_number`) and well-known primitives (`token.symbol`) don't need descriptions. + +Mechanical shortlist of properties lacking `description:`: recipe C in `references/oastools-audit-recipes.md`. Human review still needed — tautologies pass this filter. + +See `references/schema-conventions.md` section "Property descriptions" for the full guidelines and before/after examples. + +### 2g. `oneOf`/`anyOf` variant reachability + +For each property in the response schema that uses `oneOf` or `anyOf`, verify that every variant is producible by this endpoint's controller action: + +1. List the variants in the `oneOf`/`anyOf` and identify the discriminator value(s) each covers. +2. Read the controller action. Trace which code paths lead to the view's render function. Identify which discriminator values the controller can pass to the view. +3. Read the view's render function and its helpers. Confirm which variants the view can actually emit for the data the controller provides. +4. Compare: flag any variant whose discriminator value(s) can never be produced by this endpoint. + +**If unreachable variants are found:** The schema overpromises to API consumers. Create a narrowed schema via `extend_schema` that overrides only the polymorphic property, keeping just the reachable variants. See `references/schema-conventions.md` section "Helper.extend_schema/2". + +To find all endpoints whose 200 response currently uses `oneOf` (for precedent), run recipe G in `references/oastools-audit-recipes.md`. + +This check is especially important for endpoints that filter by a specific discriminator value (e.g., a DA-type lookup that always returns one DA variant, but references a shared batch schema containing all DA variants). + +## 3. Convention compliance + +### 3a. Controller prerequisites + +Verify the controller has: +- `use OpenApiSpex.ControllerSpecs` +- `plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true)` +- `tags(["domain-tag"])` — kebab-case (e.g. `"internal-transactions"`, `"smart-contracts"`, `"account-abstraction"`); should match the router scope/resource group +- The tag is registered in `specs/public.ex` — in `@default_api_categories` (base), in the appropriate `case @chain_identity` branch of `chain_type_category/0` (chain-type), or already pinned as `"legacy"`. An un-registered tag still renders per-operation but has no guaranteed ordering in the generated spec. + +Spec-wide tripwire: recipe I in `references/oastools-audit-recipes.md` returns any tag containing `_`. The baseline is `[]` — any hit likely means a controller predates the kebab-case convention. + +### 3b. base_params() is included + +Every public API operation should include `base_params()` in its parameters. Check that `base_params()` is present and isn't accidentally duplicated. + +Spec-wide audit: recipe F in `references/oastools-audit-recipes.md` lists operations missing `apikey`. + +### 3c. Error responses are appropriate + +Check which error cases the controller action handles (not_found, forbidden, etc.) and verify corresponding error responses are declared. See `references/error-response-patterns.md` for the status-code-to-module mapping. + +At minimum, every operation should declare `:unprocessable_entity: JsonErrorResponse.response()` since CastAndValidate can always fail. + +Spec-wide audit: recipe B in `references/oastools-audit-recipes.md` lists operations missing 422. Some are likely-intentional (legacy endpoints, CSV exports) — triage per endpoint. + +### 3d. Summary and description + +- `summary:` should be a short imperative sentence (shown in endpoint lists) +- `description:` should add useful detail beyond the summary +- Both should be present + +### 3e. Operation name matches action + +The first argument to `operation/2` must match the controller action function name: +```elixir +operation :transaction, ... # matches def transaction(conn, params) +``` + +## 4. Schema module organization + +### 4a. Schema is in the right location + +Check that the schema module follows directory conventions: +- Domain schemas under `schemas/api/v2/.ex` or `schemas/api/v2//*.ex` +- Chain-specific schemas under `schemas/api/v2//` +- Leaf types under `schemas/api/v2/general/` + +See `references/schema-conventions.md` for full conventions. + +### 4b. Module naming follows conventions + +`BlockScoutWeb.Schemas.API.V2.` for base schemas, `BlockScoutWeb.Schemas.API.V2..Response` for response wrappers. + +## 5. Verification + +After identifying and fixing issues from sections 1-4, run the verification ladder described in the "Verification" section of `SKILL.md` (compile → generate-spec → controller tests). Each step catches a different class of problems, and earlier steps are faster. + +### 5a. Test coverage check + +Check that tests exist and exercise the endpoint: + +1. **Test file exists**: `test/block_scout_web/controllers/api/v2/_controller_test.exs` +2. **Tests hit the endpoint**: grep for the endpoint path in the test file +3. **All declared status codes are tested**: enumerate every status code in the operation's `responses:` and verify at least one test exercises each. Pay special attention to status codes with multiple triggering conditions (e.g., multiple 400 branches) — each condition ideally has its own test case. Spec-side enumeration: `oastools walk responses -path -method -q .ai/tmp/openapi_public.yaml`. + +### Minimal test templates + +If no tests exist for the endpoint: + +```elixir +# For a list endpoint (empty list, zero factory data) +test "empty list", %{conn: conn} do + request = get(conn, "/api/v2/") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil +end + +# For a single-resource endpoint +test "returns resource", %{conn: conn} do + resource = insert(:) + request = get(conn, "/api/v2//#{resource.id}") + assert _response = json_response(request, 200) +end + +# For a not-found case +test "returns 404", %{conn: conn} do + resource = build(:) # build but don't insert + request = get(conn, "/api/v2//#{resource.id}") + assert %{"message" => "Not found"} = json_response(request, 404) +end + +# For a validation error +test "returns 422 on invalid input", %{conn: conn} do + request = get(conn, "/api/v2//invalid_value") + assert %{"errors" => [_]} = json_response(request, 422) +end +``` + +## 6. Spec-wide sweep + +Independent of any single-endpoint audit, run this sweep once per work session to catch drift introduced elsewhere in the codebase. Regenerate the spec first — stale YAML produces false positives. + +- Recipe A — object schemas missing `additionalProperties: false` +- Recipe B — operations missing 422 +- Recipe F — operations missing `apikey` (base_params) +- Recipe I — tags violating kebab-case + +Full recipes in `references/oastools-audit-recipes.md`. Results belong in the "Convention deviations" section of the audit output below. + +## Audit output + +After completing the checklist, summarize findings as: + +1. **Issues found** — concrete problems that will cause test failures or spec inaccuracies +2. **Convention deviations** — things that work but don't follow project conventions +3. **Improvement opportunities** — optional enhancements (better descriptions, missing examples, undeclared headers) diff --git a/.agents/skills/openapi-spec/references/oastools-audit-recipes.md b/.agents/skills/openapi-spec/references/oastools-audit-recipes.md new file mode 100644 index 000000000000..7c12fd31fca5 --- /dev/null +++ b/.agents/skills/openapi-spec/references/oastools-audit-recipes.md @@ -0,0 +1,238 @@ +# oastools Audit Recipes + +Use these when authoring or auditing a declaration and you need a fact about the spec as a whole (not just one endpoint). For single-endpoint queries, see `references/spec-generation-and-verification.md`. + +## Before running any recipe: regenerate + +The generated spec is cache-like. A stale `.ai/tmp/openapi_public.yaml` produces false positives — e.g., a pre-migration spec may report tag-casing or path-prefix hits that the current codebase has already fixed. + +```bash +.claude/skills/openapi-spec/scripts/generate-spec.sh +``` + +All recipes below assume `F=.ai/tmp/openapi_public.yaml` is set as a shell variable; substitute the full path if not. + +## How each recipe is organized + +Each recipe states: what it answers, the command, a baseline count (a tripwire, not a spec — not every hit is a bug; read the notes), and which skill sections cite it. + +When a recipe's hit count goes down because you fixed drift, update the number here in the same PR. When it goes up, the PR introducing the regression should either fix it or document why in the recipe notes. + +--- + +## Convention audits + +Run these after any schema-touching change to catch drift a single-endpoint test run won't. + +### A. Object schemas missing `additionalProperties: false` + +```bash +oastools walk schemas -component -type object -detail -format json -q $F \ + | jq -rs '[.[] | select(.jsonPath | test("^\\$.components.schemas\\[[^.]+\\]$")) + | select(.schema.additionalProperties != false) | .name]' +``` + +Baseline: 12 hits, of which 6 are error-response schemas (`NotFoundResponse`, `ForbiddenResponse`, `UnauthorizedResponse`, `BadRequestResponse`, `NotImplementedResponse`, `JsonErrorResponse`) that intentionally omit `additionalProperties: false` — error payloads may carry extra debug fields. Real domain-schema drift is ~6 (`AuditReport`, `BlockCountdown`, `Counters`, `Response`, `SearchResult`, `StatsResponse`, `Status`). + +Used by: `inspection-checklist.md` §2d, `schema-conventions.md` §"Composite object schemas". + +### B. Operations missing `:unprocessable_entity` (422) + +```bash +oastools walk operations -detail -format json $F \ + | jq -rs '[.[] | select(.operation.responses | has("422") | not) + | "\(.method) \(.path)"]' +``` + +Baseline: 21 hits. Some are likely-intentional (legacy endpoints without CastAndValidate, CSV exports whose only failure mode is 404). Triage per endpoint. + +Used by: `inspection-checklist.md` §3c, `error-response-patterns.md` §"Choosing error responses for an operation". + +### F. Operations missing `base_params()` (no `apikey` query param) + +```bash +oastools walk operations -detail -format json $F \ + | jq -rs '[.[] | select((.operation.parameters // [] | map(.name) | index("apikey")) == null) + | "\(.method) \(.path)"]' +``` + +Baseline: 1 hit (`GET /v2/transactions/stats`). + +Used by: `inspection-checklist.md` §3b, `SKILL.md` §"base_params() — always include". + +### H. Operations missing summary or description + +```bash +oastools walk operations -detail -format json $F \ + | jq -rs '[.[] | select(.operation.summary == null or .operation.description == null) + | "\(.method) \(.path)"]' +``` + +Baseline: 0 hits. Any hit is a straight fix. + +Used by: `inspection-checklist.md` §3d. + +### I. Tags violating kebab-case + +```bash +oastools walk operations -detail -format json $F \ + | jq -rs '[.[] | .operation.tags[]?] | unique | map(select(contains("_")))' +``` + +Baseline: 0 hits. Any hit likely means a controller predates the kebab-case convention — migrate both the `tags(...)` call in the controller AND the registry entry in `specs/public.ex`. + +Used by: `inspection-checklist.md` §3a, `SKILL.md` §"Controller prerequisites". + +### O. Tags used by operations but missing from the top-level `tags:` registry + +```bash +oastools walk operations -detail -format json $F \ + | jq -rs --slurpfile spec <(yq -o=json '.tags // []' $F) \ + '([.[] | .operation.tags[]?] | unique) as $used + | ($spec[0] | map(.name)) as $declared + | $used - $declared' +``` + +Baseline: 0 hits. Any hit means a controller emits a tag not registered in `specs/public.ex` — Swagger UI will still show the tag on the operation, but ordering is undefined and the top-level `tags:` array is incomplete. Fix by adding the tag to `@default_api_categories` (base) or the appropriate `chain_type_category_tags/0` clause (chain-type), per `SKILL.md` §"Tag registry". + +This recipe is the mechanical safety net for the rule in Workflow A Step 4d — schema validation in tests does not check tags, so without this recipe the registration is silently missed. + +Used by: `SKILL.md` §Workflow A Step 4d, `SKILL.md` §"Tag registry". + +### P. URL prefix vs operation tag mismatch (heuristic) + +For each known cross-cutting URL prefix (`/v2/main-page/`, `/v2/csv-exports/`, etc.), find operations under that prefix whose tag list does not include the corresponding category tag. + +```bash +# main-page check +oastools walk operations -detail -format json $F \ + | jq -rs '[.[] | select(.path | startswith("/v2/main-page/")) + | select((.operation.tags // []) | index("main-page") | not) + | "\(.method) \(.path) tags=\(.operation.tags)"]' +``` + +Baseline: 0 hits expected. Repeat with other prefixes (`/v2/csv-exports/`, etc.) as needed. A hit means an operation under a cross-cutting URL prefix is tagged only by its hosting controller's domain — see `tagging-conventions.md` for whether to dual-tag or relocate. + +This recipe is heuristic — false positives are possible if a `/v2/main-page/...` URL genuinely should not be grouped with the rest of the main-page surface. Read the operation before applying a fix. + +Used by: `SKILL.md` §Workflow A Step 4d, `schema-conventions.md` §"Cross-cutting URL prefixes and tags". + +--- + +## Reuse and dedup scans + +Run before writing new schema code to find consolidation candidates. + +### D. Every inline enum in the spec + +```bash +oastools walk schemas -detail -format json -q $F \ + | jq -rs '[.[] | select(.schema.enum != null) + | {path: .jsonPath, enum: .schema.enum}]' +``` + +Baseline: 71 inline enums. Post-process by grouping on `.enum` to find duplicates worth extracting per `schema-conventions.md` §"Domain-scoped shared schemas". + +Used by: `schema-conventions.md` §"Domain-scoped shared schemas", `schema-conventions.md` §"Required: Ecto.Enum sync comments". + +### E. Duplicate parameter definitions within one domain + +```bash +oastools walk parameters -path '/v2//*' -detail -format json -q $F \ + | jq -rs '[group_by(.parameter.name)[] | select(length > 1) + | {name: .[0].parameter.name, count: length, paths: [.[].path]}]' +``` + +Returns any parameter declared on more than one endpoint under the given domain path. If the definitions are character-identical across endpoints, that's a helper extraction candidate per `inspection-checklist.md` §1e. Most domains return `[]`. + +Used by: `inspection-checklist.md` §1e. + +### G. Operations whose 200 response contains `oneOf` + +```bash +oastools walk operations -detail -resolve-refs -format json $F \ + | jq -rs '[.[] | select(.operation.responses."200".content."application/json".schema + | tostring | contains("oneOf")) + | "\(.method) \(.path)"]' +``` + +Baseline: 7 hits — all transaction-list endpoints. Use as precedent when modeling polymorphic responses. + +Used by: `schema-conventions.md` §"Polymorphic properties (`oneOf`)", `inspection-checklist.md` §2g. + +### N. Schema subset/superset scan + +```bash +oastools walk schemas -component -detail -format json -q $F \ + | jq -rs '[.[] | select(.jsonPath | test("^\\$.components.schemas\\[[^.]+\\]$")) + | {name, props: (.schema.properties // {} | keys)}] + | map(select(.props | length > 0))' +``` + +Emits `{name, props}` tuples for every component schema. Post-process with jq (set difference on `.props`) to find pairs where `A.props ⊆ B.props` — candidates for `extend_schema` reuse per `schema-conventions.md` §"Schema reuse and naming for related schemas". + +Used by: `SKILL.md` §Workflow A Step 3.2, `schema-conventions.md` §"Schema reuse and naming for related schemas". + +--- + +## Precedent / discovery lookups + +Run during authoring to find peer examples before writing new code. + +### J. Endpoints consuming a specific parameter helper + +```bash +oastools walk parameters -name -q $F +``` + +Authoritative about which endpoints *declare* the parameter in the spec — doesn't see controller-private helpers or inline params that happen to share a name. + +Used by: `parameter-discovery.md` §"Find which controllers use a helper". + +### K. All distinct parameter names reaching the spec + +```bash +oastools walk parameters -detail -format json $F \ + | jq -rs '[.[] | .parameter.name] | unique' +``` + +Baseline: 92 distinct names (vs ~48 `def.*_param` in `general.ex`). Diff to find: +- Zombie helpers — defined in `general.ex` but never reaching the spec. +- Inline-only params — declared in controllers, candidates for promotion to `general.ex` if they are reusable. + +Used by: `parameter-discovery.md` §"Browse all helpers". + +### L. Request body discovery across POST/PUT/PATCH ops + +```bash +oastools walk operations -method post -detail -format json $F \ + | jq -rs '.[] | select(.operation.requestBody) + | {path, body: .operation.requestBody.content."application/json".schema}' +``` + +Baseline: 1 op (`POST /v2/smart-contracts/{address_hash_param}/audit-reports`). Repeat with `-method put` / `-method patch` as needed. + +Used by: `request-body-security-headers.md` §"Discovering existing request body helpers". + +### M. Operations declaring `security:` + +```bash +oastools walk operations -detail -format json $F \ + | jq -rs '[.[] | select(.operation.security) | "\(.method) \(.path)"]' +``` + +Baseline: 0 in the public spec — `security:` is private/account-only. Regenerate against `specs/private.ex` for auth-bearing endpoints. + +Used by: `request-body-security-headers.md` §"Security schemes". + +### C. Properties missing `description:` in a named schema + +```bash +oastools walk schemas -name -detail -format json -q $F \ + | jq -rs '.[0].schema.properties | to_entries + | map(select(.value.description == null)) | map(.key)' +``` + +Produces a mechanical shortlist for review. Still needs human judgment — tautological descriptions (that restate the property name) pass this filter but are equally bad; see `schema-conventions.md` §"Property descriptions". + +Used by: `inspection-checklist.md` §2f, `schema-conventions.md` §"Property descriptions". diff --git a/.agents/skills/openapi-spec/references/parameter-discovery.md b/.agents/skills/openapi-spec/references/parameter-discovery.md new file mode 100644 index 000000000000..1da0c57f7e97 --- /dev/null +++ b/.agents/skills/openapi-spec/references/parameter-discovery.md @@ -0,0 +1,152 @@ +# Parameter Discovery Guide + +All parameter helper functions are centralized in a single file: +`apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex` + +They are auto-imported into every controller via `block_scout_web.ex`: +```elixir +import BlockScoutWeb.Schemas.API.V2.General +``` + +No controllers or other schema modules define their own parameter helpers — `general.ex` is the single source. + +## How to discover existing helpers + +### Find a specific helper + +Grep for the function name in `general.ex`: +``` +grep "def transaction_hash_param" in general.ex +``` + +Parameter helpers follow the naming convention `_param` for single params, and `_params` for grouped helpers. + +### Browse all helpers + +Grep for `def.*_param` in `general.ex` to see the full list. There are ~48 helpers organized into functional categories (described below). + +To see which parameter names actually reach the generated spec, run recipe K in `references/oastools-audit-recipes.md`. The spec surfaces ~92 distinct names — diff against `general.ex` to find zombie helpers (defined but never used) and inline-only names (declared in controllers, candidates for promotion to `general.ex`). + +### Find which controllers use a helper + +Grep for the function name across `controllers/api/v2/`: +``` +grep "transaction_hash_param()" in controllers/api/v2/**/*.ex +``` + +`oastools walk parameters -name -q .ai/tmp/openapi_public.yaml` (recipe J) is authoritative about which endpoints *declare* the parameter in the spec — it doesn't see controller-private helpers or inline `%Parameter{}` structs, but it won't miss anything wired into the public spec. + +## Naming conventions + +| Category | Naming pattern | Examples | +|---|---|---| +| Path identifiers | `_hash_param`, `_number_param`, `_id_param` | `address_hash_param`, `block_number_param`, `token_id_param` | +| Domain filters | `_param` | `token_type_param`, `direction_filter_param`, `transaction_filter_param` | +| Sorting | `sort_param(fields)`, `order_param` | `sort_param(["name", "holder_count"])` | +| Boolean toggles | descriptive name | `just_request_body_param` | +| Authentication | `_param` | `api_key_param`, `key_param`, `admin_api_key_param` | +| Paging factories | `define_paging_params(field_names)` | `define_paging_params(["index", "block_number"])` | + +## Functional categories + +When looking for existing helpers, think about which category the parameter falls into: + +**A. Path identifiers** — Entity identifiers in URL path segments. Grep: `def.*_hash_param\|def.*_number_param\|def.*_id_param`. + +**B. Domain filters** — Query params that filter list results. Grep: `def.*_filter_param\|def.*_type_param\|def.*q_param`. + +**C. Sorting** — `sort_param/1` takes a list of allowed sort fields, `order_param/0` provides asc/desc. Grep: `def sort_param\|def order_param`. + +**D. Paging factories** — `define_paging_params/1` generates multiple `%Parameter{in: :query}` structs from a list of field name strings. There are also `define_state_changes_paging_params/1` and `define_search_paging_params/1` variants. Grep: `def define_paging_params\|def define.*paging`. + +**E. Authentication** — `api_key_param`, `key_param` (bundled as `base_params()`), `admin_api_key_param` (header), `recaptcha_response_param`. Grep: `def.*api_key\|def.*key_param\|def recaptcha`. + +**F. Composite helpers** — `base_params()` returns `[api_key_param(), key_param()]`. Grep: `def base_params`. + +## Creating a new parameter helper + +### When to create a helper vs inline + +- **Create a helper in `general.ex`** if the parameter is a generic concept reusable across multiple controllers (entity hashes, block numbers, token types). +- **Create a private helper in the controller** if the parameter is domain-specific but used by multiple operations in the same controller (e.g., an Arbitrum message direction param shared by `messages` and `messages_count`). This avoids polluting `general.ex` with chain-specific concerns while preventing copy-paste duplication across operations. +- **Use inline `%Parameter{}`** only if the parameter is truly unique to a single operation. + +### Helper function template + +```elixir +@spec my_new_param() :: Parameter.t() +def my_new_param do + %Parameter{ + name: :my_new_param, # atom — must match route segment and controller pattern-match + in: :path, # :path | :query | :header + schema: FullHash, # a schema module or inline %Schema{} + required: true, # true for path params, typically false for query params + description: "Description of what this parameter does" + } +end +``` + +Place it in `general.ex` near other helpers of the same category. The file is organized roughly by category, though not strictly enforced. + +### Extract before you copy + +If the same structural payload shows up at 2+ sites, extract it. The rule is the same on both sides of the spec: + +- **Whole-`%Parameter{}` duplication** (same `name`, `schema`, `description`) → promote to a helper in `general.ex` (or a controller-private helper for chain-specific concerns), per "When to create a helper vs inline" above. +- **Same `schema:` payload across parameters with distinct `name`/`description`/`example`** → extract the payload to a leaf schema in `schemas/api/v2/general/.ex` and reference it via `schema: General.` from each `%Parameter{}`. The per-parameter description stays on the `%Parameter{}` struct, so the leaf can be description-less. Existing precedent: `IntegerString`, `Timestamp`, `FullHash`. +- **Same regex literal in multiple `pattern:` fields** → promote it to an accessor in `general.ex` alongside `integer_pattern/0`, `non_negative_integer_pattern/0`, `address_hash_pattern/0`, etc., and reuse via `General._pattern()`. + +This mirrors the schema-side dedup rule in `references/schema-conventions.md §"Domain-scoped shared schemas"` — same principle, applied wherever the duplication actually lives. + +### Inline parameter template + +For one-off parameters, define directly in the `operation` macro arguments: + +```elixir +operation :my_action, + parameters: [ + %OpenApiSpex.Parameter{ + name: :height, + in: :path, + schema: Schemas.General.IntegerString, + required: true, + description: "Block height" + } + | base_params() + ], + responses: [...] +``` + +## Schema types for parameters + +Parameter schemas reference leaf schema modules from `schemas/api/v2/general/`: + +| Type | Module | Use for | +|---|---|---| +| Full hash (0x + 64 hex) | `FullHash` | Transaction hashes, block hashes | +| Address hash (0x + 40 hex) | `AddressHash` | Address identifiers | +| Integer as string | `IntegerString` | Numeric IDs passed as strings | +| Hex string | `HexString` | Arbitrary hex data | +| Generic string | `%Schema{type: :string}` | Free-form text, API keys | +| Boolean | `%Schema{type: :boolean}` | Toggle flags | +| Enum | `%Schema{type: :string, enum: [...]}` | Fixed set of allowed values | + +To discover available leaf schemas, glob `schemas/api/v2/general/*.ex`. + +## The `define_paging_params` factory + +For paginated list endpoints, pagination cursor parameters are generated from a list of field names: + +```elixir +define_paging_params(["index", "block_number", "batch_log_index"]) +``` + +This creates one `%Parameter{in: :query, required: false}` per field name. The string names are converted to atoms as the parameter `:name`. Each gets an `IntegerString` schema by default. + +**Always include `"items_count"`** in the field list. The `next_page_params/5` function in `chain.ex` unconditionally adds `items_count` to every pagination cursor. If the operation doesn't declare it as a query param, CastAndValidate will reject next-page requests with "Unexpected field: items_count." Example: `define_paging_params(["id", "items_count"])`. + +There are specialized variants: +- `define_state_changes_paging_params/1` — for state change pagination +- `define_search_paging_params/1` — for search result pagination (uses object params) + +Grep `define_paging_params\|define_state_changes\|define_search` in `general.ex` to see their implementations. diff --git a/.agents/skills/openapi-spec/references/request-body-security-headers.md b/.agents/skills/openapi-spec/references/request-body-security-headers.md new file mode 100644 index 000000000000..cfed1fe42544 --- /dev/null +++ b/.agents/skills/openapi-spec/references/request-body-security-headers.md @@ -0,0 +1,207 @@ +# Request Bodies, Security Schemes, and Headers + +## Request bodies (POST/PUT/PATCH endpoints) + +### The pattern + +Request bodies are declared via the `request_body:` key in the `operation/2` macro: + +```elixir +operation :my_action, + summary: "Create a resource", + description: "Creates a new resource.", + request_body: my_resource_request_body(), + parameters: base_params(), + responses: [...] +``` + +The value is always a function call returning an `%OpenApiSpex.RequestBody{}` struct. + +### Helper function pattern + +```elixir +def my_resource_request_body do + %OpenApiSpex.RequestBody{ + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + field_a: %OpenApiSpex.Schema{type: :string}, + field_b: %OpenApiSpex.Schema{type: :integer} + }, + required: [:field_a, :field_b] + } + } + } + } +end +``` + +### Naming convention + +`_request_body()` — e.g., `admin_api_key_request_body()`, `audit_report_request_body()`. + +### Where to place helpers + +- **General/shared**: `schemas/api/v2/general.ex` (auto-imported) +- **Domain-specific**: `schemas/api/v2/.ex` (must be aliased in the controller) + +### Discovering existing request body helpers + +Grep for `request_body` in `general.ex` and domain-specific schema files: +``` +grep "def.*_request_body" in schemas/api/v2/**/*.ex +``` + +To see request bodies actually wired into the generated spec (not just defined), run recipe L in `references/oastools-audit-recipes.md`. Useful because grep finds helpers that may not be referenced by any operation. + +### How CastAndValidate handles request bodies + +After casting, body params are written to `conn.body_params` with **atom keys** (not string keys). Controllers read casted body params like: +```elixir +Map.get(conn.body_params, :email) +``` + +Body params are NOT merged into `conn.params` — they must be read from `conn.body_params` separately. + +### Inline schemas vs module references + +Existing request body schemas are defined **inline** within the `%RequestBody{}` struct, not as separate named schema modules. This is the established convention for request bodies in the codebase. + +### The library also supports tuple shorthands + +`open_api_spex` supports alternative forms for `request_body:`: +1. `%RequestBody{}` struct (what Blockscout uses exclusively) +2. 3-tuple: `{"description", "application/json", SchemaModule}` +3. 4-tuple: `{"description", "application/json", SchemaModule, opts}` + +Stick with the `%RequestBody{}` struct to stay consistent with existing code. + +### File upload / multipart + +Currently, file upload endpoints (like VerificationController) have **no OpenAPI annotations**. There is no established pattern for `"multipart/form-data"` in the codebase. If you need to annotate a multipart endpoint, this would be a new pattern — flag it for discussion. + +--- + +## Security schemes + +### Component-level scheme definition + +Security schemes are defined in spec aggregator modules, not in controllers. The private API spec (`specs/private.ex`) defines: + +```elixir +components: %Components{ + securitySchemes: %{ + "dynamic_jwt" => %SecurityScheme{type: "http", scheme: "bearer", bearerFormat: "JWT"} + } +} +``` + +### Per-operation security + +The `operation` macro supports a `security:` key: + +```elixir +operation :authenticate_via_dynamic, + summary: "Authenticate via Dynamic JWT", + security: [%{"dynamic_jwt" => []}], + responses: [...] +``` + +This references the scheme defined in components. The `[]` in the value is the list of required scopes (empty = no specific scopes required). + +### When to use security + +Most public API endpoints don't use `security:`. It's primarily for private/account API endpoints that require authentication. To check if an endpoint needs security: + +1. Look at the controller's plugs — does it use an authentication plug? +2. Check the router — is the endpoint in the `AccountRouter` or behind an auth pipeline? +3. Check if the controller reads `conn.assigns.current_user` or similar auth state. + +### Discovering existing security patterns + +Grep for `security:` in controller files: +``` +grep "security:" in controllers/**/*.ex +``` + +Spec-side lookup: recipe M in `references/oastools-audit-recipes.md`. Empty against the public spec — `security:` is private/account-only. Regenerate against `specs/private.ex` to see auth-bearing endpoints. + +--- + +## Header parameters + +### Request headers + +Header parameters use `%Parameter{in: :header}`: + +```elixir +def my_header_param do + %Parameter{ + name: :"x-api-key", # atom with the exact header name (case-insensitive matching) + in: :header, + schema: %Schema{type: :string}, + required: false, + description: "Description of the header" + } +end +``` + +`CastAndValidate` reads from `conn.req_headers` with case-insensitive matching — header parameters are cast and validated identically to path/query parameters. + +### Discovering existing header params + +Grep for `in: :header` in `general.ex`: +``` +grep "in: :header" in schemas/api/v2/general.ex +``` + +### Undeclared cross-cutting headers + +Several request headers are consumed at runtime but not declared in OpenAPI specs: +- `show-scam-tokens` — consumed by multiple controllers via `fetch_scam_token_toggle` +- `recaptcha-v2-response` / `recaptcha-v3-response` — consumed by rate limiting +- `x-api-v2-temp-token` — consumed by rate limiting + +This is a known gap. When adding these to specs, use the "separate grouped helpers" approach: + +```elixir +# Individual helpers in general.ex +def show_scam_tokens_header_param do + %Parameter{ + name: :"show-scam-tokens", in: :header, + schema: %Schema{type: :string, enum: ["true", "false"]}, + required: false, + description: "When 'true', includes tokens flagged as potential scams." + } +end + +# Grouped helper for convenience +def scam_token_header_params, do: [show_scam_tokens_header_param()] +``` + +Then add to only the operations that actually use them: +```elixir +parameters: base_params() ++ scam_token_header_params() ++ define_paging_params([...]) +``` + +To identify which controllers consume `show-scam-tokens`, grep for `fetch_scam_token_toggle` in controllers. + +### Response headers + +Response headers use the `OpenApiSpex.Header` struct and are declared via a 4-tuple response form: + +```elixir +responses: [ + ok: {"Success description", "application/json", Schemas.MyDomain.Response, + headers: %{ + "x-ratelimit-limit" => %OpenApiSpex.Header{ + description: "Max requests per window", + schema: %Schema{type: :integer} + } + }} +] +``` + +Currently, **no response headers are declared** in the codebase (rate-limit, CSRF, temp token headers are all undeclared). This is a known gap. If adding response headers, this 4-tuple form (changing from the standard 3-tuple) is the way to do it. diff --git a/.agents/skills/openapi-spec/references/schema-conventions.md b/.agents/skills/openapi-spec/references/schema-conventions.md new file mode 100644 index 000000000000..5750a10636ab --- /dev/null +++ b/.agents/skills/openapi-spec/references/schema-conventions.md @@ -0,0 +1,601 @@ +# Schema Conventions + +All schema modules live under `apps/block_scout_web/lib/block_scout_web/schemas/api/v2/`. + +## Directory structure conventions + +These conventions are inferred from consistent patterns — there's no written doc. + +### Base entity + subdirectory pattern + +Major domain objects have a base file at the root plus a same-named subdirectory for sub-schemas: + +``` +schemas/api/v2/ + transaction.ex # base Transaction schema (properties, types) + transaction/ + response.ex # Transaction.Response (extends base with title/description) + fee.ex # Transaction.Fee + counters.ex # Transaction.Counters + state_change.ex # Transaction.StateChange +``` + +This pattern applies to: `address`, `block`, `blob`, `token`, `transaction`, `withdrawal`, `smart_contract`, and chain-specific domains (`optimism/batch`, `celo/election_reward`, etc.). + +### When to create a subdirectory + +Create a subdirectory when 2+ sub-schemas exist for a domain entity. Simple leaf entities (`CoinBalance`, `Log`, `InternalTransaction`) have no subdirectory — just a single file. + +### Shared primitives in `general/` + +`general/` contains ~22 reusable type schemas: `AddressHash`, `FullHash`, `IntegerString`, `Timestamp`, nullable variants, etc. These are leaf schemas referenced by property types across all domain schemas. + +To discover available primitives, glob `schemas/api/v2/general/*.ex`. + +### Domain-scoped shared schemas + +Domain subdirectories (e.g., `arbitrum/`, `optimism/`) can also contain leaf schemas shared across multiple schemas within that domain. This is the same pattern as `general/` primitives, but scoped to a specific chain or domain. + +**When to extract:** When 2+ schemas in the same domain directory define an identical inline structure — either an object sub-schema with the same properties/types, or an enum with the same values. The trigger is duplication, not speculation: don't pre-extract a structure used by only one schema. + +To find duplicates mechanically: recipe D in `references/oastools-audit-recipes.md` enumerates every inline enum in the spec (group by `.enum` array); recipe N gives a property-set subset/superset scan for shared object structures. + +**Why it matters:** +- **For sub-objects** (e.g., a `commitment_transaction` block with 4 properties): if the structure changes, every inline copy must be found and updated. A shared schema eliminates this drift risk. +- **For enums** (e.g., a `batch_data_container` enum): each inline copy needs its own "keep in sync with Ecto" comment (see "Required: Ecto.Enum sync comments" below). A shared enum schema consolidates that comment to one location — the leaf module — so there's one place to update when Ecto enum values change. + +**Where to put them:** In the domain subdirectory alongside the schemas that use them: `arbitrum/commitment_transaction.ex`, `arbitrum/batch_data_container.ex`. + +**Template — shared object sub-schema:** + +```elixir +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.CommitmentTransaction do + @moduledoc """ + Parent chain transaction that committed a batch. + + Shared across Batch and BatchForList schemas. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Parent chain transaction that committed the batch.", + type: :object, + properties: %{ + hash: General.FullHashNullable, + block_number: %Schema{type: :integer, minimum: 0, nullable: true}, + timestamp: General.TimestampNullable, + status: %Schema{type: :string, nullable: true, description: "Finalization status."} + }, + required: [:hash, :block_number, :timestamp, :status], + additionalProperties: false + }) +end +``` + +Then reference it in both schemas: + +```elixir +# In Batch and BatchForList: +commitment_transaction: Arbitrum.CommitmentTransaction +``` + +**Template — shared enum leaf schema:** + +```elixir +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchDataContainer do + @moduledoc """ + Data availability container type for Arbitrum batches. + """ + require OpenApiSpex + + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. + OpenApiSpex.schema(%{ + type: :string, + enum: ["in_blob4844", "in_calldata", "in_celestia", "in_anytrust", "in_eigenda"], + nullable: true, + description: "Data availability container type." + }) +end +``` + +Then reference it in both schemas: + +```elixir +batch_data_container: Arbitrum.BatchDataContainer +``` + +Note: the "keep in sync" comment lives in the leaf module. Schemas that reference it don't need their own copy — the single source of truth is the leaf module. + +### Chain-specific schemas + +Chain-specific schemas get top-level subdirectories: `optimism/`, `scroll/`, `celo/`, `zilliqa/`, `beacon/`. These map to chain-conditional router scopes. + +### File naming + +Snake_case files, CamelCase modules: `transaction/fee.ex` contains `BlockScoutWeb.Schemas.API.V2.Transaction.Fee`. + +## Schema composition patterns + +### Leaf schemas (primitives) + +Single-purpose modules — one-liner `OpenApiSpex.schema/1` calls: + +```elixir +# general/full_hash.ex +defmodule BlockScoutWeb.Schemas.API.V2.General.FullHash do + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{type: :string, pattern: General.full_hash_pattern(), nullable: false}) +end +``` + +### Composite object schemas + +Larger schemas with `type: :object`: + +```elixir +OpenApiSpex.schema(%{ + title: "TransactionFee", + description: "Transaction fee details", + type: :object, + required: [:type, :value], + properties: %{ + type: %Schema{type: :string, enum: ["maximum", "actual"]}, + value: General.IntegerStringNullable + }, + additionalProperties: false # always set this on object schemas +}) +``` + +Key conventions: +- **`additionalProperties: false`** — always set on object schemas. This enables test-time enforcement: any key the view emits that isn't in the schema causes a test failure. Spec-wide audit: recipe A in `references/oastools-audit-recipes.md`. +- **`required:`** — list all keys that the view always emits. +- **Property values** can be schema modules (like `General.IntegerStringNullable`) or inline `%Schema{}` structs. + +### Polymorphic properties (`oneOf`) + +When a view branches on a discriminator field and emits different object shapes per branch, model the property using `oneOf`. Each variant is a standalone `%Schema{type: :object}` with its own properties and `additionalProperties: false`. The discriminator field (the field the view branches on) must appear in every variant so validation can match exactly one. + +**Existing precedent:** `transaction.ex` uses `oneOf` for the `revert_reason` property (line ~392), which can be either a decoded input object or a raw hex string wrapper. For the full list of endpoints whose 200 response currently uses `oneOf`, run recipe G in `references/oastools-audit-recipes.md`. + +**Structural pattern:** + +```elixir +polymorphic_field: %Schema{ + oneOf: [ + %Schema{ + type: :object, + properties: %{discriminator: DiscriminatorType, field_a: ...}, + required: [:discriminator, :field_a], + additionalProperties: false + }, + %Schema{ + type: :object, + properties: %{discriminator: DiscriminatorType, field_b: ..., field_c: ...}, + required: [:discriminator, :field_b, :field_c], + additionalProperties: false + } + ], + description: "Structure varies by `discriminator` value." +} +``` + +**Constrain the discriminator per variant.** Each variant's discriminator property must be narrowed to the specific value(s) that identify it — use an inline `%Schema{type: :string, enum: [...]}` instead of referencing the shared enum schema. This prevents logically invalid combinations from passing validation (e.g., `batch_data_container: "in_calldata"` paired with `data_hash` + `signers` fields that only exist on the `in_anytrust` variant). + +**Concrete template (batch data availability with 2 variants shown):** + +```elixir +data_availability: %Schema{ + oneOf: [ + # Variant: nil / in_blob4844 / in_calldata (no extra fields) + %Schema{ + type: :object, + properties: %{ + batch_data_container: %Schema{type: :string, enum: ["in_blob4844", "in_calldata"], nullable: true} + }, + required: [:batch_data_container], + additionalProperties: false + }, + # Variant: in_anytrust + %Schema{ + type: :object, + properties: %{ + batch_data_container: %Schema{type: :string, enum: ["in_anytrust"]}, + data_hash: %Schema{type: :string, nullable: true}, + timeout: %Schema{type: :string, nullable: true}, + bls_signature: %Schema{type: :string, nullable: true}, + signers: %Schema{type: :array, items: %Schema{type: :string}} + }, + required: [:batch_data_container, :data_hash, :timeout, :bls_signature, :signers], + additionalProperties: false + } + # ... additional variants (each with its own enum constraint) + ], + description: "Data availability info. Structure varies by `batch_data_container`." +} +``` + +**Catch-all branch.** If the view has a fallthrough clause (e.g., `value -> %{"field" => to_string(value)}`), model it as the minimal variant containing only the discriminator. + +**Notes:** +- `discriminator:` (the OpenAPI 3.0 keyword) is optional in OpenApiSpex — the `oneOf` alone is sufficient for validation. OpenApiSpex checks each variant and requires exactly one to match. +- Each variant gets `additionalProperties: false`, which means test-time validation will catch extra or missing keys per variant — not just on the top-level schema. +- For simple variants (1-2 properties beyond the discriminator), inline schemas inside the `oneOf` list are fine. For larger or reusable variants, extract each into a domain schema module. + +### Response schemas + +Response schemas extend a base entity schema with title/description: + +```elixir +# transaction/response.ex +OpenApiSpex.schema( + Transaction.schema() + |> Helper.extend_schema( + title: "TransactionResponse", + description: "Transaction response" + ) + |> ChainTypeCustomizations.chain_type_fields() +) +``` + +The pattern is: base schema -> extend with metadata -> apply chain-type fields. + +### Helper.extend_schema/2 + +Located at `schemas/helper.ex`. Merges `:properties`, `:required`, `:title`, `:description`, `:nullable`, and `:enum` into an existing schema map: +- Properties are merged (new keys added, existing overwritten) +- Required lists are concatenated +- Scalar fields (title, description, nullable) are replaced + +```elixir +schema_map +|> Helper.extend_schema( + title: "ExtendedSchema", + properties: %{new_field: %Schema{type: :string}}, + required: [:new_field] +) +``` + +**Reusing a leaf primitive with a per-property override.** When you want the type/format/nullable of a `general/` leaf (`Timestamp`, `IntegerString`, `FullHash`, …) *and* a per-property field like `description:`, `example:`, or a flipped `nullable:`, use `Helper.extend_schema` at the property position: + +```elixir +timestamp: + Helper.extend_schema(General.Timestamp.schema(), + description: "Block timestamp of the parent transaction." + ) +``` + +This inlines the leaf's shape and overlays the description, no `allOf` casting layer involved. + +Avoid `%Schema{allOf: [Leaf], description: "..."}` as the overlay mechanism. `allOf` is for *object* composition: it works fine for object leaves like `Address` (and `advanced_filter.ex` does this for `from`/`to`/`created_contract`), but for primitive leaves with a non-trivial `format:` cast — `Timestamp`'s `format: :"date-time"` casts strings to `%DateTime{}`, `Decimal`-typed leaves cast to `%Decimal{}` — `OpenApiSpex.Cast.AllOf` enumerates the per-branch results as maps and fails on non-Enumerable structs. The `extend_schema` form sidesteps the cast composition entirely. + +### Schema reuse and naming for related schemas + +When multiple endpoints render the same underlying entity with different levels of detail (e.g., a list endpoint emits 7 fields while a main-page widget emits only 4), avoid duplicating properties across standalone schemas. Instead, use `extend_schema` to build one from the other. + +**When to apply:** This is a post-factum decision — evaluate only when you're creating a new schema and discover an existing one in the same domain with overlapping properties. Don't speculatively refactor schemas that have only one consumer. + +**Identifying the relationship:** +1. Compare the property sets of the existing and new schemas. +2. Determine which is the subset (fewer properties) and which is the superset. +3. Cross-reference with the Ecto schema to see which OpenAPI schema most closely matches the full entity. + +Mechanical candidate detection across all component schemas: recipe N in `references/oastools-audit-recipes.md`. + +**Naming convention:** +- The schema whose properties most closely match the Ecto schema should be named after the entity: `` (e.g., `Message`). This is the "full" representation. +- The schema with fewer properties (a subset) should be named `Minimal` (e.g., `MinimalMessage`). This clearly communicates it's a reduced view without tying the name to a specific endpoint. +- `extend_schema` only adds properties — it cannot subtract. So `Minimal` is always the base that `` extends. + +**Renaming existing schemas:** If an existing schema was named for its endpoint (e.g., `MessageForMainPage`) and turns out to be the minimal subset, rename it to `Minimal`. Update all references in controller operations, tests, and any other schemas that use it. Then create the full `` schema extending it. + +**Critical: always pass `title:` when extending.** Without an explicit `title:`, the child schema inherits the parent's auto-generated title. OpenApiSpex uses titles as keys in its internal schema registry, so two schemas with the same title collide — the child silently overwrites the parent. This causes test failures on the parent's endpoints because the wrong schema (with extra required fields) is used for validation. + +**Template:** + +```elixir +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Message do + @moduledoc """ + Full Arbitrum cross-chain message schema. + + Extends `MinimalMessage` with: id, origination_address_hash, status. + """ + + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.MinimalMessage + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + MinimalMessage.schema() + |> Helper.extend_schema( + title: "Arbitrum.Message", # REQUIRED — prevents registry collision + description: "Full Arbitrum cross-chain message.", + properties: %{ + id: %Schema{type: :integer, minimum: 0}, + origination_address_hash: General.AddressHashNullable, + status: %Schema{type: :string, enum: ["initiated", "sent", "confirmed", "relayed"]} + }, + required: [:id, :origination_address_hash, :status] + ) + ) +end +``` + +### Paginated response wrapper + +For list endpoints, use `General.paginated_response/1`: + +```elixir +# In the operation macro +responses: [ + ok: {"Token transfer list", "application/json", + paginated_response( + items: Schemas.TokenTransfer, + next_page_params_example: %{"index" => 442, "block_number" => 21307214} + )} +] +``` + +This wraps the item schema in a standard envelope: +```json +{"items": [...], "next_page_params": {...} | null} +``` + +The `next_page_params` is typed as a generic `type: :object, nullable: true` with no fixed properties — only the `example` documents expected keys. + +## Chain-type customization pattern + +Schemas and views use the same dispatch mechanism for chain-specific fields: + +```elixir +# In a ChainTypeCustomizations module (co-located in the parent schema file) +def chain_type_fields(schema) do + case chain_type() do + :zksync -> schema |> Helper.extend_schema(properties: %{zksync: @zksync_schema}) + :arbitrum -> schema |> Helper.extend_schema(properties: %{arbitrum: @arbitrum_schema}) + :optimism -> schema |> Helper.extend_schema(properties: %{l1_fee: ...}) + _ -> schema + end +end +``` + +ChainTypeCustomizations modules are almost always defined at the top of the same `.ex` file as the schema they modify. One exception: `general/implementation/chain_type_customizations.ex`. + +When creating a new schema that needs chain-type support: +1. Define the base schema with default-chain properties +2. Add a `ChainTypeCustomizations` module in the same file +3. Pipe the schema through `ChainTypeCustomizations.chain_type_fields/1` + +## Creating a new schema module + +### Template for a new object schema + +```elixir +defmodule BlockScoutWeb.Schemas.API.V2.MyDomain do + alias OpenApiSpex.Schema + alias BlockScoutWeb.Schemas.API.V2.General + + require OpenApiSpex + + @moduledoc "Schema for MyDomain entity" + + OpenApiSpex.schema(%{ + title: "MyDomain", + description: "Description of this entity", + type: :object, + required: [:field_a, :field_b], + properties: %{ + field_a: %Schema{type: :string, description: "What field_a is"}, + field_b: General.IntegerString, + field_c: %Schema{type: :string, nullable: true} + }, + additionalProperties: false + }) +end +``` + +### Template for a response wrapper + +```elixir +defmodule BlockScoutWeb.Schemas.API.V2.MyDomain.Response do + alias BlockScoutWeb.Schemas.API.V2.MyDomain + alias BlockScoutWeb.Schemas.Helper + + require OpenApiSpex + + OpenApiSpex.schema( + MyDomain.schema() + |> Helper.extend_schema( + title: "MyDomainResponse", + description: "MyDomain response" + ) + ) +end +``` + +### Aliasing in controllers + +The `block_scout_web.ex` `:controller` block provides: +```elixir +alias BlockScoutWeb.Schemas.API.V2, as: Schemas +``` + +So in controllers you reference schemas as `Schemas.MyDomain.Response`. + +## Determining property types from Ecto schemas + +The view layer is lossy about types — it renders everything as JSON primitives. To declare precise OpenAPI types, cross-reference with the underlying Ecto schema in the Explorer app (`apps/explorer/lib/explorer/chain/.ex`). + +### Discovery process + +1. Identify the Ecto schema module for the entity. The view's `prepare_*` function usually receives a struct — trace its type back to the `Explorer.Chain.*` module. +2. Read the Ecto schema's `schema` block and `@type` definition to see the field types. +3. Grep for `Ecto.Enum` in the file to find enum fields. + +### Computed values (no direct Ecto field) + +Sometimes the view emits a key whose value is not bound to a single Ecto field — it is produced by a helper such as `assign_/1`, `prepare_/1`, or a `case` expression in `prepare_*`. The Ecto schema alone won't tell you the value's shape; you have to read the helper. + +Read the helper's branches. If every branch returns a value drawn from a closed set — string literals, atoms converted to strings, calls into `Module.valid_types/0`, `Ecto.Enum.values/2` — model the property as `enum` and assemble the values from every source the helper consults. The sync comment should name each source so a future maintainer knows what to update if any of them changes. + +If the helper has an open branch (e.g. a `_ -> error_reason` clause that propagates a free-form string), the set is not closed and `enum` would misrepresent the API. Keep `type: :string` in that case and lean on the description to enumerate the well-known values. + +### Ecto type → OpenAPI type mapping + +| Ecto type | OpenAPI schema | Notes | +|---|---|---| +| `Ecto.Enum` with values | `%Schema{type: :string, enum: [...values...]}` | Extract the atom values list from the Ecto schema. Convert atoms to strings for the enum. | +| `:string` | `%Schema{type: :string}` | | +| `:integer` | `%Schema{type: :integer}` | If the view converts large integers to strings (common for Wei values), use a string-typed schema — see "Leaf primitives encode the most permissive form" below before reaching for `IntegerString`. | +| `:boolean` | `%Schema{type: :boolean}` | | +| `:decimal` | `%Schema{type: :string}` or `FloatString` | Decimals are typically serialized as strings to preserve precision | +| `Explorer.Chain.Hash.Full` | `General.FullHash` | 0x + 64 hex chars | +| `Explorer.Chain.Hash.Address` | `General.AddressHash` | 0x + 40 hex chars | +| `:utc_datetime_usec` | `General.Timestamp` or `General.TimestampNullable` | ISO 8601 datetime string | +| `:map` | `%Schema{type: :object}` | Check what keys the view actually emits | +| `{:array, inner_type}` | `%Schema{type: :array, items: ...}` | Map the inner type recursively | + +### Leaf primitives encode the most permissive form + +Leaf schemas in `general/` are deliberately loose so they can be reused widely. `IntegerString`, for instance, accepts any integer literal — *including negative ones* — because nothing about the name commits it to a sign. Before reusing such a leaf, check whether the property's domain is actually stricter: a Wei amount, a gas value, a fee, a balance, a count, an index — these are non-negative by definition; a fixed-length identifier has a length constraint; a hex-only field has a character-set constraint. + +If the leaf is looser than the domain warrants, **define a stricter pattern or a stricter leaf** rather than reusing the loose one and accepting the accidental permissiveness. Reuse should narrow when the domain narrows, not widen the schema to match the loosest available helper. + +The pragmatic ordering: + +1. If a stricter helper already exists in `general.ex` (e.g. `non_negative_integer_pattern/0`, `address_hash_pattern/0`), use it via `pattern: General.()`. +2. If 2+ properties want the same stricter shape and no helper exists, add the helper or extract a stricter leaf — see "Domain-scoped shared schemas" and the parameter-discovery reference for the dedup rule. +3. Only inline a one-off `pattern:` literal when the constraint really is unique to a single property. + +Note: `minimum:` is a JSON-Schema *numeric* keyword and is silently ignored on `type: :string` schemas. To express "non-negative" on a string-encoded integer, use a pattern that excludes the leading `-`, not `minimum: 0`. + +### Required: Ecto.Enum sync comments + +**Every `enum:` property in an OpenAPI schema must have a comment pointing to the source Ecto field.** There is no automatic sync between Ecto enums and OpenAPI enums — if someone adds a new value to the Ecto enum without updating the OpenAPI schema, `CastAndValidate` will reject the new value on input, and test-time validation will fail on output only if a test exercises that specific value. The comment is the only signal that tells the next developer where to look. + +Format: +```elixir +# Enum values must be kept in sync with Explorer.Chain. : field. +``` + +When using a shared enum leaf schema (see "Domain-scoped shared schemas" above), the comment lives in the leaf module only — schemas that reference it don't need their own copy. When using an inline enum, the comment goes directly above the `%Schema{type: :string, enum: [...]}` definition. + +Before writing an inline enum, check existing schemas in the same domain — if another schema already defines the same enum, extract it into a shared leaf schema instead of duplicating it (and the comment). + +### Ecto.Enum example + +If the Ecto schema has: +```elixir +field(:batch_data_container, Ecto.Enum, values: [:in_blob4844, :in_calldata, :in_celestia]) +``` + +The OpenAPI property should be: +```elixir +# Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_data_container field. +batch_data_container: %Schema{ + type: :string, + enum: ["in_blob4844", "in_calldata", "in_celestia"], + nullable: true # if the field can be nil +} +``` + +### Nullable fields + +If the Ecto schema field can be `nil` (not in `@required_attrs`, or the view conditionally emits it), the OpenAPI property should have `nullable: true`. If the key is always present but sometimes null, keep it in `required:` and set `nullable: true`. If the key is sometimes absent entirely, remove it from `required:`. + +**Do not use `type: :null` in `anyOf`/`oneOf`, and do not use array types like `type: [:string, :null]`.** These are OpenAPI 3.1 / JSON Schema 2020-12 patterns. Blockscout's spec is OpenAPI 3.0 (open_api_spex v3.22), which predates the null type and supports nullability exclusively through the `nullable: true` keyword. + +```elixir +# Correct — OpenAPI 3.0 +block_number: %Schema{type: :integer, nullable: true} + +result: %Schema{nullable: true, allOf: [result_schema]} + +# Wrong — OpenAPI 3.1 syntax, invalid in 3.0 +block_number: %Schema{anyOf: [%Schema{type: :null}, %Schema{type: :integer}]} + +result: %Schema{anyOf: [%Schema{type: :null}, result_schema]} +``` + +When combining nullability with `allOf`/`oneOf`/`anyOf`, set `nullable: true` alongside the composition keyword on the same schema — don't express null as a separate branch. + +## Property descriptions + +Not every property needs a `description:` — but ambiguous ones without descriptions become a guessing game for API consumers who aren't reading the source code. + +### When to add a description + +Add a description when the property name alone doesn't convey what the value represents: + +- **Domain jargon.** Names inherited from protocol internals that mean nothing outside that context. Example: `before_acc_hash` and `after_acc_hash` are Arbitrum Nitro accumulator hashes — a consumer seeing "acc hash" has no idea this refers to a cumulative hash over sequencer inbox messages. +- **Ambiguous roles.** Names where the "who" or "what" is unclear. Example: `caller_address_hash` — caller of what? Is this the EOA that signed the transaction, or the contract that emitted the event? `destination_address_hash` — destination on which chain? +- **Unclear chain context.** In cross-chain schemas, a bare `block_number` could refer to either the Parent chain or the Rollup. If the containing schema's description doesn't disambiguate, the property must. +- **Opaque Solidity mirrors.** Field names lifted directly from contract events or structs. Example: `callvalue` mirrors Solidity's `msg.value` but reads as one opaque word to REST consumers — describe it as the native coin amount in wei. +- **Enum lifecycle.** When enum values represent a state machine, list the progression and what triggers each transition. A `status` field with `["initiated", "sent", "confirmed", "relayed"]` is meaningless without knowing what moves a message from "sent" to "confirmed". +- **Tautological descriptions.** "Withdrawal status." on a `status` property inside a Withdrawal schema adds zero information — it restates the name. Either write a real description or omit it; a tautology is worse than nothing because it signals "this was reviewed" when it wasn't. + +### When descriptions are unnecessary + +- **Self-documenting compound names.** `origination_transaction_block_number`, `completion_transaction_hash` — the full context is in the name. +- **Well-known token primitives.** `token.symbol`, `token.name`, `token.decimals` — universally understood in the domain. +- **Context from the parent schema.** If the schema-level `description:` already explains the object's role and the property name is unambiguous within that context, a per-property description is redundant. + +### Avoid backend jargon + +API consumers don't read Blockscout source, so keep database columns (`refetch_needed`), Ecto terms ("preloaded", `NotLoaded`), and indexer/cache internals out of descriptions. Describe what the value means or why it can be null from the client's perspective: prefer "null when the count is unavailable" over "null when the association was not preloaded", and "true when the block is scheduled for re-fetch" over "mirrors the `refetch_needed` DB column". + +### Quality standard + +A description should tell the consumer something they cannot infer from the property name alone. If you can delete the description and the property is equally clear, it wasn't worth writing. + +To list undocumented properties on a given schema deterministically: recipe C in `references/oastools-audit-recipes.md`. Tautologies pass that filter — still read each description. + +### Where to find the meaning + +When a property name is ambiguous, cross-reference these sources to determine what it actually represents: + +1. **Ecto schema** — field comments, type annotations, and module docs in `apps/explorer/lib/explorer/chain/`. +2. **Solidity source** — the event or struct the data originates from (e.g., `L2ToL1Tx` event for Arbitrum withdrawals). Contract ABIs clarify which field is the sender, recipient, value, etc. +3. **View's `prepare_*` functions** — trace how the Ecto struct is transformed into the JSON map. The transformation logic often reveals the semantic meaning. + +### Example: before and after + +```elixir +# Bad — tautological, adds nothing +status: %Schema{type: :string, enum: [...], description: "Withdrawal status."} + +# Good — explains the lifecycle +status: %Schema{ + type: :string, + enum: ["initiated", "sent", "confirmed", "relayed"], + description: + "Cross-chain message lifecycle: initiated (tx submitted on Rollup) → " <> + "sent (included in an outbox entry) → confirmed (batch committed to " <> + "Parent chain) → relayed (executed on Parent chain)." +} +``` + +## Examples in schemas + +Three patterns exist, all optional: + +1. **Inline on a property**: `field: %Schema{type: :string, example: "transfer"}` +2. **Top-level on schema**: `example: %{field_a: "value", field_b: 42}` +3. **`next_page_params_example`**: passed to `paginated_response/1` for unstructured paging objects + +Convention: use examples when the type is generic and readers need real-value context. Don't add examples to leaf pattern-based schemas (`FullHash`, `AddressHash`, etc.) — their type and pattern are self-documenting. + +## Cross-cutting URL prefixes and tags + +**Merge behavior.** Per-operation `tags: [...]` is **appended** to the module-level `tags(...)`, not substituted. So an operation in a controller with `tags(["arbitrum"])` and a per-operation `tags: ["main-page"]` ends up with both tags and appears under both Swagger groups (dual-tagging). + +**When to dual-tag.** When the operation's URL lives under a cross-cutting prefix that is itself a registered tag (`/v2/main-page/...`, `/v2/csv-exports/...`), add `tags: [""]` per-operation. Default for these cases — keeps the operation discoverable both via the chain/domain group and the cross-cutting group. + +**When to exclusively relocate.** Remove module-level `tags(...)` and add `tags: [...]` to every operation in the controller. This is what `csv_export_controller.ex` does — every export action sits under its consuming domain (`tokens`, `addresses`) instead of `csv-export`. Use only when the operation truly does not belong in the controller's domain group, or when a reviewer explicitly asks for exclusive grouping. diff --git a/.agents/skills/openapi-spec/references/spec-generation-and-verification.md b/.agents/skills/openapi-spec/references/spec-generation-and-verification.md new file mode 100644 index 000000000000..2215e273a78b --- /dev/null +++ b/.agents/skills/openapi-spec/references/spec-generation-and-verification.md @@ -0,0 +1,106 @@ +# Spec Generation and Verification + +## Generating the spec + +Generate the public OpenAPI spec YAML from Blockscout's `open_api_spex` annotations: + +```bash +.claude/skills/openapi-spec/scripts/generate-spec.sh +``` + +This produces `.ai/tmp/openapi_public.yaml` by default. + +For chain-specific endpoints, pass `--chain`: + +```bash +.claude/skills/openapi-spec/scripts/generate-spec.sh --chain arbitrum +``` + +This produces `.ai/tmp/openapi_public_arbitrum.yaml`. + +To write to a custom path: + +```bash +.claude/skills/openapi-spec/scripts/generate-spec.sh --chain optimism --output .ai/tmp/optimism_spec.yaml +``` + +The script always generates from `BlockScoutWeb.Specs.Public`, which aggregates all routes (API v2, tokens, smart contracts, and Etherscan-compatible endpoints). + +### Behavior + +- On success: prints the output file path and `SPEC_OK`. Mix output is suppressed. +- On failure: prints the captured mix output and `SPEC_FAIL`. Exit code 2. +- If `mix` is not available on the host, the script automatically delegates to the devcontainer. +- The script creates `.ai/tmp/` if it does not exist. + +## Verifying with oastools + +After generating the spec, use `oastools` to validate and inspect it. All examples below assume the default output path — adjust if you used `--output` or `--chain`. + +**Keep queries precise.** Always narrow by exact `-path` and `-method` to avoid large outputs that consume context. Never omit filters when you know the target endpoint. + +### Validate the full spec + +```bash +oastools validate .ai/tmp/openapi_public.yaml +``` + +### Check that an endpoint exists + +```bash +oastools walk operations -path "/v2/addresses/{address_hash_param}" .ai/tmp/openapi_public.yaml +``` + +### Get operation parameters + +```bash +oastools walk parameters -detail -format json -method get -path "/v2/addresses/{address_hash_param}" .ai/tmp/openapi_public.yaml | jq 'del(.path)' +``` + +Filter by parameter location when you only need query or path params: + +```bash +oastools walk parameters -detail -format json -in query -method get -path "/v2/addresses/{address_hash_param}/transactions" .ai/tmp/openapi_public.yaml | jq 'del(.path)' +``` + +### Get response schema + +```bash +oastools walk responses -detail -format json -status 200 -method get -path "/v2/addresses/{address_hash_param}" .ai/tmp/openapi_public.yaml | jq 'del(.path)' +``` + +Always specify `-status` to get only the response code you need. + +### Inspect a specific schema + +```bash +oastools walk schemas -detail -format json -name AddressResponse .ai/tmp/openapi_public.yaml | jq 'del(.jsonPath)' +``` + +## Typical verification workflow + +After creating or modifying an OpenAPI declaration: + +1. **Generate** the spec: + ```bash + .claude/skills/openapi-spec/scripts/generate-spec.sh + ``` + +2. **Validate** the full spec: + ```bash + oastools validate .ai/tmp/openapi_public.yaml + ``` + +3. **Inspect** the target operation — use exact path and method: + ```bash + oastools walk parameters -detail -format json -method get -path "/v2/" .ai/tmp/openapi_public.yaml | jq 'del(.path)' + oastools walk responses -detail -format json -status 200 -method get -path "/v2/" .ai/tmp/openapi_public.yaml | jq 'del(.path)' + ``` + +4. **Run tests** to verify response schemas match the view output (use the `run-tests` skill). + +## Spec-wide audits + +Single-endpoint queries above answer "did I declare this right?" For "does the whole spec still follow our conventions?", use `references/oastools-audit-recipes.md`. Minimum sweep after any schema-touching change: recipe A (additionalProperties), B (422 coverage), F (base_params), I (tag casing). + +Always regenerate before auditing — the generated spec is cache-like, and a stale YAML produces false positives. diff --git a/.agents/skills/openapi-spec/scripts/generate-spec.sh b/.agents/skills/openapi-spec/scripts/generate-spec.sh new file mode 100755 index 000000000000..b7c5ec54623b --- /dev/null +++ b/.agents/skills/openapi-spec/scripts/generate-spec.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Generate the public OpenAPI spec YAML from Blockscout's OpenApiSpex annotations. +# +# Environment-aware: if mix is not found on the host, automatically +# re-invokes itself inside the project's devcontainer via exec.sh. +# +# Usage: generate-spec.sh [--chain ] [--output ] +# --chain Set CHAIN_TYPE for chain-specific endpoints (optional). +# --output Output file path (default: .ai/tmp/openapi_public.yaml, +# or .ai/tmp/openapi_public_.yaml when --chain is set). +# +# Exit codes: +# 0 spec generated successfully +# 1 script error (bad arguments, missing dependencies) +# 2 spec generation failed + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" + +SPEC_MODULE="BlockScoutWeb.Specs.Public" + +# --- Parse flags --- +CHAIN_TYPE="${CHAIN_TYPE:-}" +OUTPUT_PATH="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --chain) + [ "$#" -ge 2 ] || { echo "Error: --chain requires a value" >&2; exit 1; } + CHAIN_TYPE="$2" + shift 2 + ;; + --output) + [ "$#" -ge 2 ] || { echo "Error: --output requires a value" >&2; exit 1; } + OUTPUT_PATH="$2" + shift 2 + ;; + *) + echo "Error: unknown argument: $1" >&2 + echo "Usage: generate-spec.sh [--chain ] [--output ]" >&2 + exit 1 + ;; + esac +done + +# --- Compute default output path --- +if [ -z "$OUTPUT_PATH" ]; then + if [ -n "$CHAIN_TYPE" ]; then + OUTPUT_PATH=".ai/tmp/openapi_public_${CHAIN_TYPE}.yaml" + else + OUTPUT_PATH=".ai/tmp/openapi_public.yaml" + fi +fi + +# --- Export CHAIN_TYPE if set --- +if [ -n "$CHAIN_TYPE" ]; then + export CHAIN_TYPE +fi + +# --- If mix is not available, re-invoke inside the devcontainer --- +if ! command -v mix &>/dev/null; then + # Locate _find-devcontainer-exec.sh + FIND_EXEC="" + for agents_dir in "$PROJECT_ROOT/.agents/agents/scripts" "$PROJECT_ROOT/.claude/agents/scripts"; do + if [ -x "$agents_dir/_find-devcontainer-exec.sh" ]; then + FIND_EXEC="$agents_dir/_find-devcontainer-exec.sh" + break + fi + done + if [ -z "$FIND_EXEC" ]; then + echo "Error: _find-devcontainer-exec.sh not found in .agents/agents/scripts or .claude/agents/scripts" >&2 + exit 1 + fi + + EXEC_SH="$("$FIND_EXEC")" || exit 1 + + EXEC_ENV_ARGS=() + if [ -n "$CHAIN_TYPE" ]; then + EXEC_ENV_ARGS+=(-e "CHAIN_TYPE=$CHAIN_TYPE") + fi + + exec "$EXEC_SH" ${EXEC_ENV_ARGS[@]+"${EXEC_ENV_ARGS[@]}"} \ + bash .agents/skills/openapi-spec/scripts/generate-spec.sh \ + ${CHAIN_TYPE:+--chain "$CHAIN_TYPE"} --output "$OUTPUT_PATH" +fi + +# --- Ensure output directory exists --- +mkdir -p "$PROJECT_ROOT/.ai/tmp" + +# --- Run spec generation --- +cd "$PROJECT_ROOT" + +CAPTURE_FILE="$PROJECT_ROOT/.ai/tmp/.generate-spec-output-$$.log" +cleanup() { rm -f "$CAPTURE_FILE"; } +trap cleanup EXIT + +RESULT=0 +mix openapi.spec.yaml --spec "$SPEC_MODULE" "$OUTPUT_PATH" --start-app=false \ + >"$CAPTURE_FILE" 2>&1 || RESULT=$? + +echo "=== SPEC_RESULTS ===" +if [ -n "$CHAIN_TYPE" ]; then + echo "Chain: $CHAIN_TYPE" +else + echo "Chain: default" +fi + +if [ "$RESULT" -eq 0 ]; then + echo "Output: $OUTPUT_PATH" + echo "---" + echo "SPEC_OK" + exit 0 +else + echo "---" + cat "$CAPTURE_FILE" + echo "---" + echo "SPEC_FAIL" + exit 2 +fi diff --git a/.agents/skills/update-common-blockscout-env/SKILL.md b/.agents/skills/update-common-blockscout-env/SKILL.md new file mode 100644 index 000000000000..d1e6ba460fb4 --- /dev/null +++ b/.agents/skills/update-common-blockscout-env/SKILL.md @@ -0,0 +1,42 @@ +--- +name: update-common-blockscout-env +description: Ensure every newly introduced environment variable is also added to docker-compose/envs/common-blockscout.env so local Docker setups stay aligned with runtime configuration. +--- + +## Overview + +This skill keeps environment-variable documentation and defaults in sync for Docker users. + +When adding or changing runtime env vars (for example in config/runtime.exs), also update docker-compose/envs/common-blockscout.env in the same task. + +## Mandatory Rule + +- Every new env variable introduced in code/config must be added to docker-compose/envs/common-blockscout.env. +- Do not postpone this to a follow-up task. + +## How To Apply + +1. Identify newly added env vars in changed files (typically config/runtime.exs, config/*.exs, or modules reading System.get_env/1-2). +2. Add each variable to docker-compose/envs/common-blockscout.env. +3. Place it in the most relevant section (for example API flags near other API_* variables). +4. Prefer non-breaking defaults: + - Use a commented example line for optional flags (for example # MY_FLAG=false). + - Use an uncommented value only when the project convention requires a default to be active. +5. Keep naming and formatting consistent with existing entries. + +## Checklist + +- New env var exists in code. +- Matching entry exists in docker-compose/envs/common-blockscout.env. +- Placement is logical and discoverable. +- Default value does not change behavior unexpectedly. + +## Example + +If code adds: + +- DISABLE_TRANSACTIONS_BENS_PRELOAD + +Then docker-compose/envs/common-blockscout.env should include: + +- # DISABLE_TRANSACTIONS_BENS_PRELOAD=false diff --git a/.agents/skills/with-to-case-refactor/SKILL.md b/.agents/skills/with-to-case-refactor/SKILL.md new file mode 100644 index 000000000000..77e42fb1c52c --- /dev/null +++ b/.agents/skills/with-to-case-refactor/SKILL.md @@ -0,0 +1,131 @@ +--- +name: with-to-case-refactor +description: Replace `with` expressions that contain only a single `<-` clause and an `else` branch with a `case` expression. This addresses the Credo warning "with contains only one <- clause and an else branch, consider using case instead" and produces cleaner, more idiomatic Elixir code. +--- + +## Overview + +Elixir's `with` construct is designed for chaining multiple pattern-matching steps. When only one `<-` clause is present alongside an `else` branch, `with` adds no value over a plain `case`. Credo flags this as: + +``` +[R] → `with` contains only one <- clause and an `else` branch, consider using `case` instead +``` + +Always prefer `case` in this situation. + +## When to Use + +- When a `with` expression has exactly one `<-` clause and one or more `else` arms. +- When refactoring code to address the Credo `Credo.Check.Refactor.WithClauses` warning. + +## Anti-Pattern (Avoid) + +```elixir +# ❌ BAD: single-clause with/else — should be a case +with {:ok, response} <- json_rpc(params, opts) do + process(response) +else + {:error, reason} -> + Logger.error("RPC failed: #{inspect(reason)}") + :error +end +``` + +```elixir +# ❌ BAD: single-clause with/else wrapping a nested case +with {:ok, response} <- json_rpc(params, opts) do + case parse(response) do + {:ok, value} -> value + _ -> :error + end +else + {:error, reason} -> + Logger.error("RPC failed: #{inspect(reason)}") + :error +end +``` + +## Best Practice (Use Instead) + +```elixir +# ✅ GOOD: flat case replaces with/else +case json_rpc(params, opts) do + {:ok, response} -> + process(response) + + {:error, reason} -> + Logger.error("RPC failed: #{inspect(reason)}") + :error +end +``` + +```elixir +# ✅ GOOD: nested case is fine when the outer with is replaced +case json_rpc(params, opts) do + {:ok, response} -> + case parse(response) do + {:ok, value} -> value + _ -> :error + end + + {:error, reason} -> + Logger.error("RPC failed: #{inspect(reason)}") + :error +end +``` + +## Transformation Rules + +1. Move the expression on the right-hand side of `<-` to become the subject of `case`. +2. Turn the left-hand side of `<-` into the matching branch of `case`. +3. Move the body of the `with` block as the body of that `case` branch. +4. Move each arm of the `else` block as additional `case` branches. +5. Remove the `with`/`else`/`end` wrapper. + +## Real-World Example (from this codebase) + +### Before + +```elixir +with {:ok, response} <- + params + |> Map.merge(%{id: 0}) + |> Nonce.request() + |> json_rpc(json_rpc_named_arguments) do + case Nonce.from_response(%{id: 0, result: response}, id_to_params) do + {:ok, %{nonce: 0}} -> handle_zero_nonce(...) + {:ok, %{nonce: nonce}} when nonce > 0 -> handle_nonzero_nonce(...) + _ -> retry(...) + end +else + {:error, reason} -> + Logger.error("Error: #{inspect(reason)}") + retry(...) +end +``` + +### After + +```elixir +case params + |> Map.merge(%{id: 0}) + |> Nonce.request() + |> json_rpc(json_rpc_named_arguments) do + {:ok, response} -> + case Nonce.from_response(%{id: 0, result: response}, id_to_params) do + {:ok, %{nonce: 0}} -> handle_zero_nonce(...) + {:ok, %{nonce: nonce}} when nonce > 0 -> handle_nonzero_nonce(...) + _ -> retry(...) + end + + {:error, reason} -> + Logger.error("Error: #{inspect(reason)}") + retry(...) +end +``` + +## Notes + +- If the `with` has **two or more** `<-` clauses, keep it as `with`; this refactor only applies to the single-clause case. +- If there is no `else` branch at all, `with` is also acceptable for a single clause — but a `case` is still clearer and preferred. +- After refactoring, run `mix format` to ensure correct indentation. diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 03275c1f98df..000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,626 +0,0 @@ -version: 2 -jobs: - build: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3-node-browsers - environment: - MIX_ENV: test - # match POSTGRES_PASSWORD for postgres image below - PGPASSWORD: postgres - # match POSTGRES_USER for postgres image below - PGUSER: postgres - - working_directory: ~/app - - steps: - - run: sudo apt-get update; sudo apt-get -y install autoconf build-essential libgmp3-dev libtool - - - checkout - - run: - command: ./bin/install_chrome_headless.sh - no_output_timeout: 2400 - - - run: mix local.hex --force - - run: mix local.rebar --force - - - run: - name: "ELIXIR_VERSION.lock" - command: echo "${ELIXIR_VERSION}" > ELIXIR_VERSION.lock - - run: - name: "OTP_VERSION.lock" - command: echo "${OTP_VERSION}" > OTP_VERSION.lock - - - restore_cache: - keys: - - v8-mix-compile-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.lock" }} - - v8-mix-compile-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.exs" }} - - v8-mix-compile-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }} - - - run: mix deps.get - - - restore_cache: - keys: - - v8-npm-install-{{ .Branch }}-{{ checksum "apps/block_scout_web/assets/package-lock.json" }} - - v8-npm-install-{{ .Branch }} - - v8-npm-install - - - run: - command: npm install - working_directory: "apps/explorer" - - - save_cache: - key: v3-npm-install-{{ .Branch }}-{{ checksum "apps/explorer/package-lock.json" }} - paths: "apps/explorer/node_modules" - - save_cache: - key: v3-npm-install-{{ .Branch }} - paths: "apps/explorer/node_modules" - - save_cache: - key: v3-npm-install - paths: "apps/explorer/node_modules" - - - run: - command: npm install - working_directory: "apps/block_scout_web/assets" - - - save_cache: - key: v8-npm-install-{{ .Branch }}-{{ checksum "apps/block_scout_web/assets/package-lock.json" }} - paths: "apps/block_scout_web/assets/node_modules" - - save_cache: - key: v8-npm-install-{{ .Branch }} - paths: "apps/block_scout_web/assets/node_modules" - - save_cache: - key: v8-npm-install - paths: "apps/block_scout_web/assets/node_modules" - - - run: mix compile - - - run: - command: make - working_directory: "deps/ex_secp256k1" - - # `deps` needs to be cached with `_build` because `_build` will symlink into `deps` - - - save_cache: - key: v8-mix-compile-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.lock" }} - paths: - - deps - - _build - - save_cache: - key: v8-mix-compile-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.exs" }} - paths: - - deps - - _build - - save_cache: - key: v8-mix-compile-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }} - paths: - - deps - - _build - - - run: - name: Build assets - command: node node_modules/webpack/bin/webpack.js --mode development - working_directory: "apps/block_scout_web/assets" - - - persist_to_workspace: - root: . - paths: - - .circleci - - .credo.exs - - .dialyzer_ignore.exs - - .formatter.exs - - .git - - .gitignore - - ELIXIR_VERSION.lock - - Gemfile - - Gemfile.lock - - OTP_VERSION.lock - - _build - - apps - - bin - - config - - deps - - doc - - mix.exs - - mix.lock - - appspec.yml - - rel - check_formatted: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: test - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: mix format --check-formatted - credo: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: test - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: mix local.hex --force - - - run: mix credo - deploy_aws: - docker: - # Ensure .tool-versions matches - - image: circleci/python:2.7-stretch - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - add_ssh_keys: - fingerprints: - - "c4:fd:a8:f8:48:a8:09:e5:3e:be:30:62:4d:6f:6f:36" - - - run: - name: Deploy to AWS - command: bin/deploy - dialyzer: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: test - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: mix local.hex --force - - - restore_cache: - keys: - - v8-mix-dialyzer-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.lock" }} - - v8-mix-dialyzer-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.exs" }} - - v8-mix-dialyzer-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }} - - - run: - name: Unpack PLT cache - command: | - mkdir -p _build/test - cp plts/dialyxir*.plt _build/test/ || true - mkdir -p ~/.mix - cp plts/dialyxir*.plt ~/.mix/ || true - - - run: mix dialyzer --plt - - - run: - name: Pack PLT cache - command: | - mkdir -p plts - cp _build/test/dialyxir*.plt plts/ - cp ~/.mix/dialyxir*.plt plts/ - - - save_cache: - key: v8-mix-dialyzer-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.lock" }} - paths: - - plts - - save_cache: - key: v8-mix-dialyzer-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }}-{{ checksum "mix.exs" }} - paths: - - plts - - save_cache: - key: v8-mix-dialyzer-{{ checksum "OTP_VERSION.lock" }}-{{ checksum "ELIXIR_VERSION.lock" }} - paths: - - plts - - - run: mix dialyzer --halt-exit-status - eslint: - docker: - # Ensure .tool-versions matches - - image: circleci/node:12.18.2-browsers-legacy - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: - name: ESLint - command: ./node_modules/.bin/eslint --format=junit --output-file="test/eslint/junit.xml" js/** - working_directory: apps/block_scout_web/assets - - - store_test_results: - path: apps/block_scout_web/assets/test - gettext: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: test - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: mix local.hex --force - - - run: - name: Check for missed translations - command: | - mix gettext.extract --merge | tee stdout.txt - ! grep "Wrote " stdout.txt - working_directory: "apps/block_scout_web" - - - store_artifacts: - path: apps/block_scout_web/priv/gettext - jest: - docker: - # Ensure .tool-versions matches - - image: circleci/node:12.18.2-browsers-legacy - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: - name: Jest - command: ./node_modules/.bin/jest - working_directory: apps/block_scout_web/assets - release: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: prod - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: mix local.hex --force - - run: mix local.rebar --force - - run: MIX_ENV=prod mix release - - run: - name: Collecting artifacts - command: | - find -name 'blockscout.tar.gz' -exec sh -c 'mkdir -p ci_artifact && cp "$@" ci_artifact/ci_artifact_blockscout.tar.gz' _ {} + - when: always - - - store_artifacts: - name: Uploading CI artifacts - path: ci_artifact/ci_artifact_blockscout.tar.gz - destination: ci_artifact_blockscout.tar.gz - sobelow: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: test - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: mix local.hex --force - - - run: - name: Scan explorer for vulnerabilities - command: mix sobelow --config - working_directory: "apps/explorer" - - - run: - name: Scan block_scout_web for vulnerabilities - command: mix sobelow --config - working_directory: "apps/block_scout_web" - # test_geth_http_websocket: - # docker: - # # Ensure .tool-versions matches - # - image: circleci/elixir:1.10.3-node-browsers - # environment: - # MIX_ENV: test - # # match POSTGRES_PASSWORD for postgres image below - # PGPASSWORD: postgres - # # match POSTGRES_USER for postgres image below - # PGUSER: postgres - # ETHEREUM_JSONRPC_CASE: "EthereumJSONRPC.Case.Geth.HTTPWebSocket" - # ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Geth" - # - image: circleci/postgres:10.10-alpine - # environment: - # # Match apps/explorer/config/test.exs config :explorer, Explorer.Repo, database - # POSTGRES_DB: explorer_test - # # match PGPASSWORD for elixir image above - # POSTGRES_PASSWORD: postgres - # # match PGUSER for elixir image above - # POSTGRES_USER: postgres - - # working_directory: ~/app - - # steps: - # - attach_workspace: - # at: . - - # - run: - # command: ./bin/install_chrome_headless.sh - # no_output_timeout: 2400 - - # - run: mix local.hex --force - # - run: mix local.rebar --force - - # - run: - # name: Wait for DB - # command: dockerize -wait tcp://localhost:5432 -timeout 1m - - # - run: - # name: mix test --exclude no_geth - # command: | - # # Don't submit coverage report for forks, but let the build succeed - # if [[ -z "$COVERALLS_REPO_TOKEN" ]]; then - # mix coveralls.html --exclude no_geth --parallel --umbrella - # else - # mix coveralls.circle --exclude no_geth --parallel --umbrella || - # # if mix failed, then coveralls_merge won't run, so signal done here and return original exit status - # (retval=$? && curl -k https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN -d "payload[build_num]=$CIRCLE_WORKFLOW_WORKSPACE_ID&payload[status]=done" && return $retval) - # fi - - # - store_artifacts: - # path: cover/excoveralls.html - # - store_test_results: - # path: _build/test/junit - # test_geth_mox: - # docker: - # # Ensure .tool-versions matches - # - image: circleci/elixir:1.10.3-node-browsers - # environment: - # MIX_ENV: test - # # match POSTGRES_PASSWORD for postgres image below - # PGPASSWORD: postgres - # # match POSTGRES_USER for postgres image below - # PGUSER: postgres - # ETHEREUM_JSONRPC_CASE: "EthereumJSONRPC.Case.Geth.Mox" - # ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Mox" - # - image: circleci/postgres:10.10-alpine - # environment: - # # Match apps/explorer/config/test.exs config :explorer, Explorer.Repo, database - # POSTGRES_DB: explorer_test - # # match PGPASSWORD for elixir image above - # POSTGRES_PASSWORD: postgres - # # match PGUSER for elixir image above - # POSTGRES_USER: postgres - - # working_directory: ~/app - - # steps: - # - attach_workspace: - # at: . - - # - run: - # command: ./bin/install_chrome_headless.sh - # no_output_timeout: 2400 - - # - run: mix local.hex --force - # - run: mix local.rebar --force - - # - run: - # name: Wait for DB - # command: dockerize -wait tcp://localhost:5432 -timeout 1m - - # - run: - # name: mix test --exclude no_geth - # command: | - # # Don't submit coverage report for forks, but let the build succeed - # if [[ -z "$COVERALLS_REPO_TOKEN" ]]; then - # mix coveralls.html --exclude no_geth --parallel --umbrella - # else - # mix coveralls.circle --exclude no_geth --parallel --umbrella || - # # if mix failed, then coveralls_merge won't run, so signal done here and return original exit status - # (retval=$? && curl -k https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN -d "payload[build_num]=$CIRCLE_WORKFLOW_WORKSPACE_ID&payload[status]=done" && return $retval) - # fi - - # - store_artifacts: - # path: cover/excoveralls.html - # - store_test_results: - # path: _build/test/junit - # test_nethermind_http_websocket: - # docker: - # # Ensure .tool-versions matches - # - image: circleci/elixir:1.10.3-node-browsers - # environment: - # MIX_ENV: test - # # match POSTGRES_PASSWORD for postgres image below - # PGPASSWORD: postgres - # # match POSTGRES_USER for postgres image below - # PGUSER: postgres - # ETHEREUM_JSONRPC_CASE: "EthereumJSONRPC.Case.Nethermind.HTTPWebSocket" - # ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Nethermind" - # - image: circleci/postgres:10.10-alpine - # environment: - # # Match apps/explorer/config/test.exs config :explorer, Explorer.Repo, database - # POSTGRES_DB: explorer_test - # # match PGPASSWORD for elixir image above - # POSTGRES_PASSWORD: postgres - # # match PGUSER for elixir image above - # POSTGRES_USER: postgres - - # working_directory: ~/app - - # steps: - # - attach_workspace: - # at: . - - # - run: - # command: ./bin/install_chrome_headless.sh - # no_output_timeout: 2400 - - # - run: mix local.hex --force - # - run: mix local.rebar --force - - # - run: - # name: Wait for DB - # command: dockerize -wait tcp://localhost:5432 -timeout 1m - - # - run: - # name: mix test --exclude no_nethermind - # command: | - # # Don't submit coverage report for forks, but let the build succeed - # if [[ -z "$COVERALLS_REPO_TOKEN" ]]; then - # mix coveralls.html --exclude no_nethermind --parallel --umbrella - # else - # mix coveralls.circle --exclude no_nethermind --parallel --umbrella || - # # if mix failed, then coveralls_merge won't run, so signal done here and return original exit status - # (retval=$? && curl -k https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN -d "payload[build_num]=$CIRCLE_WORKFLOW_WORKSPACE_ID&payload[status]=done" && return $retval) - # fi - - # - store_artifacts: - # path: cover/excoveralls.html - # - store_test_results: - # path: _build/test/junit - test_nethermind_mox: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3-node-browsers - environment: - MIX_ENV: test - # match POSTGRES_PASSWORD for postgres image below - PGPASSWORD: postgres - # match POSTGRES_USER for postgres image below - PGUSER: postgres - ETHEREUM_JSONRPC_CASE: "EthereumJSONRPC.Case.Nethermind.Mox" - ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Mox" - - image: circleci/postgres:10.10-alpine - environment: - # Match apps/explorer/config/test.exs config :explorer, Explorer.Repo, database - POSTGRES_DB: explorer_test - # match PGPASSWORD for elixir image above - POSTGRES_PASSWORD: postgres - # match PGUSER for elixir image above - POSTGRES_USER: postgres - - working_directory: ~/app - - steps: - - attach_workspace: - at: . - - - run: - command: ./bin/install_chrome_headless.sh - no_output_timeout: 2400 - - - run: mix local.hex --force - - run: mix local.rebar --force - - - run: - name: Wait for DB - command: dockerize -wait tcp://localhost:5432 -timeout 1m - - - run: - name: mix test --exclude no_nethermind - command: | - # Don't submit coverage report for forks, but let the build succeed - if [[ -z "$COVERALLS_REPO_TOKEN" ]]; then - mix coveralls.html --exclude no_nethermind --parallel --umbrella - else - mix coveralls.circle --exclude no_nethermind --parallel --umbrella || - # if mix failed, then coveralls_merge won't run, so signal done here and return original exit status - (retval=$? && curl -k https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN -d "payload[build_num]=$CIRCLE_WORKFLOW_WORKSPACE_ID&payload[status]=done" && return $retval) - fi - - - store_artifacts: - path: cover/excoveralls.html - - store_test_results: - path: _build/test/junit - coveralls_merge: - docker: - # Ensure .tool-versions matches - - image: circleci/elixir:1.10.3 - environment: - MIX_ENV: test - - steps: - - run: - name: Tell coveralls.io build is done - command: curl -k https://coveralls.io/webhook?repo_token=$COVERALLS_REPO_TOKEN -d "payload[build_num]=$CIRCLE_WORKFLOW_WORKSPACE_ID&payload[status]=done" -workflows: - version: 2 - primary: - jobs: - - build - - check_formatted: - requires: - - build - # This unfortunately will only fire if all the tests pass because of how `requires` works - - coveralls_merge: - requires: - # - test_nethermind_http_websocket - - test_nethermind_mox - # - test_geth_http_websocket - # - test_geth_mox - - credo: - requires: - - build - - deploy_aws: - filters: - branches: - only: - - production - - staging - - /deploy-[A-Za-z0-9]+$/ - requires: - - check_formatted - - credo - - eslint - - jest - - sobelow - # - test_nethermind_http_websocket - - test_nethermind_mox - # - test_geth_http_websocket - # - test_geth_mox - - dialyzer: - requires: - - build - - eslint: - requires: - - build - - gettext: - requires: - - build - - jest: - requires: - - build - - release: - requires: - - build - - sobelow: - requires: - - build - # - test_nethermind_http_websocket: - # requires: - # - build - - test_nethermind_mox: - requires: - - build - # - test_geth_http_websocket: - # requires: - # - build - # - test_geth_mox: - # requires: - # - build diff --git a/.credo.exs b/.credo.exs index 444b59323dc9..2649b6cafc4a 100644 --- a/.credo.exs +++ b/.credo.exs @@ -30,6 +30,10 @@ ] }, # + # Load and configure plugins here: + # + plugins: [], + # # If you create your own checks, you must specify the source files for # them here, so they can be loaded by Credo before running the analysis. # @@ -40,6 +44,10 @@ # strict: true, # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # # If you want to use uncolored output by default, you can change `color` # to `false` below: # @@ -52,100 +60,166 @@ # # {Credo.Check.Design.DuplicatedCode, false} # - checks: [ - # outdated by formatter in Elixir 1.6. See https://github.com/rrrene/credo/issues/505 - {Credo.Check.Consistency.LineEndings, false}, - {Credo.Check.Consistency.SpaceAroundOperators, false}, - {Credo.Check.Consistency.SpaceInParentheses, false}, - {Credo.Check.Consistency.TabsOrSpaces, false}, - {Credo.Check.Readability.LargeNumbers, false}, - {Credo.Check.Readability.MaxLineLength, false}, - {Credo.Check.Readability.ParenthesesInCondition, false}, - {Credo.Check.Readability.RedundantBlankLines, false}, - {Credo.Check.Readability.Semicolons, false}, - {Credo.Check.Readability.SpaceAfterCommas, false}, - {Credo.Check.Readability.TrailingBlankLine, false}, - {Credo.Check.Readability.TrailingWhiteSpace, false}, + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, - # outdated by lazy Logger in Elixir 1.7. See https://elixir-lang.org/blog/2018/07/25/elixir-v1-7-0-released/ - {Credo.Check.Warning.LazyLogging, false}, + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [ + excluded_namespaces: ~w(Block Blocks Import Runner Socket SpandexDatadog Task Schemas), + excluded_lastnames: + ~w(Address DateTime Exporter Fetcher Full Instrumenter Logger Monitor Name Number Repo Spec Time Unit), + priority: :low, + if_nested_deeper_than: 2, + if_called_more_often_than: 0 + ]}, + {Credo.Check.Design.DuplicatedCode, excluded_macros: [], mass_threshold: 800}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 0]}, - # not handled by formatter - {Credo.Check.Consistency.ExceptionNames}, - {Credo.Check.Consistency.ParameterPatternMatching}, + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, - # You can customize the priority of any check - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, - excluded_namespaces: ~w(Block Blocks Import Runner Socket SpandexDatadog Task), - excluded_lastnames: - ~w(Address DateTime Exporter Fetcher Full Instrumenter Logger Monitor Name Number Repo Spec Time Unit), - priority: :low}, + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, - # For some checks, you can also set other parameters - # - # If you don't want the `setup` and `test` macro calls in ExUnit tests - # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just - # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. - # - {Credo.Check.Design.DuplicatedCode, excluded_macros: [], mass_threshold: 80}, + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.StructFieldAmount, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedMapOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFilename, []}, + {Utils.Credo.Checks.CompileEnvUsage} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, exit_status: 0}, - {Credo.Check.Design.TagFIXME}, - {Credo.Check.Readability.FunctionNames}, - {Credo.Check.Readability.ModuleAttributeNames}, - {Credo.Check.Readability.ModuleDoc}, - {Credo.Check.Readability.ModuleNames}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, - {Credo.Check.Readability.PredicateFunctionNames}, - {Credo.Check.Readability.PreferImplicitTry}, - {Credo.Check.Readability.StringSigils}, - {Credo.Check.Readability.VariableNames}, - {Credo.Check.Refactor.DoubleBooleanNegation}, - {Credo.Check.Refactor.CondStatements}, - {Credo.Check.Refactor.CyclomaticComplexity}, - {Credo.Check.Refactor.FunctionArity}, - {Credo.Check.Refactor.LongQuoteBlocks}, - {Credo.Check.Refactor.MatchInCondition}, - {Credo.Check.Refactor.NegatedConditionsInUnless}, - {Credo.Check.Refactor.NegatedConditionsWithElse}, - {Credo.Check.Refactor.Nesting}, - {Credo.Check.Refactor.PipeChainStart}, - {Credo.Check.Refactor.UnlessWithElse}, - {Credo.Check.Warning.BoolOperationOnSameValues}, - {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, - {Credo.Check.Warning.IExPry}, - {Credo.Check.Warning.IoInspect}, - {Credo.Check.Warning.OperationOnSameValues}, - {Credo.Check.Warning.OperationWithConstantResult}, - {Credo.Check.Warning.UnusedEnumOperation}, - {Credo.Check.Warning.UnusedFileOperation}, - {Credo.Check.Warning.UnusedKeywordOperation}, - {Credo.Check.Warning.UnusedListOperation}, - {Credo.Check.Warning.UnusedPathOperation}, - {Credo.Check.Warning.UnusedRegexOperation}, - {Credo.Check.Warning.UnusedStringOperation}, - {Credo.Check.Warning.UnusedTupleOperation}, - {Credo.Check.Warning.RaiseInsideRescue}, + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.CondInsteadOfIfElse, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + # {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]} - # Controversial and experimental checks (opt-in, just remove `, false`) - # - # TODO reenable before merging optimized-indexer branch - {Credo.Check.Refactor.ABCSize, false}, - {Credo.Check.Refactor.AppendSingleItem}, - {Credo.Check.Refactor.VariableRebinding}, - {Credo.Check.Warning.MapGetUnsafePass}, - {Credo.Check.Consistency.MultiAliasImportRequireUse}, + # {Credo.Check.Refactor.MapInto, []}, - # Custom checks can be created using `mix credo.gen.check`. - {Utils.Credo.Checks.CompileEnvUsage} - # - ] + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } } ] } diff --git a/.devcontainer/.blockscout_config.example b/.devcontainer/.blockscout_config.example index 737933b067b6..209cb7db8592 100644 --- a/.devcontainer/.blockscout_config.example +++ b/.devcontainer/.blockscout_config.example @@ -30,7 +30,7 @@ INDEXER_EMPTY_BLOCKS_SANITIZER_BATCH_SIZE=1 INDEXER_BLOCK_REWARD_BATCH_SIZE=1 INDEXER_RECEIPTS_BATCH_SIZE=10 INDEXER_COIN_BALANCES_BATCH_SIZE=1 -INDEXER_TOKEN_BALANCES_BATCH_SIZE=1 +INDEXER_ARCHIVAL_TOKEN_BALANCES_BATCH_SIZE=1 INDEXER_CATCHUP_BLOCKS_CONCURRENCY=1 MIGRATION_TOKEN_INSTANCE_OWNER_BATCH_SIZE=1 @@ -39,7 +39,7 @@ INDEXER_BLOCK_REWARD_CONCURRENCY=1 INDEXER_RECEIPTS_CONCURRENCY=1 INDEXER_COIN_BALANCES_CONCURRENCY=1 INDEXER_TOKEN_CONCURRENCY=1 -INDEXER_TOKEN_BALANCES_CONCURRENCY=1 +INDEXER_ARCHIVAL_TOKEN_BALANCES_CONCURRENCY=1 INDEXER_TOKEN_INSTANCE_RETRY_CONCURRENCY=1 INDEXER_TOKEN_INSTANCE_REALTIME_CONCURRENCY=1 INDEXER_TOKEN_INSTANCE_SANITIZE_CONCURRENCY=1 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 98f0627b5d8f..324a6d2eb818 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ # Since this is a copy of https://github.com/blockscout/devcontainer-elixir/blob/main/Dockerfile # So after successful testing this file, the original one must be updated as well. -ARG VARIANT="1.17.3-erlang-27.1-debian-bullseye-20240926" +ARG VARIANT="1.19.4-erlang-27.3.4.6-debian-bookworm-20251117" FROM hexpm/elixir:${VARIANT} # ARGs declared before FROM are not persisted beyond the FROM instruction. diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b02f2981ad5f..b0126db4cc9d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,6 +1,6 @@ services: elixir: - image: ghcr.io/blockscout/devcontainer-elixir:1.17.3-erlang-27.1 + image: ghcr.io/blockscout/devcontainer-elixir:1.19.4-erlang-27.3.4.6 # Uncomment next lines to use test Dockerfile with new Elixir version # build: diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 9fe80cb6f9a9..170fe3dbb0f2 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -1,16 +1,13 @@ [ - {"lib/ethereum_jsonrpc/rolling_window.ex", :improper_list_constr, 171}, + {"lib/ethereum_jsonrpc/rolling_window.ex", :improper_list_constr}, {"lib/explorer/smart_contract/solidity/publisher_worker.ex", :pattern_match, 1}, - {"lib/explorer/smart_contract/solidity/publisher_worker.ex", :exact_eq, 8}, - {"lib/explorer/smart_contract/solidity/publisher_worker.ex", :pattern_match, 8}, + {"lib/explorer/smart_contract/solidity/publisher_worker.ex", :exact_eq, 9}, + {"lib/explorer/smart_contract/solidity/publisher_worker.ex", :pattern_match, 9}, {"lib/explorer/smart_contract/vyper/publisher_worker.ex", :pattern_match, 1}, - {"lib/explorer/smart_contract/vyper/publisher_worker.ex", :exact_eq, 8}, - {"lib/explorer/smart_contract/vyper/publisher_worker.ex", :pattern_match, 8}, + {"lib/explorer/smart_contract/vyper/publisher_worker.ex", :exact_eq, 9}, + {"lib/explorer/smart_contract/vyper/publisher_worker.ex", :pattern_match, 9}, {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 1}, - {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :exact_eq, 14}, - {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 14}, - ~r/lib\/phoenix\/router.ex/, - {"lib/explorer/chain/search.ex", :pattern_match, 80}, - {"lib/explorer/chain/search.ex", :pattern_match, 227}, - {"lib/explorer/chain/search.ex", :pattern_match, 322} + {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :exact_eq, 15}, + {"lib/explorer/smart_contract/stylus/publisher_worker.ex", :pattern_match, 15}, + ~r/lib\/phoenix\/router.ex/ ] diff --git a/.formatter.exs b/.formatter.exs index 69ae0d25d2c6..563ddfd8e03a 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -7,5 +7,6 @@ "mix.exs", "{config}/**/*.{ex,exs}" ], - line_length: 120 + line_length: 120, + import_deps: [:open_api_spex] ] diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 96% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md index b16fd67570a4..326a268c91cb 100644 --- a/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -17,6 +17,7 @@ We welcome contributions that enhance the project and improve the overall qualit ## General +* Keep `.dialyzer-ignore` as small as possible. Only add entries to suppress false positives that cannot be resolved by fixing the underlying type issue. Every new suppression should include a comment explaining why it is necessary and cannot be fixed properly. * Commits should be one logical change that still allows all tests to pass. Prefer smaller commits if there could be two levels of logic grouping. The goal is to allow contributors in the future (including your own future self) to determine your reasoning for making changes and to allow them to cherry-pick, patch or port those changes in isolation to other branches or forks. * If during your PR you reveal a pre-existing bug: 1. Try to isolate the bug and fix it on an independent branch and PR it first. @@ -76,6 +77,7 @@ When contributing to the codebase, please adhere to the following naming convent - Instead of "tx" or "txn", use "transaction". - Instead of "txs", use "transactions". - Instead of "tx_hash" or "txn_hash", use "transaction_hash". + - Instead of "address", use "address_hash". - Instead of "block_num", use "block_number". - Ensure that variable names are descriptive and convey the purpose or content clearly. - Consistent naming helps in maintaining readability and understanding of the code, especially for new contributors. @@ -179,8 +181,8 @@ runtime approaches: ```elixir scope "/v2", as: :api_v2 do - chain_scope :polygon_zkevm do - get("/zkevm-batch/:batch_number", V2.TransactionController, :polygon_zkevm_batch) + chain_scope :zksync do + get("/zksync-batch/:batch_number", V2.TransactionController, :zksync_batch) end end ``` diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..15ab63d31d24 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ +_[GitHub keywords to close any associated issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/closing-issues-using-keywords)_ + +## Motivation + +_Why we should merge these changes. If using GitHub keywords to close [issues](https://github.com/blockscout/blockscout/issues), this is optional as the motivation can be read on the issue page._ + +## Changelog + +### Enhancements + +_Things you added that don't break anything. Regression tests for Bug Fixes count as Enhancements._ + +### Bug Fixes + +_Things you changed that fix bugs. If it fixes a bug but, in so doing, adds a new requirement, removes code, or requires a database reset and reindex, the breaking part of the change should also be added to "Incompatible Changes" below._ + +### Incompatible Changes + +_Things you broke while doing Enhancements and Bug Fixes. Breaking changes include (1) adding new requirements and (2) removing code. Renaming counts as (2) because a rename is a removal followed by an add._ + +## Upgrading + +_If you have any Incompatible Changes in the above Changelog, outline how users of prior versions can upgrade once this PR lands or when reviewers are testing locally. A common upgrading step is "Database reset and re-index required"._ + +## Checklist for your Pull Request (PR) + +- [ ] I verified this PR does not break any public APIs, contracts, or interfaces that external consumers depend on. +- [ ] If I added new functionality, I added tests covering it. +- [ ] If I fixed a bug, I added a regression test to prevent the bug from silently reappearing again. +- [ ] I updated documentation if needed: + - [ ] General docs: submitted PR to [docs repository](https://github.com/blockscout/docs). + - [ ] ENV vars: updated [env vars list](https://github.com/blockscout/docs/tree/main/setup/env-variables) and set version parameter to `master`. + - [ ] Deprecated vars: added to [deprecated env vars list](https://github.com/blockscout/docs/tree/main/setup/env-variables/deprecated-env-variables). +- [ ] If I modified API endpoints, I updated the Swagger/OpenAPI schemas accordingly and checked that schemas are asserted in tests, and highlighted the change in the PR description. +- [ ] If I added new DB indices, I checked, that they are not redundant, with PGHero or other tools. +- [ ] If I added/removed chain type, I modified the Github CI matrix and PR labels accordingly. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7ff4b3ed8af5..90b61deae2b8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,18 +6,18 @@ updates: schedule: interval: "weekly" - - package-ecosystem: "npm" - directory: "/apps/block_scout_web/assets" - open-pull-requests-limit: 10 - schedule: - interval: "monthly" - ignore: - - dependency-name: "bootstrap" - - dependency-name: "web3" - versions: ["4.x"] + # - package-ecosystem: "npm" + # directory: "/apps/block_scout_web/assets" + # open-pull-requests-limit: 10 + # schedule: + # interval: "monthly" + # ignore: + # - dependency-name: "bootstrap" + # - dependency-name: "web3" + # versions: ["4.x"] - - package-ecosystem: "npm" - directory: "/apps/explorer" - open-pull-requests-limit: 10 - schedule: - interval: "monthly" + # - package-ecosystem: "npm" + # directory: "/apps/explorer" + # open-pull-requests-limit: 10 + # schedule: + # interval: "monthly" diff --git a/.github/workflows/close-issues-on-dev-merge.yml b/.github/workflows/close-issues-on-dev-merge.yml new file mode 100644 index 000000000000..4124c3a92c3e --- /dev/null +++ b/.github/workflows/close-issues-on-dev-merge.yml @@ -0,0 +1,145 @@ +name: Close issues on dev merge + +on: + pull_request: + types: [closed] + branches: [dev] + +permissions: + issues: write + pull-requests: read + +jobs: + close-linked-issues: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Close issues linked via closing keywords + uses: actions/github-script@v7 + with: + script: | + const SINGLE_ISSUE_REF = + "(?:[\\w.-]+\\/[\\w.-]+)?#\\d+" + + "|https?:\\/\\/github\\.com\\/[\\w.-]+\\/[\\w.-]+\\/issues\\/\\d+"; + + const ISSUE_REF_PATTERN = new RegExp( + `(?:^|[\\s(])(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\\s+((?:${SINGLE_ISSUE_REF})(?:\\s*(?:,\\s*|\\s+and\\s+)(?:${SINGLE_ISSUE_REF}))*)`, + "gi" + ); + + const HASH_REF_PATTERN = /(?:(?[\w.-]+\/[\w.-]+))?#(?\d+)/g; + const URL_REF_PATTERN = + /https?:\/\/github\.com\/(?[\w.-]+)\/(?[\w.-]+)\/issues\/(?\d+)/gi; + + const addIssueFromRef = (issueNumbers, { refRepo, number }, { owner, repo }) => { + if (refRepo && refRepo.toLowerCase() !== `${owner}/${repo}`.toLowerCase()) { + return; + } + + issueNumbers.add(Number(number)); + }; + + const parseIssueNumbers = (text, { owner, repo }) => { + if (!text) return []; + + const issueNumbers = new Set(); + let match; + + ISSUE_REF_PATTERN.lastIndex = 0; + + while ((match = ISSUE_REF_PATTERN.exec(text)) !== null) { + const refs = match[1]; + let refMatch; + + HASH_REF_PATTERN.lastIndex = 0; + + while ((refMatch = HASH_REF_PATTERN.exec(refs)) !== null) { + addIssueFromRef(issueNumbers, refMatch.groups, { owner, repo }); + } + + URL_REF_PATTERN.lastIndex = 0; + + while ((refMatch = URL_REF_PATTERN.exec(refs)) !== null) { + const { owner: refOwner, repoName, number } = refMatch.groups; + const refRepo = `${refOwner}/${repoName}`; + + addIssueFromRef(issueNumbers, { refRepo, number }, { owner, repo }); + } + } + + return [...issueNumbers]; + }; + + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const pr = context.payload.pull_request; + + const commits = await github.paginate( + github.rest.pulls.listCommits, + { owner, repo, pull_number } + ); + + const commitMessages = commits + .map((commit) => commit.commit.message) + .join("\n"); + + const texts = [pr.title, pr.body, commitMessages].filter(Boolean); + const repoContext = { owner, repo }; + const issueNumbers = [ + ...new Set( + texts.flatMap((text) => parseIssueNumbers(text, repoContext)) + ), + ]; + + if (issueNumbers.length === 0) { + core.info("No issues to close (no closing keywords found)."); + return; + } + + core.info(`Closing issues: ${issueNumbers.join(", ")}`); + + for (const issue_number of issueNumbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number, + }); + + if (issue.state === "closed") { + core.info(`Issue #${issue_number} is already closed.`); + continue; + } + + if (issue.pull_request) { + core.info(`Skipping #${issue_number}: not an issue.`); + continue; + } + + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed", + state_reason: "completed", + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: + `Closed by merge of #${pull_number} into \`dev\`.\n\n` + + `GitHub closes linked issues automatically only when merging into the default branch. ` + + `This comment was added by the [\`close-issues-on-dev-merge\`](${context.serverUrl}/${owner}/${repo}/actions/workflows/close-issues-on-dev-merge.yml) workflow.`, + }); + + core.info(`Closed issue #${issue_number}.`); + } catch (error) { + if (error.status === 404) { + core.warning(`Issue #${issue_number} not found in ${owner}/${repo}.`); + } else { + core.setFailed(`Failed to close issue #${issue_number}: ${error.message}`); + } + } + } diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8a4cf36f5bb4..c437d1dcdf2f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,11 +38,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 89fb2de8531d..df10af8071ba 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -1,39 +1,26 @@ -name: Blockscout +name: Blockscout main CI on: push: branches: - master - - production-arbitrum - - production-core - - production-eth-sepolia - - production-filecoin - - production-fuse - - production-optimism - - production-immutable - - production-iota - - production-lukso - - production-rsk - - production-sokol - - production-suave - - production-xdai - - production-zkevm - - production-zksync - - staging-l2 + - dev paths-ignore: - "CHANGELOG.md" - "**/README.md" - "docker/*" - "docker-compose/*" + workflow_dispatch: pull_request: types: [opened, synchronize, reopened, labeled] branches: - master + - dev env: MIX_ENV: test - OTP_VERSION: ${{ github.ref_name == '10284/merge' && '27.1' || vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ github.ref_name == '10284/merge' && '1.17.3' || vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' ACCOUNT_AUTH0_DOMAIN: "blockscoutcom.us.auth0.com" jobs: @@ -47,18 +34,18 @@ jobs: run: | echo "matrix=$(node -e ' - // Add/remove CI matrix chain types here const defaultChainTypes = ["default"]; + // Add/remove CI matrix chain types here const chainTypes = [ + "default", "arbitrum", - "berachain", + "arc", "blackfort", - "celo", "ethereum", "filecoin", "optimism", - "polygon_zkevm", + "optimism-celo", "rsk", "scroll", "shibarium", @@ -69,23 +56,43 @@ jobs: "neon" ]; - const extraChainTypes = ["suave", "polygon_edge"]; - - // Chain type matrix we use in master branch - const allChainTypes = [].concat(defaultChainTypes, chainTypes, extraChainTypes); + // Add/remove CI matrix chain types for "ci:core" label here + const coreChainTypes = [ + "default", + "ethereum", + "optimism", + "optimism-celo" + ]; const labels = ${{ github.event_name == 'pull_request' && toJson(github.event.pull_request.labels.*.name) || '[]' }}; const ciLabels = labels.filter(label => label.startsWith("ci:")); - const labeledChainTypes = [].concat( - defaultChainTypes.filter(chainType => ciLabels.includes("ci:" + chainType)), - chainTypes.filter(chainType => ciLabels.includes("ci:all") || ciLabels.includes("ci:" + chainType)), - extraChainTypes.filter(chainType => ciLabels.includes("ci:" + chainType)) + const labeledChainTypes = chainTypes.filter(chainType => + ciLabels.includes("ci:all") || + ciLabels.includes("ci:core") && coreChainTypes.includes(chainType) || + ciLabels.includes("ci:" + chainType) ); - // Chain type matrix we use in PRs to master branch + // Chain type matrix we use in PRs to master/dev branches const ciChainTypes = labeledChainTypes.length > 0 ? labeledChainTypes : defaultChainTypes; - const matrix = { "chain-type": ${{ github.event_name == 'pull_request' && 'ciChainTypes' || 'allChainTypes' }} }; + // Check for bridged tokens label + const hasBridgedTokensLabel = ciLabels.includes("ci:bridged-tokens"); + + // Create matrix combinations + const targetChainTypes = ${{ github.event_name == 'pull_request' && 'ciChainTypes' || 'chainTypes' }}; + const bridgedTokensConfigs = hasBridgedTokensLabel ? [false, true] : [false]; + + const matrixIncludes = []; + for (const chainType of targetChainTypes) { + for (const bridgedTokens of bridgedTokensConfigs) { + matrixIncludes.push({ + "chain-type": chainType, + "bridged-tokens": bridgedTokens + }); + } + } + + const matrix = { "include": matrixIncludes }; console.log(JSON.stringify(matrix)); ')" >> $GITHUB_OUTPUT @@ -93,7 +100,7 @@ jobs: name: Build and Cache deps runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -124,6 +131,7 @@ jobs: run: | mix local.hex --force mix local.rebar --force + mix deps.clean --all mix deps.get mix deps.compile --skip-umbrella-children @@ -160,7 +168,7 @@ jobs: runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -187,7 +195,7 @@ jobs: runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -219,7 +227,7 @@ jobs: - build-and-cache - matrix-builder steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -244,9 +252,9 @@ jobs: id: dialyzer-cache with: path: priv/plts - key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-${{ matrix.chain-type }}-dialyzer-mixlockhash-${{ hashFiles('mix.lock') }} + key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-${{ matrix.chain-type }}-${{ matrix.bridged-tokens }}-dialyzer-mixlockhash-${{ hashFiles('mix.lock') }} restore-keys: | - ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-${{ matrix.chain-type }}-dialyzer-mixlockhash- + ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-${{ matrix.chain-type }}-${{ matrix.bridged-tokens }}-dialyzer-mixlockhash- - name: Conditionally build Dialyzer Cache if: steps.dialyzer-cache.output.cache-hit != 'true' @@ -255,18 +263,20 @@ jobs: mix dialyzer --plt env: CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} + BRIDGED_TOKENS_ENABLED: ${{ matrix.bridged-tokens }} - name: Run Dialyzer run: mix dialyzer --halt-exit-status env: CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} + BRIDGED_TOKENS_ENABLED: ${{ matrix.bridged-tokens }} gettext: name: Missing translation keys check runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -290,12 +300,13 @@ jobs: mix gettext.extract --merge | tee stdout.txt grep "Wrote priv/gettext/en/LC_MESSAGES/default.po (0 new messages, 0 removed, " stdout.txt working-directory: "apps/block_scout_web" + sobelow: name: Sobelow security analysis runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -327,7 +338,7 @@ jobs: runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -376,7 +387,7 @@ jobs: runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -420,12 +431,13 @@ jobs: - run: ./node_modules/.bin/eslint --format=junit --output-file="test/eslint/junit.xml" js/** working-directory: apps/block_scout_web/assets + jest: name: JS Tests runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -466,7 +478,7 @@ jobs: runs-on: ubuntu-latest needs: build-and-cache steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -518,7 +530,7 @@ jobs: # Maps tcp port 5432 on service container to the host - 5432:5432 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -552,6 +564,8 @@ jobs: ETHEREUM_JSONRPC_CASE: "EthereumJSONRPC.Case.Nethermind.Mox" ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Mox" CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} + BRIDGED_TOKENS_ENABLED: ${{ matrix.bridged-tokens }} + test_nethermind_mox_explorer: strategy: fail-fast: false @@ -581,7 +595,7 @@ jobs: # Maps tcp port 5432 on service container to the host - 5432:5432 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -627,6 +641,8 @@ jobs: ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Mox" CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} WETH_TOKEN_TRANSFERS_FILTERING_ENABLED: "true" + BRIDGED_TOKENS_ENABLED: ${{ matrix.bridged-tokens }} + test_nethermind_mox_indexer: strategy: fail-fast: false @@ -656,7 +672,7 @@ jobs: # Maps tcp port 5432 on service container to the host - 5432:5432 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -694,6 +710,8 @@ jobs: ETHEREUM_JSONRPC_WEB_SOCKET_CASE: "EthereumJSONRPC.WebSocket.Case.Mox" CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} WETH_TOKEN_TRANSFERS_FILTERING_ENABLED: "true" + BRIDGED_TOKENS_ENABLED: ${{ matrix.bridged-tokens }} + test_nethermind_mox_block_scout_web: strategy: fail-fast: false @@ -728,7 +746,7 @@ jobs: # Maps tcp port 5432 on service container to the host - 5432:5432 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{ env.OTP_VERSION }} @@ -795,3 +813,5 @@ jobs: SOURCIFY_INTEGRATION_ENABLED: "true" CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} WETH_TOKEN_TRANSFERS_FILTERING_ENABLED: "true" + BRIDGED_TOKENS_ENABLED: ${{ matrix.bridged-tokens }} + DISABLE_WEBAPP: "false" diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml new file mode 100644 index 000000000000..828444da5712 --- /dev/null +++ b/.github/workflows/generate-swagger.yml @@ -0,0 +1,291 @@ +name: Generate OpenAPI Specs + +on: + push: + branches: + - master + - dev + paths-ignore: + - "CHANGELOG.md" + - "**/README.md" + - "docker/*" + - "docker-compose/*" + workflow_dispatch: + release: + types: [published] + +env: + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' + RELEASE_VERSION: 11.2.2 + +jobs: + matrix-builder: + name: Build matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - id: set-matrix + run: | + echo "matrix=$(node -e ' + + // Add/remove CI matrix chain types here + const chainTypes = [ + "default", + "arbitrum", + "arc", + "blackfort", + "ethereum", + "filecoin", + "neon", + "optimism", + "optimism-celo", + "rsk", + "scroll", + "shibarium", + "stability", + "suave", + "zetachain", + "zilliqa", + "zksync" + ]; + + const matrix = { "chain-type": ${{ 'chainTypes' }} }; + console.log(JSON.stringify(matrix)); + ')" >> $GITHUB_OUTPUT + + build-and-cache: + name: Build and Cache deps + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION }} + hexpm-mirrors: | + https://builds.hex.pm + https://cdn.jsdelivr.net/hex + + - name: "ELIXIR_VERSION.lock" + run: echo "${ELIXIR_VERSION}" > ELIXIR_VERSION.lock + + - name: "OTP_VERSION.lock" + run: echo "${OTP_VERSION}" > OTP_VERSION.lock + + - name: Restore Mix Deps Cache + uses: actions/cache@v4 + id: deps-cache + with: + path: | + deps + _build + key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash- + + - name: Conditionally build Mix deps cache + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + mix deps.compile --skip-umbrella-children + + generate-swagger: + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix-builder.outputs.matrix) }} + name: Generate Open API spec + runs-on: ubuntu-latest + needs: + - build-and-cache + - matrix-builder + steps: + - uses: actions/checkout@v5 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION }} + hexpm-mirrors: | + https://builds.hex.pm + https://cdn.jsdelivr.net/hex + + - name: Mix Deps Cache + uses: actions/cache/restore@v4 + id: deps-cache + with: + path: | + deps + _build + key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash- + + - name: mix openapi.spec.yaml + run: | + mix openapi.spec.yaml --spec BlockScoutWeb.Specs.Public openapi.${{ matrix.chain-type }}.yaml --start-app=false + env: + CHAIN_TYPE: ${{ matrix.chain-type != 'default' && matrix.chain-type || '' }} + MUD_INDEXER_ENABLED: false + + - name: Generate MUD-enabled spec for Optimism + if: matrix.chain-type == 'optimism' + run: | + mix openapi.spec.yaml --spec BlockScoutWeb.Specs.Public openapi.mud.yaml --start-app=false + env: + CHAIN_TYPE: optimism + MUD_INDEXER_ENABLED: true + + - name: Upload OpenAPI spec + uses: actions/upload-artifact@v4 + with: + name: openapi-spec-${{ matrix.chain-type }} + path: openapi.${{ matrix.chain-type }}.yaml + retention-days: 1 + + - name: Upload MUD-enabled spec + if: matrix.chain-type == 'optimism' + uses: actions/upload-artifact@v4 + with: + name: openapi-spec-mud + path: openapi.mud.yaml + retention-days: 1 + + push-specs: + needs: + - generate-swagger + - matrix-builder + runs-on: ubuntu-latest + name: Push all OpenAPI specs + steps: + - name: Validate required secrets + run: | + if [ -z "${{ secrets.API_SPECS_PAT }}" ]; then + echo "Error: API_SPECS_PAT secret is not set" + exit 1 + fi + + - name: Checkout specs repository + uses: actions/checkout@v5 + with: + repository: ${{ vars.API_SPECS_REPOSITORY }} + token: ${{ secrets.API_SPECS_PAT }} + path: api-specs + + - name: Download all swagger specs + uses: actions/download-artifact@v5 + with: + pattern: openapi-spec-* + merge-multiple: true + path: temp-specs + + - name: Merge all OpenAPI specs into all-in-one spec + run: | + npm install js-yaml + cat > merge-specs.js << 'SCRIPT_EOF' + const fs = require('fs'); + const path = require('path'); + const yaml = require('js-yaml'); + + const specDir = './temp-specs'; + const outputFile = path.join(specDir, 'openapi.all.yaml'); + + const files = fs.readdirSync(specDir) + .filter(f => f.endsWith('.yaml')) + .sort(); + + let merged = null; + + for (const file of files) { + const content = yaml.load(fs.readFileSync(path.join(specDir, file), 'utf8')); + if (!merged) { + merged = JSON.parse(JSON.stringify(content)); + continue; + } + // Union merge paths (first definition wins for duplicates) + if (content.paths) { + merged.paths = merged.paths || {}; + for (const [p, def] of Object.entries(content.paths)) { + if (!merged.paths[p]) { + merged.paths[p] = def; + } + } + } + // Union merge components (first definition wins for duplicates) + if (content.components) { + merged.components = merged.components || {}; + for (const [section, defs] of Object.entries(content.components)) { + if (!merged.components[section]) { + merged.components[section] = defs; + } else { + for (const [name, def] of Object.entries(defs)) { + if (!merged.components[section][name]) { + merged.components[section][name] = def; + } + } + } + } + } + // Union merge tags (by name) + if (content.tags) { + merged.tags = merged.tags || []; + const existingTagNames = new Set(merged.tags.map(t => t.name)); + for (const tag of content.tags) { + if (!existingTagNames.has(tag.name)) { + merged.tags.push(tag); + existingTagNames.add(tag.name); + } + } + } + } + + fs.writeFileSync(outputFile, yaml.dump(merged, { lineWidth: -1 })); + console.log(`Merged ${files.length} specs into ${outputFile}`); + SCRIPT_EOF + node merge-specs.js + + - name: Create specs directory structure + run: | + VERSION=${{ github.event_name == 'release' && env.RELEASE_VERSION || (github.ref_name == 'dev' && 'dev' || 'master') }} + + for SPEC_FILE in temp-specs/*; do + if [ -f "$SPEC_FILE" ]; then + FILENAME=$(basename "$SPEC_FILE") + + # Handle MUD spec specially + if [ "$FILENAME" = "openapi.mud.yaml" ]; then + mkdir -p "api-specs/blockscout/${VERSION}/mud" + cp "$SPEC_FILE" "api-specs/blockscout/${VERSION}/mud/swagger.yaml" + else + # Extract chain type from filename (openapi.CHAINTYPE.yaml) + CHAIN_TYPE=$(echo "$FILENAME" | sed 's/openapi\.\(.*\)\.yaml/\1/') + mkdir -p "api-specs/blockscout/${VERSION}/${CHAIN_TYPE}" + cp "$SPEC_FILE" "api-specs/blockscout/${VERSION}/${CHAIN_TYPE}/swagger.yaml" + fi + fi + done + + + - name: Commit and push changes + working-directory: api-specs + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + git add . + + # Only commit if there are changes + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "[SKIP-GH-PAGES] create OpenAPI specs for Blockscout ${{ github.event_name == 'release' && env.RELEASE_VERSION || github.sha }}" + git push + fi + + - name: Clean up + if: always() + run: | + rm -rf temp-specs + rm -rf api-specs diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 000000000000..06362683d3a7 --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,15 @@ +name: PR Conventional Commit Validation + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + validate-pr-title: + runs-on: ubuntu-latest + steps: + - name: PR Conventional Commit Validation + uses: ytanikin/pr-conventional-commits@b72758283dcbee706975950e96bc4bf323a8d8c0 + with: + task_types: '["feat","fix","chore","perf","refactor","docs","doc"]' + add_label: 'false' diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index 6c31b17aa4c0..57465e60939b 100644 --- a/.github/workflows/pre-release-arbitrum.yml +++ b/.github/workflows/pre-release-arbitrum.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-arbitrum:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-arbitrum-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-arbitrum:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-arbitrum-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-celo.yml b/.github/workflows/pre-release-celo.yml index 9edc3fc1779e..7ac7bf707ee3 100644 --- a/.github/workflows/pre-release-celo.yml +++ b/.github/workflows/pre-release-celo.yml @@ -5,21 +5,26 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 API_GRAPHQL_MAX_COMPLEXITY: 10400 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -35,7 +40,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-celo:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-celo-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -43,7 +48,7 @@ jobs: build-args: | BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=celo + CHAIN_TYPE=optimism-celo - name: Build and push Docker image for CELO (indexer) uses: docker/build-push-action@v6 @@ -51,7 +56,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-celo:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-celo-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -60,4 +65,4 @@ jobs: DISABLE_API=true BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=celo + CHAIN_TYPE=optimism-celo diff --git a/.github/workflows/pre-release-eth.yml b/.github/workflows/pre-release-eth.yml index 6d1c68016c05..20ea0a704cb5 100644 --- a/.github/workflows/pre-release-eth.yml +++ b/.github/workflows/pre-release-eth.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-ethereum:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-ethereum-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-ethereum:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-ethereum-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-filecoin.yml b/.github/workflows/pre-release-filecoin.yml index e81fd2725326..8b51021a198d 100644 --- a/.github/workflows/pre-release-filecoin.yml +++ b/.github/workflows/pre-release-filecoin.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-filecoin:latest, ghcr.io/blockscout/blockscout-filecoin:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-filecoin-private:latest, ghcr.io/blockscout/blockscout-filecoin-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-filecoin:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-filecoin-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-fuse.yml b/.github/workflows/pre-release-fuse.yml index 54e957d6a7ae..786bf4d56cb2 100644 --- a/.github/workflows/pre-release-fuse.yml +++ b/.github/workflows/pre-release-fuse.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-fuse:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-fuse-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-fuse:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-fuse-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-berachain.yml b/.github/workflows/pre-release-gnosis.yml similarity index 63% rename from .github/workflows/pre-release-berachain.yml rename to .github/workflows/pre-release-gnosis.yml index 196201c4bdc7..39d0eeab31e4 100644 --- a/.github/workflows/pre-release-berachain.yml +++ b/.github/workflows/pre-release-gnosis.yml @@ -1,24 +1,29 @@ -name: Pre-release for Berachain +name: Pre-release for Gnosis Chain on: workflow_dispatch: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -28,13 +33,13 @@ jobs: docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - name: Build and push Docker image for Ethereum (indexer + API) + - name: Build and push Docker image for Gnosis Chain (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-berachain:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-xdai-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -42,15 +47,16 @@ jobs: build-args: | BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=berachain + BRIDGED_TOKENS_ENABLED=true + CHAIN_TYPE=ethereum - - name: Build and push Docker image for Ethereum (indexer) + - name: Build and push Docker image for Gnosis Chain (indexer) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-berachain:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-xdai-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -59,4 +65,5 @@ jobs: DISABLE_API=true BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=berachain + BRIDGED_TOKENS_ENABLED=true + CHAIN_TYPE=ethereum diff --git a/.github/workflows/pre-release-optimism.yml b/.github/workflows/pre-release-optimism.yml index 4925acd2c295..5bc848df4565 100644 --- a/.github/workflows/pre-release-optimism.yml +++ b/.github/workflows/pre-release-optimism.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-optimism:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-optimism-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-optimism:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-optimism-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-polygon-zkevm.yml b/.github/workflows/pre-release-polygon-zkevm.yml deleted file mode 100644 index 90cab9635343..000000000000 --- a/.github/workflows/pre-release-polygon-zkevm.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Pre-release for Polygon ZkEVM - -on: - workflow_dispatch: - inputs: - number: - type: number - required: true - -env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image for Polygon ZkEVM (indexer + API) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-zkevm:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=polygon_zkevm - - - name: Build and push Docker image for Polygon ZkEVM (indexer) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-zkevm:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DISABLE_API=true - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=polygon_zkevm diff --git a/.github/workflows/pre-release-rootstock.yml b/.github/workflows/pre-release-rootstock.yml index fc3af1f9c80a..97f227e41fb9 100644 --- a/.github/workflows/pre-release-rootstock.yml +++ b/.github/workflows/pre-release-rootstock.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-rsk:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-rsk-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-rsk:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-rsk-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-scroll.yml b/.github/workflows/pre-release-scroll.yml index 565438cd9ec4..fdf87321fcbd 100644 --- a/.github/workflows/pre-release-scroll.yml +++ b/.github/workflows/pre-release-scroll.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-scroll:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-scroll-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-scroll:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-scroll-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-zilliqa.yml b/.github/workflows/pre-release-zilliqa.yml index 3e95ea0a4967..d97963a5391b 100644 --- a/.github/workflows/pre-release-zilliqa.yml +++ b/.github/workflows/pre-release-zilliqa.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zilliqa:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-zilliqa-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zilliqa:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-zilliqa-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release-zksync.yml b/.github/workflows/pre-release-zksync.yml index 9ad8a1b1499f..266cc0ed6cee 100644 --- a/.github/workflows/pre-release-zksync.yml +++ b/.github/workflows/pre-release-zksync.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,7 +39,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zksync:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + tags: ghcr.io/blockscout/blockscout-zksync-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -50,7 +55,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zksync:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + tags: ghcr.io/blockscout/blockscout-zksync-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 41f1a3992c80..a46348072cec 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -5,20 +5,25 @@ on: inputs: number: type: number + description: Number of pre-release alpha iteration required: true +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,9 +39,9 @@ jobs: context: . file: ./docker/Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:master, ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + cache-to: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache,mode=max + tags: ghcr.io/blockscout/blockscout-private:master, ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -56,9 +61,9 @@ jobs: context: . file: ./docker/Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + cache-to: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache,mode=max + tags: ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}-alpha.${{ inputs.number }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/public-release.yml b/.github/workflows/public-release.yml new file mode 100644 index 000000000000..4ad1c303b603 --- /dev/null +++ b/.github/workflows/public-release.yml @@ -0,0 +1,110 @@ +name: Public release publishing + +on: + workflow_dispatch: + inputs: + release_number: + description: 'Release number (e.g., v9.1.0)' + required: true + type: string + +jobs: + copy-bundle: + runs-on: build + permissions: + contents: read + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - uses: actions/checkout@v5 + - name: Setup repo + uses: ./.github/actions/setup-repo + id: setup + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + docker-remote-multi-platform: true + docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} + docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} + + - name: Set source and target tags + run: | + RELEASE_NUMBER="${{ github.event.inputs.release_number }}" + SOURCE_TAG="${RELEASE_NUMBER}" + TARGET_TAG="${RELEASE_NUMBER}" + echo "SOURCE_TAG=${SOURCE_TAG}" >> $GITHUB_ENV + echo "TARGET_TAG=${TARGET_TAG}" >> $GITHUB_ENV + + - name: Copy chain-specific images from private to public repositories + run: | + RELEASE_NUMBER="${{ github.event.inputs.release_number }}" + + # Array of chain-specific repositories + CHAINS=( + "blockscout" + "blockscout-arbitrum" + "blockscout-celo" + "blockscout-ethereum" + "blockscout-filecoin" + "blockscout-fuse" + "blockscout-xdai" + "blockscout-optimism" + "blockscout-zkevm" + "blockscout-rsk" + "blockscout-scroll" + "blockscout-zetachain" + "blockscout-zilliqa" + "blockscout-zksync" + ) + + # Copy each chain-specific image + for CHAIN in "${CHAINS[@]}"; do + echo "Copying ${CHAIN}..." + + # Copy with the specific release number tag + docker buildx imagetools create \ + --tag ghcr.io/blockscout/${CHAIN}:${RELEASE_NUMBER} \ + ghcr.io/blockscout/${CHAIN}-private:${RELEASE_NUMBER} + + # Also copy with latest tag + docker buildx imagetools create \ + --tag ghcr.io/blockscout/${CHAIN}:latest \ + ghcr.io/blockscout/${CHAIN}-private:latest + + echo "✅ Completed copying ${CHAIN}" + done + + - name: Verify deployment + run: | + RELEASE_NUMBER="${{ github.event.inputs.release_number }}" + echo "🎉 All bundles successfully copied from private to public repositories" + echo "" + echo "🔗 All repositories copied:" + + CHAINS=( + "blockscout" + "blockscout-arbitrum" + "blockscout-celo" + "blockscout-ethereum" + "blockscout-filecoin" + "blockscout-fuse" + "blockscout-xdai" + "blockscout-optimism" + "blockscout-zkevm" + "blockscout-rsk" + "blockscout-scroll" + "blockscout-zetachain" + "blockscout-zilliqa" + "blockscout-zksync" + ) + + for CHAIN in "${CHAINS[@]}"; do + echo " ✅ ${CHAIN}-private -> ${CHAIN} (tags: ${RELEASE_NUMBER}, latest)" + done + + echo "" + echo "🔍 Inspecting main repository images:" + docker buildx imagetools inspect ghcr.io/blockscout/blockscout:${TARGET_TAG} + docker buildx imagetools inspect ghcr.io/blockscout/blockscout:latest diff --git a/.github/workflows/publish-api-types-npm-dev.yml b/.github/workflows/publish-api-types-npm-dev.yml new file mode 100644 index 000000000000..d4303c592a1d --- /dev/null +++ b/.github/workflows/publish-api-types-npm-dev.yml @@ -0,0 +1,25 @@ +name: Publish @blockscout/api-types to npm (dev) +# This workflow is used to publish the dev version (before release) of the @blockscout/api-types package to npm. + +on: + workflow_dispatch: + +jobs: + version: + name: Resolve dev package version + runs-on: ubuntu-latest + outputs: + package_version: ${{ steps.set.outputs.package_version }} + steps: + - name: Set version from commit SHA + id: set + run: echo "package_version=v0.0.1-beta.${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + publish: + name: Build and publish + needs: version + uses: ./.github/workflows/publish-api-types-npm.yml + with: + package_version: ${{ needs.version.outputs.package_version }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-api-types-npm.yml b/.github/workflows/publish-api-types-npm.yml new file mode 100644 index 000000000000..86f818b20878 --- /dev/null +++ b/.github/workflows/publish-api-types-npm.yml @@ -0,0 +1,149 @@ +name: Publish @blockscout/api-types to npm + +on: + workflow_dispatch: + inputs: + package_version: + description: "Package version (semver or Git tag, e.g. 1.0.0 or v1.0.0-beta.1)" + required: true + type: string + # todo: re-enable once all fixes to OpenApi schemas will be made + # release: + # types: [published] + workflow_call: + inputs: + package_version: + description: "Package version (semver or Git tag, e.g. 1.0.0 or v1.0.0-beta.1)" + required: true + type: string + secrets: + NPM_TOKEN: + required: true + +env: + OTP_VERSION: "27.3.4.6" + ELIXIR_VERSION: "1.19.4" + NODE_VERSION: "22" + +permissions: + id-token: write + contents: read + +jobs: + publish: + name: Build and publish + # Skip GitHub pre-releases; manual dispatch and workflow_call are unaffected. + if: github.event_name != 'release' || github.event.release.prerelease == false + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - name: Resolve package version and npm dist-tag + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_VERSION: ${{ inputs.package_version }} + run: | + case "$EVENT_NAME" in + release) + raw="$RELEASE_TAG" + ;; + *) + raw="$INPUT_VERSION" + ;; + esac + + version="${raw#[vV]}" + + if [ "$EVENT_NAME" = "release" ] && [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "Skipping npm publish: release tag '$raw' is not a final version" >&2 + exit 1 + fi + + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid semver (optional leading v/V): $raw" >&2 + exit 1 + fi + + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then + dist_tag=beta + else + dist_tag=latest + fi + + echo "NPM_VERSION=$version" >> "$GITHUB_ENV" + echo "NPM_DIST_TAG=$dist_tag" >> "$GITHUB_ENV" + echo "Publishing @blockscout/api-types@$version with dist-tag '$dist_tag' (input: $raw)" + + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION }} + hexpm-mirrors: | + https://builds.hex.pm + https://cdn.jsdelivr.net/hex + + - name: ELIXIR_VERSION.lock + run: echo "${ELIXIR_VERSION}" > ELIXIR_VERSION.lock + + - name: OTP_VERSION.lock + run: echo "${OTP_VERSION}" > OTP_VERSION.lock + + - name: Restore Mix deps cache + uses: actions/cache/restore@v4 + id: deps-cache + with: + path: | + deps + _build + key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-deps-mixlockhash- + + - name: Build Mix deps + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + mix deps.compile --skip-umbrella-children + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: types-package/package-lock.json + registry-url: https://registry.npmjs.org + scope: "@blockscout" + + - name: Install npm dependencies + working-directory: types-package + run: npm ci + + - name: Build OpenAPI specs and TypeScript types + working-directory: types-package + run: npm run build + + - name: Typecheck + working-directory: types-package + run: npm run typecheck + + - name: Set package version + working-directory: types-package + run: npm version "$NPM_VERSION" --no-git-tag-version + + - name: Prepare package for publish + working-directory: types-package + run: npm pkg delete private + + - name: Publish to npm + working-directory: types-package + run: npm publish --access public --tag "$NPM_DIST_TAG" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-docker-image-custom-build.yml b/.github/workflows/publish-docker-image-custom-build.yml index 232975f6f9ed..7b1f1798b419 100644 --- a/.github/workflows/publish-docker-image-custom-build.yml +++ b/.github/workflows/publish-docker-image-custom-build.yml @@ -5,14 +5,18 @@ on: push: branches: - custom-build +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -21,14 +25,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-postrelease-custom-build-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}-postrelease-custom-build-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -43,7 +47,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-postrelease-custom-build-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}-postrelease-custom-build-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-every-push.yml b/.github/workflows/publish-docker-image-every-push.yml index 9510bee606e1..66cc7640865f 100644 --- a/.github/workflows/publish-docker-image-every-push.yml +++ b/.github/workflows/publish-docker-image-every-push.yml @@ -1,24 +1,29 @@ -name: Publish Docker image on every push to master branch +name: Publish Docker image on every push to master/dev branches on: push: branches: - master + - dev paths-ignore: - 'CHANGELOG.md' - '**/README.md' - 'docker-compose/*' env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - RELEASE_VERSION: 8.0.2 + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' + RELEASE_VERSION: 11.2.2 + +permissions: + contents: read + packages: write jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -34,9 +39,9 @@ jobs: context: . file: ./docker/Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:master, ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }} + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + cache-to: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache,mode=max + tags: ghcr.io/blockscout/blockscout-private:${{ github.ref_name }}, ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -56,7 +61,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -77,8 +82,8 @@ jobs: context: . file: ./docker/Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - tags: ghcr.io/blockscout/blockscout:frontend-main + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + tags: ghcr.io/blockscout/blockscout-private:frontend-main labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index da97433f6603..41742473babc 100644 --- a/.github/workflows/publish-docker-image-for-arbitrum.yml +++ b/.github/workflows/publish-docker-image-for-arbitrum.yml @@ -5,15 +5,19 @@ on: push: branches: - production-arbitrum +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: arbitrum steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-berachain.yml b/.github/workflows/publish-docker-image-for-berachain.yml deleted file mode 100644 index edf43346bdba..000000000000 --- a/.github/workflows/publish-docker-image-for-berachain.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Berachain Publish Docker image - -on: - workflow_dispatch: - push: - branches: - - production-berachain -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - DOCKER_CHAIN_NAME: berachain - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image (indexer + API) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=berachain - - - name: Build and push Docker image (indexer) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DISABLE_API=true - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=berachain diff --git a/.github/workflows/publish-docker-image-for-celo.yml b/.github/workflows/publish-docker-image-for-celo.yml index 1e869a7e8b8e..6bdf83cb2ed1 100644 --- a/.github/workflows/publish-docker-image-for-celo.yml +++ b/.github/workflows/publish-docker-image-for-celo.yml @@ -5,16 +5,21 @@ on: push: branches: - production-celo +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: celo + CHAIN_TYPE: optimism-celo API_GRAPHQL_MAX_COMPLEXITY: 10400 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -23,14 +28,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image for CELO (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -39,7 +44,7 @@ jobs: API_GRAPHQL_MAX_COMPLEXITY=${{ env.API_GRAPHQL_MAX_COMPLEXITY }} BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=${{ env.DOCKER_CHAIN_NAME }} + CHAIN_TYPE=${{ env.CHAIN_TYPE }} - name: Build and push Docker image for CELO (indexer) uses: docker/build-push-action@v6 @@ -47,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -57,5 +62,4 @@ jobs: DISABLE_API=true BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=${{ env.DOCKER_CHAIN_NAME }} - + CHAIN_TYPE=${{ env.CHAIN_TYPE }} diff --git a/.github/workflows/publish-docker-image-for-core.yml b/.github/workflows/publish-docker-image-for-core.yml deleted file mode 100644 index 671fe4990934..000000000000 --- a/.github/workflows/publish-docker-image-for-core.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: POA Core Publish Docker image - -on: - workflow_dispatch: - push: - branches: - - production-core -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - DOCKER_CHAIN_NAME: poa - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:latest, ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/publish-docker-image-for-eth-sepolia.yml b/.github/workflows/publish-docker-image-for-eth-sepolia.yml index e66cc53ef2ef..4d0c98f17589 100644 --- a/.github/workflows/publish-docker-image-for-eth-sepolia.yml +++ b/.github/workflows/publish-docker-image-for-eth-sepolia.yml @@ -5,15 +5,19 @@ on: push: branches: - production-eth-sepolia +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: eth-sepolia steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:latest, ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:latest, ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-eth.yml b/.github/workflows/publish-docker-image-for-eth.yml index d1b877ec2d15..507fddc11363 100644 --- a/.github/workflows/publish-docker-image-for-eth.yml +++ b/.github/workflows/publish-docker-image-for-eth.yml @@ -5,15 +5,19 @@ on: push: branches: - production-eth +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: ethereum steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-filecoin.yml b/.github/workflows/publish-docker-image-for-filecoin.yml index 9030fb4bad6c..5291fc40f05d 100644 --- a/.github/workflows/publish-docker-image-for-filecoin.yml +++ b/.github/workflows/publish-docker-image-for-filecoin.yml @@ -4,15 +4,19 @@ on: push: branches: - production-filecoin +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: filecoin steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -21,14 +25,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image for Filecoin (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -44,7 +48,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-fuse.yml b/.github/workflows/publish-docker-image-for-fuse.yml index a48aac635389..5e97c2f6706d 100644 --- a/.github/workflows/publish-docker-image-for-fuse.yml +++ b/.github/workflows/publish-docker-image-for-fuse.yml @@ -5,15 +5,19 @@ on: push: branches: - production-fuse +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: fuse steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-gnosis-chain.yml b/.github/workflows/publish-docker-image-for-gnosis-chain.yml index 0226d10f9c3d..4bebe750fd40 100644 --- a/.github/workflows/publish-docker-image-for-gnosis-chain.yml +++ b/.github/workflows/publish-docker-image-for-gnosis-chain.yml @@ -5,15 +5,19 @@ on: push: branches: - production-xdai +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: xdai steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -46,7 +50,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-l2-staging.yml b/.github/workflows/publish-docker-image-for-l2-staging.yml deleted file mode 100644 index ab1dfc75d442..000000000000 --- a/.github/workflows/publish-docker-image-for-l2-staging.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: L2 staging Publish Docker image - -on: - workflow_dispatch: - push: - branches: - - staging-l2 -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - DOCKER_CHAIN_NAME: optimism-l2-advanced - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:latest, ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/publish-docker-image-for-lukso.yml b/.github/workflows/publish-docker-image-for-lukso.yml deleted file mode 100644 index be70ef364d52..000000000000 --- a/.github/workflows/publish-docker-image-for-lukso.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: LUKSO Publish Docker image - -on: - workflow_dispatch: - push: - branches: - - production-lukso -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - DOCKER_CHAIN_NAME: lukso - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:latest, ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/publish-docker-image-for-zkevm.yml b/.github/workflows/publish-docker-image-for-optimism-exeperimental.yml similarity index 75% rename from .github/workflows/publish-docker-image-for-zkevm.yml rename to .github/workflows/publish-docker-image-for-optimism-exeperimental.yml index 53e05c89bd47..258880ac26f8 100644 --- a/.github/workflows/publish-docker-image-for-zkevm.yml +++ b/.github/workflows/publish-docker-image-for-optimism-exeperimental.yml @@ -1,19 +1,23 @@ -name: Zkevm publish Docker image +name: Optimism Publish experimental Docker image on: workflow_dispatch: push: branches: - - production-zkevm + - production-optimism-experimental +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 - DOCKER_CHAIN_NAME: zkevm + RELEASE_VERSION: 11.2.2 + DOCKER_CHAIN_NAME: optimism steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-experimental-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -37,7 +41,7 @@ jobs: build-args: | BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=polygon_zkevm + CHAIN_TYPE=optimism - name: Build and push Docker image (indexer) uses: docker/build-push-action@v6 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-experimental-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -54,5 +58,4 @@ jobs: DISABLE_API=true BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=polygon_zkevm - + CHAIN_TYPE=optimism diff --git a/.github/workflows/publish-docker-image-for-optimism.yml b/.github/workflows/publish-docker-image-for-optimism.yml index 2ac79845cdd6..512753a2c14d 100644 --- a/.github/workflows/publish-docker-image-for-optimism.yml +++ b/.github/workflows/publish-docker-image-for-optimism.yml @@ -5,15 +5,19 @@ on: push: branches: - production-optimism +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: optimism steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -55,4 +59,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=optimism - diff --git a/.github/workflows/publish-docker-image-for-rootstock.yml b/.github/workflows/publish-docker-image-for-rootstock.yml index 747a8873f81c..7ee2898a2ec9 100644 --- a/.github/workflows/publish-docker-image-for-rootstock.yml +++ b/.github/workflows/publish-docker-image-for-rootstock.yml @@ -5,15 +5,19 @@ on: push: branches: - production-rsk +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: rsk steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-scroll.yml b/.github/workflows/publish-docker-image-for-scroll.yml index 15168c24839c..046746d5b087 100644 --- a/.github/workflows/publish-docker-image-for-scroll.yml +++ b/.github/workflows/publish-docker-image-for-scroll.yml @@ -5,15 +5,19 @@ on: push: branches: - production-scroll +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: scroll steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -55,4 +59,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=scroll - diff --git a/.github/workflows/publish-docker-image-for-suave.yml b/.github/workflows/publish-docker-image-for-suave.yml deleted file mode 100644 index 7cefb9c5debd..000000000000 --- a/.github/workflows/publish-docker-image-for-suave.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: SUAVE Publish Docker image - -on: - workflow_dispatch: - push: - branches: - - production-suave -env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - DOCKER_CHAIN_NAME: suave - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=suave diff --git a/.github/workflows/publish-docker-image-for-xrplevm.yml b/.github/workflows/publish-docker-image-for-xrplevm.yml index 5a3a6ff070c0..3e5405eb85d7 100644 --- a/.github/workflows/publish-docker-image-for-xrplevm.yml +++ b/.github/workflows/publish-docker-image-for-xrplevm.yml @@ -14,16 +14,34 @@ jobs: env: DOCKER_CHAIN_NAME: xrplevm steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Add SHORT_SHA env property with commit short sha shell: bash run: echo "SHORT_SHA=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_ENV + # RELEASE_VERSION must exactly match the version in mix.exs: the Dockerfile uses it as a + # filesystem path (/app/releases/${RELEASE_VERSION}/config_helper.exs) and the Elixir release + # only reads from the directory named after the mix.exs version. A mismatch builds fine but + # crashes at boot with ENOENT. Do NOT replace this with the workflow input. + - name: Read release version from mix.exs + id: mix_version + shell: bash + run: | + # -m1: stop at the first match, so a second `version:` line in mix.exs can never + # produce a multi-line value that silently ships the wrong version. + # `|| true`: without it, grep's non-zero exit on no-match trips `bash -e` and the + # step dies before the check below, so the error message would never be printed. + version="$(grep -oPm1 'version: "\K[^"]+' mix.exs || true)" + if [ -z "$version" ]; then + echo "Failed to extract version from mix.exs" >&2 + exit 1 + fi + echo "version=$version" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub 👤 - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -40,5 +58,5 @@ jobs: linux/arm64/v8 build-args: | BLOCKSCOUT_VERSION=v${{ github.event.inputs.version }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ github.event.inputs.version }} - CHAIN_TYPE=ethereum \ No newline at end of file + RELEASE_VERSION=${{ steps.mix_version.outputs.version }} + CHAIN_TYPE=ethereum diff --git a/.github/workflows/publish-docker-image-for-zetachain.yml b/.github/workflows/publish-docker-image-for-zetachain.yml index c7d40769ca10..122f518aed1f 100644 --- a/.github/workflows/publish-docker-image-for-zetachain.yml +++ b/.github/workflows/publish-docker-image-for-zetachain.yml @@ -5,15 +5,19 @@ on: push: branches: - production-zetachain +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: zetachain steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/publish-docker-image-for-zilliqa.yml b/.github/workflows/publish-docker-image-for-zilliqa.yml index 75bc869afafa..bbfa8e4eddbc 100644 --- a/.github/workflows/publish-docker-image-for-zilliqa.yml +++ b/.github/workflows/publish-docker-image-for-zilliqa.yml @@ -5,15 +5,19 @@ on: push: branches: - production-zilliqa +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: zilliqa steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -22,14 +26,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -45,7 +49,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -55,5 +59,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=${{ env.DOCKER_CHAIN_NAME }} - - diff --git a/.github/workflows/publish-docker-image-for-zksync.yml b/.github/workflows/publish-docker-image-for-zksync.yml index 88413afb8a99..7688a4f7f972 100644 --- a/.github/workflows/publish-docker-image-for-zksync.yml +++ b/.github/workflows/publish-docker-image-for-zksync.yml @@ -4,15 +4,19 @@ on: push: branches: - production-zksync +permissions: + contents: read + packages: write + jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 DOCKER_CHAIN_NAME: zksync steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -21,14 +25,14 @@ jobs: docker-remote-multi-platform: true docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - + - name: Build and push Docker image (indexer + API) uses: docker/build-push-action@v6 with: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -44,7 +48,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer + tags: ghcr.io/blockscout/blockscout-${{ env.DOCKER_CHAIN_NAME }}-private:${{ env.RELEASE_VERSION }}-postrelease-${{ env.SHORT_SHA }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -54,4 +58,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=zksync - diff --git a/.github/workflows/publish-docker-image-staging-on-demand.yml b/.github/workflows/publish-docker-image-old-ui.yml similarity index 61% rename from .github/workflows/publish-docker-image-staging-on-demand.yml rename to .github/workflows/publish-docker-image-old-ui.yml index 861d647b125a..3b529e55e13b 100644 --- a/.github/workflows/publish-docker-image-staging-on-demand.yml +++ b/.github/workflows/publish-docker-image-old-ui.yml @@ -1,25 +1,24 @@ -name: Publish Docker image to staging on demand +name: Publish Docker image with an old UI on: workflow_dispatch: - push: - branches: - - staging - paths-ignore: - - 'CHANGELOG.md' - - '**/README.md' - - 'docker-compose/*' + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - RELEASE_VERSION: 8.0.2 + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' + +permissions: + contents: read + packages: write jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build + env: + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -29,15 +28,15 @@ jobs: docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - name: Build and push Docker image + - name: Build & Push Docker image with an old UI (indexer + API) uses: docker/build-push-action@v6 with: context: . - file: ./docker/Dockerfile + file: ./docker/oldUI.Dockerfile push: true cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout-staging:latest, ghcr.io/blockscout/blockscout-staging:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }} + tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-with-old-ui-postrelease-${{ env.SHORT_SHA }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,5 +47,5 @@ jobs: MIXPANEL_TOKEN= AMPLITUDE_URL= AMPLITUDE_API_KEY= - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} + BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/publish-regular-docker-image-on-demand.yml b/.github/workflows/publish-regular-docker-image-on-demand.yml deleted file mode 100644 index 6a7e81b542b0..000000000000 --- a/.github/workflows/publish-regular-docker-image-on-demand.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Publish regular Docker image on demand - -on: - workflow_dispatch: -env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - RELEASE_VERSION: 8.0.2 - -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image (indexer + API) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DECODE_NOT_A_CONTRACT_CALLS=false - MIXPANEL_URL= - MIXPANEL_TOKEN= - AMPLITUDE_URL= - AMPLITUDE_API_KEY= - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - - - name: Build and push Docker image (indexer) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}.commit.${{ env.SHORT_SHA }}-indexer - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DISABLE_API=true - DECODE_NOT_A_CONTRACT_CALLS=false - MIXPANEL_URL= - MIXPANEL_TOKEN= - AMPLITUDE_URL= - AMPLITUDE_API_KEY= - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }}.+commit.${{ env.SHORT_SHA }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index f34e8ef721d1..fcfcea4300f4 100644 --- a/.github/workflows/release-arbitrum.yml +++ b/.github/workflows/release-arbitrum.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-arbitrum:latest, ghcr.io/blockscout/blockscout-arbitrum:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-arbitrum-private:latest, ghcr.io/blockscout/blockscout-arbitrum-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-arbitrum:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-arbitrum-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/release-berachain.yml b/.github/workflows/release-berachain.yml deleted file mode 100644 index 24c946077f5f..000000000000 --- a/.github/workflows/release-berachain.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Release for Berachain - -on: - workflow_dispatch: - release: - types: [published] - -env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image for Ethereum (indexer + API) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-berachain:latest, ghcr.io/blockscout/blockscout-berachain:${{ env.RELEASE_VERSION }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=berachain - - - name: Build and push Docker image for Ethereum (indexer) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-berachain:${{ env.RELEASE_VERSION }}-indexer - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DISABLE_API=true - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=berachain diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index e8a96172e961..17145b98265b 100644 --- a/.github/workflows/release-celo.yml +++ b/.github/workflows/release-celo.yml @@ -5,19 +5,23 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 API_GRAPHQL_MAX_COMPLEXITY: 10400 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -33,7 +37,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-celo:latest, ghcr.io/blockscout/blockscout-celo:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-celo-private:latest, ghcr.io/blockscout/blockscout-celo-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -42,7 +46,7 @@ jobs: API_GRAPHQL_MAX_COMPLEXITY=${{ env.API_GRAPHQL_MAX_COMPLEXITY }} BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=celo + CHAIN_TYPE=optimism-celo - name: Build and push Docker image for CELO (indexer) uses: docker/build-push-action@v6 @@ -50,7 +54,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-celo:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-celo-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -60,5 +64,4 @@ jobs: DISABLE_API=true BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=celo - + CHAIN_TYPE=optimism-celo diff --git a/.github/workflows/release.yml b/.github/workflows/release-default.yml similarity index 87% rename from .github/workflows/release.yml rename to .github/workflows/release-default.yml index 5e4f2fd7bf5e..d5485efa8059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release-default.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,9 +36,9 @@ jobs: context: . file: ./docker/Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:latest, ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }} + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + cache-to: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache,mode=max + tags: ghcr.io/blockscout/blockscout-private:latest, ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -54,9 +58,9 @@ jobs: context: . file: ./docker/Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-indexer + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + cache-to: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache,mode=max + tags: ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -78,9 +82,9 @@ jobs: context: . file: ./docker/oldUI.Dockerfile push: true - cache-from: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache - cache-to: type=registry,ref=ghcr.io/blockscout/blockscout:buildcache,mode=max - tags: ghcr.io/blockscout/blockscout:${{ env.RELEASE_VERSION }}-with-old-ui + cache-from: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache + cache-to: type=registry,ref=ghcr.io/blockscout/blockscout-private:buildcache,mode=max + tags: ghcr.io/blockscout/blockscout-private:${{ env.RELEASE_VERSION }}-with-old-ui labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -121,7 +125,7 @@ jobs: # production-rsk # production-immutable # steps: - # - uses: actions/checkout@v4 + # - uses: actions/checkout@v5 # - name: Set Git config # run: | # git config --local user.email "actions@github.com" diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index 57ae67eaad35..2ea34cec9ad6 100644 --- a/.github/workflows/release-eth.yml +++ b/.github/workflows/release-eth.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-ethereum:latest, ghcr.io/blockscout/blockscout-ethereum:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-ethereum-private:latest, ghcr.io/blockscout/blockscout-ethereum-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-ethereum:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-ethereum-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -58,4 +62,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=ethereum - diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index 56348f11550f..3dc771622fdf 100644 --- a/.github/workflows/release-filecoin.yml +++ b/.github/workflows/release-filecoin.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-filecoin:latest, ghcr.io/blockscout/blockscout-filecoin:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-filecoin-private:latest, ghcr.io/blockscout/blockscout-filecoin-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-filecoin:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-filecoin-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index a7a4209c634f..35066003f920 100644 --- a/.github/workflows/release-fuse.yml +++ b/.github/workflows/release-fuse.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-fuse:latest, ghcr.io/blockscout/blockscout-fuse:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-fuse-private:latest, ghcr.io/blockscout/blockscout-fuse-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-fuse:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-fuse-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index e691c8eed7b3..3793c8f39dde 100644 --- a/.github/workflows/release-gnosis.yml +++ b/.github/workflows/release-gnosis.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-xdai:latest, ghcr.io/blockscout/blockscout-xdai:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-xdai-private:latest, ghcr.io/blockscout/blockscout-xdai-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -49,7 +53,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-xdai:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-xdai-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index 207d80a26770..379f05108a1f 100644 --- a/.github/workflows/release-optimism.yml +++ b/.github/workflows/release-optimism.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-optimism:latest, ghcr.io/blockscout/blockscout-optimism:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-optimism-private:latest, ghcr.io/blockscout/blockscout-optimism-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-optimism:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-optimism-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/release-polygon-zkevm.yml b/.github/workflows/release-polygon-zkevm.yml deleted file mode 100644 index 2ee5ef966eca..000000000000 --- a/.github/workflows/release-polygon-zkevm.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Release for Polygon zkEVM - -on: - workflow_dispatch: - release: - types: [published] - -env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image for Polygon zkEVM (indexer + API) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-zkevm:latest, ghcr.io/blockscout/blockscout-zkevm:${{ env.RELEASE_VERSION }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=polygon_zkevm - - - name: Build and push Docker image for Polygon zkEVM (indexer) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-zkevm:${{ env.RELEASE_VERSION }}-indexer - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DISABLE_API=true - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=polygon_zkevm - diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index 9554b7a045b0..cd77422eb925 100644 --- a/.github/workflows/release-rootstock.yml +++ b/.github/workflows/release-rootstock.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-rsk:latest, ghcr.io/blockscout/blockscout-rsk:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-rsk-private:latest, ghcr.io/blockscout/blockscout-rsk-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-rsk:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-rsk-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -58,4 +62,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=rsk - diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index 29404c08185d..d29b55e42caa 100644 --- a/.github/workflows/release-scroll.yml +++ b/.github/workflows/release-scroll.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-scroll:latest, ghcr.io/blockscout/blockscout-scroll:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-scroll-private:latest, ghcr.io/blockscout/blockscout-scroll-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-scroll:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-scroll-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -58,4 +62,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=scroll - diff --git a/.github/workflows/release-suave.yml b/.github/workflows/release-suave.yml deleted file mode 100644 index 1abddeea38a8..000000000000 --- a/.github/workflows/release-suave.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Release for SUAVE - -on: - workflow_dispatch: - release: - types: [published] - -env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} - -jobs: - push_to_registry: - name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest - env: - RELEASE_VERSION: 8.0.2 - steps: - - uses: actions/checkout@v4 - - name: Setup repo - uses: ./.github/actions/setup-repo - id: setup - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - docker-remote-multi-platform: true - docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} - docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} - - - name: Build and push Docker image for SUAVE (indexer + API) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-suave:latest, ghcr.io/blockscout/blockscout-suave:${{ env.RELEASE_VERSION }} - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=suave - - - name: Build and push Docker image for SUAVE (indexer) - uses: docker/build-push-action@v6 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ghcr.io/blockscout/blockscout-suave:${{ env.RELEASE_VERSION }}-indexer - labels: ${{ steps.setup.outputs.docker-labels }} - platforms: | - linux/amd64 - linux/arm64/v8 - build-args: | - DISABLE_API=true - BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} - RELEASE_VERSION=${{ env.RELEASE_VERSION }} - CHAIN_TYPE=suave diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index 53ce112e100b..cdec7f3febe6 100644 --- a/.github/workflows/release-zetachain.yml +++ b/.github/workflows/release-zetachain.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zetachain:latest, ghcr.io/blockscout/blockscout-zetachain:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-zetachain-private:latest, ghcr.io/blockscout/blockscout-zetachain-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zetachain:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-zetachain-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index d88514d9ff88..1f15c4244a3f 100644 --- a/.github/workflows/release-zilliqa.yml +++ b/.github/workflows/release-zilliqa.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zilliqa:latest, ghcr.io/blockscout/blockscout-zilliqa:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-zilliqa-private:latest, ghcr.io/blockscout/blockscout-zilliqa-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zilliqa:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-zilliqa-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -58,5 +62,3 @@ jobs: BLOCKSCOUT_VERSION=v${{ env.RELEASE_VERSION }} RELEASE_VERSION=${{ env.RELEASE_VERSION }} CHAIN_TYPE=zilliqa - - diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index cd95e5d03e90..53f6e01db9eb 100644 --- a/.github/workflows/release-zksync.yml +++ b/.github/workflows/release-zksync.yml @@ -5,18 +5,22 @@ on: release: types: [published] +permissions: + contents: read + packages: write + env: - OTP_VERSION: ${{ vars.OTP_VERSION }} - ELIXIR_VERSION: ${{ vars.ELIXIR_VERSION }} + OTP_VERSION: '27.3.4.6' + ELIXIR_VERSION: '1.19.4' jobs: push_to_registry: name: Push Docker image to GitHub Container Registry - runs-on: ubuntu-latest + runs-on: build env: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup repo uses: ./.github/actions/setup-repo id: setup @@ -32,7 +36,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zksync:latest, ghcr.io/blockscout/blockscout-zksync:${{ env.RELEASE_VERSION }} + tags: ghcr.io/blockscout/blockscout-zksync-private:latest, ghcr.io/blockscout/blockscout-zksync-private:${{ env.RELEASE_VERSION }} labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 @@ -48,7 +52,7 @@ jobs: context: . file: ./docker/Dockerfile push: true - tags: ghcr.io/blockscout/blockscout-zksync:${{ env.RELEASE_VERSION }}-indexer + tags: ghcr.io/blockscout/blockscout-zksync-private:${{ env.RELEASE_VERSION }}-indexer labels: ${{ steps.setup.outputs.docker-labels }} platforms: | linux/amd64 diff --git a/.gitignore b/.gitignore index 16545281812b..d4ba21b88b12 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ dump.rdb .cursorignore .cursorrules .elixir_ls +.claude/settings.local.json **.dec** @@ -74,4 +75,6 @@ queue_storage tasks_in_progress /dets -/temp \ No newline at end of file +/temp + +openapi.yaml diff --git a/.pairs b/.pairs deleted file mode 100644 index e40bd6ebc940..000000000000 --- a/.pairs +++ /dev/null @@ -1,13 +0,0 @@ -pairs: - cj: CJ Bryan; cj - dr: Doc Ritezel; doc - mo: Matt Olenick; matto - db: Derek Barnes; dgb - rdwb: Desmond Bowe; des - -email: - prefix: pair - domain: ministryofvelocity.com - no_solo_prefix: true - -global: true diff --git a/.tool-versions b/.tool-versions index 639a1a07cd2e..45ce6d515c65 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.17.3-otp-27 -erlang 27.1 +elixir 1.19.4-otp-27 +erlang 27.3.4.6 nodejs 20.17.0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..85f793c624ac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# Agent Guidelines for Blockscout + +## Separate API / Indexer Mode Architecture + +Blockscout supports running as a single combined application or as separate API and indexer instances via the `APPLICATION_MODE` environment variable. + +### Mode Configuration + +`APPLICATION_MODE` environment variable (defined in `config/config_helper.exs`): +- `all` (default) — both API and indexer run together +- `api` — API-only instance, no indexing +- `indexer` — indexer-only instance, no API serving + +The current mode is accessible via `Explorer.mode()` (defined in `apps/explorer/lib/explorer.ex`), which returns `:all`, `:api`, `:indexer`, or `:media_worker`. + +`:media_worker` is a special standalone mode for NFT media processing. It is not set via `APPLICATION_MODE` — it activates when `nft_media_handler[:standalone_media_worker?]` is true, overriding the configured mode. In this mode, `Explorer.Application` starts only libcluster — no base_children or configurable_children. The process mode filtering rules below do not apply to `:media_worker`. + +Related environment variables: +- `DISABLE_INDEXER` — forces indexer off (auto-set when `APPLICATION_MODE=api`) +- `DISABLE_API` — forces API off (compile-time, controls `BlockScoutWeb.Endpoint`) + +### Rules for Assigning Processes to Modes + +When adding or modifying processes started by `Explorer.Application`, `Indexer.Application`, or `BlockScoutWeb.Application`, follow these rules: + +**Start in `:indexer` mode only:** +- Active periodic updaters — GenServers that periodically query the DB and write results to `last_fetched_counters` table (e.g., `ContractsCount`, `NewPendingTransactionsCount`, `Transactions24hCount`). The API side reads directly from the DB table without needing a local process. +- Data migrators (`Explorer.Migrator.*`) — one-time or ongoing data transformations. +- Catalogers and tag importers (`AddressTag.Cataloger`, `CertifiedSmartContractCataloger`). +- Block gap scanning (`MinMissingBlockNumber`). +- Indexer-specific caches that are written and read by indexer only (`LatestL1BlockNumber`). + +**Start in `:api` mode only:** +- Passive on-demand ETS/in-memory caches — GenServers that manage an ETS table and populate it on API request (e.g., `AddressTransactionsCount`, `TokenHoldersCount`, `BlockBurntFeeCount`, `AverageBlockTime`). ETS is local to the process, so these must run on the instance serving requests. +- On-demand fetchers triggered by API requests (`CheckBytecodeMatchingOnDemand`, `FetchValidatorInfoOnDemand`, `LookUpSmartContractSourcesOnDemand`). +- API access control (`AddressesBlacklist`). +- Contract verification tooling (`SolcDownloader`, `VyperDownloader`). +- Read-only DB replicas (`Explorer.Repo.Replica1`). +- API-only caches (`OptimismFinalizationPeriod`, `CeloEpochs`, `Rootstock.LockedBTCCount`). + +**Start in both modes (`:all`, `:api`, `:indexer`):** +- Core infrastructure: main `Explorer.Repo`, `Explorer.Vault`, `Registry.ChainEvents`, `Redix`. +- Event system: `Explorer.Chain.Events.Listener` (mode-controlled via its own `:enabled` config). +- Cluster discovery (`libcluster`) — needed for node communication in separate mode. + +### Helper Functions in Explorer.Application + +- `configure(process)` — starts if `Application.get_env(:explorer, process)[:enabled] == true`. No mode check. +- `configure_mode_dependent_process(process, mode)` — starts if `:enabled` is true AND `Explorer.mode()` matches. Use for processes that have `:enabled` config in `runtime.exs`. +- `only_in_mode(process, mode)` — starts if `Explorer.mode()` matches. No `:enabled` check. Use for processes without `:enabled` config (e.g., repos, downloaders, unconditional entries). +- `configure_chain_type_dependent_process(process, chain_type)` — starts if chain type matches. Can be piped with mode filters. + +Piping pattern for combined restrictions: +```elixir +SomeProcess +|> configure_mode_dependent_process(:indexer) +|> configure_chain_type_dependent_process(:optimism) +``` + +### Cache Pattern Reference + +How to distinguish active updaters from passive caches when deciding the mode: +- **Active periodic updater**: has `schedule_next_consolidation()`, `handle_info(:consolidate)`, writes to `last_fetched_counters` via `LastFetchedCounter.upsert()` -> `:indexer` +- **Passive on-demand ETS cache**: has `fetch()` with cache expiry check, stores in ETS via `Helper.put_into_ets_cache()`, may update model columns -> `:api` +- **MapCache (ConCache)**: uses `use Explorer.Chain.MapCache`, implements `handle_fallback` -> `:api` diff --git a/CHANGELOG.md b/CHANGELOG.md index a014975c88d0..3436d6f2da30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,1289 @@ # Changelog -## 8.0.2 +## 11.2.2 + +### 🚀 Features + +- Add realtime ERC-20 token balance and block indexing delay metrics ([#14531](https://github.com/blockscout/blockscout/issues/14531)) ### 🐛 Bug Fixes -- Fix Indexer.Helper.http_get_request function ([#12317](https://github.com/blockscout/blockscout/pull/12317)) -- Rename left props in API v2 with new naming convention ([#12314](https://github.com/blockscout/blockscout/issues/12314)) -- Temporary disable PendingTransactionOperation ([#12312](https://github.com/blockscout/blockscout/issues/12312)) -- Add `bash` to `builder-deps` build stage ([#12316](https://github.com/blockscout/blockscout/issues/12316)) -- Build on macos ([#12308](https://github.com/blockscout/blockscout/issues/12308)) +- Fix import result merging for chunked data ([#14528](https://github.com/blockscout/blockscout/issues/14528)) +- Add missing async importers for token balances and instances ([#14534](https://github.com/blockscout/blockscout/pull/14534)) +- Log error instead of silent empty map on CBOR decode failure ([#14510](https://github.com/blockscout/blockscout/issues/14510)) +- Log warning when bytecode metadata hex parsing fails ([#14511](https://github.com/blockscout/blockscout/issues/14511)) + + +## 11.2.1 + +### 🐛 Bug Fixes + +- Don't call InternalTransaction.async_fetch from ContractCreator ([#14482](https://github.com/blockscout/blockscout/issues/14482)) + + +## 11.2.0 + +### 🚀 Features + +- Support for EIP-7708 on arc ([#14336](https://github.com/blockscout/blockscout/pull/14336)) +- Preload only listened entities before broadcast ([#14430](https://github.com/blockscout/blockscout/issues/14430)) +- Add hot smart contracts caching ([#14320](https://github.com/blockscout/blockscout/issues/14320)) +- Add MinimalProxy detection for mid-bytecode EIP-1167-like pattern ([#14426](https://github.com/blockscout/blockscout/issues/14426)) +- Mark instance unhealthy when cache block lags DB ([#14449](https://github.com/blockscout/blockscout/pull/14449)) + +### 🐛 Bug Fixes + +- Don't start health monitor in tests ([#14481](https://github.com/blockscout/blockscout/pull/14481)) +- Improve BlockNumber cache ([#14453](https://github.com/blockscout/blockscout/pull/14453)) +- Fix revert reason for nethermind ([#14442](https://github.com/blockscout/blockscout/pull/14442)) +- Fix token import on Celo ([#14435](https://github.com/blockscout/blockscout/issues/14435)) +- Scope missing_current_token_balances_count indexer metric to configured block ranges ([#14423](https://github.com/blockscout/blockscout/issues/14423)) +- Restrict minimal proxy detection to bytecode ≤ 100 bytes ([#14427](https://github.com/blockscout/blockscout/issues/14427)) +- Add required fields to SmartContract schema ([#14437](https://github.com/blockscout/blockscout/issues/14437)) +- Fix traceable_blocks_dynamic_query ([#14436](https://github.com/blockscout/blockscout/issues/14436)) ### ⚙️ Miscellaneous Tasks -- Add Scroll Euclid upgrade support ([#12294](https://github.com/blockscout/blockscout/issues/12294)) +- Optimize deriving current token balances ([#14450](https://github.com/blockscout/blockscout/pull/14450), [#14479](https://github.com/blockscout/blockscout/pull/14479)) +- Limit max node requests in one batch ([#14319](https://github.com/blockscout/blockscout/issues/14319)) +- Log block fetch errors in catchup fetcher ([#14318](https://github.com/blockscout/blockscout/issues/14318)) +- Reset skip metadata flag for NFTs ([#14337](https://github.com/blockscout/blockscout/issues/14337)) +- Enhance missing current token balances metric ([#14438](https://github.com/blockscout/blockscout/issues/14438)) +- Perceive "out of gas" error as contract failure ([#14417](https://github.com/blockscout/blockscout/issues/14417)) +- Add PG statement_timeout for import transactions ([#14414](https://github.com/blockscout/blockscout/issues/14414)) +- Comment out direct Sourcify tests ([#8168](https://github.com/blockscout/blockscout/issues/8168)) + +### New ENV variables -## 8.0.1 +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `ETHEREUM_JSONRPC_HTTP_BATCH_SIZE` | Max http requests count in one batch. Implemented in [#14319](https://github.com/blockscout/blockscout/pull/14319). | Version: v11.2.0\+
Default: `500`
Applications: API, Indexer | +| `CACHE_HOT_SMART_CONTRACTS_5M_PERIOD` | TTL for ConCache entries serving `GET /api/v2/stats/hot-smart-contracts` with `scale=5m`. Controls how long paginated rankings over the last 5 minutes of contract activity are reused before recomputing from the database. [Time format](/setup/env-variables/backend-env-variables#time-format). Shorter TTL = fresher data, more DB load. Longer TTL = less load, staler rankings. | Version: v11.2.0\+
Default: `30s`
Applications: API | +| `CACHE_HOT_SMART_CONTRACTS_1H_PERIOD` | TTL for ConCache entries serving `GET /api/v2/stats/hot-smart-contracts` with `scale=1h`. Controls how long paginated rankings over the last 1 hour of contract activity are reused before recomputing from the database. [Time format](/setup/env-variables/backend-env-variables#time-format). | Version: v11.2.0\+
Default: `6m`
Applications: API | +| `CACHE_HOT_SMART_CONTRACTS_3H_PERIOD` | TTL for ConCache entries serving `GET /api/v2/stats/hot-smart-contracts` with `scale=3h`. Controls how long paginated rankings over the last 3 hours of contract activity are reused before recomputing from the database. [Time format](/setup/env-variables/backend-env-variables#time-format). | Version: v11.2.0\+
Default: `18m`
Applications: API | + + +## 11.1.3 ### 🐛 Bug Fixes -- Fix MissingBlockRange.fill_ranges_between/3 for empty range ([#12319](https://github.com/blockscout/blockscout/pull/12319)) -- Fix CSV export "to" range to include the whole day in all cases ([#12286](https://github.com/blockscout/blockscout/pull/12286)) -- Return compatibility with previous version of health endpoint([#12280](https://github.com/blockscout/blockscout/pull/12280)) -- Unbind import from compile-time chain_type ([#12277](https://github.com/blockscout/blockscout/pull/12277)) -- Read `CHAIN_TYPE` and `MUD_INDEXER_ENABLED` envs in runtime config ([#12270](https://github.com/blockscout/blockscout/issues/12270)) +- Run background migrations immediately on green install ([#14424](https://github.com/blockscout/blockscout/issues/14424)) +- Adapt maybe_reject_zero_value for pre-changeset values ([#14425](https://github.com/blockscout/blockscout/issues/14425)) ### ⚙️ Miscellaneous Tasks -- Decrease PBO to PTO migration batch size ([#12279](https://github.com/blockscout/blockscout/pull/12279)) -- Decrease PendingOperationsHelper blocks_batch_size ([#12276](https://github.com/blockscout/blockscout/pull/12276)) -- Update docker compose to use ghcr.io images ([#12177](https://github.com/blockscout/blockscout/issues/12177)) +- Distributed MapCache ([#14411](https://github.com/blockscout/blockscout/pull/14411)) + +## 11.1.2 + +### 🐛 Bug Fixes + +- Fix VersionUpgrade check for empty previous version ([#14410](https://github.com/blockscout/blockscout/issues/14410)) + +## 11.1.1 + +### 🐛 Bug Fixes + +- Declare missing OpenAPI params for advanced-filters endpoint ([#14401](https://github.com/blockscout/blockscout/pull/14401), [#14399](https://github.com/blockscout/blockscout/issues/14399)) + +## 11.1.0 + +### 🚀 Features + +- Forward new BENS fields to search ([#14389](https://github.com/blockscout/blockscout/pull/14389)) +- Fetch token circulating supply along with circulating market cap ([#11969](https://github.com/blockscout/blockscout/issues/11969)) +- Support token lists import ([#11801](https://github.com/blockscout/blockscout/issues/11801)) +- transform ECTO_USE_SSL to sslmode param ([#8818](https://github.com/blockscout/blockscout/issues/8818)) + +### 🐛 Bug Fixes + +- Properly start VersionUpgrade on application launch ([#14396](https://github.com/blockscout/blockscout/pull/14396)) +- MissingBalanceOfToken fixes ([#14267](https://github.com/blockscout/blockscout/pull/14267)) +- Improvements of OpenAPI specification for `/v2/blocks` ([#14251](https://github.com/blockscout/blockscout/issues/14251)) +- Normalize Tesla timeout middleware exceptions ([#14059](https://github.com/blockscout/blockscout/issues/14059)) + +### 🚜 Refactor + +- Change multichain_search_db_export_token_info_queue.address_hash field type from :binary to Hash.Address ([#12894](https://github.com/blockscout/blockscout/issues/12894)) +- Refactor json rpc response parsers to ignore unknown fields ([#10334](https://github.com/blockscout/blockscout/issues/10334)) + +### 📚 Documentation + +- Add verification websocket subscription guide ([#14259](https://github.com/blockscout/blockscout/issues/14259)) + +### ⚙️ Miscellaneous Tasks + +- Add SPDX license identifier to Elixir source and test files ([#14393](https://github.com/blockscout/blockscout/pull/14393)) +- Publish OpenAPI specs on dev branch pushes ([#14391](https://github.com/blockscout/blockscout/pull/14391)) +- Add MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_CONCURRENCY ([#14390](https://github.com/blockscout/blockscout/pull/14390)) +- Close linked issues when PRs merge into dev ([#14384](https://github.com/blockscout/blockscout/pull/14384), [#14385](https://github.com/blockscout/blockscout/pull/14385)) +- Add SPDX attribution ([#14360](https://github.com/blockscout/blockscout/issues/14360)) +- Eliminate horizontal scroll in the main LICENSE file ([#14359](https://github.com/blockscout/blockscout/issues/14359)) +- Partial async import ([#14277](https://github.com/blockscout/blockscout/issues/14277)) +- OpenAPI specifications for all `/api/v2/advanced-filters` endpoints ([#14227](https://github.com/blockscout/blockscout/issues/14227)) +- OpenAPI spec for Arbitrum-related endpoints ([#14169](https://github.com/blockscout/blockscout/issues/14169)) +- Audit mode dependent processes ([#13925](https://github.com/blockscout/blockscout/issues/13925), [#14383](https://github.com/blockscout/blockscout/pull/14383)) +- Delete fiat_value for token if it disappears in coingecko ([#8932](https://github.com/blockscout/blockscout/issues/8932)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `TOKEN_LIST_URL` | URL for token list standard https://tokenlists.org/. Implemented in [#14206](https://github.com/blockscout/blockscout/pull/14206). | Version: v11.1.0\+
Default: (empty)
Applications: Indexer | +| `TOKEN_LIST_REFETCH_INTERVAL` | Interval to update data from token list. Implemented in [#14206](https://github.com/blockscout/blockscout/pull/14206). | Version: v11.1.0\+
Default: (empty)
Applications: Indexer | +| `ECTO_SSL_MODE` | SSL mode for Ecto DB connections. Supported values: `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full`. Resolution order: `ECTO_SSL_MODE` has highest priority, then `sslmode` in `DATABASE_URL`, otherwise defaults to `require`. | Version: v11.1.0\++
Default: `require`
Applications: API, Indexer | +| `INDEXER_ENABLE_PARTIAL_ASYNC_IMPORT` | If `true`, addresses, current token balances, tokens and token instances are imported asynchronously. Implemented in [#14277](https://github.com/blockscout/blockscout/pull/14277). | Version: v11.1.0\+
Default: `false`
Applications: Indexer | +| `MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_CONCURRENCY` | Number of parallel processes filling internal transactions address ids. Implemented in [#14390](https://github.com/blockscout/blockscout/pull/14390). | Version: v11.1.0\+
Default: `10`
Applications: Indexer | + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Need recompile | Deprecated in Version | +| -------- | ----------- | ------- | ------- | -------------- | --------------------- | +| Deprecated `ECTO_USE_SSL` | Boolean SSL toggle for Ecto DB connections. Replaced with `ECTO_SSL_MODE`. | `TRUE` | All | | v11.1.0+ | + +## 11.0.3 + +### 🐛 Bug Fixes + +- Insert AddressIdToAddressHash via safe_insert_all ([#14333](https://github.com/blockscout/blockscout/pull/14333)) +- Force search for contract creator if internal transactions module is disabled ([#14324](https://github.com/blockscout/blockscout/issues/14324)) +- Add transactions uniqueness before insert ([#14329](https://github.com/blockscout/blockscout/issues/14329)) + +### ⚙️ Miscellaneous Tasks + +- Don't lock tables if foreign keys are already dropped ([#14321](https://github.com/blockscout/blockscout/issues/14321)) +- Dev branch + CI, remove obsolete GA workflows ([#14317](https://github.com/blockscout/blockscout/issues/14317)) + + +## 11.0.2 + +### 🐛 Bug Fixes + +- Process empty list of changes on fetching contract codes ([#14312](https://github.com/blockscout/blockscout/pull/14312)) +- Add fallback for empty "to" in Geth selfdestruct ([#14256](https://github.com/blockscout/blockscout/issues/14256)) +- Trim contractaddresses in getcontractcreation ([#14306](https://github.com/blockscout/blockscout/issues/14306)) +- Adapt maybe_reject_zero_value for empty blocks ([#14309](https://github.com/blockscout/blockscout/issues/14309)) +- Add missing internal transactions address preload ([#14308](https://github.com/blockscout/blockscout/issues/14308)) +- Fix some web tests ([#14310](https://github.com/blockscout/blockscout/pull/14310)) + +### ⚙️ Miscellaneous Tasks + +- Disable on-demand IT fetcher test for rsk and filecoin ([#14314](https://github.com/blockscout/blockscout/pull/14314)) +- Disable flaky contract code compiler doctest ([#14313](https://github.com/blockscout/blockscout/pull/14313)) +- Add coverage for core API v2 views ([#14254](https://github.com/blockscout/blockscout/issues/14254)) + + +## 11.0.1 + +### 🐛 Bug Fixes + +- Update OnDemand.InternalTransaction etherscan fields ([#14297](https://github.com/blockscout/blockscout/pull/14297)) +- Use inner join for verified contract addresses instead of lateral join ([#14294](https://github.com/blockscout/blockscout/pull/14294)) +- Disable on-demand internal tx fetch when corresponding flag is provided ([#14289](https://github.com/blockscout/blockscout/pull/14289)) +- Add fill IT addresses dependency into drop index migrations ([#14280](https://github.com/blockscout/blockscout/issues/14280)) +- Fix incorrect batch size in Indexer.Fetcher.OnDemand.TokenBalance ([#14265](https://github.com/blockscout/blockscout/issues/14265)) +- Prevent ETS crash in ContractCreator on GenServer restart ([#14221](https://github.com/blockscout/blockscout/issues/14221)) +- Fix internal transactions address dynamic condition ([#14278](https://github.com/blockscout/blockscout/issues/14278)) +- Remove addresses preload in celo parse_internal_transactions ([#14272](https://github.com/blockscout/blockscout/issues/14272)) +- Parse celo reward cursor params for address pagination ([#14275](https://github.com/blockscout/blockscout/issues/14275)) +- Map optimism-celo to celo OpenAPI folder ([#14274](https://github.com/blockscout/blockscout/issues/14274)) +- Make "sort_param" description endpoint-agnostic in OpenAPI spec ([#14270](https://github.com/blockscout/blockscout/issues/14270)) +- Fix Celo epochs list pagination ([#14269](https://github.com/blockscout/blockscout/issues/14269)) + +### 🚜 Refactor + +- Move preload contract creation internal transaction under runtime toggle ([#14279](https://github.com/blockscout/blockscout/issues/14279), ([#14287](https://github.com/blockscout/blockscout/pull/14287))) + +### ⚙️ Miscellaneous Tasks + +- Cover all RPC API stats endpoints and stabilize flaky specs ([#14299](https://github.com/blockscout/blockscout/pull/14299)) +- Change "coinsupply" RPC API response to fit JSON RPC requirements ([#14298](https://github.com/blockscout/blockscout/pull/14298)) +- Optimize internal transactions address_match_dynamic ([#14293](https://github.com/blockscout/blockscout/pull/14293)) +- Add window_size for PendingTransactionsSanitizer ([#14292](https://github.com/blockscout/blockscout/pull/14292)) +- Make pending operations helper batching configurable ([#14273](https://github.com/blockscout/blockscout/issues/14273)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `INDEXER_PENDING_TRANSACTIONS_WINDOW_SIZE` | Time offset for pending transactions sanitizer. Implemented in [#14292](https://github.com/blockscout/blockscout/pull/14292). | Version: v11.0.1\+
Default: `1d`
Applications: Indexer | +| `TOKEN_BALANCE_ON_DEMAND_FETCHER_BATCH_SIZE` | Batch size for Indexer.Fetcher.OnDemand.TokenBalance. Introduced in [#14265](https://github.com/poanetwork/blockscout/pull/14265) | Version: v11.0.1\+
Default: `500`
Applications: API, Indexer | +| `TOKEN_BALANCE_ON_DEMAND_FETCHER_CONCURRENCY` | Concurrency for Indexer.Fetcher.OnDemand.TokenBalance. Introduced in [#14265](https://github.com/poanetwork/blockscout/pull/14265) | Version: v11.0.1\+
Default: `4`
Applications: API, Indexer | +| `TOKEN_BALANCE_ON_DEMAND_FETCHER_ADDRESS_QUEUE_FLUSH_INTERVAL` | How often the on-demand token balance address queue is flushed to processing. Use a shorter interval for lower latency, or a longer interval to accumulate larger batches and reduce query frequency. Introduced in [#14265](https://github.com/poanetwork/blockscout/pull/14265) | Version: v11.0.1\+
Default: `1s`
Applications: API, Indexer | +| `TOKEN_BALANCE_ON_DEMAND_FETCHER_ADDRESS_QUEUE_BATCH_SIZE` | Batch size for on-demand token balance address queue. Introduced in [#14265](https://github.com/poanetwork/blockscout/pull/14265) | Version: v11.0.1\+
Default: `50`
Applications: API, Indexer | +| `PENDING_OPERATIONS_HELPER_TRANSACTIONS_BATCH_SIZE` | Batch size for transactions when processing pending operations. Implemented in [#14273](https://github.com/blockscout/blockscout/pull/14273). | Version: v11.0.1\+
Default: `1000`
Applications: Indexer | +| `PENDING_OPERATIONS_HELPER_BLOCKS_BATCH_SIZE` | Batch size for blocks when processing pending operations. Implemented in [#14273](https://github.com/blockscout/blockscout/pull/14273). | Version: v11.0.1\+
Default: `10`
Applications: Indexer | + + + +## 11.0.0 + +### 🚀 Features + +- Async CSV export ([#14028](https://github.com/blockscout/blockscout/issues/14028)) +- FHE operations and tags ([#13742](https://github.com/blockscout/blockscout/issues/13742)) +- Restore BENS preloads on the main page under toggle and add blocks BENS preload toggle ([#14262](https://github.com/blockscout/blockscout/pull/14262)) +- /api/legacy/* wrappers for three ES-compatible RPC endpoints ([#14239](https://github.com/blockscout/blockscout/pull/14239)) +- Make token balances import chunk size configurable ([#14250](https://github.com/blockscout/blockscout/pull/14250)) +- Add toggle to disable transactions / token transfers BENS preload ([#14159](https://github.com/blockscout/blockscout/issues/14159)) +- Add ENS and metadata preloading in block channel ([#12074](https://github.com/blockscout/blockscout/issues/12074)) +- Add validation for IPFS links before sending requests to gateway ([#14131](https://github.com/blockscout/blockscout/issues/14131)) +- Add search by token address hash in /api/v2/tokens ([#14102](https://github.com/blockscout/blockscout/issues/14102)) +- Add :rename heavy index db operation type and implement zero-downtime index replacement for transactions table ([#14052](https://github.com/blockscout/blockscout/issues/14052)) +- Use libraries field from eth bytecode db response ([#13948](https://github.com/blockscout/blockscout/issues/13948)) + +### 🐛 Bug Fixes + +- Fix filecoin view error ([#14255](https://github.com/blockscout/blockscout/pull/14255)) +- Internal transactions on-demand fetcher: check existence of deleted internal transactions address placeholders ([#14249](https://github.com/blockscout/blockscout/pull/14249)) +- Fix OnDemand.InternalTransaction fetcher ([#14242](https://github.com/blockscout/blockscout/pull/14242)) +- Guard missing ETS table in contract creator fetcher ([#14241](https://github.com/blockscout/blockscout/pull/14241)) +- Address ids usage improvements ([#14240](https://github.com/blockscout/blockscout/pull/14240)) +- Fix timeouts for API v1 tokentx endpoint ([#14185](https://github.com/blockscout/blockscout/issues/14185)) +- Remove internal transaction error field references ([#14213](https://github.com/blockscout/blockscout/pull/14213)) +- Handle partial errors in ContractCode fetch_codes ([#14211](https://github.com/blockscout/blockscout/pull/14211)) +- Include bridged token query params in OpenAPI spec ([#14209](https://github.com/blockscout/blockscout/pull/14209)) +- Update changed constraint name in shrink IT migration ([#14205](https://github.com/blockscout/blockscout/pull/14205)) +- Fix contract internal transactions preload ([#14203](https://github.com/blockscout/blockscout/issues/14203)) +- Handle RPC errors in ContractCreator, limit retries to 5 ([#14136](https://github.com/blockscout/blockscout/issues/14136)) +- Prevent duplicate missing block range inserts ([#14138](https://github.com/blockscout/blockscout/issues/14138)) +- Implementation address hash retrieval logic in the old UI ([#14192](https://github.com/blockscout/blockscout/issues/14192)) +- Keycloak address displaying ([#14155](https://github.com/blockscout/blockscout/issues/14155)) +- Celo election rewards csv export ([#14160](https://github.com/blockscout/blockscout/issues/14160)) +- Sync GraphQL language enum with SmartContract schema ([#14109](https://github.com/blockscout/blockscout/issues/14109)) +- Don't insert PTO for non-traceable transactions ([#14133](https://github.com/blockscout/blockscout/issues/14133)) +- Fix pending ops migration overflow by adaptive batching and chunked inserts ([#14135](https://github.com/blockscout/blockscout/issues/14135)) +- State changes use token transfer type ([#14073](https://github.com/blockscout/blockscout/issues/14073)) +- Fix 500 error when apikey provided with disabled account ([#14064](https://github.com/blockscout/blockscout/issues/14064)) +- Fix ArgumentError in BlockScoutWeb.NFTHelper.get_media_src/2 ([#14051](https://github.com/blockscout/blockscout/issues/14051)) + +### 🚜 Refactor + +- Refactor RollupReorgMonitorQueue ([#14196](https://github.com/blockscout/blockscout/issues/14196)) +- Deduplicate json_rpc_named_arguments ([#14194](https://github.com/blockscout/blockscout/issues/14194)) +- Fully migrate to `language` enum field in `smart_contracts` table ([#14049](https://github.com/blockscout/blockscout/issues/14049)) +- Migrate address_names to composite primary key on (address_hash, name) ([#14078](https://github.com/blockscout/blockscout/issues/14078)) + +### 📚 Documentation + +- Add .dialyzer-ignore hygiene guideline to CONTRIBUTING ([#14199](https://github.com/blockscout/blockscout/issues/14199)) + +### ⚡ Performance + +- Optimize token1155tx API v1 endpoint ([#14202](https://github.com/blockscout/blockscout/issues/14202)) +- Optimize optional address preloads across tx endpoints ([#14165](https://github.com/blockscout/blockscout/pull/14165)) +- Optimize on demand hot contracts performance ([#14150](https://github.com/blockscout/blockscout/issues/14150)) +- Remove join to "blocks" in api/v2/blocks/:block_number/transactions API endpoint ([#14162](https://github.com/blockscout/blockscout/issues/14162)) +- Improve performance of /api/v2/tokens API endpoint ([#14158](https://github.com/blockscout/blockscout/issues/14158)) + +### ⚙️ Miscellaneous Tasks + +- Update LICENCE ([#14201](https://github.com/blockscout/blockscout/pull/14201)) +- Remove "transaction_hash", "block_hash" and "block_index" from internal transactions, migrate Address Hashes to Address IDs ([#14099](https://github.com/blockscout/blockscout/issues/14099)) +- Remove timeout for test for FillInternalTransactionsAddressIds ([#14266](https://github.com/blockscout/blockscout/pull/14266)) +- Increase default timeout for FillInternalTransactionsAddressIds ([#14264](https://github.com/blockscout/blockscout/pull/14264)) +- Expand action of API_DISABLE_CONTRACT_CREATION_INTERNAL_TRANSACTION_ASSOCIATION flag to preload smart-contract associations ((#14257)[https://github.com/blockscout/blockscout/pull/14257]) +- Add Autoscout promo in the logs ([#14234](https://github.com/blockscout/blockscout/pull/14234)) +- Improve internal transactions migrations ([#14233](https://github.com/blockscout/blockscout/pull/14233)) +- Remove unused Explorer.Chain.Address.find_contract_addresses/2 function ([#14220](https://github.com/blockscout/blockscout/pull/14220)) +- Prevent deadlocks in IT fields removing migration ([#14215](https://github.com/blockscout/blockscout/pull/14215)) +- Filter blocks by BLOCK_RANGES in add_ranges_by_block_numbers ([#13875](https://github.com/blockscout/blockscout/pull/13875)) +- FillInternalTransactionsAddressIds improvements ([#14208](https://github.com/blockscout/blockscout/pull/14208)) +- Remove timeout between successful migrations ([#14198](https://github.com/blockscout/blockscout/issues/14198)) +- Add batch size env for FillInternalTransactionsAddressIds migration ([#14204](https://github.com/blockscout/blockscout/issues/14204)) +- Add Celo OpenAPI specs ([#14197](https://github.com/blockscout/blockscout/issues/14197), [#14229](https://github.com/blockscout/blockscout/pull/14229)) +- Cover counters to multichain export with unit tests ([#14193](https://github.com/blockscout/blockscout/issues/14193)) +- Update credo config ([#14147](https://github.com/blockscout/blockscout/issues/14147)) +- Remove Polygon zkEVM support ([#14188](https://github.com/blockscout/blockscout/issues/14188)) +- Add Block.full_refetch ([#14180](https://github.com/blockscout/blockscout/issues/14180)) +- Remove deprecated files from the root folder ([#14186](https://github.com/blockscout/blockscout/issues/14186)) +- Remove deprecated "transaction actions" indexer ([#14183](https://github.com/blockscout/blockscout/issues/14183)) +- Stabilize various flaky tests ([#14149](https://github.com/blockscout/blockscout/issues/14149)) +- Return automatic chromedriver version definition ([#14108](https://github.com/blockscout/blockscout/issues/14108)) +- Move agents skills to .agents/skills folder ([#14081](https://github.com/blockscout/blockscout/issues/14081)) +- Put in order background db migrations on the "transactions" table ([#14077](https://github.com/blockscout/blockscout/issues/14077)) +- Drop `transactions_operator_fee_constant_index` ([#14066](https://github.com/blockscout/blockscout/issues/14066)) +- Unescape ampersand in token's metadata ([#14055](https://github.com/blockscout/blockscout/issues/14055)) +- Treat blocks with huge amount of transactions as massive ([#13994](https://github.com/blockscout/blockscout/issues/13994)) +- Add OpenAPI docs for Scroll and Zilliqa endpoints ([#13972](https://github.com/blockscout/blockscout/issues/13972)) +- Add all-in-one open API spec file ([#14050](https://github.com/blockscout/blockscout/issues/14050)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `DISABLE_BLOCK_BROADCAST_ENRICHMENT` | If `true`, disables ENS and metadata enrichment for `new_block` WebSocket broadcasts. Implemented in [#12074](https://github.com/blockscout/blockscout/pull/12074). | Version: v11.0.0\+
Default: (empty)
Applications: API | +| `INDEXER_MASSIVE_BLOCK_THRESHOLD` | Max transactions count in a single block after which the block is treated as massive. Implemented in [#13994](https://github.com/blockscout/blockscout/pull/13994). | Version: v11.0.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_CURRENT_TOKEN_BALANCES_IMPORT_CHUNK_SIZE` | Number of CurrentTokenBalances items processed per chunk in token balances import. Default is 50; effective minimum is 1. Implemented in [#14250](https://github.com/blockscout/blockscout/pull/14250). | Version: v11.0.0\+
Default: `50`
Applications: Indexer | +| `INDEXER_FHE_OPERATIONS_ENABLED` | Flag to enable parsing of Fully Homomorphic Encryption (FHE) operations from transactions. Implemented in [#13742](https://github.com/blockscout/blockscout/pull/13742). | Version: v11.0.0\+
Default: `false`
Applications: Indexer | +| `MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_BATCH_SIZE` | Number of internal transactions to fill their address ids in the batch. Implemented in [#14204](https://github.com/blockscout/blockscout/pull/14204). | Version: v11.0.0\+
Default: `30`
Applications: Indexer | +| `MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_TIMEOUT` | Timeout between filling internal transactions address ids batches processing. Implemented in [#14208](https://github.com/blockscout/blockscout/pull/14208). | Version: v11.0.0\+
Default: `5s`
Applications: Indexer | +| `DISABLE_BLOCKS_BENS_PRELOAD` | If `true`, skips ENS name preloading in responses for block list endpoints: `/api/v2/blocks`, `/api/v2/main-page/blocks`, `/api/v2/blocks/optimism-batch/:batch_number`, `/api/v2/blocks/scroll-batch/:batch_number`. | Version: v11.0.0+
Default: `false`
Applications: API | +| `DISABLE_TRANSACTIONS_BENS_PRELOAD` | If `true`, skips ENS name preloading in responses for transaction list endpoints: `/api/v2/transactions`, `/api/v2/transactions/watchlist`, `/api/v2/main-page/transactions`, `/api/v2/main-page/transactions/watchlist`, `/api/v2/addresses/:hash/transactions`, `/api/v2/blocks/:hash/transactions`. | Version: v11.0.0+
Default: `false`
Applications: API | +| `DISABLE_TOKEN_TRANSFERS_BENS_PRELOAD` | If `true`, skips ENS name preloading in responses for token transfer list endpoints: `/api/v2/token-transfers`, `/api/v2/addresses/:hash/token-transfers`, `/api/v2/tokens/:address_hash_param/transfers`. | Version: v11.0.0+
Default: `false`
Applications: API | +| `CSV_EXPORT_ASYNC_ENABLED` | Enables async CSV export for supported endpoints. When enabled, the API returns `202 Accepted` with a `request_id` and processes exports through Oban instead of streaming them directly. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `false`
Applications: API | +| `CSV_EXPORT_ASYNC_OBAN_CONCURRENCY` | Sets Oban concurrency for the `csv_export` queue used by async CSV exports. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `10`
Applications: API | +| `CSV_EXPORT_ASYNC_GOKAPI_URL` | Base URL of the Gokapi instance used to store completed async CSV exports. Trailing slash is stripped during validation. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: Yes, if async export is enabled
Default: (empty)
Applications: API | +| `CSV_EXPORT_ASYNC_GOKAPI_API_KEY` | API key sent to Gokapi in the `apikey` header for async CSV export uploads. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: Yes, if async export is enabled
Default: (empty)
Applications: API | +| `CSV_EXPORT_ASYNC_MAX_PENDING_TASKS_PER_IP` | Maximum number of pending async CSV export requests allowed per client IP at once. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `3`
Applications: API | +| `CSV_EXPORT_ASYNC_UPLOAD_CHUNK_SIZE` | Chunk size in bytes for reading the generated CSV file and uploading it to Gokapi. Should be synchronized with Gokapi settings. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `47185920`
Applications: API | +| `CSV_EXPORT_DB_TIMEOUT` | Timeout for CSV export database work. Follows the [time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `1h` if async export is enabled, otherwise `5m`
Applications: API | +| `CSV_EXPORT_ASYNC_TMP_DIR` | Directory used for in-progress async CSV export files before they are uploaded to Gokapi. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `/tmp/csv_export`
Applications: API | +| `CSV_EXPORT_ASYNC_GOKAPI_TIMEOUT` | HTTP timeout and `recv_timeout` used for Gokapi requests during async CSV export. Follows the [time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `60s`
Applications: API | +| `CSV_EXPORT_ASYNC_GOKAPI_UPLOAD_EXPIRY_DAYS` | Sets Gokapi `expiryDays` for completed async CSV export uploads. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `1`
Applications: API | +| `CSV_EXPORT_ASYNC_GOKAPI_UPLOAD_ALLOWED_DOWNLOADS` | Sets Gokapi `allowedDownloads` for completed async CSV export uploads. Implemented in [#14028](https://github.com/blockscout/blockscout/pull/14028) | Version: v11.0.0\+ Required: No
Default: `1`
Applications: API | + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Need recompile | Deprecated in Version | +| -------- | ----------- | ------- | ------- | -------------- | --------------------- | +| Deprecated `INDEXER_POLYGON_ZKEVM_BATCHES_ENABLED` | Enables Polygon zkEVM batches fetcher. Implemented in [#7584](https://github.com/blockscout/blockscout/pull/7584). | `false` | v5.3.1+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_BATCHES_CHUNK_SIZE` | The number of Polygon zkEVM batches in one chunk when reading them from RPC. Implemented in [#7584](https://github.com/blockscout/blockscout/pull/7584). | `20` | v5.3.1+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_BATCHES_RECHECK_INTERVAL` | The latest batch rechecking interval, seconds. Implemented in [#7584](https://github.com/blockscout/blockscout/pull/7584). | `60` | v5.3.1+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_BATCHES_IGNORE` | Comma-separated list of batch numbers that should be ignored by the fetcher. Implemented in [#12387](https://github.com/blockscout/blockscout/pull/12387). | (empty) | v9.0.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_RPC` | The RPC endpoint for L1 used to fetch Deposit or Withdrawal bridge events. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | (empty) | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK` | The number of a start block on L1 to index L1 bridge events. If the table of bridge operations is not empty, the process will continue indexing from the last indexed L1 event. If empty or not defined, the L1 events are not handled. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | (empty) | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_CONTRACT` | The address of PolygonZkEVMBridgeV2 contract on L1 used to fetch L1 bridge events. Required for L1 bridge events indexing. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | (empty) | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NETWORK_ID` | L1 Network ID in terms of Polygon zkEVM bridge (0 = Ethereum Mainnet, 1 = Polygon zkEVM, 2 = Astar zkEVM, etc.). Required if `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK` or `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK` is defined. Implemented in [#9637](https://github.com/blockscout/blockscout/pull/9637). | (empty) | v6.4.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_ROLLUP_INDEX` | L1 Rollup index in terms of Polygon zkEVM bridge (0 = Polygon zkEVM, 1 = Astar zkEVM, etc.). Not defined if L1 is Ethereum Mainnet. Required if L1 is not Ethereum Mainnet and `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK` or `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK` is defined. Implemented in [#9637](https://github.com/blockscout/blockscout/pull/9637). | (empty) | v6.4.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NATIVE_SYMBOL` | The symbol of the native coin on L1 to display it in the table of the bridge Deposits and Withdrawals on UI. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | `ETH` | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NATIVE_DECIMALS` | The number of decimals to correctly display an amount of native coins for some Deposit or Withdrawal bridge operations on UI. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | `18` | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK` | The number of a start block on L2 to index L2 bridge events. If the table of bridge operations is not empty, the process will continue indexing from the last indexed L2 event. If empty or not defined, the L2 events are not handled. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | (empty) | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_CONTRACT` | The address of PolygonZkEVMBridgeV2 contract on L2 used to fetch L2 bridge events. Required for L2 bridge events indexing. Implemented in [#9098](https://github.com/blockscout/blockscout/pull/9098). | (empty) | v6.2.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_NETWORK_ID` | L2 Network ID in terms of Polygon zkEVM bridge (1 = Polygon zkEVM, 2 = Astar zkEVM, etc.). Required if `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK` or `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK` is defined. Implemented in [#9637](https://github.com/blockscout/blockscout/pull/9637). | (empty) | v6.4.0+ | | v11.0.0+ | +| Deprecated `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_ROLLUP_INDEX` | L2 Rollup index in terms of Polygon zkEVM bridge (0 = Polygon zkEVM, 1 = Astar zkEVM, etc.). Required if `INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK` or `INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK` is defined. Implemented in [#9637](https://github.com/blockscout/blockscout/pull/9637). | (empty) | v6.4.0+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_AAVE_V3_POOL_CONTRACT` | Pool contract address for Aave v3 protocol. If not defined, Aave transaction actions are ignored by the indexer. Implemented in [#7185](https://github.com/blockscout/blockscout/pull/7185). | (empty) | v5.1.3+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_ENABLE` | If `true`, transaction action indexer is active. Implemented in [#6582](https://github.com/blockscout/blockscout/pull/6582). | `false` | v5.1.0+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_MAX_TOKEN_CACHE_SIZE` | Maximum number of items in an internal cache of tx actions indexing process (to limit memory consumption). Implemented in [#6582](https://github.com/blockscout/blockscout/pull/6582). | `100000` | v5.1.0+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_REINDEX_FIRST_BLOCK` | The first block of a block range for historical indexing or reindexing of tx actions. Implemented in [#6582](https://github.com/blockscout/blockscout/pull/6582). | (empty) | v5.1.0+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_REINDEX_LAST_BLOCK` | The last block of a block range for historical indexing or reindexing of tx actions. Implemented in [#6582](https://github.com/blockscout/blockscout/pull/6582). | (empty) | v5.1.0+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_REINDEX_PROTOCOLS` | Comma-separated names of protocols which should be indexed or reindexed on historical blocks defined by the range. Example: `uniswap_v3,zkbob` - only these protocols will be indexed or reindexed for the defined block range. If the value is empty string (or not defined), all supported protocols will be indexed/reindexed. This option is not applicable to `realtime` and `catchup` fetchers (it always indexes all supported protocols). Implemented in [#6582](https://github.com/blockscout/blockscout/pull/6582). | (empty) | v5.1.0+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_UNISWAP_V3_FACTORY_CONTRACT` | UniswapV3Factory contract address. Implemented in [#7312](https://github.com/blockscout/blockscout/pull/7312). | `0x1F98431c8aD98523631AE4a59f267346ea31F984` | v5.1.4+ | | v11.0.0+ | +| Deprecated `INDEXER_TX_ACTIONS_UNISWAP_V3_NFT_POSITION_MANAGER_CONTRACT` | NonfungiblePositionManager contract address for Uniswap v3. Implemented in [#7312](https://github.com/blockscout/blockscout/pull/7312). | `0xC36442b4a4522E871399CD717aBDD847Ab11FE88` | v5.1.4+ | | v11.0.0+ | +| Deprecated `MIGRATION_REINDEX_DUPLICATED_INTERNAL_TRANSACTIONS_BATCH_SIZE` | Number of internal transactions to reindex in the batch. Implemented in [#12394](https://github.com/blockscout/blockscout/pull/12394). | `100` | v8.1.0+ | | v11.0.0+ | +| Deprecated `MIGRATION_REINDEX_DUPLICATED_INTERNAL_TRANSACTIONS_CONCURRENCY` | Number of parallel reindexing internal transaction batches processing. Implemented in [#12394](https://github.com/blockscout/blockscout/pull/12394). | `1` | v8.1.0+ | | v11.0.0+ | +| Deprecated `MIGRATION_REINDEX_DUPLICATED_INTERNAL_TRANSACTIONS_TIMEOUT` | Timeout between reindexing internal transaction batches processing. Implemented in [#12394](https://github.com/blockscout/blockscout/pull/12394). | `0` | v8.1.0+ | | v11.0.0+ | + + +## 10.2.6 + +### 🐛 Bug Fixes + +- Fix PendingTransactionsSanitizer ([#14235](https://github.com/blockscout/blockscout/issues/14235)) + + +## 10.2.5 + +### 🐛 Bug Fixes + +- Update changed constraint name in shrink IT migration ([#14205](https://github.com/blockscout/blockscout/issues/14205)) + + +## 10.2.4 + +### ⚡ Performance + +- Use tuple-based comparison to utilize index ([#14178](https://github.com/blockscout/blockscout/pull/14178)) + +### 🐛 Bug Fixes + +- Update transaction from receipt in PendingTransactionsSanitizer ([#14182](https://github.com/blockscout/blockscout/issues/14182)) + +### ⚙️ Miscellaneous Tasks + +- Add swagger generation for Arc and Suave chain types ([#14181](https://github.com/blockscout/blockscout/issues/14181)) + + +## 10.2.3 + +### 🐛 Bug Fixes + +- Allow fetching of stale token balances ([#14154](https://github.com/blockscout/blockscout/issues/14154)) + + +## 10.2.2 + +### 🐛 Bug Fixes + +- Fix token transfers block_consensus setting ([#14005](https://github.com/blockscout/blockscout/issues/14005)) +- OP Withdrawals indexer enhancement ([#13436](https://github.com/blockscout/blockscout/issues/13436)) + +### ⚙️ Miscellaneous Tasks + +- Add token transfer consensus sanitizer ([#14144](https://github.com/blockscout/blockscout/issues/14144)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `INDEXER_TOKEN_TRANSFER_BLOCK_CONSENSUS_SANITIZER_INTERVAL` | Interval for token transfer block consensus sanitizer. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14144](https://github.com/blockscout/blockscout/pull/14144). | Version: v10.2.2\+
Default: `20m`
Applications: Indexer | + + +## 10.2.1 + +### 🐛 Bug Fixes + +- Notify.check_auth0 for Keycloak and Dynamic ([#14146](https://github.com/blockscout/blockscout/issues/14146)) + +## 10.2.0 + +### 🚀 Features + +- Fetch transaction receipts by block ([#14046](https://github.com/blockscout/blockscout/issues/14046)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `ETHEREUM_JSONRPC_RECEIPTS_BY_BLOCK` | If `true`, block fetchers will fetch transaction receipts by block instead of per transaction. Implemented in [#14046](https://github.com/blockscout/blockscout/pull/14046) | Version: v10.2.0\+
Default: `false`
Applications: API, Indexer | +| `ETHEREUM_JSONRPC_MAX_RECEIPTS_BY_BLOCK` | Max number of transactions in block for which receipts will be fetched by block. If block has more transactions, receipts will be fetched per transaction in purpose of reducing response body size. Implemented in [#14046](https://github.com/blockscout/blockscout/pull/14046) | Version: v10.2.0\+
Default: `1000`
Applications: API, Indexer | + + +## 10.1.1 + +### 🐛 Bug Fixes + +- Authentication provider token redis key ([#14137](https://github.com/blockscout/blockscout/issues/14137)) + + +## 10.1.0 + +### 🚀 Features + +- KeyCloak integration ([#14068](https://github.com/blockscout/blockscout/issues/14068)) + +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `ACCOUNT_SENDGRID_OTP_TEMPLATE` | Sendgrid email OTP template for login with email functionality. Implemented in [#14068](https://github.com/blockscout/blockscout/pull/14068). | Version: v9.4.0\+
Default: (empty)
Applications: API | +| `ACCOUNT_KEYCLOAK_DOMAIN` | Domain for [Keycloak](https://www.keycloak.org/). Implemented in [#14068](https://github.com/blockscout/blockscout/pull/14068). | Version: v9.4.0\+
Default: (empty)
Applications: API | +| `ACCOUNT_KEYCLOAK_REALM` | Realm for [Keycloak](https://www.keycloak.org/). Implemented in [#14068](https://github.com/blockscout/blockscout/pull/14068). | Version: v9.4.0\+
Default: (empty)
Applications: API | +| `ACCOUNT_KEYCLOAK_CLIENT_ID` | [Keycloak](https://www.keycloak.org/) client ID. Implemented in [#14068](https://github.com/blockscout/blockscout/pull/14068). | Version: v9.4.0\+
Default: (empty)
Applications: API | +| `ACCOUNT_KEYCLOAK_CLIENT_SECRET` | [Keycloak](https://www.keycloak.org/) client secret. Implemented in [#14068](https://github.com/blockscout/blockscout/pull/14068). | Version: v9.4.0\+
Default: (empty)
Applications: API | +| `ACCOUNT_KEYCLOAK_EMAIL_WEBHOOK_URL` | URL address where new email users are reported. Implemented in [#14068](https://github.com/blockscout/blockscout/pull/14068). | Version: v9.4.0\+
Default: (empty)
Applications: API | + + +## 10.0.8 + +### 🐛 Bug Fixes + +- Zetachain internal txs fetching error ([#14122](https://github.com/blockscout/blockscout/issues/14122)) + + +## 10.0.7 + +### 🐛 Bug Fixes + +- Add missing DenormalizationHelper alias in state changes ([#14119](https://github.com/blockscout/blockscout/issues/14119)) + + +## 10.0.6 + +### 🐛 Bug Fixes + +- Add dependency between heavy internal transactions migrations ([#14107](https://github.com/blockscout/blockscout/issues/14107)) + + +## 10.0.5 + +### 🐛 Bug Fixes + +- Add missing query params in user ops swagger spec ([#14104](https://github.com/blockscout/blockscout/issues/14104)) +- State changes handle ERC-7984; nil tx.value ([#14101](https://github.com/blockscout/blockscout/issues/14101)) + + +## 10.0.4 + +### 🐛 Bug Fixes + +- `confirm_otp` after `OpenApiSpex` integration ([#14098](https://github.com/blockscout/blockscout/issues/14098)) + + +## 10.0.3 + +### ⚙️ Miscellaneous Tasks + +- Allow disabling contract creation internal transaction association ([#14090](https://github.com/blockscout/blockscout/issues/14090), [#14097](https://github.com/blockscout/blockscout/pull/14097)) + + +## 10.0.2 + +### ⚙️ Miscellaneous Tasks + +- Add missing TokenBalance.Current launch in tests ([#14076](https://github.com/blockscout/blockscout/issues/14076)) +- Put backend versions into constants on launch ([#14072](https://github.com/blockscout/blockscout/issues/14072)) + + +## 10.0.1 + +### ⚡ Performance + +- Fix /advanced-filters timeout when scam filtering enabled ([#14047](https://github.com/blockscout/blockscout/issues/14047)) + +## 10.0.0 + +### 🚀 Features + +- ERC-7984 Confidential Tokens ([#13593](https://github.com/blockscout/blockscout/pull/13593), [#14019](https://github.com/blockscout/blockscout/pull/14019), [#14022](https://github.com/blockscout/blockscout/pull/14022), [#14023](https://github.com/blockscout/blockscout/pull/14023)) +- Move current token balances into a separate fetcher ([#13923](https://github.com/blockscout/blockscout/issues/13923)) +- Re-architect internal transaction format with call-type enum, error dictionary, and normalization ([#13893](https://github.com/blockscout/blockscout/issues/13893), [#14042](https://github.com/blockscout/blockscout/pull/14042), [#14043](https://github.com/blockscout/blockscout/pull/14043)) +- Add audit-reports import endpoint ([#13884](https://github.com/blockscout/blockscout/issues/13884)) +- Solady smart-contract proxy with immutable arguments support ([#13794](https://github.com/blockscout/blockscout/issues/13794)) +- Optionally accrue burnt fees to the block miner ([#13894](https://github.com/blockscout/blockscout/issues/13894)) +- Allow adding EOA with code to watchlist ([#13885](https://github.com/blockscout/blockscout/issues/13885)) +- Add Dynamic provider for account ([#13786](https://github.com/blockscout/blockscout/issues/13786)) +- Distributed cache ([#13698](https://github.com/blockscout/blockscout/issues/13698)) +- Return timestamps in the event logs list API endpoints ([#13779](https://github.com/blockscout/blockscout/issues/13779)) +- Support EigenDA blobs by Optimism batch indexer ([#13709](https://github.com/blockscout/blockscout/issues/13709)) +- `txlistinternal` API endpoint pending item status ([#13758](https://github.com/blockscout/blockscout/issues/13758)) +- Setup universal proxy config from the JSON content in ENV variable ([#13787](https://github.com/blockscout/blockscout/issues/13787)) +- Missed L1-to-L2 messages catchup on Arbitrum rollups ([#13792](https://github.com/blockscout/blockscout/issues/13792)) +- Expose CHAIN_TYPE in the REST API ([#13805](https://github.com/blockscout/blockscout/issues/13805)) +- REST API endpoint to list uncompleted DB migrations ([#13835](https://github.com/blockscout/blockscout/issues/13835)) +- Show ENS domains without resolved address in search ([#13638](https://github.com/blockscout/blockscout/issues/13638)) +- Mark contract addresses in search results ([#13636](https://github.com/blockscout/blockscout/issues/13636)) + +### 🐛 Bug Fixes + +- Handle nil coin balance in "broadcast_address_coin_balance/1" function ([#14044](https://github.com/blockscout/blockscout/pull/14044)) +- Fix duplicating paging params ([#14010](https://github.com/blockscout/blockscout/pull/14010)) +- Handle maybe_reject_zero_value for missing value ([#13990](https://github.com/blockscout/blockscout/pull/13990)) +- Handle internal transactions nil value([#13974](https://github.com/blockscout/blockscout/pull/13974)) +- `HttpClient.get` usage in genesis data module ([#13945](https://github.com/blockscout/blockscout/pull/13945)) +- Multichain counter starting time and small fixes ([#13920](https://github.com/blockscout/blockscout/pull/13920)) +- Fix 500 on empty ens domain search ([#13928](https://github.com/blockscout/blockscout/pull/13928)) +- Limit `getlogs` after filtering consensus ([#13934](https://github.com/blockscout/blockscout/pull/13934)) +- Handle nil in update_transactions_cache/2 ([#13911](https://github.com/blockscout/blockscout/pull/13911)) +- Fix token balances broadcasting function ([#13902](https://github.com/blockscout/blockscout/issues/13902)) +- Wrong `next_page_params` in OP Deposits ([#13870](https://github.com/blockscout/blockscout/issues/13870)) +- Fix error on loading thumbnails when public_r2_url is missed ([#13895](https://github.com/blockscout/blockscout/issues/13895)) +- Check token presence in address current token balance ([#13892](https://github.com/blockscout/blockscout/issues/13892)) +- Fix swagger generation for Mud chain +- Fix error in Indexer.Fetcher.OnDemand.TokenBalance module ([#13890](https://github.com/blockscout/blockscout/issues/13890)) +- Actualize indexer tests ([#13887](https://github.com/blockscout/blockscout/issues/13887)) +- Clear bytecode for smart-contracts self destructed in a separate transaction ([#13834](https://github.com/blockscout/blockscout/issues/13834)) +- Skip hot contracts fetching if last 30 days not indexed ([#13873](https://github.com/blockscout/blockscout/issues/13873)) +- Add block range filtering to TokenBalance fetcher ([#13874](https://github.com/blockscout/blockscout/issues/13874)) +- Filter traceable data in InternalTransaction.async_fetch ([#13872](https://github.com/blockscout/blockscout/issues/13872)) +- Take into account empty arrays in Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers ([#13852](https://github.com/blockscout/blockscout/issues/13852)) +- Fix search for ERC-1155 with null symbol ([#13632](https://github.com/blockscout/blockscout/issues/13632)) +- Return date to logs ([#13858](https://github.com/blockscout/blockscout/issues/13858)) +- Convert token id to string from refetch metadata in the socket ([#13762](https://github.com/blockscout/blockscout/issues/13762)) +- Prevent DeleteZeroValueInternalTransactions from running while ShrinkInternalTransactions is in progress ([#13847](https://github.com/blockscout/blockscout/issues/13847)) +- Remove contract code and verified data on lose consensus ([#13829](https://github.com/blockscout/blockscout/issues/13829), [#13905](https://github.com/blockscout/blockscout/pull/13905)) +- Exclude 0 index internal transactions from /api/v2/internal-transactions endpoint ([#13841](https://github.com/blockscout/blockscout/issues/13841)) +- Fix NaN gas limit for `selfdestruct` internal transaction in the REST API ([#13827](https://github.com/blockscout/blockscout/issues/13827)) +- Handle normal termination of Indexer.Fetcher.OnDemand.ContractCode process ([#13828](https://github.com/blockscout/blockscout/issues/13828)) +- Validate block number in the api/v2/blocks/:block_number API endpoint ([#13795](https://github.com/blockscout/blockscout/issues/13795)) +- Fix methodId detection ([#13811](https://github.com/blockscout/blockscout/issues/13811)) +- Improve Arbitrum L1->L2 message discovery for reorg and RPC consistency ([#13770](https://github.com/blockscout/blockscout/issues/13770)) + +### 🚜 Refactor + +- Improve error handling in `EthereumJSONRPC.execute_contract_function/3` ([#13764](https://github.com/blockscout/blockscout/issues/13764)) + +### ⚙️ Miscellaneous Tasks + +- Claim storage space from multichain - related queues tables ([#14025](https://github.com/blockscout/blockscout/pull/14025)) +- Improve pending block operations count metric ([#14024](https://github.com/blockscout/blockscout/pull/14024)) +- Add initial_stream delay to BufferedTask ([#14018](https://github.com/blockscout/blockscout/pull/14018), [#14020](https://github.com/blockscout/blockscout/pull/14020)) +- Adjust query for "missing_current_token_balances_count" indexer metric ([#14009](https://github.com/blockscout/blockscout/pull/14009)) +- Add support for new BENS api ([#13992](https://github.com/blockscout/blockscout/pull/13992)) +- Add internal transactions not null constraints ([#13976](https://github.com/blockscout/blockscout/pull/13976), [#13995](https://github.com/blockscout/blockscout/pull/13995)) +- Change 429 error text ([#13989](https://github.com/blockscout/blockscout/pull/13989)) +- Enhance indexer metrics calculation ([#13985](https://github.com/blockscout/blockscout/pull/13985)) +- Don't send historic rate for recent txs ([#13960](https://github.com/blockscout/blockscout/pull/13960)) +- Increase default for MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_BATCH_SIZE to 1000 ([#13953](https://github.com/blockscout/blockscout/pull/13953)) +- Improve EmptyInternalTransactionsData migration ([#13918](https://github.com/blockscout/blockscout/pull/13918)) +- Disable Auth0 when Dynamic enabled ([#13912](https://github.com/blockscout/blockscout/pull/13912)) +- Add swagger spec for account abstraction endpoints ([#13897](https://github.com/blockscout/blockscout/issues/13897)) +- Clear token "skip_metadata" property ([#13891](https://github.com/blockscout/blockscout/issues/13891)) +- Refactor internal transaction logic from "block_index" to "transaction_index" and "index" ([#12474](https://github.com/blockscout/blockscout/issues/12474), [#14029](https://github.com/blockscout/blockscout/pull/14029)) +- Cover Optimism API endpoints with swagger docs ([#13672](https://github.com/blockscout/blockscout/issues/13672)) +- Duplicate internal transaction created_contract_address_hash to to_address_hash ([#13846](https://github.com/blockscout/blockscout/issues/13846), [#14039](https://github.com/blockscout/blockscout/pull/14039), [#14040](https://github.com/blockscout/blockscout/pull/14040)) +- Add "openapi_spec_folder_name" to the response of api/v2/config/backend endpoint ([#13845](https://github.com/blockscout/blockscout/issues/13845)) +- Re-use parse_url_env_var/3 function for all *_URL env variables ([#13800](https://github.com/blockscout/blockscout/issues/13800)) +- Add swagger spec for MUD endpoints ([#13793](https://github.com/blockscout/blockscout/issues/13793)) +- Set unique block numbers in handle_partially_imported_blocks/1 ([#13657](https://github.com/blockscout/blockscout/issues/13657)) +- Disband 37% of Explorer.Chain module ([#13755](https://github.com/blockscout/blockscout/issues/13755)) +- Disable MissingRangesManipulator ([#13359](https://github.com/blockscout/blockscout/issues/13359)) +- Improve replica usage ([#13344](https://github.com/blockscout/blockscout/issues/13344)) +- Make "jsonrpc" field in response optional ([#13724](https://github.com/blockscout/blockscout/issues/13724)) + +### New ENV variables + +| Variable | Description | Parameters | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `BLOCK_MINER_GETS_BURNT_FEES` | If `true`, the Burnt fees are added to block miner profit and displayed in UI as zero. Implemented in [#13894](https://github.com/blockscout/blockscout/pull/13894). | Version: v10.0.0\+
Default: `false`
Applications: API | +| `UNIVERSAL_PROXY_CONFIG` | JSON-encoded configuration string used to define settings for the universal proxy. Implemented in [#13787](https://github.com/blockscout/blockscout/pull/13787). | Version: v10.0.0\+
Default: (empty)
Applications: API | +| `MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_BATCH_SIZE` | Number of internal transactions to clear their data in the batch. Implemented in [#13893](https://github.com/blockscout/blockscout/pull/13893). | Version: v10.0.0\+
Default: `1000`
Applications: Indexer | +| `MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_CONCURRENCY` | Number of parallel clearing internal transaction data batches processing. Implemented in [#13893](https://github.com/blockscout/blockscout/pull/13893). | Version: v10.0.0\+
Default: `1`
Applications: Indexer | +| `MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_TIMEOUT` | Timeout between clearing internal transaction data batches processing. Implemented in [#13893](https://github.com/blockscout/blockscout/pull/13893). | Version: v10.0.0\+
Default: `0`
Applications: Indexer | +| `CACHE_PENDING_OPERATIONS_COUNT_PERIOD` | Time interval to restart the task which calculates the total pending operations count. Introduced in [#12474](https://github.com/blockscout/blockscout/pull/12474). | Version: v10.0.0\+
Default: `5m`
Applications: API, Indexer | +| `ACCOUNT_DYNAMIC_ENV_ID` | Dynamic Environment ID, can be found here https://app.dynamic.xyz/dashboard/developer/api. Implemented in [#13786](https://github.com/blockscout/blockscout/pull/13786). | Version: v10.0.0\+
Default: (empty)
Applications: API | +| `INDEXER_OPTIMISM_L1_BATCH_EIGENDA_BLOBS_API_URL` | Defines a URL to DA indexer supporting EigenDA layer to retrieve L1 blobs from that. Example: `https://da-indexer-dev.k8s-prod-3.blockscout.com/api/v1/eigenda/v2/blobs`. Implemented in [#13709](https://github.com/blockscout/blockscout/pull/13709). | Version: v10.0.0+
Default: (empty)
Applications: Indexer | +| `INDEXER_OPTIMISM_L1_BATCH_EIGENDA_PROXY_BASE_URL` | Defines a URL to EigenDA proxy node which is used by the DA indexer (planned to be optional in the future). Example for MegaETH: `http://megaeth-eigenda-proxy.node.blockscout.com:3100`. Implemented in [#13709](https://github.com/blockscout/blockscout/pull/13709). | Version: v10.0.0+
Default: (empty)
Applications: Indexer | +| `INDEXER_ARBITRUM_MESSAGES_TRACKING_FAILURE_THRESHOLD` | The time threshold for L1 message tracking tasks. If a task has not run successfully within this threshold, it is marked as failed and enters a cooldown period before retrying. Implemented in [#13792](https://github.com/blockscout/blockscout/pull/13792). | Version: v10.0.0+
Default: `10m`
Applications: Indexer | +| `INDEXER_ARBITRUM_MISSED_MESSAGE_IDS_RANGE` | Size of each message ID range inspected when discovering L1-to-L2 messages with missing L1 origination information. Implemented in [#13792](https://github.com/blockscout/blockscout/pull/13792). | Version: v10.0.0+
Default: `10000`
Applications: Indexer | +| `INDEXER_CURRENT_TOKEN_BALANCES_BATCH_SIZE` | Batch size for current token balances fetcher. Implemented in [#13923](https://github.com/blockscout/blockscout/pull/13923). | Version: v10.0.0+
Default: `100`
Applications: Indexer | +| `INDEXER_CURRENT_TOKEN_BALANCES_CONCURRENCY` | Concurrency for current token balances fetcher. Implemented in [#13923](https://github.com/blockscout/blockscout/pull/13923). | Version: v10.0.0+
Default: `10`
Applications: Indexer | + + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Deprecated in Version | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------- | +| `CACHE_PBO_COUNT_PERIOD` | Time interval to restart the task which calculates the total pending_block_operations count. | `20m` | v5.2.0+ | | v10.0.0 | + + +## 9.3.7 + +### ⚙️ Miscellaneous Tasks + +- Allow disabling contract creation internal transaction association ([#14090](https://github.com/blockscout/blockscout/issues/14090), [#14097](https://github.com/blockscout/blockscout/pull/14097)) + + +## 9.3.6 + +### ⚡ Performance + +- Fix /advanced-filters timeout when scam filtering enabled ([#14047](https://github.com/blockscout/blockscout/pull/14047)) + + +## 9.3.5 + +### 🐛 Bug Fixes + +- Fix block reindex condition in ContractCreator on-demand ([#13831](https://github.com/blockscout/blockscout/issues/13831)) + + +## 9.3.4 + +### ⚡ Performance + +- Fix /token-transfers timeout when filtering scam tokens enabled ([#13973](https://github.com/blockscout/blockscout/pull/13973)) + + +## 9.3.3 + +### ⚙️ Miscellaneous Tasks + +- Replace ZeroValueDeleteQueue with filtering on import ([#13921](https://github.com/blockscout/blockscout/pull/13921), [#13947](https://github.com/blockscout/blockscout/pull/13947)) +- Allow to set IT storage period not only in days ([#13932](https://github.com/blockscout/blockscout/pull/13932)) + +### New ENV variables + +| Variable | Description | Parameters | +|---------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_STORAGE_PERIOD` | Specifies the period for which recent zero-value calls won't be deleted in delete zero-value calls migration. Implemented in [#13932](https://github.com/blockscout/blockscout/pull/13932). | Version: v9.3.3\+
Default: `30d`
Applications: Indexer | + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Deprecated in Version | +|--------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|---------|-----------|-----------------------| +| `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_STORAGE_PERIOD_DAYS` | Specifies the period for which recent zero-value calls won't be deleted in delete zero-value calls migration. | `30` | v9.3.0+ | v9.3.3 | + + +## 9.3.2 + +### 🐛 Bug Fixes + +- Handle_continue bad return value ([#13769](https://github.com/blockscout/blockscout/issues/13769)) +- Make `find_history_and_token_fetchers` public ([#13768](https://github.com/blockscout/blockscout/issues/13768)) +- Resolve TLS version issue on application startup ([#13767](https://github.com/blockscout/blockscout/issues/13767)) -## 8.0.0 +## 9.3.1 + +### 🐛 Bug Fixes + +- Fix blob transactions list API endpoint ([#13756](https://github.com/blockscout/blockscout/issues/13756)) + +## 9.3.0 + +### 🚀 Features + +- Update InternalTransactionsAddressPlaceholder upserts ([#13696](https://github.com/blockscout/blockscout/pull/13696)) +- Internal transactions on demand fetcher ([#13604](https://github.com/blockscout/blockscout/pull/13604)) +- Indexer config API endpoint ([#13679](https://github.com/blockscout/blockscout/pull/13679)) +- Add DIA market source ([#12678](https://github.com/blockscout/blockscout/issues/12678)) +- Add metadata to eth bytecode DB lookup request ([#13625](https://github.com/blockscout/blockscout/issues/13625)) +- Support ZRC-2 tokens for `zilliqa` chain type ([#13443](https://github.com/blockscout/blockscout/issues/13443)) +- Indexer monitor Prometheus metrics ([#13539](https://github.com/blockscout/blockscout/issues/13539), [#13668](https://github.com/blockscout/blockscout/pull/13668), [#13670](https://github.com/blockscout/blockscout/pull/13670)) +- Hot smart-contracts ([#13471](https://github.com/blockscout/blockscout/issues/13471), [#13669](https://github.com/blockscout/blockscout/pull/13669)) +- Support OP Jovian upgrade, other enhancements ([#13538](https://github.com/blockscout/blockscout/issues/13538)) +- Scope celo under optimism chain type ([#13375](https://github.com/blockscout/blockscout/issues/13375)) + +### 🐛 Bug Fixes + +- Fix tests for on-demand internal transaction fetcher ([#13744](https://github.com/blockscout/blockscout/pull/13744)) +- `batch_number` input param is now integer for OP and Scroll API endpoints ([#13727](https://github.com/blockscout/blockscout/pull/13727)) +- Set timeout: :infinity for delete zero value migration ([#13708](https://github.com/blockscout/blockscout/pull/13708)) +- Limit batch size for placeholders insertion ([#13699](https://github.com/blockscout/blockscout/pull/13699)) +- Add missed reputation fetch ([#13695](https://github.com/blockscout/blockscout/pull/13695)) +- Fix NFTMediaHandler postgres parameters overflow error ([#13694](https://github.com/blockscout/blockscout/pull/13694)) +- Add smart contract preload to hot contracts query ([#13691](https://github.com/blockscout/blockscout/pull/13691)) +- Restore fetcher name to dev console output ([#13681](https://github.com/blockscout/blockscout/pull/13681)) +- JSON RPC encoding for signed authorizations ([#13678](https://github.com/blockscout/blockscout/pull/13678)) +- Fix 500 for pending tx in tokentx RPC API endpoint ([#13666](https://github.com/blockscout/blockscout/pull/13666)) +- Fix 500 for pending tx in gettxinfo RPC API endpoint([#13665](https://github.com/blockscout/blockscout/pull/13665)) +- `Mix.env()` in `runtime.exs` ([#13641](https://github.com/blockscout/blockscout/issues/13641)) +- Celo aggregated election rewards migrator test ([#13639](https://github.com/blockscout/blockscout/issues/13639)) +- Fix filecoin web tests ([#13634](https://github.com/blockscout/blockscout/issues/13634)) +- Fix dialyzer test for filecoin chain type ([#13623](https://github.com/blockscout/blockscout/issues/13623)) +- Handle deposit status statement too complex ([#13588](https://github.com/blockscout/blockscout/issues/13588)) +- Beacon deposits: fallback to node ([#13425](https://github.com/blockscout/blockscout/issues/13425), [#13656](https://github.com/blockscout/blockscout/pull/13656)) +- Fix logic of disable token exchange rate ([#13414](https://github.com/blockscout/blockscout/issues/13414)) +- Null-checks for distribution field in celo epochs api ([#13457](https://github.com/blockscout/blockscout/issues/13457)) +- Reset ResetSanitizeDuplicatedLogsMigration status ([#13556](https://github.com/blockscout/blockscout/issues/13556)) +- Duplicated block numbers in int txs queue ([#13554](https://github.com/blockscout/blockscout/issues/13554)) +- Fix coin balance history - related normalize_balances_by_day/2 function ([#13515](https://github.com/blockscout/blockscout/issues/13515)) + +### 📚 Documentation + +- Update API endpoints descriptions in OpenAPI ([#13647](https://github.com/blockscout/blockscout/issues/13647)) + +### ⚡ Performance + +- Improve performance of api/v2/main-page/indexing-status endpoint ([#13730](https://github.com/blockscout/blockscout/pull/13730)) +- Implement celo aggregated election rewards ([#13418](https://github.com/blockscout/blockscout/issues/13418)) + +### ⚙️ Miscellaneous Tasks + +- GitHub Actions workflows: stop using ELIXIR_VERSION & OTP_VERSION from org/repo variables ([#13718](https://github.com/blockscout/blockscout/pull/13718)) +- Refactoring of the application mode config ([#13715](https://github.com/blockscout/blockscout/pull/13715)) +- Eliminate warnings in the Swagger file ([#13714](https://github.com/blockscout/blockscout/pull/13714)) +- Change URL to Solidity binaries list ([#13711](https://github.com/blockscout/blockscout/pull/13711)) +- Add osaka to the default list of supported EVM versions ([#13680](https://github.com/blockscout/blockscout/pull/13680)) +- Filter out empty addresses from multichain export ([#13674](https://github.com/blockscout/blockscout/pull/13674)) +- Validate NFT_MEDIA_HANDLER_BUCKET_FOLDER env ([#13671](https://github.com/blockscout/blockscout/pull/13671)) +- Enhance RPC API errors logging ([#13664](https://github.com/blockscout/blockscout/pull/13664)) +- Use chain id `31337` for `anvil` ([#13644](https://github.com/blockscout/blockscout/issues/13644)) +- Update devcontainer image to use Elixir 1.19.4 ([#13645](https://github.com/blockscout/blockscout/issues/13645)) +- Elixir 1.19.3 -> 1.19.4 ([#13643](https://github.com/blockscout/blockscout/issues/13643)) +- Update devcontainer image to use Elixir 1.19 ([#13637](https://github.com/blockscout/blockscout/issues/13637)) +- Internal transaction, Token transfer, Withdrawal, Smart-contracts, Main Page, Stats, Config and Search controllers OpenAPI specs ([#13557](https://github.com/blockscout/blockscout/issues/13557)) +- Using own runner for build ([#13624](https://github.com/blockscout/blockscout/issues/13624)) +- Drop token_instances_token_id_index index ([#13598](https://github.com/blockscout/blockscout/issues/13598)) +- Add migration to drop unique tokens_contract_address_hash_index index ([#13596](https://github.com/blockscout/blockscout/issues/13596), [#13655](https://github.com/blockscout/blockscout/pull/13655)) +- Elixir 1.17 -> 1.19 ([#13566](https://github.com/blockscout/blockscout/issues/13566)) +- Handle `NativeCoin*ed` events on Arc chain to make dual token balances synced ([#13452](https://github.com/blockscout/blockscout/issues/13452)) +- Improve DeleteZeroValueInternalTransactions migration ([#13569](https://github.com/blockscout/blockscout/issues/13569)) +- Remove address-related props from sending to multichain service ([#13584](https://github.com/blockscout/blockscout/issues/13584)) +- Move not auth cookies to headers ([#13478](https://github.com/blockscout/blockscout/issues/13478)) +- Transaction controller OpenAPI spec ([#13419](https://github.com/blockscout/blockscout/issues/13419)) +- Increase genesis file content fetch timeout ([#13527](https://github.com/blockscout/blockscout/issues/13527)) + +### New ENV variables + +| Variable | Description | Parameters | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `INDEXER_DISABLE_HOT_SMART_CONTRACTS_FETCHER` | If `true`, `Indexer.Fetcher.Stats.HotSmartContracts` won't be started. Implemented in [#13471](https://github.com/blockscout/blockscout/pull/13471). | Version: v9.3.0\+
Default: `false`
Applications: Indexer | +| `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_ENABLED` | Enable of delete zero-value calls migration. Implemented in [#13305](https://github.com/blockscout/blockscout/pull/13305). | Version: v9.3.0\+
Default: `false`
Applications: Indexer | +| `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_BATCH_SIZE` | Specifies the block batch size selected for the delete zero-value calls migration. Implemented in [#13305](https://github.com/blockscout/blockscout/pull/13305). | Version: v9.3.0\+
Default: `100`
Applications: Indexer | +| `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_STORAGE_PERIOD_DAYS` | Specifies the period for which recent zero-value calls won't be deleted in delete zero-value calls migration. Implemented in [#13305](https://github.com/blockscout/blockscout/pull/13305). | Version: v9.3.0\+
Default: `30`
Applications: Indexer | +| `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_CHECK_INTERVAL` | Specifies the interval between checking of new zero-value calls to be deleted in delete zero-value calls migration. Implemented in [#13305](https://github.com/blockscout/blockscout/pull/13305). | Version: v9.3.0\+
Default: `1m`
Applications: Indexer | +| `MARKET_DIA_BLOCKCHAIN` | Sets DIA platform from https://www.diadata.org/docs/reference/apis/token-prices/api-endpoints/blockchains. Implemented in [#12678](https://github.com/blockscout/blockscout/pull/12678). | Version: v9.3.0\+
Default: (empty)
Applications: Indexer | +| `MARKET_DIA_BASE_URL` | If set, overrides the DIA API url. Implemented in [#12678](https://github.com/blockscout/blockscout/pull/12678). | Version: v9.3.0\+
Default: `https://api.diadata.org/v1`
Applications: API, Indexer | +| `MARKET_DIA_COIN_ADDRESS_HASH` | Sets address hash for native coin in DIA. Implemented in [#12678](https://github.com/blockscout/blockscout/pull/12678). | Version: v9.3.0\+
Default: (empty)
Applications: API | +| `MARKET_DIA_SECONDARY_COIN_ADDRESS_HASH` | Sets address hash for secondary coin in DIA. Implemented in [#12678](https://github.com/blockscout/blockscout/pull/12678). | Version: v9.3.0\+
Default: (empty)
Applications: API | +| `INDEXER_METRICS_ENABLED` | Flag to enable base indexer metrics. Implemented in [#13539](https://github.com/blockscout/blockscout/pull/13539). | Version: v9.3.0\+
Default: true
Applications: Indexer | +| `INDEXER_METRICS_ENABLED_TOKEN_INSTANCES_NOT_UPLOADED_TO_CDN_COUNT` | Flag to enable indexer metric: the count of token instances not uploaded to CDN. Implemented in [#13539](https://github.com/blockscout/blockscout/pull/13539). | Version: v9.3.0\+
Default: false
Applications: Indexer | +| `INDEXER_METRICS_ENABLED_FAILED_TOKEN_INSTANCES_METADATA_COUNT` | Flag to enable indexer metric: the count of token instances with failed metadata fetches. Implemented in [#13539](https://github.com/blockscout/blockscout/pull/13539). | Version: v9.3.0\+
Default: true
Applications: Indexer | +| `INDEXER_METRICS_ENABLED_UNFETCHED_TOKEN_INSTANCES_COUNT` | Flag to enable indexer metric: the count of token instances pending to fetch. Implemented in [#13539](https://github.com/blockscout/blockscout/pull/13539). | Version: v9.3.0\+
Default: true
Applications: Indexer | +| `INDEXER_METRICS_ENABLED_MISSING_CURRENT_TOKEN_BALANCES_COUNT` | Flag to enable indexer metric: the count of current token balances with missing values. Implemented in [#13539](https://github.com/blockscout/blockscout/pull/13539). | Version: v9.3.0\+
Default: true
Applications: Indexer | +| `INDEXER_METRICS_ENABLED_MISSING_ARCHIVAL_TOKEN_BALANCES_COUNT` | Flag to enable indexer metric: the count of archival token balances with missing values. Implemented in [#13539](https://github.com/blockscout/blockscout/pull/13539). | Version: v9.3.0\+
Default: true
Applications: Indexer | +| `INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP` | Jovian upgrade L2 block timestamp. If set to `0`, the Jovian is assumed to be active from genesis block. Implemented in [#13538](https://github.com/blockscout/blockscout/pull/13538). | Version: v9.3.0+
Default: (empty)
Applications: API, Indexer | +| `INDEXER_ARC_NATIVE_TOKEN_DECIMALS` | Defines the number of decimals for Arc chain native token (e.g. USDC). Implemented in [#13452](https://github.com/blockscout/blockscout/pull/13452). | Version: v9.3.0+
Default: `6`
Applications: Indexer | +| `INDEXER_ARC_NATIVE_TOKEN_CONTRACT` | Arc chain native token contract address. Implemented in [#13452](https://github.com/blockscout/blockscout/pull/13452). | Version: v9.3.0+
Default: `0x3600000000000000000000000000000000000000`
Applications: Indexer | +| `INDEXER_ARC_NATIVE_TOKEN_SYSTEM_CONTRACT` | Arc chain system contract address emitting `NativeCoinTransferred` event. Implemented in [#13452](https://github.com/blockscout/blockscout/pull/13452). | Version: v9.3.0+
Default: `0x1800000000000000000000000000000000000000`
Applications: Indexer | + + +## 9.2.2 + +### 🐛 Bug Fixes + +- Fix next page params for tokens list API endpoint ([#13520](https://github.com/blockscout/blockscout/issues/13520)) + +## 9.2.1 + +### 🐛 Bug Fixes + +- Fix REST API token holders pagination ([#13500](https://github.com/blockscout/blockscout/issues/13500)) +- Add missing query binding to txlistinternal query ([#13479](https://github.com/blockscout/blockscout/issues/13479)) +- API v2 errors logging to the proper log file ([#13133](https://github.com/blockscout/blockscout/issues/13133)) + +### ⚙️ Miscellaneous Tasks + +- Silence multiple cspell complaints ([#13424](https://github.com/blockscout/blockscout/issues/13424)) + + +## 9.2.0 + +### 🚀 Features + +- distributed elixir runtime ([#13080](https://github.com/blockscout/blockscout/pull/13080)) +- Delete internal transactions on reorgs ([#13121](https://github.com/blockscout/blockscout/issues/13121)) +- Implement websocket endpoints support in the Universal Proxy config ([#13167](https://github.com/blockscout/blockscout/issues/13167)) +- Celo accounts api ([#12982](https://github.com/blockscout/blockscout/issues/12982)) +- Celo accounts indexing ([#12893](https://github.com/blockscout/blockscout/issues/12893)) +- OP operator fee indexing ([#13139](https://github.com/blockscout/blockscout/issues/13139)) +- Fields for OP Withdrawal Claim button ([#13272](https://github.com/blockscout/blockscout/issues/13272)) +- OP Alt-DA support for batch indexer ([#13179](https://github.com/blockscout/blockscout/issues/13179)) +- Add ci:core label ([#13249](https://github.com/blockscout/blockscout/issues/13249)) +- Initial support of indexing EigenDA-grounded Arbitrum batches ([#12915](https://github.com/blockscout/blockscout/issues/12915)) + +### 🐛 Bug Fixes + +- Fix token holders CSV export ([#13485](https://github.com/blockscout/blockscout/pull/13485)) +- ERC-1155 value in advanced filters csv ([#13474](https://github.com/blockscout/blockscout/pull/13474)) +- Fix /api/v2/tokens endpoints: allow back limit param ([#13473](https://github.com/blockscout/blockscout/pull/13473)) +- Incorrect average block time for sub-second blocks ([#13469](https://github.com/blockscout/blockscout/issues/13469)) +- Remove transaction_has_multiple_internal_transactions filter ([#13453](https://github.com/blockscout/blockscout/pull/13453)) +- celo accounts transformer ([#13423](https://github.com/blockscout/blockscout/pull/13423)) +- Fix broken txn batch blocks API endpoint ([#13438](https://github.com/blockscout/blockscout/pull/13438), [#13483](https://github.com/blockscout/blockscout/pull/13483)) +- Set timeout: :infinity for DeleteZeroValueInternalTransactions ([#13434](https://github.com/blockscout/blockscout/pull/13434)) +- Fix DeleteZeroValueInternalTransactions state keys ([#13431](https://github.com/blockscout/blockscout/pull/13431)) +- dump block_hash to binary when querying celo epoch distributions ([#13410](https://github.com/blockscout/blockscout/pull/13410)) +- Fix flaky indexer, web tests, refactoring ([#13392](https://github.com/blockscout/blockscout/issues/13392)) +- Advanced filters: ERC-20 value in CSV ([#13326](https://github.com/blockscout/blockscout/issues/13326)) +- Ignore old reorgs in beacon deposits fetcher ([#13372](https://github.com/blockscout/blockscout/issues/13372)) +- Update FUNDING.json ([#13399](https://github.com/blockscout/blockscout/issues/13399)) +- Add `log_index` field to celo validator group votes table ([#13391](https://github.com/blockscout/blockscout/issues/13391)) +- Add fallback to cached token counters in corresponding async tasks ([#13348](https://github.com/blockscout/blockscout/issues/13348)) +- Sanitize internal transaction error before insertion ([#13362](https://github.com/blockscout/blockscout/issues/13362)) +- Set skip metadata only on contract errors ([#12858](https://github.com/blockscout/blockscout/issues/12858)) +- Check if CoinBalance Realtime fetcher is disabled ([#13223](https://github.com/blockscout/blockscout/issues/13223)) +- Improve timeout exception definition ([#13286](https://github.com/blockscout/blockscout/issues/13286)) +- Enforce legacy query usage when sort by id ([#13323](https://github.com/blockscout/blockscout/issues/13323)) +- Fix web tests after hiding compile-time chain types routes ([#13324](https://github.com/blockscout/blockscout/issues/13324)) +- Hide compile-time chain type API routes in other chain type swaggers ([#13309](https://github.com/blockscout/blockscout/issues/13309)) +- Fix SanitizeDuplicatedLogIndexLogs migration completion check ([#13308](https://github.com/blockscout/blockscout/issues/13308)) + +### ⚡ Performance + +- Remove BENS preload from the main page API endpoints ([#13442](https://github.com/blockscout/blockscout/pull/13442), [#13449](https://github.com/blockscout/blockscout/pull/13449)) +- Batch preload token transfers in `/api/v2/celo/epochs` ([#13398](https://github.com/blockscout/blockscout/issues/13398)) +- Optimize token balance synchronous import steps ([#13217](https://github.com/blockscout/blockscout/issues/13217)) +- Optimize `EmptyBlocksSanitizer` queries ([#13132](https://github.com/blockscout/blockscout/issues/13132)) + +### ⚙️ Miscellaneous Tasks + +- add `CACHE_AVERAGE_BLOCK_TIME_WINDOW` ([#13470](https://github.com/blockscout/blockscout/pull/13470)) +- Improve DeleteZeroValueInternalTransactions future updating ([#13437](https://github.com/blockscout/blockscout/pull/13437)) +- advanced filters improvements ([#11909](https://github.com/blockscout/blockscout/pull/11909)) +- Allow api_key in the query string for api/v2/tokens/:address_hash/instances/refetch-metadata endpoint ([#13412](https://github.com/blockscout/blockscout/pull/13412)) +- *(ReindexDuplicatedInternalTransactions)* Optimize migration performance ([#13363](https://github.com/blockscout/blockscout/issues/13363)) +- OpenAPI spec for the REST API endpoints in token and CSV export controllers ([#13311](https://github.com/blockscout/blockscout/issues/13311)) +- Edited the broken Discord badge ([#13115](https://github.com/blockscout/blockscout/issues/13115)) +- OpenAPI spec for the REST API block controller ([#13274](https://github.com/blockscout/blockscout/issues/13274)) +- Add PR title conventional commit check workflow ([#13238](https://github.com/blockscout/blockscout/issues/13238)) +- Add label for running tests with enabled bridged tokens ([#13263](https://github.com/blockscout/blockscout/issues/13263)) +- Phoenix update ([#13147](https://github.com/blockscout/blockscout/issues/13147)) + +### New ENV variables + +| Variable | Description | Parameters | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `INDEXER_EMPTY_BLOCKS_SANITIZER_HEAD_OFFSET` | Minimal age for block to be processed by empty block sanitizer. Implemented in [#13132](https://github.com/blockscout/blockscout/pull/13132) | Version: v9.2.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_INTERNAL_TRANSACTIONS_DELETE_QUEUE_BATCH_SIZE` | Batch size for internal transactions delete queue handler. Implemented in [#13121](https://github.com/blockscout/blockscout/pull/13121). | Version: v9.2.0\+
Default: `100`
Applications: Indexer | +| `INDEXER_INTERNAL_TRANSACTIONS_DELETE_QUEUE_CONCURRENCY` | Concurrency for internal transactions delete queue handler. Implemented in [#13121](https://github.com/blockscout/blockscout/pull/13121). | Version: v9.2.0\+
Default: `1`
Applications: Indexer | +| `INDEXER_INTERNAL_TRANSACTIONS_DELETE_QUEUE_THRESHOLD` | Threshold for internal transactions delete queue handler. Implemented in [#13121](https://github.com/blockscout/blockscout/pull/13121). | Version: v9.2.0\+
Default: `10m`
Applications: Indexer | +| `INDEXER_OPTIMISM_L1_BATCH_ALT_DA_SERVER_URL` | Defines a URL to Alt-DA server to retrieve L1 data from that. Example for Redstone: `https://da.redstonechain.com/get`. Implemented in [#13179](https://github.com/blockscout/blockscout/pull/13179). | Version: v9.2.0+
Default: (empty)
Applications: Indexer | +| `INDEXER_OPTIMISM_L2_ISTHMUS_TIMESTAMP` | Isthmus upgrade L2 block timestamp. Needed for operator fee determining. If set to `0`, the Isthmus is assumed to be active from genesis block. Implemented in [#13139](https://github.com/blockscout/blockscout/pull/13139). | Version: v9.2.0+
Default: (empty)
Applications: API, Indexer | +| `INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_BATCH_SIZE` | Batch size for OP operator fee fetcher. Defines max number of transactions handled per batch. Implemented in [#13139](https://github.com/blockscout/blockscout/pull/13139). | Version: v9.2.0\+
Default: `100`
Applications: Indexer | +| `INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_CONCURRENCY` | Concurrency for OP operator fee fetcher. Implemented in [#13139](https://github.com/blockscout/blockscout/pull/13139). | Version: v9.2.0\+
Default: `3`
Applications: Indexer | +| `INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT` | Timeout before new attempt to append item to OP operator fee fetcher queue if it's full. [Time format](backend-env-variables.md#time-format). Implemented in [#13139](https://github.com/blockscout/blockscout/pull/13139). | Version: v9.2.0\+
Default: `1s`
Applications: Indexer | +| `INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_MAX_QUEUE_SIZE` | Maximum size of OP operator fee fetcher queue. Implemented in [#13139](https://github.com/blockscout/blockscout/pull/13139). | Version: v9.2.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_INIT_QUERY_LIMIT` | Limit of the init query for processing the OP operator fee fetcher queue. Implemented in [#13139](https://github.com/blockscout/blockscout/pull/13139). | Version: v9.2.0\+
Default: `1000`
Applications: Indexer | +| `CELO_LOCKED_GOLD_CONTRACT` | The address of the `LockedGold` core contract. Implemented in [#12893](https://github.com/blockscout/blockscout/pull/12893). | Version: v9.2.0+
Default: (empty)
Applications: Indexer | +| `CELO_ACCOUNTS_CONTRACT` | The address of the `Accounts` core contract. Implemented in [#12893](https://github.com/blockscout/blockscout/pull/12893). | Version: v9.2.0+
Default: (empty)
Applications: Indexer | +| `INDEXER_CELO_ACCOUNTS_CONCURRENCY` | Sets the maximum number of concurrent requests for fetching Celo accounts. | Version: v9.2.0+
Default: `1`
Applications: Indexer | +| `INDEXER_CELO_ACCOUNTS_BATCH_SIZE` | Specifies the number of account addresses processed per batch during fetching. | Version: v9.2.0+
Default: `100`
Applications: Indexer | +| `K8S_SERVICE` | Kubernetes service name for Elixir nodes clusterization, more info on how to configure it can be found here https://hexdocs.pm/libcluster/Cluster.Strategy.Kubernetes.DNS.html. Implemented in [#13080](https://github.com/blockscout/blockscout/pull/13080). | Version: v9.2.0\+
Default: (empty)
Applications: API, Indexer | +| `CACHE_AVERAGE_BLOCK_TIME_WINDOW` | The number of blocks to be taken into account in the calculations. Introduced in [#13470](https://github.com/blockscout/blockscout/pull/13470). | Version: v9.2.0\+
Default: `100`
Applications: API, Indexer | + +### Deprecated ENV variables + +| Variable | Description | Default | Version | Deprecated in Version | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------- | +| `NFT_MEDIA_HANDLER_NODES_MAP` | String in json map format, where key is erlang node and value is folder in R2/S3 bucket, example: `"{\"producer@172.18.0.4\": \"/folder_1\"}"`. If nft_media_handler runs in one pod with indexer, map should contain `self` key | | v6.10.0+ | v9.2.0+ | + + +## 9.1.1 + +### 🚀 Features + +- Auto assert_schema in tests ([#13029](https://github.com/blockscout/blockscout/issues/13029)) + +### 🐛 Bug Fixes + +- Fix token transfer test for celo ([#13250](https://github.com/blockscout/blockscout/pull/13250)) +- Add reputation preload to celo base fee ([#13248](https://github.com/blockscout/blockscout/pull/13248)) +- Add reputation preload for user op body for transaction interpreter ([#13241](https://github.com/blockscout/blockscout/pull/13241)) +- Fix condition in Indexer.Fetcher.OnDemand.TokenTotalSupply fetcher ([#13240](https://github.com/blockscout/blockscout/pull/13240)) +- Add reputation preload to state changes and bridged tokens ([#13235](https://github.com/blockscout/blockscout/pull/13235)) +- Soften deposits deletion condition ([#13234](https://github.com/blockscout/blockscout/pull/13234)) +- Fix logic of checking finishing of heavy DB index operation ([#13231](https://github.com/blockscout/blockscout/pull/13231)) +- some flapping explorer/indexer tests ([#13230](https://github.com/blockscout/blockscout/pull/13230)) +- Remove requirement for beacon deposit indexes to be sequential ([#13228](https://github.com/blockscout/blockscout/pull/13228)) + +### ⚡ Performance + +- Improve perf of internal transactions retrieval from the DB ([#13232](https://github.com/blockscout/blockscout/pull/13232)) + +### ⚙️ Miscellaneous Tasks + +- Fix tests ([#13244](https://github.com/blockscout/blockscout/pull/13244)) +- Do not modify deposit indexer state on reorgs ([#13236](https://github.com/blockscout/blockscout/pull/13236)) +- Refactoring reputation ([#13221](https://github.com/blockscout/blockscout/issues/13221)) + +## 9.1.0 + +### 🚀 Features + +- beacon deposits ([#12985](https://github.com/blockscout/blockscout/pull/12985)) +- on-demand bytecode fetching on smart contract verification requests ([#10724](https://github.com/blockscout/blockscout/issues/10724)) +- Improved proxy detection ([#12846](https://github.com/blockscout/blockscout/issues/12846)) +- Add `reputation` property where applicable ([#13070](https://github.com/blockscout/blockscout/issues/13070)) +- Add envs to configure RemoteIp lib usage ([#13082](https://github.com/blockscout/blockscout/issues/13082)) +- Add possibility to forward event notification to another DB ([#13064](https://github.com/blockscout/blockscout/issues/13064)) +- Add x-api-key header ([#13076](https://github.com/blockscout/blockscout/issues/13076)) +- Add token_type to token transfer API response ([#13038](https://github.com/blockscout/blockscout/issues/13038)) +- Export main page counters to Multichain service ([#13007](https://github.com/blockscout/blockscout/issues/13007)) +- Add methodId to txlist rpc method ([#13043](https://github.com/blockscout/blockscout/issues/13043)) +- Runtime config option to disable file logging ([#12805](https://github.com/blockscout/blockscout/issues/12805)) +- Add celo-specific APIv1 `getepoch` action ([#12853](https://github.com/blockscout/blockscout/issues/12853)) + +### 🐛 Bug Fixes + +- Fix errors in celo epochs endpoints([#13201](https://github.com/blockscout/blockscout/pull/13201)) +- Fix api/v2/addresses/{hash}/celo/election-rewards pagination ([#13215](https://github.com/blockscout/blockscout/pull/13215)) +- Add reputation preload for celo gas_token ([#13200](https://github.com/blockscout/blockscout/pull/13200)) +- Mark completed deposits in batches ([#13210](https://github.com/blockscout/blockscout/pull/13210)) +- Adjustments in address nft and collections endpoints ([#13192](https://github.com/blockscout/blockscout/pull/13192)) +- Fix batch's number processing from the socket event ([#13181](https://github.com/blockscout/blockscout/pull/13181)) +- Delete PTOs for forked transactions ([#13145](https://github.com/blockscout/blockscout/pull/13145)) +- Pagination and filtering issues in `/addresses/:hash/nft` ([#13175](https://github.com/blockscout/blockscout/pull/13175)) +- Fix reputation preload for ERC-404 collections ([#13174](https://github.com/blockscout/blockscout/pull/13174)) +- Add reputation to token, rework reputation preload ([#13149](https://github.com/blockscout/blockscout/pull/13149)) +- Replace get_constant_by_key with get_constant_value in get_last_processed_token_address_hash ([#13118](https://github.com/blockscout/blockscout/issues/13118)) +- Duplicates of smart contracts additional sources ([#13018](https://github.com/blockscout/blockscout/issues/13018)) +- Set for read ops in NFT backfillers ([#13116](https://github.com/blockscout/blockscout/issues/13116)) +- Return internal transactions for consensus blocks only in /api/v2/internal-transactions ([#13041](https://github.com/blockscout/blockscout/issues/13041)) +- Fix recv timeout option in Universal proxy config ([#13046](https://github.com/blockscout/blockscout/issues/13046)) +- Fix failing ETH RPC tests ([#13099](https://github.com/blockscout/blockscout/issues/13099)) +- Escape only significant characters in tokens ([#13078](https://github.com/blockscout/blockscout/issues/13078)) +- `/api/v2/addresses/:hash/token-transfers` returns 500 on celo ([#13050](https://github.com/blockscout/blockscout/issues/13050)) +- RuntimeEnvHelper usage in Auth0.Migrated ([#13075](https://github.com/blockscout/blockscout/issues/13075)) +- Fix no function clause matching in Explorer.Chain.Transaction.decoded_input_data/5 ([#13055](https://github.com/blockscout/blockscout/issues/13055)) +- Fix Postgres errors in Explorer.Migrator.BackfillMetadataURL ([#13063](https://github.com/blockscout/blockscout/issues/13063)) +- Fix multichain search queue export bug processing ([#13049](https://github.com/blockscout/blockscout/issues/13049)) +- Csv export for celo l2 epoch rewards on address ([#12815](https://github.com/blockscout/blockscout/issues/12815)) +- Change signed_authorizations chain_id type to numeric ([#13042](https://github.com/blockscout/blockscout/issues/13042)) +- Address api spec for `filecoin` and `zilliqa` chain types ([#12996](https://github.com/blockscout/blockscout/issues/12996)) +- Token type filtering to support multiple types with OR logic ([#13008](https://github.com/blockscout/blockscout/issues/13008)) +- Don't validate address hash for common blocks channels ([#13020](https://github.com/blockscout/blockscout/issues/13020)) +- Fix matching in current token balances import filter ([#12930](https://github.com/blockscout/blockscout/issues/12930)) +- Expand indexer timeout exception definition ([#12748](https://github.com/blockscout/blockscout/issues/12748)) + +### 🚜 Refactor + +- Remove public tags request functionality ([#13006](https://github.com/blockscout/blockscout/issues/13006)) + +### ⚡ Performance + +- Optimize maybe_hide_scam_addresses/3 query ([#12927](https://github.com/blockscout/blockscout/issues/12927)) +- Fix perf of finding non pending block in internal transactions related queries ([#13189](https://github.com/blockscout/blockscout/pull/13189)) +- Internal transactions REST API endpoint perf tradeoff ([#13191](https://github.com/blockscout/blockscout/pull/13191)) + +### ⚙️ Miscellaneous Tasks + +- Remove quantile_estimator dep ([#13190](https://github.com/blockscout/blockscout/pull/13190)) +- Add support of Scroll codecv8 ([#13090](https://github.com/blockscout/blockscout/pull/13090)) +- Change release workflow ([#13087](https://github.com/blockscout/blockscout/issues/13087)) +- Add INDEXER_DISABLE_OPTIMISM_INTEROP_MULTICHAIN_EXPORT env variable ([#13051](https://github.com/blockscout/blockscout/pull/13051)) +- Update and format pull request template ([#13028](https://github.com/blockscout/blockscout/issues/13028)) +- Add final check for ReindexDuplicatedInternalTransactions ([#13091](https://github.com/blockscout/blockscout/issues/13091)) +- Remove obsolete circleci config ([#13097](https://github.com/blockscout/blockscout/issues/13097)) +- Replace ReindexDuplicatedInternalTransactions grouping field ([#13084](https://github.com/blockscout/blockscout/issues/13084)) +- Bump default rps to 5 ([#13089](https://github.com/blockscout/blockscout/issues/13089)) +- Add `is_pending_update` flag to block and transaction API endpoints ([#13013](https://github.com/blockscout/blockscout/issues/13013)) +- Bump actions major versions ([#13077](https://github.com/blockscout/blockscout/issues/13077)) +- Move token transfers to a separate event handler ([#13068](https://github.com/blockscout/blockscout/issues/13068)) +- Remove Polygon Edge modules and chain type ([#13056](https://github.com/blockscout/blockscout/issues/13056)) +- Cover token info export to Multichain service by unit tests ([#12899](https://github.com/blockscout/blockscout/issues/12899)) +- Route left API DB requests from master to read DB replica ([#12896](https://github.com/blockscout/blockscout/issues/12896)) +- Refactor usage of delete_parameters_from_next_page_params/1 ([#13005](https://github.com/blockscout/blockscout/issues/13005)) +- Move address nonce updating to a separate process ([#12941](https://github.com/blockscout/blockscout/issues/12941)) +- Catchup fetcher various improvements ([#12866](https://github.com/blockscout/blockscout/issues/12866)) +- Add disconnect_on_error_codes param to repo config ([#12800](https://github.com/blockscout/blockscout/issues/12800)) +- Move addresses to a separate import stage ([#12857](https://github.com/blockscout/blockscout/issues/12857)) + +### New ENV variables + +| Variable | Description | Parameters | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `DISABLE_FILE_LOGGING` | Disables file-based logging when set to `true`. When enabled, application logs will only be written to stdout/stderr. | Version: v9.1.0\+
Default: `false`
Applications: API, Indexer | +| `API_RATE_LIMIT_REMOTE_IP_HEADERS` | Comma separated list of HTTP headers to extract the real client IP address when Blockscout is behind a proxy for rate limiting purposes. Implemented in [#12386](https://github.com/blockscout/blockscout/pull/13082) | Version: v9.1.0\+
Default: `x-forwarded-for`
Applications: API | +| `API_RATE_LIMIT_REMOTE_IP_KNOWN_PROXIES` | Comma separated list of trusted proxy IP addresses or CIDR ranges that are allowed to set the client IP headers for rate limiting. Implemented in [#12386](https://github.com/blockscout/blockscout/pull/13082) | Version: v9.1.0\+
Default: `(empty)`
Applications: API | +| `INDEXER_DISABLE_OPTIMISM_INTEROP_MULTICHAIN_EXPORT` | Disables exporting of interop messages to Multichain service. Implemented in [#13051](https://github.com/blockscout/blockscout/pull/13051). | Version: v9.1.0\+
Default: `true`
Applications: Indexer | +| `MICROSERVICE_MULTICHAIN_SEARCH_COUNTERS_CHUNK_SIZE` | Chunk size of counters while exporting to Multichain Search DB. Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_FETCHER` | If `true`, multichain DB counters export fetcher doesn't run. Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `false`
Applications: Indexer | +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_BATCH_SIZE` | Batch size for multichain DB counters export fetcher. Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_CONCURRENCY` | Concurrency for multichain DB counters export fetcher. Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `10`
Applications: Indexer | +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT` | Timeout before new attempt to append item to multichain DB counters export queue if it's full. [Time format](backend-env-variables.md#time-format). Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `1s`
Applications: Indexer | +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_MAX_QUEUE_SIZE` | Maximum size of multichain DB counters export queue. Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_INIT_QUERY_LIMIT` | Limit of the init query for processing the counters export queue to the Multichain DB. Implemented in [#13007](https://github.com/blockscout/blockscout/pull/13007). | Version: v9.1.0\+
Default: `1000`
Applications: Indexer | +| `INDEXER_DISABLE_BEACON_DEPOSIT_FETCHER` | If `true`, the Beacon deposit fetcher won't be started. Implemented in [#12985](https://github.com/blockscout/blockscout/pull/12985). | Version: v9.1.0+
Default: `false`
Applications: Indexer | +| `INDEXER_BEACON_DEPOSIT_FETCHER_INTERVAL` | The interval indicating how often deposit events should be queried. [Time format](/setup/env-variables/backend-envs-chain-specific#time-format). Implemented in [#12985](https://github.com/blockscout/blockscout/pull/12985). | Version: v9.1.0+
Default: `6s`
Applications: Indexer | +| `INDEXER_BEACON_DEPOSIT_FETCHER_BATCH_SIZE` | The batch size specifies how many events are retrieved in a single database query. Implemented in [#12985](https://github.com/blockscout/blockscout/pull/12985). | Version: v9.1.0+
Default: `1000`
Applications: Indexer | +| `INDEXER_DISABLE_BEACON_DEPOSIT_STATUS_FETCHER` | If `true`, the Beacon deposit status fetcher won't be started. Implemented in [#12985](https://github.com/blockscout/blockscout/pull/12985). | Version: v9.1.0+
Default: `false`
Applications: Indexer | +| `INDEXER_BEACON_DEPOSIT_STATUS_FETCHER_EPOCH_DURATION` | Epoch duration in the Beacon chain in seconds. Implemented in [#12985](https://github.com/blockscout/blockscout/pull/12985). | Version: v9.1.0+
Default: `384`
Applications: Indexer | +| `INDEXER_BEACON_DEPOSIT_STATUS_FETCHER_REFERENCE_TIMESTAMP` | Any past finalized Beacon Chain epoch UTC timestamp. Used as reference for status fetcher scheduling. Implemented in [#12985](https://github.com/blockscout/blockscout/pull/12985). | Version: v9.1.0+
Default: `1722024023`
Applications: Indexer | + + +### Deprecated ENV variables + +| Variable | Required | Description | Default | Version | Need recompile | Deprecated in Version | +| ----------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------- | -------------- | --------------------- | +| `INDEXER_POLYGON_EDGE_L1_RPC` | The RPC endpoint for L1 used to fetch deposit or withdrawal events. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L1_EXIT_HELPER_CONTRACT` | The address of ExitHelper contract on L1 (root chain) used to fetch withdrawal exits. Required for withdrawal events indexing. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L1_WITHDRAWALS_START_BLOCK` | The number of start block on L1 (root chain) to index withdrawal exits. If the table of withdrawal exits is not empty, the process will continue indexing from the last indexed message. If empty or not defined, the withdrawal exits are not indexed. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L1_STATE_SENDER_CONTRACT` | The address of StateSender contract on L1 (root chain) used to fetch deposits. Required for deposit events indexing. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L1_DEPOSITS_START_BLOCK` | The number of start block on L1 (root chain) to index deposits. If the table of deposits is not empty, the process will continue indexing from the last indexed message. If empty or not defined, the deposits are not indexed. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L2_STATE_SENDER_CONTRACT` | The address of L2StateSender contract on L2 (child chain) used to fetch withdrawals. Required for withdrawal events indexing. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L2_WITHDRAWALS_START_BLOCK` | The number of start block on L2 (child chain) to index withdrawals. If the table of withdrawals is not empty, the process will fill gaps and then continue indexing from the last indexed message. If empty or not defined, the withdrawals are not indexed. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L2_STATE_RECEIVER_CONTRACT` | The address of StateReceiver contract on L2 (child chain) used to fetch deposit executes. Required for deposit events indexing. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_L2_DEPOSITS_START_BLOCK` | The number of start block on L2 (child chain) to index deposit executes. If the table of deposit executes is not empty, the process will fill gaps and then continue indexing from the last indexed message. If empty or not defined, the deposit executes are not indexed. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `INDEXER_POLYGON_EDGE_ETH_GET_LOGS_RANGE_SIZE` | Block range size for eth\_getLogs request in Polygon Edge indexer modules. Implemented in [#8180](https://github.com/blockscout/blockscout/pull/8180). | | v5.3.0+ | | v9.1.0 | +| `ACCOUNT_PUBLIC_TAGS_AIRTABLE_URL` | Airtable URL for public tag requests functionality | | v5.0.0+ | | v9.1.0 | +| `ACCOUNT_PUBLIC_TAGS_AIRTABLE_API_KEY` | Airtable API key for public tag requests functionality | | v5.0.0+ | | v9.1.0 | + + +## 9.0.2 + +### 🐛 Bug Fixes + +- atoms in token_transfers_next_page_params ([#12992](https://github.com/blockscout/blockscout/pull/12992)) +- Fix Mud worlds API endpoint ([#12991](https://github.com/blockscout/blockscout/pull/12991)) +- Set 5 RPS for api/health/* ([#12990]https://github.com/blockscout/blockscout/pull/12990) +- Pagination with atoms in paging_params ([#12986](https://github.com/blockscout/blockscout/issues/12986)) +- Fix RangesHelper.sanitize_ranges for empty list ([#12946](https://github.com/blockscout/blockscout/issues/12946)) +- Remove apikey from next_page_params ([#12972](https://github.com/blockscout/blockscout/issues/12972)) + +## 9.0.1 + +### ⚙️ Miscellaneous Tasks + +- Restore `getblocknobytime` response format to use `blockNumber` key ([#12955](https://github.com/blockscout/blockscout/issues/12955)) + +## 9.0.0 + +### 🚀 Features + +- Export token info to Multichain service ([#12867](https://github.com/blockscout/blockscout/pull/12867)) +- Export balances to Multichain DB([#12726](https://github.com/blockscout/blockscout/pull/12726)) +- Add eip7702 authorization status fetcher ([#12451](https://github.com/blockscout/blockscout/issues/12451)) +- Add token1155tx token404tx api v1 endpoints ([#12720](https://github.com/blockscout/blockscout/issues/12720)) +- Async multichain data export ([#12490](https://github.com/blockscout/blockscout/issues/12490)) +- Rate limits refactoring ([#12386](https://github.com/blockscout/blockscout/issues/12386)) +- Integrate Open API Spex lib ([#11886](https://github.com/blockscout/blockscout/issues/11886)) +- Update CodeQL action to v3 ([#12697](https://github.com/blockscout/blockscout/issues/12697)) ([#12703](https://github.com/blockscout/blockscout/issues/12703)) +- Support merged tenants ([#12109](https://github.com/blockscout/blockscout/issues/12109)) +- Support ethereum pre-deploy contracts ([#12579](https://github.com/blockscout/blockscout/issues/12579)) +- Add `creation_status` field to address response ([#12660](https://github.com/blockscout/blockscout/issues/12660)) +- Decode OP interop message payload, store cross-chain transfer data, display message page, send messages to Multichain ([#12387](https://github.com/blockscout/blockscout/issues/12387)) +- Celo l2 epochs ([#12373](https://github.com/blockscout/blockscout/issues/12373)) +- Add `/api/v2/config/celo` convenience endpoint ([#12238](https://github.com/blockscout/blockscout/issues/12238)) + +### 🐛 Bug Fixes + +- Ignore rate limit for api/v2/import/token-info and api/v2/import/smart-contracts/:param ([#12917](https://github.com/blockscout/blockscout/pull/12917)) +- Mitigate deadlocks while exporting balances and the main queue to the Multichain DB ([#12898](https://github.com/blockscout/blockscout/pull/12898), [#12928](https://github.com/blockscout/blockscout/pull/12928)) +- Balances export queue: replace replace_all with replace only value and updated_at ([#12892](https://github.com/blockscout/blockscout/pull/12892)) +- Fix naming for apikey param in OpenAPI spec ([#12891](https://github.com/blockscout/blockscout/pull/12891)) +- Don't send coin balances with zero delta via ws ([#12890](https://github.com/blockscout/blockscout/pull/12890)) +- Balances export queue to multichain replace do_nothing with replace_all on insertion to the queue ([#12888](https://github.com/blockscout/blockscout/pull/12888)) +- Allow using temporary token for api/account/v2 by default ([#12869](https://github.com/blockscout/blockscout/pull/12869)) +- Fix increment of retries_number in exporting data to Multichain DB ([#12847](https://github.com/blockscout/blockscout/pull/12847)) +- Fix various errors on export of balances to Multichain DB ([#12837](https://github.com/blockscout/blockscout/pull/12837)) +- Reject empty token_id and value in export of token balances to the Multichain DB ([#12829](https://github.com/blockscout/blockscout/pull/12829)) +- Fix multichain export queues processing ([#12822](https://github.com/blockscout/blockscout/pull/12822)) +- Remove token_id parameter from coin balance payload to Multichain service API endpoint ([#12817](https://github.com/blockscout/blockscout/pull/12817)) +- Sanitize empty block_ranges payload before sending HTTP request to Multichain service([#12816](https://github.com/blockscout/blockscout/pull/12816)) +- Disable Indexer.Fetcher.Optimism.Interop.MultichainExport for non-OP chains ([#12814](https://github.com/blockscout/blockscout/pull/12814)) +- Fix flaky test for exporting balances to Multichain DB ([#12813](https://github.com/blockscout/blockscout/pull/12813)) +- Filter out creation internal transaction with `index == 0` ([#12777](https://github.com/blockscout/blockscout/issues/12777)) +- Filter out scilla transactions in internal transactions fetcher ([#12793](https://github.com/blockscout/blockscout/issues/12793)) +- Change default ordering in `/api/v2/smart-contracts` ([#12767](https://github.com/blockscout/blockscout/issues/12767)) +- Filter scilla transactions by status ([#12756](https://github.com/blockscout/blockscout/issues/12756)) +- Fix timeout on cache update ([#12773](https://github.com/blockscout/blockscout/issues/12773)) +- Error on too big block numbers in APIv1 `txlist` method ([#12727](https://github.com/blockscout/blockscout/issues/12727)) +- Fix CSV export tests ([#12744](https://github.com/blockscout/blockscout/issues/12744)) +- Fix race condition for EventNotification ([#12738](https://github.com/blockscout/blockscout/issues/12738)) +- Multichain retry hex decoding ([#12742](https://github.com/blockscout/blockscout/issues/12742)) +- Internal transactions balance extraction ([#12654](https://github.com/blockscout/blockscout/issues/12654)) +- Multichain search export: retry only on failed chunks ([#12459](https://github.com/blockscout/blockscout/issues/12459)) +- Display correct OP Deposit origin address ([#12672](https://github.com/blockscout/blockscout/issues/12672)) +- Store blocks_validated in DB for Stability Validators ([#12540](https://github.com/blockscout/blockscout/issues/12540)) +- `MarketHistory` on conflict clause ([#12541](https://github.com/blockscout/blockscout/issues/12541)) +- Flaky 404 in `/api/v2/internal-transactions` ([#12701](https://github.com/blockscout/blockscout/issues/12701)) +- CryptoRank integration ([#12523](https://github.com/blockscout/blockscout/issues/12523)) +- Fix timeout on fetching address internal transactions ([#12570](https://github.com/blockscout/blockscout/issues/12570)) +- Coin balance history with internal tx changes ([#12631](https://github.com/blockscout/blockscout/issues/12631)) +- Update all block fields on conflict ([#12418](https://github.com/blockscout/blockscout/issues/12418)) +- Fix pending transactions sanitizer ([#12559](https://github.com/blockscout/blockscout/issues/12559)) +- Don't send logs without topic to sig provider ([#12620](https://github.com/blockscout/blockscout/issues/12620)) +- Add missing fields to Celo Epochs-related endpoints ([#12589](https://github.com/blockscout/blockscout/issues/12589)) +- Correctly use Geth importer for Besu genesis file. ([#12466](https://github.com/blockscout/blockscout/issues/12466)) ([#12686](https://github.com/blockscout/blockscout/issues/12686)) +- Ignore unknown type txs in gas price oracle ([#12613](https://github.com/blockscout/blockscout/issues/12613)) +- Resolve timeouts on Celo epoch reward contract reads ([#12229](https://github.com/blockscout/blockscout/issues/12229)) +- Prevent constant refetching of celo epoch blocks ([#12498](https://github.com/blockscout/blockscout/issues/12498)) +- Fix typo in ondemand token balance request ([#12495](https://github.com/blockscout/blockscout/issues/12495)) +- Fix for `add_0x_prefix` function ([#12514](https://github.com/blockscout/blockscout/issues/12514)) + +### ⚡ Performance + +- Api v1 `txlist`& `txlistinternal` endpoints ([#12774](https://github.com/blockscout/blockscout/issues/12774)) +- Optimize Explorer.Chain.Cache.Blocks ([#12402](https://github.com/blockscout/blockscout/issues/12402)) + +### ⚙️ Miscellaneous Tasks + +- Remove obsolete API response props ([#12931](https://github.com/blockscout/blockscout/pull/12931)) +- Balances Multichain export: Refactor rows acquisition for deletion query ([#12839](https://github.com/blockscout/blockscout/pull/12839)) +- Change name of Swagger generation workflow ([#12840](https://github.com/blockscout/blockscout/pull/12840)) +- migrate Auth0 to mint as well ([#12807](https://github.com/blockscout/blockscout/pull/12807)) +- Migrate from HTTPoison to Tesla.Mint ([#12699](https://github.com/blockscout/blockscout/pull/12699)) +- Merge adjacent missing block ranges ([#12778](https://github.com/blockscout/blockscout/issues/12778)) +- Optimize missing block ranges operations ([#12705](https://github.com/blockscout/blockscout/issues/12705)) +- Hold parity with Etherscan APIv1 for `getcontractcreation` and `getblocknobytime` endpoints ([#12721](https://github.com/blockscout/blockscout/issues/12721)) +- Allow resending reindexed OP interop messages to Multichain service ([#12626](https://github.com/blockscout/blockscout/issues/12626)) +- Duplicate block countdown endpoint in API v2 ([#12704](https://github.com/blockscout/blockscout/issues/12704)) +- Revise Explorer.Helper.add_0x_prefix usage ([#12543](https://github.com/blockscout/blockscout/issues/12543)) +- New tac microservice endpoint for search ([#12448](https://github.com/blockscout/blockscout/issues/12448)) +- Add filter for value > 0 to txlistinternal ([#12679](https://github.com/blockscout/blockscout/issues/12679)) +- Optimize realtime events notifier ([#12494](https://github.com/blockscout/blockscout/issues/12494)) +- Drop address_coin_balances value_fetched_at index ([#12598](https://github.com/blockscout/blockscout/issues/12598)) +- Update deprecated address to address_hash in tx summary response ([#12617](https://github.com/blockscout/blockscout/issues/12617)) +- Remove redundant word in comment ([#12603](https://github.com/blockscout/blockscout/issues/12603)) +- Move background migrations under indexer mode ([#12480](https://github.com/blockscout/blockscout/issues/12480)) +- Support multiple interop messages view on transaction page ([#12455](https://github.com/blockscout/blockscout/issues/12455)) +- Remove `is_self_destructed` field in `/api/v2/smart-contracts/{address_hash}` response ([#12239](https://github.com/blockscout/blockscout/issues/12239)) +- Set home directory for blockscout user ([#12337](https://github.com/blockscout/blockscout/issues/12337)) + +### New ENV variables + +| Variable | Description | Parameters | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_ENABLED` | If `true`, `Indexer.Utils.EventNotificationsCleaner` process starts. Implemented in [#12738](https://github.com/blockscout/blockscout/pull/12738) |

Version: v9.0.0+
Default: true
Applications: Indexer

| +| `INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_INTERVAL` | Interval between DB event notifications cleanup. [Time format](backend-env-variables.md#time-format). Implemented in [#12738](https://github.com/blockscout/blockscout/pull/12738) |

Version: v9.0.0+
Default: 2m
Applications: Indexer

| +| `INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_MAX_AGE` | Max age of DB event notifications before they are cleaned up. [Time format](backend-env-variables.md#time-format). Implemented in [#12738](https://github.com/blockscout/blockscout/pull/12738) |

Version: v9.0.0+
Default: 5m
Applications: Indexer

| +| `INDEXER_SIGNED_AUTHORIZATION_STATUS_BATCH_SIZE` | Batch size (number of blocks) for EIP7702 authorizations status fetcher. Implemented in [#12451](https://github.com/blockscout/blockscout/pull/12451). |

Version: v9.0.0+
Default: 10
Applications: Indexer

| +| `MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_BATCH_SIZE` | Number of blocks to reindex in the batch. Implemented in [#12559](https://github.com/blockscout/blockscout/pull/12559). |

Version: v9.0.0+
Default: 10
Applications: Indexer

| +| `MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_CONCURRENCY` | Number of parallel reindexing block batches processing. Implemented in [#12559](https://github.com/blockscout/blockscout/pull/12559). |

Version: v9.0.0+
Default: 1
Applications: Indexer

| +| `MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_TIMEOUT` | Timeout between reindexing block batches processing. Implemented in [#12559](https://github.com/blockscout/blockscout/pull/12559). |

Version: v9.0.0+
Default: 0
Applications: Indexer

| +| `MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_ENABLED` | Enable reindex blocks with missing transactions migration. Implemented in [#12559](https://github.com/blockscout/blockscout/pull/12559). |

Version: v9.0.0+
Default: false
Applications: Indexer

| +| `MIGRATION_MERGE_ADJACENT_MISSING_BLOCK_RANGES_BATCH_SIZE` | Specifies the missing block range batch size selected for the merge migration. Implemented in [#12778](https://github.com/blockscout/blockscout/pull/12778). |

Version: v9.0.0+
Default: 100
Applications: Indexer

| +| `API_RATE_LIMIT_CONFIG_URL` | URL to fetch API rate limit configuration from external source. Implemented in [#12386](https://github.com/blockscout/blockscout/pull/12386) |

Version: v9.0.0+
Default: (empty)
Applications: API

| +| `API_RATE_LIMIT_BY_KEY_TIME_INTERVAL` | Time interval for API rate limit by key. [Time format](backend-env-variables.md#time-format). Implemented in [#12386](https://github.com/blockscout/blockscout/pull/12386) |

Version: v9.0.0+
Default: 1s
Applications: API

| +| `API_RATE_LIMIT_BY_WHITELISTED_IP_TIME_INTERVAL` | Time interval for API rate limit by whitelisted IP. [Time format](backend-env-variables.md#time-format). Implemented in [#12386](https://github.com/blockscout/blockscout/pull/12386) |

Version: v9.0.0+
Default: 1s
Applications: API

| +| `API_RATE_LIMIT_UI_V2_WITH_TOKEN_TIME_INTERVAL` | Time interval for API rate limit for UI v2 with token. [Time format](backend-env-variables.md#time-format). Implemented in [#12386](https://github.com/blockscout/blockscout/pull/12386) |

Version: v9.0.0+
Default: 1s
Applications: API

| +| `API_RATE_LIMIT_BY_ACCOUNT_API_KEY_TIME_INTERVAL` | Time interval for API rate limit by account API key. [Time format](backend-env-variables.md#time-format). Implemented in [#12386](https://github.com/blockscout/blockscout/pull/12386) |

Version: v9.0.0+
Default: 1s
Applications: API

| +| `INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_FETCHER` | If `true`, multichain DB main (blocks, transactions, addresses) export fetcher doesn't run. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v9.0.0+
Default: false
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_BATCH_SIZE` | Batch size for multichain DB main (blocks, transactions, addresses) export fetcher. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v9.0.0+
Default: 1000
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_CONCURRENCY` | Concurrency for multichain DB main (blocks, transactions, addresses) export fetcher. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v9.0.0+
Default: 10
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT` | Timeout before new attempt to append item to multichain DB main (blocks, transactions, addresses) export queue if it's full. [Time format](backend-env-variables.md#time-format). Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v9.0.0+
Default: 1s
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_MAX_QUEUE_SIZE` | Maximum size of multichain DB main (blocks, transactions, addresses) export queue. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v9.0.0+
Default: 1000
Applications: Indexer

| +| `INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_FETCHER` | If `true`, multichain DB balances export fetcher doesn't run. Implemented in [#12580](https://github.com/blockscout/blockscout/pull/12580). |

Version: v9.0.0+
Default: false
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_BATCH_SIZE` | Batch size for multichain DB balances export fetcher. Implemented in [#12580](https://github.com/blockscout/blockscout/pull/12580). |

Version: v9.0.0+
Default: 1000
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_CONCURRENCY` | Concurrency for multichain DB balances export fetcher. Implemented in [#12580](https://github.com/blockscout/blockscout/pull/12580). |

Version: v9.0.0+
Default: 10
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT` | Timeout before new attempt to append item to multichain DB balances export queue if it's full. [Time format](backend-env-variables.md#time-format). Implemented in [#12580](https://github.com/blockscout/blockscout/pull/12580). |

Version: v9.0.0+
Default: 1s
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_MAX_QUEUE_SIZE` | Maximum size of multichain DB balances export queue. Implemented in [#12580](https://github.com/blockscout/blockscout/pull/12580). |

Version: v9.0.0+
Default: 1000
Applications: Indexer

| +| `INDEXER_POLYGON_ZKEVM_BATCHES_IGNORE` | Comma-separated list of batch numbers that should be ignored by the fetcher. Implemented in [#12387](https://github.com/blockscout/blockscout/pull/12387). |

Version: v9.0.0+
Default: (empty)
Applications: Indexer

| +| `INDEXER_OPTIMISM_MULTICHAIN_BATCH_SIZE` | Max number of items sent to the Multichain service in one batch. Implemented in [#12387](https://github.com/blockscout/blockscout/pull/12387). |

Version: v9.0.0+
Default: 100
Applications: Indexer

| +| `CELO_UNRELEASED_TREASURY_CONTRACT` | The address[^1] of the `CeloUnreleasedTreasury` core contract. Implemented in [#12373](https://github.com/blockscout/blockscout/pull/12373). |

Version: v9.0.0+
Default: (empty)
Applications: Indexer

| +| `CELO_VALIDATORS_CONTRACT` | The address[^1] of the `Validators` core contract. Implemented in [#12373](https://github.com/blockscout/blockscout/pull/12373). |

Version: v9.0.0+
Default: (empty)
Applications: Indexer

| +| `CELO_EPOCH_MANAGER_CONTRACT` | The address[^1] of the `EpochManager` core contract. Implemented in [#12373](https://github.com/blockscout/blockscout/pull/12373). |

Version: v9.0.0+
Default: (empty)
Applications: Indexer

| + +## 8.1.2 + +### ⚙️ Miscellaneous Tasks + +- Parsing L2 block number of OP Dispute Game on BOB chain ([#12831](https://github.com/blockscout/blockscout/pull/12831)) + +## 8.1.1 + +### 🐛 Bug Fixes + +- Add missing preload for logs in /api/v2/transactions/:transaction_hash_param/summary ([#12491](https://github.com/blockscout/blockscout/issues/12491)) + +## 8.1.0 + +### 🚀 Features + +- Add lower bound for base fee ([#12370](https://github.com/blockscout/blockscout/pull/12370)) +- Multichain Search DB export retry queue ([#12377](https://github.com/blockscout/blockscout/issues/12377)) +- Add TAC operation search ([#12367](https://github.com/blockscout/blockscout/issues/12367)) +- Add `internal_transactions_count` prop in api/v2/blocks/:block endpoint ([#12405](https://github.com/blockscout/blockscout/issues/12405)) + +### 🐛 Bug Fixes + +- Handle mismatched 0x prefixed bytes ([#12453](https://github.com/blockscout/blockscout/pull/12453)) +- Fix logs decoding issue for proxies ([#12414](https://github.com/blockscout/blockscout/issues/12414)) +- Refactor TokenInstanceMetadataRefetch on demand fetcher ([#12419](https://github.com/blockscout/blockscout/issues/12419)) +- Fix for type output in ETH RPC API transaction by hash endpoint +- Frozen confirmations discovery on Arbitrum Nova ([#12385](https://github.com/blockscout/blockscout/issues/12385)) +- Add prague Solidity EVM version ([#12115](https://github.com/blockscout/blockscout/issues/12115)) +- Fix :checkout_timeout error ([#12406](https://github.com/blockscout/blockscout/issues/12406)) +- Force index usage on select current token balances ([#12390](https://github.com/blockscout/blockscout/issues/12390)) +- Fix retrieving max block number in MissingRangesCollector ([#12333](https://github.com/blockscout/blockscout/issues/12333)) +- Start PubSub before Endpoint ([#12274](https://github.com/blockscout/blockscout/issues/12274)) +- Fix FunctionClauseError on internal transactions indexing ([#12246](https://github.com/blockscout/blockscout/issues/12246)) +- Support updated zkSync calldata format in batch proof tracking ([#12234](https://github.com/blockscout/blockscout/issues/12234)) +- On demand bytecode fetcher for eip7702 addresses ([#12330](https://github.com/blockscout/blockscout/issues/12330)) +- Handle pending operations for empty blocks as well ([#12349](https://github.com/blockscout/blockscout/issues/12349)) + +### 🚜 Refactor + +- Eliminate join with internal_transactions table to get list logs in API v1 ([#12352](https://github.com/blockscout/blockscout/issues/12352)) +- Define pending block operations by set of block hashes query ([#12375](https://github.com/blockscout/blockscout/issues/12375)) +- Move `address_to_internal_transactions/2` to `Explorer.Chain.InternalTransaction` module ([#12346](https://github.com/blockscout/blockscout/issues/12346)) +- Single definition of smart-contract internal creation transaction query ([#12335](https://github.com/blockscout/blockscout/issues/12335)) + +### ⚡ Performance + +- Force index usage in `api/v2/addresses/:hash/transactions` ([#12415](https://github.com/blockscout/blockscout/issues/12415)) + +### ⚙️ Miscellaneous Tasks + +- Add updated-gas-oracle to Access-Control-Allow-Headers ([#12473](https://github.com/blockscout/blockscout/pull/12473)) +- Add additional test for Universal proxy, duplicate all proxy endpoints at /3rdparty ([#12442](https://github.com/blockscout/blockscout/pull/12442)) +- Improve logic behind emerging of custom fields in the response of `eth_getTransactionByHash` ETH RPC API endpoint ([#12416](https://github.com/blockscout/blockscout/issues/12416)) +- Internal transactions unique index ([#12394](https://github.com/blockscout/blockscout/issues/12394)) +- Update blocks consensus in case of import failure ([#12243](https://github.com/blockscout/blockscout/issues/12243)) +- Sanitize ERC-1155 token balances without token ids ([#12305](https://github.com/blockscout/blockscout/issues/12305)) +- Support Celestia Alt-DA in OP batches indexer and Super Roots in OP withdrawals indexer ([#12332](https://github.com/blockscout/blockscout/issues/12332)) +- Send DB read queries to replica in on-demand fetchers ([#12383](https://github.com/blockscout/blockscout/issues/12383)) + +### New ENV variables + +| Variable | Description | Parameters | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `HACKNEY_DEFAULT_POOL_SIZE` | Size of `default` hackney pool. Implemented in [#12406](https://github.com/blockscout/blockscout/pull/12406). |

Version: v8.1.0+
Default: 1000
Applications: API, Indexer

| +| `MIGRATION_REINDEX_DUPLICATED_INTERNAL_TRANSACTIONS_BATCH_SIZE` | Number of internal transactions to reindex in the batch. Implemented in [#12394](https://github.com/blockscout/blockscout/pull/12394). |

Version: v8.1.0+
Default: 100
Applications: Indexer

| +| `MIGRATION_REINDEX_DUPLICATED_INTERNAL_TRANSACTIONS_CONCURRENCY` | Number of parallel reindexing internal transaction batches processing. Implemented in [#12394](https://github.com/blockscout/blockscout/pull/12394). |

Version: v8.1.0+
Default: 1
Applications: Indexer

| +| `MIGRATION_REINDEX_DUPLICATED_INTERNAL_TRANSACTIONS_TIMEOUT` | Timeout between reindexing internal transaction batches processing. Implemented in [#12394](https://github.com/blockscout/blockscout/pull/12394). |

Version: v8.1.0+
Default: 0
Applications: Indexer

| +| `INDEXER_SCROLL_L1_BATCH_BLOCKSCOUT_BLOBS_API_URL` | Defines a URL to Blockscout Blobs API to retrieve L1 blobs from that. Example for Sepolia: `https://eth-sepolia.blockscout.com/api/v2/blobs`. Implemented in [#12294](https://github.com/blockscout/blockscout/pull/12294). |

Version: v8.1.0+
Default: (empty)
Applications: Indexer

| +| `MICROSERVICE_MULTICHAIN_SEARCH_ADDRESSES_CHUNK_SIZE` | Chunk size of addresses while exporting to Multichain Search DB. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377) |

Version: v8.1.0+
Default: (empty)
Applications: API, Indexer

| +| `INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_RETRY_FETCHER` | If `true`, `retry` multichain search export fetcher doesn't run. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v8.1.0+
Default: false
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_RETRY_BATCH_SIZE` | Batch size for `retry` multichain search export fetcher. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v8.1.0+
Default: 10
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_RETRY_CONCURRENCY` | Concurrency for `retry` multichain search export fetcher. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v8.1.0+
Default: 10
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_RETRY_ENQUEUE_BUSY_WAITING_TIMEOUT` | Timeout before new attempt to append item to `retry` multichain search export queue if it's full. [Time format](backend-env-variables.md#time-format). Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v8.1.0+
Default: 1s
Applications: Indexer

| +| `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_RETRY_MAX_QUEUE_SIZE` | Maximum size of `retry` multichain search export queue. Implemented in [#12377](https://github.com/blockscout/blockscout/pull/12377). |

Version: v8.1.0+
Default: 1000
Applications: Indexer

| + + +## 8.0.2 ### 🚀 Features @@ -57,6 +1310,16 @@ ### 🐛 Bug Fixes +- Fix Indexer.Helper.http_get_request function ([#12317](https://github.com/blockscout/blockscout/pull/12317)) +- Rename left props in API v2 with new naming convention ([#12314](https://github.com/blockscout/blockscout/issues/12314)) +- Temporary disable PendingTransactionOperation ([#12312](https://github.com/blockscout/blockscout/issues/12312)) +- Add `bash` to `builder-deps` build stage ([#12316](https://github.com/blockscout/blockscout/issues/12316)) +- Build on macos ([#12308](https://github.com/blockscout/blockscout/issues/12308)) +- Fix MissingBlockRange.fill_ranges_between/3 for empty range ([#12319](https://github.com/blockscout/blockscout/pull/12319)) +- Fix CSV export "to" range to include the whole day in all cases ([#12286](https://github.com/blockscout/blockscout/pull/12286)) +- Return compatibility with previous version of health endpoint([#12280](https://github.com/blockscout/blockscout/pull/12280)) +- Unbind import from compile-time chain_type ([#12277](https://github.com/blockscout/blockscout/pull/12277)) +- Read `CHAIN_TYPE` and `MUD_INDEXER_ENABLED` envs in runtime config ([#12270](https://github.com/blockscout/blockscout/issues/12270)) - Limit max import concurrency ([#12261](https://github.com/blockscout/blockscout/pull/12261)) - CSV export: download items for the given day if from / to period are equal ([#12260](https://github.com/blockscout/blockscout/pull/12260)) - Upgrade missing balanceOf token condition ([#12254](https://github.com/blockscout/blockscout/pull/12254)) @@ -105,6 +1368,10 @@ ### ⚙️ Miscellaneous Tasks +- Add Scroll Euclid upgrade support ([#12294](https://github.com/blockscout/blockscout/issues/12294)) +- Decrease PBO to PTO migration batch size ([#12279](https://github.com/blockscout/blockscout/pull/12279)) +- Decrease PendingOperationsHelper blocks_batch_size ([#12276](https://github.com/blockscout/blockscout/pull/12276)) +- Update docker compose to use ghcr.io images ([#12177](https://github.com/blockscout/blockscout/issues/12177)) - Add typed_ecto_schema to release ([#12255](https://github.com/blockscout/blockscout/pull/12255)) - Suppress logging for expected 404 errors in account abstraction ([#12242](https://github.com/blockscout/blockscout/pull/12242)) - Upgrade on demand balances fetchers ([#12104](https://github.com/blockscout/blockscout/pull/12104)) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..5e7a02004399 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See AGENTS.md for Blockscout architecture guidance. diff --git a/FUNDING.json b/FUNDING.json new file mode 100644 index 000000000000..6bc52bce679f --- /dev/null +++ b/FUNDING.json @@ -0,0 +1,10 @@ +{ + "drips": { + "filecoin": { + "ownedBy": "0x5C36Bd76a6c138187C43da92f66f37E23b4017fA" + }, + "opRetro": { + "projectId": "0x663e4d25ca3f327365240471b4831ea3c989cb132bbf6ae8f5c1e15268591795" + } + } +} diff --git a/LICENSE b/LICENSE index 94a9ed024d38..925326d4d936 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,446 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +SPDX-License-Identifier: LicenseRef-Blockscout + +Effective Date: 2026-04-22 +Version: 1.0 +Previous Version: N/A + +PLEASE READ THIS LICENCE CAREFULLY. BY DOWNLOADING, ACCESSING, COPYING, +MODIFYING, DISTRIBUTING, DEPLOYING, OR OTHERWISE USING THE SOFTWARE, YOU +CONFIRM THAT YOU HAVE READ, UNDERSTOOD, AND AGREE TO BE LEGALLY BOUND BY +THE TERMS OF THIS LICENCE IN FULL. IF YOU DO NOT AGREE TO THESE TERMS, +YOU MUST NOT DOWNLOAD, USE, COPY, MODIFY, OR DISTRIBUTE THE SOFTWARE. + +1. Definitions + +“Commercial Licence” means a separate written commercial licence +agreement entered into between you and the Licensor, which expressly +references this Licence and supplements its terms by granting additional +rights, or permitting uses, that are not granted or permitted under this +Licence. + +“Derivative Work” means any work, whether in source or object form, that +is based on or derived from the Software and in which any editorial +revisions, annotations, elaborations, additions, deletions, or other +modifications, taken as a whole, constitute an original work of +authorship. For the avoidance of doubt, Derivative Works do not include +works that remain separable from, or merely link to, the Software. + +“Feedback” means any comments, suggestions, recommendations, ideas, +proposals, or other feedback, whether oral or written, provided by you in +connection with or relating to the Software. + +“Group” means, in respect of an entity, that entity together with any +other entity that directly or indirectly controls, is controlled by, or +is under common control with, that entity. For the purposes of this +definition, “control” means the direct or indirect ownership of more than +fifty per cent (50%) of the voting securities or other ownership interest +of an entity, or the power to direct or cause the direction of the +management and policies of that entity (whether by ownership, contract, +or otherwise). + +“Licence” means this Blockscout Software Licence, as amended or updated +from time to time. + +“Licensor” means Blockscout Limited, an international business company +incorporated under the laws of the Republic of Seychelles. + +“Prior Software” means any prior version, release, build, or component of +the Software that was made available by or on behalf of the Licensor +before the Effective Date, and that is not distributed under this Licence. + +“Software” means the Blockscout blockchain explorer, a tool for +inspecting and analyzing blockchain networks, as made available by the +Licensor under this Licence, including the source code, object code, +executable files, configuration and deployment materials, documentation, +APIs/SDKs (if any), and any part or portion thereof. + +“You” or “your” means the individual who accepts this Licence. Where you +act on behalf of an entity, “you” shall refer to both: (i) you as an +individual exercising rights under this Licence; and (ii) the entity on +whose behalf you are acting. + +2. Licence and Attribution + + a. Licence. Subject to and conditional upon your compliance with this + Licence, the Licensor hereby grants you a temporary, worldwide, + non-exclusive, royalty-free, revocable, non-transferable, and + non-sublicensable licence to download, review, use, deploy, copy, + modify, and create Derivative Works of the Software. All rights not + expressly granted under this Licence are reserved by the Licensor. + + b. Branding and Attribution. You shall preserve all copyright, patent, + trademark, branding, and attribution notices included in or displayed + by the Software, and shall not remove, obscure, conceal, replace, + alter, disable, or otherwise interfere with the display or integrity of + such notices. + + c. Interface Attribution. Where the Software is used to power, enable, + or provide functionality for any user interface (including any website, + web application, mobile application, or other frontend), you shall + ensure that such interface includes clear and reasonably prominent + attribution to the Licensor at all times while you use the Software. + Such attribution shall (i) prominently identify the Licensor by the + brand name “Blockscout” (such as “Made with Blockscout” or “Powered by + Blockscout”), and (ii) include the respective attribution text, link + (or a hyperlink) to the website https://blockscout.com and any branding + or notices provided by the Licensor, in each case in the same form and + manner as displayed in the footer of the following website: + https://eth.blockscout.com. + + d. No Endorsement or Service Provision. Except as expressly agreed in + writing by the Licensor, the Licensor does not provide, and shall not + be deemed to provide, any product or service that you (or any third + party) offer, operate, or make available using the Software, and the + Licensor is not a party to, and has no responsibility or liability for, + any relationship, transaction or interaction between you and any end + user or other third party. The Licensor does not endorse, sponsor, + approve, or recommend you, your business, or any of your products or + services. Except as expressly agreed in writing by the Licensor, you + shall not (and shall not authorise or permit any third party to) state, + represent, imply, or otherwise hold out that: (i) the Licensor provides + any services to or for you; (ii) the Licensor acts on your behalf; + (iii) you are acting as an agent, representative, partner, or affiliate + of the Licensor; or (iv) the Licensor endorses, sponsors, approves, or + recommends you, your business, or any of your products or services. + +3. Scope and Updates + + a. Scope. Subject to Third-Party Licences clause, this Licence applies + solely to the version of the Software (and its components) with which + it is distributed by or on behalf of the Licensor. This Licence does + not apply to any Prior Software. + + + b. Software Changes. The Software is under active development and may + be modified, updated, improved, withdrawn, suspended, or discontinued + by the Licensor at any time, in whole or in part, with or without + notice. The Licensor does not warrant or guarantee that any particular + features, functionality, integrations, interfaces, or components of the + Software will remain available, unchanged, or compatible with any prior + or future versions. You acknowledge and agree that the Software may + change over time and that continued use of the Software is at your sole + risk. + + + c. Licence Updates. The Licensor may amend, replace, or update this + Licence at any time in its sole discretion, with or without notice. + Where you continue to access, use, deploy, copy, modify, or otherwise + use the Software after the effective date of an updated Licence, you + acknowledge and agree that your continued use constitutes acceptance + of, and you shall comply with, the updated Licence. For the avoidance + of doubt, an updated Licence may introduce additional restrictions or + permissions, including requiring a Commercial Licence for certain uses. + +4. Restricted Uses and Commercial Licence + + a. Restricted Commercial or Monetised Use (including SaaS and RaaS). + Unless and until you obtain a Commercial Licence, you shall not, and + shall not authorise or permit any third party to exercise any rights + granted under this Licence to (directly or indirectly) sell, license, + monetise, commercialise, or otherwise make available the Software or + its functionality to any third party in exchange for any fee or other + consideration (including without limitation fees for hosting, access, + subscriptions, support, consulting, implementation, customisation, + maintenance, managed services, or any other services), where such + product or service incorporates, uses, depends on, or is materially + enabled by the Software (including offering the Software or its + functionality on a hosted, “as-a-service”, or managed basis). + + b. Obtaining Commercial Licence. If you intend to exercise any rights + or engage in any uses of the Software that are prohibited, restricted, + or not granted under this Licence, you must, prior to such use, contact + the Licensor at https://eaas.blockscout.com/#contact to request a + Commercial Licence and applicable pricing and terms. Any such rights or + uses are unauthorised unless and until a Commercial Licence has been + expressly agreed in writing by the Licensor. Nothing in this Licence + obliges the Licensor to grant any Commercial Licence or to enter into + any agreement with you. + + c. Compliance Verification. Upon the Licensor’s reasonable request, you + shall promptly provide the Licensor with such information and + documentation as the Licensor may reasonably require to verify your + compliance with this Licence, including (without limitation) to confirm + whether your use of the Software requires a Commercial Licence. The + Licensor shall use any such information solely for compliance + verification purposes. + + d. Name and Branding Restrictions. You shall not distribute, market, or + otherwise make available the Software or any Derivative Works under any + name, designation, branding, or identifier that is identical or + confusingly similar to the Licensor’s product names, trademarks, + service marks, or trade names, or that is likely to cause confusion as + to the origin, sponsorship, affiliation, or endorsement by the Licensor. + +5. Derivative Works + + a. Permission. Subject to and conditional upon your compliance with + this Licence, you may create Derivative Works of the Software solely + for your internal use of the Software. + + b. Restrictions. You shall not distribute, sublicense, sell, license, + make available, or otherwise provide any Derivative Works, in whole or + in part, to any third party without first obtaining a Commercial + Licence. + + c. Ownership and Licence. Except as expressly provided in this Licence, + ownership of any Derivative Works shall remain with you. + Notwithstanding the foregoing, you hereby grant the Licensor a + perpetual, irrevocable, worldwide, royalty-free, non-exclusive, + transferable, and sublicensable licence to use, reproduce, modify, + adapt, incorporate, and otherwise exploit any Derivative Works for any + purpose, including to develop, improve, or distribute the Software. + + d. Tracking of Changes. Where you modify the Software or create any + Derivative Works, you shall ensure that any modified files carry + prominent notices stating that you have modified the Software and + indicating the date of such modification. Any such notices shall not be + construed as modifying, limiting, or otherwise affecting this Licence. + + e. Warranties. You represent and warrant that you own or otherwise have + all necessary rights to create and license any Derivative Works as + contemplated by this Licence, and that such Derivative Works do not + infringe any third-party intellectual property rights, violate this + Licence, or breach any applicable laws or regulations. + +6. Feedback + + a. Permission and Rights. You may, but are not obliged to, provide + Feedback. Where you provide any Feedback, you acknowledge and agree + that the Licensor may, in its sole discretion, use, reproduce, + disclose, make publicly available, and otherwise exploit such Feedback + for any purpose, commercial or otherwise, without restriction and + without any obligation to you, including without acknowledgment or + compensation. + + b. Licence. You hereby grant the Licensor a perpetual, irrevocable, + worldwide, royalty-free, transferable, and sublicensable licence to + use, reproduce, modify, adapt, publish, translate, distribute, publicly + perform, publicly display, and otherwise exploit the Feedback, in whole + or in part, in any manner and for any purpose. To the extent permitted + by applicable law, you waive, and agree not to assert, any moral rights + or similar rights you may have in the Feedback. + + c. Warranties. You represent and warrant that you own or otherwise have + all necessary rights to grant the licence set out in this clause, and + that the Feedback does not infringe any third-party rights or + applicable laws. + +7. Ownership + + a. Ownership. The Licensor is and shall remain the sole owner (or, + where applicable, the authorised licensor) of the Software. Nothing in + this Licence shall operate to assign, transfer, or otherwise convey to + you any right, title, or interest in or to the Software, save for the + limited licence expressly granted under this Licence and the Commercial + Licence, if applicable. All rights are licensed, not sold. You shall + not take, or assist others in taking, any action that may diminish the + Licensor's rights in the Software. + + b. Branding. Subject to your compliance with this Licence, the Licensor + hereby grants you a temporary, worldwide, non-exclusive, royalty-free, + revocable, non-transferable, and non-sublicensable licence to display + the Licensor’s trademarks, trade names, and logos as provided along + with the Software or as required under this Licence, solely for + attribution purposes as required under this Licence. Except for the + foregoing, no rights in any trademarks, trade names, or logos of the + Licensor or its affiliates are granted under this Licence. + + c. Third-Party Licences. While the Software is made available in its + entirety under this Licence, certain components of the Software may + incorporate or be derived from third-party open-source software + provided under permissive licences. Such specific third-party + open-source software components are distributed under the terms of the + applicable third-party licences. To the extent required by such + third-party licences, applicable copyright notices, licence texts, and + attribution requirements shall be preserved. Subject to the foregoing, + the Software as a whole, and all parts thereof, is licensed under this + Licence. + +8. Disclaimers + +To the maximum extent permitted by applicable law, the Software is +provided on an “AS IS” and “AS AVAILABLE” basis and is used at your sole +risk. The Licensor disclaims all warranties of any kind, whether express, +implied, statutory, or otherwise, including (without limitation) any +implied warranties of merchantability, satisfactory quality, fitness for +a particular purpose, non-infringement, and title, and any warranties +arising out of course of dealing, course of performance, or usage of +trade. Without limiting the foregoing, the Licensor makes no +representation or warranty that the Software will function as expected, +meet your requirements, operate in combination with any other software, +have any specific functionality, be uninterrupted, timely, secure, +accurate, complete, or error-free, or that any defects or errors will be +corrected. Nothing in this Licence excludes or limits any warranty, +liability, or other term to the extent it cannot be excluded or limited +under applicable law. The Software is provided for informational and +technical purposes only and does not constitute legal, financial, tax, +investment, or other professional advice. You are solely responsible for +determining whether use of the Software is appropriate for your purposes. + +9. Limitation of Liability + +Nothing in this Licence excludes or limits liability for: (i) death or +personal injury caused by negligence; (ii) fraud or fraudulent +misrepresentation; or (iii) any other liability which cannot be excluded +or limited under applicable law. Subject to the foregoing, to the maximum +extent permitted by applicable law, the Licensor shall not be liable to +you for any loss or damage whatsoever (whether direct, indirect, +incidental, special, punitive or consequential), or for any loss of +profits, revenue, business, business opportunity, anticipated savings, +goodwill or data, or for any business interruption, arising out of or in +connection with the use of, or inability to use, the Software, whether in +contract, tort (including negligence), misrepresentation, restitution, +breach of statutory duty, or otherwise, even if advised of the +possibility of such loss or damage. To the extent that the Licensor is +held liable notwithstanding the above, the total aggregate liability +arising out of or in connection with this Licence or the Software shall +not exceed the total amounts actually paid by you to the Licensor under +this Licence in the twelve (12) months preceding the event giving rise to +the claim (or, if no such amounts were paid, USD 100). + +10. Term and Termination + + a. Automatic Termination. This Licence shall automatically terminate, + without any further action by the Licensor, upon any breach by you of + its terms. + + b. Termination by the Licensor. The Licensor may terminate this Licence + at any time in its sole discretion. Where reasonably practicable, the + Licensor will use reasonable efforts to provide you with advance notice + of termination. + + c. Effect of Termination. Upon termination of this Licence for any + reason: (i) all rights granted to you under this Licence shall + immediately cease; (ii) all Commercial Licences executed with you shall + automatically terminate simultaneously with this Licence; (iii) you + shall immediately cease all access to and use of the Software and any + Derivative Works; (iv) you shall uninstall and delete the Software and + any Derivative Works from all systems under your control and destroy + all copies in your possession or control (in each case including any + copies held by your contractors or service providers), except to the + extent retention is required by applicable law; (v) you shall + immediately cease all distribution or making available of the Software + and any Derivative Works; and (vi) any provisions of this Licence which + by their nature are intended to survive termination shall survive, + including without limitation provisions relating to ownership, + trademarks, feedback, disclaimers, limitation of liability, and + governing law and jurisdiction. + +11. Governing Law and Arbitration + + a. Governing Law. This Licence and any dispute or claim (including + non-contractual disputes or claims) arising out of or in connection + with it or its subject matter or formation shall be governed by and + construed in accordance with the law of England and Wales, excluding + its conflict of law rules. For the avoidance of doubt, the provisions + of the United Nations Convention on the International Sale of Goods + shall not apply to this Licence. + + b. Dispute Resolution. The parties shall first attempt to resolve any + dispute arising out of or in connection with this Licence informally. + You may initiate such informal discussions by giving notice to the + Licensor by email at info@blockscout.com. If the dispute is not + resolved within thirty (30) days of such notice, the dispute shall be + referred to and finally resolved by arbitration under the LCIA Rules, + which Rules are deemed incorporated by reference into this clause. The + seat (legal place) of arbitration shall be London, United Kingdom. The + tribunal shall consist of one (1) arbitrator. The language of the + arbitration shall be English. The governing law of this arbitration + agreement shall be the laws of England and Wales. To the maximum extent + permitted by applicable law, you may bring claims against the Licensor + only in your individual capacity and not as a claimant or class member + in any purported class, collective, consolidated, or representative + proceeding. Any notices, requests, demands, or other communications + given in connection with the arbitration may be sent in electronic + form, including via email or any electronic filing system operated by + the LCIA, and shall be deemed received when successfully transmitted to + the recipient (as evidenced by no delivery failure notice). + +12. Miscellaneous + + a. Injunctive Relief. You acknowledge and agree that any breach of this + Licence (including any breach of the restrictions on use of the + Software) may cause the Licensor irreparable harm for which damages may + not be an adequate remedy. Accordingly, the Licensor shall be entitled + to seek injunctive relief, specific performance, and/or any other + equitable relief for any such breach, in addition to any other rights + or remedies available at law. + + b. Rights and Remedies. The rights and remedies provided under this + Licence are cumulative and are in addition to, and not exclusive of, + any rights or remedies provided by law. Any right or remedy may be + exercised as often as required. + + c. Assignment. Unless otherwise permitted under this Licence, you shall + not assign, transfer, charge, subcontract, declare a trust over, or + deal in any other manner with any of your rights or obligations under + this Licence without the prior written consent of the Licensor. The + Licensor may at any time assign, transfer, charge, subcontract, or + otherwise deal with any of its rights or obligations under this Licence + without your consent or notice to you. + + d. Severability. If any provision (or part of a provision) of this + Licence is found by any court or competent authority to be invalid, + illegal, or unenforceable, that provision (or part-provision) shall be + deemed modified to the minimum extent necessary to make it valid, + legal, and enforceable. If such modification is not possible, the + relevant provision (or part-provision) shall be deemed deleted. Any + modification to or deletion of a provision (or part-provision) under + this clause shall not affect the validity and enforceability of the + remainder of this Licence. + + e. Entire Agreement. This Licence (together with the Commercial + Licence, if any) constitutes the entire agreement between you and the + Licensor in relation to its subject matter and supersedes and + extinguishes all prior and contemporaneous agreements, understandings, + negotiations, representations, and arrangements between the parties, + whether written or oral. You acknowledge and agree that you shall have + no remedies in respect of any statement, representation, assurance, or + warranty (whether made innocently or negligently) that is not set out + in this Licence (or the Commercial Licence). + + f. Commercial Licence. If a Commercial Licence is in place, it forms an + integral part of this Licence. In the event of any conflict or + inconsistency between the terms of this Licence and the Commercial + Licence, the terms of the Commercial Licence shall prevail to the + extent of such conflict or inconsistency. + + g. Notices. Any notice or other communication given by the Licensor + under or in connection with this Licence may be given using any + available means reasonably selected by the Licensor, including (without + limitation) publication of notice in the Software repository, on the + Licensor’s website, or through any other communication channel + reasonably selected by the Licensor. Where you have a Commercial + Licence in force, any notice or other communication given by the + Licensor under or in connection with this Licence shall be in writing + and may be delivered by email to the email address(es) specified for + notices in the executed Commercial Licence. A notice sent by email + shall be deemed received: (i) if sent during normal business hours, at + the time of transmission; or (ii) if sent outside normal business + hours, at 9:00 a.m. on the next business day. Notices sent by email + shall be legally effective. + + h. Waiver. No failure or delay by the Licensor to exercise any right or + remedy under this Licence or by law shall constitute a waiver of that + or any other right or remedy, nor shall it prevent or restrict any + further exercise of that or any other right or remedy. No single or + partial exercise of any right or remedy shall prevent or restrict the + further exercise of that or any other right or remedy. + + i. Third Party Rights. Except as expressly provided in this clause, a + person who is not a party to this Licence shall not have any rights + under the Contracts (Rights of Third Parties) Act 1999 to enforce any + term of this Licence. Notwithstanding the foregoing, the Licensor’s + affiliates and the Licensor’s directors, officers, employees, + contractors, agents, representatives, and other personnel shall be + entitled, pursuant to the Contracts (Rights of Third Parties) Act 1999, + to enforce and rely on any provision of this Licence that limits or + excludes the liability of the Licensor (including any limitations and + exclusions of liability and any indemnities in favour of the Licensor) + as if they were parties to this Licence. This Licence may be amended, + varied, terminated, or rescinded (in whole or in part) without the + consent of any such person. + +END OF THE LICENCE + +Copyright © Blockscout Limited 2026 diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 442777d478c7..000000000000 --- a/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,32 +0,0 @@ -*[GitHub keywords to close any associated issues](https://blog.github.com/2013-05-14-closing-issues-via-pull-requests/)* - -## Motivation - -*Why we should merge these changes. If using GitHub keywords to close [issues](https://github.com/poanetwork/blockscout/issues), this is optional as the motivation can be read on the issue page.* - -## Changelog - -### Enhancements - -*Things you added that don't break anything. Regression tests for Bug Fixes count as Enhancements.* - -### Bug Fixes - -*Things you changed that fix bugs. If it fixes a bug, but in so doing adds a new requirement, removes code, or requires a database reset and reindex, the breaking part of the change should be added to Incompatible Changes below also.* - -### Incompatible Changes - -*Things you broke while doing Enhancements and Bug Fixes. Breaking changes include (1) adding new requirements and (2) removing code. Renaming counts as (2) because a rename is a removal followed by an add.* - -## Upgrading - -*If you have any Incompatible Changes in the above Changelog, outline how users of prior versions can upgrade once this PR lands or when reviewers are testing locally. A common upgrading step is "Database reset and re-index required".* - -## Checklist for your Pull Request (PR) - -- [ ] If I added new functionality, I added tests covering it. -- [ ] If I fixed a bug, I added a regression test to prevent the bug from silently reappearing again. -- [ ] I checked whether I should update the docs and did so by submitting a PR to [docs repository](https://github.com/blockscout/docs). -- [ ] If I added/changed/removed ENV var, I submitted a PR to [docs repository](https://github.com/blockscout/docs) to update the list of [env vars](https://github.com/blockscout/docs/blob/master/setup/env-variables/README.md) and I updated the version to `master` in the Version column. If I removed variable, I added it to [Deprecated ENV Variables](https://github.com/blockscout/docs/blob/master/setup/env-variables/deprecated-env-variables/README.md) page. After merging docs PR, changes will be reflected in these [pages](https://docs.blockscout.com/setup/env-variables). -- [ ] If I added new DB indices, I checked, that they are not redundant, with PGHero or other tools. -- [ ] If I added/removed chain type, I modified the Github CI matrix and PR labels accordingly. diff --git a/README.md b/README.md index 5ab52a8e6af3..578711d78c21 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@

Blockchain Explorer for inspecting and analyzing EVM Chains.

-[![Blockscout](https://github.com/blockscout/blockscout/actions/workflows/config.yml/badge.svg)](https://github.com/blockscout/blockscout/actions) -[![Discord](https://dcbadge.vercel.app/api/server/blockscout?style=flat)](https://discord.gg/blockscout) +[![Discord](https://img.shields.io/badge/chat-Blockscout-green.svg)](https://discord.gg/blockscout)
@@ -42,10 +41,12 @@ We would like to thank the EthPrize foundation for their funding support. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and pull request protocol. We expect contributors to follow our [code of conduct](CODE_OF_CONDUCT.md) when submitting code or comments. +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for contribution and pull request protocol. We expect contributors to follow our [code of conduct](.github/CODE_OF_CONDUCT.md) when submitting code or comments. ## License -[![License: GPL v3.0](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![License: Blockscout Software Licence](https://img.shields.io/badge/License-Blockscout%20Software%20Licence-blue.svg)](LICENSE) -This project is licensed under the GNU General Public License v3.0. See the [LICENSE](LICENSE) file for details. +This project is licensed under the Blockscout Software Licence. See the [LICENSE](LICENSE) file for full terms. + +Third-party components included in this repository remain subject to their own licenses. See dependency manifests and bundled third-party notices for component-level license terms. diff --git a/apps/block_scout_web/.sobelow-conf b/apps/block_scout_web/.sobelow-conf index 45fc3453b994..64b8c550a18f 100644 --- a/apps/block_scout_web/.sobelow-conf +++ b/apps/block_scout_web/.sobelow-conf @@ -7,9 +7,11 @@ format: "compact", ignore: ["Config.Headers", "Config.CSWH", "XSS.SendResp", "XSS.Raw"], ignore_files: [ - "apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex", "apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex", + "apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex", "apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex", - "apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex" + "apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex", + "apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex", + "apps/block_scout_web/lib/block_scout_web/rate_limit.ex" ] ] diff --git a/apps/block_scout_web/API blueprint.md b/apps/block_scout_web/API blueprint.md index d473b194a930..8e5de5f06ab2 100644 --- a/apps/block_scout_web/API blueprint.md +++ b/apps/block_scout_web/API blueprint.md @@ -2100,1179 +2100,3 @@ HOST:http://blockscout.com/poa/core { "message": "OK" } -### BlockScoutWeb.Account.Api.V1.UserController create_public_tags_request [POST /api/account/v1/user/public_tags] - - - - - -+ Request Submit request to add a public tag -**POST**  `/api/account/v1/user/public_tags` - - + Headers - - content-type: multipart/mixed; boundary=plug_conn_test - + Body - - { - "website": "website11", - "tags": "Tag17", - "is_owner": false, - "full_name": "full name11", - "email": "test_user-24@blockscout.com", - "company": "company11", - "addresses": [ - "0x0000000000000000000000000000000000000067", - "0x0000000000000000000000000000000000000068", - "0x0000000000000000000000000000000000000069", - "0x000000000000000000000000000000000000006a", - "0x000000000000000000000000000000000000006b", - "0x000000000000000000000000000000000000006c", - "0x000000000000000000000000000000000000006d" - ], - "additional_comment": "additional_comment11" - } - -+ Response 200 - - + Headers - - set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAmaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyMTJkAAVlbWFpbG0AAAAbdGVzdF91c2VyLTIzQGJsb2Nrc2NvdXQuY29tZAACaWRiAAABEWQABG5hbWVtAAAAC1VzZXIgVGVzdDEyZAAIbmlja25hbWVtAAAAC3Rlc3RfdXNlcjEyZAADdWlkbQAAABBibG9ja3Njb3V0fDAwMDEyZAAMd2F0Y2hsaXN0X2lkYgAAARE.NJjO7QbBKV5g6_hGxLxBb5wlGDmJMKp-bpgLhhrFjLM; path=/; SameSite=Lax - content-type: application/json; charset=utf-8 - cache-control: max-age=0, private, must-revalidate - x-request-id: Fy1W2Zt2e1-7YrQAABVh - access-control-allow-credentials: true - access-control-allow-origin: * - access-control-expose-headers: - + Body - - { - "website": "website11", - "tags": "Tag17", - "submission_date": "2022-12-03T16:55:29.441979Z", - "is_owner": false, - "id": 202, - "full_name": "full name11", - "email": "test_user-24@blockscout.com", - "company": "company11", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000067" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000068" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000069" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000006a" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000006b" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000006C" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000006D" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000067", - "0x0000000000000000000000000000000000000068", - "0x0000000000000000000000000000000000000069", - "0x000000000000000000000000000000000000006a", - "0x000000000000000000000000000000000000006b", - "0x000000000000000000000000000000000000006c", - "0x000000000000000000000000000000000000006d" - ], - "additional_comment": "additional_comment11" - } -### BlockScoutWeb.Account.Api.V1.UserController public_tags_requests [GET /api/account/v1/user/public_tags] - - - - - -+ Request Get list of requests to add a public tag -**GET**  `/api/account/v1/user/public_tags` - - -+ Response 200 - - + Headers - - set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAlaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyM2QABWVtYWlsbQAAABp0ZXN0X3VzZXItM0BibG9ja3Njb3V0LmNvbWQAAmlkYgAAAQhkAARuYW1lbQAAAApVc2VyIFRlc3QzZAAIbmlja25hbWVtAAAACnRlc3RfdXNlcjNkAAN1aWRtAAAAD2Jsb2Nrc2NvdXR8MDAwM2QADHdhdGNobGlzdF9pZGIAAAEI.-a6kcGlCbsFgQtwPNaGA4yaOOpSpyG_54rEROF3a6E0; path=/; SameSite=Lax - content-type: application/json; charset=utf-8 - cache-control: max-age=0, private, must-revalidate - x-request-id: Fy1W2YJiDacnhiAAAA9h - access-control-allow-credentials: true - access-control-allow-origin: * - access-control-expose-headers: - + Body - - [ - { - "website": "website9", - "tags": "Tag14", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": false, - "id": 200, - "full_name": "full name9", - "email": "test_user-13@blockscout.com", - "company": "company9", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000003D" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000003e" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000003f" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000040" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000041" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000042" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000043" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000044" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000045" - } - ], - "addresses": [ - "0x000000000000000000000000000000000000003d", - "0x000000000000000000000000000000000000003e", - "0x000000000000000000000000000000000000003f", - "0x0000000000000000000000000000000000000040", - "0x0000000000000000000000000000000000000041", - "0x0000000000000000000000000000000000000042", - "0x0000000000000000000000000000000000000043", - "0x0000000000000000000000000000000000000044", - "0x0000000000000000000000000000000000000045" - ], - "additional_comment": "additional_comment9" - }, - { - "website": "website8", - "tags": "Tag13", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": false, - "id": 199, - "full_name": "full name8", - "email": "test_user-12@blockscout.com", - "company": "company8", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000003a" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000003b" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000003c" - } - ], - "addresses": [ - "0x000000000000000000000000000000000000003a", - "0x000000000000000000000000000000000000003b", - "0x000000000000000000000000000000000000003c" - ], - "additional_comment": "additional_comment8" - }, - { - "website": "website7", - "tags": "Tag11;Tag12", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": true, - "id": 198, - "full_name": "full name7", - "email": "test_user-11@blockscout.com", - "company": "company7", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000032" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000033" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000034" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000035" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000036" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000037" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000038" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000039" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000032", - "0x0000000000000000000000000000000000000033", - "0x0000000000000000000000000000000000000034", - "0x0000000000000000000000000000000000000035", - "0x0000000000000000000000000000000000000036", - "0x0000000000000000000000000000000000000037", - "0x0000000000000000000000000000000000000038", - "0x0000000000000000000000000000000000000039" - ], - "additional_comment": "additional_comment7" - }, - { - "website": "website6", - "tags": "Tag10", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": true, - "id": 197, - "full_name": "full name6", - "email": "test_user-10@blockscout.com", - "company": "company6", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000002c" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000002D" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000002E" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000002F" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000030" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000031" - } - ], - "addresses": [ - "0x000000000000000000000000000000000000002c", - "0x000000000000000000000000000000000000002d", - "0x000000000000000000000000000000000000002e", - "0x000000000000000000000000000000000000002f", - "0x0000000000000000000000000000000000000030", - "0x0000000000000000000000000000000000000031" - ], - "additional_comment": "additional_comment6" - }, - { - "website": "website5", - "tags": "Tag9", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": true, - "id": 196, - "full_name": "full name5", - "email": "test_user-9@blockscout.com", - "company": "company5", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000028" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000029" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000002A" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000002b" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000028", - "0x0000000000000000000000000000000000000029", - "0x000000000000000000000000000000000000002a", - "0x000000000000000000000000000000000000002b" - ], - "additional_comment": "additional_comment5" - }, - { - "website": "website4", - "tags": "Tag7;Tag8", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": false, - "id": 195, - "full_name": "full name4", - "email": "test_user-8@blockscout.com", - "company": "company4", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000020" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000021" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000022" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000023" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000024" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000025" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000026" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000027" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000020", - "0x0000000000000000000000000000000000000021", - "0x0000000000000000000000000000000000000022", - "0x0000000000000000000000000000000000000023", - "0x0000000000000000000000000000000000000024", - "0x0000000000000000000000000000000000000025", - "0x0000000000000000000000000000000000000026", - "0x0000000000000000000000000000000000000027" - ], - "additional_comment": "additional_comment4" - }, - { - "website": "website3", - "tags": "Tag5;Tag6", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": true, - "id": 194, - "full_name": "full name3", - "email": "test_user-7@blockscout.com", - "company": "company3", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000001a" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000001B" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000001c" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000001D" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000001e" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000001F" - } - ], - "addresses": [ - "0x000000000000000000000000000000000000001a", - "0x000000000000000000000000000000000000001b", - "0x000000000000000000000000000000000000001c", - "0x000000000000000000000000000000000000001d", - "0x000000000000000000000000000000000000001e", - "0x000000000000000000000000000000000000001f" - ], - "additional_comment": "additional_comment3" - }, - { - "website": "website2", - "tags": "Tag3;Tag4", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": true, - "id": 193, - "full_name": "full name2", - "email": "test_user-6@blockscout.com", - "company": "company2", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000010" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000011" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000012" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000013" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000014" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000015" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000016" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000017" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000018" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000019" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000010", - "0x0000000000000000000000000000000000000011", - "0x0000000000000000000000000000000000000012", - "0x0000000000000000000000000000000000000013", - "0x0000000000000000000000000000000000000014", - "0x0000000000000000000000000000000000000015", - "0x0000000000000000000000000000000000000016", - "0x0000000000000000000000000000000000000017", - "0x0000000000000000000000000000000000000018", - "0x0000000000000000000000000000000000000019" - ], - "additional_comment": "additional_comment2" - }, - { - "website": "website1", - "tags": "Tag2", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": false, - "id": 192, - "full_name": "full name1", - "email": "test_user-5@blockscout.com", - "company": "company1", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000000E" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000000F" - } - ], - "addresses": [ - "0x000000000000000000000000000000000000000e", - "0x000000000000000000000000000000000000000f" - ], - "additional_comment": "additional_comment1" - }, - { - "website": "website0", - "tags": "Tag0;Tag1", - "submission_date": "2022-12-03T16:55:29.000000Z", - "is_owner": true, - "id": 191, - "full_name": "full name0", - "email": "test_user-4@blockscout.com", - "company": "company0", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000008" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000009" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000000A" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000000b" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000000C" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000000d" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000008", - "0x0000000000000000000000000000000000000009", - "0x000000000000000000000000000000000000000a", - "0x000000000000000000000000000000000000000b", - "0x000000000000000000000000000000000000000c", - "0x000000000000000000000000000000000000000d" - ], - "additional_comment": "additional_comment0" - } - ] -### BlockScoutWeb.Account.Api.V1.UserController delete_public_tags_request [DELETE /api/account/v1/user/public_tags/{id}] - - - - -+ Parameters - + id: `200` - id: 200 - - -+ Request Delete public tags request -**DELETE**  `/api/account/v1/user/public_tags/200` - - + Headers - - content-type: multipart/mixed; boundary=plug_conn_test - + Body - - { - "remove_reason": "reason" - } - -+ Response 200 - - + Headers - - set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAlaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyM2QABWVtYWlsbQAAABp0ZXN0X3VzZXItM0BibG9ja3Njb3V0LmNvbWQAAmlkYgAAAQhkAARuYW1lbQAAAApVc2VyIFRlc3QzZAAIbmlja25hbWVtAAAACnRlc3RfdXNlcjNkAAN1aWRtAAAAD2Jsb2Nrc2NvdXR8MDAwM2QADHdhdGNobGlzdF9pZGIAAAEI.-a6kcGlCbsFgQtwPNaGA4yaOOpSpyG_54rEROF3a6E0; path=/; SameSite=Lax - content-type: application/json; charset=utf-8 - cache-control: max-age=0, private, must-revalidate - x-request-id: Fy1W2YdEq9snhiAAAA-h - access-control-allow-credentials: true - access-control-allow-origin: * - access-control-expose-headers: - + Body - - { - "message": "OK" - } -### BlockScoutWeb.Account.Api.V1.UserController update_public_tags_request [PUT /api/account/v1/user/public_tags/{id}] - - - - -+ Parameters - + id: `203` - id: 203 - - -+ Request Edit request to add a public tag -**PUT**  `/api/account/v1/user/public_tags/203` - - + Headers - - content-type: multipart/mixed; boundary=plug_conn_test - + Body - - { - "website": "website13", - "tags": "Tag20;Tag21", - "is_owner": false, - "full_name": "full name13", - "email": "test_user-35@blockscout.com", - "company": "company13", - "addresses": [ - "0x0000000000000000000000000000000000000085", - "0x0000000000000000000000000000000000000086", - "0x0000000000000000000000000000000000000087", - "0x0000000000000000000000000000000000000088", - "0x0000000000000000000000000000000000000089", - "0x000000000000000000000000000000000000008a", - "0x000000000000000000000000000000000000008b" - ], - "additional_comment": "additional_comment13" - } - -+ Response 200 - - + Headers - - set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAmaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyMjFkAAVlbWFpbG0AAAAbdGVzdF91c2VyLTMzQGJsb2Nrc2NvdXQuY29tZAACaWRiAAABGmQABG5hbWVtAAAAC1VzZXIgVGVzdDIxZAAIbmlja25hbWVtAAAAC3Rlc3RfdXNlcjIxZAADdWlkbQAAABBibG9ja3Njb3V0fDAwMDIxZAAMd2F0Y2hsaXN0X2lkYgAAARo.byLDQXd4VuN-Y1kqWEWSxe5Q_ne42ove8xpm5k_GwHc; path=/; SameSite=Lax - content-type: application/json; charset=utf-8 - cache-control: max-age=0, private, must-revalidate - x-request-id: Fy1W2aqpFvr2fxsAABjB - access-control-allow-credentials: true - access-control-allow-origin: * - access-control-expose-headers: - + Body - - { - "website": "website13", - "tags": "Tag20;Tag21", - "submission_date": "2022-12-03T16:55:30.000000Z", - "is_owner": false, - "id": 203, - "full_name": "full name13", - "email": "test_user-35@blockscout.com", - "company": "company13", - "addresses_with_info": [ - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000085" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000086" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000087" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000088" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x0000000000000000000000000000000000000089" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000008A" - }, - { - "watchlist_names": [], - "public_tags": [], - "private_tags": [], - "name": null, - "is_verified": null, - "is_contract": false, - "implementation_name": null, - "hash": "0x000000000000000000000000000000000000008b" - } - ], - "addresses": [ - "0x0000000000000000000000000000000000000085", - "0x0000000000000000000000000000000000000086", - "0x0000000000000000000000000000000000000087", - "0x0000000000000000000000000000000000000088", - "0x0000000000000000000000000000000000000089", - "0x000000000000000000000000000000000000008a", - "0x000000000000000000000000000000000000008b" - ], - "additional_comment": "additional_comment13" - } - diff --git a/apps/block_scout_web/API.md b/apps/block_scout_web/API.md index afc68c19e65f..74ded7812d48 100644 --- a/apps/block_scout_web/API.md +++ b/apps/block_scout_web/API.md @@ -30,10 +30,6 @@ * [custom_abis](#blockscoutweb-account-api-v1-usercontroller-custom_abis) * [update_custom_abi](#blockscoutweb-account-api-v1-usercontroller-update_custom_abi) * [delete_custom_abi](#blockscoutweb-account-api-v1-usercontroller-delete_custom_abi) - * [create_public_tags_request](#blockscoutweb-account-api-v1-usercontroller-create_public_tags_request) - * [public_tags_requests](#blockscoutweb-account-api-v1-usercontroller-public_tags_requests) - * [delete_public_tags_request](#blockscoutweb-account-api-v1-usercontroller-delete_public_tags_request) - * [update_public_tags_request](#blockscoutweb-account-api-v1-usercontroller-update_public_tags_request) ## BlockScoutWeb.Account.Api.V1.UserController ### info @@ -1846,382 +1842,3 @@ access-control-expose-headers: "message": "OK" } ``` - -### create_public_tags_request -#### Submit request to add a public tag - -##### Request -* __Method:__ POST -* __Path:__ /api/account/v1/user/public_tags -* __Request headers:__ -``` -content-type: multipart/mixed; boundary=plug_conn_test -``` -* __Request body:__ -```json -{ - "website": "website0", - "tags": "Tag0", - "is_owner": true, - "full_name": "full name0", - "email": "test_user-6@blockscout.com", - "company": "company0", - "addresses": [ - "0x0000000000000000000000000000000000000009", - "0x000000000000000000000000000000000000000a", - "0x000000000000000000000000000000000000000b", - "0x000000000000000000000000000000000000000c", - "0x000000000000000000000000000000000000000d" - ], - "additional_comment": "additional_comment0" -} -``` - -##### Response -* __Status__: 200 -* __Response headers:__ -``` -set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAlaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyNWQABWVtYWlsbQAAABp0ZXN0X3VzZXItNUBibG9ja3Njb3V0LmNvbWQAAmlkYcVkAARuYW1lbQAAAApVc2VyIFRlc3Q1ZAAIbmlja25hbWVtAAAACnRlc3RfdXNlcjVkAAN1aWRtAAAAD2Jsb2Nrc2NvdXR8MDAwNWQADHdhdGNobGlzdF9pZGHF.kXAMBaL9a7aYjPDgZ9Llxe1etUCPH3vEvQe9Fq2May4; path=/; HttpOnly -content-type: application/json; charset=utf-8 -cache-control: max-age=0, private, must-revalidate -x-request-id: FxF1Y2BIESA-ecUAAGgB -access-control-allow-credentials: true -access-control-allow-origin: * -access-control-expose-headers: -``` -* __Response body:__ -```json -{ - "website": "website0", - "tags": "Tag0", - "submission_date": "2022-09-03T21:00:07.156465Z", - "is_owner": true, - "id": 131, - "full_name": "full name0", - "email": "test_user-6@blockscout.com", - "company": "company0", - "addresses": [ - "0x0000000000000000000000000000000000000009", - "0x000000000000000000000000000000000000000a", - "0x000000000000000000000000000000000000000b", - "0x000000000000000000000000000000000000000c", - "0x000000000000000000000000000000000000000d" - ], - "additional_comment": "additional_comment0" -} -``` - -### public_tags_requests -#### Get list of requests to add a public tag - -##### Request -* __Method:__ GET -* __Path:__ /api/account/v1/user/public_tags - -##### Response -* __Status__: 200 -* __Response headers:__ -``` -set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAmaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyMjNkAAVlbWFpbG0AAAAbdGVzdF91c2VyLTI3QGJsb2Nrc2NvdXQuY29tZAACaWRh12QABG5hbWVtAAAAC1VzZXIgVGVzdDIzZAAIbmlja25hbWVtAAAAC3Rlc3RfdXNlcjIzZAADdWlkbQAAABBibG9ja3Njb3V0fDAwMDIzZAAMd2F0Y2hsaXN0X2lkYdc._6gJnvzjA6VEztgoIdpp7chhmhsdFrJImlcdrp4-pW0; path=/; HttpOnly -content-type: application/json; charset=utf-8 -cache-control: max-age=0, private, must-revalidate -x-request-id: FxF1Y3SaPVCdkicAAHIi -access-control-allow-credentials: true -access-control-allow-origin: * -access-control-expose-headers: -``` -* __Response body:__ -```json -[ - { - "website": "website13", - "tags": "Tag17", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": false, - "id": 143, - "full_name": "full name13", - "email": "test_user-37@blockscout.com", - "company": "company13", - "addresses": [ - "0x000000000000000000000000000000000000007e", - "0x000000000000000000000000000000000000007f", - "0x0000000000000000000000000000000000000080", - "0x0000000000000000000000000000000000000081", - "0x0000000000000000000000000000000000000082", - "0x0000000000000000000000000000000000000083", - "0x0000000000000000000000000000000000000084" - ], - "additional_comment": "additional_comment13" - }, - { - "website": "website12", - "tags": "Tag16", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": false, - "id": 142, - "full_name": "full name12", - "email": "test_user-36@blockscout.com", - "company": "company12", - "addresses": [ - "0x0000000000000000000000000000000000000075", - "0x0000000000000000000000000000000000000076", - "0x0000000000000000000000000000000000000077", - "0x0000000000000000000000000000000000000078", - "0x0000000000000000000000000000000000000079", - "0x000000000000000000000000000000000000007a", - "0x000000000000000000000000000000000000007b", - "0x000000000000000000000000000000000000007c", - "0x000000000000000000000000000000000000007d" - ], - "additional_comment": "additional_comment12" - }, - { - "website": "website11", - "tags": "Tag15", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": false, - "id": 141, - "full_name": "full name11", - "email": "test_user-35@blockscout.com", - "company": "company11", - "addresses": [ - "0x000000000000000000000000000000000000006d", - "0x000000000000000000000000000000000000006e", - "0x000000000000000000000000000000000000006f", - "0x0000000000000000000000000000000000000070", - "0x0000000000000000000000000000000000000071", - "0x0000000000000000000000000000000000000072", - "0x0000000000000000000000000000000000000073", - "0x0000000000000000000000000000000000000074" - ], - "additional_comment": "additional_comment11" - }, - { - "website": "website10", - "tags": "Tag14", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": false, - "id": 140, - "full_name": "full name10", - "email": "test_user-34@blockscout.com", - "company": "company10", - "addresses": [ - "0x0000000000000000000000000000000000000067", - "0x0000000000000000000000000000000000000068", - "0x0000000000000000000000000000000000000069", - "0x000000000000000000000000000000000000006a", - "0x000000000000000000000000000000000000006b", - "0x000000000000000000000000000000000000006c" - ], - "additional_comment": "additional_comment10" - }, - { - "website": "website9", - "tags": "Tag13", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": true, - "id": 139, - "full_name": "full name9", - "email": "test_user-33@blockscout.com", - "company": "company9", - "addresses": [ - "0x0000000000000000000000000000000000000061", - "0x0000000000000000000000000000000000000062", - "0x0000000000000000000000000000000000000063", - "0x0000000000000000000000000000000000000064", - "0x0000000000000000000000000000000000000065", - "0x0000000000000000000000000000000000000066" - ], - "additional_comment": "additional_comment9" - }, - { - "website": "website8", - "tags": "Tag12", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": false, - "id": 138, - "full_name": "full name8", - "email": "test_user-32@blockscout.com", - "company": "company8", - "addresses": [ - "0x0000000000000000000000000000000000000060" - ], - "additional_comment": "additional_comment8" - }, - { - "website": "website7", - "tags": "Tag11", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": true, - "id": 137, - "full_name": "full name7", - "email": "test_user-31@blockscout.com", - "company": "company7", - "addresses": [ - "0x000000000000000000000000000000000000005f" - ], - "additional_comment": "additional_comment7" - }, - { - "website": "website6", - "tags": "Tag9;Tag10", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": true, - "id": 136, - "full_name": "full name6", - "email": "test_user-30@blockscout.com", - "company": "company6", - "addresses": [ - "0x000000000000000000000000000000000000005a", - "0x000000000000000000000000000000000000005b", - "0x000000000000000000000000000000000000005c", - "0x000000000000000000000000000000000000005d", - "0x000000000000000000000000000000000000005e" - ], - "additional_comment": "additional_comment6" - }, - { - "website": "website5", - "tags": "Tag8", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": false, - "id": 135, - "full_name": "full name5", - "email": "test_user-29@blockscout.com", - "company": "company5", - "addresses": [ - "0x0000000000000000000000000000000000000051", - "0x0000000000000000000000000000000000000052", - "0x0000000000000000000000000000000000000053", - "0x0000000000000000000000000000000000000054", - "0x0000000000000000000000000000000000000055", - "0x0000000000000000000000000000000000000056", - "0x0000000000000000000000000000000000000057", - "0x0000000000000000000000000000000000000058", - "0x0000000000000000000000000000000000000059" - ], - "additional_comment": "additional_comment5" - }, - { - "website": "website4", - "tags": "Tag6;Tag7", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": true, - "id": 134, - "full_name": "full name4", - "email": "test_user-28@blockscout.com", - "company": "company4", - "addresses": [ - "0x000000000000000000000000000000000000004c", - "0x000000000000000000000000000000000000004d", - "0x000000000000000000000000000000000000004e", - "0x000000000000000000000000000000000000004f", - "0x0000000000000000000000000000000000000050" - ], - "additional_comment": "additional_comment4" - } -] -``` - -### delete_public_tags_request -#### Delete public tags request - -##### Request -* __Method:__ DELETE -* __Path:__ /api/account/v1/user/public_tags/143 -* __Request headers:__ -``` -content-type: multipart/mixed; boundary=plug_conn_test -``` -* __Request body:__ -```json -{ - "remove_reason": "reason" -} -``` - -##### Response -* __Status__: 200 -* __Response headers:__ -``` -set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAmaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyMjNkAAVlbWFpbG0AAAAbdGVzdF91c2VyLTI3QGJsb2Nrc2NvdXQuY29tZAACaWRh12QABG5hbWVtAAAAC1VzZXIgVGVzdDIzZAAIbmlja25hbWVtAAAAC3Rlc3RfdXNlcjIzZAADdWlkbQAAABBibG9ja3Njb3V0fDAwMDIzZAAMd2F0Y2hsaXN0X2lkYdc._6gJnvzjA6VEztgoIdpp7chhmhsdFrJImlcdrp4-pW0; path=/; HttpOnly -content-type: application/json; charset=utf-8 -cache-control: max-age=0, private, must-revalidate -x-request-id: FxF1Y3SwObudkicAAHBB -access-control-allow-credentials: true -access-control-allow-origin: * -access-control-expose-headers: -``` -* __Response body:__ -```json -{ - "message": "OK" -} -``` - -### update_public_tags_request -#### Edit request to add a public tag - -##### Request -* __Method:__ PUT -* __Path:__ /api/account/v1/user/public_tags/132 -* __Request headers:__ -``` -content-type: multipart/mixed; boundary=plug_conn_test -``` -* __Request body:__ -```json -{ - "website": "website2", - "tags": "Tag2;Tag3", - "is_owner": true, - "full_name": "full name2", - "email": "test_user-9@blockscout.com", - "company": "company2", - "addresses": [ - "0x000000000000000000000000000000000000000f", - "0x0000000000000000000000000000000000000010", - "0x0000000000000000000000000000000000000011", - "0x0000000000000000000000000000000000000012", - "0x0000000000000000000000000000000000000013", - "0x0000000000000000000000000000000000000014" - ], - "additional_comment": "additional_comment2" -} -``` - -##### Response -* __Status__: 200 -* __Response headers:__ -``` -set-cookie: _explorer_key=SFMyNTY.g3QAAAABbQAAAAxjdXJyZW50X3VzZXJ0AAAAB2QABmF2YXRhcm0AAAAlaHR0cHM6Ly9leGFtcGxlLmNvbS9hdmF0YXIvdGVzdF91c2VyNmQABWVtYWlsbQAAABp0ZXN0X3VzZXItN0BibG9ja3Njb3V0LmNvbWQAAmlkYcZkAARuYW1lbQAAAApVc2VyIFRlc3Q2ZAAIbmlja25hbWVtAAAACnRlc3RfdXNlcjZkAAN1aWRtAAAAD2Jsb2Nrc2NvdXR8MDAwNmQADHdhdGNobGlzdF9pZGHG.86gruprPiLE-Nf9xkOzjEcW2wfSnCCPly5fHTwHrF6c; path=/; HttpOnly -content-type: application/json; charset=utf-8 -cache-control: max-age=0, private, must-revalidate -x-request-id: FxF1Y2E03jhU4u4AAGSi -access-control-allow-credentials: true -access-control-allow-origin: * -access-control-expose-headers: -``` -* __Response body:__ -```json -{ - "website": "website2", - "tags": "Tag2;Tag3", - "submission_date": "2022-09-03T21:00:07.000000Z", - "is_owner": true, - "id": 132, - "full_name": "full name2", - "email": "test_user-9@blockscout.com", - "company": "company2", - "addresses": [ - "0x000000000000000000000000000000000000000f", - "0x0000000000000000000000000000000000000010", - "0x0000000000000000000000000000000000000011", - "0x0000000000000000000000000000000000000012", - "0x0000000000000000000000000000000000000013", - "0x0000000000000000000000000000000000000014" - ], - "additional_comment": "additional_comment2" -} -``` - diff --git a/apps/block_scout_web/SMART_CONTRACT_VERIFICATION_WEBSOCKET.md b/apps/block_scout_web/SMART_CONTRACT_VERIFICATION_WEBSOCKET.md new file mode 100644 index 000000000000..b330f68bcf0e --- /dev/null +++ b/apps/block_scout_web/SMART_CONTRACT_VERIFICATION_WEBSOCKET.md @@ -0,0 +1,253 @@ +# Smart Contract Verification Websocket Events + +This guide explains how to subscribe to websocket notifications related to smart contract verification. + +It covers: +- verification result notifications +- automated source lookup lifecycle notifications +- legacy and V2 websocket namespaces + +## 1. Which Socket To Use + +Blockscout exposes two websocket endpoints: + +- Legacy UI socket: `/socket` +- V2 socket: `/socket/v2` + +For new integrations, use the V2 socket. + +## 2. Topic Format + +Subscribe to an address topic. + +- Legacy topic: `addresses_old:` +- V2 topic: `addresses:` + +Examples: +- `addresses_old:0xabc123...` +- `addresses:0xabc123...` + +The join validates the address hash and access restrictions. Join may fail with: +- `Invalid address hash` +- `Restricted access` + +## 3. Events You Can Receive + +### 3.1 `verification_result` + +Purpose: +- Final result of a verification attempt (success or validation errors). + +Emitted to topics: +- `addresses:` +- `addresses_old:` + +Broadcast type in event bus: +- `:on_demand` + +#### V2 payload + +Success: + +```json +{ + "status": "success" +} +``` + +Error: + +```json +{ + "status": "error", + "errors": { + "field_name": [ + "error message" + ] + } +} +``` + +Notes: +- `errors` is generated from changeset errors. +- Field names and messages depend on verification flow and validator results. + +#### Legacy behavior + +Legacy notifier broadcasts an internal payload with `result`, but the legacy address channel intercepts `verification_result` and pushes event `verification` to clients. + +Legacy client-facing event: +- `verification` + +Legacy client payload: + +```json +{ + "verification_result": "ok" +} +``` + +or + +```json +{ + "verification_result": "" +} +``` + +Important legacy nuance: +- If the intercepted result is `{:error, %Ecto.Changeset{}}`, the channel does not push a websocket message for that event. + +### 3.2 `eth_bytecode_db_lookup_started` + +Purpose: +- Signals that automated lookup in Ethereum Bytecode DB started. + +Emitted to topics: +- `addresses:` +- `addresses_old:` + +Payload: + +```json +{} +``` + +### 3.3 `smart_contract_was_verified` + +Purpose: +- Signals that automated lookup/verification finished with a verified result. + +Emitted to topics: +- `addresses:` +- `addresses_old:` + +Payload: + +```json +{} +``` + +### 3.4 `smart_contract_was_not_verified` + +Purpose: +- Signals that automated lookup/verification finished without verification. + +Emitted to topics: +- `addresses:` +- `addresses_old:` + +Payload: + +```json +{} +``` + +## 4. Event Producers (Server-Side) + +### `contract_verification_result` chain event + +Produced by verification workers/helpers and then mapped to websocket `verification_result`: +- Solidity verification worker +- Vyper verification worker +- Stylus verification worker +- Solidity publish helper (including some error paths) + +### Automated source lookup lifecycle chain events + +Produced by on-demand source lookup fetcher and mapped 1:1 to websocket event names: +- `eth_bytecode_db_lookup_started` +- `smart_contract_was_verified` +- `smart_contract_was_not_verified` + +## 5. Subscription Example (Phoenix JS) + +### V2 (recommended) + +```javascript +import { Socket } from "phoenix"; + +const socket = new Socket("https://your-blockscout.example/socket/v2", { + params: {} +}); + +socket.connect(); + +const addressHash = "0x..."; +const channel = socket.channel(`addresses:${addressHash}`, {}); + +channel + .join() + .receive("ok", () => console.log("joined")) + .receive("error", (err) => console.error("join failed", err)); + +channel.on("verification_result", (payload) => { + // { status: "success" } OR { status: "error", errors: {...} } + console.log("verification_result", payload); +}); + +channel.on("eth_bytecode_db_lookup_started", () => { + console.log("lookup started"); +}); + +channel.on("smart_contract_was_verified", () => { + console.log("verified via automatic lookup"); +}); + +channel.on("smart_contract_was_not_verified", () => { + console.log("not verified via automatic lookup"); +}); +``` + +### Legacy + +```javascript +import { Socket } from "phoenix"; + +const socket = new Socket("https://your-blockscout.example/socket", { + params: { locale: "en" } +}); + +socket.connect(); + +const addressHash = "0x..."; +const channel = socket.channel(`addresses_old:${addressHash}`, {}); + +channel.join(); + +// Legacy verification result event name is "verification" +channel.on("verification", (payload) => { + // payload.verification_result is "ok" or rendered html error string + console.log("verification", payload); +}); + +// Automatic lookup lifecycle events are forwarded with original names +channel.on("eth_bytecode_db_lookup_started", () => { + console.log("lookup started"); +}); + +channel.on("smart_contract_was_verified", () => { + console.log("verified"); +}); + +channel.on("smart_contract_was_not_verified", () => { + console.log("not verified"); +}); +``` + +## 6. Practical Client Flow + +Recommended for automation clients: + +1. Submit verification request via HTTP API. +2. Immediately subscribe to `addresses:` on `/socket/v2`. +3. Wait for `verification_result` for final API-style outcome. +4. Optionally track automated lookup lifecycle with: + - `eth_bytecode_db_lookup_started` + - `smart_contract_was_verified` + - `smart_contract_was_not_verified` + +Notes: +- API response like "verification started" means the job was accepted, not completed. +- Final state should be taken from websocket events. +- Server currently broadcasts to both legacy and V2 address namespaces for backward compatibility. diff --git a/apps/block_scout_web/assets/package-lock.json b/apps/block_scout_web/assets/package-lock.json index c484b41945c8..43aa08cee137 100644 --- a/apps/block_scout_web/assets/package-lock.json +++ b/apps/block_scout_web/assets/package-lock.json @@ -5,19 +5,19 @@ "packages": { "": { "name": "blockscout", - "license": "GPL-3.0", + "license": "SEE LICENSE IN ../../../LICENSE", "dependencies": { - "@amplitude/analytics-browser": "^2.12.2", + "@amplitude/analytics-browser": "^2.21.1", "@fortawesome/fontawesome-free": "^6.7.2", "@tarekraafat/autocomplete.js": "^10.2.9", "@walletconnect/web3-provider": "^1.8.0", "assert": "^2.1.0", - "bignumber.js": "^9.1.2", + "bignumber.js": "^9.3.1", "bootstrap": "^4.6.0", - "chart.js": "^4.4.8", + "chart.js": "^4.5.0", "chartjs-adapter-luxon": "^1.3.1", "clipboard": "^2.0.11", - "core-js": "^3.41.0", + "core-js": "^3.43.0", "crypto-browserify": "^3.12.1", "dropzone": "^5.9.3", "eth-net-props": "^1.0.41", @@ -44,9 +44,9 @@ "lodash.omit": "^4.5.0", "lodash.rangeright": "^4.2.0", "lodash.reduce": "^4.6.0", - "luxon": "^3.5.0", + "luxon": "^3.7.1", "malihu-custom-scrollbar-plugin": "3.1.5", - "mixpanel-browser": "^2.63.0", + "mixpanel-browser": "^2.67.0", "moment": "^2.30.1", "nanomorph": "^5.4.0", "numeral": "^2.0.6", @@ -61,7 +61,7 @@ "redux": "^5.0.1", "stream-browserify": "^3.0.0", "stream-http": "^3.1.1", - "sweetalert2": "^11.17.2", + "sweetalert2": "^11.22.4", "urijs": "^1.19.11", "url": "^0.11.4", "util": "^0.12.5", @@ -71,29 +71,29 @@ "xss": "^1.0.15" }, "devDependencies": { - "@babel/core": "^7.26.10", - "@babel/preset-env": "^7.26.9", + "@babel/core": "^7.28.0", + "@babel/preset-env": "^7.28.0", "autoprefixer": "^10.4.21", "babel-loader": "^10.0.0", "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.2", - "eslint": "^9.23.0", + "eslint": "^9.30.1", "eslint-formatter-junit": "^8.40.0", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^7.2.1", "file-loader": "^6.2.0", - "globals": "^16.0.0", + "globals": "^16.3.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.0.5", "mini-css-extract-plugin": "^2.9.2", - "postcss": "^8.5.1", + "postcss": "^8.5.6", "postcss-loader": "^8.1.1", - "sass": "^1.86.1", + "sass": "^1.89.2", "sass-loader": "^14.2.1", "style-loader": "^4.0.0", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" }, "engines": { @@ -122,15 +122,17 @@ "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" }, "node_modules/@amplitude/analytics-browser": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.12.2.tgz", - "integrity": "sha512-Z41NKTbda144yo3/lLGENlVQjrIV/4J8TZyoPlysHCAXQdGh78cv1j29RKy/zqc8nu6ieY1qoH/aDRdPC1UvEw==", + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.21.1.tgz", + "integrity": "sha512-3Snmz4OgtSFWnFcqIC5QCFuUSIaVIK2iX0Wl1KIV+DzDhRwc+JIxuJCiwgWkvTPiO1N0Vfi9CO/bdockHqEMzg==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "^2.6.1", + "@amplitude/analytics-core": "^2.19.0", "@amplitude/analytics-remote-config": "^0.4.0", - "@amplitude/plugin-autocapture-browser": "^1.1.2", - "@amplitude/plugin-page-view-tracking-browser": "^2.3.12", + "@amplitude/plugin-autocapture-browser": "^1.8.1", + "@amplitude/plugin-network-capture-browser": "^1.4.2", + "@amplitude/plugin-page-view-tracking-browser": "^2.3.37", + "@amplitude/plugin-web-vitals-browser": "^0.1.0-beta.12", "tslib": "^2.4.1" } }, @@ -140,14 +142,14 @@ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" }, "node_modules/@amplitude/analytics-client-common": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.3.11.tgz", - "integrity": "sha512-1Q1obcN14R7lpK6+UfqFdhLIhmwWrxLgHoJhEV/FwDPq1YvwhENtzVGmNuQtUUWOPIuavltmZ6w1zdL18YIahQ==", + "version": "2.3.31", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.3.31.tgz", + "integrity": "sha512-YkhKM78htSVNpRd+ul6MgDekaKseRCnYVLDyBQ0u3vtIt6m5kVtbBMffcrZz7DbOHIsyrUxA8u/RfLo+7xkouA==", "license": "MIT", "dependencies": { "@amplitude/analytics-connector": "^1.4.8", - "@amplitude/analytics-core": "^2.6.1", - "@amplitude/analytics-types": "^2.9.1", + "@amplitude/analytics-core": "^2.19.0", + "@amplitude/analytics-types": "^2.9.2", "tslib": "^2.4.1" } }, @@ -157,19 +159,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@amplitude/analytics-connector": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.1.tgz", - "integrity": "sha512-QAGeOfBQc3tamwcECu6YqAPD4mFI1TLBoWi+n0iViYWUZma2FeDLPMihwIquxI8CVvqpn4gswFZsIPRit3q9tQ==", - "dependencies": { - "@amplitude/experiment-core": "^0.10.0" - } + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", + "integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==", + "license": "MIT" }, "node_modules/@amplitude/analytics-core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.6.1.tgz", - "integrity": "sha512-PaTs1T02j8/zGNsLuHsTrMh1kFjxtNxVaaUgGuqqM1dl2Jf5sPFQdwu8O/mqrgmV4vPSStvf7odSUpPNN1tFnQ==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.19.0.tgz", + "integrity": "sha512-G1RwMOjkEV1Ccx7vU3CsPFt0w5yNFlVFNpCKaWO7zVbsqA9PRljPXj3fKXPrto6s2vKTXmuGG5NuWM/iSVfBwA==", "license": "MIT", "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", "tslib": "^2.4.1" } }, @@ -195,44 +196,81 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@amplitude/analytics-types": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.9.1.tgz", - "integrity": "sha512-Hw0pgUQCDV6AgvZD8ymBF3mzyBnPrIvXiPxfTNTg/bAkg1h+GVNdJIL6VAXd53kYuO7Lx/vjdhZS54i+RYtgMA==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.9.2.tgz", + "integrity": "sha512-juhTz396dDP/jLJYP9zDOEAZBtJM0JVvP8G10p1OxUDBVwVIprpQL598F9GRQwVFyqV4CEhDmNyAY0HqqU5bhA==", "license": "MIT" }, - "node_modules/@amplitude/experiment-core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.10.0.tgz", - "integrity": "sha512-FBfM6a4aHp+7OYLYiHO3kni3vCThInT6o4ucuNIB+EIvQ141gQDSjMPOET3ik82fMuJPQITex9sdpZ6cO30ALw==", + "node_modules/@amplitude/plugin-autocapture-browser": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.8.1.tgz", + "integrity": "sha512-Wg5Ogi2rW9DM40ZI7iTZuGxm7TXUfkfAZ5yTUrRDy5MIXr+zdv89IpXhj4fsKW9ZO3xc7OFkFklXCWq+QJ580A==", + "license": "MIT", "dependencies": { - "js-base64": "^3.7.5" + "@amplitude/analytics-core": "^2.19.0", + "@amplitude/analytics-remote-config": "^0.6.3", + "rxjs": "^7.8.1", + "tslib": "^2.4.1" } }, - "node_modules/@amplitude/plugin-autocapture-browser": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.1.2.tgz", - "integrity": "sha512-/P5hX3FtcMgYlNn38bVbqElETN+BSwLvfopwXRoBTIjfaBS340QytbvfXlo18qfeq8mHU6bi4l8fVPZdessBZQ==", + "node_modules/@amplitude/plugin-autocapture-browser/node_modules/@amplitude/analytics-remote-config": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-remote-config/-/analytics-remote-config-0.6.3.tgz", + "integrity": "sha512-icE0ogCzdHAtQi9jiOFQUmKrvWQc5YEO6bLZUfQXCT/yTTNXppWnT1zHMKzXa3SMDosfrLwU/X8sro1PTI+jZQ==", "license": "MIT", "dependencies": { - "@amplitude/analytics-core": "^2.6.1", - "rxjs": "^7.8.1", + "@amplitude/analytics-core": ">=1 <2", + "@amplitude/analytics-types": ">=1 <2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-autocapture-browser/node_modules/@amplitude/analytics-remote-config/node_modules/@amplitude/analytics-core": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-1.2.8.tgz", + "integrity": "sha512-Krxpr5uvS3HmmjvpYqPfbMbs2kcZZu09L+6KwQnPiofWRzoXWIM217fRfy6aSD/QrAoPGbZjvtVitw9cB7Cx+A==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-types": "^1.4.0", "tslib": "^2.4.1" } }, + "node_modules/@amplitude/plugin-autocapture-browser/node_modules/@amplitude/analytics-types": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-1.4.0.tgz", + "integrity": "sha512-RiMPHBqdrJ8ktTqG+Wzj2htnN/PCG9jGZG0SXtTFnWwVvcAJYbYm55/nrP1TTyrx1OlLhvF2VG3lVUP/xGAU8w==", + "license": "MIT" + }, "node_modules/@amplitude/plugin-autocapture-browser/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@amplitude/plugin-network-capture-browser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.4.2.tgz", + "integrity": "sha512-Is1uBYfqEeS3bm0mVyqksW1AOTr5B9JWQOinT9WcF4R9c/56CoIXBGcLnRG+4p5p6xAQcmP/tggjc8xsDeI52g==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "^2.19.0", + "rxjs": "^7.8.1", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-network-capture-browser/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@amplitude/plugin-page-view-tracking-browser": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.3.12.tgz", - "integrity": "sha512-vsfe4UqjrPvbJ9pS3X0x3bN8FL9Phlt/2BsC6SaHsZCqiScvJ+Hn+r5D36/wKbZp1XNrmc2YOjrOuWwzzdUsNA==", + "version": "2.3.37", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.3.37.tgz", + "integrity": "sha512-Jikvke4Af8qQMwWcF6LHBF/HYUaAaWxZPcJoFYS1HK0/Gzxsa/T5SIJC35EDEERsHMKRryrNUdchlBY2xFKULw==", "license": "MIT", "dependencies": { - "@amplitude/analytics-client-common": "^2.3.11", - "@amplitude/analytics-types": "^2.9.1", + "@amplitude/analytics-client-common": "^2.3.31", + "@amplitude/analytics-types": "^2.9.2", "tslib": "^2.4.1" } }, @@ -242,6 +280,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@amplitude/plugin-web-vitals-browser": { + "version": "0.1.0-frustrationanalytics.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-0.1.0-frustrationanalytics.0.tgz", + "integrity": "sha512-xv4sje6/D8r+SgNFTA22FJ5PhtdhN+VSydvs63Frll+qWlyQwaZ1IgDbPyqjzryEkldHRPD7GUaQual+geoIYg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "^2.14.0-frustrationanalytics.0", + "rxjs": "^7.8.1", + "tslib": "^2.4.1", + "web-vitals": "^5.0.1" + } + }, + "node_modules/@amplitude/plugin-web-vitals-browser/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -266,44 +322,66 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -324,15 +402,15 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -340,24 +418,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -380,17 +459,18 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "engines": { @@ -401,13 +481,14 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", - "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "regexpu-core": "^6.1.1", + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, "engines": { @@ -433,39 +514,51 @@ "@babel/core": "^7.4.0-0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -475,35 +568,37 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", - "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-wrap-function": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -513,14 +608,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", - "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -530,76 +626,81 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", - "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -609,13 +710,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", - "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -625,12 +727,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", - "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -640,12 +743,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", - "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -655,14 +759,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -672,13 +777,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", - "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -736,12 +842,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", - "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -751,12 +858,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -923,12 +1031,13 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", - "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -938,15 +1047,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", - "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.26.8" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -956,14 +1065,15 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", - "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -973,13 +1083,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", - "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -989,12 +1099,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", - "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", + "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1004,13 +1115,14 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", - "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1020,13 +1132,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", - "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1036,17 +1149,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", - "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", + "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/traverse": "^7.25.9", - "globals": "^11.1.0" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1055,24 +1169,15 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-classes/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", - "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/template": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1082,12 +1187,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", - "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1097,13 +1204,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", - "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1113,12 +1221,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", - "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1128,13 +1237,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1144,12 +1254,30 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", - "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1159,13 +1287,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", - "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1175,12 +1303,13 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", - "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1190,14 +1319,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", - "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1207,14 +1336,15 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", - "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1224,12 +1354,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", - "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1239,12 +1370,13 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", - "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1254,12 +1386,13 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", - "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1269,12 +1402,13 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", - "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1284,13 +1418,14 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", - "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1300,14 +1435,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", - "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1317,15 +1452,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", - "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1335,13 +1471,14 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", - "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1351,13 +1488,14 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1367,12 +1505,13 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", - "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1382,13 +1521,13 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.26.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", - "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1398,12 +1537,13 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", - "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1413,14 +1553,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", - "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", + "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9" + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1430,13 +1573,14 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", - "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1446,12 +1590,13 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", - "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1461,13 +1606,14 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1477,12 +1623,13 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", - "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1492,13 +1639,14 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", - "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1508,14 +1656,15 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", - "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1525,12 +1674,13 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", - "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1540,13 +1690,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", - "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.1.tgz", + "integrity": "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "regenerator-transform": "^0.15.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1556,13 +1706,14 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", - "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1572,12 +1723,13 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", - "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1618,12 +1770,13 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", - "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1633,13 +1786,14 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", - "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1649,12 +1803,13 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", - "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1664,13 +1819,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", - "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1680,13 +1835,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz", - "integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1696,12 +1851,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", - "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1711,13 +1867,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", - "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1727,13 +1884,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", - "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1743,13 +1901,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", - "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1759,80 +1918,81 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", - "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", + "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/compat-data": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.26.8", - "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.26.5", - "@babel/plugin-transform-block-scoping": "^7.25.9", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.26.0", - "@babel/plugin-transform-classes": "^7.25.9", - "@babel/plugin-transform-computed-properties": "^7.25.9", - "@babel/plugin-transform-destructuring": "^7.25.9", - "@babel/plugin-transform-dotall-regex": "^7.25.9", - "@babel/plugin-transform-duplicate-keys": "^7.25.9", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.26.3", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.26.9", - "@babel/plugin-transform-function-name": "^7.25.9", - "@babel/plugin-transform-json-strings": "^7.25.9", - "@babel/plugin-transform-literals": "^7.25.9", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", - "@babel/plugin-transform-member-expression-literals": "^7.25.9", - "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.26.3", - "@babel/plugin-transform-modules-systemjs": "^7.25.9", - "@babel/plugin-transform-modules-umd": "^7.25.9", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", - "@babel/plugin-transform-numeric-separator": "^7.25.9", - "@babel/plugin-transform-object-rest-spread": "^7.25.9", - "@babel/plugin-transform-object-super": "^7.25.9", - "@babel/plugin-transform-optional-catch-binding": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9", - "@babel/plugin-transform-private-methods": "^7.25.9", - "@babel/plugin-transform-private-property-in-object": "^7.25.9", - "@babel/plugin-transform-property-literals": "^7.25.9", - "@babel/plugin-transform-regenerator": "^7.25.9", - "@babel/plugin-transform-regexp-modifiers": "^7.26.0", - "@babel/plugin-transform-reserved-words": "^7.25.9", - "@babel/plugin-transform-shorthand-properties": "^7.25.9", - "@babel/plugin-transform-spread": "^7.25.9", - "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.26.8", - "@babel/plugin-transform-typeof-symbol": "^7.26.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.9", - "@babel/plugin-transform-unicode-property-regex": "^7.25.9", - "@babel/plugin-transform-unicode-regex": "^7.25.9", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.0", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.0", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "engines": { @@ -1843,29 +2003,31 @@ } }, "node_modules/@babel/preset-env/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", - "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", - "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.1", + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { @@ -1873,12 +2035,13 @@ } }, "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.1.tgz", - "integrity": "sha512-JfTApdE++cgcTWjsiCQlLyFBMbTUft9ja17saCc93lgV33h4tuCVj7tlvu//qpLwaG+3yEz7/KhahGrUMkVq9g==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.1" + "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -1899,65 +2062,57 @@ } }, "node_modules/@babel/runtime": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.0.tgz", - "integrity": "sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1969,6 +2124,121 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -2031,9 +2301,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2046,9 +2316,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2056,9 +2326,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2126,13 +2396,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", - "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -2146,13 +2419,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.14.0", "levn": "^0.4.1" }, "engines": { @@ -2294,80 +2567,10 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/console/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/console/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/console/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/console/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/console/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, "dependencies": { "@jest/console": "^29.7.0", @@ -2411,91 +2614,260 @@ } } }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.0.5.tgz", + "integrity": "sha512-gpWwiVxZunkoglP8DCnT3As9x5O8H6gveAOpvaJd2ATAoSh7ZSSCWbr9LQtUMvr8WD3VjG9YnDhsmkCK5WN1rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/@jest/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" }, "engines": { - "node": ">=10" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=7.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } }, - "node_modules/@jest/core/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@jest/core/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/types": "30.0.5", "@types/node": "*", - "jest-mock": "^29.7.0" + "jest-util": "30.0.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -2553,6 +2925,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -2596,77 +2992,28 @@ } } }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@jest/reporters/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/@jest/reporters/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/reporters/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/reporters/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/reporters/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/reporters/node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/reporters/node_modules/jest-worker/node_modules/supports-color": { @@ -2684,18 +3031,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@jest/reporters/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2778,82 +3113,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/transform/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/transform/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/@jest/transform/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/@jest/transform/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/transform/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -2871,87 +3136,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/types/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@jest/types/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@jest/types/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/@jest/types/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/types/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2982,14 +3174,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3525,10 +3719,11 @@ } }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.0", @@ -3540,19 +3735,21 @@ } }, "node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } }, "node_modules/@types/jsdom": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz", - "integrity": "sha512-YfAchFs0yM1QPDrLm2VHe+WHGtqms3NXnXAMolrgrVP6fgBHHXy1ozAbo/dFtPNtZC/m66bPiCTWYmqp1F14gA==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -3594,16 +3791,18 @@ } }, "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/tough-cookie": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", - "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", - "dev": true + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/ws": { "version": "8.5.3", @@ -3614,10 +3813,11 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.11.tgz", - "integrity": "sha512-aB4y9UDUXTSMxmM4MH+YnuR0g5Cph3FLQBoWoMB21DSvFVAxRVEHEMx3TLh+zUZYMCQtKiqazz0Q4Rre31f/OA==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -4081,12 +4281,6 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "dev": true - }, "node_modules/abitype": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.7.1.tgz", @@ -4110,32 +4304,11 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "dev": true, - "dependencies": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - } - }, - "node_modules/acorn-globals/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -4153,30 +4326,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/aes-js": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "dependencies": { - "debug": "4" - }, + "license": "MIT", "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -4300,13 +4462,14 @@ } }, "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { "node": ">= 0.4" @@ -4316,17 +4479,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4336,17 +4502,19 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4356,15 +4524,16 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4374,15 +4543,16 @@ } }, "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4392,19 +4562,19 @@ } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -4480,6 +4650,16 @@ "async": "^2.4.0" } }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -4589,76 +4769,6 @@ "@babel/core": "^7.8.0" } }, - "node_modules/babel-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/babel-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/babel-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/babel-loader": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", @@ -4796,31 +4906,31 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", - "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -4913,9 +5023,10 @@ "dev": true }, "node_modules/base-x": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz", - "integrity": "sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", "dependencies": { "safe-buffer": "^5.0.1" } @@ -4966,9 +5077,10 @@ } }, "node_modules/bignumber.js": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", - "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", "engines": { "node": "*" } @@ -5030,12 +5142,6 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "dev": true - }, "node_modules/browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", @@ -5163,9 +5269,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "funding": [ { "type": "opencollective", @@ -5182,10 +5288,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -5304,15 +5410,44 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -5357,9 +5492,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001707", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", - "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "funding": [ { "type": "opencollective", @@ -5397,6 +5532,82 @@ "node": ">=4" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/chalk/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -5407,9 +5618,9 @@ } }, "node_modules/chart.js": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", - "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -5474,14 +5685,38 @@ "dev": true }, "node_modules/cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", + "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", + "license": "MIT", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" } }, + "node_modules/cipher-base/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -5650,9 +5885,9 @@ } }, "node_modules/core-js": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", - "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz", + "integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==", "hasInstallScript": true, "license": "MIT", "funding": { @@ -5661,12 +5896,12 @@ } }, "node_modules/core-js-compat": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", - "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", + "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.4" + "browserslist": "^4.25.1" }, "funding": { "type": "opencollective", @@ -5793,104 +6028,34 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/create-jest/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/cross-fetch": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.2.6.tgz", + "integrity": "sha512-9JZz+vXCmfKUZ68zAptS7k4Nu8e2qcibe7WVZYps7sAgk5R8GYTc+T1WR0v1rlP9HxgARmOX1UTIJZFytajpNA==", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node-fetch": "^2.6.7", + "whatwg-fetch": "^2.0.4" } }, - "node_modules/create-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 8" } }, - "node_modules/create-jest/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/create-jest/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/create-jest/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/create-jest/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cross-fetch": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.2.6.tgz", - "integrity": "sha512-9JZz+vXCmfKUZ68zAptS7k4Nu8e2qcibe7WVZYps7sAgk5R8GYTc+T1WR0v1rlP9HxgARmOX1UTIJZFytajpNA==", - "dependencies": { - "node-fetch": "^2.6.7", - "whatwg-fetch": "^2.0.4" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypto-browserify": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", - "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", "dependencies": { "browserify-cipher": "^1.0.1", "browserify-sign": "^4.2.3", @@ -6284,30 +6449,20 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true - }, "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, + "license": "MIT", "dependencies": { - "cssom": "~0.3.6" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -6320,28 +6475,29 @@ } }, "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, + "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -6351,29 +6507,31 @@ } }, "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/inspect-js" } }, "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" }, @@ -6385,11 +6543,12 @@ } }, "node_modules/debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -6409,10 +6568,11 @@ } }, "node_modules/decimal.js": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.0.tgz", - "integrity": "sha512-Nv6ENEzyPQ6AItkGwLE2PGKinZZ9g59vSh2BeH6NqPu0OTKZ5ruJsVqh/orbAnqXc9pBbgXAIrc2EyaCj8NpGg==", - "dev": true + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" }, "node_modules/decode-uri-component": { "version": "0.2.2", @@ -6613,18 +6773,6 @@ ], "license": "BSD-2-Clause" }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "dev": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -6661,6 +6809,20 @@ "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.3.tgz", "integrity": "sha512-Azk8kD/2/nJIuVPK+zQ9sjKMRIpRvNyqn9XwbBHNq+iNuSccbJS6hwm1Woy0pMST0erSo0u4j+KJaodndDk4vA==" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -6671,9 +6833,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.109", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.109.tgz", - "integrity": "sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==", + "version": "1.5.194", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", + "integrity": "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==", "license": "ISC" }, "node_modules/elliptic": { @@ -6733,19 +6895,6 @@ "iconv-lite": "^0.6.2" } }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.17.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", @@ -6813,57 +6962,66 @@ } }, "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, + "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", + "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -6873,12 +7031,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -6898,10 +7054,10 @@ "dev": true }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -6910,37 +7066,44 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, + "license": "MIT", "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" }, "engines": { "node": ">= 0.4" @@ -6957,103 +7120,21 @@ "node": ">=6" } }, - "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", - "dev": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/escodegen/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/escodegen/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", - "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.23.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -7064,9 +7145,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -7131,10 +7212,11 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.2.7" }, @@ -7152,34 +7234,36 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -7317,55 +7401,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -7379,9 +7414,9 @@ } }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7396,9 +7431,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7434,15 +7469,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7482,28 +7508,16 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7513,9 +7527,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8244,11 +8258,18 @@ "license": "ISC" }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/forever-agent": { @@ -8314,15 +8335,18 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" @@ -8341,6 +8365,7 @@ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8370,15 +8395,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8396,6 +8427,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -8409,14 +8453,15 @@ } }, "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -8481,9 +8526,9 @@ } }, "node_modules/globals": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", - "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { @@ -8494,12 +8539,14 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, + "license": "MIT", "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -8517,11 +8564,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8555,10 +8603,14 @@ } }, "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8583,9 +8635,14 @@ } }, "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -8594,9 +8651,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -8697,15 +8755,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, + "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/html-escaper": { @@ -8714,6 +8773,20 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -8734,16 +8807,17 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-signals": { @@ -8760,6 +8834,19 @@ "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", "integrity": "sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -8880,14 +8967,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8918,13 +9006,15 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -8939,26 +9029,51 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, + "license": "MIT", "dependencies": { - "has-bigints": "^1.0.1" + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -8979,9 +9094,10 @@ } }, "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", "dependencies": { "hasown": "^2.0.2" }, @@ -8993,11 +9109,14 @@ } }, "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, + "license": "MIT", "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" }, "engines": { @@ -9008,12 +9127,14 @@ } }, "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9031,6 +9152,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fn/-/is-fn-1.0.0.tgz", @@ -9097,6 +9234,19 @@ "npm": ">=3" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-nan": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", @@ -9134,12 +9284,14 @@ } }, "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9164,17 +9316,34 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -9183,12 +9352,13 @@ } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -9210,12 +9380,14 @@ } }, "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, + "license": "MIT", "dependencies": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9225,12 +9397,15 @@ } }, "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -9240,11 +9415,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -9258,13 +9434,47 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9476,80 +9686,10 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-circus/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-circus/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-circus/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-circus/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, "dependencies": { "@jest/core": "^29.7.0", @@ -9579,76 +9719,6 @@ } } }, - "node_modules/jest-cli/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-cli/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-cli/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-cli/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-cli/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-config": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", @@ -9694,286 +9764,285 @@ } } }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "detect-newline": "^3.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-config/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-config/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-config/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-config/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest-environment-jsdom": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.0.5.tgz", + "integrity": "sha512-BmnDEoAH+jEjkPrvE9DTKS2r3jYSJWlN/r46h0/DBUxKrkgt2jAZ5Nj4wXLAcV1KWkRpcFqA5zri9SWzJZ1cCg==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/environment": "30.0.5", + "@jest/environment-jsdom-abstract": "30.0.5", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "node_modules/jest-environment-jsdom/node_modules/@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", "dev": true, + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-environment-jsdom/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@sinclair/typebox": "^0.34.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": ">=7.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-diff/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/jest-environment-jsdom/node_modules/@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", "dev": true, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/jest-diff/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "type-detect": "4.0.8" } }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, + "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-each/node_modules/ansi-styles": { + "node_modules/jest-environment-jsdom/node_modules/ci-info": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-each/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/jest-environment-jsdom/node_modules/jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/jest-environment-jsdom/node_modules/jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", "dev": true, + "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" }, "engines": { - "node": ">=7.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-each/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/jest-environment-jsdom/node_modules/jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest-environment-jsdom/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "node_modules/jest-environment-jsdom/node_modules/pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-environment-jsdom/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -10079,177 +10148,37 @@ }, "node_modules/jest-matcher-utils": { "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-matcher-utils/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-matcher-utils/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-matcher-utils/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-matcher-utils/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-message-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-message-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-message-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-mock": { @@ -10325,76 +10254,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-resolve/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-resolve/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-resolve/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-resolve/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-resolve/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-runner": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", @@ -10427,55 +10286,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runner/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-runner/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-runner/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-runner/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/jest-runner/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -10525,18 +10335,6 @@ "source-map": "^0.6.0" } }, - "node_modules/jest-runner/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-runtime": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", @@ -10570,76 +10368,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runtime/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-runtime/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-runtime/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-runtime/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-runtime/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-runtime/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-snapshot": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", @@ -10663,70 +10391,12 @@ "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-snapshot/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-snapshot/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-snapshot/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-snapshot/node_modules/semver": { @@ -10744,18 +10414,6 @@ "node": ">=10" } }, - "node_modules/jest-snapshot/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -10773,76 +10431,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-util/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-util/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-util/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-util/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -10860,21 +10448,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -10887,61 +10460,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-validate/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-validate/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-watcher": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", @@ -10961,76 +10479,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-watcher/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-watcher/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-watcher/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-watcher/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-watcher/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-watcher/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -11091,11 +10539,6 @@ "resolved": "https://registry.npmjs.org/jquery-mousewheel/-/jquery-mousewheel-3.1.13.tgz", "integrity": "sha512-GXhSjfOPyDemM005YCEHvzrEALhKDIswtxSHSR2e4K/suHVJKJxxRCGz3skPjNxjJjQa9AVSGGlYjv1M3VLIPg==" }, - "node_modules/js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==" - }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -11133,86 +10576,43 @@ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "node_modules/jsdom": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.0.tgz", - "integrity": "sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA==", - "dev": true, - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.7.1", - "acorn-globals": "^6.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.3.1", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "^7.0.0", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^3.0.0", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.8.0", - "xml-name-validator": "^4.0.0" + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/jsdom/node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/jsdom/node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" + "canvas": { + "optional": true + } } }, "node_modules/jsesc": { @@ -11700,9 +11100,10 @@ "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=" }, "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "license": "MIT", "engines": { "node": ">=12" } @@ -11754,6 +11155,15 @@ "jquery-mousewheel": ">=3.0.6" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -11980,9 +11390,9 @@ "license": "MIT" }, "node_modules/mixpanel-browser": { - "version": "2.63.0", - "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.63.0.tgz", - "integrity": "sha512-h7M0J/LR/5YLWCVuvPaYuzwV7CgV9jkJz0m94uaTDPebWkhNQPEir63rf/ZpBZgntyvYjO1yMZp2pIpwQ1sBMQ==", + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.67.0.tgz", + "integrity": "sha512-LudY4eRIkvjEpAlIAg10i2T2mbtiKZ4XlMGbTyF1kcAhEqMa9JhEEdEcjxYPwiKhuMVSBM3RVkNCZaNqcnE4ww==", "license": "Apache-2.0", "dependencies": { "rrweb": "2.0.0-alpha.18" @@ -11997,9 +11407,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/nanoassert": { "version": "1.1.0", @@ -12007,9 +11418,9 @@ "integrity": "sha1-TzFS4JVA/eKMdvRLGbvNHVpCR40=" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -12161,10 +11572,11 @@ } }, "node_modules/nwsapi": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.1.tgz", - "integrity": "sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg==", - "dev": true + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", + "dev": true, + "license": "MIT" }, "node_modules/oauth-sign": { "version": "0.9.0", @@ -12183,9 +11595,13 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12214,13 +11630,16 @@ } }, "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { @@ -12263,12 +11682,14 @@ } }, "node_modules/object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, @@ -12325,6 +11746,24 @@ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=" }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -12458,17 +11897,31 @@ } }, "node_modules/parse5": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz", - "integrity": "sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, + "license": "MIT", "dependencies": { - "entities": "^4.3.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -12518,20 +11971,73 @@ } }, "node_modules/pbkdf2": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz", + "integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==", + "license": "MIT", "dependencies": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "create-hash": "~1.1.3", + "create-hmac": "^1.1.7", + "ripemd160": "=2.0.1", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.11", + "to-buffer": "^1.2.0" }, "engines": { "node": ">=0.12" } }, + "node_modules/pbkdf2/node_modules/create-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", + "integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==", + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "sha.js": "^2.4.0" + } + }, + "node_modules/pbkdf2/node_modules/hash-base": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", + "integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1" + } + }, + "node_modules/pbkdf2/node_modules/ripemd160": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", + "integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==", + "license": "MIT", + "dependencies": { + "hash-base": "^2.0.0", + "inherits": "^2.0.1" + } + }, + "node_modules/pbkdf2/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -12619,9 +12125,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -12638,7 +12144,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13357,9 +12863,10 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", "engines": { "node": ">=6" } @@ -13555,12 +13062,6 @@ "node": ">=0.6" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -13658,6 +13159,29 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -13677,28 +13201,23 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.8.4" - } + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" }, "node_modules/regexp.prototype.flags": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", - "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { @@ -13820,24 +13339,22 @@ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -13926,6 +13443,13 @@ "rrweb-snapshot": "^2.0.0-alpha.18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rrweb-snapshot": { "version": "2.0.0-alpha.18", "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz", @@ -13956,14 +13480,16 @@ "license": "0BSD" }, "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "engines": { @@ -13977,7 +13503,8 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.1.2", @@ -13993,15 +13520,40 @@ "events": "^3.0.0" } }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.1.4" + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -14016,9 +13568,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { - "version": "1.86.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.86.1.tgz", - "integrity": "sha512-Yaok4XELL1L9Im/ZUClKu//D2OP1rOljKj0Gf34a+GzLbMveOzL7CfqYo+JUa5Xt1nhTCW+OcKp/FtR7/iqj1w==", + "version": "1.89.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", + "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "dev": true, "license": "MIT", "dependencies": { @@ -14081,6 +13633,7 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, @@ -14098,9 +13651,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14236,6 +13789,7 @@ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, + "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -14254,23 +13808,66 @@ "node": ">=0.10.0" } }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sha.js/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -14310,14 +13907,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -14414,10 +14066,11 @@ } }, "node_modules/stack-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", - "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -14434,6 +14087,20 @@ "node": ">=8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -14509,15 +14176,19 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14527,15 +14198,20 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -14722,9 +14398,9 @@ } }, "node_modules/sweetalert2": { - "version": "11.17.2", - "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.17.2.tgz", - "integrity": "sha512-HKxDr1IyV3Lxr3W6sb61qm/p2epFIEdr5EKwteRFHnIg6f8nHFl2kX++DBVz16Mac+fFiU3hMpjq1L6yE2Ge5w==", + "version": "11.22.4", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.22.4.tgz", + "integrity": "sha512-JwcRODfozxiKmspFp+xctZ2izAmLAKbRPcoLMEW7LdugN/YmNrX1LT7hdBW87qsgupEO1ukBBuB17KzKFKW0tg==", "license": "MIT", "funding": { "type": "individual", @@ -14735,7 +14411,8 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.1", @@ -14871,12 +14548,72 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, + "node_modules/to-buffer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", + "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/to-buffer/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14895,39 +14632,29 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, "node_modules/tough-cookie": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", - "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, + "license": "MIT", "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsconfig-paths": { @@ -15017,30 +14744,31 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -15050,17 +14778,19 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, + "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" @@ -15070,17 +14800,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-proto": "^1.0.3", "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" }, "engines": { "node": ">= 0.4" @@ -15111,15 +14842,19 @@ } }, "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", + "call-bound": "^1.0.3", "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15166,9 +14901,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "funding": [ { "type": "opencollective", @@ -15183,9 +14918,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -15219,16 +14955,6 @@ "node": ">= 0.4" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/url/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", @@ -15320,25 +15046,17 @@ "resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.11.7.tgz", "integrity": "sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==" }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "dev": true, - "dependencies": { - "browser-process-hrtime": "^1.0.0" - } - }, "node_modules/w3c-xmlserializer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", - "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/walker": { @@ -15363,6 +15081,12 @@ "node": ">=10.13.0" } }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, "node_modules/web3": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/web3/-/web3-4.12.1.tgz", @@ -15834,19 +15558,21 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.99.9", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", + "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -15863,7 +15589,7 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", @@ -15960,27 +15686,16 @@ } }, "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, + "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" }, "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, "node_modules/whatwg-fetch": { @@ -15989,25 +15704,27 @@ "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==" }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, + "license": "MIT", "dependencies": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/which": { @@ -16026,16 +15743,74 @@ } }, "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, + "license": "MIT", "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16047,14 +15822,17 @@ "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" }, "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, "engines": { @@ -16070,15 +15848,6 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, - "node_modules/word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -16188,19 +15957,21 @@ } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/xss": { "version": "1.0.15", @@ -16311,14 +16082,16 @@ "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==" }, "@amplitude/analytics-browser": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.12.2.tgz", - "integrity": "sha512-Z41NKTbda144yo3/lLGENlVQjrIV/4J8TZyoPlysHCAXQdGh78cv1j29RKy/zqc8nu6ieY1qoH/aDRdPC1UvEw==", + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.21.1.tgz", + "integrity": "sha512-3Snmz4OgtSFWnFcqIC5QCFuUSIaVIK2iX0Wl1KIV+DzDhRwc+JIxuJCiwgWkvTPiO1N0Vfi9CO/bdockHqEMzg==", "requires": { - "@amplitude/analytics-core": "^2.6.1", + "@amplitude/analytics-core": "^2.19.0", "@amplitude/analytics-remote-config": "^0.4.0", - "@amplitude/plugin-autocapture-browser": "^1.1.2", - "@amplitude/plugin-page-view-tracking-browser": "^2.3.12", + "@amplitude/plugin-autocapture-browser": "^1.8.1", + "@amplitude/plugin-network-capture-browser": "^1.4.2", + "@amplitude/plugin-page-view-tracking-browser": "^2.3.37", + "@amplitude/plugin-web-vitals-browser": "^0.1.0-beta.12", "tslib": "^2.4.1" }, "dependencies": { @@ -16330,13 +16103,13 @@ } }, "@amplitude/analytics-client-common": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.3.11.tgz", - "integrity": "sha512-1Q1obcN14R7lpK6+UfqFdhLIhmwWrxLgHoJhEV/FwDPq1YvwhENtzVGmNuQtUUWOPIuavltmZ6w1zdL18YIahQ==", + "version": "2.3.31", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.3.31.tgz", + "integrity": "sha512-YkhKM78htSVNpRd+ul6MgDekaKseRCnYVLDyBQ0u3vtIt6m5kVtbBMffcrZz7DbOHIsyrUxA8u/RfLo+7xkouA==", "requires": { "@amplitude/analytics-connector": "^1.4.8", - "@amplitude/analytics-core": "^2.6.1", - "@amplitude/analytics-types": "^2.9.1", + "@amplitude/analytics-core": "^2.19.0", + "@amplitude/analytics-types": "^2.9.2", "tslib": "^2.4.1" }, "dependencies": { @@ -16348,18 +16121,16 @@ } }, "@amplitude/analytics-connector": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.1.tgz", - "integrity": "sha512-QAGeOfBQc3tamwcECu6YqAPD4mFI1TLBoWi+n0iViYWUZma2FeDLPMihwIquxI8CVvqpn4gswFZsIPRit3q9tQ==", - "requires": { - "@amplitude/experiment-core": "^0.10.0" - } + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", + "integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==" }, "@amplitude/analytics-core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.6.1.tgz", - "integrity": "sha512-PaTs1T02j8/zGNsLuHsTrMh1kFjxtNxVaaUgGuqqM1dl2Jf5sPFQdwu8O/mqrgmV4vPSStvf7odSUpPNN1tFnQ==", + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.19.0.tgz", + "integrity": "sha512-G1RwMOjkEV1Ccx7vU3CsPFt0w5yNFlVFNpCKaWO7zVbsqA9PRljPXj3fKXPrto6s2vKTXmuGG5NuWM/iSVfBwA==", "requires": { + "@amplitude/analytics-connector": "^1.6.4", "tslib": "^2.4.1" }, "dependencies": { @@ -16375,9 +16146,75 @@ "resolved": "https://registry.npmjs.org/@amplitude/analytics-remote-config/-/analytics-remote-config-0.4.1.tgz", "integrity": "sha512-BYl6kQ9qjztrCACsugpxO+foLaQIC0aSEzoXEAb/gwOzInmqkyyI+Ub+aWTBih4xgB/lhWlOcidWHAmNiTJTNw==", "requires": { - "@amplitude/analytics-client-common": ">=1 <3", - "@amplitude/analytics-core": ">=1 <3", - "@amplitude/analytics-types": ">=1 <3", + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-core": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", + "tslib": "^2.4.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@amplitude/analytics-types": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.9.2.tgz", + "integrity": "sha512-juhTz396dDP/jLJYP9zDOEAZBtJM0JVvP8G10p1OxUDBVwVIprpQL598F9GRQwVFyqV4CEhDmNyAY0HqqU5bhA==" + }, + "@amplitude/plugin-autocapture-browser": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.8.1.tgz", + "integrity": "sha512-Wg5Ogi2rW9DM40ZI7iTZuGxm7TXUfkfAZ5yTUrRDy5MIXr+zdv89IpXhj4fsKW9ZO3xc7OFkFklXCWq+QJ580A==", + "requires": { + "@amplitude/analytics-core": "^2.19.0", + "@amplitude/analytics-remote-config": "^0.6.3", + "rxjs": "^7.8.1", + "tslib": "^2.4.1" + }, + "dependencies": { + "@amplitude/analytics-remote-config": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-remote-config/-/analytics-remote-config-0.6.3.tgz", + "integrity": "sha512-icE0ogCzdHAtQi9jiOFQUmKrvWQc5YEO6bLZUfQXCT/yTTNXppWnT1zHMKzXa3SMDosfrLwU/X8sro1PTI+jZQ==", + "requires": { + "@amplitude/analytics-core": ">=1 <2", + "@amplitude/analytics-types": ">=1 <2", + "tslib": "^2.4.1" + }, + "dependencies": { + "@amplitude/analytics-core": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-1.2.8.tgz", + "integrity": "sha512-Krxpr5uvS3HmmjvpYqPfbMbs2kcZZu09L+6KwQnPiofWRzoXWIM217fRfy6aSD/QrAoPGbZjvtVitw9cB7Cx+A==", + "requires": { + "@amplitude/analytics-types": "^1.4.0", + "tslib": "^2.4.1" + } + } + } + }, + "@amplitude/analytics-types": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-1.4.0.tgz", + "integrity": "sha512-RiMPHBqdrJ8ktTqG+Wzj2htnN/PCG9jGZG0SXtTFnWwVvcAJYbYm55/nrP1TTyrx1OlLhvF2VG3lVUP/xGAU8w==" + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "@amplitude/plugin-network-capture-browser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.4.2.tgz", + "integrity": "sha512-Is1uBYfqEeS3bm0mVyqksW1AOTr5B9JWQOinT9WcF4R9c/56CoIXBGcLnRG+4p5p6xAQcmP/tggjc8xsDeI52g==", + "requires": { + "@amplitude/analytics-core": "^2.19.0", + "rxjs": "^7.8.1", "tslib": "^2.4.1" }, "dependencies": { @@ -16388,26 +16225,13 @@ } } }, - "@amplitude/analytics-types": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.9.1.tgz", - "integrity": "sha512-Hw0pgUQCDV6AgvZD8ymBF3mzyBnPrIvXiPxfTNTg/bAkg1h+GVNdJIL6VAXd53kYuO7Lx/vjdhZS54i+RYtgMA==" - }, - "@amplitude/experiment-core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.10.0.tgz", - "integrity": "sha512-FBfM6a4aHp+7OYLYiHO3kni3vCThInT6o4ucuNIB+EIvQ141gQDSjMPOET3ik82fMuJPQITex9sdpZ6cO30ALw==", - "requires": { - "js-base64": "^3.7.5" - } - }, - "@amplitude/plugin-autocapture-browser": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.1.2.tgz", - "integrity": "sha512-/P5hX3FtcMgYlNn38bVbqElETN+BSwLvfopwXRoBTIjfaBS340QytbvfXlo18qfeq8mHU6bi4l8fVPZdessBZQ==", + "@amplitude/plugin-page-view-tracking-browser": { + "version": "2.3.37", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.3.37.tgz", + "integrity": "sha512-Jikvke4Af8qQMwWcF6LHBF/HYUaAaWxZPcJoFYS1HK0/Gzxsa/T5SIJC35EDEERsHMKRryrNUdchlBY2xFKULw==", "requires": { - "@amplitude/analytics-core": "^2.6.1", - "rxjs": "^7.8.1", + "@amplitude/analytics-client-common": "^2.3.31", + "@amplitude/analytics-types": "^2.9.2", "tslib": "^2.4.1" }, "dependencies": { @@ -16418,14 +16242,15 @@ } } }, - "@amplitude/plugin-page-view-tracking-browser": { - "version": "2.3.12", - "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.3.12.tgz", - "integrity": "sha512-vsfe4UqjrPvbJ9pS3X0x3bN8FL9Phlt/2BsC6SaHsZCqiScvJ+Hn+r5D36/wKbZp1XNrmc2YOjrOuWwzzdUsNA==", + "@amplitude/plugin-web-vitals-browser": { + "version": "0.1.0-frustrationanalytics.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-0.1.0-frustrationanalytics.0.tgz", + "integrity": "sha512-xv4sje6/D8r+SgNFTA22FJ5PhtdhN+VSydvs63Frll+qWlyQwaZ1IgDbPyqjzryEkldHRPD7GUaQual+geoIYg==", "requires": { - "@amplitude/analytics-client-common": "^2.3.11", - "@amplitude/analytics-types": "^2.9.1", - "tslib": "^2.4.1" + "@amplitude/analytics-core": "^2.14.0-frustrationanalytics.0", + "rxjs": "^7.8.1", + "tslib": "^2.4.1", + "web-vitals": "^5.0.1" }, "dependencies": { "tslib": { @@ -16455,36 +16280,57 @@ } } }, + "@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "requires": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + }, + "dependencies": { + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + } + } + }, "@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "requires": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" } }, "@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==" + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==" }, "@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "requires": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -16500,32 +16346,32 @@ } }, "@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", - "requires": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "requires": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "requires": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" } }, "@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "requires": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -16547,28 +16393,28 @@ } }, "@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" } }, "@babel/helper-create-regexp-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", - "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "regexpu-core": "^6.1.1", + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", "semver": "^6.3.1" } }, @@ -16585,171 +16431,176 @@ "semver": "^6.1.2" } }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==" + }, "@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "dev": true, "requires": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" } }, "@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "requires": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" } }, "@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "requires": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" } }, "@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dev": true, "requires": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" } }, "@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==" + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==" }, "@babel/helper-remap-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", - "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-wrap-function": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" } }, "@babel/helper-replace-supers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", - "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "dev": true, "requires": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" } }, "@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "requires": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" } }, "@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" }, "@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==" }, "@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==" + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" }, "@babel/helper-wrap-function": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", - "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", "dev": true, "requires": { - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" } }, "@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "requires": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" } }, "@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "requires": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.28.0" } }, "@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", - "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" } }, "@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", - "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", - "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" } }, "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", - "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" } }, "@babel/plugin-proposal-private-property-in-object": { @@ -16787,21 +16638,21 @@ } }, "@babel/plugin-syntax-import-assertions": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", - "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-syntax-import-meta": { @@ -16914,413 +16765,417 @@ } }, "@babel/plugin-transform-arrow-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", - "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-async-generator-functions": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", - "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.26.8" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" } }, "@babel/plugin-transform-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", - "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" } }, "@babel/plugin-transform-block-scoped-functions": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", - "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-block-scoping": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", - "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", + "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-class-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", - "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-class-static-block": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", - "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-classes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", - "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", + "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/traverse": "^7.25.9", - "globals": "^11.1.0" - }, - "dependencies": { - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - } + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.0" } }, "@babel/plugin-transform-computed-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", - "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/template": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" } }, "@babel/plugin-transform-destructuring": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", - "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" } }, "@babel/plugin-transform-dotall-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", - "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-duplicate-keys": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", - "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-dynamic-import": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", - "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" } }, "@babel/plugin-transform-exponentiation-operator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", - "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-export-namespace-from": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", - "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-for-of": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", - "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" } }, "@babel/plugin-transform-function-name": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", - "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "dev": true, "requires": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" } }, "@babel/plugin-transform-json-strings": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", - "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", - "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", - "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-member-expression-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", - "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-modules-amd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", - "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-modules-commonjs": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", - "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-modules-systemjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", - "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" } }, "@babel/plugin-transform-modules-umd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", - "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-new-target": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", - "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.26.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", - "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-numeric-separator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", - "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-object-rest-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", - "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", + "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", "dev": true, "requires": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9" + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.0" } }, "@babel/plugin-transform-object-super": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", - "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" } }, "@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", - "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" } }, "@babel/plugin-transform-parameters": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", - "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-private-methods": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", - "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-private-property-in-object": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", - "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-property-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", - "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-regenerator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", - "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.1.tgz", + "integrity": "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "regenerator-transform": "^0.15.2" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-regexp-modifiers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", - "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-reserved-words": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", - "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-runtime": { @@ -17348,198 +17203,199 @@ } }, "@babel/plugin-transform-shorthand-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", - "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", - "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" } }, "@babel/plugin-transform-sticky-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", - "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-template-literals": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", - "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-typeof-symbol": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz", - "integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-unicode-escapes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", - "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-unicode-property-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", - "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-unicode-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", - "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/plugin-transform-unicode-sets-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", - "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" } }, "@babel/preset-env": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", - "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", + "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.26.8", - "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.26.5", - "@babel/plugin-transform-block-scoping": "^7.25.9", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.26.0", - "@babel/plugin-transform-classes": "^7.25.9", - "@babel/plugin-transform-computed-properties": "^7.25.9", - "@babel/plugin-transform-destructuring": "^7.25.9", - "@babel/plugin-transform-dotall-regex": "^7.25.9", - "@babel/plugin-transform-duplicate-keys": "^7.25.9", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.26.3", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.26.9", - "@babel/plugin-transform-function-name": "^7.25.9", - "@babel/plugin-transform-json-strings": "^7.25.9", - "@babel/plugin-transform-literals": "^7.25.9", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", - "@babel/plugin-transform-member-expression-literals": "^7.25.9", - "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.26.3", - "@babel/plugin-transform-modules-systemjs": "^7.25.9", - "@babel/plugin-transform-modules-umd": "^7.25.9", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", - "@babel/plugin-transform-numeric-separator": "^7.25.9", - "@babel/plugin-transform-object-rest-spread": "^7.25.9", - "@babel/plugin-transform-object-super": "^7.25.9", - "@babel/plugin-transform-optional-catch-binding": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9", - "@babel/plugin-transform-private-methods": "^7.25.9", - "@babel/plugin-transform-private-property-in-object": "^7.25.9", - "@babel/plugin-transform-property-literals": "^7.25.9", - "@babel/plugin-transform-regenerator": "^7.25.9", - "@babel/plugin-transform-regexp-modifiers": "^7.26.0", - "@babel/plugin-transform-reserved-words": "^7.25.9", - "@babel/plugin-transform-shorthand-properties": "^7.25.9", - "@babel/plugin-transform-spread": "^7.25.9", - "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.26.8", - "@babel/plugin-transform-typeof-symbol": "^7.26.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.9", - "@babel/plugin-transform-unicode-property-regex": "^7.25.9", - "@babel/plugin-transform-unicode-regex": "^7.25.9", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.0", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.0", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "dependencies": { "@babel/helper-define-polyfill-provider": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", - "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "dev": true, "requires": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" } }, "babel-plugin-polyfill-corejs2": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", - "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", "dev": true, "requires": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.1", + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" } }, "babel-plugin-polyfill-regenerator": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.1.tgz", - "integrity": "sha512-JfTApdE++cgcTWjsiCQlLyFBMbTUft9ja17saCc93lgV33h4tuCVj7tlvu//qpLwaG+3yEz7/KhahGrUMkVq9g==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", "dev": true, "requires": { - "@babel/helper-define-polyfill-provider": "^0.6.1" + "@babel/helper-define-polyfill-provider": "^0.6.5" } } } @@ -17556,51 +17412,44 @@ } }, "@babel/runtime": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.0.tgz", - "integrity": "sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "requires": { - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.14.0" } }, "@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "requires": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" } }, "@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", - "requires": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "dependencies": { - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" - } + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" } }, "@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "requires": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" } }, "@bcoe/v8-coverage": { @@ -17609,6 +17458,42 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true + }, + "@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "requires": {} + }, + "@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "dev": true, + "requires": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + } + }, + "@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "requires": {} + }, + "@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true + }, "@discoveryjs/json-ext": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", @@ -17654,9 +17539,9 @@ "dev": true }, "@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "requires": { "@eslint/object-schema": "^2.1.6", @@ -17665,15 +17550,15 @@ } }, "@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true }, "@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "requires": { "@types/json-schema": "^7.0.15" @@ -17720,9 +17605,9 @@ } }, "@eslint/js": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", - "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", "dev": true }, "@eslint/object-schema": { @@ -17732,12 +17617,12 @@ "dev": true }, "@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", "dev": true, "requires": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, @@ -17826,57 +17711,6 @@ "jest-message-util": "^29.7.0", "jest-util": "^29.7.0", "slash": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "@jest/core": { @@ -17913,71 +17747,188 @@ "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" + } + }, + "@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + } + }, + "@jest/environment-jsdom-abstract": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.0.5.tgz", + "integrity": "sha512-gpWwiVxZunkoglP8DCnT3As9x5O8H6gveAOpvaJd2ATAoSh7ZSSCWbr9LQtUMvr8WD3VjG9YnDhsmkCK5WN1rQ==", + "dev": true, + "requires": { + "@jest/environment": "30.0.5", + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", "dev": true, "requires": { - "color-convert": "^2.0.1" + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" } }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", "dev": true, "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" } }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "requires": { - "color-name": "~1.1.4" + "@sinclair/typebox": "^0.34.0" } }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "dev": true, + "requires": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + } + }, + "@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", "dev": true }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true + }, + "jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", "dev": true, "requires": { - "has-flag": "^4.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + } + }, + "jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "dev": true, + "requires": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + } + }, + "jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "dev": true, + "requires": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + } + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + }, + "pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "requires": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" } + }, + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true } } }, - "@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "requires": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - } - }, "@jest/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", @@ -18023,6 +17974,24 @@ "jest-mock": "^29.7.0" } }, + "@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "requires": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "dependencies": { + "jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true + } + } + }, "@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -18054,41 +18023,7 @@ "strip-ansi": "^6.0.0", "v8-to-istanbul": "^9.0.1" }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, + "dependencies": { "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -18117,15 +18052,6 @@ } } } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -18196,60 +18122,11 @@ "write-file-atomic": "^4.0.2" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -18265,66 +18142,14 @@ "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "requires": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, @@ -18349,14 +18174,14 @@ } }, "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" }, "@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "requires": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -18676,9 +18501,9 @@ } }, "@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true }, "@types/istanbul-lib-report": { @@ -18691,18 +18516,18 @@ } }, "@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "requires": { "@types/istanbul-lib-report": "*" } }, "@types/jsdom": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz", - "integrity": "sha512-YfAchFs0yM1QPDrLm2VHe+WHGtqms3NXnXAMolrgrVP6fgBHHXy1ozAbo/dFtPNtZC/m66bPiCTWYmqp1F14gA==", + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "requires": { "@types/node": "*", @@ -18744,15 +18569,15 @@ } }, "@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, "@types/tough-cookie": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", - "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, "@types/ws": { @@ -18764,9 +18589,9 @@ } }, "@types/yargs": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.11.tgz", - "integrity": "sha512-aB4y9UDUXTSMxmM4MH+YnuR0g5Cph3FLQBoWoMB21DSvFVAxRVEHEMx3TLh+zUZYMCQtKiqazz0Q4Rre31f/OA==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -19191,12 +19016,6 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, - "abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "dev": true - }, "abitype": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.7.1.tgz", @@ -19212,29 +19031,11 @@ } }, "acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true }, - "acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "dev": true, - "requires": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - }, - "dependencies": { - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true - } - } - }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -19242,25 +19043,16 @@ "dev": true, "requires": {} }, - "acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true - }, "aes-js": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" }, "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "requires": { - "debug": "4" - } + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true }, "ajv": { "version": "6.12.6", @@ -19352,81 +19144,83 @@ } }, "array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "requires": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" } }, "array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" } }, "array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" } }, "array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" } }, "array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" } }, "arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "requires": { "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" } }, "asn1": { @@ -19492,6 +19286,12 @@ "async": "^2.4.0" } }, + "async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true + }, "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -19562,57 +19362,6 @@ "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "babel-loader": { @@ -19711,26 +19460,26 @@ } }, "babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "requires": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" }, "dependencies": { "@babel/helper-define-polyfill-provider": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", - "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "dev": true, "requires": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" } } } @@ -19804,9 +19553,9 @@ "dev": true }, "base-x": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.9.tgz", - "integrity": "sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", "requires": { "safe-buffer": "^5.0.1" } @@ -19836,9 +19585,9 @@ "dev": true }, "bignumber.js": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", - "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" }, "blakejs": { "version": "1.1.1", @@ -19886,12 +19635,6 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, - "browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "dev": true - }, "browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", @@ -20005,14 +19748,14 @@ } }, "browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "requires": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" } }, "bs58": { @@ -20101,15 +19844,32 @@ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=" }, "call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "requires": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" } }, "callsites": { @@ -20141,9 +19901,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001707", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", - "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==" + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==" }, "caseless": { "version": "0.12.0", @@ -20163,6 +19923,57 @@ "type-detect": "^4.0.5" } }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -20170,9 +19981,9 @@ "dev": true }, "chart.js": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", - "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "requires": { "@kurkle/color": "^0.3.0" } @@ -20218,12 +20029,19 @@ "dev": true }, "cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", + "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } } }, "cjs-module-lexer": { @@ -20362,16 +20180,16 @@ } }, "core-js": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", - "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==" + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.43.0.tgz", + "integrity": "sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==" }, "core-js-compat": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", - "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", + "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", "requires": { - "browserslist": "^4.24.4" + "browserslist": "^4.25.1" } }, "core-util-is": { @@ -20467,57 +20285,6 @@ "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "cross-fetch": { @@ -20789,27 +20556,14 @@ } } }, - "cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true - }, "cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "requires": { - "cssom": "~0.3.6" - }, - "dependencies": { - "cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - } + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" } }, "dashdash": { @@ -20821,55 +20575,54 @@ } }, "data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "requires": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" } }, "data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "requires": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" } }, "data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "is-data-view": "^1.0.2" } }, "data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "requires": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "decamelize": { @@ -20878,9 +20631,9 @@ "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, "decimal.js": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.0.tgz", - "integrity": "sha512-Nv6ENEzyPQ6AItkGwLE2PGKinZZ9g59vSh2BeH6NqPu0OTKZ5ruJsVqh/orbAnqXc9pBbgXAIrc2EyaCj8NpGg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, "decode-uri-component": { @@ -21030,15 +20783,6 @@ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "dev": true }, - "domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "dev": true, - "requires": { - "webidl-conversions": "^7.0.0" - } - }, "domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -21064,6 +20808,16 @@ "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.3.tgz", "integrity": "sha512-Azk8kD/2/nJIuVPK+zQ9sjKMRIpRvNyqn9XwbBHNq+iNuSccbJS6hwm1Woy0pMST0erSo0u4j+KJaodndDk4vA==" }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -21074,9 +20828,9 @@ } }, "electron-to-chromium": { - "version": "1.5.109", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.109.tgz", - "integrity": "sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==" + "version": "1.5.194", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", + "integrity": "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==" }, "elliptic": { "version": "6.6.1", @@ -21125,18 +20879,6 @@ "peer": true, "requires": { "iconv-lite": "^0.6.2" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "peer": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } } }, "enhanced-resolve": { @@ -21185,66 +20927,71 @@ } }, "es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", "dev": true, "requires": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", + "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" } }, "es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "requires": { - "get-intrinsic": "^1.2.4" - } + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" }, "es-errors": { "version": "1.3.0", @@ -21258,43 +21005,43 @@ "dev": true }, "es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "requires": { "es-errors": "^1.3.0" } }, "es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "requires": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" } }, "es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "requires": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" } }, "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" } }, "escalade": { @@ -21302,80 +21049,20 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, - "escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", - "dev": true, - "requires": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" - }, - "dependencies": { - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } - } - } - }, "eslint": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", - "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.23.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -21386,9 +21073,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -21405,40 +21092,6 @@ "optionator": "^0.9.3" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -21446,9 +21099,9 @@ "dev": true }, "eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "requires": { "esrecurse": "^4.3.0", @@ -21456,9 +21109,9 @@ } }, "eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true }, "estraverse": { @@ -21477,12 +21130,6 @@ "path-exists": "^4.0.0" } }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -21506,15 +21153,6 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -21547,9 +21185,9 @@ } }, "eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "requires": { "debug": "^3.2.7" @@ -21567,29 +21205,29 @@ } }, "eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "requires": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "dependencies": { @@ -21682,20 +21320,20 @@ "dev": true }, "espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "requires": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "dependencies": { "eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true } } @@ -22346,11 +21984,11 @@ "dev": true }, "for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "requires": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" } }, "forever-agent": { @@ -22393,15 +22031,17 @@ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" } }, "functional-red-black-tree": { @@ -22431,15 +22071,20 @@ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==" }, "get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" } }, "get-package-type": { @@ -22448,6 +22093,15 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, "get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -22455,14 +22109,14 @@ "dev": true }, "get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "requires": { - "call-bind": "^1.0.5", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" + "get-intrinsic": "^1.2.6" } }, "getpass": { @@ -22512,18 +22166,19 @@ } }, "globals": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", - "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true }, "globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "requires": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" } }, "good-listener": { @@ -22535,12 +22190,9 @@ } }, "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "requires": { - "get-intrinsic": "^1.1.3" - } + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" }, "graceful-fs": { "version": "4.2.11", @@ -22563,9 +22215,9 @@ } }, "has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true }, "has-flag": { @@ -22582,14 +22234,18 @@ } }, "has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.0" + } }, "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, "has-tostringtag": { "version": "1.0.2", @@ -22657,12 +22313,12 @@ } }, "html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "requires": { - "whatwg-encoding": "^2.0.0" + "whatwg-encoding": "^3.1.1" } }, "html-escaper": { @@ -22671,6 +22327,16 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -22687,12 +22353,12 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" }, "https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "requires": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" } }, @@ -22707,6 +22373,15 @@ "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", "integrity": "sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=" }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "devOptional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, "icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -22789,14 +22464,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "requires": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" } }, "interpret": { @@ -22815,13 +22490,14 @@ } }, "is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" } }, "is-arrayish": { @@ -22830,23 +22506,36 @@ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, + "is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "requires": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + } + }, "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "requires": { - "has-bigints": "^1.0.1" + "has-bigints": "^1.0.2" } }, "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" } }, "is-callable": { @@ -22855,29 +22544,32 @@ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" }, "is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "requires": { "hasown": "^2.0.2" } }, "is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "requires": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "requires": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" } }, "is-extglob": { @@ -22886,6 +22578,15 @@ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "dev": true }, + "is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "requires": { + "call-bound": "^1.0.3" + } + }, "is-fn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fn/-/is-fn-1.0.0.tgz", @@ -22930,6 +22631,12 @@ "resolved": "https://registry.npmjs.org/is-hex-prefixed/-/is-hex-prefixed-1.0.0.tgz", "integrity": "sha1-fY035q135dEnFIkTxXPggtd39VQ=" }, + "is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true + }, "is-nan": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", @@ -22952,12 +22659,13 @@ "dev": true }, "is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "requires": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" } }, "is-plain-object": { @@ -22976,22 +22684,30 @@ "dev": true }, "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" } }, + "is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true + }, "is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "requires": { - "call-bind": "^1.0.7" + "call-bound": "^1.0.3" } }, "is-stream": { @@ -23001,29 +22717,32 @@ "dev": true }, "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "requires": { - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" } }, "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "requires": { - "has-symbols": "^1.0.2" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" } }, "is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "requires": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" } }, "is-typedarray": { @@ -23031,13 +22750,29 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true + }, "is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "requires": { + "call-bound": "^1.0.3" + } + }, + "is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "requires": { - "call-bind": "^1.0.2" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" } }, "isarray": { @@ -23196,57 +22931,6 @@ "pure-rand": "^6.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-cli": { @@ -23266,57 +22950,6 @@ "jest-util": "^29.7.0", "jest-validate": "^29.7.0", "yargs": "^17.3.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-config": { @@ -23347,57 +22980,6 @@ "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-diff": { @@ -23406,61 +22988,10 @@ "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" } }, "jest-docblock": { @@ -23483,75 +23014,174 @@ "jest-get-type": "^29.6.3", "jest-util": "^29.7.0", "pretty-format": "^29.7.0" + } + }, + "jest-environment-jsdom": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.0.5.tgz", + "integrity": "sha512-BmnDEoAH+jEjkPrvE9DTKS2r3jYSJWlN/r46h0/DBUxKrkgt2jAZ5Nj4wXLAcV1KWkRpcFqA5zri9SWzJZ1cCg==", + "dev": true, + "requires": { + "@jest/environment": "30.0.5", + "@jest/environment-jsdom-abstract": "30.0.5", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jsdom": "^26.1.0" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "@jest/environment": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.5.tgz", + "integrity": "sha512-aRX7WoaWx1oaOkDQvCWImVQ8XNtdv5sEWgk4gxR6NXb7WBUnL5sRak4WRzIQRZ1VTWPvV4VI4mgGjNL9TeKMYA==", "dev": true, "requires": { - "color-convert": "^2.0.1" + "@jest/fake-timers": "30.0.5", + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-mock": "30.0.5" } }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "@jest/fake-timers": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.5.tgz", + "integrity": "sha512-ZO5DHfNV+kgEAeP3gK3XlpJLL4U3Sz6ebl/n68Uwt64qFFs5bv4bfEEjyRGK5uM0C90ewooNgFuKMdkbEoMEXw==", "dev": true, "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "@jest/types": "30.0.5", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.5", + "jest-mock": "30.0.5", + "jest-util": "30.0.5" } }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "requires": { - "color-name": "~1.1.4" + "@sinclair/typebox": "^0.34.0" } }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "@jest/types": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", + "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "dev": true, + "requires": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + } + }, + "@sinclair/typebox": { + "version": "0.34.38", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", + "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", "dev": true }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true + }, + "jest-message-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.5.tgz", + "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", "dev": true, "requires": { - "has-flag": "^4.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.5", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.5", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + } + }, + "jest-mock": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", + "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "dev": true, + "requires": { + "@jest/types": "30.0.5", + "@types/node": "*", + "jest-util": "30.0.5" + } + }, + "jest-util": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", + "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "dev": true, + "requires": { + "@jest/types": "30.0.5", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + } + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + }, + "pretty-format": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", + "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "dev": true, + "requires": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" } + }, + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true } } }, - "jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", - "dev": true, - "requires": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" - } - }, "jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -23641,57 +23271,6 @@ "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-message-util": { @@ -23709,57 +23288,6 @@ "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-mock": { @@ -23801,57 +23329,6 @@ "resolve": "^1.20.0", "resolve.exports": "^2.0.0", "slash": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-resolve-dependencies": { @@ -23889,44 +23366,10 @@ "jest-util": "^29.7.0", "jest-watcher": "^29.7.0", "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "dependencies": { "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -23965,15 +23408,6 @@ "buffer-from": "^1.0.0", "source-map": "^0.6.0" } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -24005,57 +23439,6 @@ "jest-util": "^29.7.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-snapshot": { @@ -24086,46 +23469,6 @@ "semver": "^7.5.3" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, "semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -24134,15 +23477,6 @@ "requires": { "lru-cache": "^6.0.0" } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -24158,57 +23492,6 @@ "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-validate": { @@ -24225,60 +23508,11 @@ "pretty-format": "^29.7.0" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, "camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -24296,57 +23530,6 @@ "emittery": "^0.13.1", "jest-util": "^29.7.0", "string-length": "^4.0.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } } }, "jest-worker": { @@ -24393,11 +23576,6 @@ "resolved": "https://registry.npmjs.org/jquery-mousewheel/-/jquery-mousewheel-3.1.13.tgz", "integrity": "sha512-GXhSjfOPyDemM005YCEHvzrEALhKDIswtxSHSR2e4K/suHVJKJxxRCGz3skPjNxjJjQa9AVSGGlYjv1M3VLIPg==" }, - "js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==" - }, "js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -24429,68 +23607,31 @@ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { - "version": "20.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.0.tgz", - "integrity": "sha512-x4a6CKCgx00uCmP+QakBDFXwjAJ69IkkIWHmtmjd3wvXPcdOS44hfX2vqkOQrVrq8l9DhNNADZRXaCEWvgXtVA==", - "dev": true, - "requires": { - "abab": "^2.0.6", - "acorn": "^8.7.1", - "acorn-globals": "^6.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.3.1", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "requires": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "^7.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^3.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.8.0", - "xml-name-validator": "^4.0.0" - }, - "dependencies": { - "@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true - }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "requires": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - } - } + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" } }, "jsesc": { @@ -24917,9 +24058,9 @@ "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=" }, "luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==" + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==" }, "make-dir": { "version": "4.0.0", @@ -24958,6 +24099,11 @@ "jquery-mousewheel": ">=3.0.6" } }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -25160,9 +24306,9 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" }, "mixpanel-browser": { - "version": "2.63.0", - "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.63.0.tgz", - "integrity": "sha512-h7M0J/LR/5YLWCVuvPaYuzwV7CgV9jkJz0m94uaTDPebWkhNQPEir63rf/ZpBZgntyvYjO1yMZp2pIpwQ1sBMQ==", + "version": "2.67.0", + "resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.67.0.tgz", + "integrity": "sha512-LudY4eRIkvjEpAlIAg10i2T2mbtiKZ4XlMGbTyF1kcAhEqMa9JhEEdEcjxYPwiKhuMVSBM3RVkNCZaNqcnE4ww==", "requires": { "rrweb": "2.0.0-alpha.18" } @@ -25173,9 +24319,9 @@ "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "nanoassert": { "version": "1.1.0", @@ -25183,9 +24329,9 @@ "integrity": "sha1-TzFS4JVA/eKMdvRLGbvNHVpCR40=" }, "nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" }, "nanomorph": { "version": "5.4.3", @@ -25293,9 +24439,9 @@ "integrity": "sha1-StCAk21EPCVhrtnyGX7//iX05QY=" }, "nwsapi": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.1.tgz", - "integrity": "sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg==", + "version": "2.2.21", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", + "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", "dev": true }, "oauth-sign": { @@ -25309,9 +24455,9 @@ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, "object-is": { "version": "1.1.5", @@ -25328,13 +24474,15 @@ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, "object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "requires": { - "call-bind": "^1.0.5", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, @@ -25362,12 +24510,13 @@ } }, "object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } @@ -25409,6 +24558,17 @@ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=" }, + "own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + } + }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -25499,12 +24659,20 @@ } }, "parse5": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.0.0.tgz", - "integrity": "sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "requires": { - "entities": "^4.3.0" + "entities": "^6.0.0" + }, + "dependencies": { + "entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true + } } }, "path-exists": { @@ -25544,15 +24712,51 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==" }, "pbkdf2": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.3.tgz", + "integrity": "sha512-wfRLBZ0feWRhCIkoMB6ete7czJcnNnqRpcoWQBLqatqXXmelSRqfdDK4F3u9T2s2cXas/hQJcryI/4lAL+XTlA==", "requires": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "create-hash": "~1.1.3", + "create-hmac": "^1.1.7", + "ripemd160": "=2.0.1", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.11", + "to-buffer": "^1.2.0" + }, + "dependencies": { + "create-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz", + "integrity": "sha512-snRpch/kwQhcdlnZKYanNF1m0RDlrCdSKQaH87w1FCFPVPNCQ/Il9QJKAX2jVBZddRdaHBMC+zXa9Gw9tmkNUA==", + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "sha.js": "^2.4.0" + } + }, + "hash-base": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz", + "integrity": "sha512-0TROgQ1/SxE6KmxWSvXHvRj90/Xo1JvZShofnYF+f6ZsGtR4eES7WfrQzPalmyagfKZCXpVnitiRebZulWsbiw==", + "requires": { + "inherits": "^2.0.1" + } + }, + "ripemd160": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz", + "integrity": "sha512-J7f4wutN8mdbV08MJnXibYpCOPHR+yzy+iQ/AsjMv2j8cLavQ8VGagDFUwwTAdF8FmRKVeNpbTTEwNHCW1g94w==", + "requires": { + "hash-base": "^2.0.0", + "inherits": "^2.0.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } } }, "performance-now": { @@ -25614,11 +24818,11 @@ "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==" }, "postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "requires": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } @@ -26072,9 +25276,9 @@ } }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "pure-rand": { "version": "6.0.3", @@ -26220,12 +25424,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -26304,6 +25502,22 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "requires": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -26320,28 +25534,21 @@ } }, "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, - "regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dev": true, - "requires": { - "@babel/runtime": "^7.8.4" - } + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "regexp.prototype.flags": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", - "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, @@ -26434,18 +25641,12 @@ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, "resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "requires": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } @@ -26519,6 +25720,12 @@ "rrweb-snapshot": "^2.0.0-alpha.18" } }, + "rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, "rrweb-snapshot": { "version": "2.0.0-alpha.18", "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz", @@ -26548,14 +25755,15 @@ } }, "safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "requires": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "dependencies": { @@ -26580,15 +25788,33 @@ "events": "^3.0.0" } }, + "safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, "safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "requires": { - "call-bind": "^1.0.6", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "is-regex": "^1.1.4" + "is-regex": "^1.2.1" } }, "safer-buffer": { @@ -26597,9 +25823,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sass": { - "version": "1.86.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.86.1.tgz", - "integrity": "sha512-Yaok4XELL1L9Im/ZUClKu//D2OP1rOljKj0Gf34a+GzLbMveOzL7CfqYo+JUa5Xt1nhTCW+OcKp/FtR7/iqj1w==", + "version": "1.89.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", + "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "dev": true, "requires": { "@parcel/watcher": "^2.4.1", @@ -26636,9 +25862,9 @@ } }, "schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "requires": { "@types/json-schema": "^7.0.9", @@ -26762,18 +25988,37 @@ "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=" }, + "set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "requires": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + } + }, "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" }, "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } } }, "shallow-clone": { @@ -26806,14 +26051,47 @@ "dev": true }, "side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "requires": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" } }, "signal-exit": { @@ -26883,9 +26161,9 @@ } }, "stack-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", - "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, "requires": { "escape-string-regexp": "^2.0.0" @@ -26899,6 +26177,16 @@ } } }, + "stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + } + }, "stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -26956,24 +26244,28 @@ } }, "string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" } }, "string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } @@ -27087,9 +26379,9 @@ } }, "sweetalert2": { - "version": "11.17.2", - "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.17.2.tgz", - "integrity": "sha512-HKxDr1IyV3Lxr3W6sb61qm/p2epFIEdr5EKwteRFHnIg6f8nHFl2kX++DBVz16Mac+fFiU3hMpjq1L6yE2Ge5w==" + "version": "11.22.4", + "resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.22.4.tgz", + "integrity": "sha512-JwcRODfozxiKmspFp+xctZ2izAmLAKbRPcoLMEW7LdugN/YmNrX1LT7hdBW87qsgupEO1ukBBuB17KzKFKW0tg==" }, "symbol-tree": { "version": "3.2.4", @@ -27177,12 +26469,49 @@ } } }, + "tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "requires": { + "tldts-core": "^6.1.86" + } + }, + "tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, + "to-buffer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", + "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "requires": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -27198,32 +26527,21 @@ "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, "tough-cookie": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", - "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "requires": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "dependencies": { - "universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true - } + "tldts": "^6.1.32" } }, "tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "requires": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" } }, "tsconfig-paths": { @@ -27294,55 +26612,55 @@ "dev": true }, "typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "requires": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" } }, "typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "requires": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" } }, "typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "requires": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" } }, "typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "requires": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-proto": "^1.0.3", "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" } }, "typedarray-to-buffer": { @@ -27360,15 +26678,15 @@ "peer": true }, "unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "requires": { - "call-bind": "^1.0.2", + "call-bound": "^1.0.3", "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" } }, "unicode-canonical-property-names-ecmascript": { @@ -27400,12 +26718,12 @@ "dev": true }, "update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "requires": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" } }, "uri-js": { @@ -27445,16 +26763,6 @@ } } }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "utf-8-validate": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.7.tgz", @@ -27513,22 +26821,13 @@ "resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.11.7.tgz", "integrity": "sha512-0JuVqOmL5v1jmEAlG5EBDR3XquxY8DWFQbFMprOXgaBB0F7Q/X9xWdEaQc59D8xzwkdUgXEMSSknTpriq95igg==" }, - "w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "dev": true, - "requires": { - "browser-process-hrtime": "^1.0.0" - } - }, "w3c-xmlserializer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", - "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "requires": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" } }, "walker": { @@ -27550,6 +26849,11 @@ "graceful-fs": "^4.1.2" } }, + "web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==" + }, "web3": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/web3/-/web3-4.12.1.tgz", @@ -27951,13 +27255,14 @@ "dev": true }, "webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.99.9", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.9.tgz", + "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", @@ -27974,7 +27279,7 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", + "schema-utils": "^4.3.2", "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", @@ -28030,23 +27335,12 @@ } }, "whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "requires": { "iconv-lite": "0.6.3" - }, - "dependencies": { - "iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "requires": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - } - } } }, "whatwg-fetch": { @@ -28055,18 +27349,18 @@ "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==" }, "whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true }, "whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "requires": { - "tr46": "^3.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, @@ -28080,16 +27374,57 @@ } }, "which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "requires": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + } + }, + "which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "requires": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, + "which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" } }, "which-module": { @@ -28098,14 +27433,16 @@ "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" }, "which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "requires": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, @@ -28115,12 +27452,6 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, - "word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true - }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -28200,9 +27531,9 @@ } }, "xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true }, "xmlchars": { diff --git a/apps/block_scout_web/assets/package.json b/apps/block_scout_web/assets/package.json index e3782e8ab7cc..36942a27c53c 100644 --- a/apps/block_scout_web/assets/package.json +++ b/apps/block_scout_web/assets/package.json @@ -6,7 +6,7 @@ "private": true, "name": "blockscout", "author": "Blockscout", - "license": "GPL-3.0", + "license": "SEE LICENSE IN ../../../LICENSE", "engines": { "node": ">=16.0.0", "npm": ">=8.0.0" @@ -19,17 +19,17 @@ "eslint": "eslint js/**" }, "dependencies": { - "@amplitude/analytics-browser": "^2.12.2", + "@amplitude/analytics-browser": "^2.21.1", "@fortawesome/fontawesome-free": "^6.7.2", "@tarekraafat/autocomplete.js": "^10.2.9", "@walletconnect/web3-provider": "^1.8.0", "assert": "^2.1.0", - "bignumber.js": "^9.1.2", + "bignumber.js": "^9.3.1", "bootstrap": "^4.6.0", - "chart.js": "^4.4.8", + "chart.js": "^4.5.0", "chartjs-adapter-luxon": "^1.3.1", "clipboard": "^2.0.11", - "core-js": "^3.41.0", + "core-js": "^3.43.0", "crypto-browserify": "^3.12.1", "dropzone": "^5.9.3", "eth-net-props": "^1.0.41", @@ -56,9 +56,9 @@ "lodash.omit": "^4.5.0", "lodash.rangeright": "^4.2.0", "lodash.reduce": "^4.6.0", - "luxon": "^3.5.0", + "luxon": "^3.7.1", "malihu-custom-scrollbar-plugin": "3.1.5", - "mixpanel-browser": "^2.63.0", + "mixpanel-browser": "^2.67.0", "moment": "^2.30.1", "nanomorph": "^5.4.0", "numeral": "^2.0.6", @@ -73,7 +73,7 @@ "redux": "^5.0.1", "stream-browserify": "^3.0.0", "stream-http": "^3.1.1", - "sweetalert2": "^11.17.2", + "sweetalert2": "^11.22.4", "urijs": "^1.19.11", "url": "^0.11.4", "util": "^0.12.5", @@ -83,29 +83,29 @@ "xss": "^1.0.15" }, "devDependencies": { - "@babel/core": "^7.26.10", - "@babel/preset-env": "^7.26.9", + "@babel/core": "^7.28.0", + "@babel/preset-env": "^7.28.0", "autoprefixer": "^10.4.21", "babel-loader": "^10.0.0", "copy-webpack-plugin": "^13.0.0", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.2", - "eslint": "^9.23.0", + "eslint": "^9.30.1", "eslint-formatter-junit": "^8.40.0", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^7.2.1", "file-loader": "^6.2.0", - "globals": "^16.0.0", + "globals": "^16.3.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest-environment-jsdom": "^30.0.5", "mini-css-extract-plugin": "^2.9.2", - "postcss": "^8.5.1", + "postcss": "^8.5.6", "postcss-loader": "^8.1.1", - "sass": "^1.86.1", + "sass": "^1.89.2", "sass-loader": "^14.2.1", "style-loader": "^4.0.0", - "webpack": "^5.98.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" }, "jest": { diff --git a/apps/block_scout_web/benchmarks/block_scout_web/controllers/api/v2/block_controller_benchmark.exs b/apps/block_scout_web/benchmarks/block_scout_web/controllers/api/v2/block_controller_benchmark.exs new file mode 100644 index 000000000000..40a22190afeb --- /dev/null +++ b/apps/block_scout_web/benchmarks/block_scout_web/controllers/api/v2/block_controller_benchmark.exs @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.BlockControllerBenchmark do + @moduledoc """ + Benchmark for the Block API V2 controller. + Tests the performance of the /api/v2/blocks endpoint with various data volumes. + + To run: + ``` + cd apps/block_scout_web + mix run benchmarks/block_scout_web/controllers/api/v2/block_controller_benchmark.exs + ``` + """ + use BlockScoutWeb.BenchmarkCase + + alias Explorer.Repo + alias Explorer.Chain.Block + + @doc """ + Benchmark the first page of the /api/v2/blocks endpoint. It mainly tests the performance of `Explorer.Chain.Cache.Blocks`. + """ + def list_blocks_first_page do + benchmark_setup() + + Benchee.run( + %{ + "GET /api/v2/blocks first page (cached)" => fn %{conn: conn} -> + get(conn, "/api/v2/blocks") + end + }, + inputs: %{ + " 0 blocks 0 transactions per block" => %{block_count: 0, transaction_per_block: 0}, + "51 blocks 0 transactions per block" => %{block_count: 51, transaction_per_block: 0}, + "51 blocks 10 transactions per block" => %{block_count: 51, transaction_per_block: 10}, + "51 blocks 100 transactions per block" => %{block_count: 51, transaction_per_block: 100}, + "51 blocks 500 transactions per block" => %{block_count: 51, transaction_per_block: 500} + }, + before_scenario: fn %{block_count: block_count, transaction_per_block: transaction_per_block} = input -> + reset_db() + + Application.put_env(:explorer, Explorer.Chain.Cache.Blocks, ttl_check_interval: false, global_ttl: nil) + + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) + + 0 = Repo.aggregate(Block, :count, :hash) + + block_count + |> insert_list(:block) + |> Enum.each(fn block -> + transaction_per_block + |> insert_list(:transaction) + |> with_block(block) + end) + + Map.put(input, :conn, get_conn()) + end, + formatters: [Benchee.Formatters.Console], + load: @path, + save: [ + path: @path + ], + warmup: 1, + time: 10, + memory_time: 10 + ) + end +end + +BlockScoutWeb.API.V2.BlockControllerBenchmark.list_blocks_first_page() diff --git a/apps/block_scout_web/benchmarks/support/benchmark_case.ex b/apps/block_scout_web/benchmarks/support/benchmark_case.ex new file mode 100644 index 000000000000..f610cdeae705 --- /dev/null +++ b/apps/block_scout_web/benchmarks/support/benchmark_case.ex @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.BenchmarkCase do + @moduledoc """ + This module defines the benchmark case to be used by benchmarks. + + This module provides common setup and utilities for benchmarking, + similar to ConnCase but optimized for benchmarking needs, including: + + - Database sandbox management + - Connection building + - Factory imports + - Common helpers + + ## Example + + ```elixir + defmodule BlockScoutWeb.MyBenchmark do + use BlockScoutWeb.BenchmarkCase + + def run do + Benchee.run(...) + end + end + + # Run the benchmark + BlockScoutWeb.MyBenchmark.run() + ``` + """ + + defmacro __using__(_opts) do + caller_file = __CALLER__.file + path = caller_file |> String.replace(Path.extname(caller_file), ".benchee") + + quote do + import Explorer.Factory + import Phoenix.ConnTest + + @endpoint BlockScoutWeb.Endpoint + @path unquote(path) + + @doc """ + Resets the database for consistent benchmarks + """ + def reset_db do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo, ownership_timeout: :infinity) + end + + @doc """ + Gets a Phoenix connection for HTTP request benchmarks + """ + def get_conn do + Phoenix.ConnTest.build_conn() + end + + @doc """ + Setup common benchmark environment + """ + def benchmark_setup do + {:ok, _} = Application.ensure_all_started(:block_scout_web) + + :ok + end + end + end +end diff --git a/apps/block_scout_web/config/config.exs b/apps/block_scout_web/config/config.exs index 73d91877a0a1..d662fadf63a8 100644 --- a/apps/block_scout_web/config/config.exs +++ b/apps/block_scout_web/config/config.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file is responsible for configuring your application # and its dependencies with the aid of the Config module. # @@ -17,8 +18,9 @@ config :block_scout_web, # 604800 seconds, 1 week session_cookie_ttl: 60 * 60 * 24 * 7, invalid_session_key: "invalid_session", - api_v2_temp_token_key: "api_v2_temp_token", - http_adapter: HTTPoison + api_v2_temp_token_cookie_key: "api_v2_temp_token", + api_v2_temp_token_header_key: "api-v2-temp-token", + http_client: Explorer.HttpClient.Tesla config :block_scout_web, admin_panel_enabled: ConfigHelper.parse_bool_env_var("ADMIN_PANEL_ENABLED") @@ -48,33 +50,13 @@ config :block_scout_web, BlockScoutWeb.Chain.TransactionHistoryChartController, # days history_size: 30 -config :ex_cldr, - default_locale: "en", - default_backend: BlockScoutWeb.Cldr - config :logger, :block_scout_web, - # keep synced with `config/config.exs` - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, + metadata: ConfigHelper.logger_metadata(), metadata_filter: [application: :block_scout_web] -config :logger, :api, - # keep synced with `config/config.exs` - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, - metadata_filter: [application: :api] - -config :logger, :api_v2, - # keep synced with `config/config.exs` - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, - metadata_filter: [application: :api_v2] +config :ex_cldr, + default_locale: "en", + default_backend: BlockScoutWeb.Cldr config :prometheus, BlockScoutWeb.Prometheus.PublicExporter, path: "/public-metrics", @@ -82,27 +64,18 @@ config :prometheus, BlockScoutWeb.Prometheus.PublicExporter, registry: :public, auth: false -config :prometheus, BlockScoutWeb.Prometheus.PhoenixInstrumenter, - # override default for Phoenix 1.4 compatibility - # * `:transport_name` to `:transport` - # * remove `:vsn` - channel_join_labels: [:channel, :topic, :transport], - # override default for Phoenix 1.4 compatibility - # * `:transport_name` to `:transport` - # * remove `:vsn` - channel_receive_labels: [:channel, :topic, :transport, :event] - config :spandex_phoenix, tracer: BlockScoutWeb.Tracer config :block_scout_web, BlockScoutWeb.Routers.ApiRouter, writing_enabled: !ConfigHelper.parse_bool_env_var("API_V1_WRITE_METHODS_DISABLED"), reading_enabled: !ConfigHelper.parse_bool_env_var("API_V1_READ_METHODS_DISABLED") -config :block_scout_web, BlockScoutWeb.Routers.WebRouter, enabled: !ConfigHelper.parse_bool_env_var("DISABLE_WEBAPP") +config :block_scout_web, BlockScoutWeb.Routers.WebRouter, + enabled: !ConfigHelper.parse_bool_env_var("DISABLE_WEBAPP", "true") config :block_scout_web, BlockScoutWeb.CSPHeader, - mixpanel_url: System.get_env("MIXPANEL_URL", "https://api-js.mixpanel.com"), - amplitude_url: System.get_env("AMPLITUDE_URL", "https://api2.amplitude.com/2/httpapi") + mixpanel_url: ConfigHelper.parse_url_env_var("MIXPANEL_URL", "https://api-js.mixpanel.com"), + amplitude_url: ConfigHelper.parse_url_env_var("AMPLITUDE_URL", "https://api2.amplitude.com/2/httpapi") config :block_scout_web, Api.GraphQL, enabled: ConfigHelper.parse_bool_env_var("API_GRAPHQL_ENABLED", "true"), @@ -118,20 +91,9 @@ config :ueberauth, Ueberauth, } ] -redis_url = System.get_env("API_RATE_LIMIT_HAMMER_REDIS_URL") - -if is_nil(redis_url) or redis_url == "" do - config :hammer, backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} -else - config :hammer, - backend: - {Hammer.Backend.Redis, - [ - delete_buckets_timeout: 60_000 * 10, - expiry_ms: 60_000 * 60 * 4, - redis_url: redis_url - ]} -end +config :oauth2, adapter: Tesla.Adapter.Mint + +config :tesla, adapter: Tesla.Adapter.Mint # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/apps/block_scout_web/config/dev.exs b/apps/block_scout_web/config/dev.exs index d69502e2d5bd..bf3d30f81bbc 100644 --- a/apps/block_scout_web/config/dev.exs +++ b/apps/block_scout_web/config/dev.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # For development, we disable any cache and enable @@ -50,17 +51,13 @@ config :block_scout_web, BlockScoutWeb.Endpoint, config :block_scout_web, BlockScoutWeb.Tracer, env: "dev", disabled?: true -config :logger, :block_scout_web, - level: :debug, - path: Path.absname("logs/dev/block_scout_web.log") +config :logger, :block_scout_web, path: Path.absname("logs/dev/block_scout_web.log") config :logger, :api, - level: :debug, path: Path.absname("logs/dev/api.log"), metadata_filter: [application: :api] config :logger, :api_v2, - level: :debug, path: Path.absname("logs/dev/api_v2.log"), metadata_filter: [application: :api_v2] diff --git a/apps/block_scout_web/config/prod.exs b/apps/block_scout_web/config/prod.exs index 2efcfd72068f..efd73abdcc30 100644 --- a/apps/block_scout_web/config/prod.exs +++ b/apps/block_scout_web/config/prod.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # For production, we often load configuration from external @@ -20,18 +21,15 @@ config :block_scout_web, BlockScoutWeb.Endpoint, config :block_scout_web, BlockScoutWeb.Tracer, env: "production", disabled?: true config :logger, :block_scout_web, - level: :info, path: Path.absname("logs/prod/block_scout_web.log"), rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :api, - level: :debug, path: Path.absname("logs/prod/api.log"), metadata_filter: [application: :api], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :api_v2, - level: :debug, path: Path.absname("logs/prod/api_v2.log"), metadata_filter: [application: :api_v2], rotate: %{max_bytes: 52_428_800, keep: 19} diff --git a/apps/block_scout_web/config/runtime/test.exs b/apps/block_scout_web/config/runtime/test.exs index 9a9309ffdac3..f2ce1772a64f 100644 --- a/apps/block_scout_web/config/runtime/test.exs +++ b/apps/block_scout_web/config/runtime/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant diff --git a/apps/block_scout_web/config/test.exs b/apps/block_scout_web/config/test.exs index 033fc74d0444..afd6ec9e20cb 100644 --- a/apps/block_scout_web/config/test.exs +++ b/apps/block_scout_web/config/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :block_scout_web, :sql_sandbox, true @@ -13,9 +14,7 @@ config :block_scout_web, BlockScoutWeb.Endpoint, config :block_scout_web, BlockScoutWeb.Tracer, disabled?: false -config :logger, :block_scout_web, - level: :warn, - path: Path.absname("logs/test/block_scout_web.log") +config :logger, :block_scout_web, path: Path.absname("logs/test/block_scout_web.log") # Configure wallaby config :wallaby, screenshot_on_failure: true, driver: Wallaby.Chrome, js_errors: false @@ -24,6 +23,10 @@ config :block_scout_web, BlockScoutWeb.Counters.BlocksIndexedCounter, enabled: f config :block_scout_web, BlockScoutWeb.Counters.InternalTransactionsIndexedCounter, enabled: false +config :oauth2, adapter: Explorer.Mock.TeslaAdapter + +config :tesla, adapter: Explorer.Mock.TeslaAdapter + config :ueberauth, Ueberauth, providers: [ auth0: { diff --git a/apps/block_scout_web/lib/block_scout_web.ex b/apps/block_scout_web/lib/block_scout_web.ex index b7afec23a90d..b0cd5dd09127 100644 --- a/apps/block_scout_web/lib/block_scout_web.ex +++ b/apps/block_scout_web/lib/block_scout_web.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb do @moduledoc """ The entrypoint for defining your web interface, such @@ -29,9 +30,18 @@ defmodule BlockScoutWeb do import BlockScoutWeb.ErrorHelper import BlockScoutWeb.Routers.AccountRouter.Helpers, except: [static_path: 2] import Plug.Conn - import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] + + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, + only: [proxy_implementations_association: 0, proxy_implementations_smart_contracts_association: 0] alias BlockScoutWeb.Routers.AdminRouter.Helpers, as: AdminRoutes + + alias BlockScoutWeb.Schemas.API.V2, as: Schemas + alias OpenApiSpex.{Schema, Reference} + alias OpenApiSpex.JsonErrorResponse + alias Schemas.ErrorResponses.ForbiddenResponse + + import BlockScoutWeb.Schemas.API.V2.General end end @@ -41,11 +51,10 @@ defmodule BlockScoutWeb do root: "lib/block_scout_web/templates", namespace: BlockScoutWeb - # Import convenience functions from controllers - import Phoenix.Controller, only: [get_flash: 2, view_module: 1] - # Use all HTML functionality (forms, tags, etc) - use Phoenix.HTML + import Phoenix.HTML + import Phoenix.HTML.Form + use PhoenixHTMLHelpers import BlockScoutWeb.{ CurrencyHelper, diff --git a/apps/block_scout_web/lib/block_scout_web/api_v2.ex b/apps/block_scout_web/lib/block_scout_web/api_v2.ex index 6bd12a410332..dfdac1f3c00f 100644 --- a/apps/block_scout_web/lib/block_scout_web/api_v2.ex +++ b/apps/block_scout_web/lib/block_scout_web/api_v2.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2 do @moduledoc """ API V2 context diff --git a/apps/block_scout_web/lib/block_scout_web/application.ex b/apps/block_scout_web/lib/block_scout_web/application.ex index 6e8b5b4b541c..5a8f14a0074e 100644 --- a/apps/block_scout_web/lib/block_scout_web/application.ex +++ b/apps/block_scout_web/lib/block_scout_web/application.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Application do @moduledoc """ Supervises `BlockScoutWeb.Endpoint` in order to serve Web UI. @@ -6,19 +7,51 @@ defmodule BlockScoutWeb.Application do use Application use Utils.CompileTimeEnvHelper, disable_api?: [:block_scout_web, :disable_api?] - alias BlockScoutWeb.{Endpoint, HealthEndpoint} + alias BlockScoutWeb.{Endpoint, HealthEndpoint, RateLimit.Hammer} + alias BlockScoutWeb.Utility.RateLimitConfigHelper + alias Explorer - def start(_type, _args) do - opts = [strategy: :one_for_one, name: BlockScoutWeb.Supervisor, max_restarts: 1_000] + if @disable_api? do + def start(_type, _args) do + opts = [strategy: :one_for_one, name: BlockScoutWeb.Supervisor, max_restarts: 1_000] - if Application.get_env(:nft_media_handler, :standalone_media_worker?) do - Supervisor.start_link([Supervisor.child_spec(HealthEndpoint, [])], opts) - else - base_children = [Supervisor.child_spec(Endpoint, [])] - api_children = setup_and_define_children() - all_children = base_children ++ api_children + if Application.get_env(:nft_media_handler, :standalone_media_worker?) do + Supervisor.start_link([Supervisor.child_spec(HealthEndpoint, [])], opts) + else + # Endpoint must be the last child in the supervision tree + # since it must be started after all of the other processes + # (to be sure that application is ready to handle traffic) + # and stopped before them for the same reason. + # However, some processes may depend on Endpoint + # so they need to be started after. + base_children = [Supervisor.child_spec(Endpoint, [])] + {first_api_children, last_api_children} = setup_and_define_children() + all_children = first_api_children ++ base_children ++ last_api_children - Supervisor.start_link(all_children, opts) + Supervisor.start_link(all_children, opts) + end + end + else + def start(_type, _args) do + opts = [strategy: :one_for_one, name: BlockScoutWeb.Supervisor, max_restarts: 1_000] + + RateLimitConfigHelper.store_rate_limit_config() + + if Application.get_env(:nft_media_handler, :standalone_media_worker?) do + Supervisor.start_link([Supervisor.child_spec(HealthEndpoint, [])], opts) + else + # Endpoint must be the last child in the supervision tree + # since it must be started after all of the other processes + # (to be sure that application is ready to handle traffic) + # and stopped before them for the same reason. + # However, some processes may depend on Endpoint + # so they need to be started after. + base_children = [Supervisor.child_spec(Endpoint, [])] + {first_api_children, last_api_children} = setup_and_define_children() + all_children = first_api_children ++ base_children ++ last_api_children + + Supervisor.start_link(all_children, opts) + end end end @@ -29,28 +62,41 @@ defmodule BlockScoutWeb.Application do :ok end + alias Indexer.Prometheus.Metrics, as: IndexerMetrics + alias Indexer.Prometheus.RealtimeMetrics, as: IndexerRealtimeMetrics + + defp indexer_metric_worker do + if Explorer.mode() in [:indexer, :all] do + [{IndexerMetrics, []}, {IndexerRealtimeMetrics, []}] + else + [] + end + end + if @disable_api? do defp setup_and_define_children do BlockScoutWeb.Prometheus.Exporter.setup() - [] + {indexer_metric_worker(), []} end else defp setup_and_define_children do alias BlockScoutWeb.API.APILogger alias BlockScoutWeb.Counters.{BlocksIndexedCounter, InternalTransactionsIndexedCounter} - alias BlockScoutWeb.Prometheus.{Exporter, PhoenixInstrumenter, PublicExporter} - alias BlockScoutWeb.{MainPageRealtimeEventHandler, RealtimeEventHandler, SmartContractRealtimeEventHandler} + alias BlockScoutWeb.Prometheus.{Exporter, PublicExporter} + + alias BlockScoutWeb.RealtimeEventHandlers.{ + Main, + MainPage, + SmartContract, + TokenTransfer + } + alias BlockScoutWeb.Utility.EventHandlersMetrics - alias Explorer.Chain.Metrics, as: ChainMetrics + alias Explorer.Chain.Metrics.PublicMetrics, as: PublicChainMetrics - PhoenixInstrumenter.setup() Exporter.setup() PublicExporter.setup() - APILogger.message( - "Current global API rate limit #{inspect(Application.get_env(:block_scout_web, :api_rate_limit)[:global_limit])} reqs/sec" - ) - APILogger.message( "Current API rate limit by key #{inspect(Application.get_env(:block_scout_web, :api_rate_limit)[:limit_by_key])} reqs/sec" ) @@ -59,19 +105,26 @@ defmodule BlockScoutWeb.Application do "Current API rate limit by IP #{inspect(Application.get_env(:block_scout_web, :api_rate_limit)[:limit_by_ip])} reqs/sec" ) - # Define workers and child supervisors to be supervised - [ - # Start the endpoint when the application starts + base_workers = [ {Phoenix.PubSub, name: BlockScoutWeb.PubSub}, - {Absinthe.Subscription, Endpoint}, - {MainPageRealtimeEventHandler, name: MainPageRealtimeEventHandler}, - {RealtimeEventHandler, name: RealtimeEventHandler}, - {SmartContractRealtimeEventHandler, name: SmartContractRealtimeEventHandler}, + {MainPage, name: MainPage}, + {Main, name: Main}, + {SmartContract, name: SmartContract}, + {TokenTransfer, name: TokenTransfer}, {BlocksIndexedCounter, name: BlocksIndexedCounter}, {InternalTransactionsIndexedCounter, name: InternalTransactionsIndexedCounter}, {EventHandlersMetrics, []}, - {ChainMetrics, []} + {PublicChainMetrics, []}, + Hammer.child_for_supervisor() ] + + # Define workers and child supervisors to be supervised + { + base_workers ++ indexer_metric_worker(), + [ + {Absinthe.Subscription, Endpoint} + ] + } end end end diff --git a/apps/block_scout_web/lib/block_scout_web/authentication_helper.ex b/apps/block_scout_web/lib/block_scout_web/authentication_helper.ex new file mode 100644 index 000000000000..44a02f6758db --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/authentication_helper.ex @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.AuthenticationHelper do + @moduledoc """ + Helper module for authentication. + """ + + @spec validate_sensitive_endpoints_api_key(binary() | nil) :: + :ok | {:sensitive_endpoints_api_key, any()} | {:api_key, any()} + def validate_sensitive_endpoints_api_key(api_key_from_request) do + with {:sensitive_endpoints_api_key, api_key} when not is_nil(api_key) <- + {:sensitive_endpoints_api_key, Application.get_env(:block_scout_web, :sensitive_endpoints_api_key)}, + {:api_key, ^api_key} <- {:api_key, api_key_from_request} do + :ok + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex b/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex index 80df6c8128ca..31ec94a7380b 100644 --- a/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/captcha_helper.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.CaptchaHelper do @moduledoc """ A helper for CAPTCHA """ require Logger - alias Explorer.Helper + alias Explorer.{Helper, HttpClient} @type token_scope() :: :token_instance_refetch_metadata @@ -103,13 +104,12 @@ defmodule BlockScoutWeb.CaptchaHelper do headers = [{"Content-type", "application/x-www-form-urlencoded"}] case !Application.get_env(:block_scout_web, :recaptcha)[:is_disabled] && - Application.get_env(:block_scout_web, :http_adapter).post( + HttpClient.post( "https://www.google.com/recaptcha/api/siteverify", body, - headers, - [] + headers ) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, %{status_code: 200, body: body}} -> body |> Jason.decode!() |> success?() false -> @@ -123,7 +123,7 @@ defmodule BlockScoutWeb.CaptchaHelper do # v3 case defp success?(%{"success" => true, "score" => score, "hostname" => hostname}) do - unless Helper.get_app_host() == hostname do + if Helper.get_app_host() != hostname do Logger.warning("reCAPTCHA v3 Hostname mismatch: #{inspect(hostname)} != #{inspect(Helper.get_app_host())}") end @@ -137,7 +137,7 @@ defmodule BlockScoutWeb.CaptchaHelper do # v2 case defp success?(%{"success" => true, "hostname" => hostname}) do - unless Helper.get_app_host() == hostname do + if Helper.get_app_host() != hostname do Logger.warning("reCAPTCHA v2 Hostname mismatch: #{inspect(hostname)} != #{inspect(Helper.get_app_host())}") end diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex index 7c5c13b54ddb..752edf0d79e8 100644 --- a/apps/block_scout_web/lib/block_scout_web/chain.ex +++ b/apps/block_scout_web/lib/block_scout_web/chain.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Chain do @moduledoc """ Converts the `param` to the corresponding resource that uses that format of param. @@ -10,16 +11,19 @@ defmodule BlockScoutWeb.Chain do hash_to_transaction: 1, number_to_block: 1, string_to_address_hash: 1, - string_to_block_hash: 1, - string_to_transaction_hash: 1 + string_to_full_hash: 1 ] + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] + import Explorer.PagingOptions, only: [ default_paging_options: 0, page_size: 0 ] + import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] + import Explorer.Helper, only: [parse_boolean: 1, parse_integer: 1] alias BlockScoutWeb.PagingHelper @@ -48,21 +52,16 @@ defmodule BlockScoutWeb.Chain do Wei } - alias Explorer.Chain.Optimism.Deposit, as: OptimismDeposit + alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Optimism.FrameSequence, as: OptimismFrameSequence alias Explorer.Chain.Optimism.InteropMessage, as: OptimismInteropMessage alias Explorer.Chain.Optimism.OutputRoot, as: OptimismOutputRoot + alias Explorer.Chain.Scroll.Batch, as: ScrollBatch alias Explorer.Chain.Scroll.Bridge, as: ScrollBridge - alias Explorer.PagingOptions - - defimpl Poison.Encoder, for: Decimal do - def encode(value, _opts) do - # silence the xref warning - decimal = Decimal - - [?\", decimal.to_string(value), ?\"] - end - end + alias Explorer.{Etherscan, PagingOptions} + alias Explorer.Migrator.DeleteZeroValueInternalTransactions + alias Indexer.Fetcher.OnDemand.InternalTransaction, as: InternalTransactionOnDemand + alias Plug.Conn @page_size page_size() @default_paging_options default_paging_options() @@ -71,7 +70,7 @@ defmodule BlockScoutWeb.Chain do def current_filter(%{paging_options: paging_options} = params) do params - |> Map.get("filter") + |> (&(Map.get(&1, "filter") || Map.get(&1, :filter))).() |> case do "to" -> [direction: :to, paging_options: paging_options] "from" -> [direction: :from, paging_options: paging_options] @@ -81,7 +80,7 @@ defmodule BlockScoutWeb.Chain do def current_filter(params) do params - |> Map.get("filter") + |> (&(Map.get(&1, "filter") || Map.get(&1, :filter))).() |> case do "to" -> [direction: :to] "from" -> [direction: :from] @@ -139,26 +138,70 @@ defmodule BlockScoutWeb.Chain do end end - @spec next_page_params(any, list(), map(), (any -> map())) :: nil | map - def next_page_params(next_page, list, params, paging_function \\ &paging_params/1) + @spec next_page_params(any(), list(), map(), boolean(), (any() -> map())) :: nil | map() + def next_page_params(next_page, list, params, increment_items_count? \\ false, paging_function \\ &paging_params/1) - def next_page_params([], _list, _params, _), do: nil + def next_page_params([], _list, _params, _increment_items_count?, _), do: nil - def next_page_params(_, list, params, paging_function) do + def next_page_params(_, list, params, increment_items_count?, paging_function) do paging_params = paging_function.(List.last(list)) - next_page_params = Map.merge(params, paging_params) - current_items_count_string = Map.get(next_page_params, "items_count") + string_keys = map_to_string_keys(paging_params) - items_count = - if is_binary(current_items_count_string) do - {current_items_count, _} = Integer.parse(current_items_count_string) - current_items_count + Enum.count(list) - else - Enum.count(list) + next_page_params = + params + |> delete_parameters_from_next_page_params() + |> Map.drop(string_keys) + |> Map.merge(paging_params) + + items_count = next_items_count(next_page_params, list, increment_items_count?) + + cond do + Map.has_key?(next_page_params, "items_count") -> + Map.put(next_page_params, "items_count", items_count) + + Map.has_key?(next_page_params, :items_count) -> + Map.put(next_page_params, :items_count, items_count) + + true -> + Map.put(next_page_params, :items_count, items_count) + end + end + + defp get_items_count_from_next_page_params(next_page_params) do + cond do + Map.has_key?(next_page_params, "items_count") -> + Map.get(next_page_params, "items_count") + + Map.has_key?(next_page_params, :items_count) -> + Map.get(next_page_params, :items_count) + + true -> + nil + end + end + + defp next_items_count(_next_page_params, list, false) do + Enum.count(list) + end + + defp next_items_count(next_page_params, list, true) do + current_items_count_object = get_items_count_from_next_page_params(next_page_params) + + current_items_count = + cond do + is_binary(current_items_count_object) -> + {current_items_count, _} = Integer.parse(current_items_count_object) + current_items_count + + is_integer(current_items_count_object) -> + current_items_count_object + + true -> + 0 end - Map.put(next_page_params, "items_count", items_count) + current_items_count + Enum.count(list) end @doc """ @@ -167,6 +210,7 @@ defmodule BlockScoutWeb.Chain do """ @spec paging_options(any) :: [{:paging_options, Explorer.PagingOptions.t()}, ...] | Explorer.PagingOptions.t() + # todo: function clause for the old UI, to be removed later def paging_options(%{ "hash" => hash_string, "fetched_coin_balance" => fetched_coin_balance_string, @@ -192,52 +236,75 @@ defmodule BlockScoutWeb.Chain do end def paging_options(%{ - "fee" => fee_string, - "value" => value_string, - "block_number" => block_number_string, - "index" => index_string, - "inserted_at" => inserted_at_string, - "hash" => hash_string + hash: hash_string, + fetched_coin_balance: fetched_coin_balance_string, + transactions_count: transactions_count_string + }) + when is_binary(hash_string) do + case string_to_address_hash(hash_string) do + {:ok, address_hash} -> + [ + paging_options: %{ + @default_paging_options + | key: %{ + fetched_coin_balance: decimal_parse(fetched_coin_balance_string), + hash: address_hash, + transactions_count: parse_integer(transactions_count_string) + } + } + ] + + _ -> + [paging_options: @default_paging_options] + end + end + + def paging_options(%{ + fee: fee_string, + value: value_string, + block_number: block_number_string, + index: index_string, + inserted_at: inserted_at, + hash: hash_string }) do - with {:ok, hash} <- string_to_transaction_hash(hash_string), - {:ok, inserted_at, _} <- DateTime.from_iso8601(inserted_at_string) do - [ - paging_options: %{ - @default_paging_options - | key: %{ - fee: decimal_parse(fee_string), - value: decimal_parse(value_string), - block_number: parse_integer(block_number_string), - index: parse_integer(index_string), - inserted_at: inserted_at, - hash: hash - } - } - ] - else - _ -> [paging_options: @default_paging_options] + case string_to_full_hash(hash_string) do + {:ok, hash} -> + [ + paging_options: %{ + @default_paging_options + | key: %{ + fee: decimal_parse(fee_string), + value: decimal_parse(value_string), + block_number: parse_integer(block_number_string), + index: parse_integer(index_string), + inserted_at: inserted_at, + hash: hash + } + } + ] + + _ -> + [paging_options: @default_paging_options] end end def paging_options( %{ "market_cap" => market_cap_string, - "holders_count" => holder_count_string, - # todo: It should be removed in favour `holders_count` property with the next release after 8.0.0 - "holder_count" => holder_count_string, + "holders_count" => holders_count_string, "name" => name_string, "contract_address_hash" => contract_address_hash_string, - "is_name_null" => is_name_null_string + "is_name_null" => is_name_null } = params ) - when is_binary(market_cap_string) and is_binary(holder_count_string) and is_binary(name_string) and - is_binary(contract_address_hash_string) and is_binary(is_name_null_string) do + when is_binary(market_cap_string) and is_binary(holders_count_string) and is_binary(name_string) and + is_binary(contract_address_hash_string) do market_cap_decimal = decimal_parse(market_cap_string) fiat_value_decimal = decimal_parse(params["fiat_value"]) - holder_count = parse_integer(holder_count_string) - token_name = if is_name_null_string == "true", do: nil, else: name_string + holders_count = parse_integer(holders_count_string) + token_name = if is_name_null, do: nil, else: name_string case Hash.Address.cast(contract_address_hash_string) do {:ok, contract_address_hash} -> @@ -247,7 +314,45 @@ defmodule BlockScoutWeb.Chain do | key: %{ fiat_value: fiat_value_decimal, circulating_market_cap: market_cap_decimal, - holder_count: holder_count, + holder_count: holders_count, + name: token_name, + contract_address_hash: contract_address_hash + } + } + ] + + _ -> + [paging_options: @default_paging_options] + end + end + + def paging_options( + %{ + market_cap: market_cap_string, + holders_count: holders_count_string, + name: name_string, + contract_address_hash: contract_address_hash_string, + is_name_null: is_name_null + } = params + ) + when is_binary(market_cap_string) and is_binary(holders_count_string) and is_binary(name_string) and + is_binary(contract_address_hash_string) do + market_cap_decimal = decimal_parse(market_cap_string) + + fiat_value_decimal = decimal_parse(params[:fiat_value]) + + holders_count = parse_integer(holders_count_string) + token_name = if is_name_null, do: nil, else: name_string + + case Hash.Address.cast(contract_address_hash_string) do + {:ok, contract_address_hash} -> + [ + paging_options: %{ + @default_paging_options + | key: %{ + fiat_value: fiat_value_decimal, + circulating_market_cap: market_cap_decimal, + holder_count: holders_count, name: token_name, contract_address_hash: contract_address_hash } @@ -275,6 +380,14 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{ + block_number: block_number, + transaction_index: transaction_index, + index: index + }) do + [paging_options: %{@default_paging_options | key: {block_number, transaction_index, index}}] + end + def paging_options(%{ "block_number" => block_number_string, "index" => index_string, @@ -284,13 +397,13 @@ defmodule BlockScoutWeb.Chain do "index_in_batch" => index_in_batch_string }) when is_binary(block_number_string) and is_binary(index_string) and is_binary(batch_log_index_string) and - is_binary(batch_transaction_hash_string) and is_binary(index_in_batch_string) and + is_binary(batch_block_hash_string) and is_binary(batch_transaction_hash_string) and is_binary(index_in_batch_string) do with {block_number, ""} <- Integer.parse(block_number_string), {index, ""} <- Integer.parse(index_string), {index_in_batch, ""} <- Integer.parse(index_in_batch_string), - {:ok, batch_transaction_hash} <- string_to_transaction_hash(batch_transaction_hash_string), - {:ok, batch_block_hash} <- string_to_block_hash(batch_block_hash_string), + {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), + {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string), {batch_log_index, ""} <- Integer.parse(batch_log_index_string) do [ paging_options: %{ @@ -314,8 +427,8 @@ defmodule BlockScoutWeb.Chain do when is_binary(batch_log_index_string) and is_binary(batch_block_hash_string) and is_binary(batch_transaction_hash_string) and is_binary(index_in_batch_string) do with {index_in_batch, ""} <- Integer.parse(index_in_batch_string), - {:ok, batch_transaction_hash} <- string_to_transaction_hash(batch_transaction_hash_string), - {:ok, batch_block_hash} <- string_to_block_hash(batch_block_hash_string), + {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), + {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string), {batch_log_index, ""} <- Integer.parse(batch_log_index_string) do [ paging_options: %{ @@ -329,6 +442,52 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{ + block_number: block_number, + index: index, + batch_log_index: batch_log_index, + batch_block_hash: batch_block_hash_string, + batch_transaction_hash: batch_transaction_hash_string, + index_in_batch: index_in_batch + }) + when is_binary(batch_transaction_hash_string) and is_binary(batch_block_hash_string) do + with {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), + {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string) do + [ + paging_options: %{ + @default_paging_options + | key: {block_number, index}, + batch_key: {batch_block_hash, batch_transaction_hash, batch_log_index, index_in_batch} + } + ] + else + _ -> + [paging_options: @default_paging_options] + end + end + + def paging_options(%{ + batch_log_index: batch_log_index, + batch_block_hash: batch_block_hash_string, + batch_transaction_hash: batch_transaction_hash_string, + index_in_batch: index_in_batch + }) + when is_binary(batch_block_hash_string) and + is_binary(batch_transaction_hash_string) do + with {:ok, batch_transaction_hash} <- string_to_full_hash(batch_transaction_hash_string), + {:ok, batch_block_hash} <- string_to_full_hash(batch_block_hash_string) do + [ + paging_options: %{ + @default_paging_options + | batch_key: {batch_block_hash, batch_transaction_hash, batch_log_index, index_in_batch} + } + ] + else + _ -> + [paging_options: @default_paging_options] + end + end + def paging_options(%{"block_number" => block_number_string, "index" => index_string}) when is_binary(block_number_string) and is_binary(index_string) do with {block_number, ""} <- Integer.parse(block_number_string), @@ -340,6 +499,10 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{block_number: block_number, index: index}) do + [paging_options: %{@default_paging_options | key: {block_number, index}}] + end + def paging_options(%{"block_number" => block_number_string}) when is_binary(block_number_string) do case Integer.parse(block_number_string) do {block_number, ""} -> @@ -350,6 +513,30 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{block_number: block_number}) do + [paging_options: %{@default_paging_options | key: {block_number}}] + end + + def paging_options(%{"transaction_index" => transaction_index_string, "index" => index_string}) + when is_binary(transaction_index_string) and is_binary(index_string) do + with {transaction_index, ""} <- Integer.parse(transaction_index_string), + {index, ""} <- Integer.parse(index_string) do + [paging_options: %{@default_paging_options | key: %{transaction_index: transaction_index, index: index}}] + else + _ -> + [paging_options: @default_paging_options] + end + end + + def paging_options(%{"transaction_index" => transaction_index, "index" => index}) + when is_integer(transaction_index) and is_integer(index) do + [paging_options: %{@default_paging_options | key: %{transaction_index: transaction_index, index: index}}] + end + + def paging_options(%{transaction_index: transaction_index, index: index}) do + [paging_options: %{@default_paging_options | key: %{transaction_index: transaction_index, index: index}}] + end + def paging_options(%{"index" => index_string}) when is_binary(index_string) do case Integer.parse(index_string) do {index, ""} -> @@ -364,6 +551,10 @@ defmodule BlockScoutWeb.Chain do [paging_options: %{@default_paging_options | key: {index}}] end + def paging_options(%{index: index}) do + [paging_options: %{@default_paging_options | key: {index}}] + end + def paging_options(%{"nonce" => nonce_string}) when is_binary(nonce_string) do case Integer.parse(nonce_string) do {nonce, ""} -> @@ -374,6 +565,14 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{"nonce" => nonce}) when is_integer(nonce) do + [paging_options: %{@default_paging_options | key: {nonce}}] + end + + def paging_options(%{nonce: nonce}) do + [paging_options: %{@default_paging_options | key: {nonce}}] + end + def paging_options(%{"number" => number_string}) when is_binary(number_string) do case Integer.parse(number_string) do {number, ""} -> @@ -384,18 +583,18 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"nonce" => nonce}) when is_integer(nonce) do - [paging_options: %{@default_paging_options | key: {nonce}}] + def paging_options(%{"number" => number}) when is_integer(number) do + [paging_options: %{@default_paging_options | key: {number}}] end - def paging_options(%{"number" => number}) when is_integer(number) do + def paging_options(%{number: number}) do [paging_options: %{@default_paging_options | key: {number}}] end def paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}) when is_binary(inserted_at_string) and is_binary(hash_string) do with {:ok, inserted_at, _} <- DateTime.from_iso8601(inserted_at_string), - {:ok, hash} <- string_to_transaction_hash(hash_string) do + {:ok, hash} <- string_to_full_hash(hash_string) do [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] else _ -> @@ -403,13 +602,38 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{inserted_at: inserted_at, hash: hash_string}) when is_binary(hash_string) do + case string_to_full_hash(hash_string) do + {:ok, hash} -> + [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] + + _ -> + [paging_options: @default_paging_options] + end + end + def paging_options(%{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at}), do: [paging_options: %{@default_paging_options | key: {name, type, inserted_at}}] + def paging_options(%{token_name: name, token_type: type, token_inserted_at: inserted_at}), + do: [paging_options: %{@default_paging_options | key: {name, type, inserted_at}}] + def paging_options(%{"value" => value, "address_hash" => address_hash}) do [paging_options: %{@default_paging_options | key: {value, address_hash}}] end + def paging_options(%{value: "", address_hash: address_hash}) do + [paging_options: %{@default_paging_options | key: {nil, address_hash}}] + end + + def paging_options(%{value: "null", address_hash: address_hash}) do + [paging_options: %{@default_paging_options | key: {nil, address_hash}}] + end + + def paging_options(%{value: value, address_hash: address_hash}) do + [paging_options: %{@default_paging_options | key: {value, address_hash}}] + end + def paging_options(%{"fiat_value" => fiat_value_string, "value" => value_string, "id" => id_string}) when is_binary(fiat_value_string) and is_binary(value_string) and is_binary(id_string) do with {id, ""} <- Integer.parse(id_string), @@ -425,6 +649,20 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{fiat_value: fiat_value_string, value: value_string, id: id}) + when is_binary(fiat_value_string) and is_binary(value_string) do + with {value, ""} <- Decimal.parse(value_string), + {_id, _value, {fiat_value, ""}} <- {id, value, Decimal.parse(fiat_value_string)} do + [paging_options: %{@default_paging_options | key: {fiat_value, value, id}}] + else + {id, value, :error} -> + [paging_options: %{@default_paging_options | key: {nil, value, id}}] + + _ -> + [paging_options: @default_paging_options] + end + end + def paging_options(%{"value" => value_string, "id" => id_string}) when is_binary(value_string) and is_binary(id_string) do with {id, ""} <- Integer.parse(id_string), @@ -436,6 +674,10 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{value: value, id: id}) do + [paging_options: %{@default_paging_options | key: {nil, value, id}}] + end + def paging_options(%{"items_count" => items_count_string, "state_changes" => _}) when is_binary(items_count_string) do case Integer.parse(items_count_string) do {count, ""} -> [paging_options: %{@default_paging_options | key: {count}}] @@ -443,9 +685,13 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{items_count: items_count, state_changes: _}) when is_integer(items_count) do + [paging_options: %{@default_paging_options | key: {items_count}}] + end + def paging_options(%{"l1_block_number" => block_number, "transaction_hash" => transaction_hash}) do with {block_number, ""} <- Integer.parse(block_number), - {:ok, transaction_hash} <- string_to_transaction_hash(transaction_hash) do + {:ok, transaction_hash} <- string_to_full_hash(transaction_hash) do [paging_options: %{@default_paging_options | key: {block_number, transaction_hash}}] else _ -> @@ -453,13 +699,19 @@ defmodule BlockScoutWeb.Chain do end end + def paging_options(%{l1_block_number: block_number, transaction_hash: transaction_hash}) do + case string_to_full_hash(transaction_hash) do + {:ok, transaction_hash} -> + [paging_options: %{@default_paging_options | key: {block_number, transaction_hash}}] + + _ -> + [paging_options: @default_paging_options] + end + end + # clause for pagination of entities: # - Account's entities - # - Optimism frame sequences - # - Polygon Edge Deposits - # - Polygon Edge Withdrawals # - Arbitrum cross chain messages - # - Scroll cross chain messages def paging_options(%{"id" => id_string}) when is_binary(id_string) do case Integer.parse(id_string) do {id, ""} -> @@ -470,36 +722,38 @@ defmodule BlockScoutWeb.Chain do end end - def paging_options(%{"timestamp" => timestamp, "init_transaction_hash" => init_transaction_hash}) do - with {ts, ""} <- Integer.parse(timestamp), - {:ok, transaction_hash} <- string_to_transaction_hash(init_transaction_hash) do - [paging_options: %{@default_paging_options | key: {ts, transaction_hash}}] - else - _ -> - [paging_options: @default_paging_options] - end + def paging_options(%{"id" => id}) when is_integer(id) do + [paging_options: %{@default_paging_options | key: {id}}] end # clause for pagination of entities: - # - Account's entities # - Optimism frame sequences - # - Polygon Edge Deposits - # - Polygon Edge Withdrawals - # - Arbitrum cross chain messages # - Scroll cross chain messages - def paging_options(%{"id" => id}) when is_integer(id) do + def paging_options(%{id: id}) do [paging_options: %{@default_paging_options | key: {id}}] end + # Clause for `Explorer.Chain.Optimism.InteropMessage`, + # returned by `BlockScoutWeb.API.V2.OptimismController.interop_messages/2` (`/api/v2/optimism/interop/messages`) + def paging_options(%{timestamp: timestamp, init_transaction_hash: init_transaction_hash}) do + with {ts, ""} <- Integer.parse(timestamp), + {:ok, transaction_hash} <- string_to_full_hash(init_transaction_hash) do + [paging_options: %{@default_paging_options | key: {ts, transaction_hash}}] + else + _ -> + [paging_options: @default_paging_options] + end + end + def paging_options(%{ - "token_contract_address_hash" => token_contract_address_hash, - "token_id" => token_id, - "token_type" => token_type + token_contract_address_hash: token_contract_address_hash, + token_id: token_id, + token_type: token_type }) do [paging_options: %{@default_paging_options | key: {token_contract_address_hash, token_id, token_type}}] end - def paging_options(%{"token_contract_address_hash" => token_contract_address_hash, "token_type" => token_type}) do + def paging_options(%{token_contract_address_hash: token_contract_address_hash, token_type: token_type}) do [paging_options: %{@default_paging_options | key: {token_contract_address_hash, token_type}}] end @@ -522,22 +776,6 @@ defmodule BlockScoutWeb.Chain do ] end - # Clause for InternalTransaction by block: - # returned by `BlockScoutWeb.API.V2.BlockController.internal_transactions/2` (`/api/v2/blocks/:block_hash_or_number/internal-transactions`) - def paging_options(%{"block_index" => index_string}) when is_binary(index_string) do - case Integer.parse(index_string) do - {index, ""} -> - [paging_options: %{@default_paging_options | key: %{block_index: index}}] - - _ -> - [paging_options: @default_paging_options] - end - end - - def paging_options(%{"block_index" => index}) when is_integer(index) do - [paging_options: %{@default_paging_options | key: %{block_index: index}}] - end - # Clause for `Explorer.Chain.Blackfort.Validator`, # returned by `BlockScoutWeb.API.V2.ValidatorController.blackfort_validators_list/2` (`/api/v2/validators/blackfort`) def paging_options(%{ @@ -555,6 +793,26 @@ defmodule BlockScoutWeb.Chain do def paging_options(_params), do: [paging_options: @default_paging_options] + def hot_smart_contracts_paging_options(%{ + transactions_count: transactions_count, + total_gas_used: total_gas_used, + contract_address_hash: contract_address_hash + }) do + [ + paging_options: %{ + @default_paging_options + | key: %{ + transactions_count: transactions_count, + total_gas_used: total_gas_used, + contract_address_hash: contract_address_hash, + to_address_hash: contract_address_hash + } + } + ] + end + + def hot_smart_contracts_paging_options(_params), do: [paging_options: @default_paging_options] + def put_key_value_to_paging_options([paging_options: paging_options], key, value) do [paging_options: Map.put(paging_options, key, value)] end @@ -580,24 +838,51 @@ defmodule BlockScoutWeb.Chain do %PagingOptions{options | page_number: new_page_number, page_size: new_page_size} end - def param_to_block_number(formatted_number) when is_binary(formatted_number) do + @spec param_to_block_number(binary(), boolean()) :: {:ok, integer()} | {:error, :invalid} | {:error, :not_found} + def param_to_block_number(_number, validate_max_block_number? \\ true) + + def param_to_block_number(formatted_number, validate_max_block_number?) when is_binary(formatted_number) do case Integer.parse(formatted_number) do - {number, ""} -> {:ok, number} - _ -> {:error, :invalid} + {number, ""} -> + validate_block_number(number, validate_max_block_number?) + + _ -> + {:error, :invalid} end end - def param_to_block_timestamp(timestamp_string) when is_binary(timestamp_string) do - case Integer.parse(timestamp_string) do - {timestamp_int, ""} -> - timestamp = - timestamp_int - |> DateTime.from_unix!(:second) + @spec param_to_block_number(integer(), boolean()) :: {:ok, integer()} | {:error, :invalid} | {:error, :not_found} + def param_to_block_number(number, validate_max_block_number?) when is_integer(number), + do: validate_block_number(number, validate_max_block_number?) + + defp validate_block_number(number, validate_max_block_number?) when is_integer(number) and number >= 0 do + if not validate_max_block_number? or (validate_max_block_number? and number <= BlockNumber.get_max()) do + {:ok, number} + else + {:error, :not_found} + end + end - {:ok, timestamp} + defp validate_block_number(_, _), do: {:error, :invalid} - _ -> - {:error, :invalid_timestamp} + @doc """ + Converts a timestamp string to a `DateTime.t()` struct for block timestamp + queries. + + ## Parameters + - `timestamp_string`: A string containing a Unix timestamp in seconds + + ## Returns + - `{:ok, DateTime.t()}` if the timestamp is valid and can be converted + - `{:error, :invalid_timestamp}` if the timestamp is invalid or out of range + """ + @spec param_to_block_timestamp(String.t()) :: {:ok, DateTime.t()} | {:error, :invalid_timestamp} + def param_to_block_timestamp(timestamp_string) when is_binary(timestamp_string) do + with {timestamp_int, ""} <- Integer.parse(timestamp_string), + {:ok, timestamp} <- DateTime.from_unix(timestamp_int, :second) do + {:ok, timestamp} + else + _ -> {:error, :invalid_timestamp} end end @@ -651,28 +936,26 @@ defmodule BlockScoutWeb.Chain do transactions_count: transactions_count }) do %{ - "hash" => hash, - "fetched_coin_balance" => fetched_coin_balance && Wei.to(fetched_coin_balance, :wei), - "transactions_count" => transactions_count + hash: hash, + fetched_coin_balance: fetched_coin_balance && Wei.to(fetched_coin_balance, :wei), + transactions_count: transactions_count } end defp paging_params(%Token{ contract_address_hash: contract_address_hash, circulating_market_cap: circulating_market_cap, - holder_count: holder_count, + holder_count: holders_count, name: token_name, fiat_value: fiat_value }) do %{ - "market_cap" => circulating_market_cap, - "holders_count" => holder_count, - # todo: It should be removed in favour `holders_count` property with the next release after 8.0.0 - "holder_count" => holder_count, - "contract_address_hash" => contract_address_hash, - "name" => token_name, - "is_name_null" => is_nil(token_name), - "fiat_value" => fiat_value + market_cap: circulating_market_cap, + holders_count: holders_count, + contract_address_hash: contract_address_hash, + name: token_name, + is_name_null: is_nil(token_name), + fiat_value: fiat_value } end @@ -680,10 +963,6 @@ defmodule BlockScoutWeb.Chain do paging_params(token) end - defp paging_params(%OptimismFrameSequence{id: id}) do - %{"id" => id} - end - defp paging_params(%TagAddress{id: id}) do %{"id" => id} end @@ -705,28 +984,31 @@ defmodule BlockScoutWeb.Chain do end defp paging_params(%Block{number: number}) do - %{"block_number" => number} + %{block_number: number} end - defp paging_params(%InternalTransaction{index: index, transaction_hash: transaction_hash}) do - {:ok, %Transaction{block_number: block_number, index: transaction_index}} = hash_to_transaction(transaction_hash) - %{"block_number" => block_number, "transaction_index" => transaction_index, "index" => index} + defp paging_params(%InternalTransaction{ + index: index, + transaction_index: transaction_index, + block_number: block_number + }) do + %{block_number: block_number, transaction_index: transaction_index, index: index} end defp paging_params(%Log{index: index, block_number: block_number}) do - %{"block_number" => block_number, "index" => index} + %{block_number: block_number, index: index} end defp paging_params(%Transaction{block_number: nil, inserted_at: inserted_at, hash: hash}) do - %{"inserted_at" => DateTime.to_iso8601(inserted_at), "hash" => hash} + %{inserted_at: DateTime.to_iso8601(inserted_at), hash: hash} end defp paging_params(%Transaction{block_number: block_number, index: index}) do - %{"block_number" => block_number, "index" => index} + %{block_number: block_number, index: index} end defp paging_params(%TokenTransfer{block_number: block_number, log_index: index}) do - %{"block_number" => block_number, "index" => index} + %{block_number: block_number, index: index} end defp paging_params(%Address.Token{name: name, type: type, inserted_at: inserted_at}) do @@ -735,73 +1017,77 @@ defmodule BlockScoutWeb.Chain do %{"token_name" => name, "token_type" => type, "token_inserted_at" => inserted_at_datetime} end + defp paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) when is_nil(value) do + %{address_hash: to_string(address_hash), value: nil} + end + defp paging_params(%CurrentTokenBalance{address_hash: address_hash, value: value}) do - %{"address_hash" => to_string(address_hash), "value" => Decimal.to_integer(value)} + %{address_hash: to_string(address_hash), value: to_string(Decimal.to_integer(value))} end defp paging_params(%CoinBalance{block_number: block_number}) do - %{"block_number" => block_number} + %{block_number: block_number} end defp paging_params(%SmartContract{address: %NotLoaded{}} = smart_contract) do - %{"smart_contract_id" => smart_contract.id} + %{smart_contract_id: smart_contract.id} end - defp paging_params(%OptimismDeposit{l1_block_number: l1_block_number, l2_transaction_hash: l2_transaction_hash}) do - %{"l1_block_number" => l1_block_number, "transaction_hash" => l2_transaction_hash} + defp paging_params(%OptimismFrameSequence{id: id}) do + %{id: id} end defp paging_params(%OptimismOutputRoot{l2_output_index: index}) do - %{"index" => index} + %{index: index} end defp paging_params(%OptimismInteropMessage{timestamp: timestamp, init_transaction_hash: init_transaction_hash}) do - %{"timestamp" => DateTime.to_unix(timestamp), "init_transaction_hash" => init_transaction_hash} + %{timestamp: DateTime.to_unix(timestamp), init_transaction_hash: init_transaction_hash} end defp paging_params(%SmartContract{} = smart_contract) do %{ - "smart_contract_id" => smart_contract.id, - "transactions_count" => smart_contract.address.transactions_count, - # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0 - "transaction_count" => smart_contract.address.transactions_count, - "coin_balance" => + smart_contract_id: smart_contract.id, + transactions_count: smart_contract.address.transactions_count, + coin_balance: smart_contract.address.fetched_coin_balance && Wei.to(smart_contract.address.fetched_coin_balance, :wei) } end + defp paging_params(%ScrollBatch{number: number}) do + %{number: number} + end + defp paging_params(%ScrollBridge{index: id}) do - %{"id" => id} + %{id: id} end - defp paging_params(%{index: index}) do - %{"index" => index} + defp paging_params(%Instance{token_id: token_id}) do + %{"unique_token" => Decimal.to_integer(token_id)} end - defp paging_params(%{msg_nonce: nonce}) do - %{"nonce" => nonce} + defp paging_params(%StateChange{}) do + # todo: remove in the future as this param is unused in the pagination of state changes + %{state_changes: nil} end - defp paging_params(%{l2_block_number: block_number}) do - %{"block_number" => block_number} + defp paging_params(%{index: index}) do + %{index: index} end - # clause for zkEVM & Scroll batches pagination + # clause for zkEVM batches pagination defp paging_params(%{number: number}) do %{"number" => number} end - defp paging_params(%Instance{token_id: token_id}) do - %{"unique_token" => Decimal.to_integer(token_id)} + # clause for Optimism Deposits + defp paging_params(%{l1_block_number: l1_block_number, l2_transaction_hash: l2_transaction_hash}) do + %{l1_block_number: l1_block_number, transaction_hash: l2_transaction_hash} end - defp paging_params(%StateChange{}) do - %{"state_changes" => nil} - end - - # clause for Polygon Edge Deposits and Withdrawals - defp paging_params(%{msg_id: msg_id}) do - %{"id" => msg_id} + # clause for Optimism Withdrawals + defp paging_params(%{msg_nonce: nonce}) do + %{nonce: nonce} end # clause for Shibarium Deposits @@ -815,14 +1101,14 @@ defmodule BlockScoutWeb.Chain do end @spec paging_params_with_fiat_value(CurrentTokenBalance.t()) :: %{ - required(String.t()) => Decimal.t() | non_neg_integer() | nil + required(atom()) => Decimal.t() | non_neg_integer() | nil } def paging_params_with_fiat_value(%CurrentTokenBalance{id: id, value: value} = ctb) do - %{"fiat_value" => ctb.fiat_value, "value" => value, "id" => id} + %{fiat_value: ctb.fiat_value, value: value, id: id} end defp block_or_transaction_or_operation_or_blob_from_param(param) do - with {:ok, hash} <- string_to_transaction_hash(param), + with {:ok, hash} <- string_to_full_hash(param), {:error, :not_found} <- hash_to_transaction(hash), {:error, :not_found} <- hash_to_block(hash), {:error, :not_found} <- hash_to_user_operation(hash), @@ -853,16 +1139,32 @@ defmodule BlockScoutWeb.Chain do def unique_tokens_paging_options(%{"unique_token" => token_id}), do: [paging_options: %{default_paging_options() | key: {token_id}}] + def unique_tokens_paging_options(%{unique_token: token_id}), + do: [paging_options: %{default_paging_options() | key: {token_id}}] + def unique_tokens_paging_options(_params), do: [paging_options: default_paging_options()] def unique_tokens_next_page([], _list, _params), do: nil def unique_tokens_next_page(_, list, params) do - Map.merge(params, paging_params(List.last(list))) + params + |> Map.merge(paging_params(List.last(list))) + |> delete_parameters_from_next_page_params() end def token_transfers_next_page_params([], _list, _params), do: nil + @batch_transfer_fields_to_delete_from_next_page_params [ + "batch_log_index", + "batch_block_hash", + "batch_transaction_hash", + "index_in_batch", + :batch_log_index, + :batch_block_hash, + :batch_transaction_hash, + :index_in_batch + ] + def token_transfers_next_page_params(next_page, list, params) do next_token_transfer = List.first(next_page) current_token_transfer = List.last(list) @@ -875,16 +1177,29 @@ defmodule BlockScoutWeb.Chain do |> last_token_transfer_before_current(current_token_transfer) |> (&if(is_nil(&1), do: %{}, else: paging_params(&1))).() + # todo: consider removing it, when all controllers will get OpenAPI specs + string_keys = map_to_string_keys(new_params) + params + |> delete_parameters_from_next_page_params() + |> Map.drop(@batch_transfer_fields_to_delete_from_next_page_params ++ string_keys) |> Map.merge(new_params) |> Map.merge(%{ - "batch_log_index" => current_token_transfer.log_index, - "batch_block_hash" => current_token_transfer.block_hash, - "batch_transaction_hash" => current_token_transfer.transaction_hash, - "index_in_batch" => current_token_transfer.index_in_batch + batch_log_index: current_token_transfer.log_index, + batch_block_hash: current_token_transfer.block_hash, + batch_transaction_hash: current_token_transfer.transaction_hash, + index_in_batch: current_token_transfer.index_in_batch }) else - Map.merge(params, paging_params(List.last(list))) + new_params = paging_params(List.last(list)) + + # todo: consider removing it, when all controllers will get OpenAPI specs + string_keys = map_to_string_keys(new_params) + + params + |> delete_parameters_from_next_page_params() + |> Map.drop(@batch_transfer_fields_to_delete_from_next_page_params ++ string_keys) + |> Map.merge(new_params) end end @@ -900,7 +1215,7 @@ defmodule BlockScoutWeb.Chain do end def parse_block_hash_or_number_param("0x" <> _ = param) do - case string_to_block_hash(param) do + case string_to_full_hash(param) do {:ok, hash} -> {:ok, :hash, hash} @@ -917,22 +1232,310 @@ defmodule BlockScoutWeb.Chain do {:error, :invalid} -> {:error, {:invalid, :number}} + + {:error, :not_found} -> + {:error, :not_found} + end + end + + def parse_block_hash_or_number_param(number) + when is_integer(number) do + case param_to_block_number(number) do + {:ok, number} -> {:ok, :number, number} + {:error, :not_found} -> {:error, :not_found} end end @doc """ - Fetches the scam token toggle from conn.cookies["show_scam_tokens"]. And put it to the params keyword. + Determines the scam token toggle value and adds it to the params keyword list. - ## Parameters + The function checks for the scam token toggle in the following order: + 1. Looks for the `"show-scam-tokens"` request header + 2. Falls back to the `"show_scam_tokens"` cookie if the header is not present + 3. Parses the retrieved value as a boolean (defaults to `false` if the value + is neither `"true"`, `"false"`, `true`, nor `false`) - - params: Initial params to append scam token toggle info. - - conn: The connection. + ## Parameters + - `params`: Initial params keyword list to append scam token toggle info. + - `conn`: The connection struct. ## Returns - - Provided params keyword with the new field `show_scam_tokens?`. + The provided params keyword list with the added `show_scam_tokens?` field + set to a boolean value. """ @spec fetch_scam_token_toggle(Keyword.t(), Plug.Conn.t()) :: Keyword.t() - def fetch_scam_token_toggle(params, conn), - do: Keyword.put(params, :show_scam_tokens?, conn.cookies["show_scam_tokens"] |> parse_boolean()) + def fetch_scam_token_toggle(params, conn) do + Keyword.put( + params, + :show_scam_tokens?, + conn + |> Conn.get_req_header("show-scam-tokens") + |> case do + [show_scam_tokens?] -> show_scam_tokens? + _ -> conn.cookies["show_scam_tokens"] + end + |> parse_boolean() + ) + end + + @doc """ + Fetches latest internal transactions, routing to either the database or on-demand RPC source. + + When internal transactions are present in the database (for recent blocks + within the storage period), they are fetched from the DB. For older blocks + where zero-value internal transactions have been deleted, the function + falls back to fetching on-demand from the JSON-RPC node. + + ## Parameters + - `options`: Keyword list with optional keys: + - `:paging_options` - pagination options including page_size and key + - `:transaction_hash` - filter by specific transaction option + + ## Returns + - List of InternalTransaction structs + """ + @spec fetch_internal_transactions(Keyword.t()) :: [InternalTransaction.t()] + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def fetch_internal_transactions(options) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + transaction_hash = Keyword.get(options, :transaction_hash) + + necessity_by_association = %{block: :optional} + + address_preloads = [ + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] + + options_with_necessity = + options + |> Keyword.put_new(:necessity_by_association, necessity_by_association) + |> Keyword.put_new(:address_preloads, address_preloads) + + cond do + match?(%PagingOptions{key: {0, 0, 0}}, paging_options) or + match?(%PagingOptions{key: %{transaction_index: 0, index: 0}}, paging_options) -> + [] + + not is_nil(transaction_hash) -> + case hash_to_transaction(transaction_hash) do + {:ok, transaction} -> + transaction_to_internal_transactions(transaction, options_with_necessity) + + {:error, :not_found} -> + [] + end + + match?(%PagingOptions{key: {_, _, _}}, paging_options) and + not InternalTransaction.present_in_db?(elem(paging_options.key, 0)) -> + InternalTransactionOnDemand.fetch_latest(options_with_necessity) + + Application.get_env(:explorer, DeleteZeroValueInternalTransactions)[:enabled] -> + from_db = InternalTransaction.fetch(options_with_necessity) + + from_node = + if InternalTransactionOnDemand.should_fetch?(from_db, paging_options.page_size) do + InternalTransactionOnDemand.fetch_latest(options_with_necessity) + else + [] + end + + merge_internal_transactions(from_db, from_node, paging_options.page_size) + + true -> + InternalTransaction.fetch(options_with_necessity) + end + end + + @doc """ + Fetches internal transactions for the given transaction, routing to either the database or on-demand RPC source. + + When internal transactions are present in the database (for recent blocks + within the storage period), they are fetched from the DB. For older blocks + where zero-value internal transactions have been deleted, the function + falls back to fetching on-demand from the JSON-RPC node. + + ## Parameters + - `transaction`: The transaction struct to fetch internal transactions for + - `options`: Keyword list with optional keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:paging_options` - pagination options including page_size and key + + ## Returns + - List of InternalTransaction structs for the given transaction + """ + @spec transaction_to_internal_transactions(Transaction.t(), Keyword.t()) :: [InternalTransaction.t()] + def transaction_to_internal_transactions(transaction, options \\ []) do + if InternalTransaction.present_in_db?(transaction.block_number) do + InternalTransaction.transaction_to_internal_transactions(transaction.hash, options) + else + InternalTransactionOnDemand.fetch_by_transaction(transaction, options) + end + end + + @doc """ + Fetches internal transactions for the given block, routing to either the database or on-demand RPC source. + + When internal transactions are present in the database (for recent blocks + within the storage period), they are fetched from the DB. For older blocks + where zero-value internal transactions have been deleted, the function + falls back to fetching on-demand from the JSON-RPC node. + + ## Parameters + - `block`: The block struct to fetch internal transactions for + - `options`: Keyword list with optional keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:address_preloads` - addresses to preload with nested associations + - `:paging_options` - pagination options including page_size and key + - `:type` - filter by transaction type + - `:call_type` - filter by call type + + ## Returns + - List of InternalTransaction structs for the given block + """ + @spec block_to_internal_transactions(Block.t(), Keyword.t()) :: [InternalTransaction.t()] + def block_to_internal_transactions(block, options \\ []) do + if InternalTransaction.present_in_db?(block.number) do + InternalTransaction.block_to_internal_transactions(block.number, options) + else + InternalTransactionOnDemand.fetch_by_block(block, options) + end + end + + @doc """ + Fetches internal transactions for the given address by combining DB and on-demand sources. + + It first loads DB-backed internal transactions for the requested page, then + fetches additional items on-demand via JSON-RPC if needed. The merged list is + deduplicated, sorted in descending order, and trimmed to the requested page size. + + ## Parameters + - `address_hash`: The address hash to fetch internal transactions for + - `options`: Keyword list with optional keys: + - `:paging_options` - pagination options including page_size and key + - `:necessity_by_association` - associations to preload as required or optional + - `:address_preloads` - addresses to preload with nested associations + + ## Returns + - List of InternalTransaction structs for the given address + """ + @spec address_to_internal_transactions(Hash.Address.t(), Keyword.t()) :: [InternalTransaction.t()] + def address_to_internal_transactions(address_hash, options \\ []) do + paging_options = Keyword.get(options, :paging_options, default_paging_options()) + + case paging_options do + %PagingOptions{key: {0, 0, 0}} -> + [] + + _ -> + from_db = InternalTransaction.fetch_from_db_by_address(address_hash, options) + + from_node = + if InternalTransactionOnDemand.should_fetch?(from_db, paging_options.page_size) do + InternalTransactionOnDemand.fetch_by_address(address_hash, options) + else + [] + end + + merge_internal_transactions(from_db, from_node, paging_options.page_size) + end + end + + @doc """ + Works similar to `Explorer.Etherscan.list_internal_transactions/2` + but using DB or on-demand RPC based on internal transactions presence in DB. + + When internal transactions are present in the database (for recent blocks + within the storage period), they are fetched from the DB. For older blocks + where zero-value internal transactions have been deleted, the function + falls back to fetching on-demand from the JSON-RPC node. + + ## Parameters + - `transaction_or_address_hash_param_or_no_param`: Transaction or address hash or `:all` as a source to fetching internal transactions + - `options`: Map of options + + ## Returns + - List of InternalTransaction fields maps for the given param + """ + @spec list_internal_transactions(Hash.Full.t() | Hash.Address.t() | :all, map()) :: [map()] + def list_internal_transactions(%Hash{byte_count: unquote(Hash.Full.byte_count())} = transaction_hash, raw_options) do + options = Map.merge(Etherscan.default_options(), raw_options) + + case hash_to_transaction(transaction_hash) do + {:ok, transaction} -> + if not options.include_zero_value or InternalTransaction.present_in_db?(transaction.block_number) do + Etherscan.list_internal_transactions(transaction.hash, options) + else + InternalTransactionOnDemand.etherscan_fetch_by_transaction(transaction, options) + end + + {:error, :not_found} -> + [] + end + end + + def list_internal_transactions(%Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash, raw_options) do + from_db = Etherscan.list_internal_transactions(address_hash, raw_options) + + options = Map.merge(Etherscan.default_options(), raw_options) + + from_node = + if options.include_zero_value and InternalTransactionOnDemand.should_fetch?(from_db, options.page_size) do + InternalTransactionOnDemand.etherscan_fetch_by_address(address_hash, options) + else + [] + end + + merge_internal_transactions(from_db, from_node, options.page_size, options.order_by_direction) + end + + def list_internal_transactions(:all, raw_options) do + options = Map.merge(Etherscan.default_options(), raw_options) + + cond do + not options.include_zero_value -> + Etherscan.list_internal_transactions(:all, options) + + not is_nil(options[:endblock]) and not InternalTransaction.present_in_db?(options[:endblock]) -> + InternalTransactionOnDemand.etherscan_fetch_latest(options) + + Application.get_env(:explorer, DeleteZeroValueInternalTransactions)[:enabled] -> + from_db = Etherscan.list_internal_transactions(:all, options) + + from_node = + if InternalTransactionOnDemand.should_fetch?(from_db, options.page_size) do + InternalTransactionOnDemand.etherscan_fetch_latest(options) + else + [] + end + + merge_internal_transactions(from_db, from_node, options.page_size, options.order_by_direction) + + true -> + Etherscan.list_internal_transactions(:all, options) + end + end + + defp merge_internal_transactions(first_list, second_list, limit, sort_direction \\ :desc) do + sort_func = + case sort_direction do + :asc -> &<=/2 + _ -> &>=/2 + end + + first_list + |> Enum.concat(second_list) + |> Enum.uniq_by(&{&1.block_number, &1.transaction_index, &1.index}) + |> Enum.sort_by(&{&1.block_number, &1.transaction_index, &1.index}, sort_func) + |> Enum.take(limit) + end + + defp map_to_string_keys(map) do + map + |> Map.keys() + |> Enum.map(fn + key when is_atom(key) -> Atom.to_string(key) + key -> key + end) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/channels/address_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/address_channel.ex index e09626a5ec8b..0a155c27fda3 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/address_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/address_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressChannel do @moduledoc """ Establishes pub/sub channel for address page live updates. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/arbitrum_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/arbitrum_channel.ex index 27bc00e8b2ea..a4ec6c448601 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/arbitrum_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/arbitrum_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ArbitrumChannel do @moduledoc """ Establishes pub/sub channel for live updates of Arbitrum related events. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/block_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/block_channel.ex index ba2ea3fd571c..fd3d7fdf2d7d 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/block_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/block_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockChannel do @moduledoc """ Establishes pub/sub channel for live updates of block events. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/exchange_rate_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/exchange_rate_channel.ex index 1654d951be40..80d34a269802 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/exchange_rate_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/exchange_rate_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ExchangeRateChannel do @moduledoc """ Establishes pub/sub channel for exchange rate live updates. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/optimism_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/optimism_channel.ex index 6fa69e4ebb3b..6aff323d077e 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/optimism_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/optimism_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.OptimismChannel do @moduledoc """ Establishes pub/sub channel for live updates of OP related events. @@ -11,10 +12,4 @@ defmodule BlockScoutWeb.OptimismChannel do def join("optimism:new_deposits", _params, socket) do {:ok, %{}, socket} end - - # todo: the `optimism_deposits:new_deposits` socket topic is for backward compatibility - # for the frontend and should be removed after the frontend starts to use the `optimism:new_deposits` - def join("optimism_deposits:new_deposits", _params, socket) do - {:ok, %{}, socket} - end end diff --git a/apps/block_scout_web/lib/block_scout_web/channels/reward_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/reward_channel.ex index 1ab1c5ff0437..665ba86a89cd 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/reward_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/reward_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.RewardChannel do @moduledoc """ Establishes pub/sub channel for live updates of block reward events. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/token_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/token_channel.ex index 13b4db343a08..89e08a00ec31 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/token_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/token_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TokenChannel do @moduledoc """ Establishes pub/sub channel for live updates of token transfer events. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/token_instance_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/token_instance_channel.ex index 4b2be335caca..660f1c20d0c8 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/token_instance_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/token_instance_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TokenInstanceChannel do @moduledoc """ Establishes pub/sub channel for live updates of token instances events. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/transaction_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/transaction_channel.ex index 75a0130a782f..28af859f6b83 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/transaction_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/transaction_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionChannel do @moduledoc """ Establishes pub/sub channel for live updates of transaction events. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/user_socket.ex b/apps/block_scout_web/lib/block_scout_web/channels/user_socket.ex index 213e5d447a47..bb1350850fdc 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/user_socket.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/user_socket.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.UserSocket do use Phoenix.Socket use Absinthe.Phoenix.Socket, schema: BlockScoutWeb.GraphQL.Schema diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/address_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/address_channel.ex index 44616a930b1d..c3417676d249 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/address_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/address_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.AddressChannel do @moduledoc """ Establishes pub/sub channel for address page live updates for API V2. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/block_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/block_channel.ex index bcf0a3c1e8d9..cba13f191d22 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/block_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/block_channel.ex @@ -1,10 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.BlockChannel do @moduledoc """ Establishes pub/sub channel for live updates of block events for API V2. """ use BlockScoutWeb, :channel - def join("blocks:new_block", _params, socket) do + def join("blocks:" <> common, _params, socket) + when common in ["new_block", "indexing", "indexing_internal_transactions"] do {:ok, %{}, socket} end diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/exchange_rate_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/exchange_rate_channel.ex index 0f4a814f30c2..d416bf4a5cf5 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/exchange_rate_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/exchange_rate_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.ExchangeRateChannel do @moduledoc """ Establishes pub/sub channel for exchange rate live updates for API V2. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/polygon_zkevm_confirmed_batch_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/polygon_zkevm_confirmed_batch_channel.ex deleted file mode 100644 index db658015ff00..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/polygon_zkevm_confirmed_batch_channel.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule BlockScoutWeb.V2.PolygonZkevmConfirmedBatchChannel do - @moduledoc """ - Establishes pub/sub channel for live updates of zkEVM confirmed batch events for API V2. - """ - use BlockScoutWeb, :channel - - def join("zkevm_batches:new_zkevm_confirmed_batch", _params, socket) do - {:ok, %{}, socket} - end -end diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/reward_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/reward_channel.ex index 616db07348aa..8127f7f04df4 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/reward_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/reward_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.RewardChannel do @moduledoc """ Establishes pub/sub channel for live updates of block reward events for API V2. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/token_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/token_channel.ex index 0e5d36e14459..f044bd6f0a28 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/token_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/token_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.TokenChannel do @moduledoc """ Establishes pub/sub channel for live updates of token transfer events for API V2. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/transaction_channel.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/transaction_channel.ex index 255416743af2..f5d83a91a627 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/transaction_channel.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/transaction_channel.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.TransactionChannel do @moduledoc """ Establishes pub/sub channel for live updates of transaction events for API V2. diff --git a/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex b/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex index 1f21d23fbaa7..34764a7595d6 100644 --- a/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex +++ b/apps/block_scout_web/lib/block_scout_web/channels/v2/user_socket.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.UserSocket do @moduledoc """ Module to distinct new and old UI websocket connections @@ -12,12 +13,10 @@ defmodule BlockScoutWeb.V2.UserSocket do channel("transactions:*", BlockScoutWeb.V2.TransactionChannel) channel("tokens:*", BlockScoutWeb.V2.TokenChannel) channel("token_instances:*", BlockScoutWeb.TokenInstanceChannel) - channel("zkevm_batches:*", BlockScoutWeb.V2.PolygonZkevmConfirmedBatchChannel) case @chain_type do :arbitrum -> channel("arbitrum:*", BlockScoutWeb.ArbitrumChannel) - # todo: change `optimism*"` to `optimism:*` after the deprecated `optimism_deposits:new_deposits` topic is removed - :optimism -> channel("optimism*", BlockScoutWeb.OptimismChannel) + :optimism -> channel("optimism:*", BlockScoutWeb.OptimismChannel) _ -> nil end diff --git a/apps/block_scout_web/lib/block_scout_web/checksum_address.ex b/apps/block_scout_web/lib/block_scout_web/checksum_address.ex index 7aeeac2671eb..158c46fc4646 100644 --- a/apps/block_scout_web/lib/block_scout_web/checksum_address.ex +++ b/apps/block_scout_web/lib/block_scout_web/checksum_address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ChecksumAddress do @moduledoc """ Adds checksummed version of address hashes. @@ -49,7 +50,7 @@ defmodule BlockScoutWeb.ChecksumAddress do conn |> Controller.redirect(to: new_path |> BlockScoutWebController.full_path()) - |> halt + |> halt() else conn end diff --git a/apps/block_scout_web/lib/block_scout_web/cldr.ex b/apps/block_scout_web/lib/block_scout_web/cldr.ex index 722dbe64856a..2e784e1aa7dd 100644 --- a/apps/block_scout_web/lib/block_scout_web/cldr.ex +++ b/apps/block_scout_web/lib/block_scout_web/cldr.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Cldr do @moduledoc """ Cldr global configuration. diff --git a/apps/block_scout_web/lib/block_scout_web/controller.ex b/apps/block_scout_web/lib/block_scout_web/controller.ex index 3031f234cc8f..f156dd3bbee2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Controller do @moduledoc """ Common controller error responses diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/address_controller.ex index 2034b8a10652..dcb5a0f2f56a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/address_controller.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.API.V2.AddressController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] alias BlockScoutWeb.Account.API.V2.AuthenticateController - alias Explorer.ThirdPartyIntegrations.Auth0 + alias Explorer.Account.Authentication alias Plug.Conn action_fallback(BlockScoutWeb.Account.API.V2.FallbackController) @@ -34,7 +35,7 @@ defmodule BlockScoutWeb.Account.API.V2.AddressController do @spec link_address(Plug.Conn.t(), map()) :: :error | {:error, any()} | Conn.t() def link_address(conn, %{"message" => message, "signature" => signature}) do with %{uid: id} <- conn |> current_user(), - {:ok, auth} <- Auth0.link_address(id, message, signature) do + {:ok, auth} <- Authentication.link_address(id, message, signature) do AuthenticateController.put_auth_to_session(conn, auth) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/authenticate_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/authenticate_controller.ex index c4785608abac..938e3e474876 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/authenticate_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/authenticate_controller.ex @@ -1,31 +1,67 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - alias BlockScoutWeb.{AccessHelper, CaptchaHelper} + alias BlockScoutWeb.{AccessHelper, AuthenticationHelper} alias BlockScoutWeb.Account.API.V2.UserView - alias BlockScoutWeb.API.V2.ApiView - alias Explorer.Account.Identity + + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.{ + ForbiddenResponse, + NotFoundResponse, + UnauthorizedResponse + } + + alias BlockScoutWeb.Schemas.API.V2.Account, as: AccountSchemas + + alias Explorer.Account.{Authentication, Identity} alias Explorer.Chain - alias Explorer.Chain.Address - alias Explorer.ThirdPartyIntegrations.Auth0 alias Plug.Conn action_fallback(BlockScoutWeb.Account.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["authentication"]) + + operation :authenticate_get, + summary: "Authenticate API Key", + description: "Authenticate using an API key passed as a query parameter.", + parameters: [admin_api_key_param_query()], + responses: %{ + ok: {"User session.", "application/json", AccountSchemas.Session}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response(), + unauthorized: UnauthorizedResponse.response(), + forbidden: ForbiddenResponse.response() + } + + @spec authenticate_get(Conn.t(), map()) :: Conn.t() | {:error, any()} def authenticate_get(conn, params) do authenticate(conn, params) end + operation :authenticate_post, + summary: "Authenticate API Key", + description: "Authenticate using an API key passed in a request body.", + request_body: admin_api_key_request_body(), + responses: %{ + ok: {"User session.", "application/json", AccountSchemas.Session}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response(), + unauthorized: UnauthorizedResponse.response(), + forbidden: ForbiddenResponse.response() + } + + @spec authenticate_post(Conn.t(), map()) :: Conn.t() | {:error, any()} def authenticate_post(conn, params) do - authenticate(conn, params) + authenticate(conn, params |> Map.merge(conn.body_params)) end defp authenticate(conn, params) do - with {:sensitive_endpoints_api_key, api_key} when not is_nil(api_key) <- - {:sensitive_endpoints_api_key, Application.get_env(:block_scout_web, :sensitive_endpoints_api_key)}, - {:api_key, ^api_key} <- {:api_key, params["api_key"]}, + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(params[:api_key]), {:auth, %{id: uid} = current_user} <- {:auth, current_user(conn)}, {:identity, %Identity{}} <- {:identity, Identity.find_identity(uid)} do conn @@ -34,6 +70,17 @@ defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do end end + operation :send_otp, + summary: "Send One-Time Password (OTP)", + description: "Sends a one-time password (OTP) to the specified email address.", + request_body: AccountSchemas.send_otp_request_body(), + responses: %{ + ok: {"Success message.", "application/json", message_response_schema()}, + unprocessable_entity: JsonErrorResponse.response(), + internal_server_error: {"Error message", "application/json", message_response_schema()}, + too_many_requests: {"Error message", "application/json", message_response_schema()} + } + @doc """ Sends a one-time password (OTP) to the specified email address. @@ -41,8 +88,7 @@ defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do with different behaviors based on the current user's authentication status and the relationship between the provided email and existing accounts. - The function first verifies the reCAPTCHA response to prevent abuse. Then, - it checks the current user's status and proceeds accordingly: + The function checks the current user's status and proceeds accordingly: 1. If no user is logged in, it sends an OTP for a new account. 2. If a user is logged in and the email matches their account, it returns an error. @@ -53,13 +99,12 @@ defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do - `conn`: The `Plug.Conn` struct representing the current connection. - `params`: A map containing: - `"email"`: The email address to which the OTP should be sent. - - `"recaptcha_v3_response"` or `"recaptcha_response"`: The reCAPTCHA response token. ## Returns - `:error`: If there's an unexpected error during the process. - `{:error, String.t()}`: If there's a specific error (e.g., email already linked). + - `{:format, :email}`: If the provided email format is invalid. - `{:interval, integer()}`: If an OTP was recently sent and the cooldown period hasn't elapsed. - - `{:recaptcha, false}`: If the reCAPTCHA verification fails. - `Plug.Conn.t()`: A modified connection struct with a 200 status and success message if the OTP is successfully sent. @@ -71,73 +116,99 @@ defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do @spec send_otp(Conn.t(), map()) :: :error | {:error, String.t()} + | {:enabled, false} + | {:format, :email} | {:interval, integer()} - | {:recaptcha, false} | Conn.t() - def send_otp(conn, %{"email" => email} = params) do - with {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)} do - case conn |> Conn.fetch_session() |> current_user() do - nil -> - with :ok <- Auth0.send_otp(email, AccessHelper.conn_to_ip_string(conn)) do - conn |> put_status(200) |> json(%{message: "Success"}) - end - - %{email: nil} -> - with :ok <- Auth0.send_otp_for_linking(email, AccessHelper.conn_to_ip_string(conn)) do - conn |> put_status(200) |> json(%{message: "Success"}) - end - - %{} -> - conn - |> put_status(500) - |> put_view(ApiView) - |> render(:message, %{message: "This account already has an email"}) - end + def send_otp(conn, _params) do + conn = Conn.fetch_session(conn) + email = Map.get(conn.body_params, :email) + + case current_user(conn) do + nil -> + with :ok <- Authentication.send_otp(email, AccessHelper.conn_to_ip_string(conn)) do + conn |> put_status(200) |> json(%{message: "Success"}) + end + + %{email: nil} -> + with :ok <- Authentication.send_otp_for_linking(email, AccessHelper.conn_to_ip_string(conn)) do + conn |> put_status(200) |> json(%{message: "Success"}) + end + + %{} -> + conn + |> put_status(500) + |> put_view(UserView) + |> render(:message, %{message: "This account already has an email"}) end end - @doc """ - Confirms a one-time password (OTP) for a given email and updates the session. + operation :confirm_otp, + summary: "Confirm One-Time Password (OTP)", + description: "Confirms a one-time password (OTP) for a given email and updates the session.", + request_body: AccountSchemas.confirm_otp_request_body(), + responses: %{ + ok: {"User info.", "application/json", AccountSchemas.User}, + unprocessable_entity: JsonErrorResponse.response(), + internal_server_error: {"Error message", "application/json", message_response_schema()} + } - This function verifies the OTP provided for a specific email address. If the - OTP is valid, it retrieves the authentication information and updates the - user's session accordingly. + @doc """ + Confirms a one-time password (OTP) for the email in the request body and establishes a user session on success. - The function performs the following steps: - 1. Confirms the OTP with Auth0 and retrieves the authentication information. - 2. If successful, updates the session with the new authentication data. + Verifies the OTP via `Auth0.confirm_otp_and_get_auth/3`. On successful + verification, retrieves or creates the user's identity and + updates the session via `put_auth_to_session/2`. ## Parameters - - `conn`: The `Plug.Conn` struct representing the current connection. - - `params`: A map containing: - - `"email"`: The email address associated with the OTP. - - `"otp"`: The one-time password to be confirmed. + - `conn`: The `Plug.Conn` struct. The request body must + contain `:email` and `:otp` fields. + - `_params`: Unused. Email and OTP are read from + `conn.body_params`. ## Returns - - `:error`: If there's an unexpected error during the process. - - `{:error, any()}`: If there's a specific error during OTP confirmation or - session update. The error details are included. - - `Conn.t()`: A modified connection struct with updated session information - if the OTP is successfully confirmed. + - `Conn.t()` with a 200 status and rendered user info on + successful OTP confirmation. + - `{:enabled, false}` if Auth0 authentication is not enabled. + - `{:error, any()}` if OTP verification or session creation + fails. + - `:error` if an unexpected error occurs during OTP + verification or session creation. ## Notes - - Errors are handled later in `BlockScoutWeb.Account.API.V2.FallbackController`. - - This function relies on the Auth0 service to confirm the OTP and retrieve - the authentication information. - - The function handles both existing and newly created users. - - For newly created users, it may create a new authentication record if the - user is not immediately found in the search after OTP confirmation. - - The session update is handled by the `put_auth_to_session/2` function, which - perform additional operations such as setting cookies or rendering user - information. + - Errors are handled by + `BlockScoutWeb.Account.API.V2.FallbackController`. + - The client's IP address is forwarded to Auth0 for rate + limiting. """ - @spec confirm_otp(Conn.t(), map()) :: :error | {:error, any()} | Conn.t() - def confirm_otp(conn, %{"email" => email, "otp" => otp}) do - with {:ok, auth} <- Auth0.confirm_otp_and_get_auth(email, otp, AccessHelper.conn_to_ip_string(conn)) do + @spec confirm_otp(Conn.t(), map()) :: :error | {:error, any()} | {:enabled, false} | Conn.t() + def confirm_otp(conn, _params) do + email = Map.get(conn.body_params, :email) + otp = Map.get(conn.body_params, :otp) + + with {:ok, auth} <- Authentication.confirm_otp(email, otp, AccessHelper.conn_to_ip_string(conn)) do put_auth_to_session(conn, auth) end end + operation :siwe_message, + summary: "Generate SIWE Message", + description: "Generates a Sign-In with Ethereum (SIWE) message for a given Ethereum address.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :address, + in: :query, + schema: Schemas.General.AddressHash, + required: true, + description: "Address hash in the query" + } + ], + responses: %{ + ok: {"SIWE message.", "application/json", AccountSchemas.siwe_message_response_schema()}, + unprocessable_entity: JsonErrorResponse.response(), + internal_server_error: {"Error message", "application/json", message_response_schema()} + } + @doc """ Generates a Sign-In with Ethereum (SIWE) message for a given Ethereum address. @@ -174,14 +245,24 @@ defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do - The nonce is cached for the address to prevent replay attacks. - The SIWE message expires after 300 seconds from generation. """ - @spec siwe_message(Conn.t(), map()) :: {:error, String.t()} | {:format, :error} | Conn.t() - def siwe_message(conn, %{"address" => address}) do + @spec siwe_message(Conn.t(), map()) :: {:error, String.t()} | {:enabled, false} | {:format, :error} | Conn.t() + def siwe_message(conn, %{address: address}) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address)}, - {:ok, message} <- Auth0.generate_siwe_message(Address.checksum(address_hash)) do + {:ok, message} <- Authentication.generate_siwe_message(address_hash) do conn |> put_status(200) |> json(%{siwe_message: message}) end end + operation :authenticate_via_wallet, + summary: "Authenticate via Ethereum Wallet", + description: "Authenticates a user using a signed Ethereum message (SIWE).", + request_body: AccountSchemas.authenticate_via_wallet_request_body(), + responses: %{ + ok: {"User info.", "application/json", AccountSchemas.User}, + unprocessable_entity: JsonErrorResponse.response(), + internal_server_error: {"Error message", "application/json", message_response_schema()} + } + @doc """ Authenticates a user via their Ethereum wallet using a signed message. @@ -215,10 +296,61 @@ defmodule BlockScoutWeb.Account.API.V2.AuthenticateController do perform additional operations such as setting cookies or rendering user information. """ - @spec authenticate_via_wallet(Conn.t(), map()) :: :error | {:error, any()} | Conn.t() - def authenticate_via_wallet(conn, %{"message" => message, "signature" => signature} = params) do - with {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)}, - {:ok, auth} <- Auth0.get_auth_with_web3(message, signature) do + @spec authenticate_via_wallet(Conn.t(), map()) :: :error | {:error, any()} | {:enabled, false} | Conn.t() + def authenticate_via_wallet(conn, _params) do + message = Map.get(conn.body_params, :message) + signature = Map.get(conn.body_params, :signature) + + with {:ok, auth} <- Authentication.verify_siwe_message(message, signature) do + put_auth_to_session(conn, auth) + end + end + + operation :authenticate_via_dynamic, + summary: "Authenticate via Dynamic JWT", + description: "Authenticates a user using a Dynamic JWT token from the Authorization header.", + security: [%{"dynamic_jwt" => []}], + responses: %{ + ok: {"User info.", "application/json", AccountSchemas.User}, + unauthorized: UnauthorizedResponse.response(), + internal_server_error: {"Error message", "application/json", message_response_schema()} + } + + @doc """ + Authenticates a request using a Dynamic JWT token from the Authorization header. + + This function extracts a Bearer token from the request's "authorization" + header, verifies it through Dynamic's authentication system, and upon + successful validation, establishes a user session with the extracted + identity information. + + The function accepts both "Bearer" and "bearer" prefixes in the + authorization header (case-insensitive prefix matching). + + ## Parameters + - `conn`: The `Plug.Conn` struct representing the current connection. + - `params`: Request parameters (unused). + + ## Returns + - `Conn.t()` with updated session and rendered user info on successful + authentication. + - `{:error, any()}` if token verification or session creation fails. + - `{:token, nil}` if the authorization header is missing or malformed. + + ## Notes + - Errors are handled by `BlockScoutWeb.Account.API.V2.FallbackController`. + """ + @spec authenticate_via_dynamic(Conn.t(), map()) :: {:error, any()} | {:token, nil} | Conn.t() + def authenticate_via_dynamic(conn, _params) do + token = + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> token + ["bearer " <> token] -> token + _ -> nil + end + + with {:token, not_nil_token} when not is_nil(not_nil_token) <- {:token, token}, + {:ok, auth} <- Authentication.authenticate_via_dynamic(not_nil_token) do put_auth_to_session(conn, auth) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex index 4bec5561cece..64c982106173 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/email_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.API.V2.EmailController do use BlockScoutWeb, :controller use Utils.CompileTimeEnvHelper, invalid_session_key: [:block_scout_web, :invalid_session_key] @@ -6,8 +7,8 @@ defmodule BlockScoutWeb.Account.API.V2.EmailController do alias BlockScoutWeb.AccessHelper alias BlockScoutWeb.Account.API.V2.AuthenticateController - alias Explorer.Account.Identity - alias Explorer.{Helper, Repo} + alias Explorer.Account.{Authentication, Identity} + alias Explorer.{Helper, HttpClient, Repo} alias Explorer.ThirdPartyIntegrations.Auth0 require Logger @@ -17,7 +18,8 @@ defmodule BlockScoutWeb.Account.API.V2.EmailController do plug(:fetch_cookies, signed: [@invalid_session_key]) def resend_email(conn, _params) do - with user <- conn.cookies[@invalid_session_key], + with {:enabled, true} <- {:enabled, Auth0.enabled?()}, + user <- conn.cookies[@invalid_session_key], {:auth, false} <- {:auth, is_nil(user)}, {:email_verified, false} <- {:email_verified, user[:email_verified]}, {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(user[:id])}, @@ -36,8 +38,8 @@ defmodule BlockScoutWeb.Account.API.V2.EmailController do "user_id" => user.uid } - case HTTPoison.post(url, Jason.encode!(body), headers, []) do - {:ok, %HTTPoison.Response{body: _body, status_code: 201}} -> + case HttpClient.post(url, Jason.encode!(body), headers) do + {:ok, %{body: _body, status_code: 201}} -> identity |> Identity.changeset(%{verification_email_sent_at: DateTime.utc_now()}) |> Repo.account_repo().update() @@ -97,7 +99,7 @@ defmodule BlockScoutWeb.Account.API.V2.EmailController do | Plug.Conn.t() def link_email(conn, %{"email" => email, "otp" => otp}) do with {:auth, %{} = user} <- {:auth, current_user(conn)}, - {:ok, auth} <- Auth0.link_email(user, email, otp, AccessHelper.conn_to_ip_string(conn)) do + {:ok, auth} <- Authentication.link_email(user, email, otp, AccessHelper.conn_to_ip_string(conn)) do AuthenticateController.put_auth_to_session(conn, auth) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/fallback_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/fallback_controller.ex index bf4bd39f9d6d..dc80010d7dc7 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/fallback_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/fallback_controller.ex @@ -1,5 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.API.V2.FallbackController do - use Phoenix.Controller + use Phoenix.Controller, namespace: BlockScoutWeb alias BlockScoutWeb.Account.API.V2.UserView alias Ecto.Changeset @@ -139,6 +140,20 @@ defmodule BlockScoutWeb.Account.API.V2.FallbackController do |> render(:message, %{message: "Invalid reCAPTCHA response"}) end + def call(conn, {:token, nil}) do + conn + |> put_status(:unauthorized) + |> put_view(UserView) + |> render(:message, %{message: "No Bearer token"}) + end + + def call(conn, {:enabled, false}) do + conn + |> put_status(:not_found) + |> put_view(UserView) + |> render(:message, %{message: "This endpoint is not configured"}) + end + defp unauthorized_error(%{email_verified: false, email: email}) do %{message: "Unverified email", email: email} end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/tags_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/tags_controller.ex index 47b967192ab0..3ad6137d4800 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/tags_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/tags_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.API.V2.TagsController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex index f8e0b6614783..6e99d13a4dbe 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api/v2/user_controller.ex @@ -1,7 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.API.V2.UserController do alias Explorer.ThirdPartyIntegrations.Auth0 use BlockScoutWeb, :controller + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] + import BlockScoutWeb.Account.AuthController, only: [current_user: 1] import BlockScoutWeb.Chain, @@ -11,11 +14,9 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do split_list_by_page: 1 ] - import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] - alias Explorer.Account.Api.Key, as: ApiKey alias Explorer.Account.CustomABI - alias Explorer.Account.{Identity, PublicTagsRequest, TagAddress, TagTransaction, WatchlistAddress} + alias Explorer.Account.{Identity, TagAddress, TagTransaction, WatchlistAddress} alias Explorer.{Chain, Market, PagingOptions, Repo} alias Plug.CSRFProtection @@ -53,22 +54,11 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do {watchlist_addresses, next_page} = split_list_by_page(results_plus_one) next_page_params = - next_page |> next_page_params(watchlist_addresses, delete_parameters_from_next_page_params(params)) + next_page |> next_page_params(watchlist_addresses, params) watchlist_addresses_prepared = - Enum.map(watchlist_addresses, fn wa -> - balances = - Chain.fetch_paginated_last_token_balances(wa.address_hash, - paging_options: %PagingOptions{page_size: @token_balances_amount + 1} - ) - - count = Enum.count(balances) - overflow? = count > @token_balances_amount - - fiat_sum = - balances - |> Enum.take(@token_balances_amount) - |> Enum.reduce(Decimal.new(0), fn tb, acc -> Decimal.add(acc, tb.fiat_value || 0) end) + Enum.map(watchlist_addresses, fn %WatchlistAddress{} = wa -> + {fiat_sum, count, overflow?} = watchlist_token_stats(wa.address_hash) %WatchlistAddress{ wa @@ -88,6 +78,23 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do end end + defp watchlist_token_stats(address_hash) do + balances = + Chain.fetch_paginated_last_token_balances(address_hash, + paging_options: %PagingOptions{page_size: @token_balances_amount + 1} + ) + + count = Enum.count(balances) + overflow? = count > @token_balances_amount + + fiat_sum = + balances + |> Enum.take(@token_balances_amount) + |> Enum.reduce(Decimal.new(0), fn tb, acc -> Decimal.add(acc, tb.fiat_value || 0) end) + + {fiat_sum, count, overflow?} + end + def delete_watchlist(conn, %{"id" => watchlist_address_id}) do with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, @@ -104,32 +111,22 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do def create_watchlist(conn, %{ "address_hash" => address_hash, "name" => name, - "notification_settings" => %{ - "native" => %{ - "incoming" => watch_coin_input, - "outcoming" => watch_coin_output - }, - "ERC-20" => %{ - "incoming" => watch_erc_20_input, - "outcoming" => watch_erc_20_output - }, - "ERC-721" => %{ - "incoming" => watch_erc_721_input, - "outcoming" => watch_erc_721_output - }, - # "ERC-1155" => %{ - # "incoming" => watch_erc_1155_input, - # "outcoming" => watch_erc_1155_output - # }, - "ERC-404" => %{ - "incoming" => watch_erc_404_input, - "outcoming" => watch_erc_404_output - } - }, + "notification_settings" => notification_settings, "notification_methods" => %{ "email" => notify_email } }) do + watch_coin_input = notification_settings["native"]["incoming"] + watch_coin_output = notification_settings["native"]["outcoming"] + watch_erc_20_input = notification_settings["ERC-20"]["incoming"] + watch_erc_20_output = notification_settings["ERC-20"]["outcoming"] + watch_erc_721_input = notification_settings["ERC-721"]["incoming"] + watch_erc_721_output = notification_settings["ERC-721"]["outcoming"] + # watch_erc_1155_input = notification_settings["ERC-1155"]["incoming"] + # watch_erc_1155_output = notification_settings["ERC-1155"]["outcoming"] + watch_erc_404_input = notification_settings["ERC-404"]["incoming"] + watch_erc_404_output = notification_settings["ERC-404"]["outcoming"] + watchlist_params = %{ name: name, watch_coin_input: watch_coin_input, @@ -146,12 +143,23 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do address_hash: address_hash } + watchlist_params_extended = + if chain_type() == :zilliqa do + zrc2_notification_settings = Map.get(notification_settings, "ZRC-2", %{"incoming" => true, "outcoming" => true}) + + watchlist_params + |> Map.put(:watch_zrc_2_input, zrc2_notification_settings["incoming"]) + |> Map.put(:watch_zrc_2_output, zrc2_notification_settings["outcoming"]) + else + watchlist_params + end + with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, {:watchlist, %{watchlists: [watchlist | _]}} <- {:watchlist, Repo.account_repo().preload(identity, :watchlists)}, {:ok, watchlist_address} <- - WatchlistAddress.create(Map.put(watchlist_params, :watchlist_id, watchlist.id)) do + WatchlistAddress.create(Map.put(watchlist_params_extended, :watchlist_id, watchlist.id)) do conn |> put_status(200) |> render(:watchlist_address, %{ @@ -165,32 +173,22 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do "id" => watchlist_address_id, "address_hash" => address_hash, "name" => name, - "notification_settings" => %{ - "native" => %{ - "incoming" => watch_coin_input, - "outcoming" => watch_coin_output - }, - "ERC-20" => %{ - "incoming" => watch_erc_20_input, - "outcoming" => watch_erc_20_output - }, - "ERC-721" => %{ - "incoming" => watch_erc_721_input, - "outcoming" => watch_erc_721_output - }, - # "ERC-1155" => %{ - # "incoming" => watch_erc_1155_input, - # "outcoming" => watch_erc_1155_output - # }, - "ERC-404" => %{ - "incoming" => watch_erc_404_input, - "outcoming" => watch_erc_404_output - } - }, + "notification_settings" => notification_settings, "notification_methods" => %{ "email" => notify_email } }) do + watch_coin_input = notification_settings["native"]["incoming"] + watch_coin_output = notification_settings["native"]["outcoming"] + watch_erc_20_input = notification_settings["ERC-20"]["incoming"] + watch_erc_20_output = notification_settings["ERC-20"]["outcoming"] + watch_erc_721_input = notification_settings["ERC-721"]["incoming"] + watch_erc_721_output = notification_settings["ERC-721"]["outcoming"] + # watch_erc_1155_input = notification_settings["ERC-1155"]["incoming"] + # watch_erc_1155_output = notification_settings["ERC-1155"]["outcoming"] + watch_erc_404_input = notification_settings["ERC-404"]["incoming"] + watch_erc_404_output = notification_settings["ERC-404"]["outcoming"] + watchlist_params = %{ id: watchlist_address_id, name: name, @@ -208,12 +206,23 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do address_hash: address_hash } + zrc2_notification_settings = Map.get(notification_settings, "ZRC-2") + + watchlist_params_extended = + if chain_type() == :zilliqa and not is_nil(zrc2_notification_settings) do + watchlist_params + |> Map.put(:watch_zrc_2_input, zrc2_notification_settings["incoming"]) + |> Map.put(:watch_zrc_2_output, zrc2_notification_settings["outcoming"]) + else + watchlist_params + end + with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, {:watchlist, %{watchlists: [watchlist | _]}} <- {:watchlist, Repo.account_repo().preload(identity, :watchlists)}, {:ok, watchlist_address} <- - WatchlistAddress.update(Map.put(watchlist_params, :watchlist_id, watchlist.id)) do + WatchlistAddress.update(Map.put(watchlist_params_extended, :watchlist_id, watchlist.id)) do conn |> put_status(200) |> render(:watchlist_address, %{ @@ -230,7 +239,7 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do {tags, next_page} = split_list_by_page(results_plus_one) - next_page_params = next_page |> next_page_params(tags, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(tags, params) conn |> put_status(200) @@ -289,7 +298,7 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do {tags, next_page} = split_list_by_page(results_plus_one) - next_page_params = next_page |> next_page_params(tags, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(tags, params) conn |> put_status(200) @@ -445,82 +454,6 @@ defmodule BlockScoutWeb.Account.API.V2.UserController do end end - def public_tags_requests(conn, _params) do - with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, - {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, - public_tags_requests <- PublicTagsRequest.get_public_tags_requests_by_identity_id(identity.id) do - conn - |> put_status(200) - |> render(:public_tags_requests, %{public_tags_requests: public_tags_requests}) - end - end - - def delete_public_tags_request(conn, %{"id" => id, "remove_reason" => remove_reason}) do - with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, - {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, - {:public_tag_delete, true} <- - {:public_tag_delete, - PublicTagsRequest.mark_as_deleted_public_tags_request(%{ - id: id, - identity_id: identity.id, - remove_reason: remove_reason - })} do - conn - |> put_status(200) - |> render(:message, %{message: @ok_message}) - end - end - - def create_public_tags_request(conn, params) do - with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, - {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, - {:ok, public_tags_request} <- - PublicTagsRequest.create(%{ - full_name: params["full_name"], - email: params["email"], - tags: params["tags"], - website: params["website"], - additional_comment: params["additional_comment"], - addresses: params["addresses"], - company: params["company"], - is_owner: params["is_owner"], - identity_id: identity.id - }) do - conn - |> put_status(200) - |> render(:public_tags_request, %{public_tags_request: public_tags_request}) - end - end - - def update_public_tags_request( - conn, - %{ - "id" => id - } = params - ) do - with {:auth, %{id: uid}} <- {:auth, current_user(conn)}, - {:identity, %Identity{} = identity} <- {:identity, Identity.find_identity(uid)}, - {:ok, public_tags_request} <- - PublicTagsRequest.update( - reject_nil_map_values(%{ - id: id, - full_name: params["full_name"], - email: params["email"], - tags: params["tags"], - website: params["website"], - additional_comment: params["additional_comment"], - addresses: params["addresses"], - company: params["company"], - is_owner: params["is_owner"], - identity_id: identity.id - }) - ) do - conn - |> put_status(200) - |> render(:public_tags_request, %{public_tags_request: public_tags_request}) - end - end - def get_csrf(conn, _) do with {:auth, %{id: _}} <- {:auth, current_user(conn)} do conn diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex index 55c0b05283b6..819f9858453c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/api_key_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.ApiKeyController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex index 2c4bbc066d6c..20dedd6b652c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.AuthController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/custom_abi_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/custom_abi_controller.ex index dcd8fca985e4..a927ad7e65dc 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/custom_abi_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/custom_abi_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.CustomABIController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/public_tags_request_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/public_tags_request_controller.ex deleted file mode 100644 index a380a2cc3ca9..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/public_tags_request_controller.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule BlockScoutWeb.Account.PublicTagsRequestController do - use BlockScoutWeb, :controller - - alias Ecto.Changeset - alias Explorer.Account.PublicTagsRequest - - import BlockScoutWeb.Account.AuthController, only: [authenticate!: 1] - - def index(conn, _params) do - current_user = authenticate!(conn) - - render(conn, "index.html", - public_tags_requests: PublicTagsRequest.get_public_tags_requests_by_identity_id(current_user.id) - ) - end - - def new(conn, _params) do - current_user = authenticate!(conn) - - render(conn, "form.html", - method: :create, - public_tags_request: - PublicTagsRequest.changeset_without_constraints(%PublicTagsRequest{}, %{ - full_name: current_user.name, - email: current_user.email - }) - ) - end - - def create(conn, %{"public_tags_request" => public_tags_request}) do - current_user = authenticate!(conn) - - case PublicTagsRequest.create(%{ - full_name: public_tags_request["full_name"], - email: public_tags_request["email"], - tags: public_tags_request["tags"], - website: public_tags_request["website"], - additional_comment: public_tags_request["additional_comment"], - addresses: public_tags_request["addresses"], - company: public_tags_request["company"], - is_owner: public_tags_request["is_owner"], - identity_id: current_user.id - }) do - {:ok, _} -> - redirect(conn, to: public_tags_request_path(conn, :index)) - - {:error, invalid_public_tags_request} -> - render(conn, "form.html", method: :create, public_tags_request: invalid_public_tags_request) - end - end - - def create(conn, _) do - redirect(conn, to: public_tags_request_path(conn, :index)) - end - - def edit(conn, %{"id" => id}) do - current_user = authenticate!(conn) - - case PublicTagsRequest.get_public_tags_request_by_id_and_identity_id(id, current_user.id) do - nil -> - not_found(conn) - - %PublicTagsRequest{} = public_tags_request -> - render(conn, "form.html", - method: :update, - public_tags_request: PublicTagsRequest.changeset_without_constraints(public_tags_request) - ) - end - end - - def update(conn, %{ - "id" => id, - "public_tags_request" => public_tags_request - }) do - current_user = authenticate!(conn) - - case PublicTagsRequest.update(%{ - id: id, - full_name: public_tags_request["full_name"], - email: public_tags_request["email"], - tags: public_tags_request["tags"], - website: public_tags_request["website"], - additional_comment: public_tags_request["additional_comment"], - addresses: public_tags_request["addresses"], - company: public_tags_request["company"], - is_owner: public_tags_request["is_owner"], - identity_id: current_user.id - }) do - {:error, %Changeset{} = public_tags_request} -> - render(conn, "form.html", method: :update, public_tags_request: public_tags_request) - - _ -> - redirect(conn, to: public_tags_request_path(conn, :index)) - end - end - - def update(conn, _) do - authenticate!(conn) - - redirect(conn, to: public_tags_request_path(conn, :index)) - end - - def delete(conn, %{"id" => id, "remove_reason" => remove_reason}) do - current_user = authenticate!(conn) - - PublicTagsRequest.mark_as_deleted_public_tags_request(%{ - id: id, - identity_id: current_user.id, - remove_reason: remove_reason - }) - - redirect(conn, to: public_tags_request_path(conn, :index)) - end -end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_address_controller.ex index 8ca30a959b69..315c5f792c1c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_address_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.TagAddressController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_transaction_controller.ex index 4a4c8fca3773..d9f15f7be0e2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/tag_transaction_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.TagTransactionController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_address_controller.ex index 55236895a427..e7cc602e5bc6 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_address_controller.ex @@ -1,6 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.WatchlistAddressController do use BlockScoutWeb, :controller + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] + alias Explorer.Account.WatchlistAddress import BlockScoutWeb.Account.AuthController, only: [authenticate!: 1] @@ -71,10 +74,10 @@ defmodule BlockScoutWeb.Account.WatchlistAddressController do "watch_erc_721_input" => watch_nft_input, "watch_erc_721_output" => watch_nft_output, "notify_email" => notify_email - }, + } = params, watchlist_id ) do - %{ + attributes = %{ address_hash: address_hash, name: name, watch_coin_input: watch_coin_input, @@ -88,5 +91,16 @@ defmodule BlockScoutWeb.Account.WatchlistAddressController do notify_email: notify_email, watchlist_id: watchlist_id } + + if chain_type() == :zilliqa do + watch_zrc_2_input = Map.get(params, "watch_zrc_2_input", true) + watch_zrc_2_output = Map.get(params, "watch_zrc_2_output", true) + + attributes + |> Map.put(:watch_zrc_2_input, watch_zrc_2_input) + |> Map.put(:watch_zrc_2_output, watch_zrc_2_output) + else + attributes + end end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_controller.ex index f8c548322f44..46fbb1964d45 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/watchlist_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.WatchlistController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_by_day_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_by_day_controller.ex index 10833909ced1..2ae6e2dda24a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_by_day_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_by_day_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressCoinBalanceByDayController do @moduledoc """ Manages the grouping by day of the coin balance history of an address diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex index 2466dfce5b4d..b5a2997df70f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_coin_balance_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressCoinBalanceController do @moduledoc """ Manages the displaying of information about the coin balance history of an address @@ -11,7 +12,7 @@ defmodule BlockScoutWeb.AddressCoinBalanceController do alias BlockScoutWeb.{AccessHelper, AddressCoinBalanceView, Controller} alias Explorer.{Chain, Market} - alias Explorer.Chain.{Address, Wei} + alias Explorer.Chain.{Address, Address.CoinBalance, Wei} alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Phoenix.View @@ -21,7 +22,7 @@ defmodule BlockScoutWeb.AddressCoinBalanceController do {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do full_options = paging_options(params) - coin_balances_plus_one = Chain.address_to_coin_balances(address, full_options) + coin_balances_plus_one = CoinBalance.address_to_coin_balances(address, full_options) {coin_balances, next_page} = split_list_by_page(coin_balances_plus_one) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_controller.ex index 79460fa4017f..e6d9ce02cb10 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule BlockScoutWeb.AddressContractController do use BlockScoutWeb, :controller @@ -19,15 +20,16 @@ defmodule BlockScoutWeb.AddressContractController do :names => :optional, [smart_contract: :smart_contract_additional_sources] => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional }, + preload_contract_creation_internal_transaction: true, ip: ip ] with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), _ <- PublishHelper.sourcify_check(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options) do + {:ok, address} <- Address.find_contract_address(address_hash, address_options) do render( conn, "index.html", diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_controller.ex index 60f4720aef37..40881e89b4b2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_flattened_code_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_flattened_code_controller.ex index 72cd3aeb3bca..c714a5b7c589 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_flattened_code_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_flattened_code_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationViaFlattenedCodeController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_json_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_json_controller.ex index b41ae1517fdc..0663b65c3209 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_json_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_json_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationViaJsonController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_multi_part_files_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_multi_part_files_controller.ex index 78f6ea6b99c4..0b146b3461ad 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_multi_part_files_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_multi_part_files_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationViaMultiPartFilesController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_standard_json_input_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_standard_json_input_controller.ex index 37447acd8195..2e1ad775239d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_standard_json_input_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_via_standard_json_input_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationViaStandardJsonInputController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_vyper_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_vyper_controller.ex index 6c978901cd0a..4eee5f306f25 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_vyper_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_contract_verification_vyper_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationVyperController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex index c8ff8f208430..5dfe1c8920a2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressController do use BlockScoutWeb, :controller use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -16,9 +17,9 @@ defmodule BlockScoutWeb.AddressController do } alias Explorer.{Chain, Market} + alias Explorer.Chain.{Address, Wei} alias Explorer.Chain.Address.Counters alias Explorer.Chain.Cache.Counters.AddressesCount - alias Explorer.Chain.{Address, Wei} alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Indexer.Fetcher.OnDemand.ContractCode, as: ContractCodeOnDemand alias Phoenix.View @@ -27,14 +28,12 @@ defmodule BlockScoutWeb.AddressController do :filecoin -> @contract_address_preloads [ :smart_contract, - [contract_creation_internal_transaction: :from_address], [contract_creation_transaction: :from_address] ] _ -> @contract_address_preloads [ :smart_contract, - :contract_creation_internal_transaction, :contract_creation_transaction ] end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex index f4c8844e2581..3b4237fe9d52 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_internal_transaction_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressInternalTransactionController do @moduledoc """ Manages the displaying of information about internal transactions as they relate to addresses @@ -6,7 +7,16 @@ defmodule BlockScoutWeb.AddressInternalTransactionController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [current_filter: 1, paging_options: 1, next_page_params: 3, split_list_by_page: 1] + + import BlockScoutWeb.Chain, + only: [ + current_filter: 1, + paging_options: 1, + next_page_params: 3, + split_list_by_page: 1, + address_to_internal_transactions: 2 + ] + import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] alias BlockScoutWeb.{AccessHelper, Controller, InternalTransactionView} @@ -22,19 +32,16 @@ defmodule BlockScoutWeb.AddressInternalTransactionController do {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do full_options = [ - necessity_by_association: %{ - [created_contract_address: :names] => :optional, - [from_address: :names] => :optional, - [to_address: :names] => :optional, - [created_contract_address: :smart_contract] => :optional, - [from_address: :smart_contract] => :optional, - [to_address: :smart_contract] => :optional - } + address_preloads: [ + created_contract_address: [:names, :smart_contract], + from_address: [:names, :smart_contract], + to_address: [:names, :smart_contract] + ] ] |> Keyword.merge(paging_options(params)) |> Keyword.merge(current_filter(params)) - internal_transactions_plus_one = Chain.address_to_internal_transactions(address_hash, full_options) + internal_transactions_plus_one = address_to_internal_transactions(address_hash, full_options) {internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) next_page_path = diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex index febb8c95d31f..f0bdb668281c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_logs_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressLogsController do @moduledoc """ Manages events logs tab. @@ -12,6 +13,7 @@ defmodule BlockScoutWeb.AddressLogsController do alias BlockScoutWeb.{AccessHelper, AddressLogsView, Controller} alias Explorer.{Chain, Market} alias Explorer.Chain.Address + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Phoenix.View @@ -21,7 +23,17 @@ defmodule BlockScoutWeb.AddressLogsController do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), :ok <- Address.check_address_exists(address_hash), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do - logs_plus_one = Chain.address_to_logs(address_hash, false, paging_options(params)) + options = + params + |> paging_options() + |> Keyword.merge( + necessity_by_association: %{ + [address: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()]] => + :optional + } + ) + + logs_plus_one = Chain.address_to_logs(address_hash, false, options) {results, next_page} = split_list_by_page(logs_plus_one) next_page_url = @@ -86,7 +98,18 @@ defmodule BlockScoutWeb.AddressLogsController do formatted_topic = if String.starts_with?(topic, "0x"), do: topic, else: "0x" <> topic - logs_plus_one = Chain.address_to_logs(address_hash, false, topic: formatted_topic) + options = + params + |> paging_options() + |> Keyword.merge( + necessity_by_association: %{ + [address: [:smart_contract, Implementation.proxy_implementations_smart_contracts_association()]] => + :optional + } + ) + |> Keyword.merge(topic: formatted_topic) + + logs_plus_one = Chain.address_to_logs(address_hash, false, options) {results, next_page} = split_list_by_page(logs_plus_one) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_read_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_read_contract_controller.ex index 1211cf9b6682..b00a13a613ec 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_read_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_read_contract_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file # # When moving the calls to ajax, this controller became very similar to the @@ -26,8 +27,9 @@ defmodule BlockScoutWeb.AddressReadContractController do :names => :optional, :smart_contract => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional }, + preload_contract_creation_internal_transaction: true, ip: ip ] @@ -45,7 +47,7 @@ defmodule BlockScoutWeb.AddressReadContractController do ] with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), false <- is_nil(address.smart_contract), need_wallet? <- Reader.read_functions_required_wallet_from_abi(address.smart_contract.abi) != [], {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do @@ -66,7 +68,7 @@ defmodule BlockScoutWeb.AddressReadContractController do _ -> if custom_abi? do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do render( conn, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_read_proxy_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_read_proxy_controller.ex index 81381240eefd..491fa672edc1 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_read_proxy_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_read_proxy_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule BlockScoutWeb.AddressReadProxyController do use BlockScoutWeb, :controller @@ -18,13 +19,14 @@ defmodule BlockScoutWeb.AddressReadProxyController do :names => :optional, :smart_contract => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional }, + preload_contract_creation_internal_transaction: true, ip: ip ] with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), false <- is_nil(address.smart_contract), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do render( diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex index 34a8a9f2dea2..08d608948d97 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_balance_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenBalanceController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex index 6c6ad85901de..e42d71318935 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_controller.ex @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenController do use BlockScoutWeb, :controller import BlockScoutWeb.Chain, - only: [next_page_params: 4, paging_options: 1, split_list_by_page: 1, paging_params_with_fiat_value: 1] + only: [next_page_params: 5, paging_options: 1, split_list_by_page: 1, paging_params_with_fiat_value: 1] import BlockScoutWeb.Account.AuthController, only: [current_user: 1] import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] @@ -24,7 +25,7 @@ defmodule BlockScoutWeb.AddressTokenController do {tokens, next_page} = split_list_by_page(token_balances_plus_one) next_page_path = - case next_page_params(next_page, tokens, params, &paging_params_with_fiat_value/1) do + case next_page_params(next_page, tokens, params, false, &paging_params_with_fiat_value/1) do nil -> nil diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex index a6ce243ddffe..bd00d9f1812e 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_token_transfer_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenTransferController do use BlockScoutWeb, :controller @@ -193,10 +194,7 @@ defmodule BlockScoutWeb.AddressTokenTransferController do end end - def index( - conn, - %{"address_id" => address_hash_string} = params - ) do + def index(conn, %{"address_id" => address_hash_string} = params) do ip = AccessHelper.conn_to_ip_string(conn) with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), @@ -209,8 +207,8 @@ defmodule BlockScoutWeb.AddressTokenTransferController do coin_balance_status: CoinBalanceOnDemand.trigger_fetch(ip, address), exchange_rate: Market.get_coin_exchange_rate(), filter: params["filter"], - current_path: Controller.current_full_path(conn), counters_path: address_path(conn, :address_counters, %{"id" => Address.checksum(address_hash)}), + current_path: Controller.current_full_path(conn), tags: get_address_tags(address_hash, current_user(conn)) ) else diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex index 7f73149a5466..ac40c01d20fe 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTransactionController do @moduledoc """ Display all the Transactions that terminate at this Address. @@ -66,26 +67,7 @@ defmodule BlockScoutWeb.AddressTransactionController do items_json = Enum.map(results, fn result -> - case result do - {%Chain.Block.Reward{} = emission_reward, %Chain.Block.Reward{} = validator_reward} -> - View.render_to_string( - TransactionView, - "_emission_reward_tile.html", - current_address: address, - emission_funds: emission_reward, - validator: validator_reward - ) - - %Chain.Transaction{} = transaction -> - View.render_to_string( - TransactionView, - "_tile.html", - conn: conn, - current_address: address, - transaction: transaction, - burn_address_hash: @burn_address_hash - ) - end + render_address_transaction_item(result, conn, address) end) json(conn, %{items: items_json, next_page_path: next_page_url}) @@ -160,4 +142,29 @@ defmodule BlockScoutWeb.AddressTransactionController do end end end + + defp render_address_transaction_item( + {%Chain.Block.Reward{} = emission_reward, %Chain.Block.Reward{} = validator_reward}, + _conn, + address + ) do + View.render_to_string( + TransactionView, + "_emission_reward_tile.html", + current_address: address, + emission_funds: emission_reward, + validator: validator_reward + ) + end + + defp render_address_transaction_item(%Chain.Transaction{} = transaction, conn, address) do + View.render_to_string( + TransactionView, + "_tile.html", + conn: conn, + current_address: address, + transaction: transaction, + burn_address_hash: @burn_address_hash + ) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex index 92ac7af242c6..a104540f8c99 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_validation_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressValidationController do @moduledoc """ Display all the blocks that this address validates. @@ -13,6 +14,7 @@ defmodule BlockScoutWeb.AddressValidationController do alias BlockScoutWeb.{AccessHelper, BlockView, Controller} alias Explorer.{Chain, Market} + alias Explorer.Chain.Block alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Phoenix.View @@ -33,7 +35,7 @@ defmodule BlockScoutWeb.AddressValidationController do paging_options(params) ) - blocks_plus_one = Chain.get_blocks_validated_by_address(full_options, address_hash) + blocks_plus_one = Block.get_blocks_validated_by_address(full_options, address_hash) {blocks, next_page} = split_list_by_page(blocks_plus_one) next_page_path = diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex index ce3c05c37466..a10d0c614f18 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_withdrawal_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressWithdrawalController do @moduledoc """ Display all the withdrawals that terminate at this Address. diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_write_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_write_contract_controller.ex index 4b0124ce1f4d..d6371741e005 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_write_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_write_contract_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file # # When moving the calls to ajax, this controller became very similar to the @@ -25,8 +26,9 @@ defmodule BlockScoutWeb.AddressWriteContractController do :names => :optional, :smart_contract => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional }, + preload_contract_creation_internal_transaction: true, ip: ip ] @@ -41,7 +43,7 @@ defmodule BlockScoutWeb.AddressWriteContractController do with false <- AddressView.contract_interaction_disabled?(), {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), false <- is_nil(address.smart_contract), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do render( @@ -60,7 +62,7 @@ defmodule BlockScoutWeb.AddressWriteContractController do _ -> if custom_abi? do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do render( conn, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/address_write_proxy_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/address_write_proxy_controller.ex index 9643660d814e..c84aa791b881 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/address_write_proxy_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/address_write_proxy_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule BlockScoutWeb.AddressWriteProxyController do use BlockScoutWeb, :controller @@ -18,14 +19,15 @@ defmodule BlockScoutWeb.AddressWriteProxyController do :names => :optional, :smart_contract => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional }, + preload_contract_creation_internal_transaction: true, ip: ip ] with false <- AddressView.contract_interaction_disabled?(), {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), false <- is_nil(address.smart_contract), {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do render( diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/admin/dashboard_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/admin/dashboard_controller.ex index 4455e0f8de31..af173c51a9ac 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/admin/dashboard_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/admin/dashboard_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.DashboardController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/admin/session_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/admin/session_controller.ex index 6c200e3304d9..f92569d44bba 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/admin/session_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/admin/session_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.SessionController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/admin/setup_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/admin/setup_controller.ex index 1d0f82f4d696..d6ddc9d5db65 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/admin/setup_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/admin/setup_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.SetupController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex index 1e94fc50c445..9dffd228804c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/admin/tasks_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.TaskController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/api_logger.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/api_logger.ex index 8a86aee8432e..01c32032c7cd 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/api_logger.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/api_logger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.APILogger do @moduledoc """ Logger for API endpoints usage diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex index f67ef4afb9dd..d3f9d4886271 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/eth_rpc/eth_controller.ex @@ -1,7 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.EthRPC.EthController do use BlockScoutWeb, :controller - alias BlockScoutWeb.AccessHelper alias BlockScoutWeb.API.EthRPC.View, as: EthRPCView alias BlockScoutWeb.API.RPC.RPCView alias Explorer.EthRPC @@ -9,8 +9,7 @@ defmodule BlockScoutWeb.API.EthRPC.EthController do def eth_request(%{body_params: %{"_json" => requests}} = conn, _) when is_list(requests) do eth_json_rpc_max_batch_size = Application.get_env(:block_scout_web, :api_rate_limit)[:eth_json_rpc_max_batch_size] - with :ok <- AccessHelper.check_rate_limit(conn), - {:batch_size, true} <- {:batch_size, Enum.count(requests) <= eth_json_rpc_max_batch_size} do + if Enum.count(requests) <= eth_json_rpc_max_batch_size do responses = EthRPC.responses(requests) conn @@ -18,55 +17,39 @@ defmodule BlockScoutWeb.API.EthRPC.EthController do |> put_view(EthRPCView) |> render("responses.json", %{responses: responses}) else - :rate_limit_reached -> - AccessHelper.handle_rate_limit_deny(conn) - - {:batch_size, _} -> - conn - |> put_status(413) - |> put_view(RPCView) - |> render(:error, %{:error => "Payload Too Large. Max batch size is #{eth_json_rpc_max_batch_size}"}) + conn + |> put_status(413) + |> put_view(RPCView) + |> render(:error, %{:error => "Payload Too Large. Max batch size is #{eth_json_rpc_max_batch_size}"}) end end def eth_request(%{body_params: %{"_json" => request}} = conn, _) do - case AccessHelper.check_rate_limit(conn) do - :ok -> - [response] = EthRPC.responses([request]) + [response] = EthRPC.responses([request]) - conn - |> put_status(200) - |> put_view(EthRPCView) - |> render("response.json", %{response: response}) - - :rate_limit_reached -> - AccessHelper.handle_rate_limit_deny(conn) - end + conn + |> put_status(200) + |> put_view(EthRPCView) + |> render("response.json", %{response: response}) end def eth_request(conn, request) do - case AccessHelper.check_rate_limit(conn) do - :ok -> - # In the case that the JSON body is sent up w/o a json content type, - # Phoenix encodes it as a single key value pair, with the value being - # nil and the body being the key (as in a CURL request w/ no content type header) - decoded_request = - with [{single_key, nil}] <- Map.to_list(request), - {:ok, decoded} <- Jason.decode(single_key) do - decoded - else - _ -> request - end - - [response] = EthRPC.responses([decoded_request]) - - conn - |> put_status(200) - |> put_view(EthRPCView) - |> render("response.json", %{response: response}) - - :rate_limit_reached -> - AccessHelper.handle_rate_limit_deny(conn) - end + # In the case that the JSON body is sent up w/o a json content type, + # Phoenix encodes it as a single key value pair, with the value being + # nil and the body being the key (as in a CURL request w/ no content type header) + decoded_request = + with [{single_key, nil}] <- Map.to_list(request), + {:ok, decoded} <- Jason.decode(single_key) do + decoded + else + _ -> request + end + + [response] = EthRPC.responses([decoded_request]) + + conn + |> put_status(200) + |> put_view(EthRPCView) + |> render("response.json", %{response: response}) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex index 885446775e58..fbcbb1ba7138 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/health_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.HealthController do use Phoenix.Controller, namespace: BlockScoutWeb @@ -8,7 +9,7 @@ defmodule BlockScoutWeb.API.HealthController do @ok_message "OK" @backfill_multichain_search_db_migration_name "backfill_multichain_search_db" - @rollups [:arbitrum, :zksync, :optimism, :polygon_zkevm, :scroll] + @rollups [:arbitrum, :zksync, :optimism, :scroll] @doc """ Handles health checks for the application. @@ -37,10 +38,7 @@ defmodule BlockScoutWeb.API.HealthController do base_health_status = %{ metadata: %{ - # todo: this key is left for backward compatibility - # and should be removed after 8.0.0 release in favour of the new health check logic based on multiple modules - latest_block: indexing_status.blocks.old, - blocks: indexing_status.blocks.new + blocks: indexing_status.blocks } } @@ -53,14 +51,13 @@ defmodule BlockScoutWeb.API.HealthController do base_health_status |> put_in([:metadata, :batches], batches_indexing_status) # todo: return this when "latest block" metric starts remain non-empty all time - # |> Map.put(:healthy, indexing_status.blocks.new.healthy and batches_indexing_status.healthy) - |> Map.put(:healthy, indexing_status.blocks.new.healthy) + # |> Map.put(:healthy, indexing_status.blocks.healthy and batches_indexing_status.healthy) + |> Map.put(:healthy, indexing_status.blocks.healthy) else base_health_status - |> Map.put(:healthy, indexing_status.blocks.new.healthy) + |> Map.put(:healthy, indexing_status.blocks.healthy) end - # todo: this should be removed after 8.0.0. It is left for backward compatibility - it is artefact of the old response format. blocks_property = Map.get(Map.get(health_status, :metadata), :blocks) health_status_with_error = @@ -136,7 +133,7 @@ defmodule BlockScoutWeb.API.HealthController do """ @spec readiness(Plug.Conn.t(), map()) :: Plug.Conn.t() def readiness(conn, _) do - unless Application.get_env(:nft_media_handler, :standalone_media_worker?) do + if !Application.get_env(:nft_media_handler, :standalone_media_worker?) do HealthHelper.last_db_block_status() end @@ -186,12 +183,11 @@ defmodule BlockScoutWeb.API.HealthController do def get_indexing_status do health_status = HealthHelper.get_indexing_health_data() - blocks_old = old_blocks_indexing_status(health_status) - blocks_new = new_blocks_indexing_status(health_status) + blocks = blocks_indexing_status(health_status) common_status = %{ - blocks: %{old: blocks_old, new: blocks_new} + blocks: blocks } status = @@ -207,41 +203,7 @@ defmodule BlockScoutWeb.API.HealthController do status end - # todo: it should be removed after 8.0.0 release in favour of the new health check logic based on multiple modules - defp old_blocks_indexing_status(health_status) do - latest_block_timestamp_from_db = - if is_nil(health_status[:health_latest_block_timestamp_from_db]) do - nil - else - {:ok, latest_block_timestamp_from_db} = - DateTime.from_unix(Decimal.to_integer(health_status[:health_latest_block_timestamp_from_db])) - - latest_block_timestamp_from_db - end - - latest_block_timestamp_from_cache = - if is_nil(health_status[:health_latest_block_timestamp_from_cache]) do - nil - else - {:ok, latest_block_timestamp_from_cache} = - DateTime.from_unix(Decimal.to_integer(health_status[:health_latest_block_timestamp_from_cache])) - - latest_block_timestamp_from_cache - end - - %{ - db: %{ - number: to_string(health_status[:health_latest_block_number_from_db]), - timestamp: to_string(latest_block_timestamp_from_db) - }, - cache: %{ - number: to_string(health_status[:health_latest_block_number_from_cache]), - timestamp: to_string(latest_block_timestamp_from_cache) - } - } - end - - defp new_blocks_indexing_status(health_status) do + defp blocks_indexing_status(health_status) do latest_block_timestamp_from_db = if is_nil(health_status[:health_latest_block_timestamp_from_db]) do nil diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex new file mode 100644 index 000000000000..a7a922b999f8 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/block_controller.ex @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.BlockController do + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + # aliased with as: to avoid shadowing this module's own name + # (BlockScoutWeb.API.V2.Legacy.BlockController) + alias BlockScoutWeb.API.RPC.BlockController, as: V1BlockController + alias BlockScoutWeb.Schemas.API.Legacy.{Envelope, EthBlockNumberResult, GetBlockNumberByTimeResult} + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.{Parameter, Schema} + + tags(["legacy"]) + + operation :get_block_number_by_time, + summary: "Get block number by time stamp", + description: """ + Returns the block number created closest to a provided timestamp. + + Required: + - `timestamp` + - `closest` + """, + parameters: + [ + %Parameter{ + name: :timestamp, + in: :query, + schema: General.IntegerString, + description: "Unix timestamp in seconds." + }, + %Parameter{ + name: :closest, + in: :query, + schema: %Schema{type: :string, enum: ["before", "after"]}, + description: "Whether to return the block before or after the timestamp." + } + ] ++ General.base_params(), + responses: [ + ok: {"Block number", "application/json", Envelope.rpc_envelope(GetBlockNumberByTimeResult)} + ] + + @doc """ + Thin bridge to the v1 `getblocknobytime` action at `/api?module=block&action=getblocknobytime`. + """ + @spec get_block_number_by_time(Plug.Conn.t(), map()) :: Plug.Conn.t() + def get_block_number_by_time(conn, params), do: V1BlockController.getblocknobytime(conn, params) + + operation :eth_block_number, + summary: "Get the latest block number", + description: """ + Returns the latest block number as a hex-encoded string in a JSON-RPC 2.0 response. + """, + parameters: + [ + %Parameter{ + name: :id, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :integer}, %Schema{type: :string}]}, + description: + "JSON-RPC request id echoed back in the response. " <> + "Defaults to 1 when omitted." + } + ] ++ General.base_params(), + responses: [ + ok: {"Latest block number", "application/json", Envelope.eth_rpc_envelope(EthBlockNumberResult)} + ] + + @doc """ + Thin bridge to the v1 `eth_block_number` action at `/api?module=block&action=eth_block_number`. + """ + @spec eth_block_number(Plug.Conn.t(), map()) :: Plug.Conn.t() + def eth_block_number(conn, params), do: V1BlockController.eth_block_number(conn, params) +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex new file mode 100644 index 000000000000..32ad67567a47 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/legacy/logs_controller.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.LogsController do + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias BlockScoutWeb.API.RPC.LogsController, as: V1LogsController + alias BlockScoutWeb.Schemas.API.Legacy.{Envelope, LogItem} + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.{Parameter, Schema} + + tags(["legacy"]) + + @topic_schema %Schema{type: :string, pattern: ~r/^0x[0-9a-fA-F]{64}$/} + @topic_opr_schema %Schema{type: :string, enum: ["and", "or"]} + + operation :get_logs, + summary: "Get Event Logs by Address and/or Topic(s)", + description: """ + Event logs for an address and topic. Use and/or with the topic operator to specify + topic retrieval options when adding multiple topics. Up to a maximum of 1,000 event logs. + + Required: + - `fromBlock` and `toBlock` + - At least one of `address`, `topic0`, `topic1`, `topic2`, `topic3` + - If any pair of topic parameters is set, the corresponding `topicA_B_opr` is required. + """, + parameters: + [ + %Parameter{ + name: :fromBlock, + in: :query, + schema: %Schema{anyOf: [General.IntegerString, %Schema{type: :string, enum: ["latest"]}]}, + description: "Start block: integer or the sentinel \"latest\"" + }, + %Parameter{ + name: :toBlock, + in: :query, + schema: %Schema{anyOf: [General.IntegerString, %Schema{type: :string, enum: ["latest"]}]}, + description: "End block: integer or the sentinel \"latest\"" + }, + %Parameter{name: :address, in: :query, schema: General.AddressHash}, + %Parameter{name: :topic0, in: :query, schema: @topic_schema}, + %Parameter{name: :topic1, in: :query, schema: @topic_schema}, + %Parameter{name: :topic2, in: :query, schema: @topic_schema}, + %Parameter{name: :topic3, in: :query, schema: @topic_schema}, + %Parameter{name: :topic0_1_opr, in: :query, schema: @topic_opr_schema}, + %Parameter{name: :topic0_2_opr, in: :query, schema: @topic_opr_schema}, + %Parameter{name: :topic0_3_opr, in: :query, schema: @topic_opr_schema}, + %Parameter{name: :topic1_2_opr, in: :query, schema: @topic_opr_schema}, + %Parameter{name: :topic1_3_opr, in: :query, schema: @topic_opr_schema}, + %Parameter{name: :topic2_3_opr, in: :query, schema: @topic_opr_schema} + ] ++ General.base_params(), + responses: [ + ok: + {"Event logs", "application/json", Envelope.rpc_envelope(%Schema{type: :array, items: LogItem, nullable: true})} + ] + + @doc """ + Thin bridge to the v1 `getlogs` action at `/api?module=logs&action=getlogs`. + """ + @spec get_logs(Plug.Conn.t(), map()) :: Plug.Conn.t() + def get_logs(conn, params), do: V1LogsController.getlogs(conn, params) +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex index c95307249060..56d1e9d4e3e5 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/address_controller.ex @@ -1,31 +1,43 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.AddressController do use BlockScoutWeb, :controller alias BlockScoutWeb.AccessHelper alias BlockScoutWeb.API.RPC.Helper + alias BlockScoutWeb.Chain, as: BlockScoutWebChain alias Explorer.{Chain, Etherscan} - alias Explorer.Chain.{Address, Wei} + alias Explorer.Chain.{Address, PendingOperationsHelper, Wei} alias Explorer.Etherscan.{Addresses, Blocks} + alias Explorer.Helper, as: ExplorerHelper alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand @api_true [api?: true] @invalid_address_message "Invalid address format" @invalid_contract_address_message "Invalid contract address format" + @no_internal_transactions_message "No internal transactions found" @no_token_transfers_message "No token transfers found" + @results_window 10000 + @results_window_too_large_message "Result window is too large, PageNo x Offset size must be less than or equal to #{@results_window}" + @max_safe_block_number round(:math.pow(2, 31)) - 1 def listaccounts(conn, params) do - options = - params - |> optional_params() - |> Map.put_new(:page_number, 0) - |> Map.put_new(:page_size, 10) + case optional_params(params) do + {:ok, options} -> + options = + options + |> Map.put_new(:page_number, 0) + |> Map.put_new(:page_size, 10) - accounts = list_accounts(options, AccessHelper.conn_to_ip_string(conn)) + accounts = list_accounts(options, AccessHelper.conn_to_ip_string(conn)) - conn - |> put_status(200) - |> render(:listaccounts, %{accounts: accounts}) + conn + |> put_status(200) + |> render(:listaccounts, %{accounts: accounts}) + + {:error, :results_window_too_large} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) + end end def eth_get_balance(conn, params) do @@ -80,9 +92,8 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end def pendingtxlist(conn, params) do - options = optional_params(params) - - with {:address_param, {:ok, address_param}} <- fetch_address(params), + with {:params, {:ok, options}} <- {:params, optional_params(params)}, + {:address_param, {:ok, address_param}} <- fetch_address(params), {:format, {:ok, address_hash}} <- to_address_hash(address_param), {:ok, transactions} <- list_pending_transactions(address_hash, options) do render(conn, :pendingtxlist, %{transactions: transactions}) @@ -99,13 +110,15 @@ defmodule BlockScoutWeb.API.RPC.AddressController do {:error, :not_found} -> render(conn, :error, error: "No transactions found", data: []) + + {:params, {:error, :results_window_too_large}} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) end end def txlist(conn, params) do - options = optional_params(params) - - with {:address_param, {:ok, address_param}} <- fetch_address(params), + with {:params, {:ok, options}} <- {:params, optional_params(params)}, + {:address_param, {:ok, address_param}} <- fetch_address(params), {:format, {:ok, address_hash}} <- to_address_hash(address_param), {:address, :ok} <- {:address, Address.check_address_exists(address_hash, @api_true)}, {:ok, transactions} <- list_transactions(address_hash, options) do @@ -123,6 +136,9 @@ defmodule BlockScoutWeb.API.RPC.AddressController do {_, :not_found} -> render(conn, :error, error: "No transactions found", data: []) + + {:params, {:error, :results_window_too_large}} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) end end @@ -131,92 +147,128 @@ defmodule BlockScoutWeb.API.RPC.AddressController do {:error, :error} -> txlistinternal(conn, params, :no_param) - {{:ok, transaction_param}, :error} -> - txlistinternal(conn, transaction_param, :transaction) - - {:error, {:ok, address_param}} -> + {_, {:ok, address_param}} -> txlistinternal(conn, params, address_param, :address) + + {{:ok, transaction_param}, _} -> + txlistinternal(conn, params, transaction_param, :transaction) end end - def txlistinternal(conn, transaction_param, :transaction) do - with {:format, {:ok, transaction_hash}} <- to_transaction_hash(transaction_param), - {:ok, internal_transactions} <- list_internal_transactions(transaction_hash) do + def txlistinternal(conn, params, transaction_param, :transaction) do + with {:params, {:ok, options}} <- {:params, optional_params(params)}, + {:format, {:ok, transaction_hash}} <- to_transaction_hash(transaction_param), + {:ok, transaction} <- Chain.hash_to_transaction(transaction_hash), + {:pending, false} <- {:pending, PendingOperationsHelper.block_pending?(transaction.block_hash)}, + {:ok, internal_transactions} <- list_internal_transactions(transaction_hash, options) do render(conn, :txlistinternal, %{internal_transactions: internal_transactions}) else {:format, :error} -> render(conn, :error, error: "Invalid txhash format") {:error, :not_found} -> - render(conn, :error, error: "No internal transactions found", data: []) - end - end + render(conn, :error, error: @no_internal_transactions_message, data: []) - def txlistinternal(conn, params, :no_param) do - options = - params - |> optional_params() - - case list_internal_transactions(:all, options) do - {:ok, internal_transactions} -> - render(conn, :txlistinternal, %{internal_transactions: internal_transactions}) + {:params, {:error, :results_window_too_large}} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) - {:error, :not_found} -> - render(conn, :error, error: "No internal transactions found", data: []) + {:pending, true} -> + render(conn, :pending_internal_transaction, + message: "Internal transactions for this transaction have not been processed yet", + data: [] + ) end end - def txlistinternal(conn, params, address_param, :address) do - options = optional_params(params) + @block_range_not_yet_processed_message "Some internal transactions within this block range have not yet been processed" - with {:format, {:ok, address_hash}} <- to_address_hash(address_param), + def txlistinternal(conn, params, address_param, :address) do + with {:params, {:ok, options}} <- {:params, optional_params(params)}, + {:format, {:ok, address_hash}} <- to_address_hash(address_param), {:address, :ok} <- {:address, Address.check_address_exists(address_hash, @api_true)}, - {:ok, internal_transactions} <- list_internal_transactions(address_hash, options) do - render(conn, :txlistinternal, %{internal_transactions: internal_transactions}) + {{:ok, internal_transactions}, _, _} <- + {list_internal_transactions(address_hash, options), options[:startblock], options[:endblock]} do + render_internal_transactions(conn, internal_transactions, options[:startblock], options[:endblock]) else {:format, :error} -> render(conn, :error, error: @invalid_address_message) {_, :not_found} -> - render(conn, :error, error: "No internal transactions found", data: []) + render(conn, :error, error: @no_internal_transactions_message, data: []) + + {:params, {:error, :results_window_too_large}} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) + + {{:error, :not_found}, start_block_number, end_block_number} -> + render_internal_transactions(conn, [], start_block_number, end_block_number) end end - def tokentx(conn, params) do - options = optional_params(params) - - with {:address_param, {:ok, address_param}} <- fetch_address(params), - {:format, {:ok, address_hash}} <- to_address_hash(address_param), - {:contract_address, {:ok, contract_address_hash}} <- - {:contract_address, to_address_hash_optional(params["contractaddress"])}, - {:address, :ok} <- {:address, Address.check_address_exists(address_hash, @api_true)}, - {:ok, token_transfers} <- list_token_transfers(address_hash, contract_address_hash, options) do - render(conn, :tokentx, %{token_transfers: token_transfers}) + def txlistinternal(conn, params, :no_param) do + with {:ok, options} <- optional_params(params), + {{:ok, internal_transactions}, _, _} <- + {list_internal_transactions(:all, options), options[:startblock], options[:endblock]} do + render_internal_transactions(conn, internal_transactions, options[:startblock], options[:endblock]) else - {:address_param, :error} -> - render(conn, :error, error: "Query parameter address is required") + {:error, :results_window_too_large} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) - {:format, :error} -> - render(conn, :error, error: @invalid_address_message) + {{:error, :not_found}, start_block_number, end_block_number} -> + render_internal_transactions(conn, [], start_block_number, end_block_number) + end + end - {:contract_address, :error} -> - render(conn, :error, error: @invalid_contract_address_message) + defp render_internal_transactions(conn, [], start_block_number, end_block_number) do + if PendingOperationsHelper.blocks_pending?(start_block_number, end_block_number) do + render(conn, :pending_internal_transaction, + message: @block_range_not_yet_processed_message, + data: [] + ) + else + render(conn, :error, error: @no_internal_transactions_message, data: []) + end + end - {_, :not_found} -> - render(conn, :error, error: @no_token_transfers_message, data: []) + defp render_internal_transactions(conn, internal_transactions, start_block_number, end_block_number) do + if PendingOperationsHelper.blocks_pending?(start_block_number, end_block_number) do + render(conn, :pending_internal_transaction, + message: @block_range_not_yet_processed_message, + data: internal_transactions + ) + else + render(conn, :txlistinternal, %{internal_transactions: internal_transactions}) end end + def tokentx(conn, params) do + do_tokentx(conn, params, :erc20) + end + def tokennfttx(conn, params) do - options = optional_params(params) + do_tokentx(conn, params, :erc721) + end + + def token1155tx(conn, params) do + do_tokentx(conn, params, :erc1155) + end + + def token404tx(conn, params) do + do_tokentx(conn, params, :erc404) + end - with {:address, {:ok, address_hash}} <- {:address, to_address_hash_optional(params["address"])}, + def token7984tx(conn, params) do + do_tokentx(conn, params, :erc7984) + end + + defp do_tokentx(conn, params, transfers_type) do + with {:params, {:ok, options}} <- {:params, optional_params(params)}, + {:address, {:ok, address_hash}} <- {:address, to_address_hash_optional(params["address"])}, {:contract_address, {:ok, contract_address_hash}} <- {:contract_address, to_address_hash_optional(params["contractaddress"])}, true <- !is_nil(address_hash) or !is_nil(contract_address_hash), {:ok, token_transfers, max_block_number} <- - list_nft_transfers(address_hash, contract_address_hash, options) do - render(conn, :tokennfttx, %{token_transfers: token_transfers, max_block_number: max_block_number}) + list_token_transfers(transfers_type, address_hash, contract_address_hash, options) do + render(conn, :tokentx, %{token_transfers: token_transfers, max_block_number: max_block_number}) else false -> render(conn, :error, error: "Query parameter address or contractaddress is required") @@ -229,6 +281,9 @@ defmodule BlockScoutWeb.API.RPC.AddressController do {_, :not_found} -> render(conn, :error, error: @no_token_transfers_message, data: []) + + {:params, {:error, :results_window_too_large}} -> + render(conn, :error, error: @results_window_too_large_message, data: nil) end end @@ -287,13 +342,11 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end - @doc """ - Sanitizes optional params. - - """ - @spec optional_params(map()) :: map() + @doc false + @spec optional_params(map()) :: {:ok, map()} | {:error, :results_window_too_large} def optional_params(params) do %{} + |> put_boolean(params, "include_zero_value", :include_zero_value) |> put_order_by_direction(params) |> Helper.put_pagination_options(params) |> put_block(params, "startblock") @@ -301,6 +354,13 @@ defmodule BlockScoutWeb.API.RPC.AddressController do |> put_filter_by(params) |> put_timestamp(params, "start_timestamp") |> put_timestamp(params, "end_timestamp") + |> case do + %{page_number: page_number, page_size: page_size} when page_number * page_size > @results_window -> + {:error, :results_window_too_large} + + params -> + {:ok, params} + end end @doc """ @@ -449,7 +509,15 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end defp to_transaction_hash(transaction_hash_string) do - {:format, Chain.string_to_transaction_hash(transaction_hash_string)} + {:format, Chain.string_to_full_hash(transaction_hash_string)} + end + + defp put_boolean(options, params, params_key, options_key) do + case params |> Map.get(params_key, "") |> String.downcase() do + "true" -> Map.put(options, options_key, true) + "false" -> Map.put(options, options_key, false) + _ -> options + end end defp put_order_by_direction(options, params) do @@ -466,7 +534,11 @@ defmodule BlockScoutWeb.API.RPC.AddressController do # sobelow_skip ["DOS.StringToAtom"] defp put_block(options, params, key) do with %{^key => block_param} <- params, - {block_number, ""} <- Integer.parse(block_param) do + {:ok, block_number} <- + ExplorerHelper.safe_parse_non_negative_integer( + block_param, + @max_safe_block_number + ) do Map.put(options, String.to_atom(key), block_number) else _ -> @@ -516,54 +588,25 @@ defmodule BlockScoutWeb.API.RPC.AddressController do end end - defp list_internal_transactions(transaction_hash) do - case Etherscan.list_internal_transactions(transaction_hash) do + defp list_internal_transactions(transaction_or_address_hash_param_or_no_param, options) do + case BlockScoutWebChain.list_internal_transactions(transaction_or_address_hash_param_or_no_param, options) do [] -> {:error, :not_found} internal_transactions -> {:ok, internal_transactions} end end - defp list_internal_transactions(:all, options) do - case Etherscan.list_internal_transactions(:all, options) do - [] -> {:error, :not_found} - internal_transactions -> {:ok, internal_transactions} - end - end - - defp list_internal_transactions(address_hash, options) do - case Etherscan.list_internal_transactions(address_hash, options) do - [] -> {:error, :not_found} - internal_transactions -> {:ok, internal_transactions} - end - end - - defp list_token_transfers(address_hash, contract_address_hash, options) do - case Etherscan.list_token_transfers(address_hash, contract_address_hash, options) do - [] -> {:error, :not_found} - token_transfers -> {:ok, token_transfers} - end - end - - defp list_nft_transfers(nil, contract_address_hash, options) do + defp list_token_transfers(transfers_type, address_hash, contract_address_hash, options) do with {:ok, max_block_number} <- Chain.max_consensus_block_number(), - token_transfers when token_transfers != [] <- - Etherscan.list_nft_transfers_by_token(contract_address_hash, options) do - {:ok, token_transfers, max_block_number} - else - _ -> - {:error, :not_found} - end - end - - defp list_nft_transfers(address_hash, contract_address_hash, options) do - with {:address, :ok} <- {:address, Address.check_address_exists(address_hash, @api_true)}, - {:ok, max_block_number} <- Chain.max_consensus_block_number(), - token_transfers when token_transfers != [] <- - Etherscan.list_nft_transfers(address_hash, contract_address_hash, options) do + [_ | _] = token_transfers <- + Etherscan.list_token_transfers( + transfers_type, + address_hash, + contract_address_hash, + options + ) do {:ok, token_transfers, max_block_number} else - _ -> - {:error, :not_found} + _ -> {:error, :not_found} end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/block_controller.ex index 6cd7c76a3034..b43e6c472f62 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/block_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.BlockController do use BlockScoutWeb, :controller @@ -8,6 +9,9 @@ defmodule BlockScoutWeb.API.RPC.BlockController do alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Timex.Duration + @invalid_block_number "Invalid block number" + @do_not_exist "Block does not exist" + @doc """ Calculates the total reward for mining a specific block. @@ -41,13 +45,13 @@ defmodule BlockScoutWeb.API.RPC.BlockController do render(conn, :block_reward, block: block) else {:block_param, :error} -> - render(conn, :error, error: "Query parameter 'blockno' is required") + render(conn, :error, error: query_param_is_required("blockno")) {:error, :invalid} -> - render(conn, :error, error: "Invalid block number") + render(conn, :error, error: @invalid_block_number) {:error, :not_found} -> - render(conn, :error, error: "Block does not exist") + render(conn, :error, error: @do_not_exist) end end @@ -74,7 +78,7 @@ defmodule BlockScoutWeb.API.RPC.BlockController do @spec getblockcountdown(Plug.Conn.t(), map()) :: Plug.Conn.t() def getblockcountdown(conn, params) do with {:block_param, {:ok, unsafe_target_block_number}} <- {:block_param, Map.fetch(params, "blockno")}, - {:ok, target_block_number} <- ChainWeb.param_to_block_number(unsafe_target_block_number), + {:ok, target_block_number} <- ChainWeb.param_to_block_number(unsafe_target_block_number, false), {:max_block, current_block_number} when not is_nil(current_block_number) <- {:max_block, BlockNumber.get_max()}, {:average_block_time, average_block_time} when is_struct(average_block_time) <- @@ -91,10 +95,10 @@ defmodule BlockScoutWeb.API.RPC.BlockController do ) else {:block_param, :error} -> - render(conn, :error, error: "Query parameter 'blockno' is required") + render(conn, :error, error: query_param_is_required("blockno")) {:error, :invalid} -> - render(conn, :error, error: "Invalid block number") + render(conn, :error, error: @invalid_block_number) {:average_block_time, {:error, :disabled}} -> render(conn, :error, error: "Average block time calculating is disabled, so getblockcountdown is not available") @@ -140,19 +144,19 @@ defmodule BlockScoutWeb.API.RPC.BlockController do render(conn, block_number: block_number) else {:timestamp_param, :error} -> - render(conn, :error, error: "Query parameter 'timestamp' is required") + render(conn, :error, error: query_param_is_required("timestamp")) {:closest_param, :error} -> - render(conn, :error, error: "Query parameter 'closest' is required") + render(conn, :error, error: query_param_is_required("closest")) {:error, :invalid_timestamp} -> - render(conn, :error, error: "Invalid `timestamp` param") + render(conn, :error, error: invalid_param("timestamp")) {:error, :invalid_closest} -> - render(conn, :error, error: "Invalid `closest` param") + render(conn, :error, error: invalid_param("closest")) {:error, :not_found} -> - render(conn, :error, error: "Block does not exist") + render(conn, :error, error: @do_not_exist) end end @@ -177,4 +181,12 @@ defmodule BlockScoutWeb.API.RPC.BlockController do render(conn, :eth_block_number, number: max_block_number, id: id) end + + defp query_param_is_required(param) do + "Query parameter '#{param}' is required" + end + + defp invalid_param(param) do + "Invalid `#{param}` param" + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/celo_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/celo_controller.ex new file mode 100644 index 000000000000..d59acc0f8a78 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/celo_controller.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.RPC.CeloController do + use BlockScoutWeb, :controller + + alias Explorer.Chain.Celo.{AggregatedElectionReward, Epoch} + alias Explorer.Helper + + @max_safe_epoch_number 32_768 + + def getepoch(conn, params) do + options = [ + necessity_by_association: %{ + :distribution => :optional, + :start_processing_block => :optional, + :end_processing_block => :optional + }, + api?: true + ] + + with {:param, {:ok, epoch_number}} <- + {:param, Map.fetch(params, "epochNumber")}, + {:format, {:ok, epoch_number}} <- + {:format, Helper.safe_parse_non_negative_integer(epoch_number, @max_safe_epoch_number)}, + {:epoch, {:ok, epoch}} <- {:epoch, Epoch.from_number(epoch_number, options)} do + aggregated_rewards = + AggregatedElectionReward.epoch_number_to_rewards_aggregated_by_type( + epoch.number, + options + ) + + conn + |> render(:celo_epoch, epoch: epoch, aggregated_rewards: aggregated_rewards) + else + {:param, :error} -> + render(conn, :error, error: "Query parameter 'epochNumber' is required") + + {:format, {:error, type}} -> + error = + case type do + :negative_integer -> "Epoch number cannot be negative" + :too_big_integer -> "Epoch number is too big" + :invalid_integer -> "Invalid epoch number" + end + + render(conn, :error, error: error) + + {:epoch, {:error, :not_found}} -> + render(conn, :error, error: "No epoch found") + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/contract_controller.ex index c7a1074ac6b0..b95751e19849 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/contract_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.ContractController do use BlockScoutWeb, :controller use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -17,6 +18,7 @@ defmodule BlockScoutWeb.API.RPC.ContractController do alias Explorer.SmartContract.Solidity.PublisherWorker, as: SolidityPublisherWorker alias Explorer.SmartContract.Vyper.Publisher, as: VyperPublisher alias Explorer.ThirdPartyIntegrations.Sourcify + alias Indexer.Fetcher.OnDemand.ContractCode if @chain_type == :zksync do @optimization_runs "0" @@ -32,7 +34,7 @@ defmodule BlockScoutWeb.API.RPC.ContractController do @addresses_required "Query parameter contractaddresses is required" @contract_not_found "Smart-contract not found or is not verified" @restricted_access "Access to this address is restricted" - @not_a_smart_contract "The address is not a smart contract" + @not_a_smart_contract "Address is not a smart-contract" @addresses_limit 10 @api_true [api?: true] @@ -44,14 +46,18 @@ defmodule BlockScoutWeb.API.RPC.ContractController do def getcontractcreation(conn, %{"contractaddresses" => contract_address_hash_strings} = params) do addresses = contract_address_hash_strings - |> String.split(",") + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) |> Enum.take(@addresses_limit) |> Enum.map(fn address_hash_string -> case validate_address(address_hash_string, params) do {:ok, _address_hash, address} -> Address.maybe_preload_smart_contract_associations( address, - Address.contract_creation_transaction_associations(), + [ + Address.contract_creation_transaction_association() + ], @api_true ) @@ -72,8 +78,11 @@ defmodule BlockScoutWeb.API.RPC.ContractController do {:format, {:ok, casted_address_hash}} <- to_address_hash(address_hash), {:params, external_libraries} <- {:params, fetch_external_libraries(params)}, - {:not_a_smart_contract, bytecode} when bytecode != "0x" <- - {:not_a_smart_contract, Chain.smart_contract_bytecode(casted_address_hash, @api_true)}, + {:not_a_smart_contract, {:ok, _bytecode}} <- + {:not_a_smart_contract, + conn + |> AccessHelper.conn_to_ip_string() + |> ContractCode.get_or_fetch_bytecode(casted_address_hash)}, {:publish, {:ok, _}} <- {:publish, Publisher.publish(address_hash, fetched_params, external_libraries)} do address = Contracts.address_hash_to_address_with_source_code(casted_address_hash) @@ -117,29 +126,32 @@ defmodule BlockScoutWeb.API.RPC.ContractController do end def verify_via_sourcify(conn, %{"addressHash" => address_hash} = input) do - files = - if Map.has_key?(input, "files") do - input["files"] - else - [] - end + files = sourcify_files(input) - if SmartContract.verified_with_full_match?(address_hash) do - render(conn, :error, error: @verified) + with false <- SmartContract.verified_with_full_match?(address_hash), + {:ok, _verified_status} <- Sourcify.check_by_address(address_hash) do + get_metadata_and_publish(address_hash, conn) else - case Sourcify.check_by_address(address_hash) do - {:ok, _verified_status} -> - get_metadata_and_publish(address_hash, conn) - - _ -> - with {:ok, files_array} <- prepare_params(files), - {:ok, validated_files} <- validate_files(files_array) do - verify_and_publish(address_hash, validated_files, conn) - else - {:error, error} -> - render(conn, :error, error: error) - end - end + true -> render(conn, :error, error: @verified) + _ -> verify_via_sourcify_with_files(conn, address_hash, files) + end + end + + defp verify_via_sourcify_with_files(conn, address_hash, files) do + with {:ok, files_array} <- prepare_params(files), + {:ok, validated_files} <- validate_files(files_array) do + verify_and_publish(address_hash, validated_files, conn) + else + {:error, error} -> + render(conn, :error, error: error) + end + end + + defp sourcify_files(input) do + if Map.has_key?(input, "files") do + input["files"] + else + [] end end @@ -155,8 +167,11 @@ defmodule BlockScoutWeb.API.RPC.ContractController do {:check_verified_status, SmartContract.verified_with_full_match?(address_hash)}, {:format, {:ok, casted_address_hash}} <- to_address_hash(address_hash), {:params, {:ok, fetched_params}} <- {:params, fetch_verifysourcecode_params(params)}, - {:not_a_smart_contract, bytecode} when bytecode != "0x" <- - {:not_a_smart_contract, Chain.smart_contract_bytecode(casted_address_hash, @api_true)}, + {:not_a_smart_contract, {:ok, _bytecode}} <- + {:not_a_smart_contract, + conn + |> AccessHelper.conn_to_ip_string() + |> ContractCode.get_or_fetch_bytecode(casted_address_hash)}, uid <- VerificationStatus.generate_uid(address_hash) do Que.add(SolidityPublisherWorker, {"json_api", fetched_params, json_input, uid}) @@ -191,8 +206,11 @@ defmodule BlockScoutWeb.API.RPC.ContractController do {:check_verified_status, SmartContract.verified_with_full_match?(address_hash)}, {:format, {:ok, casted_address_hash}} <- to_address_hash(address_hash), {:params, {:ok, fetched_params}} <- {:params, fetch_verifysourcecode_solidity_single_file_params(params)}, - {:not_a_smart_contract, bytecode} when bytecode != "0x" <- - {:not_a_smart_contract, Chain.smart_contract_bytecode(casted_address_hash, @api_true)}, + {:not_a_smart_contract, {:ok, _bytecode}} <- + {:not_a_smart_contract, + conn + |> AccessHelper.conn_to_ip_string() + |> ContractCode.get_or_fetch_bytecode(casted_address_hash)}, external_libraries <- fetch_external_libraries_for_verifysourcecode(params), uid <- VerificationStatus.generate_uid(address_hash) do Que.add(SolidityPublisherWorker, {"flattened_api", fetched_params, external_libraries, uid}) @@ -335,7 +353,7 @@ defmodule BlockScoutWeb.API.RPC.ContractController do files_array |> Enum.filter(fn file -> SmartContractHelper.sol_file?(file.filename) end) - if length(jsons) > 0 and length(sols) > 0 do + if not Enum.empty?(jsons) and not Enum.empty?(sols) do {:ok, files_array} else {:error, "You should attach at least one *.json and one *.sol files"} @@ -429,8 +447,11 @@ defmodule BlockScoutWeb.API.RPC.ContractController do def verify_vyper_contract(conn, %{"addressHash" => address_hash} = params) do with {:params, {:ok, fetched_params}} <- {:params, fetch_vyper_verify_params(params)}, {:format, {:ok, casted_address_hash}} <- to_address_hash(address_hash), - {:not_a_smart_contract, bytecode} when bytecode != "0x" <- - {:not_a_smart_contract, Chain.smart_contract_bytecode(casted_address_hash, @api_true)}, + {:not_a_smart_contract, {:ok, _bytecode}} <- + {:not_a_smart_contract, + conn + |> AccessHelper.conn_to_ip_string() + |> ContractCode.get_or_fetch_bytecode(casted_address_hash)}, {:publish, {:ok, _}} <- {:publish, VyperPublisher.publish(address_hash, fetched_params)} do address = Contracts.address_hash_to_address_with_source_code(casted_address_hash) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/helper.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/helper.ex index a533ff8b57df..b72c6f840a6a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.Helper do @moduledoc """ Small helpers for RPC api controllers. diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex index 388b99df25a0..b6425cad65e2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/logs_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.LogsController do use BlockScoutWeb, :controller @@ -133,10 +134,10 @@ defmodule BlockScoutWeb.API.RPC.LogsController do fetched_params = Map.take(params, @required_params.one_of) found_keys = Map.keys(fetched_params) - if length(found_keys) > 0 do - {:ok, fetched_params} - else + if Enum.empty?(found_keys) do {:error, @required_params.one_of} + else + {:ok, fetched_params} end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/rpc_translator.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/rpc_translator.ex index 17fd203f6cb9..f4357e51309e 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/rpc_translator.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/rpc_translator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.RPCTranslator do @moduledoc """ Converts an RPC-style request into a controller action. @@ -18,12 +19,54 @@ defmodule BlockScoutWeb.API.RPC.RPCTranslator do import Plug.Conn import Phoenix.Controller, only: [put_view: 2] - alias BlockScoutWeb.AccessHelper alias BlockScoutWeb.API.APILogger alias BlockScoutWeb.API.RPC.RPCView + + alias BlockScoutWeb.API.RPC.{ + AddressController, + BlockController, + CeloController, + ContractController, + LogsController, + RPCView, + StatsController, + TokenController, + TransactionController + } + alias Phoenix.Controller alias Plug.Conn + @on_load :load_atoms + + @doc """ + Ensures that the specified controller modules are loaded into memory. + + This function iterates over a predefined list of controller modules and calls `Code.ensure_loaded?/1` + on each, which loads the module if it hasn't been loaded yet. This is useful for ensuring that + all necessary controllers are available before performing operations that depend on them. + + Returns `:ok` after attempting to load all modules. + """ + @spec load_atoms() :: :ok + def load_atoms do + Enum.each( + [ + AddressController, + BlockController, + CeloController, + ContractController, + LogsController, + StatsController, + TokenController, + TransactionController + ], + &Code.ensure_loaded?/1 + ) + + :ok + end + def init(opts) do opts end @@ -33,7 +76,6 @@ defmodule BlockScoutWeb.API.RPC.RPCTranslator do {:ok, {controller, write_actions}} <- translate_module(translations, module), {:ok, action} <- translate_action(action), true <- action_accessed?(action, write_actions), - :ok <- AccessHelper.check_rate_limit(conn), {:ok, conn} <- call_controller(conn, controller, action) do conn else @@ -53,7 +95,12 @@ defmodule BlockScoutWeb.API.RPC.RPCTranslator do {:error, error} -> APILogger.error(fn -> - ["Error while calling RPC action", inspect(error, limit: :infinity, printable_limit: :infinity)] + redacted_query_string = redact_apikey(conn.query_string) + + [ + "Error while calling RPC action #{action} in module #{module} with query string #{redacted_query_string}", + inspect(error, limit: :infinity, printable_limit: :infinity) + ] end) conn @@ -62,9 +109,6 @@ defmodule BlockScoutWeb.API.RPC.RPCTranslator do |> Controller.render(:error, error: "Something went wrong.") |> halt() - :rate_limit_reached -> - AccessHelper.handle_rate_limit_deny(conn) - {:valid_api_v1_request, false} -> conn |> put_status(404) @@ -89,6 +133,14 @@ defmodule BlockScoutWeb.API.RPC.RPCTranslator do |> halt() end + @doc """ + Redacts the API key from the query string. + """ + @spec redact_apikey(String.t()) :: String.t() + def redact_apikey(query_string) do + String.replace(query_string, ~r/apikey=[^&]*/i, "apikey=[REDACTED]") + end + @doc false @spec translate_module(map(), String.t()) :: {:ok, {module(), list(atom())}} | {:error, :no_action} defp translate_module(translations, module) do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/stats_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/stats_controller.ex index a544429c77cf..800670b4e619 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/stats_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/stats_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.StatsController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex index f01730b48763..3f80d4fe8cad 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/token_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.TokenController do use BlockScoutWeb, :controller use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]] @@ -65,8 +66,9 @@ defmodule BlockScoutWeb.API.RPC.TokenController do paging_options: 1 ] + # credo:disable-for-lines:2 Credo.Check.Design.AliasUsage bridged_tokens = - if BridgedToken.enabled?() do + if Explorer.Chain.BridgedToken.enabled?() do options = params |> paging_options() @@ -74,7 +76,8 @@ defmodule BlockScoutWeb.API.RPC.TokenController do |> Keyword.merge(tokens_sorting(params)) |> Keyword.merge(@api_true) - "" |> BridgedToken.list_top_bridged_tokens(options) + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + "" |> Explorer.Chain.BridgedToken.list_top_bridged_tokens(options) else [] end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex index 0d932e6c0741..30baabf032d0 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/rpc/transaction_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.TransactionController do use BlockScoutWeb, :controller @@ -19,7 +20,7 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do transaction_updated = if (error == "Reverted" || error == "execution reverted") && !revert_reason do - %Transaction{transaction | revert_reason: Chain.fetch_transaction_revert_reason(transaction)} + %Transaction{transaction | revert_reason: Transaction.fetch_transaction_revert_reason(transaction)} else transaction end @@ -82,7 +83,7 @@ defmodule BlockScoutWeb.API.RPC.TransactionController do end defp to_transaction_hash(transaction_hash_string) do - {:format, Chain.string_to_transaction_hash(transaction_hash_string)} + {:format, Chain.string_to_full_hash(transaction_hash_string)} end defp to_transaction_status(transaction_hash) do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex index 1c99dcb17136..e9c2dc4fbb9d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/gas_price_oracle_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V1.GasPriceOracleController do use BlockScoutWeb, :controller @@ -36,7 +37,9 @@ defmodule BlockScoutWeb.API.V1.GasPriceOracleController do end def error({:error, error}) do - Logger.error(fn -> ["Something went wrong while estimates gas prices in the gas price oracle: ", inspect(error)] end) + Logger.error(fn -> + ["Something went wrong while estimates gas prices in the gas price oracle: ", inspect(error)] + end) %{ "error_code" => 6001, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/supply_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/supply_controller.ex index 6b3cf3edd6a2..02153e0a1618 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/supply_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/supply_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V1.SupplyController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex index 6a93491f21eb..a4b23d2f6896 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v1/verified_smart_contract_controller.ex @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V1.VerifiedSmartContractController do use BlockScoutWeb, :controller - alias Explorer.Chain.Hash.Address, as: AddressHash alias Explorer.Chain.{Address, SmartContract} + alias Explorer.Chain.Hash.Address, as: AddressHash alias Explorer.SmartContract.Solidity.Publisher def create(conn, params) do @@ -16,13 +17,7 @@ defmodule BlockScoutWeb.API.V1.VerifiedSmartContractController do send_resp(conn, :created, encode(%{status: :success})) {:error, changeset} -> - errors = - changeset.errors - |> Enum.into(%{}, fn {field, {message, _}} -> - {field, message} - end) - - send_resp(conn, :unprocessable_entity, encode(errors)) + send_resp(conn, :unprocessable_entity, encode(format_changeset_errors(changeset))) end else :invalid_address -> @@ -40,6 +35,13 @@ defmodule BlockScoutWeb.API.V1.VerifiedSmartContractController do end end + defp format_changeset_errors(changeset) do + changeset.errors + |> Enum.into(%{}, fn {field, {message, _}} -> + {field, message} + end) + end + defp validate_address_hash(address_hash) do case AddressHash.cast(address_hash) do {:ok, hash} -> {:ok, hash} diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex index 5a3d58d572ea..46322431d6a6 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_badge_controller.ex @@ -1,7 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.AddressBadgeController do require Logger use BlockScoutWeb, :controller + alias BlockScoutWeb.AuthenticationHelper alias Explorer.Chain alias Explorer.Chain.Address.ScamBadgeToAddress alias Plug.Conn @@ -17,7 +19,7 @@ defmodule BlockScoutWeb.API.V2.AddressBadgeController do } = params ) when is_list(address_hashes) do - with :ok <- check_sensitive_endpoint_api_key(params["api_key"]), + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(params["api_key"]), valid_address_hashes = filter_address_hashes(address_hashes), {_num_of_inserted, badge_to_address_list} <- ScamBadgeToAddress.add(valid_address_hashes) do conn @@ -38,7 +40,7 @@ defmodule BlockScoutWeb.API.V2.AddressBadgeController do } = params ) when is_list(address_hashes) do - with :ok <- check_sensitive_endpoint_api_key(params["api_key"]), + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(params["api_key"]), valid_address_hashes = filter_address_hashes(address_hashes), {_num_of_deleted, badge_to_address_list} <- ScamBadgeToAddress.delete(valid_address_hashes) do conn @@ -55,7 +57,7 @@ defmodule BlockScoutWeb.API.V2.AddressBadgeController do def show_badge_addresses(conn, _) do with {:ok, body, _conn} <- Conn.read_body(conn, []), {:ok, %{"api_key" => provided_api_key}} <- Jason.decode(body), - :ok <- check_sensitive_endpoint_api_key(provided_api_key) do + :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(provided_api_key) do badge_to_address_list = ScamBadgeToAddress.get(@api_true) conn @@ -69,14 +71,6 @@ defmodule BlockScoutWeb.API.V2.AddressBadgeController do end end - defp check_sensitive_endpoint_api_key(provided_api_key) do - with {:sensitive_endpoints_api_key, api_key} when not is_nil(api_key) <- - {:sensitive_endpoints_api_key, Application.get_env(:block_scout_web, :sensitive_endpoints_api_key)}, - {:api_key, ^api_key} <- {:api_key, provided_api_key} do - :ok - end - end - defp filter_address_hashes(address_hashes) do address_hashes |> Enum.uniq() diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex index 51560ae4b4e8..640b82feba64 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex @@ -1,97 +1,123 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.AddressController do use BlockScoutWeb, :controller - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity], chain_type: [:explorer, :chain_type] use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ next_page_params: 3, - next_page_params: 4, + next_page_params: 5, token_transfers_next_page_params: 3, paging_options: 1, split_list_by_page: 1, current_filter: 1, paging_params_with_fiat_value: 1, - fetch_scam_token_toggle: 2 + fetch_scam_token_toggle: 2, + address_to_internal_transactions: 2 ] import BlockScoutWeb.PagingHelper, only: [ addresses_sorting: 1, - delete_parameters_from_next_page_params: 1, token_transfers_types_options: 1, address_transactions_sorting: 1, nft_types_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1, maybe_preload_ens_to_address: 1] + import Explorer.Helper, only: [safe_parse_non_negative_integer: 1] + + import Explorer.MicroserviceInterfaces.BENS, + only: [ + maybe_preload_ens: 1, + maybe_preload_ens_for_token_transfers: 1, + maybe_preload_ens_for_transactions: 1, + maybe_preload_ens_to_address: 1 + ] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] alias BlockScoutWeb.AccessHelper - alias BlockScoutWeb.API.V2.{BlockView, TransactionView, WithdrawalView} - alias Explorer.{Chain, Market} - alias Explorer.Chain.{Address, Hash, Transaction} - alias Explorer.Chain.Address.Counters + alias BlockScoutWeb.API.V2.{ + BlockView, + Ethereum.DepositController, + Ethereum.DepositView, + TransactionView, + WithdrawalView + } + + alias BlockScoutWeb.Schemas.Helper, as: SchemasHelper + alias Explorer.{Chain, Market, PagingOptions} + alias Explorer.Chain.{Address, Beacon.Deposit, Block, Hash, Token, Transaction} + alias Explorer.Chain.Address.{CoinBalance, Counters} + + alias Explorer.Chain.Token.FiatValue alias Explorer.Chain.Token.Instance alias Explorer.SmartContract.Helper, as: SmartContractHelper alias BlockScoutWeb.API.V2.CeloView alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward - alias Explorer.Chain.Celo.Reader, as: CeloReader alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Indexer.Fetcher.OnDemand.ContractCode, as: ContractCodeOnDemand alias Indexer.Fetcher.OnDemand.TokenBalance, as: TokenBalanceOnDemand - case @chain_type do - :celo -> + case @chain_identity do + {:optimism, :celo} -> @chain_type_transaction_necessity_by_association %{ - :gas_token => :optional + [gas_token: reputation_association()] => :optional } _ -> @chain_type_transaction_necessity_by_association %{} end - @transaction_necessity_by_association [ - necessity_by_association: - %{ - [created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - :block => :optional - } - |> Map.merge(@chain_type_transaction_necessity_by_association), - api?: true - ] - @token_transfer_necessity_by_association [ necessity_by_association: %{ [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, :block => :optional, :transaction => :optional, - :token => :optional + [token: reputation_association()] => :optional }, api?: true ] + case @chain_identity do + {:optimism, :celo} -> + @chain_type_address_necessity_by_association %{ + [ + celo_account: [ + :vote_signer_address, + :validator_signer_address, + :attestation_signer_address + ] + ] => :optional + } + + _ -> + @chain_type_address_necessity_by_association %{} + end + @address_options [ - necessity_by_association: %{ - :names => :optional, - :scam_badge => :optional, - :token => :optional, - :signed_authorization => :optional, - :smart_contract => :optional - }, + necessity_by_association: + %{ + :names => :optional, + :scam_badge => :optional, + :signed_authorization => :optional, + :smart_contract => :optional, + [token: reputation_association()] => :optional + } + |> Map.merge(@chain_type_address_necessity_by_association), api?: true ] @nft_necessity_by_association [ necessity_by_association: %{ - :token => :optional + [token: reputation_association()] => :optional } ] @@ -112,17 +138,24 @@ defmodule BlockScoutWeb.API.V2.AddressController do :smart_contract, proxy_implementations_association() ] - ] => :optional + ] => :optional, + [epoch: [:end_processing_block]] => :optional }, api?: true ] + @token_preload_options [ + necessity_by_association: %{ + [token: reputation_association()] => :optional + } + ] + @spec contract_address_preloads() :: [keyword()] defp contract_address_preloads do chain_type_associations = case chain_type() do - :filecoin -> Address.contract_creation_transaction_with_from_address_associations() - _ -> Address.contract_creation_transaction_associations() + :filecoin -> [Address.contract_creation_transaction_with_from_address_association()] + _ -> [Address.contract_creation_transaction_association()] end [:smart_contract | chain_type_associations] @@ -130,18 +163,34 @@ defmodule BlockScoutWeb.API.V2.AddressController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags ["addresses"] + + operation :address, + summary: "Retrieve detailed information about a specific address or contract", + description: + "Retrieves detailed information for a specific address, including balance, transaction count, and metadata.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Detailed information about the specified address.", "application/json", Schemas.Address.Response}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/addresses/:address_hash_param` endpoint. Returns 200 on any valid address_hash, even if the address is not found in the database. """ @spec address(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def address(conn, %{"address_hash_param" => address_hash_string} = params) do + def address(conn, %{address_hash_param: address_hash_string} = params) do ip = AccessHelper.conn_to_ip_string(conn) with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, address} -> - fully_preloaded_address = + %Address{} = + fully_preloaded_address = Address.maybe_preload_smart_contract_associations(address, contract_address_preloads(), @api_true) implementations = SmartContractHelper.pre_fetch_implementations(fully_preloaded_address) @@ -179,6 +228,17 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :counters, + summary: "Get activity count stats for a specific address", + description: + "Retrieves count statistics for an address, including transactions, token transfers, gas usage, and validations.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Count statistics for the specified address.", "application/json", Schemas.Address.Counters}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/counters` endpoint. @@ -192,8 +252,9 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the operation is successful. """ @spec counters(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def counters(conn, %{"address_hash_param" => address_hash_string} = params) do + def counters(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do + # TODO: check if @address_options is needed here case Chain.hash_to_address(address_hash, @address_options) do {:ok, address} -> {validation_count} = Counters.address_counters(address, @api_true) @@ -220,6 +281,24 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + if @chain_type == :zilliqa do + @token_balances_operation_description "Retrieves all token balances held by a specific address, including ERC-20, ERC-721, ERC-1155, ERC-404, and ZRC-2 tokens." + else + @token_balances_operation_description "Retrieves all token balances held by a specific address, including ERC-20, ERC-721, ERC-1155, and ERC-404 tokens." + end + + operation :token_balances, + summary: "List all token balances held by a specific address", + description: @token_balances_operation_description, + parameters: [address_hash_param() | base_params()], + responses: [ + ok: + {"All token balances for the specified address.", "application/json", + %Schema{type: :array, items: Schemas.Address.TokenBalance}}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/token-balances` endpoint (retrieves the token balances for a given address) @@ -235,7 +314,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec token_balances(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def token_balances(conn, %{"address_hash_param" => address_hash_string} = params) do + def token_balances(conn, %{address_hash_param: address_hash_string} = params) do ip = AccessHelper.conn_to_ip_string(conn) with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do @@ -243,7 +322,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do {:ok, _address} -> token_balances = address_hash - |> Chain.fetch_last_token_balances(@api_true |> fetch_scam_token_toggle(conn)) + |> Chain.fetch_last_token_balances( + @api_true + |> fetch_scam_token_toggle(conn) + |> Keyword.merge(@token_preload_options) + ) TokenBalanceOnDemand.trigger_fetch(ip, address_hash) @@ -259,6 +342,46 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :transactions, + summary: "List transactions involving a specific address with to-from filtering", + description: + "Retrieves transactions involving a specific address, with optional filtering for transactions sent from or to the address.", + parameters: + base_params() ++ + [ + address_hash_param(), + direction_filter_param(), + sort_param(["block_number", "value", "fee"]), + order_param() + ] ++ + define_paging_params([ + "block_number_nullable", + "index_nullable", + "inserted_at", + "hash", + "value", + "fee", + "items_count" + ]), + responses: [ + ok: + {"All transactions for the specified address.", "application/json", + paginated_response( + items: Schemas.Transaction, + next_page_params_example: %{ + "block_number" => 22_566_361, + "fee" => "19206937428000", + "hash" => "0xe38d616dade747097354b0731b5560f581536dacf22121feb4bb4a0b776018aa", + "index" => 103, + "inserted_at" => "2025-05-26T10:26:51.474448Z", + "items_count" => 50, + "value" => "24741049597737" + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/transactions` endpoint (retrieves transactions for a given address) @@ -274,12 +397,13 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec transactions(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def transactions(conn, %{"address_hash_param" => address_hash_string} = params) do + def transactions(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> options = - @transaction_necessity_by_association + [necessity_by_association: address_transactions_necessity_by_association()] + |> Keyword.merge(@api_true) |> Keyword.merge(paging_options(params)) |> Keyword.merge(current_filter(params)) |> Keyword.merge(address_transactions_sorting(params)) @@ -291,7 +415,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page |> next_page_params( transactions, - delete_parameters_from_next_page_params(params), + params, + false, &Transaction.address_transactions_next_page_params/1 ) @@ -299,7 +424,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params }) @@ -315,6 +440,65 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + defp address_transactions_necessity_by_association do + %{ + [ + created_contract_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + [ + from_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + [ + to_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + :block => :optional + } + |> Map.merge(@chain_type_transaction_necessity_by_association) + end + + operation :token_transfers, + summary: "List token transfers involving a specific address with filtering options", + description: + "Retrieves token transfers involving a specific address, with optional filtering by token type, direction, and specific token.", + parameters: + base_params() ++ + [address_hash_param(), direction_filter_param(), token_type_param(), token_filter_param()] ++ + define_paging_params([ + "block_number", + "index", + "items_count", + "batch_log_index", + "batch_block_hash", + "batch_transaction_hash", + "index_in_batch" + ]), + responses: [ + ok: + {"All token transfers for the specified address.", "application/json", + paginated_response( + items: Schemas.TokenTransfer, + next_page_params_example: %{ + "block_number" => 12_345_678, + "index" => 0, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/token-transfers` endpoint (retrieves token transfers for a given address) @@ -335,10 +519,10 @@ defmodule BlockScoutWeb.API.V2.AddressController do | {:not_found, {:error, :not_found}} | {:restricted_access, true} | Plug.Conn.t() - def token_transfers(conn, %{"address_hash_param" => address_hash_string} = params) do + def token_transfers(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params), - {:ok, token_address_hash} <- validate_optional_address_hash(params["token"], params), - token_address_exists <- (token_address_hash && Chain.check_token_exists(token_address_hash)) || :ok do + {:ok, token_address_hash} <- validate_optional_address_hash(params[:token], params), + token_address_exists <- (token_address_hash && Token.check_token_exists(token_address_hash)) || :ok do case {Chain.hash_to_address(address_hash, @address_options), token_address_exists} do {{:ok, _address}, :ok} -> paging_options = paging_options(params) @@ -361,14 +545,17 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page_params = next_page - |> token_transfers_next_page_params(token_transfers, delete_parameters_from_next_page_params(params)) + |> token_transfers_next_page_params(token_transfers, params) conn |> put_status(200) |> put_view(TransactionView) |> render(:token_transfers, %{ token_transfers: - token_transfers |> Instance.preload_nft(@api_true) |> maybe_preload_ens() |> maybe_preload_metadata(), + token_transfers + |> Instance.preload_nft(@api_true) + |> maybe_preload_ens_for_token_transfers() + |> maybe_preload_metadata(), next_page_params: next_page_params }) @@ -384,6 +571,30 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :internal_transactions, + summary: "List all internal transactions involving a specific address", + description: + "Retrieves all internal transactions involving a specific address, with optional filtering for internal transactions sent from or to the address.", + parameters: + base_params() ++ + [address_hash_param(), direction_filter_param()] ++ + define_paging_params(["block_number", "index", "items_count", "transaction_index"]), + responses: [ + ok: + {"All internal transactions for the specified address.", "application/json", + paginated_response( + items: Schemas.InternalTransaction, + next_page_params_example: %{ + "block_number" => 22_530_770, + "index" => 8, + "items_count" => 50, + "transaction_index" => 8 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/internal-transactions` endpoint (retrieves internal transactions for a given address) @@ -399,29 +610,27 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec internal_transactions(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def internal_transactions(conn, %{"address_hash_param" => address_hash_string} = params) do + def internal_transactions(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> full_options = [ - necessity_by_association: %{ - [created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional - } + address_preloads: [ + created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] ] |> Keyword.merge(paging_options(params)) |> Keyword.merge(current_filter(params)) |> Keyword.merge(@api_true) - results_plus_one = Chain.address_to_internal_transactions(address_hash, full_options) + results_plus_one = address_to_internal_transactions(address_hash, full_options) {internal_transactions, next_page} = split_list_by_page(results_plus_one) next_page_params = - next_page |> next_page_params(internal_transactions, delete_parameters_from_next_page_params(params)) + next_page |> next_page_params(internal_transactions, params) conn |> put_status(200) @@ -443,6 +652,23 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :logs, + summary: "List event logs emitted by or involving a specific address", + description: "Retrieves event logs emitted by or involving a specific address.", + parameters: + base_params() ++ + [address_hash_param(), topic_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"Event logs for the specified address, with pagination.", "application/json", + paginated_response( + items: Schemas.Log, + next_page_params_example: %{"block_number" => 22_546_398, "index" => 268, "items_count" => 50} + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/logs` endpoint (retrieves logs for a given address) @@ -458,31 +684,28 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec logs(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def logs(conn, %{"address_hash_param" => address_hash_string, "topic" => topic} = params) do - with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do + def logs(conn, %{address_hash_param: address_hash_string} = params) do + with {:ok, address_hash} <- validate_address_hash(address_hash_string, params), + {:ok, topic} <- validate_optional_topic(params[:topic]) do + case Chain.hash_to_address(address_hash, @api_true) do {:ok, _address} -> - prepared_topic = String.trim(topic) - - formatted_topic = - if String.starts_with?(prepared_topic, "0x"), do: prepared_topic, else: "0x" <> prepared_topic - options = params |> paging_options() - |> Keyword.merge(topic: formatted_topic) |> Keyword.merge( necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_association()]] => :optional + [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional, + :block => :optional } ) |> Keyword.merge(@api_true) + |> Keyword.put(:topic, topic) results_plus_one = Chain.address_to_logs(address_hash, false, options) {logs, next_page} = split_list_by_page(results_plus_one) - next_page_params = next_page |> next_page_params(logs, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(logs, params) conn |> put_status(200) @@ -504,37 +727,21 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end - def logs(conn, %{"address_hash_param" => address_hash_string} = params) do - with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> - options = params |> paging_options() |> Keyword.merge(@api_true) - - results_plus_one = Chain.address_to_logs(address_hash, false, options) - - {logs, next_page} = split_list_by_page(results_plus_one) - - next_page_params = next_page |> next_page_params(logs, delete_parameters_from_next_page_params(params)) - - conn - |> put_status(200) - |> put_view(TransactionView) - |> render(:logs, %{ - logs: logs |> maybe_preload_ens() |> maybe_preload_metadata(), - next_page_params: next_page_params - }) - - _ -> - conn - |> put_status(200) - |> put_view(TransactionView) - |> render(:logs, %{ - logs: [], - next_page_params: nil - }) - end - end - end + operation :blocks_validated, + summary: "List blocks validated (mined) by a specific validator/miner address", + description: + "Retrieves blocks that were validated (mined) by a specific address. Useful for tracking validator/miner performance.", + parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["block_number", "items_count"]), + responses: [ + ok: + {"Blocks validated by the specified address, with pagination.", "application/json", + paginated_response( + items: Schemas.Block, + next_page_params_example: %{"block_number" => 22_546_398, "items_count" => 50} + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/blocks-validated` endpoint (retrieves validated by a given address blocks) @@ -551,7 +758,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec blocks_validated(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def blocks_validated(conn, %{"address_hash_param" => address_hash_string} = params) do + def blocks_validated(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> @@ -568,10 +775,10 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) - results_plus_one = Chain.get_blocks_validated_by_address(full_options, address_hash) + results_plus_one = Block.get_blocks_validated_by_address(full_options, address_hash) {blocks, next_page} = split_list_by_page(results_plus_one) - next_page_params = next_page |> next_page_params(blocks, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(blocks, params) conn |> put_status(200) @@ -587,6 +794,22 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :coin_balance_history, + summary: "Get native coin balance history for an address showing all balance changes", + description: + "Retrieves historical native coin balance changes for a specific address, tracking how an address's balance has changed over time.", + parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["block_number", "items_count"]), + responses: [ + ok: + {"Historical coin balance changes for the specified address, with pagination.", "application/json", + paginated_response( + items: Schemas.CoinBalance, + next_page_params_example: %{"block_number" => 22_546_398, "items_count" => 50} + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/coin-balance-history` endpoint (retrieves coin balance history for given address) @@ -602,18 +825,18 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec coin_balance_history(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def coin_balance_history(conn, %{"address_hash_param" => address_hash_string} = params) do + def coin_balance_history(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, address} -> full_options = params |> paging_options() |> Keyword.merge(@api_true) - results_plus_one = Chain.address_to_coin_balances(address, full_options) + results_plus_one = CoinBalance.address_to_coin_balances(address, full_options) {coin_balances, next_page} = split_list_by_page(results_plus_one) next_page_params = - next_page |> next_page_params(coin_balances, delete_parameters_from_next_page_params(params)) + next_page |> next_page_params(coin_balances, params) conn |> put_status(200) @@ -627,6 +850,27 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :coin_balance_history_by_day, + summary: "Get daily native coin balance snapshots for an address from previous 10 days", + description: + "Retrieves daily snapshots of native coin balance for a specific address. Useful for generating balance-over-time charts.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: + {"Daily coin balance history for the specified address.", "application/json", + %Schema{ + type: :object, + properties: %{ + days: %Schema{type: :integer, nullable: false}, + items: %Schema{type: :array, items: Schemas.CoinBalanceByDay} + }, + nullable: false, + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/coin-balance-history-by-day` endpoint (retrieves coin balance history by day for given address) @@ -643,7 +887,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do """ @spec coin_balance_history_by_day(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def coin_balance_history_by_day(conn, %{"address_hash_param" => address_hash_string} = params) do + def coin_balance_history_by_day(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> @@ -663,6 +907,30 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :tokens, + summary: "List token balances for an address with pagination and type filtering", + description: + "Retrieves token balances for a specific address with pagination and filtering by token type. Useful for displaying large token portfolios.", + parameters: + base_params() ++ + [address_hash_param(), token_type_param()] ++ + define_paging_params(["fiat_value_nullable", "id", "items_count", "value"]), + responses: [ + ok: + {"Token balances for the specified address with pagination.", "application/json", + paginated_response( + items: Schemas.Address.TokenBalance, + next_page_params_example: %{ + "fiat_value" => nil, + "id" => 12_519_063_346, + "items_count" => 50, + "value" => "3750000000000000000000" + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/tokens` endpoint (retrieves token balances for given address) @@ -678,7 +946,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec tokens(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def tokens(conn, %{"address_hash_param" => address_hash_string} = params) do + def tokens(conn, %{address_hash_param: address_hash_string} = params) do ip = AccessHelper.conn_to_ip_string(conn) with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do @@ -691,6 +959,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> paging_options() |> Keyword.merge(token_transfers_types_options(params)) |> Keyword.merge(@api_true) + |> Keyword.merge(@token_preload_options) |> fetch_scam_token_toggle(conn) ) @@ -702,7 +971,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page |> next_page_params( tokens, - delete_parameters_from_next_page_params(params), + params, + false, &paging_params_with_fiat_value/1 ) @@ -718,6 +988,23 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :withdrawals, + summary: "List validator withdrawals involving a specific address", + description: + "Retrieves withdrawals involving a specific address, typically for proof-of-stake networks supporting validator withdrawals.", + parameters: base_params() ++ [address_hash_param()] ++ define_paging_params(["index", "items_count"]), + responses: [ + ok: + {"Withdrawals for the specified address, with pagination. Note that receiver field is not included in this endpoint.", + "application/json", + paginated_response( + items: Schemas.Withdrawal, + next_page_params_example: %{"index" => 88_192_653, "items_count" => 50} + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/withdrawals` endpoint (retrieves withdrawals for given address) @@ -733,7 +1020,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec withdrawals(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def withdrawals(conn, %{"address_hash_param" => address_hash_string} = params) do + def withdrawals(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> @@ -741,7 +1028,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do withdrawals_plus_one = address_hash |> Chain.address_hash_to_withdrawals(options) {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) - next_page_params = next_page |> next_page_params(withdrawals, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(withdrawals, params) conn |> put_status(200) @@ -763,6 +1050,35 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :addresses_list, + summary: "List addresses holding native coins sorted by balance - top accounts", + description: "Retrieves a paginated list of addresses holding the native coin, sorted by balance.", + parameters: + base_params() ++ + [sort_param(["balance", "transactions_count"]), order_param()] ++ + define_paging_params(["fetched_coin_balance", "address_hash", "items_count", "transactions_count"]), + responses: [ + ok: + {"List of native coin holders with their balances, with pagination.", "application/json", + SchemasHelper.extend_schema( + paginated_response( + items: Schemas.Address.TopAddress, + next_page_params_example: %{ + "fetched_coin_balance" => "124355417998347240251800", + "hash" => "0x59708733fbbf64378d9293ec56b977c011a08fd2", + "items_count" => 50, + "transactions_count" => nil + } + ), + properties: %{ + exchange_rate: Schemas.General.FloatStringNullable, + total_supply: Schemas.General.FloatStringNullable + }, + required: [:exchange_rate, :total_supply] + )}, + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses` endpoint (retrieves addresses list) @@ -785,7 +1101,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> Address.list_top_addresses() |> split_list_by_page() - next_page_params = next_page_params(next_page, addresses, delete_parameters_from_next_page_params(params)) + next_page_params = next_page_params(next_page, addresses, params) exchange_rate = Market.get_coin_exchange_rate() total_supply = Chain.total_supply() @@ -800,6 +1116,16 @@ defmodule BlockScoutWeb.API.V2.AddressController do }) end + operation :tabs_counters, + summary: "Get counters for address tabs", + description: "Retrieves counters for various address-related entities (max counter value is 51).", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Counters for address tabs.", "application/json", Schemas.Address.TabsCounters}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ Handles GET requests to `/api/v2/addresses/:address_hash_param/tabs-counters` endpoint (retrieves counter for each entity (max counter value is 51) for given address) @@ -815,7 +1141,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec tabs_counters(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def tabs_counters(conn, %{"address_hash_param" => address_hash_string} = params) do + def tabs_counters(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do counter_name_to_json_field_name = %{ validations: :validations_count, @@ -825,7 +1151,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do logs: :logs_count, withdrawals: :withdrawals_count, internal_transactions: :internal_transactions_count, - celo_election_rewards: :celo_election_rewards_count + celo_election_rewards: :celo_election_rewards_count, + beacon_deposits: :beacon_deposits_count } case Chain.hash_to_address(address_hash, @address_options) do @@ -864,8 +1191,32 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :nft_list, + summary: "List NFTs owned by a specific address with optional type filtering", + description: + "Retrieves a list of NFTs (non-fungible tokens) owned by a specific address, with optional filtering by token type.", + parameters: + base_params() ++ + [address_hash_param(), nft_token_type_param()] ++ + define_paging_params(["items_count", "token_contract_address_hash", "token_id", "token_type"]), + responses: [ + ok: + {"NFTs owned by the specified address, with pagination.", "application/json", + paginated_response( + items: Schemas.TokenInstanceInList, + next_page_params_example: %{ + "items_count" => 50, + "token_contract_address_hash" => "0x1ffe11b9fb7f6ff1b153ab8608cf403ecaf9d44a", + "token_id" => "24950", + "token_type" => "ERC-721" + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ - Handles GET requests to `/api/v2/addresses/:address_hash_param/nft-list` endpoint (retrieves NFTs for given address) + Handles GET requests to `/api/v2/addresses/:address_hash_param/nft` endpoint (retrieves NFTs for given address) ## Parameters @@ -879,7 +1230,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec nft_list(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def nft_list(conn, %{"address_hash_param" => address_hash_string} = params) do + def nft_list(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> @@ -900,7 +1251,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page |> next_page_params( nfts, - delete_parameters_from_next_page_params(params), + params, + false, &Instance.nft_list_next_page_params/1 ) @@ -916,8 +1268,31 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :nft_collections, + summary: "List NFTs owned by an address grouped by collection/project", + description: + "Retrieves NFTs owned by a specific address, organized by collection. Useful for displaying an address's NFT portfolio grouped by project.", + parameters: + base_params() ++ + [address_hash_param(), nft_token_type_param()] ++ + define_paging_params(["items_count", "token_contract_address_hash", "token_type"]), + responses: [ + ok: + {"NFTs owned by the specified address, grouped by collection, with pagination.", "application/json", + paginated_response( + items: Schemas.NFTCollection, + next_page_params_example: %{ + "items_count" => 50, + "token_contract_address_hash" => "0x1ffe11b9fb7f6ff1b153ab8608cf403ecaf9d44a", + "token_type" => "ERC-721" + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ - Handles GET requests to `/api/v2/addresses/:address_hash_param/nft-collections` endpoint (retrieves NFTs grouped by collections for given address) + Handles GET requests to `/api/v2/addresses/:address_hash_param/nft/collections` endpoint (retrieves NFTs grouped by collections for given address) ## Parameters @@ -931,7 +1306,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do - `Plug.Conn.t()` if the request is successful. """ @spec nft_collections(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def nft_collections(conn, %{"address_hash_param" => address_hash_string} = params) do + def nft_collections(conn, %{address_hash_param: address_hash_string} = params) do with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do case Chain.hash_to_address(address_hash, @address_options) do {:ok, _address} -> @@ -952,7 +1327,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do next_page |> next_page_params( collections, - delete_parameters_from_next_page_params(params), + params, + false, &Instance.nft_collections_next_page_params/1 ) @@ -968,47 +1344,215 @@ defmodule BlockScoutWeb.API.V2.AddressController do end end + operation :celo_election_rewards, + summary: "List Celo election rewards for a specific address", + description: "Retrieves Celo election rewards for a specific address.", + parameters: + base_params() ++ + [address_hash_param()] ++ + define_paging_params(["items_count", "epoch_number", "amount", "associated_account_address_hash", "type"]), + responses: [ + ok: + {"Celo election rewards for the specified address.", "application/json", + paginated_response( + items: Schemas.Celo.ElectionReward, + next_page_params_example: %{ + "epoch_number" => 100, + "amount" => "1000000000000000000", + "associated_account_address_hash" => "0x1234567890123456789012345678901234567890", + "type" => "validator", + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/addresses/:address_hash_param/election-rewards` endpoint. + Handles GET requests to `/api/v2/addresses/:address_hash_param/election-rewards` endpoint. """ @spec celo_election_rewards(Plug.Conn.t(), map()) :: {:format, :error} | {:restricted_access, true} | Plug.Conn.t() - def celo_election_rewards(conn, %{"address_hash_param" => address_hash_string} = params) do - with {:ok, address_hash} <- validate_address_hash(address_hash_string, params) do - case Chain.hash_to_address(address_hash, @address_options) do - {:ok, _address} -> - full_options = - @celo_election_rewards_options - |> Keyword.merge(CeloElectionReward.address_paging_options(params)) + def celo_election_rewards(conn, %{address_hash_param: address_hash_string} = params) do + with {:ok, address_hash} <- validate_address_hash(address_hash_string, params), + {:ok, _address} <- Chain.hash_to_address(address_hash, api?: true) do + full_options = + @celo_election_rewards_options + |> Keyword.put( + :paging_options, + celo_election_rewards_paging_options(params) + ) + + results_plus_one = CeloElectionReward.address_hash_to_rewards(address_hash, full_options) + + {rewards, next_page} = split_list_by_page(results_plus_one) + + filtered_params = + params + |> Map.drop([ + "epoch_number", + "amount", + "associated_account_address_hash", + "type" + ]) + + next_page_params = + next_page_params( + next_page, + rewards, + filtered_params, + false, + &%{ + epoch_number: &1.epoch_number, + amount: &1.amount, + associated_account_address_hash: &1.associated_account_address_hash, + type: &1.type + } + ) + + conn + |> put_status(200) + |> put_view(CeloView) + |> render(:celo_address_election_rewards, %{ + rewards: rewards, + next_page_params: next_page_params + }) + end + end + + @spec celo_election_rewards_paging_options(map()) :: PagingOptions.t() + defp celo_election_rewards_paging_options(params) do + with {:ok, epoch_number_string} <- fetch_key(params, ["epoch_number", :epoch_number]), + {:ok, amount_string} <- fetch_key(params, ["amount", :amount]), + {:ok, associated_account_address_hash_string} <- + fetch_key(params, ["associated_account_address_hash", :associated_account_address_hash]), + {:ok, type_string} <- fetch_key(params, ["type", :type]), + {:ok, epoch_number} <- parse_non_negative_integer_string(epoch_number_string), + {:ok, amount} <- FiatValue.cast(amount_string), + {:ok, associated_account_address_hash} <- Hash.Address.cast(associated_account_address_hash_string), + {:ok, type} <- parse_celo_reward_type_string(type_string) do + %{ + PagingOptions.default_paging_options() + | key: %{ + epoch_number: epoch_number, + amount: amount, + associated_account_address_hash: associated_account_address_hash, + type: type + } + } + else + _ -> + PagingOptions.default_paging_options() + end + end - results_plus_one = CeloReader.address_hash_to_election_rewards(address_hash, full_options) + defp fetch_key(map, keys) when is_list(keys) do + Enum.find_value(keys, :error, fn key -> + case Map.fetch(map, key) do + {:ok, value} -> {:ok, value} + :error -> nil + end + end) + end - {rewards, next_page} = split_list_by_page(results_plus_one) + @spec parse_non_negative_integer_string(String.t()) :: {:ok, non_neg_integer()} | :error + defp parse_non_negative_integer_string(value) when is_binary(value), + do: safe_parse_non_negative_integer(value) - next_page_params = - next_page_params( - next_page, - rewards, - delete_parameters_from_next_page_params(params), - &CeloElectionReward.to_address_paging_params/1 - ) + defp parse_non_negative_integer_string(_), do: :error - conn - |> put_status(200) - |> put_view(CeloView) - |> render(:celo_election_rewards, %{ - rewards: rewards, - next_page_params: next_page_params - }) + @spec parse_celo_reward_type_string(String.t() | atom()) :: {:ok, CeloElectionReward.type()} | :error + defp parse_celo_reward_type_string(value) when is_atom(value) do + if value in CeloElectionReward.types(), do: {:ok, value}, else: :error + end - _ -> - conn - |> put_status(200) - |> put_view(CeloView) - |> render(:celo_election_rewards, %{ - rewards: [], - next_page_params: nil - }) - end + defp parse_celo_reward_type_string(value) when is_binary(value) do + value + |> String.trim() + |> String.downcase() + |> String.replace("-", "_") + |> CeloElectionReward.type_from_string() + end + + defp parse_celo_reward_type_string(_), do: :error + + operation :beacon_deposits, + summary: "List Beacon Deposits for a specific address", + description: "Retrieves Beacon deposits for a specific address.", + parameters: + base_params() ++ + [address_hash_param()] ++ + define_paging_params(["deposit_index", "items_count"]), + responses: [ + ok: + {"Beacon deposits for the specified address.", "application/json", + paginated_response( + items: Schemas.Beacon.Deposit, + next_page_params_example: %{ + "index" => 123, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + + @doc """ + Handles `api/v2/addresses/:address_hash_param/beacon/deposits` endpoint. + Fetches beacon deposits for a given address with pagination support. + + This endpoint retrieves all beacon deposits originating from the specified + address. The results include preloaded associations for both the from_address + and withdrawal_address, including scam badges, names, smart contracts, and + proxy implementations. The response is paginated and may include ENS and + metadata enrichment if those services are enabled. + + ## Parameters + - `conn`: The Plug connection. + - `params`: A map containing: + - `address_hash_param`: The address hash string to fetch deposits for. + - Optional pagination parameter: + - `index`: non-negative integer, the starting index for pagination. + + ## Returns + - `{:format, :error}` - If the address hash format is invalid. + - `{:restricted_access, true}` - If the address is restricted from access. + - `Plug.Conn.t()` - A 200 response with rendered deposits and pagination + information when successful. + """ + @spec beacon_deposits(Plug.Conn.t(), map()) :: + {:format, :error} | {:restricted_access, true} | Plug.Conn.t() + def beacon_deposits(conn, %{address_hash_param: address_hash_param} = params) do + with {:ok, address_hash} <- validate_address_hash(address_hash_param, params) do + full_options = + [ + necessity_by_association: %{ + [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [withdrawal_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => + :optional + }, + api?: true + ] + |> Keyword.merge(DepositController.paging_options(params)) + + deposit_plus_one = Deposit.from_address_hash(address_hash, full_options) + {deposits, next_page} = split_list_by_page(deposit_plus_one) + + next_page_params = + next_page + |> next_page_params( + deposits, + params, + false, + DepositController.paging_function() + ) + + conn + |> put_status(200) + |> put_view(DepositView) + |> render(:deposits, %{ + deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + next_page_params: next_page_params + }) end end @@ -1043,4 +1587,25 @@ defmodule BlockScoutWeb.API.V2.AddressController do validate_address_hash(address_hash_string, params) end end + + @spec validate_optional_topic(nil | String.t()) :: {:ok, nil | Hash.Full.t()} | {:format, :error} + defp validate_optional_topic(topic) do + topic = if is_binary(topic), do: String.trim(topic), else: topic + + case topic do + nil -> + {:ok, nil} + + "" -> + {:ok, nil} + + "null" -> + {:ok, nil} + + _ -> + with {:format, {:ok, topic}} <- {:format, Chain.string_to_full_hash(topic)} do + {:ok, topic} + end + end + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex index 95b349fc5490..c1e50df7fa1e 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/advanced_filter_controller.ex @@ -1,19 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.AdvancedFilterController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - import BlockScoutWeb.Chain, only: [split_list_by_page: 1, next_page_params: 4, fetch_scam_token_toggle: 2] + import BlockScoutWeb.Chain, only: [split_list_by_page: 1, next_page_params: 5, fetch_scam_token_toggle: 2] import Explorer.PagingOptions, only: [default_paging_options: 0] - alias BlockScoutWeb.CaptchaHelper - alias BlockScoutWeb.API.V2.{AdvancedFilterView, CsvExportController} + alias BlockScoutWeb.AccessHelper + alias BlockScoutWeb.API.V2.CsvExportController + alias BlockScoutWeb.Schemas.API.V2.General alias Explorer.{Chain, PagingOptions} - alias Explorer.Chain.{AdvancedFilter, ContractMethod, Data, Token, Transaction} + alias Explorer.Chain.{Address.Reputation, AdvancedFilter, ContractMethod, Data, Token, Transaction} + alias Explorer.Chain.CsvExport.AdvancedFilter, as: CsvExportAdvancedFilter + alias Explorer.Chain.CsvExport.AsyncHelper, as: AsyncCsvHelper alias Explorer.Chain.CsvExport.Helper, as: CsvHelper - alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Chain.CsvExport.Request, as: AsyncCsvExportRequest alias Plug.Conn + require Logger + action_fallback(BlockScoutWeb.API.V2.FallbackController) + # Once CastAndValidate is installed, every new action in this controller must + # declare either a real `operation …` spec or `operation :name, false`; + # otherwise the plug rejects requests to it. + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["advanced-filters"]) + @api_true [api?: true] @methods [ @@ -48,6 +63,223 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do @methods_filter_limit 20 @tokens_filter_limit 20 + @token_options [api?: true, necessity_by_association: %{Reputation.reputation_association() => :optional}] + + @comma_separated_address_hashes_example "0x5a52e96bacdabb82fd05763e25335261b270efcb,0x00000000219ab540356cbb839cbe05303d7705fa" + + @advanced_filter_query_params [ + %OpenApiSpex.Parameter{ + name: :transaction_types, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: + "Comma-separated list of transaction types to include. Allowed values: `COIN_TRANSFER`, " <> + "`CONTRACT_INTERACTION`, `CONTRACT_CREATION`, `ERC-20`, `ERC-404`, `ERC-721`, `ERC-1155`, `ERC-7984` " <> + "(plus `ZRC-2` on Zilliqa). Values are matched case-insensitively; unknown entries are silently dropped.", + example: "COIN_TRANSFER,ERC-20" + }, + %OpenApiSpex.Parameter{ + name: :methods, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: + "Comma-separated list of 4-byte contract method selectors (lowercase, `0x`-prefixed). At most 20 unique " <> + "entries are honored; invalid entries are dropped.", + example: "0xa9059cbb,0x095ea7b3" + }, + %OpenApiSpex.Parameter{ + name: :age_from, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Inclusive lower bound on `timestamp` (ISO 8601).", + example: "2024-01-01T00:00:00Z" + }, + %OpenApiSpex.Parameter{ + name: :age_to, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Inclusive upper bound on `timestamp` (ISO 8601).", + example: "2024-12-31T23:59:59Z" + }, + %OpenApiSpex.Parameter{ + name: :from_address_hashes_to_include, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of sender address hashes to include.", + example: @comma_separated_address_hashes_example + }, + %OpenApiSpex.Parameter{ + name: :from_address_hashes_to_exclude, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of sender address hashes to exclude.", + example: @comma_separated_address_hashes_example + }, + %OpenApiSpex.Parameter{ + name: :to_address_hashes_to_include, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of recipient address hashes to include.", + example: @comma_separated_address_hashes_example + }, + %OpenApiSpex.Parameter{ + name: :to_address_hashes_to_exclude, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of recipient address hashes to exclude.", + example: @comma_separated_address_hashes_example + }, + %OpenApiSpex.Parameter{ + name: :address_relation, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: + "How to combine the `from_address_hashes_*` and `to_address_hashes_*` filters. " <> + "Accepts `or` or `and` (case-insensitive). `or` (default) matches an item if either side matches; " <> + "`and` requires both sides to match. Any other value is silently coerced to `nil` (no relation constraint).", + example: "and" + }, + %OpenApiSpex.Parameter{ + name: :amount_from, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Inclusive lower bound on the item's transferred amount (decimal string in the token's base units).", + example: "0" + }, + %OpenApiSpex.Parameter{ + name: :amount_to, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Inclusive upper bound on the item's transferred amount (decimal string in the token's base units).", + example: "1000000" + }, + %OpenApiSpex.Parameter{ + name: :token_contract_address_hashes_to_include, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: + "Comma-separated list of token contract address hashes to include. Use the literal `native` to also " <> + "include native coin transfers. Each list (include and exclude) is capped to 20 entries separately.", + example: "native,0xdac17f958d2ee523a2206206994597c13d831ec7" + }, + %OpenApiSpex.Parameter{ + name: :token_contract_address_hashes_to_exclude, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: + "Comma-separated list of token contract address hashes to exclude. Use the literal `native` to also " <> + "exclude native coin transfers. Each list (include and exclude) is capped to 20 entries separately.", + example: "0x0000000000000000000000000000000000000000" + }, + %OpenApiSpex.Parameter{ + name: :methods_names, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of human-readable method names corresponding to the `methods` selectors.", + example: "transfer,approve" + }, + %OpenApiSpex.Parameter{ + name: :token_contract_symbols_to_include, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of token symbols to include.", + example: "USDT,USDC" + }, + %OpenApiSpex.Parameter{ + name: :token_contract_symbols_to_exclude, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: "Comma-separated list of token symbols to exclude.", + example: "USDT,USDC" + } + ] + + @advanced_filter_keyset_params [ + %OpenApiSpex.Parameter{ + name: :block_number, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, pattern: General.non_negative_integer_pattern()}, + required: false, + description: "Keyset cursor: block number of the last item from the previous page.", + example: "23532302" + }, + %OpenApiSpex.Parameter{ + name: :transaction_index, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, pattern: General.non_negative_integer_pattern()}, + required: false, + description: "Keyset cursor: transaction index within the block of the last item from the previous page.", + example: "1" + }, + %OpenApiSpex.Parameter{ + name: :internal_transaction_index, + in: :query, + schema: General.IntegerStringOrEmptyOrNullLiteral, + required: false, + description: + "Keyset cursor: internal-transaction index of the last item from the previous page. " <> + "Use an empty string or the literal `null` when the previous item was not an internal transaction." + }, + %OpenApiSpex.Parameter{ + name: :token_transfer_index, + in: :query, + schema: General.IntegerStringOrEmptyOrNullLiteral, + required: false, + description: + "Keyset cursor: token-transfer index of the last item from the previous page. " <> + "Use an empty string or the literal `null` when the previous item was not a token transfer." + }, + %OpenApiSpex.Parameter{ + name: :token_transfer_batch_index, + in: :query, + schema: General.IntegerStringOrEmptyOrNullLiteral, + required: false, + description: + "Keyset cursor: index within an ERC-1155 batch token transfer. " <> + "Use an empty string or the literal `null` when the previous item was not part of a batch." + } + ] + + @items_count_param %OpenApiSpex.Parameter{ + name: :items_count, + in: :query, + schema: %OpenApiSpex.Schema{type: :integer, minimum: 1}, + required: false, + description: "Cumulative number of items already returned across previous pages." + } + + operation :list, + summary: "List transactions, internal transactions and token transfers matching the advanced filter criteria", + description: + "Returns a paginated, mixed list of activity — native value transfers, internal transactions and token " <> + "transfers — filtered by transaction type, contract method, time window, address relations, value range " <> + "and/or token contract. The response also echoes the resolved human-readable names of the methods and " <> + "tokens referenced in the request filters.", + parameters: + base_params() ++ @advanced_filter_query_params ++ @advanced_filter_keyset_params ++ [@items_count_param], + responses: [ + ok: + {"List of matching items with pagination information and resolved search params.", "application/json", + Schemas.AdvancedFilter.Response}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function responsible for `api/v2/advanced-filters/` endpoint. """ @@ -70,7 +302,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do |> Transaction.decode_transactions(true, @api_true) next_page_params = - next_page |> next_page_params(advanced_filters, Map.take(params, ["items_count"]), &paging_params/1) + next_page |> next_page_params(advanced_filters, Map.take(params, [:items_count]), false, &paging_params/1) render(conn, :advanced_filters, advanced_filters: advanced_filters, @@ -83,43 +315,114 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do ) end + operation :list_csv, + summary: "Export advanced-filter results as CSV", + description: + "Streams the items matching the advanced filter criteria as a CSV file. " <> + "When asynchronous CSV export is enabled on the deployment, returns `202 Accepted` with a `request_id` " <> + "that can be polled via `/api/v2/csv-exports/{request_id}`; otherwise the CSV body is streamed inline.", + parameters: base_params() ++ @advanced_filter_query_params ++ @advanced_filter_keyset_params, + responses: [ + ok: {"CSV file (sync export).", "application/csv", nil}, + accepted: + {"Async export queued; poll `/api/v2/csv-exports/{request_id}` with the returned `request_id`.", + "application/json", Schemas.AdvancedFilter.CsvExportAccepted}, + conflict: + {"Too many pending export requests for this client.", "application/json", Schemas.AdvancedFilter.CsvExportError}, + internal_server_error: + {"Failed to create CSV export request.", "application/json", Schemas.AdvancedFilter.CsvExportError}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function responsible for `api/v2/advanced-filters/csv` endpoint. """ @spec list_csv(Plug.Conn.t(), map()) :: Plug.Conn.t() def list_csv(conn, params) do - with {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)} do - full_options = - params - |> extract_filters() - |> Keyword.merge(paging_options(params)) - |> Keyword.update(:paging_options, %PagingOptions{page_size: CsvHelper.limit()}, fn paging_options -> - %PagingOptions{paging_options | page_size: CsvHelper.limit()} - end) - |> Keyword.put(:timeout, :timer.minutes(5)) - - full_options - |> AdvancedFilter.list() - |> AdvancedFilterView.to_csv_format() - |> CsvHelper.dump_to_stream() - |> Enum.reduce_while(CsvExportController.put_resp_params(conn), fn chunk, conn -> - case Conn.chunk(conn, chunk) do - {:ok, conn} -> - {:cont, conn} - - {:error, :closed} -> - {:halt, conn} - end - end) + full_options = build_csv_export_options(params) + + if CsvHelper.async_enabled?() do + handle_async_csv_export(conn, full_options) + else + stream_csv_to_conn(conn, CsvExportAdvancedFilter.export(full_options)) + end + end + + defp build_csv_export_options(params) do + [] + |> Keyword.merge(extract_filters(params)) + |> Keyword.merge(paging_options(params)) + |> Keyword.update(:paging_options, %PagingOptions{page_size: CsvHelper.limit()}, fn + %PagingOptions{} = paging_options -> + %PagingOptions{paging_options | page_size: CsvHelper.limit()} + end) + end + + defp handle_async_csv_export(conn, full_options) do + case AsyncCsvExportRequest.create(AccessHelper.conn_to_ip_string(conn), %{ + advanced_filters_params: full_options |> :erlang.term_to_binary() |> Base.encode64() + }) do + {:ok, request} -> + conn |> put_status(:accepted) |> json(%{request_id: request.id}) + + {:error, :too_many_pending_requests} -> + conn + |> put_status(:conflict) + |> json(%{error: "You can only have #{AsyncCsvHelper.max_pending_tasks_per_ip()} pending requests at a time"}) + + {:error, error} -> + Logger.error("Failed to create CSV export request: #{inspect(error)}") + conn |> put_status(:internal_server_error) |> json(%{error: "Failed to create CSV export request"}) end end + defp stream_csv_to_conn(conn, stream) do + stream + |> Enum.reduce_while(CsvExportController.put_resp_params(conn), fn chunk, conn -> + case Conn.chunk(conn, chunk) do + {:ok, conn} -> {:cont, conn} + {:error, :closed} -> {:halt, conn} + end + end) + end + + operation :list_methods, + summary: "List known contract methods", + description: + "Returns a list of known contract methods. " <> + "When the `q` parameter is provided, searches for a single method by its 4-byte selector or name. " <> + "Without `q`, returns the default list of popular methods.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :q, + in: :query, + schema: %OpenApiSpex.Schema{type: :string, nullable: true}, + required: false, + description: + "Search string: either a 4-byte method selector (e.g. `0xa9059cbb`) or a method name (e.g. `transfer`).", + example: "transfer" + } + | base_params() + ], + responses: [ + ok: + {"List of contract methods.", "application/json", + %OpenApiSpex.Schema{ + type: :array, + items: Schemas.AdvancedFilter.Method, + nullable: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function responsible for `api/v2/advanced-filters/methods` endpoint, including `api/v2/advanced-filters/methods/?q=:search_string`. """ - @spec list_methods(Plug.Conn.t(), map()) :: {:method, nil | Explorer.Chain.ContractMethod.t()} | Plug.Conn.t() - def list_methods(conn, %{"q" => query}) do + @spec list_methods(Plug.Conn.t(), map()) :: Plug.Conn.t() + def list_methods(conn, %{q: query}) when is_binary(query) do + query = String.downcase(query) + case {@methods_id_to_name_map[query], @methods_name_to_id_map[query]} do {name, _} when is_binary(name) -> render(conn, :methods, methods: [%{method_id: query, name: name}]) @@ -128,18 +431,21 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do render(conn, :methods, methods: [%{method_id: id, name: query}]) _ -> - mb_contract_method = + contract_method_method_id_tuple = case Data.cast(query) do - {:ok, %Data{bytes: <<_::bytes-size(4)>> = binary_method_id}} -> - ContractMethod.find_contract_method_by_selector_id(binary_method_id, @api_true) + {:ok, %Data{bytes: <<_::bytes-size(4)>> = binary_method_id} = data_method_id} -> + {ContractMethod.find_contract_method_by_selector_id(binary_method_id, @api_true), data_method_id} _ -> - ContractMethod.find_contract_method_by_name(query, @api_true) + {ContractMethod.find_contract_method_by_name(query, @api_true), nil} end - case mb_contract_method do - %ContractMethod{abi: %{"name" => name}, identifier: identifier} -> - render(conn, :methods, methods: [%{method_id: ExplorerHelper.add_0x_prefix(identifier), name: name}]) + case contract_method_method_id_tuple do + {%ContractMethod{abi: %{"name" => name}, identifier: identifier}, _} -> + render(conn, :methods, methods: [%{method_id: identifier, name: name}]) + + {_, identifier} when not is_nil(identifier) -> + render(conn, :methods, methods: [%{method_id: identifier, name: ""}]) _ -> render(conn, :methods, methods: []) @@ -175,7 +481,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do |> Enum.reduce(%{}, fn contract_method, acc -> case contract_method do %ContractMethod{abi: %{"name" => name}, identifier: identifier} when is_binary(name) -> - Map.put(acc, ExplorerHelper.add_0x_prefix(identifier), name) + Map.put(acc, identifier, name) _ -> acc @@ -194,33 +500,33 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do |> Enum.reject(&(&1 == "native")) |> Enum.uniq() |> Enum.take(@tokens_filter_limit) - |> Token.get_by_contract_address_hashes(@api_true) + |> Token.get_by_contract_address_hashes(@token_options) |> Map.new(fn token -> {token.contract_address_hash, token} end) end defp extract_filters(params) do [ - transaction_types: prepare_transaction_types(params["transaction_types"]), - methods: params["methods"] |> prepare_methods(), - age: prepare_age(params["age_from"], params["age_to"]), + transaction_types: prepare_transaction_types(params[:transaction_types]), + methods: merge_methods(prepare_methods(params[:methods]), prepare_methods_from_names(params[:methods_names])), + age: prepare_age(params[:age_from], params[:age_to]), from_address_hashes: prepare_include_exclude_address_hashes( - params["from_address_hashes_to_include"], - params["from_address_hashes_to_exclude"], + params[:from_address_hashes_to_include], + params[:from_address_hashes_to_exclude], &prepare_address_hash/1 ), to_address_hashes: prepare_include_exclude_address_hashes( - params["to_address_hashes_to_include"], - params["to_address_hashes_to_exclude"], + params[:to_address_hashes_to_include], + params[:to_address_hashes_to_exclude], &prepare_address_hash/1 ), - address_relation: prepare_address_relation(params["address_relation"]), - amount: prepare_amount(params["amount_from"], params["amount_to"]), + address_relation: prepare_address_relation(params[:address_relation]), + amount: prepare_amount(params[:amount_from], params[:amount_to]), token_contract_address_hashes: - params["token_contract_address_hashes_to_include"] + params[:token_contract_address_hashes_to_include] |> prepare_include_exclude_address_hashes( - params["token_contract_address_hashes_to_exclude"], + params[:token_contract_address_hashes_to_exclude], &prepare_token_address_hash/1 ) |> Enum.map(fn @@ -230,7 +536,15 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do ] end - @allowed_transaction_types ~w(COIN_TRANSFER ERC-20 ERC-404 ERC-721 ERC-1155) + @default_allowed_transaction_types ~w(COIN_TRANSFER CONTRACT_INTERACTION CONTRACT_CREATION ERC-20 ERC-404 ERC-721 ERC-1155 ERC-7984) + + if @chain_type == :zilliqa do + @chain_type_allowed_transaction_types ~w(ZRC-2) + else + @chain_type_allowed_transaction_types ~w() + end + + @allowed_transaction_types @default_allowed_transaction_types ++ @chain_type_allowed_transaction_types defp prepare_transaction_types(transaction_types) when is_binary(transaction_types) do transaction_types @@ -261,6 +575,28 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do defp prepare_methods(_), do: nil + defp prepare_methods_from_names(names) when is_binary(names) do + names + |> String.split(",") + |> Enum.map(&(String.trim(&1) |> String.downcase())) + |> Enum.flat_map(fn name -> List.wrap(Map.get(@methods_name_to_id_map, name)) end) + |> Enum.uniq() + end + + defp prepare_methods_from_names(_), do: nil + + defp merge_methods(nil, nil), do: nil + + defp merge_methods(a, b) do + (List.wrap(a) ++ List.wrap(b)) + |> Enum.uniq() + |> Enum.take(@methods_filter_limit) + |> case do + [] -> nil + list -> list + end + end + defp prepare_age(from, to), do: [from: parse_date(from), to: parse_date(to)] defp parse_date(string_date) do @@ -320,11 +656,11 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterController do # Paging defp paging_options(%{ - "block_number" => block_number_string, - "transaction_index" => transaction_index_string, - "internal_transaction_index" => internal_transaction_index_string, - "token_transfer_index" => token_transfer_index_string, - "token_transfer_batch_index" => token_transfer_batch_index_string + block_number: block_number_string, + transaction_index: transaction_index_string, + internal_transaction_index: internal_transaction_index_string, + token_transfer_index: token_transfer_index_string, + token_transfer_batch_index: token_transfer_batch_index_string }) do with {block_number, ""} <- block_number_string && Integer.parse(block_number_string), {transaction_index, ""} <- transaction_index_string && Integer.parse(transaction_index_string), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/api_key_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/api_key_controller.ex index 995d063863a8..fff2b7d38a0f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/api_key_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/api_key_controller.ex @@ -1,28 +1,70 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.APIKeyController do use BlockScoutWeb, :controller - use Utils.CompileTimeEnvHelper, api_v2_temp_token_key: [:block_scout_web, :api_v2_temp_token_key] + + use Utils.CompileTimeEnvHelper, + api_v2_temp_token_cookie_key: [:block_scout_web, :api_v2_temp_token_cookie_key], + api_v2_temp_token_header_key: [:block_scout_web, :api_v2_temp_token_header_key] alias BlockScoutWeb.{AccessHelper, CaptchaHelper} + alias Plug.Crypto action_fallback(BlockScoutWeb.API.V2.FallbackController) - plug(:fetch_cookies, signed: [@api_v2_temp_token_key]) + plug(:fetch_cookies, signed: [@api_v2_temp_token_cookie_key]) @doc """ - Function to handle POST requests to `/api/v2/key` endpoint. It expects body with `recaptcha_response`. And puts cookie with temporary API v2 token. Which is handled here: https://github.com/blockscout/blockscout/blob/cd19739347f267d8a6ad81bbba2dbdad08bcc134/apps/block_scout_web/lib/block_scout_web/views/access_helper.ex#L170 + Handles POST requests to `/api/v2/key` endpoint to generate a temporary API v2 token after CAPTCHA verification. + + The function verifies the CAPTCHA response and, upon successful verification, + generates a temporary API v2 token tied to the client's IP address. The token + is delivered either as a signed response header or as a signed cookie, + depending on the `in_header` parameter. The token is validated by + `BlockScoutWeb.RateLimit.get_ui_v2_token/2`. + + ## Parameters + - `conn`: The connection struct. + - `params`: A map that may contain: + * `"recaptcha_bypass_token"`, `"recaptcha_v3_response"`, or + `"recaptcha_response"` for CAPTCHA verification + * `"in_header"` - if set to `"true"`, the token is placed in a response + header; otherwise, it is placed in a cookie + + ## Returns + - A connection struct with a JSON response `{"message": "OK"}` and either: + * A signed token in the response header (if `params["in_header"]` is + `"true"`) + * A signed token in a cookie (otherwise) + - `{:recaptcha, false}` if CAPTCHA verification fails. """ @spec get_key(Plug.Conn.t(), nil | map) :: {:recaptcha, any} | Plug.Conn.t() def get_key(conn, params) do - ttl = Application.get_env(:block_scout_web, :api_rate_limit)[:api_v2_token_ttl_seconds] + ttl = div(Application.get_env(:block_scout_web, :api_rate_limit)[:api_v2_token_ttl], 1000) with {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)} do - conn - |> put_resp_cookie(@api_v2_temp_token_key, %{ip: AccessHelper.conn_to_ip_string(conn)}, - max_age: ttl, - sign: true, - same_site: "Lax", - domain: Application.get_env(:block_scout_web, :cookie_domain) - ) + params["in_header"] + |> case do + "true" -> + put_resp_header( + conn, + @api_v2_temp_token_header_key, + Crypto.sign( + conn.secret_key_base, + @api_v2_temp_token_header_key <> "-header", + %{ip: AccessHelper.conn_to_ip_string(conn)}, + keys: Plug.Keys, + max_age: ttl + ) + ) + + _ -> + put_resp_cookie(conn, @api_v2_temp_token_cookie_key, %{ip: AccessHelper.conn_to_ip_string(conn)}, + max_age: ttl, + sign: true, + same_site: "Lax", + domain: Application.get_env(:block_scout_web, :cookie_domain) + ) + end |> json(%{ message: "OK" }) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex index 54a18765eff3..a32ee1e701c4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex @@ -1,9 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ArbitrumController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ - next_page_params: 4, + next_page_params: 5, paging_options: 1, split_list_by_page: 1, parse_block_hash_or_number_param: 1 @@ -11,6 +13,8 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do import Explorer.Chain.Arbitrum.DaMultiPurposeRecord.Helper, only: [calculate_celestia_data_key: 2] + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.BadRequestResponse + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse alias Explorer.Arbitrum.ClaimRollupMessage alias Explorer.Chain.Arbitrum.{L1Batch, Message} alias Explorer.Chain.Hash @@ -21,13 +25,322 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["arbitrum"]) + @batch_necessity_by_association %{:commitment_transaction => :required} + @direction_param %OpenApiSpex.Parameter{ + name: :direction, + in: :path, + required: true, + schema: %Schema{type: :string, enum: ["from-rollup", "to-rollup"]}, + description: "Message direction: `from-rollup` for Rollup to Parent chain, `to-rollup` for Parent chain to Rollup." + } + + operation :messages, + summary: "List cross-chain messages.", + description: "Retrieves a paginated list of Arbitrum cross-chain messages filtered by the specified direction.", + parameters: [@direction_param | base_params()] ++ define_paging_params(["id", "items_count"]), + responses: [ + ok: + {"Paginated list of cross-chain messages.", "application/json", + paginated_response( + items: Schemas.Arbitrum.Message, + next_page_params_example: %{"id" => 123} + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :messages_count, + summary: "Get cross-chain messages count.", + description: "Retrieves the total count of Arbitrum cross-chain messages for the specified direction.", + parameters: [@direction_param | base_params()], + responses: [ + ok: + {"Total count of cross-chain messages for the specified direction.", "application/json", + %Schema{type: :integer, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :claim_message, + summary: "Get claim data for a withdrawal.", + description: + "Returns the ABI-encoded calldata and outbox contract address required to execute a Rollup withdrawal on the Parent chain.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :message_id, + in: :path, + schema: %Schema{type: :integer, minimum: 0}, + required: true, + description: "Withdrawal message ID." + } + | base_params() + ], + responses: [ + ok: {"Claim data for the withdrawal.", "application/json", Schemas.Arbitrum.ClaimMessage}, + bad_request: + {"Withdrawal cannot be claimed. Returned when the withdrawal is unconfirmed, just initiated, or already executed.", + "application/json", BadRequestResponse}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :withdrawals, + summary: "Get withdrawal messages for a transaction.", + description: "Returns the list of Rollup withdrawal messages (L2ToL1Tx events) emitted by the given transaction.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :transaction_hash, + in: :path, + schema: Schemas.General.FullHash, + required: true, + description: "Transaction hash." + } + | base_params() + ], + responses: [ + ok: + {"Withdrawal messages for the transaction.", "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + items: %OpenApiSpex.Schema{type: :array, items: Schemas.Arbitrum.Withdrawal} + }, + required: [:items], + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batch, + summary: "Get batch by number.", + description: "Retrieves detailed information about an Arbitrum batch by its number.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :batch_number, + in: :path, + schema: %Schema{type: :integer, minimum: 0}, + required: true, + description: "Batch number." + } + | base_params() + ], + responses: [ + ok: {"Batch info.", "application/json", Schemas.Arbitrum.Batch}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batch_by_anytrust_da_info, + summary: "Get batch by AnyTrust data hash.", + description: + "Retrieves an Arbitrum batch associated with the given AnyTrust data hash. " <> + "By default, returns the most recently associated batch. " <> + "When `type=all`, returns a paginated list of all batches referencing this data hash.", + parameters: + [ + %OpenApiSpex.Parameter{ + name: :data_hash, + in: :path, + required: true, + schema: Schemas.General.FullHash, + description: "AnyTrust data hash." + } + | base_params() + ] ++ + [ + %OpenApiSpex.Parameter{ + name: :type, + in: :query, + required: false, + schema: %Schema{type: :string, enum: ["all"]}, + description: "When set to `all`, returns a paginated list of all batches for this data hash." + } + ] ++ define_paging_params(["number", "items_count"]), + responses: [ + ok: + {"Batch info, or paginated batch list when `type=all`.", "application/json", + %Schema{ + oneOf: [ + Schemas.Arbitrum.BatchByAnytrust, + paginated_response( + items: Schemas.Arbitrum.BatchForList, + next_page_params_example: %{"number" => 123} + ) + ] + }}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batch_by_eigenda_da_info, + summary: "Get batch by EigenDA data hash.", + description: + "Retrieves an Arbitrum batch associated with the given EigenDA data hash. " <> + "By default, returns the most recently associated batch. " <> + "When `type=all`, returns a paginated list of all batches referencing this data hash.", + parameters: + [ + %OpenApiSpex.Parameter{ + name: :data_hash, + in: :path, + required: true, + schema: Schemas.General.FullHash, + description: "EigenDA data hash (Keccak-256 of the blob header)." + } + | base_params() + ] ++ + [ + %OpenApiSpex.Parameter{ + name: :type, + in: :query, + required: false, + schema: %Schema{type: :string, enum: ["all"]}, + description: "When set to `all`, returns a paginated list of all batches for this data hash." + } + ] ++ define_paging_params(["number", "items_count"]), + responses: [ + ok: + {"Batch info, or paginated batch list when `type=all`.", "application/json", + %Schema{ + oneOf: [ + Schemas.Arbitrum.BatchByEigenda, + paginated_response( + items: Schemas.Arbitrum.BatchForList, + next_page_params_example: %{"number" => 123} + ) + ] + }}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batch_by_celestia_da_info, + summary: "Get batch by Celestia blob reference.", + description: + "Retrieves an Arbitrum batch whose data availability blob is identified by the given " <> + "Celestia block height and transaction commitment hash.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :height, + in: :path, + required: true, + schema: %Schema{type: :integer, minimum: 0}, + description: "Celestia block height." + }, + %OpenApiSpex.Parameter{ + name: :transaction_commitment, + in: :path, + required: true, + schema: Schemas.General.FullHash, + description: "Celestia transaction commitment hash." + } + | base_params() + ], + responses: [ + ok: {"Batch info.", "application/json", Schemas.Arbitrum.BatchByCelestia}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batches_count, + summary: "Get batches count.", + description: "Retrieves the total count of Arbitrum batches committed to the Parent chain.", + parameters: base_params(), + responses: [ + ok: {"Total count of batches.", "application/json", %Schema{type: :integer, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batches, + summary: "List batches.", + description: "Retrieves a paginated list of Arbitrum batches committed to the Parent chain.", + parameters: + base_params() ++ + [ + %OpenApiSpex.Parameter{ + name: :batch_numbers, + in: :query, + required: false, + schema: %Schema{type: :array, items: %Schema{type: :integer, minimum: 0}}, + description: "Optional list of specific batch numbers to retrieve." + } + ] ++ define_paging_params(["number", "items_count"]), + responses: [ + ok: + {"Paginated list of Arbitrum batches.", "application/json", + paginated_response( + items: Schemas.Arbitrum.BatchForList, + next_page_params_example: %{"number" => 123} + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :batch_latest_number, + summary: "Get the latest batch number.", + description: + "Retrieves the number of the most recent Arbitrum batch submitted to the Parent chain. Returns 0 if no batches exist.", + parameters: base_params(), + responses: [ + ok: {"Latest Arbitrum batch number.", "application/json", %Schema{type: :integer, minimum: 0}}, + unprocessable_entity: JsonErrorResponse.response() + ], + tags: ["main-page"] + + operation :recent_messages_to_l2, + summary: "List recent Parent chain to Rollup messages on the main page.", + description: "Retrieves the most recent relayed messages from Parent chain to Rollup, displayed on the main page.", + parameters: base_params(), + responses: [ + ok: + {"List of recent Parent chain to Rollup messages.", "application/json", + %Schema{ + type: :object, + properties: %{ + items: %Schema{ + type: :array, + items: Schemas.Arbitrum.MinimalMessage, + nullable: false + } + }, + required: [:items], + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ], + tags: ["main-page"] + + operation :batches_committed, + summary: "List committed batches on the main page.", + description: + "Retrieves a list of Arbitrum batches that have been committed to the Parent chain, displayed on the main page.", + parameters: base_params(), + responses: [ + ok: + {"List of committed Arbitrum batches.", "application/json", + %Schema{ + type: :object, + properties: %{ + items: %Schema{ + type: :array, + items: Schemas.Arbitrum.BatchForList, + nullable: false + } + }, + required: [:items], + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ], + tags: ["main-page"] + @doc """ Function to handle GET requests to `/api/v2/arbitrum/messages/:direction` endpoint. """ @spec messages(Plug.Conn.t(), map()) :: Plug.Conn.t() - def messages(conn, %{"direction" => direction} = params) do + def messages(conn, %{direction: direction} = params) do options = params |> paging_options() @@ -42,6 +355,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do next_page, messages, params, + false, fn %Message{message_id: message_id} -> %{"id" => message_id} end ) @@ -57,7 +371,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do Function to handle GET requests to `/api/v2/arbitrum/messages/:direction/count` endpoint. """ @spec messages_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def messages_count(conn, %{"direction" => direction} = _params) do + def messages_count(conn, %{direction: direction} = _params) do conn |> put_status(200) |> render(:arbitrum_messages_count, %{count: MessagesReader.messages_count(direction)}) @@ -67,9 +381,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do Function to handle GET requests to `/api/v2/arbitrum/messages/claim/:message_id` endpoint. """ @spec claim_message(Plug.Conn.t(), map()) :: Plug.Conn.t() - def claim_message(conn, %{"message_id" => message_id} = _params) do - message_id = String.to_integer(message_id) - + def claim_message(conn, %{message_id: message_id} = _params) do case ClaimRollupMessage.claim(message_id) do {:ok, [contract_address: outbox_contract_address, calldata: calldata]} -> conn @@ -107,7 +419,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do Function to handle GET requests to `/api/v2/arbitrum/messages/withdrawals/:transaction_hash` endpoint. """ @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() - def withdrawals(conn, %{"transaction_hash" => transaction_hash} = _params) do + def withdrawals(conn, %{transaction_hash: transaction_hash} = _params) do hash = case Hash.Full.cast(transaction_hash) do {:ok, address} -> address @@ -125,7 +437,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do Function to handle GET requests to `/api/v2/arbitrum/batches/:batch_number` endpoint. """ @spec batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch(conn, %{"batch_number" => batch_number} = _params) do + def batch(conn, %{batch_number: batch_number} = _params) do case SettlementReader.batch(batch_number, necessity_by_association: @batch_necessity_by_association) do {:ok, batch} -> conn @@ -138,46 +450,47 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do end @doc """ - Function to handle GET requests to `/api/v2/arbitrum/batches/da/:data_hash` or - `/api/v2/arbitrum/batches/da/:transaction_commitment/:height` endpoints. - - For AnyTrust data hash, the function can be called in two ways: - 1. Without type parameter - returns the most recent batch for the data hash - 2. With type=all parameter - returns all batches for the data hash + Function to handle GET requests to `/api/v2/arbitrum/batches/da/anytrust/:data_hash` endpoint. + """ + @spec batch_by_anytrust_da_info(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found} + # For AnyTrust, data_key is the hash of the data itself + def batch_by_anytrust_da_info(conn, %{data_hash: data_hash} = params) do + case Map.get(params, :type) do + "all" -> all_batches_by_data_availability_info(conn, data_hash, params) + _ -> one_batch_by_data_availability_info(conn, data_hash, params) + end + end - ## Parameters - - `conn`: The connection struct - - `params`: A map that may contain: - * `data_hash` - The AnyTrust data hash - * `transaction_commitment` and `height` - For Celestia data - * `type` - Optional parameter to specify return type ("all" for all batches) + @doc """ + Function to handle GET requests to `/api/v2/arbitrum/batches/da/eigenda/:data_hash` endpoint. """ - @spec batch_by_data_availability_info(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch_by_data_availability_info(conn, %{"data_hash" => data_hash} = params) do - # In case of AnyTrust, `data_key` is the hash of the data itself - case Map.get(params, "type") do + @spec batch_by_eigenda_da_info(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found} + # For EigenDA, data_key is the hash of the data itself + def batch_by_eigenda_da_info(conn, %{data_hash: data_hash} = params) do + case Map.get(params, :type) do "all" -> all_batches_by_data_availability_info(conn, data_hash, params) _ -> one_batch_by_data_availability_info(conn, data_hash, params) end end - def batch_by_data_availability_info( + @doc """ + Function to handle GET requests to `/api/v2/arbitrum/batches/da/celestia/:height/:transaction_commitment` endpoint. + """ + @spec batch_by_celestia_da_info(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found} + def batch_by_celestia_da_info( conn, - %{"transaction_commitment" => transaction_commitment, "height" => height} = _params + %{transaction_commitment: transaction_commitment, height: height} = _params ) do - # In case of Celestia, `data_key` is the hash of the height and the commitment hash - with {:ok, :hash, transaction_commitment_hash} <- parse_block_hash_or_number_param(transaction_commitment), - key <- calculate_celestia_data_key(height, transaction_commitment_hash) do + with {:ok, :hash, transaction_commitment_hash} <- parse_block_hash_or_number_param(transaction_commitment) do + key = calculate_celestia_data_key(height, transaction_commitment_hash) + case SettlementReader.get_da_record_by_data_key(key) do {:ok, {batch_number, _}} -> - batch(conn, %{"batch_number" => batch_number}) + batch(conn, %{batch_number: batch_number}) {:error, :not_found} = res -> res end - else - res -> - res end end @@ -185,16 +498,17 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do # # ## Parameters # - `conn`: The connection struct - # - `data_hash`: The AnyTrust data hash + # - `data_hash`: The AnyTrust or EigenDA data hash # - `params`: The original request parameters # # ## Returns # - The connection struct with rendered response - @spec one_batch_by_data_availability_info(Plug.Conn.t(), binary(), map()) :: Plug.Conn.t() + @spec one_batch_by_data_availability_info(Plug.Conn.t(), binary(), map()) :: + Plug.Conn.t() | {:error, :not_found} defp one_batch_by_data_availability_info(conn, data_hash, _params) do case SettlementReader.get_da_record_by_data_key(data_hash) do {:ok, {batch_number, _}} -> - batch(conn, %{"batch_number" => batch_number}) + batch(conn, %{batch_number: batch_number}) {:error, :not_found} = res -> res @@ -205,16 +519,17 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do # # ## Parameters # - `conn`: The connection struct - # - `data_hash`: The AnyTrust data hash + # - `data_hash`: The AnyTrust or EigenDA data hash # - `params`: The original request parameters (for pagination) # # ## Returns # - The connection struct with rendered response - @spec all_batches_by_data_availability_info(Plug.Conn.t(), binary(), map()) :: Plug.Conn.t() + @spec all_batches_by_data_availability_info(Plug.Conn.t(), binary(), map()) :: + Plug.Conn.t() | {:error, :not_found} defp all_batches_by_data_availability_info(conn, data_hash, params) do case SettlementReader.get_all_da_records_by_data_key(data_hash) do {:ok, {batch_numbers, _}} -> - params = Map.put(params, "batch_numbers", batch_numbers) + params = Map.put(params, :batch_numbers, batch_numbers) batches(conn, params) {:error, :not_found} = res -> @@ -260,6 +575,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do next_page, batches, params, + false, fn %L1Batch{number: number} -> %{"number" => number} end ) @@ -280,7 +596,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumController do # ## Returns # - The options keyword list, potentially extended with batch_numbers @spec maybe_add_batch_numbers(Keyword.t(), map()) :: Keyword.t() - defp maybe_add_batch_numbers(options, %{"batch_numbers" => batch_numbers}) when is_list(batch_numbers) do + defp maybe_add_batch_numbers(options, %{batch_numbers: batch_numbers}) when is_list(batch_numbers) do Keyword.put(options, :batch_numbers, batch_numbers) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/blob_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/blob_controller.ex index f72a6424a7c3..2d88fc7fb3b2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/blob_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/blob_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.BlobController do use BlockScoutWeb, :controller @@ -11,7 +12,7 @@ defmodule BlockScoutWeb.API.V2.BlobController do """ @spec blob(Plug.Conn.t(), map()) :: Plug.Conn.t() def blob(conn, %{"blob_hash_param" => blob_hash_string} = _params) do - with {:format, {:ok, blob_hash}} <- {:format, Chain.string_to_transaction_hash(blob_hash_string)} do + with {:format, {:ok, blob_hash}} <- {:format, Chain.string_to_full_hash(blob_hash_string)} do transaction_hashes = Reader.blob_hash_to_transactions(blob_hash, api?: true) {status, blob} = diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex index 05ce918b1292..8feb71955d14 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex @@ -1,49 +1,55 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.BlockController do use BlockScoutWeb, :controller - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ next_page_params: 3, - next_page_params: 4, + next_page_params: 5, paging_options: 1, + param_to_block_number: 1, put_key_value_to_paging_options: 3, split_list_by_page: 1, - parse_block_hash_or_number_param: 1 + parse_block_hash_or_number_param: 1, + block_to_internal_transactions: 2 ] import BlockScoutWeb.PagingHelper, only: [ - delete_parameters_from_next_page_params: 1, select_block_type: 1, type_filter_options: 1, internal_transaction_type_options: 1, internal_transaction_call_type_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.MicroserviceInterfaces.BENS, + only: [maybe_preload_ens: 1, maybe_preload_ens_for_blocks: 1, maybe_preload_ens_for_transactions: 1] - import Explorer.Chain.Celo.Helper, - only: [ - validate_epoch_block_number: 1, - block_number_to_epoch_number: 1 - ] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] alias BlockScoutWeb.API.V2.{ - CeloView, + Ethereum.DepositController, + Ethereum.DepositView, TransactionView, WithdrawalView } + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse alias Explorer.Chain alias Explorer.Chain.Arbitrum.Reader.API.Settlement, as: ArbitrumSettlementReader - alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward - alias Explorer.Chain.Celo.EpochReward, as: CeloEpochReward - alias Explorer.Chain.Celo.Reader, as: CeloReader - alias Explorer.Chain.InternalTransaction + alias Explorer.Chain.Beacon.Deposit + alias Explorer.Chain.{Block, InternalTransaction} + alias Explorer.Chain.Cache.{BlockNumber, Counters.AverageBlockTime} alias Explorer.Chain.Optimism.TransactionBatch, as: OptimismTransactionBatch alias Explorer.Chain.Scroll.Reader, as: ScrollReader + alias Timex.Duration case @chain_type do :ethereum -> @@ -51,11 +57,19 @@ defmodule BlockScoutWeb.API.V2.BlockController do :beacon_blob_transaction => :optional } @chain_type_block_necessity_by_association %{ - [transactions: :beacon_blob_transaction] => :optional + [transactions: :beacon_blob_transaction] => :optional, + :beacon_deposits => :optional } :optimism -> - @chain_type_transaction_necessity_by_association %{} + if @chain_identity == {:optimism, :celo} do + @chain_type_transaction_necessity_by_association %{ + [gas_token: reputation_association()] => :optional + } + else + @chain_type_transaction_necessity_by_association %{} + end + @chain_type_block_necessity_by_association %{ :op_frame_sequence => :optional } @@ -69,12 +83,6 @@ defmodule BlockScoutWeb.API.V2.BlockController do :zksync_execute_transaction => :optional } - :celo -> - @chain_type_transaction_necessity_by_association %{ - :gas_token => :optional - } - @chain_type_block_necessity_by_association %{} - :arbitrum -> @chain_type_transaction_necessity_by_association %{} @chain_type_block_necessity_by_association %{ @@ -96,25 +104,12 @@ defmodule BlockScoutWeb.API.V2.BlockController do @chain_type_block_necessity_by_association %{} end - @transaction_necessity_by_association [ - necessity_by_association: - %{ - [created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - :block => :optional - } - |> Map.merge(@chain_type_transaction_necessity_by_association) - ] - - @internal_transaction_necessity_by_association [ - necessity_by_association: %{ - [created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional - } + @internal_transaction_address_preloads [ + address_preloads: [ + created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] ] @api_true [api?: true] @@ -127,7 +122,8 @@ defmodule BlockScoutWeb.API.V2.BlockController do :nephews => :optional, :rewards => :optional, :transactions => :optional, - :withdrawals => :optional + :withdrawals => :optional, + :internal_transactions => :optional } |> Map.merge(@chain_type_block_necessity_by_association), api?: true @@ -135,14 +131,29 @@ defmodule BlockScoutWeb.API.V2.BlockController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["blocks"]) + + operation :block, + summary: "Retrieves detailed information for a specific block identified by its number or hash.", + description: + "Retrieves detailed information for a specific block, including transactions, internal transactions, and metadata.", + parameters: [block_hash_or_number_param() | base_params()], + responses: [ + ok: {"Detailed information about the specified block.", "application/json", Schemas.Block.Response}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number` endpoint. + Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number_param` endpoint. """ @spec block(Plug.Conn.t(), map()) :: {:error, :not_found | {:invalid, :hash | :number}} | {:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}} | Plug.Conn.t() - def block(conn, %{"block_hash_or_number" => block_hash_or_number}) do + def block(conn, %{block_hash_or_number_param: block_hash_or_number}) do with {:ok, block} <- block_param_to_block(block_hash_or_number, @block_params) do conn |> put_status(200) @@ -160,10 +171,41 @@ defmodule BlockScoutWeb.API.V2.BlockController do ok_response _ -> - {:lost_consensus, Chain.nonconsensus_block_by_number(number, @api_true)} + {:lost_consensus, Block.nonconsensus_block_by_number(number, @api_true)} end end + operation :blocks, + summary: "List blocks with optional filtering by block type", + description: """ + Retrieves a paginated list of blocks ordered by descending block number. + + When the `type` query parameter is omitted, only main-chain consensus blocks + (equivalent to `type=block`) are returned. Use `type=uncle` to list ommer + blocks (valid but not in the main chain) and `type=reorg` to list blocks + that lost consensus during a chain reorganization. + + Pagination is cursor-based: the response contains `next_page_params` with + `block_number` and `items_count` — pass these back as query parameters on + the next request to fetch the following page. + """, + parameters: + base_params() ++ + [block_type_param()] ++ + define_paging_params(["block_number", "items_count"]), + responses: [ + ok: + {"List of blocks with pagination information.", "application/json", + paginated_response( + items: Schemas.Block, + next_page_params_example: %{ + "block_number" => 22_566_361, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/blocks` endpoint. """ @@ -179,22 +221,43 @@ defmodule BlockScoutWeb.API.V2.BlockController do {blocks, next_page} = split_list_by_page(blocks_plus_one) - next_page_params = next_page |> next_page_params(blocks, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(blocks, params) conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), next_page_params: next_page_params }) end + operation :arbitrum_batch, + summary: "List L2 blocks in an Arbitrum batch", + description: "Retrieves L2 blocks that are bound to a specific Arbitrum batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ + define_paging_params(["block_number", "items_count"]), + responses: [ + ok: + {"L2 blocks in the specified Arbitrum batch.", "application/json", + paginated_response( + items: Schemas.Block, + next_page_params_example: %{ + "block_number" => 22_566_361, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/arbitrum-batch/:batch_number` endpoint. + Function to handle GET requests to `/api/v2/blocks/arbitrum-batch/:batch_number_param` endpoint. It renders the list of L2 blocks bound to the specified batch. """ @spec arbitrum_batch(Plug.Conn.t(), any()) :: Plug.Conn.t() - def arbitrum_batch(conn, %{"batch_number" => batch_number} = params) do + def arbitrum_batch(conn, %{batch_number_param: batch_number} = params) do + # todo: remove select_block_type() as it is actually not processed in the endpoint full_options = params |> select_block_type() @@ -205,22 +268,43 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> ArbitrumSettlementReader.batch_blocks(full_options) |> split_list_by_page() - next_page_params = next_page |> next_page_params(blocks, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(blocks, params) conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), next_page_params: next_page_params }) end + operation :optimism_batch, + summary: "List L2 blocks in an Optimism batch", + description: "Retrieves L2 blocks that are bound to a specific Optimism batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ + define_paging_params(["block_number", "items_count"]), + responses: [ + ok: + {"L2 blocks in the specified Optimism batch.", "application/json", + paginated_response( + items: Schemas.Block, + next_page_params_example: %{ + "block_number" => 22_566_361, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/optimism-batch/:batch_number` endpoint. + Function to handle GET requests to `/api/v2/blocks/optimism-batch/:batch_number_param` endpoint. It renders the list of L2 blocks bound to the specified batch. """ @spec optimism_batch(Plug.Conn.t(), any()) :: Plug.Conn.t() - def optimism_batch(conn, %{"batch_number" => batch_number} = params) do + def optimism_batch(conn, %{batch_number_param: batch_number} = params) do + # todo: remove select_block_type() as it is actually not processed in the endpoint full_options = params |> select_block_type() @@ -232,22 +316,43 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> OptimismTransactionBatch.batch_blocks(full_options) |> split_list_by_page() - next_page_params = next_page |> next_page_params(blocks, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(blocks, params) conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), next_page_params: next_page_params }) end + operation :scroll_batch, + summary: "List L2 blocks in a Scroll batch", + description: "Retrieves L2 blocks that are bound to a specific Scroll batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ + define_paging_params(["block_number", "items_count"]), + responses: [ + ok: + {"L2 blocks in the specified Scroll batch.", "application/json", + paginated_response( + items: Schemas.Block, + next_page_params_example: %{ + "block_number" => 22_566_361, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/scroll-batch/:batch_number` endpoint. + Function to handle GET requests to `/api/v2/blocks/scroll-batch/:batch_number_param` endpoint. It renders the list of L2 blocks bound to the specified batch. """ @spec scroll_batch(Plug.Conn.t(), any()) :: Plug.Conn.t() - def scroll_batch(conn, %{"batch_number" => batch_number} = params) do + def scroll_batch(conn, %{batch_number_param: batch_number} = params) do + # todo: remove select_block_type() as it is actually not processed in the endpoint full_options = params |> select_block_type() @@ -259,27 +364,49 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> ScrollReader.batch_blocks(full_options) |> split_list_by_page() - next_page_params = next_page |> next_page_params(blocks, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(blocks, params) conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), next_page_params: next_page_params }) end + operation :transactions, + summary: "List transactions and tx details included in a specific block", + description: "Retrieves transactions included in a specific block, ordered by transaction index.", + parameters: + base_params() ++ + [block_hash_or_number_param(), block_transaction_type_param()] ++ + define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"Transactions in the specified block, with pagination.", "application/json", + paginated_response( + items: Schemas.Transaction, + next_page_params_example: %{ + "block_number" => 12_345_678, + "index" => 103, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number/transactions` endpoint. + Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number_param/transactions` endpoint. """ @spec transactions(Plug.Conn.t(), map()) :: {:error, :not_found | {:invalid, :hash | :number}} | {:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}} | Plug.Conn.t() - def transactions(conn, %{"block_hash_or_number" => block_hash_or_number} = params) do + def transactions(conn, %{block_hash_or_number_param: block_hash_or_number} = params) do with {:ok, block} <- block_param_to_block(block_hash_or_number) do full_options = - @transaction_necessity_by_association + transaction_necessity_by_association() |> Keyword.merge(put_key_value_to_paging_options(paging_options(params), :is_index_in_asc_order, true)) |> Keyword.merge(type_filter_options(params)) |> Keyword.merge(@api_true) @@ -290,20 +417,43 @@ defmodule BlockScoutWeb.API.V2.BlockController do next_page_params = next_page - |> next_page_params(transactions, delete_parameters_from_next_page_params(params)) + |> next_page_params(transactions, params) conn |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params }) end end + operation :internal_transactions, + summary: "List internal transactions in a specific block", + description: + "Retrieves internal transactions included in a specific block with optional filtering by type and call type.", + parameters: + base_params() ++ + [block_hash_or_number_param(), internal_transaction_type_param(), internal_transaction_call_type_param()] ++ + define_paging_params(["transaction_index", "index", "items_count"]), + responses: [ + ok: + {"Internal transactions in the specified block.", "application/json", + paginated_response( + items: Schemas.InternalTransaction, + next_page_params_example: %{ + "transaction_index" => 3, + "index" => 8, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number/internal-transactions` endpoint. + Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number_param/internal-transactions` endpoint. Query params: - `type` - Filters internal transactions by type. Possible values: (#{Explorer.Chain.InternalTransaction.Type.values()}) - `call_type` - Filters internal transactions by call type. Possible values: (#{Explorer.Chain.InternalTransaction.CallType.values()}) @@ -313,16 +463,16 @@ defmodule BlockScoutWeb.API.V2.BlockController do {:error, :not_found | {:invalid, :hash | :number}} | {:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}} | Plug.Conn.t() - def internal_transactions(conn, %{"block_hash_or_number" => block_hash_or_number} = params) do + def internal_transactions(conn, %{block_hash_or_number_param: block_hash_or_number} = params) do with {:ok, block} <- block_param_to_block(block_hash_or_number) do full_options = - @internal_transaction_necessity_by_association + @internal_transaction_address_preloads |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) |> Keyword.merge(internal_transaction_type_options(params)) |> Keyword.merge(internal_transaction_call_type_options(params)) - internal_transactions_plus_one = InternalTransaction.block_to_internal_transactions(block.hash, full_options) + internal_transactions_plus_one = block_to_internal_transactions(block, full_options) {internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) @@ -330,7 +480,8 @@ defmodule BlockScoutWeb.API.V2.BlockController do next_page |> next_page_params( internal_transactions, - delete_parameters_from_next_page_params(params), + params, + false, &InternalTransaction.internal_transaction_to_block_paging_options/1 ) @@ -345,14 +496,36 @@ defmodule BlockScoutWeb.API.V2.BlockController do end end + operation :withdrawals, + summary: "List validator withdrawals including amounts, index and receiver details processed in a specific block", + description: "Retrieves withdrawals processed in a specific block (typically for proof-of-stake networks).", + parameters: + base_params() ++ + [block_hash_or_number_param()] ++ + define_paging_params(["index", "items_count"]), + responses: [ + ok: + {"Withdrawals in the specified block, with pagination. Note that block_number and timestamp fields are not included in this endpoint.", + "application/json", + paginated_response( + items: Schemas.Withdrawal, + next_page_params_example: %{ + "index" => 88_192_653, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number/withdrawals` endpoint. + Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number_param/withdrawals` endpoint. """ @spec withdrawals(Plug.Conn.t(), map()) :: {:error, :not_found | {:invalid, :hash | :number}} | {:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}} | Plug.Conn.t() - def withdrawals(conn, %{"block_hash_or_number" => block_hash_or_number} = params) do + def withdrawals(conn, %{block_hash_or_number_param: block_hash_or_number} = params) do with {:ok, block} <- block_param_to_block(block_hash_or_number) do full_options = [ @@ -366,7 +539,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do withdrawals_plus_one = Chain.block_to_withdrawals(block.hash, full_options) {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) - next_page_params = next_page |> next_page_params(withdrawals, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(withdrawals, params) conn |> put_status(200) @@ -378,123 +551,179 @@ defmodule BlockScoutWeb.API.V2.BlockController do end end - @doc """ - Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number/epoch` endpoint. - """ - @spec celo_epoch(Plug.Conn.t(), map()) :: - {:error, :not_found | {:invalid, :hash | :number | :celo_election_reward_type}} - | {:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}} - | Plug.Conn.t() - def celo_epoch(conn, %{"block_hash_or_number" => block_hash_or_number}) do - params = [ - necessity_by_association: %{ - :celo_epoch_reward => :optional - }, - api?: true + operation :block_countdown, + summary: "Get countdown information for a target block number", + description: + "Calculates the estimated time remaining until a specified block number is reached based on current block and average block time.", + parameters: [block_number_param() | base_params()], + responses: [ + ok: {"Block countdown information.", "application/json", Schemas.Block.Countdown}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() ] - with {:ok, block} <- block_param_to_block(block_hash_or_number, params), - :ok <- validate_epoch_block_number(block.number) do - epoch_number = block_number_to_epoch_number(block.number) - - epoch_distribution = - block - |> Map.get(:celo_epoch_reward) - |> case do - %CeloEpochReward{} = epoch_reward -> - CeloEpochReward.load_token_transfers(epoch_reward, api?: true) - - _ -> - nil - end + @doc """ + Function to handle GET requests to `/api/v2/blocks/:block_number_param/countdown` endpoint. + Calculates the estimated time remaining until a specified block number is reached + based on the current block number and average block time. - aggregated_election_rewards = - CeloReader.block_hash_to_aggregated_election_rewards_by_type( - block.hash, - api?: true - ) + ## Parameters + - `conn`: The connection struct + - `params`: Map containing the target block number - conn - |> put_status(200) - |> put_view(CeloView) - |> render(:celo_epoch, %{ - epoch_number: epoch_number, - epoch_distribution: epoch_distribution, - aggregated_election_rewards: aggregated_election_rewards - }) + ## Returns + - Renders countdown data with current block, target block, remaining blocks, and estimated time + - Returns appropriate error responses via fallback controller for various failure cases + """ + @spec block_countdown(Plug.Conn.t(), map()) :: + Plug.Conn.t() + | {:format, {:error, :invalid}} + | {:max_block, nil} + | {:average_block_time, {:error, :disabled}} + | {:remaining_blocks, 0} + def block_countdown(conn, %{block_number_param: block_number}) do + with {:format, {:ok, target_block_number}} <- {:format, param_to_block_number(block_number)}, + {:max_block, current_block_number} when not is_nil(current_block_number) <- + {:max_block, BlockNumber.get_max()}, + {:average_block_time, average_block_time} when is_struct(average_block_time) <- + {:average_block_time, AverageBlockTime.average_block_time()}, + {:remaining_blocks, remaining_blocks} when remaining_blocks > 0 <- + {:remaining_blocks, target_block_number - current_block_number} do + estimated_time_in_sec = Float.round(remaining_blocks * Duration.to_seconds(average_block_time), 1) + + render(conn, :block_countdown, + current_block: current_block_number, + countdown_block: target_block_number, + remaining_blocks: remaining_blocks, + estimated_time_in_sec: estimated_time_in_sec + ) end end + operation :beacon_deposits, + summary: "List beacon deposits in a specific block", + description: "Retrieves beacon deposits included in a specific block with pagination support.", + parameters: + base_params() ++ + [block_hash_or_number_param()] ++ + define_paging_params(["index", "items_count"]), + responses: [ + ok: + {"Beacon deposits in the specified block.", "application/json", + paginated_response( + items: Schemas.Beacon.Deposit, + next_page_params_example: %{ + "index" => 123, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/blocks/:block_hash_or_number/election-rewards/:reward_type` endpoint. + Handles `api/v2/blocks/:block_hash_or_number_param/beacon/deposits` endpoint. + Fetches beacon deposits included in a specific block with pagination support. + + This endpoint retrieves all beacon deposits that were included in the + specified block. The block can be identified by either its hash or number. + The results include preloaded associations for both the from_address and + withdrawal_address, including scam badges, names, smart contracts, and proxy + implementations. The response is paginated and may include ENS and metadata + enrichment if those services are enabled. + + ## Parameters + - `conn`: The Plug connection. + - `params`: A map containing: + - `"block_hash_or_number_param"`: The block identifier (hash or number) to fetch + deposits from. + - Optional pagination parameter: + - `"index"`: non-negative integer, the starting index for pagination. + + ## Returns + - `{:error, :not_found}` - If the block is not found. + - `{:error, {:invalid, :hash | :number}}` - If the block identifier format is + invalid. + - `{:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}}` + - If the block has lost consensus in the blockchain. + - `Plug.Conn.t()` - A 200 response with rendered deposits and pagination + information when successful. """ - @spec celo_election_rewards(Plug.Conn.t(), map()) :: - {:error, :not_found | {:invalid, :hash | :number | :celo_election_reward_type}} + @spec beacon_deposits(Plug.Conn.t(), map()) :: + {:error, :not_found | {:invalid, :hash | :number}} | {:lost_consensus, {:error, :not_found} | {:ok, Explorer.Chain.Block.t()}} | Plug.Conn.t() - def celo_election_rewards( - conn, - %{"block_hash_or_number" => block_hash_or_number, "reward_type" => reward_type} = params - ) do - with {:ok, reward_type_atom} <- celo_reward_type_to_atom(reward_type), - {:ok, block} <- - block_param_to_block(block_hash_or_number) do - address_associations = [:names, :smart_contract, proxy_implementations_association()] - + def beacon_deposits(conn, %{block_hash_or_number_param: block_hash_or_number} = params) do + with {:ok, block} <- block_param_to_block(block_hash_or_number) do full_options = [ necessity_by_association: %{ - [account_address: address_associations] => :optional, - [associated_account_address: address_associations] => :optional - } + [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [withdrawal_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => + :optional + }, + api?: true ] - |> Keyword.merge(CeloElectionReward.block_paging_options(params)) - |> Keyword.merge(@api_true) + |> Keyword.merge(DepositController.paging_options(params)) - rewards_plus_one = - CeloReader.block_hash_to_election_rewards_by_type( - block.hash, - reward_type_atom, - full_options - ) - - {rewards, next_page} = split_list_by_page(rewards_plus_one) - - filtered_params = - params - |> delete_parameters_from_next_page_params() - |> Map.delete("reward_type") + deposit_plus_one = Deposit.from_block_hash(block.hash, full_options) + {deposits, next_page} = split_list_by_page(deposit_plus_one) next_page_params = - next_page_params( - next_page, - rewards, - filtered_params, - &CeloElectionReward.to_block_paging_params/1 + next_page + |> next_page_params( + deposits, + params, + false, + DepositController.paging_function() ) conn |> put_status(200) - |> put_view(CeloView) - |> render(:celo_election_rewards, %{ - rewards: rewards, + |> put_view(DepositView) + |> render(:deposits, %{ + deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), next_page_params: next_page_params }) end end + defp transaction_necessity_by_association do + [ + necessity_by_association: + Map.merge( + %{ + [ + created_contract_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + [ + from_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + [ + to_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + :block => :optional + }, + @chain_type_transaction_necessity_by_association + ) + ] + end + defp block_param_to_block(block_hash_or_number, options \\ @api_true) do with {:ok, type, value} <- parse_block_hash_or_number_param(block_hash_or_number) do fetch_block(type, value, options) end end - - defp celo_reward_type_to_atom(reward_type_string) do - reward_type_string - |> CeloElectionReward.type_from_url_string() - |> case do - {:ok, type} -> {:ok, type} - :error -> {:error, {:invalid, :celo_election_reward_type}} - end - end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex new file mode 100644 index 000000000000..175f4046c04e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/celo_controller.ex @@ -0,0 +1,319 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.CeloController do + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + import Explorer.Helper, only: [safe_parse_non_negative_integer: 1] + + import BlockScoutWeb.Chain, + only: [ + next_page_params: 5, + split_list_by_page: 1 + ] + + import Explorer.PagingOptions, only: [default_paging_options: 0] + + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse + alias Explorer.Chain.Celo.{AggregatedElectionReward, ElectionReward, Epoch} + alias Explorer.Chain.Hash + alias Explorer.PagingOptions + + @celo_reward_types ElectionReward.types() + + action_fallback(BlockScoutWeb.API.V2.FallbackController) + + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["celo"]) + + operation :epochs, + summary: "List Celo epochs.", + description: "Retrieves a paginated list of Celo epochs.", + parameters: + base_params() ++ + define_paging_params([ + "number", + "items_count" + ]), + responses: [ + ok: + {"List of Celo epochs.", "application/json", + paginated_response( + items: Schemas.Celo.Epoch, + next_page_params_example: %{ + "number" => 100, + "items_count" => 50 + }, + title_prefix: "CeloEpochs" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/celo/epochs` endpoint. + """ + @spec epochs(Plug.Conn.t(), map()) :: Plug.Conn.t() + def epochs(conn, params) do + paging_options = + with {:ok, number_string} <- Map.fetch(params, :number), + {:ok, number} <- parse_epoch_number(number_string) do + %{default_paging_options() | key: %{number: number}} + else + _ -> default_paging_options() + end + + options = [ + necessity_by_association: %{ + :end_processing_block => :optional, + :distribution => :optional + }, + paging_options: paging_options, + api?: true + ] + + {epochs, next_page} = + options + |> Epoch.all() + |> split_list_by_page() + + filtered_params = + params + |> Map.drop([:number]) + + next_page_params = + next_page_params( + next_page, + epochs, + filtered_params, + false, + &%{number: &1.number} + ) + + conn + |> render(:celo_epochs, %{ + epochs: epochs, + next_page_params: next_page_params + }) + end + + operation :epoch, + summary: "Get Celo epoch details.", + description: "Retrieves detailed information about a Celo epoch.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :number, + in: :path, + schema: Schemas.General.IntegerString, + required: true, + description: "Epoch number in the path." + } + | base_params() + ], + responses: [ + ok: {"Celo epoch details.", "application/json", Schemas.Celo.Epoch.Detailed}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/celo/epochs/:number` endpoint. + """ + @spec epoch(Plug.Conn.t(), map()) :: Plug.Conn.t() + def epoch(conn, %{number: number_string}) do + options = [ + necessity_by_association: %{ + :distribution => :optional, + :start_processing_block => :optional, + :end_processing_block => :optional + }, + api?: true + ] + + with {:ok, number} <- parse_epoch_number(number_string), + {:ok, epoch} <- Epoch.from_number(number, options) do + aggregated_rewards = AggregatedElectionReward.epoch_number_to_rewards_aggregated_by_type(epoch.number, api?: true) + + conn + |> render(:celo_epoch, %{ + epoch: epoch, + aggregated_election_rewards: aggregated_rewards + }) + end + end + + operation :election_rewards, + summary: "List Celo epoch election rewards.", + description: "Retrieves a paginated list of election rewards for a Celo epoch and reward type.", + parameters: + [ + %OpenApiSpex.Parameter{ + name: :number, + in: :path, + schema: Schemas.General.IntegerString, + required: true, + description: "Epoch number in the path." + }, + %OpenApiSpex.Parameter{ + name: :type, + in: :path, + schema: Schemas.Celo.ElectionReward.Type, + required: true, + description: "Reward type in the path." + } + | base_params() + ] ++ + define_paging_params([ + "amount", + "account_address_hash", + "associated_account_address_hash", + "items_count" + ]), + responses: [ + ok: + {"Election rewards for the specified Celo epoch.", "application/json", + paginated_response( + items: Schemas.Celo.ElectionReward, + next_page_params_example: %{ + "amount" => "1000000000000000000", + "account_address_hash" => "0x1234567890123456789012345678901234567890", + "associated_account_address_hash" => "0x0987654321098765432109876543210987654321", + "items_count" => 50 + }, + title_prefix: "CeloEpochElectionRewards" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/celo/epochs/:number/election-rewards/:type` + endpoint. + """ + @spec election_rewards(Plug.Conn.t(), map()) :: Plug.Conn.t() + def election_rewards(conn, %{number: epoch_number_string, type: reward_type} = params) do + with {:ok, number} <- parse_epoch_number(epoch_number_string), + {:ok, reward_type_atom} <- parse_celo_reward_type(reward_type) do + address_associations = [:names, :smart_contract, proxy_implementations_association()] + + full_options = [ + necessity_by_association: %{ + [account_address: address_associations] => :optional, + [associated_account_address: address_associations] => :optional + }, + paging_options: election_rewards_paging_options(params), + api?: true + ] + + rewards_plus_one = + ElectionReward.epoch_number_and_type_to_rewards( + number, + reward_type_atom, + full_options + ) + + {rewards, next_page} = split_list_by_page(rewards_plus_one) + + filtered_params = + params + |> Map.drop([ + :number, + :type, + :amount, + :account_address_hash, + :associated_account_address_hash + ]) + + next_page_params = + next_page_params( + next_page, + rewards, + filtered_params, + false, + &%{ + amount: &1.amount, + account_address_hash: &1.account_address_hash, + associated_account_address_hash: &1.associated_account_address_hash + } + ) + + conn + |> render(:celo_epoch_election_rewards, %{ + rewards: rewards, + next_page_params: next_page_params + }) + end + end + + @spec election_rewards_paging_options(map()) :: PagingOptions.t() + defp election_rewards_paging_options(params) do + with %{ + amount: amount_string, + account_address_hash: account_address_hash_string, + associated_account_address_hash: associated_account_address_hash_string + } + when is_binary(amount_string) and + is_binary(account_address_hash_string) and + is_binary(associated_account_address_hash_string) <- params, + {amount, ""} <- Decimal.parse(amount_string), + true <- Decimal.compare(amount, Decimal.new(0)) == :gt, + {:ok, account_address_hash} <- Hash.Address.cast(account_address_hash_string), + {:ok, associated_account_address_hash} <- + Hash.Address.cast(associated_account_address_hash_string) do + %{ + default_paging_options() + | key: %{ + amount: amount, + account_address_hash: account_address_hash, + associated_account_address_hash: associated_account_address_hash + } + } + else + _ -> default_paging_options() + end + end + + @spec parse_epoch_number(non_neg_integer() | String.t()) :: + {:ok, non_neg_integer()} | {:error, {:invalid, :number}} + defp parse_epoch_number(epoch_number) when is_integer(epoch_number) and epoch_number >= 0 and epoch_number < 32_768, + do: {:ok, epoch_number} + + defp parse_epoch_number(number) when is_binary(number) do + case safe_parse_non_negative_integer(number) do + {:ok, epoch_number} when epoch_number < 32_768 -> {:ok, epoch_number} + _ -> {:error, {:invalid, :number}} + end + end + + defp parse_epoch_number(_), do: {:error, {:invalid, :number}} + + # Parses a reward type value produced by CastAndValidate. + # + # The OpenAPI schema enum (see `ElectionReward.type_enum_with_legacy/0`) + # contains both atoms (:voter, :validator, :group, :delegated_payment) + # and a legacy hyphenated string ("delegated-payment"). CastAndValidate + # returns an atom when the URL segment matches `to_string(atom)`, but + # passes "delegated-payment" through as a string because + # `to_string(:delegated_payment)` is "delegated_payment" (underscore), + # which does not match the hyphenated URL form. + # + # The atom clause handles the canonical types; the string clause handles + # the legacy "delegated-payment" form via `type_from_url_string/1`. + # + # Once the legacy form is removed from the enum, the string clause and + # catch-all can be deleted. + @spec parse_celo_reward_type(atom() | String.t()) :: + {:ok, ElectionReward.type()} | {:error, {:invalid, :celo_election_reward_type}} + defp parse_celo_reward_type(reward_type) when reward_type in @celo_reward_types do + {:ok, reward_type} + end + + defp parse_celo_reward_type(reward_type_string) when is_binary(reward_type_string) do + reward_type_string + |> ElectionReward.type_from_url_string() + |> case do + {:ok, type} -> {:ok, type} + :error -> {:error, {:invalid, :celo_election_reward_type}} + end + end + + defp parse_celo_reward_type(_), do: {:error, {:invalid, :celo_election_reward_type}} +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex index 543b43d4f8dd..fc2fdf3fdfd7 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/config_controller.ex @@ -1,5 +1,77 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ConfigController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + alias Explorer.Chain.CsvExport.Helper, as: CsvHelper + alias Explorer.Chain.SmartContract + alias Explorer.Migrator.MigrationStatus + alias OpenApiSpex.Schema + + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["config"]) + + operation :backend, + summary: "Get backend environment configuration", + description: "Returns non-secret backend environment variables in the snake case (e.g., chain_type).", + parameters: base_params(), + responses: [ + ok: + {"Backend environment configuration.", "application/json", + %Schema{ + type: :object, + properties: %{ + chain_type: %Schema{type: :string, nullable: true}, + openapi_spec_folder_name: %Schema{type: :string, nullable: true} + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/config/backend` endpoint. + """ + @spec backend(Plug.Conn.t(), map()) :: Plug.Conn.t() + def backend(conn, _params) do + chain_type = chain_type() + + conn + |> put_status(200) + |> json(%{ + "chain_type" => chain_type, + "openapi_spec_folder_name" => chain_type_translate_to_openapi_spec_folder_name() + }) + end + + @spec chain_type_translate_to_openapi_spec_folder_name() :: String.t() + defp chain_type_translate_to_openapi_spec_folder_name do + cond do + Application.get_env(:explorer, Explorer.Chain.Mud)[:enabled] -> + "mud" + + chain_identity() == {:optimism, :celo} -> + "optimism-celo" + + true -> + chain_type() || "default" + end + end + + operation :backend_version, + summary: "Get backend version", + description: "Returns application backend version string.", + parameters: base_params(), + responses: [ + ok: + {"Backend version.", "application/json", + %Schema{type: :object, properties: %{backend_version: %Schema{type: :string, nullable: true}}}}, + unprocessable_entity: JsonErrorResponse.response() + ] def backend_version(conn, _params) do backend_version = Application.get_env(:block_scout_web, :version) @@ -9,19 +81,198 @@ defmodule BlockScoutWeb.API.V2.ConfigController do |> render(:backend_version, %{version: backend_version}) end + operation :csv_export, + summary: "CSV export limits", + description: "Returns configured limits for CSV export endpoints.", + parameters: base_params(), + responses: [ + ok: + {"CSV export limits.", "application/json", + %Schema{type: :object, properties: %{limit: %Schema{type: :integer}, async_enabled: %Schema{type: :boolean}}}}, + unprocessable_entity: JsonErrorResponse.response() + ] + def csv_export(conn, _params) do - limit = Application.get_env(:explorer, :csv_export_limit) + limit = CsvHelper.limit() + async_enabled? = CsvHelper.async_enabled?() conn |> put_status(200) - |> json(%{limit: limit}) + |> json(%{limit: limit, async_enabled: async_enabled?}) end + operation :indexer, + summary: "Indexer configuration", + description: "Returns config of indexer.", + parameters: base_params(), + responses: [ + ok: + {"Indexer configuration.", "application/json", + %Schema{ + type: :object, + properties: %{ + indexer_enabled: %Schema{type: :boolean}, + internal_transactions_fetcher_enabled: %Schema{type: :boolean}, + pending_transactions_fetcher_enabled: %Schema{type: :boolean}, + token_instance_retry_fetcher_enabled: %Schema{type: :boolean}, + token_instance_sanitize_fetcher_enabled: %Schema{type: :boolean}, + block_reward_fetcher_enabled: %Schema{type: :boolean} + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + def indexer(conn, _params) do + indexer_enabled? = System.get_env("DISABLE_INDEXER") !== "true" + + internal_transactions_fetcher_enabled? = + indexer_enabled? && + not Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor)[:disabled?] + + pending_transactions_fetcher_enabled? = + indexer_enabled? && + not Application.get_env(:indexer, Indexer.Fetcher.PendingTransaction.Supervisor)[:disabled?] + + block_reward_fetcher_enabled? = + indexer_enabled? && + not Application.get_env(:indexer, Indexer.Fetcher.BlockReward.Supervisor)[:disabled?] + + token_instance_retry_fetcher_enabled? = + indexer_enabled? && + not Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Retry.Supervisor)[:disabled?] + + token_instance_sanitize_fetcher_enabled? = + indexer_enabled? && + not Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Sanitize.Supervisor)[:disabled?] + + conn + |> put_status(200) + |> json(%{ + indexer_enabled: indexer_enabled?, + internal_transactions_fetcher_enabled: internal_transactions_fetcher_enabled?, + pending_transactions_fetcher_enabled: pending_transactions_fetcher_enabled?, + block_reward_fetcher_enabled: block_reward_fetcher_enabled?, + token_instance_retry_fetcher_enabled: token_instance_retry_fetcher_enabled?, + token_instance_sanitize_fetcher_enabled: token_instance_sanitize_fetcher_enabled? + }) + end + + operation :public_metrics, + summary: "Public metrics configuration", + description: "Returns update period / configuration for public metrics.", + parameters: base_params(), + responses: [ + ok: + {"Public metrics config.", "application/json", + %Schema{type: :object, properties: %{update_period_hours: %Schema{type: :integer}}}}, + unprocessable_entity: JsonErrorResponse.response() + ] + def public_metrics(conn, _params) do - public_metrics_update_period_hours = Application.get_env(:explorer, Explorer.Chain.Metrics)[:update_period_hours] + public_metrics_update_period_hours = + Application.get_env(:explorer, Explorer.Chain.Metrics.PublicMetrics)[:update_period_hours] conn |> put_status(200) |> json(%{update_period_hours: public_metrics_update_period_hours}) end + + operation :celo, + summary: "Celo chain configuration", + description: "Returns Celo-specific configuration (l2 migration block).", + parameters: base_params(), + responses: [ + ok: + {"Celo config.", "application/json", + %Schema{type: :object, properties: %{l2_migration_block: %Schema{type: :integer, nullable: true}}}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + def celo(conn, _params) do + config = Application.get_env(:explorer, :celo) + + conn + |> put_status(200) + |> json(%{l2_migration_block: config[:l2_migration_block]}) + end + + operation :languages_list, + summary: "Smart contract languages list", + description: "Returns list of smart contract languages supported by the database schema.", + parameters: base_params(), + responses: [ + ok: + {"Smart contract languages.", "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + languages: %OpenApiSpex.Schema{ + type: :array, + items: Schemas.SmartContract.Language + } + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/config/smart-contracts/languages` endpoint. + """ + @spec languages_list(Plug.Conn.t(), map()) :: Plug.Conn.t() + def languages_list(conn, _params) do + conn + |> put_status(200) + |> json(%{languages: SmartContract.language_strings()}) + end + + operation :db_background_migrations, + summary: "Uncompleted background migrations", + description: "Returns list of background migrations that are not yet completed.", + parameters: base_params(), + responses: [ + ok: + {"Uncompleted background migrations.", "application/json", + %Schema{ + type: :object, + properties: %{ + migrations: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + migration_name: %Schema{type: :string}, + status: %Schema{type: :string}, + inserted_at: %Schema{type: :string, format: "date-time"}, + updated_at: %Schema{type: :string, format: "date-time"}, + meta: %Schema{type: :object, nullable: true} + } + } + } + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/config/db_background_migrations` endpoint. + """ + @spec db_background_migrations(Plug.Conn.t(), map()) :: Plug.Conn.t() + def db_background_migrations(conn, _params) do + migrations = MigrationStatus.fetch_uncompleted_migrations() + + conn + |> put_status(200) + |> json(%{ + migrations: + Enum.map(migrations, fn migration -> + %{ + migration_name: migration.migration_name, + status: migration.status, + inserted_at: migration.inserted_at, + updated_at: migration.updated_at, + meta: migration.meta + } + end) + }) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex index bd25ca12efd2..e43d6750e9a2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/csv_export_controller.ex @@ -1,52 +1,76 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.CsvExportController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs - alias BlockScoutWeb.{AccessHelper, CaptchaHelper} - alias BlockScoutWeb.API.V2.ApiView + alias BlockScoutWeb.AccessHelper + alias BlockScoutWeb.CsvExport.Address.InternalTransactions, as: AddressInternalTransactionsCsvExporter + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse alias Explorer.Chain alias Explorer.Chain.Address - alias Explorer.Chain.Address.CurrentTokenBalance - alias Explorer.Chain.CsvExport.Address.InternalTransactions, as: AddressInternalTransactionsCsvExporter + alias Explorer.Chain.CsvExport.Address.Celo.ElectionRewards, as: AddressCeloElectionRewardsCsvExporter alias Explorer.Chain.CsvExport.Address.Logs, as: AddressLogsCsvExporter alias Explorer.Chain.CsvExport.Address.TokenTransfers, as: AddressTokenTransfersCsvExporter alias Explorer.Chain.CsvExport.Address.Transactions, as: AddressTransactionsCsvExporter - - alias Explorer.Chain.CsvExport.Address.Celo.ElectionRewards, - as: AddressCeloElectionRewardsCsvExporter - + alias Explorer.Chain.CsvExport.AsyncHelper, as: AsyncCsvHelper alias Explorer.Chain.CsvExport.Helper, as: CsvHelper + alias Explorer.Chain.CsvExport.Request, as: AsyncCsvExportRequest + alias Explorer.Chain.CsvExport.Token.Holders, as: TokenHoldersCsvExporter alias Plug.Conn import BlockScoutWeb.Chain, only: [fetch_scam_token_toggle: 2] + require Logger + action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + @api_true [api?: true] + operation :export_token_holders, + summary: "Export token holders as CSV", + description: "Exports the holders of a specific token as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + optional_from_period_param(), + optional_to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of token holders.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["tokens"] + @doc """ Performs CSV export of token holders for a given address Endpoint: `/api/v2/tokens/:address_hash_param/holders/csv` """ @spec export_token_holders(Conn.t(), map()) :: Conn.t() - def export_token_holders(conn, %{"address_hash_param" => address_hash_string} = params) do + def export_token_holders(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)}, - {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do - token_holders = Chain.fetch_token_holders_from_token_hash_for_csv(address_hash, options()) - - token_holders - |> CurrentTokenBalance.to_csv_format(token) - |> CsvHelper.dump_to_stream() - |> Enum.reduce_while(put_resp_params(conn), fn chunk, conn -> - case Conn.chunk(conn, chunk) do - {:ok, conn} -> - {:cont, conn} - - {:error, :closed} -> - {:halt, conn} - end - end) + {:not_found, {:ok, _token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do + opts = %{ + address_hash: address_hash, + from_period: nil, + to_period: nil, + filter_type: nil, + filter_value: nil, + show_scam_tokens?: nil + } + + do_csv_export( + CsvHelper.async_enabled?(), + conn, + opts, + TokenHoldersCsvExporter + ) end end @@ -59,71 +83,293 @@ defmodule BlockScoutWeb.API.V2.CsvExportController do |> send_chunked(200) end - defp options, do: [paging_options: CsvHelper.paging_options(), api?: true] - defp items_csv( conn, %{ - # todo: eliminate this parameter in favour address_hash_param - "address_id" => address_hash_string, - "from_period" => from_period, - "to_period" => to_period + address_hash_param: address_hash_string, + from_period: _from_period, + to_period: _to_period } = params, csv_export_module ) when is_binary(address_hash_string) do with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:address_exists, true} <- {:address_exists, Address.address_exists?(address_hash)}, - {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)} do - filter_type = Map.get(params, "filter_type") - filter_value = Map.get(params, "filter_value") - - address_hash - |> csv_export_module.export(from_period, to_period, fetch_scam_token_toggle([], conn), filter_type, filter_value) - |> Enum.reduce_while(put_resp_params(conn), fn chunk, conn -> - case Conn.chunk(conn, chunk) do - {:ok, conn} -> - {:cont, conn} - - {:error, :closed} -> - {:halt, conn} - end - end) + {:address_exists, true} <- {:address_exists, Address.address_exists?(address_hash)} do + do_csv_export( + CsvHelper.async_enabled?(), + conn, + Map.merge(params, %{ + address_hash: address_hash, + show_scam_tokens?: fetch_scam_token_toggle([], conn)[:show_scam_tokens?] + }), + csv_export_module + ) else :error -> unprocessable_entity(conn) {:address_exists, false} -> not_found(conn) + end + end + + defp items_csv(conn, _, _), do: not_found(conn) + + @spec do_csv_export(boolean(), Conn.t(), map(), module()) :: Conn.t() + defp do_csv_export(async?, conn, params, csv_export_module) - {:recaptcha, false} -> + defp do_csv_export(true, conn, params, csv_export_module) do + params = + params + |> Map.take([:address_hash, :from_period, :to_period, :filter_type, :filter_value, :show_scam_tokens?]) + |> Map.put(:module, to_string(csv_export_module)) + + case AsyncCsvExportRequest.create(AccessHelper.conn_to_ip_string(conn), params) do + {:ok, request} -> + conn |> put_status(:accepted) |> json(%{request_id: request.id}) + + {:error, :too_many_pending_requests} -> conn - |> put_status(:forbidden) - |> put_view(ApiView) - |> render(:message, %{message: "Invalid reCAPTCHA response"}) + |> put_status(:conflict) + |> json(%{error: "You can only have #{AsyncCsvHelper.max_pending_tasks_per_ip()} pending requests at a time"}) + + {:error, error} -> + Logger.error("Failed to create CSV export request: #{inspect(error)}") + conn |> put_status(:internal_server_error) |> json(%{error: "Failed to create CSV export request"}) end end - defp items_csv(conn, _, _), do: not_found(conn) + defp do_csv_export(false, conn, params, csv_export_module) do + params[:address_hash] + |> csv_export_module.export( + params[:from_period], + params[:to_period], + [show_scam_tokens?: params[:show_scam_tokens?]], + params[:filter_type], + params[:filter_value] + ) + |> Enum.reduce_while(put_resp_params(conn), fn chunk, conn -> + case Conn.chunk(conn, chunk) do + {:ok, conn} -> + {:cont, conn} + {:error, :closed} -> + {:halt, conn} + end + end) + end + + operation :token_transfers_csv, + summary: "Export token transfers as CSV", + description: "Exports token transfers for a specific address as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + from_period_param(), + to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of token transfers.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["addresses"] + + @doc """ + Handles CSV export of token transfers for a given address. + + ## Parameters + + - `conn`: The Plug connection. + - `params`: A map of request parameters. + + ## Returns + + - The updated Plug connection with the CSV response. + + Delegates the CSV generation to `AddressTokenTransfersCsvExporter`. + """ + @spec token_transfers_csv(Conn.t(), map()) :: Conn.t() def token_transfers_csv(conn, params) do items_csv(conn, params, AddressTokenTransfersCsvExporter) end + operation :transactions_csv, + summary: "Export transactions as CSV", + description: "Exports transactions for a specific address as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + from_period_param(), + to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of transactions.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["addresses"] + + @doc """ + Exports transactions related to a specific address in CSV format. + + ## Parameters + + - `conn`: The Plug connection. + - `params`: A map containing request parameters. + + ## Returns + + - The updated Plug connection with the CSV response. + + This endpoint delegates CSV generation to `AddressTransactionsCsvExporter`. + """ + @spec transactions_csv(Conn.t(), map()) :: Conn.t() def transactions_csv(conn, params) do items_csv(conn, params, AddressTransactionsCsvExporter) end + operation :internal_transactions_csv, + summary: "Export internal transactions as CSV", + description: "Exports internal transactions for a specific address as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + from_period_param(), + to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of internal transactions.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["addresses"] + + @doc """ + Exports internal transactions as a CSV file. + + ## Parameters + + - `conn`: The Plug connection. + - `params`: A map of request parameters. + + ## Returns + + - The updated Plug connection with the CSV response. + + This function delegates the CSV export logic to `AddressInternalTransactionsCsvExporter`. + """ + @spec internal_transactions_csv(Conn.t(), map()) :: Conn.t() def internal_transactions_csv(conn, params) do items_csv(conn, params, AddressInternalTransactionsCsvExporter) end + operation :logs_csv, + summary: "Export logs as CSV", + description: "Exports logs for a specific address as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + from_period_param(), + to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of logs.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["addresses"] + + @doc """ + Exports logs as a CSV file. + + This controller action receives a connection and parameters, then delegates + the CSV export functionality to the `AddressLogsCsvExporter` module. + + ## Parameters + + - `conn`: The Plug connection. + - `params`: A map of request parameters. + + ## Returns + + - The updated Plug connection with the CSV response. + """ + @spec logs_csv(Conn.t(), map()) :: Conn.t() def logs_csv(conn, params) do items_csv(conn, params, AddressLogsCsvExporter) end + operation :celo_election_rewards_csv, + summary: "Export Celo election rewards as CSV", + description: "Exports Celo election rewards for a specific address as a CSV file.", + parameters: + base_params() ++ + [ + address_hash_param(), + from_period_param(), + to_period_param(), + filter_type_param(), + filter_value_param() + ], + responses: [ + ok: {"CSV file of Celo election rewards.", "application/csv", nil}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ], + tags: ["addresses"] + + @doc """ + Handles the CSV export of Celo election rewards. + + Receives a connection and parameters, and delegates the CSV generation + to the `AddressCeloElectionRewardsCsvExporter` module. + + ## Parameters + + - `conn`: The Plug connection. + - `params`: A map of request parameters. + + ## Returns + + - The updated Plug connection with the CSV response. + """ @spec celo_election_rewards_csv(Conn.t(), map()) :: Conn.t() def celo_election_rewards_csv(conn, params) do items_csv(conn, params, AddressCeloElectionRewardsCsvExporter) end + + operation :get_csv_export, + summary: "Get CSV export", + description: "Gets a CSV export by UUID", + parameters: [uuid_param() | base_params()], + responses: [ + ok: {"Status of CSV export.", "application/json", Schemas.CSVExport.Response}, + not_found: NotFoundResponse.response() + ], + tags: ["csv-export"] + + @doc """ + Gets a CSV export by UUID. + """ + @spec get_csv_export(Conn.t(), map()) :: Conn.t() + def get_csv_export(conn, %{uuid_param: uuid}) do + with {:not_found, request} when not is_nil(request) <- + {:not_found, + uuid |> AsyncCsvExportRequest.get_by_uuid(api?: true) |> AsyncCsvHelper.actualize_csv_export_request()} do + conn |> put_status(200) |> render(:csv_export, %{request: request}) + end + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex new file mode 100644 index 000000000000..827e23ae7ab5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.Ethereum.DepositController do + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + import BlockScoutWeb.Chain, only: [next_page_params: 5, split_list_by_page: 1] + import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + + alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain.Beacon.Deposit + + action_fallback(BlockScoutWeb.API.V2.FallbackController) + + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags ["beacon_deposits"] + + operation :list, + summary: "Lists all beacon deposits", + description: "Retrieves a paginated list of all beacon deposits.", + parameters: base_params() ++ define_paging_params(["deposit_index", "items_count"]), + responses: [ + ok: + {"List of Beacon Deposits, with pagination.", "application/json", + paginated_response( + items: Schemas.Beacon.Deposit, + next_page_params_example: %{ + "index" => 123, + "items_count" => 50 + } + )}, + forbidden: ForbiddenResponse.response() + ] + + @doc """ + Handles `api/v2/beacon/deposits` endpoint. + Lists all beacon deposits with pagination support. + + This endpoint retrieves all beacon deposits from the blockchain in a + paginated format. The results include preloaded associations for both the + from_address and withdrawal_address, including scam badges, names, smart + contracts, and proxy implementations. The response may include ENS and + metadata enrichment if those services are enabled. + + ## Parameters + - `conn`: The Plug connection. + - `params`: A map containing optional pagination parameters: + - `"index"`: non-negative integer, the starting index for pagination. + + ## Returns + - `Plug.Conn.t()` - A 200 response with rendered deposits and pagination + information. + """ + @spec list(Plug.Conn.t(), map()) :: Plug.Conn.t() + def list(conn, params) do + full_options = + [ + necessity_by_association: %{ + [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [withdrawal_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional + }, + api?: true + ] + |> Keyword.merge(paging_options(params)) + + deposit_plus_one = Deposit.all(full_options) + {deposits, next_page} = split_list_by_page(deposit_plus_one) + + next_page_params = + next_page + |> next_page_params(deposits, params, false, paging_function()) + + conn + |> put_status(200) + |> render(:deposits, %{ + deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + next_page_params: next_page_params + }) + end + + operation :count, + summary: "Gets total count of beacon deposits", + description: "Retrieves the total count of beacon deposits.", + responses: [ + ok: + {"Total count of beacon deposits.", "application/json", + %Schema{ + type: :object, + properties: %{ + deposits_count: %Schema{type: :integer, nullable: false} + }, + required: [:deposits_count], + additionalProperties: false + }}, + forbidden: ForbiddenResponse.response() + ] + + @doc """ + Handles `api/v2/beacon/deposits/count` endpoint. + Returns the total count of beacon deposits. + + This endpoint calculates the total number of beacon deposits by retrieving + the latest deposit's index. Since deposit indices are 0-based and sequential, + the total count equals the highest index plus one. If no deposits exist, the + count is 0. + + ## Parameters + - `conn`: The Plug connection. + + ## Returns + - `Plug.Conn.t()` - A JSON response containing: + - `deposits_count`: The total number of beacon deposits (integer). + """ + @spec count(Plug.Conn.t(), map()) :: Plug.Conn.t() + def count(conn, _params) do + last_deposit = Deposit.get_latest_deposit(api?: true) || %{index: -1} + + conn |> json(%{deposits_count: last_deposit.index + 1}) + end + + @spec paging_options(map()) :: [Chain.paging_options()] + def paging_options(%{"index" => index}) do + case Integer.parse(index) do + {index, ""} -> [paging_options: %{PagingOptions.default_paging_options() | key: %{index: index}}] + _ -> [paging_options: PagingOptions.default_paging_options()] + end + end + + def paging_options(%{index: index}) do + [paging_options: %{PagingOptions.default_paging_options() | key: %{index: index}}] + end + + def paging_options(_), do: [paging_options: PagingOptions.default_paging_options()] + + @spec paging_function() :: (Deposit.t() -> %{index: non_neg_integer()}) + def paging_function, + do: fn deposit -> + %{index: deposit.index} + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex index 3369e577d356..290df68714ad 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex @@ -1,5 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.FallbackController do - use Phoenix.Controller + use Phoenix.Controller, namespace: BlockScoutWeb require Logger @@ -318,9 +319,37 @@ defmodule BlockScoutWeb.API.V2.FallbackController do |> render(:message, %{message: @not_a_smart_contract}) end + def call(conn, {:average_block_time, {:error, :disabled}}) do + conn + |> put_status(501) + |> put_view(ApiView) + |> render(:message, %{message: "Average block time calculating is disabled, so getblockcountdown is not available"}) + end + + def call(conn, {stage, _}) when stage in ~w(max_block average_block_time)a do + conn + |> put_status(200) + |> put_view(ApiView) + |> render(:message, %{message: "Chain is indexing now, try again later"}) + end + + def call(conn, {:remaining_blocks, _}) do + conn + |> put_status(200) + |> put_view(ApiView) + |> render(:message, %{message: "Error! Block number already pass"}) + end + def call(conn, {code, response}) when is_integer(code) do conn |> put_status(code) |> json(response) end + + def call(conn, :unknown_action) do + conn + |> put_status(400) + |> put_view(ApiView) + |> render(:message, %{message: "Unknown API v2 action"}) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/import_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/import_controller.ex index ba7b6bf002e4..f63da1af801f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/import_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/import_controller.ex @@ -1,14 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ImportController do use BlockScoutWeb, :controller alias BlockScoutWeb.API.V2.ApiView + alias BlockScoutWeb.AuthenticationHelper alias Explorer.Chain alias Explorer.Chain.{Address, Data, Token} alias Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand + alias Explorer.Chain.SmartContract.AuditReport alias Explorer.SmartContract.EthBytecodeDBInterface alias Indexer.Fetcher.TokenUpdater + alias Utils.ConfigHelper, as: UtilsConfigHelper - import Explorer.SmartContract.Helper, only: [prepare_bytecode_for_microservice: 3, contract_creation_input: 1] + import Explorer.SmartContract.Helper, only: [prepare_bytecode_for_microservice: 3, fetch_data_for_verification: 2] require Logger @api_true [api?: true] @@ -109,9 +113,7 @@ defmodule BlockScoutWeb.API.V2.ImportController do | {:sensitive_endpoints_api_key, any()} | Plug.Conn.t() def try_to_search_contract(conn, %{"address_hash_param" => address_hash_string} = params) do - with {:sensitive_endpoints_api_key, api_key} when not is_nil(api_key) <- - {:sensitive_endpoints_api_key, Application.get_env(:block_scout_web, :sensitive_endpoints_api_key)}, - {:api_key, ^api_key} <- {:api_key, get_api_key_header(conn)}, + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(get_api_key_header(conn)), {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:not_found, {:ok, address}} <- {:not_found, @@ -126,14 +128,16 @@ defmodule BlockScoutWeb.API.V2.ImportController do {:already_verified, not (is_nil(address.smart_contract) or address.smart_contract.partially_verified)} do - creation_transaction_input = contract_creation_input(address.hash) + {creation_transaction_input, deployed_bytecode, verifier_metadata} = + fetch_data_for_verification(address_hash_string, Data.to_string(address.contract_code)) with {:ok, %{"sourceType" => type} = source} <- %{} - |> prepare_bytecode_for_microservice(creation_transaction_input, Data.to_string(address.contract_code)) + |> prepare_bytecode_for_microservice(creation_transaction_input, deployed_bytecode) |> EthBytecodeDBInterface.search_contract_in_eth_bytecode_internal_db( address_hash_string, - params_to_contract_search_options(params) + params_to_contract_search_options(params), + verifier_metadata ), {:ok, _} <- LookUpSmartContractSourcesOnDemand.process_contract_source(type, source, address.hash) do conn @@ -148,6 +152,19 @@ defmodule BlockScoutWeb.API.V2.ImportController do end end + @doc """ + Function to handle DELETE request at: + `/api/v2/import/token-info` + + Needed to delete token info via admin panel. + Protected by `x-api-key` header. + """ + @spec delete_token_info(Plug.Conn.t(), map()) :: + {:api_key, any()} + | {:format_address, :error} + | {:not_found, {:error, :not_found}} + | {:sensitive_endpoints_api_key, any()} + | Plug.Conn.t() def delete_token_info( conn, %{ @@ -174,6 +191,61 @@ defmodule BlockScoutWeb.API.V2.ImportController do end end + @doc """ + Function to handle POST request at: + `/api/v2/import/smart-contracts/{address_hash_param}/audit-reports` + + Needed to import audit report via admin panel. + Protected by `x-api-key` header. + """ + @spec import_audit_report(Plug.Conn.t(), map()) :: + {:api_key, any()} + | {:format_address, :error} + | {:not_found, {:error, :not_found}} + | {:sensitive_endpoints_api_key, any()} + | Plug.Conn.t() + def import_audit_report( + conn, + %{ + "address_hash_param" => address_hash_string, + "audit_report_url" => audit_report_url, + "audit_publish_date" => audit_publish_date, + "audit_company_name" => audit_company_name + } + ) do + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(get_api_key_header(conn)), + {:format_address, {:ok, address_hash}} <- + {:format_address, Chain.string_to_address_hash(address_hash_string)}, + {:not_found, {:ok, %{smart_contract: smart_contract}}} when not is_nil(smart_contract) <- + {:not_found, + Chain.hash_to_address(address_hash, + necessity_by_association: %{ + :smart_contract => :optional + }, + api?: true + )} do + case AuditReport.import(%{ + address_hash: address_hash, + audit_report_url: audit_report_url, + audit_publish_date: audit_publish_date, + audit_company_name: audit_company_name + }) do + {:ok, _} -> + conn + |> put_view(ApiView) + |> render(:message, %{message: "Success"}) + + {:error, changeset} -> + Logger.warning(fn -> ["Error on importing audit report: ", inspect(changeset)] end) + + conn + |> put_view(ApiView) + |> put_status(:bad_request) + |> render(:smart_contract_audit_report_changeset_errors, changeset: changeset) + end + end + end + defp params_to_contract_search_options(%{"import_from" => "verifier_alliance"}) do [only_verifier_alliance?: true] end @@ -184,15 +256,8 @@ defmodule BlockScoutWeb.API.V2.ImportController do defp params_to_contract_search_options(_), do: [] - defp valid_url?(url) when is_binary(url) do - uri = URI.parse(url) - uri.scheme != nil && uri.host =~ "." - end - - defp valid_url?(_url), do: false - defp put_icon_url(changeset, icon_url) do - if valid_url?(icon_url) do + if UtilsConfigHelper.valid_url?(icon_url) do Map.put(changeset, :icon_url, icon_url) else changeset @@ -222,9 +287,7 @@ defmodule BlockScoutWeb.API.V2.ImportController do end defp validate_api_key_address_hash_and_token(token_address_hash_string, provided_api_key) do - with {:sensitive_endpoints_api_key, api_key} when not is_nil(api_key) <- - {:sensitive_endpoints_api_key, Application.get_env(:block_scout_web, :sensitive_endpoints_api_key)}, - {:api_key, ^api_key} <- {:api_key, provided_api_key}, + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(provided_api_key), {:format_address, {:ok, address_hash}} <- {:format_address, Chain.string_to_address_hash(token_address_hash_string)}, {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex index 3a71ffe0542e..8ff0b142bb42 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/internal_transaction_controller.ex @@ -1,27 +1,53 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.InternalTransactionController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain.Cache.BackgroundMigrations - alias Explorer.Chain.InternalTransaction - alias Explorer.{Chain, Helper, PagingOptions} import BlockScoutWeb.Chain, only: [ split_list_by_page: 1, paging_options: 1, - next_page_params: 3 - ] - - import BlockScoutWeb.PagingHelper, - only: [ - delete_parameters_from_next_page_params: 1 + next_page_params: 3, + fetch_internal_transactions: 1 ] import Explorer.PagingOptions, only: [default_paging_options: 0] action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["internal-transactions"]) + @api_true [api?: true] + operation :internal_transactions, + summary: "List internal transactions generated during smart contract execution", + description: + "Retrieves a paginated list of internal transactions. Internal transactions are generated during contract execution and not directly recorded on the blockchain.", + parameters: + base_params() ++ + [query_transaction_hash_param(), limit_param()] ++ + define_paging_params(["index", "block_number", "transaction_index", "items_count"]), + responses: [ + ok: + {"List of internal transactions with pagination information.", "application/json", + paginated_response( + items: Schemas.InternalTransaction, + next_page_params_example: %{ + "index" => 50, + "transaction_index" => 68, + "block_number" => 22_133_247, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/internal-transactions` endpoint. """ @@ -32,17 +58,17 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do transaction_hash = transaction_hash_from_params(params), false <- transaction_hash == :invalid do paging_options = paging_options(params) - options = options(paging_options, %{transaction_hash: transaction_hash, limit: params["limit"]}) + options = options(paging_options, %{transaction_hash: transaction_hash, limit: params[:limit]}) result = options - |> InternalTransaction.fetch() + |> fetch_internal_transactions() |> split_list_by_page() {internal_transactions, next_page} = result next_page_params = - next_page |> next_page_params(internal_transactions, delete_parameters_from_next_page_params(params)) + next_page |> next_page_params(internal_transactions, params) conn |> put_status(200) @@ -68,18 +94,19 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionController do defp options(paging_options, params) do paging_options |> Keyword.put(:transaction_hash, params.transaction_hash) + |> Keyword.put(:exclude_origin_internal_transaction, true) |> Keyword.update(:paging_options, default_paging_options(), fn %PagingOptions{ page_size: page_size } = paging_options -> - maybe_parsed_limit = Helper.parse_integer(params["limit"]) + maybe_parsed_limit = params[:limit] %PagingOptions{paging_options | page_size: min(page_size, maybe_parsed_limit && abs(maybe_parsed_limit))} end) |> Keyword.merge(@api_true) end defp transaction_hash_from_params(params) do - with transaction_hash_string when not is_nil(transaction_hash_string) <- params["transaction_hash"], - {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string) do + with transaction_hash_string when not is_nil(transaction_hash_string) <- params[:transaction_hash], + {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string) do transaction_hash else nil -> nil diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex index 74cf80675e82..151528db3dae 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex @@ -1,20 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.MainPageController do use BlockScoutWeb, :controller - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - alias Explorer.{Chain, PagingOptions} - alias BlockScoutWeb.API.V2.{BlockView, OptimismView, TransactionView} - alias Explorer.{Chain, Repo} - alias Explorer.Chain.Optimism.Deposit + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + use OpenApiSpex.ControllerSpecs + + alias BlockScoutWeb.API.V2.{BlockView, TransactionView} + alias Explorer.Account.WatchlistAddress + alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.Chain.Transaction import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] + + import Explorer.MicroserviceInterfaces.BENS, + only: [maybe_preload_ens_for_blocks: 1, maybe_preload_ens_for_transactions: 1] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] - case @chain_type do - :celo -> + case @chain_identity do + {:optimism, :celo} -> @chain_type_transaction_necessity_by_association %{ - :gas_token => :optional + [gas_token: reputation_association()] => :optional } _ -> @@ -37,6 +46,28 @@ defmodule BlockScoutWeb.API.V2.MainPageController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["main-page"]) + + operation :blocks, + summary: "Retrieve recent blocks as displayed on Blockscout homepage", + description: "Retrieves a limited set of recent blocks for display on the main page or dashboard.", + parameters: base_params(), + responses: [ + ok: + {"List of recent blocks on the home page.", "application/json", + %Schema{ + type: :array, + items: Schemas.Block, + nullable: false + }} + ] + + @doc """ + Returns the last 4 blocks for display on the main page. + """ + @spec blocks(Plug.Conn.t(), map()) :: Plug.Conn.t() def blocks(conn, _params) do blocks = [paging_options: %PagingOptions{page_size: 4}, api?: true] @@ -50,45 +81,95 @@ defmodule BlockScoutWeb.API.V2.MainPageController do conn |> put_status(200) |> put_view(BlockView) - |> render(:blocks, %{blocks: blocks |> maybe_preload_ens() |> maybe_preload_metadata()}) - end - - def optimism_deposits(conn, _params) do - recent_deposits = - Deposit.list( - paging_options: %PagingOptions{page_size: 6}, - api?: true - ) - - conn - |> put_status(200) - |> put_view(OptimismView) - |> render(:optimism_deposits, %{deposits: recent_deposits}) + |> render(:blocks, %{blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata()}) end + operation :transactions, + summary: "Retrieve recent transactions as displayed on Blockscout homepage", + description: "Retrieves a limited set of recent transactions displayed on the home page.", + parameters: base_params(), + responses: [ + ok: + {"List of recent transactions on the home page.", "application/json", + %Schema{ + type: :array, + items: Schemas.Transaction.Response, + nullable: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Returns the last 6 transactions for display on the main page. + """ + @spec transactions(Plug.Conn.t(), map()) :: Plug.Conn.t() def transactions(conn, _params) do - recent_transactions = Chain.recent_collated_transactions(false, @transactions_options) + recent_transactions = Transaction.recent_collated_transactions(false, @transactions_options) conn |> put_status(200) |> put_view(TransactionView) - |> render(:transactions, %{transactions: recent_transactions |> maybe_preload_ens() |> maybe_preload_metadata()}) + |> render(:transactions, %{ + transactions: recent_transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata() + }) end + operation :watchlist_transactions, + summary: "Last 6 transactions from the current user's watchlist", + description: "Retrieves a list of last 6 transactions from the current user's watchlist.", + parameters: base_params(), + responses: [ + ok: + {"List of watchlist transactions", "application/json", + %Schema{ + type: :array, + items: Schemas.Transaction.Response, + nullable: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Returns the last 6 watchlist transactions for display on the main page. + """ + @spec watchlist_transactions(Plug.Conn.t(), map()) :: Plug.Conn.t() def watchlist_transactions(conn, _params) do with {:auth, %{watchlist_id: watchlist_id}} <- {:auth, current_user(conn)} do - {watchlist_names, transactions} = Chain.fetch_watchlist_transactions(watchlist_id, @transactions_options) + {watchlist_names, transactions} = + WatchlistAddress.fetch_watchlist_transactions(watchlist_id, @transactions_options) conn |> put_status(200) |> put_view(TransactionView) |> render(:transactions_watchlist, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), watchlist_names: watchlist_names }) end end + operation :indexing_status, + summary: "Check if indexing is finished with indexing ratio", + description: "Retrieves the current status of blockchain data indexing by the BlockScout instance.", + parameters: base_params(), + responses: [ + ok: + {"Current blockchain indexing status.", "application/json", + %Schema{ + type: :object, + properties: %{ + finished_indexing_blocks: %Schema{type: :boolean}, + finished_indexing: %Schema{type: :boolean}, + indexed_blocks_ratio: %Schema{type: :number, format: :float}, + indexed_internal_transactions_ratio: %Schema{type: :number, format: :float, nullable: true} + } + }} + ] + + @doc """ + Lists the indexing status of blocks and transactions. + """ + @spec indexing_status(Plug.Conn.t(), map()) :: Plug.Conn.t() def indexing_status(conn, _params) do indexed_ratio_blocks = Chain.indexed_ratio_blocks() finished_indexing_blocks = Chain.finished_indexing_from_ratio?(indexed_ratio_blocks) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex index bff0df4e29b1..60a4886dbc29 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex @@ -1,9 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.MudController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ - next_page_params: 4, + next_page_params: 5, split_list_by_page: 1 ] @@ -13,6 +15,7 @@ defmodule BlockScoutWeb.API.V2.MudController do import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + alias BlockScoutWeb.Schemas.Helper, as: SchemasHelper alias Explorer.Chain alias Explorer.Chain.{Address, Data, Hash, Mud, Mud.Schema.FieldSchema, Mud.Table} @@ -20,6 +23,32 @@ defmodule BlockScoutWeb.API.V2.MudController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["mud"]) + + operation :worlds, + summary: "List of MUD worlds.", + description: "Retrieves a paginated list of MUD worlds with basic stats.", + parameters: + base_params() ++ + define_paging_params([ + "world", + "items_count" + ]), + responses: [ + ok: + {"List of MUD worlds.", "application/json", + paginated_response( + items: Schemas.MUD.World, + next_page_params_example: %{ + "world" => "0x82cb040ff4463bff3395d52b558fd77c61583b27", + "items_count" => 50 + }, + title_prefix: "Worlds" + )} + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds` endpoint. """ @@ -27,7 +56,7 @@ defmodule BlockScoutWeb.API.V2.MudController do def worlds(conn, params) do {worlds, next_page} = params - |> mud_paging_options(["world"], [Hash.Address]) + |> mud_paging_options([:world], [Hash.Address]) |> Mud.worlds_list() |> split_list_by_page() @@ -44,7 +73,7 @@ defmodule BlockScoutWeb.API.V2.MudController do |> Enum.into(%{}, &{&1.hash, &1}) next_page_params = - next_page_params(next_page, worlds, conn.query_params, fn item -> + next_page_params(next_page, worlds, conn.query_params, false, fn item -> %{"world" => item} end) @@ -60,6 +89,14 @@ defmodule BlockScoutWeb.API.V2.MudController do }) end + operation :worlds_count, + summary: "Number of known MUD worlds.", + description: "Retrieves the total number of known MUD worlds.", + parameters: base_params(), + responses: [ + ok: {"Number of known MUD worlds.", "application/json", %Schema{type: :integer}} + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/count` endpoint. """ @@ -72,13 +109,37 @@ defmodule BlockScoutWeb.API.V2.MudController do |> render(:count, %{count: count}) end + operation :world_tables, + summary: "List of MUD world tables.", + description: "Retrieves a paginated list of MUD tables in the specific MUD world.", + parameters: + base_params() ++ + [world_param(), q_param(), filter_namespace_param()] ++ + define_paging_params([ + "table_id", + "items_count" + ]), + responses: [ + ok: + {"List of MUD tables.", "application/json", + paginated_response( + items: Schemas.MUD.TableWithSchema, + next_page_params_example: %{ + "table_id" => "0x746243484553545f5641554c5400000043686573744163636573730000000000", + "items_count" => 50 + }, + title_prefix: "Tables" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/tables` endpoint. """ @spec world_tables(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_tables(conn, %{"world" => world_param} = params) do + def world_tables(conn, %{world: world_param} = params) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)} do - options = params |> mud_paging_options(["table_id"], [Hash.Full]) |> Keyword.merge(mud_tables_filter(params)) + options = params |> mud_paging_options([:table_id], [Hash.Full]) |> Keyword.merge(mud_tables_filter(params)) {tables, next_page} = world @@ -86,7 +147,7 @@ defmodule BlockScoutWeb.API.V2.MudController do |> split_list_by_page() next_page_params = - next_page_params(next_page, tables, conn.query_params, fn item -> + next_page_params(next_page, tables, conn.query_params, false, fn item -> %{"table_id" => item |> elem(0)} end) @@ -96,11 +157,20 @@ defmodule BlockScoutWeb.API.V2.MudController do end end + operation :world_systems, + summary: "List of MUD world systems.", + description: "Retrieves a list of MUD systems registered in the specific MUD world.", + parameters: [world_param() | base_params()], + responses: [ + ok: {"List of MUD systems.", "application/json", %Schema{type: :array, items: Schemas.MUD.System}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/systems` endpoint. """ @spec world_systems(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_systems(conn, %{"world" => world_param} = _params) do + def world_systems(conn, %{world: world_param} = _params) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)} do systems = world |> Mud.world_systems() @@ -110,11 +180,20 @@ defmodule BlockScoutWeb.API.V2.MudController do end end + operation :world_system, + summary: "List of MUD world system ABI methods.", + description: "Retrieves a list of MUD system ABI methods registered in the specific MUD world.", + parameters: base_params() ++ [world_param(), system_param()], + responses: [ + ok: {"List of MUD world system ABI methods.", "application/json", Schemas.MUD.SystemDetails}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/systems/:system` endpoint. """ @spec world_system(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_system(conn, %{"world" => world_param, "system" => system_param} = _params) do + def world_system(conn, %{world: world_param, system: system_param} = _params) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, {:format, {:ok, system}} <- {:format, Hash.Address.cast(system_param)}, {:ok, system_id, abi} <- Mud.world_system(world, system, @api_true) do @@ -124,11 +203,20 @@ defmodule BlockScoutWeb.API.V2.MudController do end end + operation :world_tables_count, + summary: "Number of known MUD world tables.", + description: "Retrieves the total number of known MUD tables in the specific MUD world.", + parameters: base_params() ++ [world_param(), q_param(), filter_namespace_param()], + responses: [ + ok: {"Number of known MUD world tables.", "application/json", %Schema{type: :integer}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/count` endpoint. """ @spec world_tables_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_tables_count(conn, %{"world" => world_param} = params) do + def world_tables_count(conn, %{world: world_param} = params) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)} do options = params |> mud_tables_filter() @@ -140,26 +228,67 @@ defmodule BlockScoutWeb.API.V2.MudController do end end + operation :world_table_records, + summary: "List of MUD world table records.", + description: "Retrieves a paginated list of records in the specific MUD world table.", + parameters: + base_params() ++ + [ + world_param(), + table_id_param(), + filter_key0_param(), + filter_key1_param(), + sort_param(["key_bytes", "key0", "key1"]), + order_param() + ] ++ + define_paging_params([ + "key_bytes", + "key0", + "key1", + "items_count" + ]), + responses: [ + ok: + {"List of MUD world table records.", "application/json", + SchemasHelper.extend_schema( + paginated_response( + items: Schemas.MUD.Record, + next_page_params_example: %{ + "key_bytes" => "0x73796269746c7900000000000000000043686573743332000000000000000000", + "key0" => "0x73796269746c7900000000000000000043686573743332000000000000000000", + "items_count" => 50 + }, + title_prefix: "Records" + ), + properties: %{ + table: Schemas.MUD.Table, + schema: Schemas.MUD.TableSchema + }, + required: [:table, :schema] + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records` endpoint. """ @spec world_table_records(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_table_records(conn, %{"world" => world_param, "table_id" => table_id_param} = params) do + def world_table_records(conn, %{world: world_param, table_id: table_id_param} = params) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, {:format, {:ok, table_id}} <- {:format, Hash.Full.cast(table_id_param)}, {:ok, schema} <- Mud.world_table_schema(world, table_id) do options = params - |> mud_paging_options(["key_bytes", "key0", "key1"], [Data, Hash.Full, Hash.Full]) + |> mud_paging_options([:key_bytes, :key0, :key1], [Data, Hash.Full, Hash.Full]) |> Keyword.merge(mud_records_filter(params, schema)) |> Keyword.merge(mud_records_sorting(params)) {records, next_page} = world |> Mud.world_table_records(table_id, options) |> split_list_by_page() - blocks = Mud.preload_records_timestamps(records) + blocks = Mud.preload_records_timestamps(records, @api_true) next_page_params = - next_page_params(next_page, records, conn.query_params, fn item -> + next_page_params(next_page, records, conn.query_params, false, fn item -> keys = [item.key_bytes, item.key0, item.key1] |> Enum.filter(&(!is_nil(&1))) ["key_bytes", "key0", "key1"] |> Enum.zip(keys) |> Enum.into(%{}) end) @@ -176,11 +305,20 @@ defmodule BlockScoutWeb.API.V2.MudController do end end + operation :world_table_records_count, + summary: "Number of known MUD world table records.", + description: "Retrieves the total number of records in the specific MUD world table.", + parameters: base_params() ++ [world_param(), table_id_param(), filter_key0_param(), filter_key1_param()], + responses: [ + ok: {"Number of known MUD world table records.", "application/json", %Schema{type: :integer}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records/count` endpoint. """ @spec world_table_records_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def world_table_records_count(conn, %{"world" => world_param, "table_id" => table_id_param} = params) do + def world_table_records_count(conn, %{world: world_param, table_id: table_id_param} = params) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, {:format, {:ok, table_id}} <- {:format, Hash.Full.cast(table_id_param)}, {:ok, schema} <- Mud.world_table_schema(world, table_id) do @@ -194,20 +332,35 @@ defmodule BlockScoutWeb.API.V2.MudController do end end + operation :world_table_record, + summary: "Single MUD world table record.", + description: "Retrieves a single record in the specific MUD world table.", + parameters: base_params() ++ [world_param(), table_id_param(), record_id_param()], + responses: [ + ok: + {"Single MUD world table record.", "application/json", + Schemas.MUD.TableWithSchema.schema() + |> SchemasHelper.extend_schema( + properties: %{record: Schemas.MUD.Record}, + required: [:record] + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/mud/worlds/:world/tables/:table_id/records/:record_id` endpoint. """ @spec world_table_record(Plug.Conn.t(), map()) :: Plug.Conn.t() def world_table_record( conn, - %{"world" => world_param, "table_id" => table_id_param, "record_id" => record_id_param} = _params + %{world: world_param, table_id: table_id_param, record_id: record_id_param} = _params ) do with {:format, {:ok, world}} <- {:format, Hash.Address.cast(world_param)}, {:format, {:ok, table_id}} <- {:format, Hash.Full.cast(table_id_param)}, {:format, {:ok, record_id}} <- {:format, Data.cast(record_id_param)}, {:ok, schema} <- Mud.world_table_schema(world, table_id), {:ok, record} <- Mud.world_table_record(world, table_id, record_id) do - blocks = Mud.preload_records_timestamps([record]) + blocks = Mud.preload_records_timestamps([record], @api_true) conn |> put_status(200) @@ -218,10 +371,10 @@ defmodule BlockScoutWeb.API.V2.MudController do defp mud_tables_filter(params) do Enum.reduce(params, [], fn {key, value}, acc -> case key do - "filter_namespace" -> + :filter_namespace -> Keyword.put(acc, :filter_namespace, parse_namespace_string(value)) - "q" -> + :q -> Keyword.put(acc, :filter_search, parse_search_string(value)) _ -> @@ -262,8 +415,8 @@ defmodule BlockScoutWeb.API.V2.MudController do defp mud_records_filter(params, schema) do Enum.reduce(params, [], fn {key, value}, acc -> case key do - "filter_key0" -> Keyword.put(acc, :filter_key0, encode_filter(value, schema, 0)) - "filter_key1" -> Keyword.put(acc, :filter_key1, encode_filter(value, schema, 1)) + :filter_key0 -> Keyword.put(acc, :filter_key0, encode_filter(value, schema, 0)) + :filter_key1 -> Keyword.put(acc, :filter_key1, encode_filter(value, schema, 1)) _ -> acc end end) @@ -278,19 +431,27 @@ defmodule BlockScoutWeb.API.V2.MudController do <<1::256>> "0x" <> hex -> - with {:ok, bin} <- Base.decode16(hex, case: :mixed) do - # addresses are padded to 32 bytes with zeros on the right - if FieldSchema.type_of(schema.key_schema, field_idx) == 97 do - <<0::size(256 - byte_size(bin) * 8), bin::binary>> - else - <> - end - end + decode_hex_filter(hex, schema, field_idx) dec -> - with {num, _} <- Integer.parse(dec) do - <> - end + decode_decimal_filter(dec) + end + end + + defp decode_hex_filter(hex, schema, field_idx) do + with {:ok, bin} <- Base.decode16(hex, case: :mixed) do + # addresses are padded to 32 bytes with zeros on the right + if FieldSchema.type_of(schema.key_schema, field_idx) == 97 do + <<0::size(256 - byte_size(bin) * 8), bin::binary>> + else + <> + end + end + end + + defp decode_decimal_filter(dec) do + with {num, _} <- Integer.parse(dec) do + <> end end @@ -301,7 +462,7 @@ defmodule BlockScoutWeb.API.V2.MudController do |> Enum.reduce(%{}, fn {key, type}, acc -> with param when param != nil <- Map.get(params, key), {:ok, val} <- type.cast(param) do - acc |> Map.put(String.to_existing_atom(key), val) + acc |> Map.put(key, val) else _ -> acc end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex index d40fcdac6c3a..bc3afc6dc1fd 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/optimism_controller.ex @@ -1,5 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.OptimismController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs require Logger @@ -10,17 +12,13 @@ defmodule BlockScoutWeb.API.V2.OptimismController do split_list_by_page: 1 ] - import BlockScoutWeb.PagingHelper, - only: [ - delete_parameters_from_next_page_params: 1 - ] - - import Explorer.Helper, only: [add_0x_prefix: 1, hash_to_binary: 1] + import Explorer.Helper, only: [hash_to_binary: 1] - alias BlockScoutWeb.API.V2.ApiView - alias Explorer.Chain + alias BlockScoutWeb.API.V2.{ApiView, OptimismView} + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse + alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.Cache.ChainId - alias Explorer.Chain.{Hash, Transaction} + alias Explorer.Chain.{Data, Hash, Token, Transaction} alias Explorer.Chain.Optimism.{ Deposit, @@ -37,43 +35,37 @@ defmodule BlockScoutWeb.API.V2.OptimismController do action_fallback(BlockScoutWeb.API.V2.FallbackController) - @api_true [api?: true] - - @doc """ - Function to handle GET requests to `/api/v2/optimism/txn-batches` and - `/api/v2/optimism/txn-batches/:l2_block_range_start/:l2_block_range_end` endpoints. - """ - @spec transaction_batches(Plug.Conn.t(), map()) :: Plug.Conn.t() - def transaction_batches(conn, params) do - {batches, next_page} = - params - |> paging_options() - |> Keyword.put(:api?, true) - |> Keyword.put(:l2_block_range_start, Map.get(params, "l2_block_range_start")) - |> Keyword.put(:l2_block_range_end, Map.get(params, "l2_block_range_end")) - |> TransactionBatch.list() - |> split_list_by_page() + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) - next_page_params = next_page_params(next_page, batches, delete_parameters_from_next_page_params(params)) + tags(["optimism"]) - conn - |> put_status(200) - |> render(:optimism_transaction_batches, %{ - batches: batches, - next_page_params: next_page_params - }) - end + @api_true [api?: true] - @doc """ - Function to handle GET requests to `/api/v2/optimism/txn-batches/count` endpoint. - """ - @spec transaction_batches_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def transaction_batches_count(conn, _params) do - items_count(conn, TransactionBatch) - end + operation :batches, + summary: "List batches.", + description: "Retrieves a paginated list of batches.", + parameters: + base_params() ++ + define_paging_params([ + "id", + "items_count" + ]), + responses: [ + ok: + {"List of batches.", "application/json", + paginated_response( + items: Schemas.Optimism.Batch, + next_page_params_example: %{ + "id" => 394_591, + "items_count" => 50 + }, + title_prefix: "Batches" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] @doc """ - Function to handle GET requests to `/api/v2/optimism/batches` endpoint. + Function to handle GET requests to `/api/v2/optimism/batches` endpoint. """ @spec batches(Plug.Conn.t(), map()) :: Plug.Conn.t() def batches(conn, params) do @@ -91,8 +83,8 @@ defmodule BlockScoutWeb.API.V2.OptimismController do batches |> Enum.map(fn fs -> Task.async(fn -> - l2_block_number_from = TransactionBatch.edge_l2_block_number(fs.id, :min) - l2_block_number_to = TransactionBatch.edge_l2_block_number(fs.id, :max) + l2_block_number_from = TransactionBatch.edge_l2_block_number(fs.id, :min, @api_true) + l2_block_number_to = TransactionBatch.edge_l2_block_number(fs.id, :max, @api_true) l2_block_range = if not is_nil(l2_block_number_from) and not is_nil(l2_block_number_to) do @@ -100,7 +92,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do end # credo:disable-for-lines:2 Credo.Check.Refactor.Nesting - transaction_count = + transactions_count = case l2_block_range do nil -> 0 range -> Transaction.transaction_count_for_block_range(range) @@ -110,9 +102,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do fs |> Map.put(:l2_block_range, l2_block_range) - |> Map.put(:transactions_count, transaction_count) - # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0 - |> Map.put(:transaction_count, transaction_count) + |> Map.put(:transactions_count, transactions_count) |> Map.put(:batch_data_container, batch_data_container) end) end) @@ -128,19 +118,55 @@ defmodule BlockScoutWeb.API.V2.OptimismController do }) end + operation :batches_count, + summary: "Number of batches in the list.", + description: "Retrieves a size of the batch list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the batch list.", "application/json", %Schema{type: :integer, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/batches/count` endpoint. + Function to handle GET requests to `/api/v2/optimism/batches/count` endpoint. """ @spec batches_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def batches_count(conn, _params) do items_count(conn, FrameSequence) end + operation :batch_by_celestia_blob, + summary: "Batch by celestia blob.", + description: "Retrieves batch detailed info by the given celestia blob metadata (height and commitment).", + parameters: + base_params() ++ + [ + %OpenApiSpex.Parameter{ + name: :height, + in: :path, + schema: Schemas.General.IntegerString, + required: true, + description: "Celestia blob height in the path." + }, + %OpenApiSpex.Parameter{ + name: :commitment, + in: :path, + schema: Schemas.General.HexString, + required: true, + description: "Celestia blob commitment in the path." + } + ], + responses: [ + ok: {"Batch detailed info.", "application/json", Schemas.Optimism.Batch.Detailed}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/batches/da/celestia/:height/:commitment` endpoint. + Function to handle GET requests to `/api/v2/optimism/batches/da/celestia/:height/:commitment` endpoint. """ @spec batch_by_celestia_blob(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch_by_celestia_blob(conn, %{"height" => height, "commitment" => commitment}) do + def batch_by_celestia_blob(conn, %{height: height, commitment: commitment}) do {height, ""} = Integer.parse(height) commitment = @@ -161,14 +187,33 @@ defmodule BlockScoutWeb.API.V2.OptimismController do end end + operation :batch_by_number, + summary: "Batch by its number.", + description: "Retrieves batch detailed info by the given number.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :number, + in: :path, + schema: Schemas.General.IntegerString, + required: true, + description: "Batch number in the path." + } + | base_params() + ], + responses: [ + ok: {"Batch detailed info.", "application/json", Schemas.Optimism.Batch.Detailed}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/batches/:internal_id` endpoint. + Function to handle GET requests to `/api/v2/optimism/batches/:number` endpoint. """ - @spec batch_by_internal_id(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch_by_internal_id(conn, %{"internal_id" => internal_id}) do - {internal_id, ""} = Integer.parse(internal_id) + @spec batch_by_number(Plug.Conn.t(), map()) :: Plug.Conn.t() + def batch_by_number(conn, %{number: number}) do + {number, ""} = Integer.parse(number) - batch = FrameSequence.batch_by_internal_id(internal_id, api?: true) + batch = FrameSequence.batch_by_number(number, api?: true) if is_nil(batch) do {:error, :not_found} @@ -179,8 +224,31 @@ defmodule BlockScoutWeb.API.V2.OptimismController do end end + operation :output_roots, + summary: "List output roots.", + description: "Retrieves a paginated list of output roots.", + parameters: + base_params() ++ + define_paging_params([ + "index", + "items_count" + ]), + responses: [ + ok: + {"List of output roots.", "application/json", + paginated_response( + items: Schemas.Optimism.OutputRoot, + next_page_params_example: %{ + "index" => 8829, + "items_count" => 50 + }, + title_prefix: "OutputRoots" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/output-roots` endpoint. + Function to handle GET requests to `/api/v2/optimism/output-roots` endpoint. """ @spec output_roots(Plug.Conn.t(), map()) :: Plug.Conn.t() def output_roots(conn, params) do @@ -201,16 +269,48 @@ defmodule BlockScoutWeb.API.V2.OptimismController do }) end + operation :output_roots_count, + summary: "Number of output roots in the list.", + description: "Retrieves a size of the output roots list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the output roots list.", "application/json", %Schema{type: :integer, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/output-roots/count` endpoint. + Function to handle GET requests to `/api/v2/optimism/output-roots/count` endpoint. """ @spec output_roots_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def output_roots_count(conn, _params) do items_count(conn, OutputRoot) end + operation :games, + summary: "List games.", + description: "Retrieves a paginated list of games.", + parameters: + base_params() ++ + define_paging_params([ + "index", + "items_count" + ]), + responses: [ + ok: + {"List of games.", "application/json", + paginated_response( + items: Schemas.Optimism.Game, + next_page_params_example: %{ + "index" => 12967, + "items_count" => 50 + }, + title_prefix: "Games" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/games` endpoint. + Function to handle GET requests to `/api/v2/optimism/games` endpoint. """ @spec games(Plug.Conn.t(), map()) :: Plug.Conn.t() def games(conn, params) do @@ -231,8 +331,17 @@ defmodule BlockScoutWeb.API.V2.OptimismController do }) end + operation :games_count, + summary: "Number of games in the list.", + description: "Retrieves a size of the games list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the games list.", "application/json", %Schema{type: :integer, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/games/count` endpoint. + Function to handle GET requests to `/api/v2/optimism/games/count` endpoint. """ @spec games_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def games_count(conn, _params) do @@ -243,8 +352,33 @@ defmodule BlockScoutWeb.API.V2.OptimismController do |> render(:optimism_items_count, %{count: count}) end + operation :deposits, + summary: "List deposits.", + description: "Retrieves a paginated list of deposits.", + parameters: + base_params() ++ + define_paging_params([ + "items_count", + "l1_block_number", + "transaction_hash" + ]), + responses: [ + ok: + {"List of deposits.", "application/json", + paginated_response( + items: Schemas.Optimism.Deposit, + next_page_params_example: %{ + "items_count" => 50, + "l1_block_number" => 23_937_283, + "transaction_hash" => "0x5dc155c382d95353c5876e735d675d284e3b29b1379e5859dc35cfd4a1dd5188" + }, + title_prefix: "Deposits" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/deposits` endpoint. + Function to handle GET requests to `/api/v2/optimism/deposits` endpoint. """ @spec deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits(conn, params) do @@ -265,24 +399,137 @@ defmodule BlockScoutWeb.API.V2.OptimismController do }) end + operation :main_page_deposits, + summary: "List deposits on the main page.", + description: "Retrieves a list of deposits for the main page.", + parameters: base_params(), + responses: [ + ok: + {"List of deposits on the main page.", "application/json", + %Schema{type: :array, items: Schemas.Optimism.Deposit.MainPage, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/deposits/count` endpoint. + Function to handle GET requests to `/api/v2/main-page/optimism-deposits` endpoint. + """ + @spec main_page_deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() + def main_page_deposits(conn, _params) do + recent_deposits = + Deposit.list( + paging_options: %PagingOptions{page_size: 6}, + api?: true + ) + + conn + |> put_status(200) + |> put_view(OptimismView) + |> render(:optimism_deposits, %{deposits: recent_deposits}) + end + + operation :deposits_count, + summary: "Number of deposits in the list.", + description: "Retrieves a size of the deposits list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the deposits list.", "application/json", %Schema{type: :integer, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/optimism/deposits/count` endpoint. """ @spec deposits_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def deposits_count(conn, _params) do - items_count(conn, Deposit) + count = Deposit.count(@api_true) + + conn + |> put_status(200) + |> render(:optimism_items_count, %{count: count}) end + operation :interop_message, false + + @doc """ + Function to handle GET requests to `/api/v2/optimism/interop/messages/:unique_id` endpoint. + """ + @spec interop_message(Plug.Conn.t(), map()) :: Plug.Conn.t() + def interop_message(conn, params) do + unique_id = Map.get(params, "unique_id", "") + + with true <- String.length(unique_id) == 16, + {init_chain_id_string, nonce_string} = String.split_at(unique_id, 8), + {init_chain_id, ""} <- Integer.parse(init_chain_id_string, 16), + {nonce, ""} <- Integer.parse(nonce_string, 16), + msg = InteropMessage.get_message(init_chain_id, nonce), + false <- is_nil(msg) do + current_chain_id = ChainId.get_id() + + relay_chain_id = msg.relay_chain_id + + direction = + case current_chain_id do + ^init_chain_id -> :out + ^relay_chain_id -> :in + _ -> nil + end + + transfer_token = fetch_transfer_token(msg.transfer_token_address_hash) + + message = + msg + |> InteropMessage.extend_with_status() + |> Map.put(:init_chain, interop_chain_id_to_instance_info(msg.init_chain_id)) + |> Map.put(:relay_chain, interop_chain_id_to_instance_info(msg.relay_chain_id)) + |> Map.put(:direction, direction) + |> Map.put(:transfer_token, transfer_token) + + conn + |> put_status(200) + |> render(:optimism_interop_message, %{message: message}) + else + _ -> + conn + |> put_view(ApiView) + |> put_status(:not_found) + |> render(:message, %{message: "Invalid message id or the message with such id is not found"}) + end + end + + defp fetch_transfer_token(nil), do: nil + + defp fetch_transfer_token(transfer_token_address_hash) do + case Token.get_by_contract_address_hash(transfer_token_address_hash, @api_true) do + nil -> %{contract_address_hash: transfer_token_address_hash, symbol: nil, decimals: nil} + t -> %{contract_address_hash: t.contract_address_hash, symbol: t.symbol, decimals: t.decimals} + end + end + + # Calls `InteropMessage.interop_chain_id_to_instance_info` function and depending on the result + # returns a map with the instance info. + # + # ## Parameters + # - `chain_id`: ID of the chain the instance info is needed for. + # + # ## Returns + # - A map with the instance info. + # - If the info cannot be retrieved, anyway returns the map with a single `chain_id` item. + @spec interop_chain_id_to_instance_info(non_neg_integer()) :: map() + defp interop_chain_id_to_instance_info(chain_id) do + case InteropMessage.interop_chain_id_to_instance_info(chain_id) do + nil -> %{chain_id: chain_id} + chain -> chain + end + end + + operation :interop_messages, false + @doc """ Function to handle GET requests to `/api/v2/optimism/interop/messages` endpoint. """ @spec interop_messages(Plug.Conn.t(), map()) :: Plug.Conn.t() def interop_messages(conn, params) do - current_chain_id = - case ChainId.get_id() do - nil -> Application.get_env(:block_scout_web, :chain_id) - chain_id -> chain_id - end + current_chain_id = ChainId.get_id() {messages, next_page} = params @@ -298,18 +545,20 @@ defmodule BlockScoutWeb.API.V2.OptimismController do messages_extended = messages |> Enum.map(fn message -> - cond do - message.init_chain_id != current_chain_id and not is_nil(current_chain_id) -> - Map.put(message, :init_chain, InteropMessage.interop_chain_id_to_instance_info(message.init_chain_id)) + message_extended = + cond do + message.init_chain_id != current_chain_id and not is_nil(current_chain_id) -> + Map.put(message, :init_chain, InteropMessage.interop_chain_id_to_instance_info(message.init_chain_id)) - message.relay_chain_id != current_chain_id and not is_nil(current_chain_id) -> - Map.put(message, :relay_chain, InteropMessage.interop_chain_id_to_instance_info(message.relay_chain_id)) + message.relay_chain_id != current_chain_id and not is_nil(current_chain_id) -> + Map.put(message, :relay_chain, InteropMessage.interop_chain_id_to_instance_info(message.relay_chain_id)) - true -> - message - end + true -> + message + end + + InteropMessage.extend_with_status(message_extended) end) - |> Enum.map(&InteropMessage.extend_with_status(&1)) conn |> put_status(200) @@ -319,8 +568,10 @@ defmodule BlockScoutWeb.API.V2.OptimismController do }) end + operation :interop_messages_count, false + @doc """ - Function to handle GET requests to `/api/v2/optimism/interop/messages/count` endpoint. + Function to handle GET requests to `/api/v2/optimism/interop/messages/count` endpoint. """ @spec interop_messages_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def interop_messages_count(conn, _params) do @@ -329,8 +580,31 @@ defmodule BlockScoutWeb.API.V2.OptimismController do |> render(:optimism_items_count, %{count: InteropMessage.count(@api_true)}) end + operation :withdrawals, + summary: "List withdrawals.", + description: "Retrieves a paginated list of withdrawals.", + parameters: + base_params() ++ + define_paging_params([ + "items_count", + "nonce" + ]), + responses: [ + ok: + {"List of withdrawals.", "application/json", + paginated_response( + items: Schemas.Optimism.Withdrawal, + next_page_params_example: %{ + "items_count" => 50, + "nonce" => "1766847064778384329583297500742918515827483896875618958121606201292650102" + }, + title_prefix: "Withdrawals" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/withdrawals` endpoint. + Function to handle GET requests to `/api/v2/optimism/withdrawals` endpoint. """ @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() def withdrawals(conn, params) do @@ -351,16 +625,27 @@ defmodule BlockScoutWeb.API.V2.OptimismController do }) end + operation :withdrawals_count, + summary: "Number of withdrawals in the list.", + description: "Retrieves a size of the withdrawals list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the withdrawals list.", "application/json", %Schema{type: :integer, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/optimism/withdrawals/count` endpoint. + Function to handle GET requests to `/api/v2/optimism/withdrawals/count` endpoint. """ @spec withdrawals_count(Plug.Conn.t(), map()) :: Plug.Conn.t() def withdrawals_count(conn, _params) do items_count(conn, Withdrawal) end + operation :interop_public_key, false + @doc """ - Function to handle GET requests to `/api/v2/optimism/interop/public-key` endpoint. + Function to handle GET requests to `/api/v2/optimism/interop/public-key` endpoint. """ @spec interop_public_key(Plug.Conn.t(), map()) :: Plug.Conn.t() def interop_public_key(conn, _params) do @@ -370,7 +655,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do {:ok, public_key} <- ExSecp256k1.create_public_key(private_key) do conn |> put_status(200) - |> render(:optimism_interop_public_key, %{public_key: add_0x_prefix(public_key)}) + |> render(:optimism_interop_public_key, %{public_key: %Data{bytes: public_key}}) else _ -> Logger.error("Interop: cannot derive a public key from the private key. Private key is invalid or undefined.") @@ -382,10 +667,12 @@ defmodule BlockScoutWeb.API.V2.OptimismController do end end + operation :interop_import, false + @doc """ - Function to handle POST request to `/api/v2/import/optimism/interop/` endpoint. - Accepts `init` part of the interop message from the source instance or - `relay` part of the interop message from the target instance. + Function to handle POST request to `/api/v2/import/optimism/interop/` endpoint. + Accepts `init` part of the interop message from the source instance or + `relay` part of the interop message from the target instance. """ @spec interop_import(Plug.Conn.t(), map()) :: Plug.Conn.t() def interop_import( @@ -492,6 +779,11 @@ defmodule BlockScoutWeb.API.V2.OptimismController do # - Resulting map with the `op_interop_messages` table's fields. @spec interop_prepare_import(map()) :: map() defp interop_prepare_import(%{"init_transaction_hash" => init_transaction_hash} = params) do + payload = hash_to_binary(params["payload"]) + + [transfer_token_address_hash, transfer_from_address_hash, transfer_to_address_hash, transfer_amount] = + InteropMessage.decode_payload(payload) + %{ sender_address_hash: params["sender_address_hash"], target_address_hash: params["target_address_hash"], @@ -500,7 +792,11 @@ defmodule BlockScoutWeb.API.V2.OptimismController do init_transaction_hash: init_transaction_hash, timestamp: DateTime.from_unix!(params["timestamp"]), relay_chain_id: params["relay_chain_id"], - payload: hash_to_binary(params["payload"]) + payload: payload, + transfer_token_address_hash: transfer_token_address_hash, + transfer_from_address_hash: transfer_from_address_hash, + transfer_to_address_hash: transfer_to_address_hash, + transfer_amount: transfer_amount } end @@ -629,7 +925,7 @@ defmodule BlockScoutWeb.API.V2.OptimismController do defp interop_prepare_transaction_hash_filter(transaction_hash) when is_binary(transaction_hash) do transaction_hash |> String.trim() - |> Chain.string_to_transaction_hash() + |> Chain.string_to_full_hash() |> case do {:ok, hash} -> hash _ -> nil diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/polygon_edge_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/polygon_edge_controller.ex deleted file mode 100644 index 53157d08bdbe..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/polygon_edge_controller.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule BlockScoutWeb.API.V2.PolygonEdgeController do - use BlockScoutWeb, :controller - - import BlockScoutWeb.Chain, - only: [ - next_page_params: 3, - paging_options: 1, - split_list_by_page: 1 - ] - - alias Explorer.Chain.PolygonEdge.Reader - - action_fallback(BlockScoutWeb.API.V2.FallbackController) - - @spec deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() - def deposits(conn, params) do - {deposits, next_page} = - params - |> paging_options() - |> Keyword.put(:api?, true) - |> Reader.deposits() - |> split_list_by_page() - - next_page_params = next_page_params(next_page, deposits, params) - - conn - |> put_status(200) - |> render(:polygon_edge_deposits, %{ - deposits: deposits, - next_page_params: next_page_params - }) - end - - @spec deposits_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def deposits_count(conn, _params) do - count = Reader.deposits_count(api?: true) - - conn - |> put_status(200) - |> render(:polygon_edge_items_count, %{count: count}) - end - - @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() - def withdrawals(conn, params) do - {withdrawals, next_page} = - params - |> paging_options() - |> Keyword.put(:api?, true) - |> Reader.withdrawals() - |> split_list_by_page() - - next_page_params = next_page_params(next_page, withdrawals, params) - - conn - |> put_status(200) - |> render(:polygon_edge_withdrawals, %{ - withdrawals: withdrawals, - next_page_params: next_page_params - }) - end - - @spec withdrawals_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def withdrawals_count(conn, _params) do - count = Reader.withdrawals_count(api?: true) - - conn - |> put_status(200) - |> render(:polygon_edge_items_count, %{count: count}) - end -end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/polygon_zkevm_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/polygon_zkevm_controller.ex deleted file mode 100644 index e01b9a7caf9c..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/polygon_zkevm_controller.ex +++ /dev/null @@ -1,180 +0,0 @@ -defmodule BlockScoutWeb.API.V2.PolygonZkevmController do - use BlockScoutWeb, :controller - - import BlockScoutWeb.Chain, - only: [ - next_page_params: 3, - paging_options: 1, - split_list_by_page: 1 - ] - - alias Explorer.Chain.PolygonZkevm.Reader - - action_fallback(BlockScoutWeb.API.V2.FallbackController) - - @batch_necessity_by_association %{ - :sequence_transaction => :optional, - :verify_transaction => :optional, - :l2_transactions => :optional - } - - @batches_necessity_by_association %{ - :sequence_transaction => :optional, - :verify_transaction => :optional - } - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/batches/:batch_number` endpoint. - """ - @spec batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch(conn, %{"batch_number" => batch_number} = _params) do - case Reader.batch( - batch_number, - necessity_by_association: @batch_necessity_by_association, - api?: true - ) do - {:ok, batch} -> - conn - |> put_status(200) - |> render(:zkevm_batch, %{batch: batch}) - - {:error, :not_found} = res -> - res - end - end - - @doc """ - Function to handle GET requests to `/api/v2/main-page/zkevm/batches/latest-number` endpoint. - """ - @spec batch_latest_number(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch_latest_number(conn, _params) do - conn - |> put_status(200) - |> render(:zkevm_batch_latest_number, %{number: batch_latest_number()}) - end - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/batches` endpoint. - """ - @spec batches(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batches(conn, params) do - {batches, next_page} = - params - |> paging_options() - |> Keyword.put(:necessity_by_association, @batches_necessity_by_association) - |> Keyword.put(:api?, true) - |> Reader.batches() - |> split_list_by_page() - - next_page_params = next_page_params(next_page, batches, params) - - conn - |> put_status(200) - |> render(:zkevm_batches, %{ - batches: batches, - next_page_params: next_page_params - }) - end - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/batches/count` endpoint. - """ - @spec batches_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batches_count(conn, _params) do - conn - |> put_status(200) - |> render(:zkevm_batches_count, %{count: batch_latest_number()}) - end - - @doc """ - Function to handle GET requests to `/api/v2/main-page/zkevm/batches/confirmed` endpoint. - """ - @spec batches_confirmed(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batches_confirmed(conn, _params) do - batches = - [] - |> Keyword.put(:necessity_by_association, @batches_necessity_by_association) - |> Keyword.put(:api?, true) - |> Keyword.put(:confirmed?, true) - |> Reader.batches() - - conn - |> put_status(200) - |> render(:zkevm_batches, %{batches: batches}) - end - - defp batch_latest_number do - case Reader.batch(:latest, api?: true) do - {:ok, batch} -> batch.number - {:error, :not_found} -> 0 - end - end - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/deposits` endpoint. - """ - @spec deposits(Plug.Conn.t(), map()) :: Plug.Conn.t() - def deposits(conn, params) do - {deposits, next_page} = - params - |> paging_options() - |> Keyword.put(:api?, true) - |> Reader.deposits() - |> split_list_by_page() - - next_page_params = next_page_params(next_page, deposits, params) - - conn - |> put_status(200) - |> render(:polygon_zkevm_bridge_items, %{ - items: deposits, - next_page_params: next_page_params - }) - end - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/deposits/count` endpoint. - """ - @spec deposits_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def deposits_count(conn, _params) do - count = Reader.deposits_count(api?: true) - - conn - |> put_status(200) - |> render(:polygon_zkevm_bridge_items_count, %{count: count}) - end - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/withdrawals` endpoint. - """ - @spec withdrawals(Plug.Conn.t(), map()) :: Plug.Conn.t() - def withdrawals(conn, params) do - {withdrawals, next_page} = - params - |> paging_options() - |> Keyword.put(:api?, true) - |> Reader.withdrawals() - |> split_list_by_page() - - next_page_params = next_page_params(next_page, withdrawals, params) - - conn - |> put_status(200) - |> render(:polygon_zkevm_bridge_items, %{ - items: withdrawals, - next_page_params: next_page_params - }) - end - - @doc """ - Function to handle GET requests to `/api/v2/zkevm/withdrawals/count` endpoint. - """ - @spec withdrawals_count(Plug.Conn.t(), map()) :: Plug.Conn.t() - def withdrawals_count(conn, _params) do - count = Reader.withdrawals_count(api?: true) - - conn - |> put_status(200) - |> render(:polygon_zkevm_bridge_items_count, %{count: count}) - end -end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex index b5801beefcc8..38e7293642e2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller.ex @@ -1,8 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs alias BlockScoutWeb.API.V2.Helper alias BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, as: TransactionInterpretationService + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.{BadRequestResponse, NotFoundResponse, NotImplementedResponse} alias Explorer.Chain alias Explorer.MicroserviceInterfaces.AccountAbstraction @@ -10,31 +13,66 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["account-abstraction"]) + + operation :operation, + summary: "Get a user operation by hash", + description: "Retrieves a user operation by its hash.", + parameters: [operation_hash_param() | base_params()], + responses: [ + ok: {"User operation", "application/json", Schemas.Proxy.AccountAbstraction.UserOperation}, + not_found: NotFoundResponse.response(), + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/operations/:user_operation_hash_param` endpoint. """ @spec operation(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def operation(conn, %{"operation_hash_param" => operation_hash_string}) do + def operation(conn, %{operation_hash_param: operation_hash_string}) do operation_hash_string |> AccountAbstraction.get_user_ops_by_hash() |> process_response(conn) end + operation :summary, + summary: "Get a human-readable, LLM-based user operation summary", + description: "Retrieves a human-readable summary of what a user operation did, presented in natural language.", + parameters: base_params() ++ [operation_hash_param(), just_request_body_param()], + responses: [ + ok: + {"Human-readable summary of the specified user operation.", "application/json", + %Schema{ + anyOf: [ + Schemas.Transaction.Summary, + Schemas.Transaction.SummaryJustRequestBody + ] + }}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/operations/:user_operation_hash_param/summary` endpoint. """ @spec summary(Plug.Conn.t(), map()) :: {:error | :format | :transaction_interpreter_enabled | non_neg_integer(), any()} | Plug.Conn.t() - def summary(conn, %{"operation_hash_param" => operation_hash_string, "just_request_body" => "true"}) do - with {:format, {:ok, _operation_hash}} <- {:format, Chain.string_to_transaction_hash(operation_hash_string)}, + def summary(conn, %{operation_hash_param: operation_hash_string, just_request_body: true}) do + with {:format, {:ok, _operation_hash}} <- {:format, Chain.string_to_full_hash(operation_hash_string)}, + {:transaction_interpreter_enabled, true} <- + {:transaction_interpreter_enabled, TransactionInterpretationService.enabled?()}, {200, %{"hash" => _} = user_op} <- AccountAbstraction.get_user_ops_by_hash(operation_hash_string) do conn |> json(TransactionInterpretationService.get_user_op_request_body(user_op)) end end - def summary(conn, %{"operation_hash_param" => operation_hash_string}) do - with {:format, {:ok, _operation_hash}} <- {:format, Chain.string_to_transaction_hash(operation_hash_string)}, + def summary(conn, %{operation_hash_param: operation_hash_string}) do + with {:format, {:ok, _operation_hash}} <- {:format, Chain.string_to_full_hash(operation_hash_string)}, {:transaction_interpreter_enabled, true} <- {:transaction_interpreter_enabled, TransactionInterpretationService.enabled?()}, {200, %{"hash" => _} = user_op} <- AccountAbstraction.get_user_ops_by_hash(operation_hash_string) do @@ -51,16 +89,45 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do end end + operation :bundler, + summary: "Get a bundler by address hash", + description: "Retrieves a bundler by its address hash.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Bundler", "application/json", Schemas.Proxy.AccountAbstraction.Bundler}, + not_found: NotFoundResponse.response(), + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/bundlers/:address_hash_param` endpoint. """ @spec bundler(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def bundler(conn, %{"address_hash_param" => address_hash_string}) do + def bundler(conn, %{address_hash_param: address_hash_string}) do address_hash_string |> AccountAbstraction.get_bundler_by_hash() |> process_response(conn) end + operation :bundlers, + summary: "List of top bundlers", + description: "Retrieves a list of top bundlers.", + parameters: base_params() ++ define_paging_params(["page_size", "page_token"]), + responses: [ + ok: + {"List of bundlers with pagination.", "application/json", + paginated_response( + items: Schemas.Proxy.AccountAbstraction.Bundler, + next_page_params_example: %{ + "page_size" => 50, + "page_token" => "5,0x9B67A24A474e9EC7372c23B023f36ab28831e4C4" + } + )}, + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/bundlers` endpoint. """ @@ -71,16 +138,45 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do |> process_response(conn) end + operation :factory, + summary: "Get a factory by address hash", + description: "Retrieves a factory by its address hash.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Factory", "application/json", Schemas.Proxy.AccountAbstraction.Factory}, + not_found: NotFoundResponse.response(), + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/factories/:address_hash_param` endpoint. """ @spec factory(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def factory(conn, %{"address_hash_param" => address_hash_string}) do + def factory(conn, %{address_hash_param: address_hash_string}) do address_hash_string |> AccountAbstraction.get_factory_by_hash() |> process_response(conn) end + operation :factories, + summary: "List of top wallet factories", + description: "Retrieves a list of top wallet factories.", + parameters: base_params() ++ define_paging_params(["page_size", "page_token"]), + responses: [ + ok: + {"List of factories with pagination.", "application/json", + paginated_response( + items: Schemas.Proxy.AccountAbstraction.Factory, + next_page_params_example: %{ + "page_size" => 50, + "page_token" => "3,0xC23957e7Fea98eBD017abe15a4e7770797ff6D8d" + } + )}, + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/factories` endpoint. """ @@ -91,16 +187,45 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do |> process_response(conn) end + operation :paymaster, + summary: "Get a paymaster by address hash", + description: "Retrieves a paymaster by its address hash.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Paymaster", "application/json", Schemas.Proxy.AccountAbstraction.Paymaster}, + not_found: NotFoundResponse.response(), + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/paymasters/:address_hash_param` endpoint. """ @spec paymaster(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def paymaster(conn, %{"address_hash_param" => address_hash_string}) do + def paymaster(conn, %{address_hash_param: address_hash_string}) do address_hash_string |> AccountAbstraction.get_paymaster_by_hash() |> process_response(conn) end + operation :paymasters, + summary: "List of top paymasters", + description: "Retrieves a list of top paymasters.", + parameters: base_params() ++ define_paging_params(["page_size", "page_token"]), + responses: [ + ok: + {"List of paymasters with pagination.", "application/json", + paginated_response( + items: Schemas.Proxy.AccountAbstraction.Paymaster, + next_page_params_example: %{ + "page_size" => 50, + "page_token" => "19,0xB98Cb1dA4F9BD640879F0bBCb30A541c84163406" + } + )}, + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/paymasters` endpoint. """ @@ -111,16 +236,45 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do |> process_response(conn) end + operation :account, + summary: "Get an account abstraction wallet by address hash", + description: "Retrieves an account abstraction wallet by its address hash.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Account", "application/json", Schemas.Proxy.AccountAbstraction.Account}, + not_found: NotFoundResponse.response(), + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/accounts/:address_hash_param` endpoint. """ @spec account(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def account(conn, %{"address_hash_param" => address_hash_string}) do + def account(conn, %{address_hash_param: address_hash_string}) do address_hash_string |> AccountAbstraction.get_account_by_hash() |> process_response(conn) end + operation :accounts, + summary: "List of account abstraction wallets", + description: "Retrieves a list of account abstraction wallets.", + parameters: [factory_address_hash_param() | base_params()] ++ define_paging_params(["page_size", "page_token"]), + responses: [ + ok: + {"List of account abstraction wallets with pagination.", "application/json", + paginated_response( + items: Schemas.Proxy.AccountAbstraction.Account, + next_page_params_example: %{ + "page_size" => 50, + "page_token" => "0x29cB129476609bBa26372b23427Af3b87cB23aF6" + } + )}, + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/accounts` endpoint. """ @@ -131,6 +285,27 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do |> process_response(conn) end + operation :bundles, + summary: "List of recent bundles", + description: "Retrieves a list of recent bundles.", + parameters: + base_params() ++ + [bundler_address_hash_param(), entry_point_address_hash_param()] ++ + define_paging_params(["page_size", "page_token"]), + responses: [ + ok: + {"List of bundles with pagination.", "application/json", + paginated_response( + items: Schemas.Proxy.AccountAbstraction.Bundle, + next_page_params_example: %{ + "page_size" => 50, + "page_token" => "3949699,0x275f7110df7d73e530f3381daa9d422dea80f036b514e56427d2d7e492c8a81f,0" + } + )}, + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/bundles` endpoint. """ @@ -141,6 +316,35 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do |> process_response(conn) end + operation :operations, + summary: "List of recent user operations", + description: "Retrieves a list of recent user operations.", + parameters: + base_params() ++ + [ + sender_address_hash_param(), + bundler_address_hash_param(), + paymaster_address_hash_param(), + factory_address_hash_param(), + query_transaction_hash_param(), + entry_point_address_hash_param(), + bundle_index_param(), + query_block_number_param() + ] ++ define_paging_params(["page_size", "page_token"]), + responses: [ + ok: + {"List of user operations with pagination.", "application/json", + paginated_response( + items: Schemas.Proxy.AccountAbstraction.UserOperationInList, + next_page_params_example: %{ + "page_size" => 50, + "page_token" => "3937439,0xbb680271614883525ac8056c388489e80b3518ec12ec46e1b910c7238c46b565" + } + )}, + bad_request: BadRequestResponse.response(), + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/operations` endpoint. """ @@ -151,6 +355,15 @@ defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionController do |> process_response(conn) end + operation :status, + summary: "Get the status of the account abstraction microservice", + description: "Retrieves the status of the account abstraction microservice.", + parameters: base_params(), + responses: [ + ok: {"Status", "application/json", Schemas.Proxy.AccountAbstraction.Status}, + not_implemented: NotImplementedResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/proxy/account-abstraction/status` endpoint. """ diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/metadata_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/metadata_controller.ex index b8b68939110c..04506ad613a3 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/metadata_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/metadata_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.Proxy.MetadataController do @moduledoc """ Controller for the metadata service diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/noves_fi_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/noves_fi_controller.ex index 11a309af7429..3d762a3775e7 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/noves_fi_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/noves_fi_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.Proxy.NovesFiController do use BlockScoutWeb, :controller @@ -8,7 +9,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.NovesFiController do action_fallback(BlockScoutWeb.API.V2.FallbackController) @doc """ - Function to handle GET requests to `/api/v2/proxy/noves-fi/transactions/:transaction_hash_param` endpoint. + Function to handle GET requests to `/api/v2/proxy/3rdparty/noves-fi/transactions/:transaction_hash_param` endpoint. """ @spec transaction(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def transaction(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do @@ -27,7 +28,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.NovesFiController do end @doc """ - Function to handle GET requests to `/api/v2/proxy/noves-fi/addresses/:address_hash_param/transactions` endpoint. + Function to handle GET requests to `/api/v2/proxy/3rdparty/noves-fi/addresses/:address_hash_param/transactions` endpoint. """ @spec address_transactions(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def address_transactions(conn, %{"address_hash_param" => address_hash_string} = params) do @@ -42,7 +43,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.NovesFiController do end @doc """ - Function to handle GET requests to `/api/v2/proxy/noves-fi/transaction-descriptions` endpoint. + Function to handle GET requests to `/api/v2/proxy/3rdparty/noves-fi/transaction-descriptions` endpoint. """ @spec describe_transactions(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def describe_transactions(conn, _) do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/solidity_scan_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/solidity_scan_controller.ex new file mode 100644 index 000000000000..f005dd0427c9 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/solidity_scan_controller.ex @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.Proxy.SolidityScanController do + use BlockScoutWeb, :controller + + alias BlockScoutWeb.AccessHelper + alias Explorer.Chain + alias Explorer.Chain.{Address, SmartContract} + alias Explorer.ThirdPartyIntegrations.SolidityScan + + @api_true [api?: true] + + @doc """ + /api/v2/proxy/3rdparty/solidityscan/smart-contracts/:address_hash_string/report logic + """ + @spec solidityscan_report(Plug.Conn.t(), map()) :: + {:address, {:error, :not_found}} + | {:format_address, :error} + | {:is_empty_response, true} + | {:is_smart_contract, false | nil} + | {:restricted_access, true} + | {:is_verified_smart_contract, false} + | {:language, :vyper} + | Plug.Conn.t() + def solidityscan_report(conn, %{"address_hash" => address_hash_string} = params) do + with {:format_address, {:ok, address_hash}} <- {:format_address, Chain.string_to_address_hash(address_hash_string)}, + {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), + {:address, {:ok, address}} <- {:address, Chain.hash_to_address(address_hash)}, + {:is_smart_contract, true} <- {:is_smart_contract, Address.smart_contract?(address)}, + smart_contract = SmartContract.address_hash_to_smart_contract(address_hash, @api_true), + {:is_verified_smart_contract, true} <- {:is_verified_smart_contract, !is_nil(smart_contract)}, + {:language, language} when language != :vyper <- {:language, smart_contract.language}, + response = SolidityScan.solidityscan_request(address_hash_string), + {:is_empty_response, false} <- {:is_empty_response, is_nil(response)} do + conn + |> put_status(200) + |> json(response) + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/universal_proxy_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/universal_proxy_controller.ex index 7f239684bb06..9ef8aa72e624 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/universal_proxy_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/universal_proxy_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.Proxy.UniversalProxyController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/xname_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/xname_controller.ex index aae469fd60b5..49ad5cfc7a0a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/xname_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/proxy/xname_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.Proxy.XnameController do use BlockScoutWeb, :controller @@ -8,7 +9,7 @@ defmodule BlockScoutWeb.API.V2.Proxy.XnameController do action_fallback(BlockScoutWeb.API.V2.FallbackController) @doc """ - Function to handle GET requests to `/api/v2/proxy/xname/address/:address_hash_param` endpoint. + Function to handle GET requests to `/api/v2/proxy/3rdparty/xname/address/:address_hash_param` endpoint. """ @spec address(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def address(conn, %{"address_hash_param" => address_hash_string} = params) do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex index 1374369284ad..ceaeb173fce3 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/scroll_controller.ex @@ -1,5 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ScrollController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [ @@ -8,24 +10,43 @@ defmodule BlockScoutWeb.API.V2.ScrollController do split_list_by_page: 1 ] - import BlockScoutWeb.PagingHelper, - only: [ - delete_parameters_from_next_page_params: 1 - ] - + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse alias Explorer.Chain.Scroll.Reader + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["scroll"]) + @api_true [api?: true] action_fallback(BlockScoutWeb.API.V2.FallbackController) @batch_necessity_by_association %{:bundle => :optional} + operation :batch, + summary: "Batch by its number.", + description: "Retrieves batch info by the given number.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :number, + in: :path, + schema: Schemas.General.IntegerString, + required: true, + description: "Batch number in the path." + } + | base_params() + ], + responses: [ + ok: {"Batch info.", "application/json", Schemas.Scroll.Batch}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/batches/:number` endpoint. """ @spec batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def batch(conn, %{"number" => number}) do + def batch(conn, %{number: number}) do {number, ""} = Integer.parse(number) options = @@ -43,6 +64,29 @@ defmodule BlockScoutWeb.API.V2.ScrollController do end end + operation :batches, + summary: "List batches.", + description: "Retrieves a paginated list of batches.", + parameters: + base_params() ++ + define_paging_params([ + "items_count", + "number" + ]), + responses: [ + ok: + {"List of batches.", "application/json", + paginated_response( + items: Schemas.Scroll.Batch, + next_page_params_example: %{ + "items_count" => 50, + "number" => 502_655 + }, + title_prefix: "Batches" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/batches` endpoint. """ @@ -55,7 +99,7 @@ defmodule BlockScoutWeb.API.V2.ScrollController do |> Reader.batches() |> split_list_by_page() - next_page_params = next_page_params(next_page, batches, delete_parameters_from_next_page_params(params)) + next_page_params = next_page_params(next_page, batches, params) conn |> put_status(200) @@ -65,6 +109,15 @@ defmodule BlockScoutWeb.API.V2.ScrollController do }) end + operation :batches_count, + summary: "Number of batches in the list.", + description: "Retrieves a size of the batch list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the batch list.", "application/json", %Schema{type: :integer, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/batches/count` endpoint. """ @@ -75,6 +128,29 @@ defmodule BlockScoutWeb.API.V2.ScrollController do |> render(:scroll_batches_count, %{count: batch_latest_number() + 1}) end + operation :deposits, + summary: "List deposits.", + description: "Retrieves a paginated list of deposits.", + parameters: + base_params() ++ + define_paging_params([ + "items_count", + "id" + ]), + responses: [ + ok: + {"List of deposits.", "application/json", + paginated_response( + items: Schemas.Scroll.Bridge, + next_page_params_example: %{ + "items_count" => 50, + "id" => 986_043 + }, + title_prefix: "Deposits" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/deposits` endpoint. """ @@ -98,6 +174,15 @@ defmodule BlockScoutWeb.API.V2.ScrollController do }) end + operation :deposits_count, + summary: "Number of deposits in the list.", + description: "Retrieves a size of the deposits list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the deposits list.", "application/json", %Schema{type: :integer, nullable: true}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/deposits/count` endpoint. """ @@ -110,6 +195,29 @@ defmodule BlockScoutWeb.API.V2.ScrollController do |> render(:scroll_bridge_items_count, %{count: count}) end + operation :withdrawals, + summary: "List withdrawals.", + description: "Retrieves a paginated list of withdrawals.", + parameters: + base_params() ++ + define_paging_params([ + "items_count", + "id" + ]), + responses: [ + ok: + {"List of withdrawals.", "application/json", + paginated_response( + items: Schemas.Scroll.Bridge, + next_page_params_example: %{ + "items_count" => 50, + "id" => 220_243 + }, + title_prefix: "Withdrawals" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/withdrawals` endpoint. """ @@ -133,6 +241,15 @@ defmodule BlockScoutWeb.API.V2.ScrollController do }) end + operation :withdrawals_count, + summary: "Number of withdrawals in the list.", + description: "Retrieves a size of the withdrawals list.", + parameters: base_params(), + responses: [ + ok: {"Number of items in the withdrawals list.", "application/json", %Schema{type: :integer, nullable: true}}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/scroll/withdrawals/count` endpoint. """ diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex index 1b9bb7ebd08a..f25a0f42e0fe 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/search_controller.ex @@ -1,16 +1,54 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.SearchController do - use Phoenix.Controller + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [from_param: 1, fetch_scam_token_toggle: 2] import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens_info_to_search_results: 1] alias Explorer.Chain.Search alias Explorer.PagingOptions + alias OpenApiSpex.Schema + + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["search"]) @api_true [api?: true] @min_query_length 3 - def search(conn, %{"q" => query} = params) do + operation :search, + summary: "Search for tokens, addresses, contracts, blocks, or transactions by identifier", + description: + "Performs a unified search across multiple blockchain entity types including tokens, addresses, contracts, blocks, transactions and other resources.", + parameters: + [q_param() | base_params()] ++ + define_search_paging_params([ + "next_page_params_type", + "label", + "token", + "contract", + "tac_operation", + "metadata_tag", + "block", + "blob", + "user_operation", + "address", + "ens_domain" + ]), + responses: [ + ok: + {"Successful search response containing matched items and pagination information. + Results are ordered by relevance and limited to 50 items per page.", "application/json", + Schemas.Search.Results}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Performs a joint search for blocks, transactions, addresses and other resources. + """ + @spec search(Plug.Conn.t(), map()) :: Plug.Conn.t() + def search(conn, %{q: query} = params) do [paging_options: paging_options] = Search.parse_paging_options(params) options = @api_true |> fetch_scam_token_toggle(conn) @@ -25,7 +63,33 @@ defmodule BlockScoutWeb.API.V2.SearchController do }) end - def check_redirect(conn, %{"q" => query}) do + operation :check_redirect, + summary: "Check if search query should redirect to a specific entity page", + description: "Checks if a search query redirects to a specific entity page rather than showing search results.", + parameters: [q_param() | base_params()], + responses: [ + ok: + {"Response indicating whether the query should redirect to a specific entity page.", "application/json", + %Schema{ + type: :object, + properties: %{ + parameter: %Schema{type: :string, nullable: true}, + redirect: %Schema{type: :boolean, nullable: true}, + type: %Schema{ + type: :string, + enum: ["address", "block", "transaction", "user_operation", "blob"], + nullable: true + } + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Check redirect target for a query. + """ + @spec check_redirect(Plug.Conn.t(), map()) :: Plug.Conn.t() + def check_redirect(conn, %{q: query}) do result = query |> String.trim() @@ -36,13 +100,28 @@ defmodule BlockScoutWeb.API.V2.SearchController do |> render(:search_results, %{result: result}) end - def quick_search(conn, %{"q" => query}) when byte_size(query) < @min_query_length do + operation :quick_search, + summary: "Quick (unpaginated) search", + description: "Performs a quick, unpaginated search for short queries.", + parameters: [q_param() | base_params()], + responses: [ + ok: + {"Quick search results.", "application/json", + %Schema{type: :array, items: %Schema{type: :object}, nullable: false}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Performs a quick, unpaginated search for short queries. + """ + @spec quick_search(Plug.Conn.t(), map()) :: Plug.Conn.t() + def quick_search(conn, %{q: query}) when byte_size(query) < @min_query_length do conn |> put_status(200) |> render(:search_results, %{search_results: []}) end - def quick_search(conn, %{"q" => query}) do + def quick_search(conn, %{q: query}) do options = @api_true |> fetch_scam_token_toggle(conn) search_results = Search.balanced_unpaginated_search(%PagingOptions{page_size: 50}, query, options) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex index a1e7532e54a3..4b2c9e47464d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/shibarium_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ShibariumController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex index fb473d19a5a1..33352f186242 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/smart_contract_controller.ex @@ -1,5 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.SmartContractController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -7,14 +9,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do import BlockScoutWeb.Chain, only: [ fetch_scam_token_toggle: 2, - next_page_params: 4, + next_page_params: 5, split_list_by_page: 1 ] import BlockScoutWeb.PagingHelper, only: [ current_filter: 1, - delete_parameters_from_next_page_params: 1, search_query: 1 ] @@ -23,59 +24,47 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do default_paging_options: 0 ] - import Explorer.Helper, only: [parse_integer: 1] + import Explorer.Helper, + only: [ + parse_integer: 1, + safe_parse_non_negative_integer: 1 + ] - alias BlockScoutWeb.{AccessHelper, CaptchaHelper} + alias BlockScoutWeb.AccessHelper alias Explorer.Chain alias Explorer.Chain.{Address, SmartContract} alias Explorer.Chain.SmartContract.AuditReport alias Explorer.SmartContract.Helper, as: SmartContractHelper alias Explorer.SmartContract.Solidity.PublishHelper - alias Explorer.ThirdPartyIntegrations.SolidityScan + + action_fallback(BlockScoutWeb.API.V2.FallbackController) @api_true [api?: true] - @spec contract_creation_transaction_associations() :: [keyword()] - defp contract_creation_transaction_associations do - case chain_type() do - :filecoin -> - Address.contract_creation_transaction_with_from_address_associations() + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) - _ -> - Address.contract_creation_transaction_associations() - end - end + tags(["smart-contracts"]) - @spec smart_contract_address_options() :: keyword() - defp smart_contract_address_options do - [ - necessity_by_association: %{ - [smart_contract: :smart_contract_additional_sources] => :optional, - contract_creation_transaction_associations() => :optional - } - ] - |> Keyword.merge(@api_true) - end - - @spec verified_smart_contract_addresses_options() :: keyword() - defp verified_smart_contract_addresses_options do - [ - necessity_by_association: %{ - [:token, :names, :proxy_implementations] => :optional, - contract_creation_transaction_associations() => :optional - } + operation :smart_contract, + summary: "Retrieve detailed information about a verified smart contract", + description: + "Retrieves detailed information about a specific verified smart contract, including source code, ABI, and deployment details.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: + {"Detailed information about the specified verified smart contract.", "application/json", Schemas.SmartContract} ] - |> Keyword.merge(@api_true) - end - action_fallback(BlockScoutWeb.API.V2.FallbackController) - - def smart_contract(conn, %{"address_hash" => address_hash_string} = params) do + @doc """ + GET /api/v2/smart-contracts/:address_hash_param + """ + @spec smart_contract(Plug.Conn.t(), map()) :: Plug.Conn.t() + def smart_contract(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), _ <- PublishHelper.sourcify_check(address_hash_string), - {:not_found, {:ok, address}} <- - {:not_found, Chain.find_contract_address(address_hash, smart_contract_address_options())} do + {:not_found, {:ok, %Address{} = address}} <- + {:not_found, Address.find_contract_address(address_hash, smart_contract_address_options())} do implementations = SmartContractHelper.pre_fetch_implementations(address) conn @@ -84,34 +73,48 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do end end + operation :smart_contracts_list, + summary: "List verified smart contracts with optional filtering options", + description: + "Retrieves a paginated list of verified smart contracts with optional filtering by proxy status or programming language.", + parameters: + base_params() ++ + [ + sort_param(["balance", "transactions_count"]), + order_param(), + q_param(), + %OpenApiSpex.Parameter{ + name: :filter, + in: :query, + schema: Schemas.SmartContract.Language, + required: false, + description: "Filter to apply" + } + ] ++ + define_paging_params([ + "smart_contract_id", + "coin_balance", + "address_hash", + "transactions_count", + "items_count" + ]), + responses: [ + ok: + {"List of verified smart contracts matching the filter criteria, with pagination.", "application/json", + paginated_response( + items: Schemas.SmartContract.ListItem, + next_page_params_example: %{ + "smart_contract_id" => 1_947_801, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - /api/v2/smart-contracts/:address_hash_string/solidityscan-report logic + Function to handle GET requests to `/api/v2/smart-contracts` endpoint. """ - @spec solidityscan_report(Plug.Conn.t(), map()) :: - {:address, {:error, :not_found}} - | {:format_address, :error} - | {:is_empty_response, true} - | {:is_smart_contract, false | nil} - | {:restricted_access, true} - | {:is_verified_smart_contract, false} - | {:language, :vyper} - | Plug.Conn.t() - def solidityscan_report(conn, %{"address_hash" => address_hash_string} = params) do - with {:format_address, {:ok, address_hash}} <- {:format_address, Chain.string_to_address_hash(address_hash_string)}, - {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:address, {:ok, address}} <- {:address, Chain.hash_to_address(address_hash)}, - {:is_smart_contract, true} <- {:is_smart_contract, Address.smart_contract?(address)}, - smart_contract = SmartContract.address_hash_to_smart_contract(address_hash, @api_true), - {:is_verified_smart_contract, true} <- {:is_verified_smart_contract, !is_nil(smart_contract)}, - {:language, language} when language != :vyper <- {:language, SmartContract.language(smart_contract)}, - response = SolidityScan.solidityscan_request(address_hash_string), - {:is_empty_response, false} <- {:is_empty_response, is_nil(response)} do - conn - |> put_status(200) - |> json(response) - end - end - + @spec smart_contracts_list(Plug.Conn.t(), map()) :: Plug.Conn.t() def smart_contracts_list(conn, params) do full_options = verified_smart_contract_addresses_options() @@ -124,12 +127,25 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do addresses_plus_one = SmartContract.verified_contract_addresses(full_options) {addresses, next_page} = split_list_by_page(addresses_plus_one) + # If no sorting options are provided, we sort by `id` descending only. If + # there are some sorting options supplied, we sort by `:hash` ascending as a + # secondary key. + pager = + full_options + |> Keyword.get(:sorting) + |> if do + &smart_contract_addresses_paging_params/1 + else + &%{smart_contract_id: &1.smart_contract.id} + end + next_page_params = next_page |> next_page_params( addresses, - delete_parameters_from_next_page_params(params), - &smart_contract_addresses_paging_params/1 + params, + false, + pager ) conn @@ -140,87 +156,87 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do }) end - @doc """ - Builds paging options for smart contract addresses based on request - parameters. - - ## Returns - If 'hash', 'transaction_count', and 'coin_balance' parameters are provided, - uses them as pagination keys. Otherwise, returns default paging options. + operation :smart_contracts_counters, + summary: "Get count statistics (new & newly verified) for deployed smart contracts", + description: + "Retrieves count statistics for smart contracts, including total contracts, verified contracts, and new contracts in the last 24 hours.", + parameters: base_params(), + responses: [ + ok: {"Count statistics for smart contracts.", "application/json", Schemas.SmartContract.Counters}, + unprocessable_entity: JsonErrorResponse.response() + ] - ## Examples - iex> smart_contract_addresses_paging_options(%{"hash" => "0x123...", "transaction_count" => "100", "coin_balance" => "1000"}) - [paging_options: %{key: %{hash: ..., transactions_count: 100, fetched_coin_balance: 1000}}] + @doc """ + Function to handle GET requests to `/api/v2/smart-contracts/counters` endpoint. """ - @spec smart_contract_addresses_paging_options(%{required(String.t()) => String.t()}) :: - [paging_options: map()] - def smart_contract_addresses_paging_options(params) do - options = - with %{"hash" => hash_string} <- params, - {:ok, address_hash} <- Chain.string_to_address_hash(hash_string) do - transactions_count = parse_integer(params["transaction_count"]) - coin_balance = parse_integer(params["coin_balance"]) - - %{ - key: %{ - hash: address_hash, - transactions_count: transactions_count, - fetched_coin_balance: coin_balance - } - } - else - _ -> %{} - end - - [paging_options: default_paging_options() |> Map.merge(options)] + @spec smart_contracts_counters(Plug.Conn.t(), map()) :: Plug.Conn.t() + def smart_contracts_counters(conn, _params) do + conn + |> json(%{ + smart_contracts: Chain.count_contracts_from_cache(@api_true), + new_smart_contracts_24h: Chain.count_new_contracts_from_cache(@api_true), + verified_smart_contracts: Chain.count_verified_contracts_from_cache(@api_true), + new_verified_smart_contracts_24h: Chain.count_new_verified_contracts_from_cache(@api_true) + }) end - @doc """ - Extracts pagination parameters from an Address struct for use in the next page - URL. - - ## Returns - A map with string keys that can be used as query parameters. + operation :audit_reports_list, + summary: "Audit reports list", + description: "Returns audit reports for a given smart contract address.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: + {"Audit reports.", "application/json", + %Schema{ + description: "List of smart-contract's audit reports", + type: :object, + properties: %{ + items: %Schema{ + type: :array, + items: Schemas.SmartContract.AuditReport, + nullable: false + }, + next_page_params: %Schema{type: :object, nullable: true, additionalProperties: true} + }, + required: [:items, :next_page_params], + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ] - ## Examples - iex> address = %Explorer.Chain.Address{hash: "0x123...", transactions_count: 100, fetched_coin_balance: 1000} - iex> smart_contract_addresses_paging_params(address) - %{"hash" => "0x123...", "transaction_count" => 100, "coin_balance" => 1000} + @doc """ + GET /api/v2/smart-contracts/{address_hash}/audit-reports """ - @spec smart_contract_addresses_paging_params(Explorer.Chain.Address.t()) :: %{ - required(String.t()) => any() - } - def smart_contract_addresses_paging_params(%Explorer.Chain.Address{ - hash: address_hash, - transactions_count: transactions_count, - fetched_coin_balance: coin_balance - }) do - %{ - "hash" => address_hash, - # todo: It should be removed in favour: transactions_count - "transaction_count" => transactions_count, - "transactions_count" => transactions_count, - "coin_balance" => coin_balance - } - end - - @spec smart_contract_addresses_sorting(%{required(String.t()) => String.t()}) :: [ - {:sorting, list()} - ] - defp smart_contract_addresses_sorting(%{"sort" => sort_field, "order" => order}) do - sorting = - case {sort_field, order} do - {"balance", "asc"} -> [{:asc_nulls_first, :fetched_coin_balance}] - {"balance", "desc"} -> [{:desc_nulls_last, :fetched_coin_balance}] - {"transactions_count", "asc"} -> [{:asc_nulls_first, :transactions_count}] - {"transactions_count", "desc"} -> [{:desc_nulls_last, :transactions_count}] - _ -> [] - end + @spec audit_reports_list(Plug.Conn.t(), map()) :: + {:format, :error} + | {:not_found, nil | Explorer.Chain.SmartContract.t()} + | {:restricted_access, true} + | Plug.Conn.t() + def audit_reports_list(conn, %{address_hash_param: address_hash_string} = params) do + with {:ok, address_hash, _smart_contract} <- validate_smart_contract(params, address_hash_string) do + reports = AuditReport.get_audit_reports_by_smart_contract_address_hash(address_hash, @api_true) - [sorting: sorting] + conn + |> render(:audit_reports, %{reports: reports}) + end end - defp smart_contract_addresses_sorting(_), do: [] + operation :audit_report_submission, + summary: "Submit audit report", + description: "Submits an audit report for a given smart contract address.", + parameters: [address_hash_param() | base_params()], + request_body: audit_report_request_body(), + responses: [ + ok: + {"Audit report submission is successful.", "application/json", + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + message: %Schema{type: :string} + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] @doc """ POST /api/v2/smart-contracts/{address_hash}/audit-reports @@ -232,21 +248,20 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do | {:recaptcha, any()} | {:restricted_access, true} | Plug.Conn.t() - def audit_report_submission(conn, %{"address_hash" => address_hash_string} = params) do + def audit_report_submission(conn, %{address_hash_param: address_hash_string} = params) do with {:disabled, true} <- {:disabled, Application.get_env(:explorer, :air_table_audit_reports)[:enabled]}, {:ok, address_hash, _smart_contract} <- validate_smart_contract(params, address_hash_string), - {:recaptcha, _} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params)}, audit_report_params <- %{ address_hash: address_hash, - submitter_name: params["submitter_name"], - submitter_email: params["submitter_email"], - is_project_owner: params["is_project_owner"], - project_name: params["project_name"], - project_url: params["project_url"], - audit_company_name: params["audit_company_name"], - audit_report_url: params["audit_report_url"], - audit_publish_date: params["audit_publish_date"], - comment: params["comment"] + submitter_name: params[:submitter_name], + submitter_email: params[:submitter_email], + is_project_owner: params[:is_project_owner], + project_name: params[:project_name], + project_url: params[:project_url], + audit_company_name: params[:audit_company_name], + audit_report_url: params[:audit_report_url], + audit_publish_date: params[:audit_publish_date], + comment: params[:comment] }, {:ok, _} <- AuditReport.create(audit_report_params) do conn @@ -255,35 +270,154 @@ defmodule BlockScoutWeb.API.V2.SmartContractController do end end + @spec contract_creation_transaction_associations() :: [keyword()] + defp contract_creation_transaction_associations do + case chain_type() do + :filecoin -> + [Address.contract_creation_transaction_with_from_address_association()] + + _ -> + [Address.contract_creation_transaction_association()] + end + end + + @spec smart_contract_address_options() :: keyword() + defp smart_contract_address_options do + [ + necessity_by_association: %{ + [smart_contract: :smart_contract_additional_sources] => :optional, + contract_creation_transaction_associations() => :optional + }, + preload_contract_creation_internal_transaction: true + ] + |> Keyword.merge(@api_true) + end + + @spec verified_smart_contract_addresses_options() :: keyword() + defp verified_smart_contract_addresses_options do + [ + necessity_by_association: %{ + [:token, :names, :proxy_implementations] => :optional, + contract_creation_transaction_associations() => :optional + }, + preload_contract_creation_internal_transaction: true + ] + |> Keyword.merge(@api_true) + end + @doc """ - GET /api/v2/smart-contracts/{address_hash}/audit-reports + Builds paging options for smart contract addresses based on request + parameters. + + ## Returns + If 'hash', 'transactions_count', and 'coin_balance' parameters are provided, + uses them as pagination keys for address-based sorting. If 'smart_contract_id' + parameter is provided, uses it as pagination key for smart contract ID-based + sorting. Otherwise, returns default paging options. + + ## Examples + iex> smart_contract_addresses_paging_options(%{hash: "0x123...", transactions_count: "100", coin_balance: "1000"}) + [paging_options: %{key: %{hash: ..., transactions_count: 100, fetched_coin_balance: 1000}}] + + iex> smart_contract_addresses_paging_options(%{smart_contract_id: "42"}) + [paging_options: %{key: %{id: 42}}] + + iex> smart_contract_addresses_paging_options(%{}) + [paging_options: %{}] """ - @spec audit_reports_list(Plug.Conn.t(), map()) :: - {:format, :error} - | {:not_found, nil | Explorer.Chain.SmartContract.t()} - | {:restricted_access, true} - | Plug.Conn.t() - def audit_reports_list(conn, %{"address_hash" => address_hash_string} = params) do - with {:ok, address_hash, _smart_contract} <- validate_smart_contract(params, address_hash_string) do - reports = AuditReport.get_audit_reports_by_smart_contract_address_hash(address_hash, @api_true) + @spec smart_contract_addresses_paging_options(%{required(atom()) => String.t()}) :: + [paging_options: map()] + def smart_contract_addresses_paging_options(params) do + options = do_smart_contract_addresses_paging_options(params) + [paging_options: default_paging_options() |> Map.merge(options)] + end - conn - |> render(:audit_reports, %{reports: reports}) + @spec do_smart_contract_addresses_paging_options(%{required(atom()) => String.t()}) :: map() + defp do_smart_contract_addresses_paging_options(%{hash: hash_string} = params) do + hash_string + |> Chain.string_to_address_hash() + |> case do + {:ok, address_hash} -> + transactions_count = parse_integer(params[:transactions_count]) + coin_balance = parse_integer(params[:coin_balance]) + + %{ + key: %{ + hash: address_hash, + transactions_count: transactions_count, + fetched_coin_balance: coin_balance + } + } + + _ -> + %{} end end - def smart_contracts_counters(conn, _params) do - conn - |> json(%{ - smart_contracts: Chain.count_contracts_from_cache(@api_true), - new_smart_contracts_24h: Chain.count_new_contracts_from_cache(@api_true), - verified_smart_contracts: Chain.count_verified_contracts_from_cache(@api_true), - new_verified_smart_contracts_24h: Chain.count_new_verified_contracts_from_cache(@api_true) - }) + defp do_smart_contract_addresses_paging_options(%{smart_contract_id: smart_contract_id}) + when is_integer(smart_contract_id) do + %{key: %{id: smart_contract_id}} + end + + # todo: remove this function clause when all controllers are covered with OpenAPI spec + defp do_smart_contract_addresses_paging_options(%{smart_contract_id: smart_contract_id}) do + smart_contract_id + |> safe_parse_non_negative_integer() + |> case do + {:ok, id} -> %{key: %{id: id}} + _ -> %{} + end + end + + defp do_smart_contract_addresses_paging_options(_params), do: %{} + + # Extracts pagination parameters from an Address struct for use in the next page + # URL. + + # ## Returns + # A map with string keys that can be used as query parameters. + + # ## Examples + # iex> address = %Explorer.Chain.Address{hash: "0x123...", transactions_count: 100, fetched_coin_balance: 1000} + # iex> smart_contract_addresses_paging_params(address) + # %{hash: "0x123...", transactions_count: 100, coin_balance: 1000} + @spec smart_contract_addresses_paging_params(Explorer.Chain.Address.t()) :: %{ + required(atom()) => any() + } + defp smart_contract_addresses_paging_params(%Explorer.Chain.Address{ + hash: address_hash, + transactions_count: transactions_count, + fetched_coin_balance: coin_balance + }) do + %{ + hash: address_hash, + transactions_count: transactions_count, + coin_balance: coin_balance + } + end + + @spec smart_contract_addresses_sorting(%{required(atom()) => String.t()}) :: [ + {:sorting, list()} + ] + defp smart_contract_addresses_sorting(%{sort: sort_field, order: order}) do + {sort_field, order} + |> case do + {"balance", "asc"} -> {:ok, [{:asc_nulls_first, :fetched_coin_balance}]} + {"balance", "desc"} -> {:ok, [{:desc_nulls_last, :fetched_coin_balance}]} + {"transactions_count", "asc"} -> {:ok, [{:asc_nulls_first, :transactions_count}]} + {"transactions_count", "desc"} -> {:ok, [{:desc_nulls_last, :transactions_count}]} + _ -> :error + end + |> case do + {:ok, sorting_params} -> + [sorting: sorting_params] + + :error -> + [] + end end - def prepare_args(list) when is_list(list), do: list - def prepare_args(other), do: [other] + defp smart_contract_addresses_sorting(_), do: [] defp validate_smart_contract(params, address_hash_string) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex index 298ba07f2e06..96ab4cf6b359 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/stats_controller.ex @@ -1,19 +1,57 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.StatsController do - use Phoenix.Controller - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs + + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + import BlockScoutWeb.PagingHelper, only: [hot_smart_contracts_sorting: 1, delete_items_count_from_next_page_params: 1] + + import BlockScoutWeb.Chain, + only: [ + hot_smart_contracts_paging_options: 1, + split_list_by_page: 1, + next_page_params: 5, + fetch_scam_token_toggle: 2 + ] + + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + + alias OpenApiSpex.Schema alias BlockScoutWeb.API.V2.Helper alias BlockScoutWeb.Chain.MarketHistoryChartController alias Explorer.{Chain, Market} - alias Explorer.Chain.Cache.GasPriceOracle alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, BlocksCount, GasUsageSum, TransactionsCount} + alias Explorer.Chain.Cache.GasPriceOracle alias Explorer.Chain.Supply.RSK alias Explorer.Chain.Transaction.History.TransactionStats + alias Explorer.Stats.{HotSmartContracts, HotSmartContractsCache} alias Plug.Conn alias Timex.Duration + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["stats"]) + @api_true [api?: true] + operation :stats, + summary: "Retrieve blockchain network statistics and metrics", + description: + "Retrieves blockchain network statistics including total blocks, transactions, addresses, average block time, market data, and network utilization.", + parameters: base_params(), + responses: [ + ok: {"Blockchain network statistics.", "application/json", Schemas.Stats.Response}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Returns current indexing progress, chain stats and market data used on the UI. + """ + @spec stats(Plug.Conn.t(), map()) :: Plug.Conn.t() def stats(conn, _params) do market_cap_type = case Application.get_env(:explorer, :supply) do @@ -79,6 +117,7 @@ defmodule BlockScoutWeb.API.V2.StatsController do "network_utilization_percentage" => network_utilization_percentage() } |> add_chain_type_fields() + |> add_chain_identity_fields() |> backward_compatibility(conn) ) end @@ -94,6 +133,21 @@ defmodule BlockScoutWeb.API.V2.StatsController do else: gas_used |> Decimal.div(gas_limit) |> Decimal.mult(100) |> Decimal.to_float() end + operation :transactions_chart, + summary: "Get daily transaction counts", + description: "Retrieves time series data of daily transaction counts for rendering charts.", + parameters: base_params(), + responses: [ + ok: + {"Time series data for transaction count charts.", "application/json", + %Schema{type: :object, properties: %{chart_data: %Schema{type: :array, items: %Schema{type: :object}}}}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Returns transaction counts by date chart. + """ + @spec transactions_chart(Plug.Conn.t(), map()) :: Plug.Conn.t() def transactions_chart(conn, _params) do [{:history_size, history_size}] = Application.get_env(:block_scout_web, BlockScoutWeb.Chain.TransactionHistoryChartController, [{:history_size, 30}]) @@ -107,8 +161,7 @@ defmodule BlockScoutWeb.API.V2.StatsController do transaction_history_data = date_range |> Enum.map(fn row -> - # todo: `transaction_count` property should be removed in favour `transactions_count` property with the next release after 8.0.0 - %{date: row.date, transaction_count: row.number_of_transactions, transactions_count: row.number_of_transactions} + %{date: row.date, transactions_count: row.number_of_transactions} end) json(conn, %{ @@ -116,6 +169,28 @@ defmodule BlockScoutWeb.API.V2.StatsController do }) end + operation :market_chart, + summary: "Get daily closing price and market cap for native coin", + description: + "Retrieves time series data of market information (daily closing price, market cap) for rendering charts.", + parameters: base_params(), + responses: [ + ok: + {"Time series data for market charts and available token supply.", "application/json", + %Schema{ + type: :object, + properties: %{ + chart_data: %Schema{type: :array, items: %Schema{type: :object}}, + available_supply: %Schema{anyOf: [Schemas.General.FloatString, %Schema{type: :integer}]} + } + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Returns market history (price, market cap, tvl) for charting. + """ + @spec market_chart(Plug.Conn.t(), map()) :: Plug.Conn.t() def market_chart(conn, _params) do exchange_rate = Market.get_coin_exchange_rate() @@ -149,6 +224,21 @@ defmodule BlockScoutWeb.API.V2.StatsController do }) end + operation :secondary_coin_market_chart, + summary: "Secondary coin market history chart data", + description: "Returns market history for the secondary coin used for charting.", + parameters: base_params(), + responses: [ + ok: + {"Secondary coin market chart data.", "application/json", + %Schema{type: :object, properties: %{chart_data: %Schema{type: :array, items: %Schema{type: :object}}}}}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Returns market history for the secondary coin used for charting. + """ + @spec secondary_coin_market_chart(Plug.Conn.t(), map()) :: Plug.Conn.t() def secondary_coin_market_chart(conn, _params) do recent_market_history = Market.fetch_recent_history(true) @@ -161,6 +251,65 @@ defmodule BlockScoutWeb.API.V2.StatsController do }) end + operation :hot_smart_contracts, + summary: "Retrieve hot smart-contracts", + description: "Retrieves paginated list of hot smart-contracts", + parameters: + base_params() ++ + [sort_param(["transactions_count", "total_gas_used"]), order_param(), hot_smart_contracts_scale_param()] ++ + define_paging_params([ + "transactions_count_positive", + "total_gas_used", + "contract_address_hash_not_nullable", + "items_count" + ]), + responses: [ + ok: + {"Paginated list of hot smart-contracts.", "application/json", + paginated_response( + items: Schemas.Stats.HotContract, + next_page_params_example: %{ + "transactions_count" => 100, + "total_gas_used" => "100", + "contract_address_hash" => "0x01a2A10583675E0e5dF52DE1b62734109201477a", + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + forbidden: ForbiddenResponse.response() + ] + + @spec hot_smart_contracts(Plug.Conn.t(), map()) :: Plug.Conn.t() + def hot_smart_contracts(conn, %{scale: scale} = params) do + options = + params + |> hot_smart_contracts_paging_options() + |> Keyword.merge(hot_smart_contracts_sorting(params)) + |> Keyword.merge(@api_true) + |> fetch_scam_token_toggle(conn) + + {hot_smart_contracts, next_page} = + scale + |> HotSmartContractsCache.fetch(options, fn -> HotSmartContracts.paginated(scale, options) end) + |> case do + {:error, :not_found} -> [] + hot_smart_contracts -> hot_smart_contracts + end + |> split_list_by_page() + + next_page_params = + next_page + |> next_page_params(hot_smart_contracts, params, false, &hot_smart_contracts_paging_params/1) + |> delete_items_count_from_next_page_params() + + conn + |> put_status(200) + |> render(:hot_smart_contracts, %{ + hot_smart_contracts: hot_smart_contracts |> maybe_preload_metadata(), + next_page_params: next_page_params + }) + end + defp backward_compatibility(response, conn) do case Conn.get_req_header(conn, "updated-gas-oracle") do ["true"] -> @@ -195,13 +344,26 @@ defmodule BlockScoutWeb.API.V2.StatsController do response |> Map.put("last_output_root_size", fetch(@api_true)) end - :celo -> - defp add_chain_type_fields(response) do - import Explorer.Chain.Celo.Reader, only: [last_block_epoch_number: 0] - response |> Map.put("celo", %{"epoch_number" => last_block_epoch_number()}) + _ -> + defp add_chain_type_fields(response), do: response + end + + case @chain_identity do + {:optimism, :celo} -> + defp add_chain_identity_fields(response) do + alias Explorer.Chain.Cache.CeloEpochs + response |> Map.put("celo", %{"epoch_number" => CeloEpochs.last_block_epoch_number()}) end _ -> - defp add_chain_type_fields(response), do: response + defp add_chain_identity_fields(response), do: response + end + + defp hot_smart_contracts_paging_params(hot_contract) do + %{ + contract_address_hash: hot_contract.contract_address_hash, + transactions_count: hot_contract.transactions_count, + total_gas_used: hot_contract.total_gas_used + } end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex index 115253e7cdbb..f9e5924bbcdb 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex @@ -1,15 +1,21 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TokenController do use BlockScoutWeb, :controller use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]] + use OpenApiSpex.ControllerSpecs - alias BlockScoutWeb.{AccessHelper, CaptchaHelper} + alias BlockScoutWeb.{AccessHelper, AuthenticationHelper} alias BlockScoutWeb.API.V2.{AddressView, TransactionView} - alias Explorer.{Chain, Helper, PagingOptions} + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.NotFoundResponse + alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.{Address, BridgedToken, Token, Token.Instance} alias Explorer.Migrator.BackfillMetadataURL alias Indexer.Fetcher.OnDemand.NFTCollectionMetadataRefetch, as: NFTCollectionMetadataRefetchOnDemand alias Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, as: TokenInstanceMetadataRefetchOnDemand alias Indexer.Fetcher.OnDemand.TokenTotalSupply, as: TokenTotalSupplyOnDemand + alias Plug.Conn + + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] import BlockScoutWeb.Chain, only: [ @@ -25,25 +31,46 @@ defmodule BlockScoutWeb.API.V2.TokenController do import BlockScoutWeb.PagingHelper, only: [ chain_ids_filter_options: 1, - delete_parameters_from_next_page_params: 1, token_transfers_types_options: 1, tokens_sorting: 1 ] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] + import Explorer.MicroserviceInterfaces.BENS, + only: [maybe_preload_ens: 1, maybe_preload_ens_for_token_transfers: 1] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.PagingOptions, only: [default_paging_options: 0] action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["tokens"]) + @api_true [api?: true] - def token(conn, %{"address_hash_param" => address_hash_string} = params) do + @token_options [api?: true, necessity_by_association: %{reputation_association() => :optional}] + + operation :token, + summary: "Retrieve detailed information about a specific token", + description: "Retrieves detailed information for a specific token identified by its contract address.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Detailed information about the specified token.", "application/json", Schemas.Token.Response}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param` endpoint. + """ + @spec token(Plug.Conn.t(), map()) :: Plug.Conn.t() + def token(conn, %{address_hash_param: address_hash_string} = params) do ip = AccessHelper.conn_to_ip_string(conn) with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do + {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @token_options)} do TokenTotalSupplyOnDemand.trigger_fetch(ip, address_hash) conn @@ -74,17 +101,67 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end - def counters(conn, %{"address_hash_param" => address_hash_string} = params) do + operation :counters, + summary: "Get holder and transfer count statistics for a specific token", + description: "Retrieves count statistics for a specific token, including holders count and transfers count.", + parameters: [address_hash_param() | base_params()], + responses: [ + ok: {"Count statistics for the specified token.", "application/json", Schemas.Token.Counters}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/counters` endpoint. + """ + @spec counters(Plug.Conn.t(), map()) :: Plug.Conn.t() + def counters(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, true} <- {:not_found, Token.by_contract_address_hash_exists?(address_hash, @api_true)} do - {transfer_count, token_holder_count} = Chain.fetch_token_counters(address_hash, 30_000) + {transfers_count, holders_count} = Token.fetch_token_counters(address_hash, 5_000) - json(conn, %{transfers_count: to_string(transfer_count), token_holders_count: to_string(token_holder_count)}) + json(conn, %{transfers_count: to_string(transfers_count), token_holders_count: to_string(holders_count)}) end end - def transfers(conn, %{"address_hash_param" => address_hash_string} = params) do + operation :transfers, + summary: "List ownership transfer history for a specific NFT", + description: "Retrieves transfer history for a specific NFT instance, showing ownership changes over time.", + parameters: + base_params() ++ + [address_hash_param()] ++ + define_paging_params([ + "index", + "block_number", + "batch_log_index", + "batch_block_hash", + "batch_transaction_hash", + "index_in_batch" + ]), + responses: [ + ok: + {"Transfers of the specified token, with pagination.", "application/json", + paginated_response( + items: Schemas.TokenTransfer, + next_page_params_example: %{ + "index" => 259, + "block_number" => 23_484_141, + "batch_log_index" => 3, + "batch_block_hash" => "0x789", + "batch_transaction_hash" => "0xabc", + "index_in_batch" => 2 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/transfers` endpoint. + """ + @spec transfers(Plug.Conn.t(), map()) :: Plug.Conn.t() + def transfers(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, true} <- {:not_found, Token.by_contract_address_hash_exists?(address_hash, @api_true)} do @@ -100,20 +177,50 @@ defmodule BlockScoutWeb.API.V2.TokenController do next_page_params = next_page - |> token_transfers_next_page_params(token_transfers, delete_parameters_from_next_page_params(params)) + |> token_transfers_next_page_params(token_transfers, params) conn |> put_status(200) |> put_view(TransactionView) |> render(:token_transfers, %{ token_transfers: - token_transfers |> Instance.preload_nft(@api_true) |> maybe_preload_ens() |> maybe_preload_metadata(), + token_transfers + |> Instance.preload_nft(@api_true) + |> maybe_preload_ens_for_token_transfers() + |> maybe_preload_metadata(), next_page_params: next_page_params }) end end - def holders(conn, %{"address_hash_param" => address_hash_string} = params) do + operation :holders, + summary: "List addresses holding a specific token sorted by balance", + description: + "Retrieves addresses holding a specific token, sorted by balance. Useful for analyzing token distribution.", + parameters: + base_params() ++ + [address_hash_param()] ++ + define_paging_params(["address_hash_param", "value", "items_count"]), + responses: [ + ok: + {"Holders of the specified token, with pagination.", "application/json", + paginated_response( + items: Schemas.Token.Holder, + next_page_params_example: %{ + "address_hash" => "0x48bb9b14483e43c7726df702b271d410e7460656", + "value" => "200000000000000", + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/holders` endpoint. + """ + @spec holders(Plug.Conn.t(), map()) :: Plug.Conn.t() + def holders(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, true} <- {:not_found, Token.by_contract_address_hash_exists?(address_hash, @api_true)} do @@ -122,7 +229,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do {token_balances, next_page} = split_list_by_page(results_plus_one) - next_page_params = next_page |> next_page_params(token_balances, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(token_balances, params) conn |> put_status(200) @@ -133,20 +240,47 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + operation :instances, + summary: "List individual NFT instances for a token contract", + description: + "Retrieves instances of NFTs for a specific token contract. This endpoint is primarily for ERC-721 and ERC-1155 tokens.", + parameters: + base_params() ++ + [address_hash_param(), holder_address_hash_param()] ++ + define_paging_params(["unique_token"]), + responses: [ + ok: + {"NFT instances for the specified token contract, with pagination.", "application/json", + paginated_response( + items: Schemas.TokenInstance, + next_page_params_example: %{ + "unique_token" => 782_098 + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/instances` endpoint. + """ + @spec instances(Plug.Conn.t(), map()) :: Plug.Conn.t() + def instances( conn, - %{"address_hash_param" => address_hash_string, "holder_address_hash" => holder_address_hash_string} = params + %{address_hash_param: address_hash_string, holder_address_hash: holder_address_hash_string} = params ) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)}, + {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @token_options)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, {:format, {:ok, holder_address_hash}} <- {:format, Chain.string_to_address_hash(holder_address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(holder_address_hash_string, params) do - holder_address = Address.get(holder_address_hash, @api_true) - holder_address_with_proxy_implementations = - holder_address && %Address{holder_address | proxy_implementations: nil} + case Address.get(holder_address_hash, @api_true) do + %Address{} = holder_address -> %Address{holder_address | proxy_implementations: nil} + nil -> nil + end results_plus_one = Instance.token_instances_by_holder_address_hash( @@ -160,7 +294,8 @@ defmodule BlockScoutWeb.API.V2.TokenController do {token_instances, next_page} = split_list_by_page(results_plus_one) next_page_params = - next_page |> unique_tokens_next_page(token_instances, delete_parameters_from_next_page_params(params)) + next_page + |> unique_tokens_next_page(token_instances, params) conn |> put_status(200) @@ -177,10 +312,10 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end - def instances(conn, %{"address_hash_param" => address_hash_string} = params) do + def instances(conn, %{address_hash_param: address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)} do + {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @token_options)} do results_plus_one = Instance.address_to_unique_tokens( token.contract_address_hash, @@ -191,7 +326,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do {token_instances, next_page} = split_list_by_page(results_plus_one) next_page_params = - next_page |> unique_tokens_next_page(token_instances, delete_parameters_from_next_page_params(params)) + next_page |> unique_tokens_next_page(token_instances, params) conn |> put_status(200) @@ -203,17 +338,38 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end - def instance(conn, %{"address_hash_param" => address_hash_string, "token_id" => token_id_string} = params) do + operation :instance, + summary: "Retrieve detailed information about a specific NFT", + description: + "Retrieves detailed information about a specific NFT instance, identified by its token contract address and token ID.", + parameters: + base_params() ++ + [ + address_hash_param(), + token_id_param() + ], + responses: [ + ok: {"Detailed information about the specified NFT instance.", "application/json", Schemas.TokenInstance}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/instances/:token_id_param` endpoint. + """ + @spec instance(Plug.Conn.t(), map()) :: Plug.Conn.t() + def instance(conn, %{address_hash_param: address_hash_string, token_id_param: token_id_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)}, + {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @token_options)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, {:format, {token_id, ""}} <- {:format, Integer.parse(token_id_string)}, {:ok, token_instance} <- Instance.nft_instance_by_token_id_and_token_address(token_id, address_hash, @api_true) do fill_metadata_url_task = maybe_run_fill_metadata_url_task(token_instance, token) - token_instance = + %Instance{} = + token_instance = token_instance |> Chain.select_repo(@api_true).preload(owner: [:names, :smart_contract, proxy_implementations_association()]) |> Instance.put_owner_to_token_instance(token, @api_true) @@ -236,26 +392,40 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end - defp maybe_run_fill_metadata_url_task(token_instance, token) do - if not is_nil(token_instance.metadata) && is_nil(token_instance.skip_metadata_url) do - Task.async(fn -> - BackfillMetadataURL.update_batch([ - {token_instance.token_contract_address_hash, token_instance.token_id, token.type} - ]) - end) - else - nil - end - end + operation :transfers_by_instance, + summary: "List token transfers for a specific token instance", + description: "Retrieves token transfers for a specific token instance (by token address and token ID).", + parameters: + base_params() ++ + [address_hash_param(), token_id_param()] ++ + define_paging_params(["index", "block_number", "token_id"]), + responses: [ + ok: + {"Transfer history for the specified NFT instance, with pagination.", "application/json", + paginated_response( + items: Schemas.TokenTransfer, + next_page_params_example: %{ + "index" => 920, + "block_number" => 23_489_243, + "token_id" => "4" + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/instances/:token_id_param/transfers` endpoint. + """ + @spec transfers_by_instance(Plug.Conn.t(), map()) :: Plug.Conn.t() def transfers_by_instance( conn, - %{"address_hash_param" => address_hash_string, "token_id" => token_id_string} = params + %{address_hash_param: address_hash_string, token_id_param: token_id_string} = params ) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, {:format, {token_id, ""}} <- {:format, Integer.parse(token_id_string)} do paging_options = paging_options(params) @@ -269,23 +439,51 @@ defmodule BlockScoutWeb.API.V2.TokenController do next_page_params = next_page - |> token_transfers_next_page_params(token_transfers, delete_parameters_from_next_page_params(params)) + |> token_transfers_next_page_params(token_transfers, params) conn |> put_status(200) |> put_view(TransactionView) |> render(:token_transfers, %{ - token_transfers: token_transfers |> maybe_preload_ens() |> maybe_preload_metadata(), + token_transfers: token_transfers |> maybe_preload_ens_for_token_transfers() |> maybe_preload_metadata(), next_page_params: next_page_params }) end end - def holders_by_instance(conn, %{"address_hash_param" => address_hash_string, "token_id" => token_id_string} = params) do + operation :holders_by_instance, + summary: "List current holders of a specific NFT", + description: + "Retrieves current holders of a specific NFT instance. For ERC-721, this will typically be a single address. For ERC-1155, multiple addresses may hold the same token ID.", + parameters: + base_params() ++ + [address_hash_param(), token_id_param()] ++ + define_paging_params(["address_hash_param", "items_count", "token_id", "value"]), + responses: [ + ok: + {"Current holders of the specified NFT instance, with pagination.", "application/json", + paginated_response( + items: Schemas.Token.Holder, + next_page_params_example: %{ + "address_hash" => "0x1d2c163fbda9486c3a384b6fa5e34c96fe948e9a", + "items_count" => 50, + "token_id" => "0", + "value" => "4217417051704137590935" + } + )}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/instances/:token_id_param/holders` endpoint. + """ + @spec holders_by_instance(Plug.Conn.t(), map()) :: Plug.Conn.t() + def holders_by_instance(conn, %{address_hash_param: address_hash_string, token_id_param: token_id_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, {:format, {token_id, ""}} <- {:format, Integer.parse(token_id_string)} do paging_options = paging_options(params) @@ -300,7 +498,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do next_page_params = next_page - |> next_page_params(token_holders, delete_parameters_from_next_page_params(params)) + |> next_page_params(token_holders, params) conn |> put_status(200) @@ -311,14 +509,36 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + operation :transfers_count_by_instance, + summary: "Get total number of ownership transfers for a specific NFT", + description: + "Retrieves the total number of transfers for a specific NFT instance. Useful for determining how frequently an NFT has changed hands.", + parameters: + base_params() ++ + [ + address_hash_param(), + token_id_param() + ], + responses: [ + ok: + {"Total number of transfers for the specified NFT instance.", "application/json", + %Schema{type: :object, properties: %{transfers_count: %Schema{type: :integer}}}}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/:address_hash_param/instances/:token_id_param/transfers-count` endpoint. + """ + @spec transfers_count_by_instance(Plug.Conn.t(), map()) :: Plug.Conn.t() def transfers_count_by_instance( conn, - %{"address_hash_param" => address_hash_string, "token_id" => token_id_string} = params + %{address_hash_param: address_hash_string, token_id_param: token_id_string} = params ) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, {:format, {token_id, ""}} <- {:format, Integer.parse(token_id_string)} do conn |> put_status(200) @@ -328,8 +548,51 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + operation :tokens_list, + summary: "List tokens with optional filtering by name, symbol, or type", + description: "Retrieves a paginated list of tokens with optional filtering by name, symbol, or type.", + parameters: + base_params() ++ + [ + token_type_param(), + q_param(), + limit_param(), + sort_param(["fiat_value", "holders_count", "circulating_market_cap"]), + order_param() + ] ++ + define_paging_params([ + "contract_address_hash", + "fiat_value", + "holders_count", + "is_name_null", + "market_cap", + "name", + "items_count" + ]), + responses: [ + ok: + {"List of tokens matching the filter criteria, with pagination.", "application/json", + paginated_response( + items: Schemas.Token, + next_page_params_example: %{ + "contract_address_hash" => "0xbe9895146f7af43049ca1c1ae358b0541ea49704", + "fiat_value" => "4724.32", + "holders_count" => 59_731, + "is_name_null" => false, + "market_cap" => "570958125.135513", + "name" => "Wrapped Staked ETH", + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens` endpoint. + """ + @spec tokens_list(Plug.Conn.t(), map()) :: Plug.Conn.t() def tokens_list(conn, params) do - filter = params["q"] + filter = params[:q] options = params @@ -337,7 +600,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do |> Keyword.update(:paging_options, default_paging_options(), fn %PagingOptions{ page_size: page_size } = paging_options -> - maybe_parsed_limit = Helper.parse_integer(params["limit"]) + maybe_parsed_limit = params[:limit] %PagingOptions{paging_options | page_size: min(page_size, maybe_parsed_limit && abs(maybe_parsed_limit))} end) |> Keyword.merge(token_transfers_types_options(params)) @@ -347,15 +610,57 @@ defmodule BlockScoutWeb.API.V2.TokenController do {tokens, next_page} = filter |> Token.list_top(options) |> split_list_by_page() - next_page_params = next_page |> next_page_params(tokens, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(tokens, params) conn |> put_status(200) |> render(:tokens, %{tokens: tokens, next_page_params: next_page_params}) end + operation :bridged_tokens_list, + summary: "List bridged tokens with optional filtering and sorting", + description: "Retrieves a paginated list of bridged tokens with optional filtering and sorting.", + parameters: + base_params() ++ + [ + chain_ids_param(), + q_param(), + sort_param(["fiat_value", "holders_count", "circulating_market_cap"]), + order_param() + ] ++ + define_paging_params([ + "contract_address_hash", + "fiat_value", + "holders_count", + "is_name_null", + "market_cap", + "name", + "items_count" + ]), + responses: [ + ok: + {"List of bridged tokens.", "application/json", + paginated_response( + items: Schemas.Token, + next_page_params_example: %{ + "contract_address_hash" => "0xbe9895146f7af43049ca1c1ae358b0541ea49704", + "fiat_value" => "4724.32", + "holders_count" => 59_731, + "is_name_null" => false, + "market_cap" => "570958125.135513", + "name" => "Wrapped Staked ETH", + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Handles GET requests to `/api/v2/tokens/bridged` endpoint. + """ + @spec bridged_tokens_list(Plug.Conn.t(), map()) :: Plug.Conn.t() def bridged_tokens_list(conn, params) do - filter = params["q"] + filter = params[:q] options = params @@ -366,25 +671,47 @@ defmodule BlockScoutWeb.API.V2.TokenController do {tokens, next_page} = filter |> BridgedToken.list_top_bridged_tokens(options) |> split_list_by_page() - next_page_params = next_page |> next_page_params(tokens, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(tokens, params) conn |> put_status(200) |> render(:bridged_tokens, %{tokens: tokens, next_page_params: next_page_params}) end + operation :refetch_metadata, + summary: "Trigger a refresh of metadata for a specific NFT", + description: + "Triggers a refresh of metadata for a specific NFT instance. Useful when the NFT's metadata has been updated but is not yet reflected in the BlockScout database.", + parameters: + base_params() ++ + [ + address_hash_param(), + token_id_param(), + recaptcha_response_param() + ], + responses: [ + ok: + {"Metadata refresh has been successfully initiated.", "application/json", + %Schema{type: :object, properties: %{message: %Schema{type: :string}}}}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles PATCH requests to `/api/v2/tokens/:address_hash_param/instances/:token_id_param/refetch-metadata` endpoint. + """ + @spec refetch_metadata(Plug.Conn.t(), map()) :: Plug.Conn.t() def refetch_metadata( conn, params ) do - address_hash_string = params["address_hash_param"] - token_id_string = params["token_id"] + address_hash_string = params[:address_hash_param] + token_id_string = params[:token_id_param] with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), - {:recaptcha, true} <- {:recaptcha, CaptchaHelper.recaptcha_passed?(params, :token_instance_refetch_metadata)}, {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)}, + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)}, {:format, {token_id, ""}} <- {:format, Integer.parse(token_id_string)}, {:ok, token_instance} <- Instance.nft_instance_by_token_id_and_token_address(token_id, address_hash, @api_true) do @@ -402,20 +729,34 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + operation :trigger_nft_collection_metadata_refetch, + summary: "Trigger metadata refetch for a token's NFT collection", + description: "Triggers a metadata refetch for a token's NFT collection (by token address). Requires API key.", + parameters: base_params() ++ [address_hash_param(), admin_api_key_param(), admin_api_key_param_query()], + responses: [ + ok: + {"NFT collection metadata refetch triggered.", "application/json", + %Schema{type: :object, properties: %{message: %Schema{type: :string}}}}, + unprocessable_entity: JsonErrorResponse.response(), + not_found: NotFoundResponse.response() + ] + + @doc """ + Handles PATCH requests to `/api/v2/tokens/:address_hash_param/instances/refetch-metadata` endpoint. + """ + @spec trigger_nft_collection_metadata_refetch(Plug.Conn.t(), map()) :: Plug.Conn.t() def trigger_nft_collection_metadata_refetch( conn, params ) do - address_hash_string = params["address_hash_param"] + address_hash_string = params[:address_hash_param] ip = AccessHelper.conn_to_ip_string(conn) - with {:sensitive_endpoints_api_key, api_key} when not is_nil(api_key) <- - {:sensitive_endpoints_api_key, Application.get_env(:block_scout_web, :sensitive_endpoints_api_key)}, - {:api_key, ^api_key} <- {:api_key, params["api_key"]}, + with :ok <- AuthenticationHelper.validate_sensitive_endpoints_api_key(get_api_key(conn)), {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:not_found, {:ok, token}} <- {:not_found, Chain.token_from_address_hash(address_hash, @api_true)}, - {:not_found, false} <- {:not_found, Chain.erc_20_token?(token)} do + {:not_found, false} <- {:not_found, Chain.erc_20_token?(token) or Token.zrc_2_token?(token)} do NFTCollectionMetadataRefetchOnDemand.trigger_refetch(ip, token) conn @@ -424,15 +765,37 @@ defmodule BlockScoutWeb.API.V2.TokenController do end end + defp get_api_key(conn) do + case Conn.get_req_header(conn, "x-api-key") do + [api_key] -> + api_key + + _ -> + Map.get(conn.query_params, "api_key") + end + end + + defp maybe_run_fill_metadata_url_task(token_instance, token) do + if not is_nil(token_instance.metadata) && is_nil(token_instance.skip_metadata_url) do + Task.async(fn -> + BackfillMetadataURL.update_batch([ + {token_instance.token_contract_address_hash, token_instance.token_id, token.type} + ]) + end) + else + nil + end + end + defp put_owner(token_instances, holder_address, holder_address_hash), do: - Enum.map(token_instances, fn token_instance -> + Enum.map(token_instances, fn %Instance{} = token_instance -> %Instance{token_instance | owner: holder_address, owner_address_hash: holder_address_hash} end) @spec put_token_to_instance(Instance.t(), Token.t()) :: Instance.t() defp put_token_to_instance( - token_instance, + %Instance{} = token_instance, token ) do %Instance{token_instance | token: token} diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex index 3141ff298331..71dff53a1954 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TokenTransferController do use BlockScoutWeb, :controller - alias Explorer.{Chain, Helper, PagingOptions} + use OpenApiSpex.ControllerSpecs + alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.{TokenTransfer, Transaction} import BlockScoutWeb.Chain, @@ -13,11 +15,12 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do import BlockScoutWeb.PagingHelper, only: [ - delete_parameters_from_next_page_params: 1, token_transfers_types_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] + import Explorer.MicroserviceInterfaces.BENS, + only: [maybe_preload_ens_for_token_transfers: 1] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.PagingOptions, only: [default_paging_options: 0] @@ -25,8 +28,39 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["token-transfers"]) + @api_true [api?: true] + operation :token_transfers, + summary: "List token transfers across all token types (ERC-20, ERC-721, ERC-1155)", + description: "Retrieves a paginated list of token transfers across all token types (ERC-20, ERC-721, ERC-1155).", + parameters: + base_params() ++ + [token_type_param(), limit_param()] ++ + define_paging_params([ + "index", + "block_number", + "batch_log_index", + "batch_block_hash", + "batch_transaction_hash", + "index_in_batch" + ]), + responses: [ + ok: + {"List of token transfers with pagination information.", "application/json", + paginated_response( + items: Schemas.TokenTransfer, + next_page_params_example: %{ + "index" => 50, + "block_number" => 22_133_247 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/token-transfers` endpoint. """ @@ -39,7 +73,7 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do |> Keyword.update(:paging_options, default_paging_options(), fn %PagingOptions{ page_size: page_size } = paging_options -> - maybe_parsed_limit = Helper.parse_integer(params["limit"]) + maybe_parsed_limit = params[:limit] %PagingOptions{paging_options | page_size: min(page_size, maybe_parsed_limit && abs(maybe_parsed_limit))} end) |> Keyword.merge(token_transfers_types_options(params)) @@ -58,10 +92,6 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do transactions = token_transfers |> Enum.map(& &1.transaction) - # Celo's Epoch logs does not have an associated transaction and linked to - # the block instead, so we discard these token transfers for transaction - # decoding - |> Enum.reject(&is_nil/1) |> Enum.uniq() decoded_transactions = Transaction.decode_transactions(transactions, true, @api_true) @@ -72,13 +102,16 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do |> Enum.into(%{}, fn {%{hash: hash}, decoded_input} -> {hash, decoded_input} end) next_page_params = - next_page |> token_transfers_next_page_params(token_transfers, delete_parameters_from_next_page_params(params)) + next_page |> token_transfers_next_page_params(token_transfers, params) conn |> put_status(200) |> render(:token_transfers, %{ token_transfers: - token_transfers |> Instance.preload_nft(@api_true) |> maybe_preload_ens() |> maybe_preload_metadata(), + token_transfers + |> Instance.preload_nft(@api_true) + |> maybe_preload_ens_for_token_transfers() + |> maybe_preload_metadata(), decoded_transactions_map: decoded_transactions_map, next_page_params: next_page_params }) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex index 848d82304390..4014ea636f1c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex @@ -1,23 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TransactionController do use BlockScoutWeb, :controller - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - alias BlockScoutWeb.API.V2.BlobView import BlockScoutWeb.Chain, only: [ next_page_params: 3, + next_page_params: 4, + next_page_params: 5, put_key_value_to_paging_options: 3, token_transfers_next_page_params: 3, paging_options: 1, split_list_by_page: 1, - fetch_scam_token_toggle: 2 + fetch_scam_token_toggle: 2, + transaction_to_internal_transactions: 2 ] import BlockScoutWeb.PagingHelper, only: [ - delete_parameters_from_next_page_params: 1, paging_options: 2, filter_options: 2, method_filter_options: 1, @@ -25,11 +31,19 @@ defmodule BlockScoutWeb.API.V2.TransactionController do type_filter_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1, maybe_preload_ens_to_transaction: 1] + import Explorer.MicroserviceInterfaces.BENS, + only: [ + maybe_preload_ens: 1, + maybe_preload_ens_for_token_transfers: 1, + maybe_preload_ens_for_transactions: 1, + maybe_preload_ens_to_transaction: 1 + ] import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1, maybe_preload_metadata_to_transaction: 1] + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] + import Ecto.Query, only: [ preload: 2 @@ -38,15 +52,18 @@ defmodule BlockScoutWeb.API.V2.TransactionController do require Logger alias BlockScoutWeb.AccessHelper + alias BlockScoutWeb.API.V2.{BlobView, Ethereum.DepositController, Ethereum.DepositView} alias BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, as: TransactionInterpretationService alias BlockScoutWeb.Models.TransactionStateHelper + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.{ForbiddenResponse, NotFoundResponse} + alias Explorer.Account.WatchlistAddress alias Explorer.{Chain, PagingOptions, Repo} alias Explorer.Chain.Arbitrum.Reader.API.Settlement, as: ArbitrumSettlementReader + alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Explorer.Chain.Beacon.Reader, as: BeaconReader alias Explorer.Chain.Cache.Counters.{NewPendingTransactionsCount, Transactions24hCount} - alias Explorer.Chain.{Hash, InternalTransaction, Transaction} + alias Explorer.Chain.{FheOperation, Hash, Transaction} alias Explorer.Chain.Optimism.TransactionBatch, as: OptimismTransactionBatch - alias Explorer.Chain.PolygonZkevm.Reader, as: PolygonZkevmReader alias Explorer.Chain.Scroll.Reader, as: ScrollReader alias Explorer.Chain.Token.Instance alias Explorer.Chain.ZkSync.Reader, as: ZkSyncReader @@ -55,15 +72,19 @@ defmodule BlockScoutWeb.API.V2.TransactionController do action_fallback(BlockScoutWeb.API.V2.FallbackController) - case @chain_type do - :ethereum -> + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + tags(["transactions"]) + + case @chain_identity do + {:ethereum, nil} -> @chain_type_transaction_necessity_by_association %{ :beacon_blob_transaction => :optional } - :celo -> + {:optimism, :celo} -> @chain_type_transaction_necessity_by_association %{ - :gas_token => :optional + [gas_token: reputation_association()] => :optional } _ -> @@ -103,44 +124,47 @@ defmodule BlockScoutWeb.API.V2.TransactionController do @token_transfers_necessity_by_association %{ [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional + [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [token: reputation_association()] => :optional } @token_transfers_in_transaction_necessity_by_association %{ [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - token: :required + [token: reputation_association()] => :optional } - @internal_transaction_necessity_by_association [ - necessity_by_association: %{ - [created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional - } + @internal_transaction_address_preloads [ + address_preloads: [ + created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] ] @api_true [api?: true] + operation :transaction, + summary: "Retrieve detailed information about a specific transaction", + description: "Retrieves detailed information for a specific transaction identified by its hash.", + parameters: [transaction_hash_param() | base_params()], + responses: [ + ok: {"Detailed information about the specified transaction.", "application/json", Schemas.Transaction.Response}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param` endpoint. """ @spec transaction(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def transaction(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def transaction(conn, %{transaction_hash_param: transaction_hash_string} = params) do necessity_by_association_with_actions = @transaction_necessity_by_association - |> Map.put(:transaction_actions, :optional) |> Map.put(:signed_authorizations, :optional) necessity_by_association = case Application.get_env(:explorer, :chain_type) do - :polygon_zkevm -> - necessity_by_association_with_actions - |> Map.put(:zkevm_batch, :optional) - |> Map.put(:zkevm_sequence_transaction, :optional) - |> Map.put(:zkevm_verify_transaction, :optional) - :zksync -> necessity_by_association_with_actions |> Map.put(:zksync_batch, :optional) @@ -189,6 +213,27 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :transactions, + summary: "List blockchain transactions with filtering options for status, type, and method", + description: "Retrieves a paginated list of transactions with optional filtering by status, type, and method.", + parameters: + base_params() ++ + [transaction_filter_param(), transaction_type_param()] ++ + define_paging_params(["block_number", "index", "items_count", "hash", "inserted_at"]), + responses: [ + ok: + {"List of transactions with pagination information.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 23_532_302, + "index" => 375, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions` endpoint. """ @@ -198,7 +243,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do full_options = [ - necessity_by_association: @transaction_necessity_by_association + necessity_by_association: transactions_necessity_by_association() ] |> Keyword.merge(paging_options(params, filter_options)) |> Keyword.merge(method_filter_options(params)) @@ -209,40 +254,65 @@ defmodule BlockScoutWeb.API.V2.TransactionController do {transactions, next_page} = split_list_by_page(transactions_plus_one) - next_page_params = next_page |> next_page_params(transactions, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(transactions, params) conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params }) end - @doc """ - Function to handle GET requests to `/api/v2/transactions/zkevm-batch/:batch_number` endpoint. - It renders the list of L2 transactions bound to the specified batch. - """ - @spec polygon_zkevm_batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def polygon_zkevm_batch(conn, %{"batch_number" => batch_number} = _params) do - options = - [necessity_by_association: @transaction_necessity_by_association] - |> Keyword.merge(@api_true) - - transactions = - batch_number - |> PolygonZkevmReader.batch_transactions(@api_true) - |> Enum.map(fn transaction -> transaction.hash end) - |> Chain.hashes_to_transactions(options) - - conn - |> put_status(200) - |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), - items: true - }) + defp transactions_necessity_by_association do + %{ + :block => :optional, + [ + created_contract_address: [ + :scam_badge, + :names, + :token, + proxy_implementations_association() + ] + ] => :optional, + [ + from_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional, + [ + to_address: [ + :scam_badge, + :names, + proxy_implementations_association() + ] + ] => :optional + } + |> Map.merge(@chain_type_transaction_necessity_by_association) end + operation :zksync_batch, + summary: "List L2 transactions in a ZkSync batch", + description: "Retrieves L2 transactions bound to a specific ZkSync batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"ZkSync batch transactions.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 65_361_291, + "index" => 1, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/zksync-batch/:batch_number` endpoint. It renders the list of L2 transactions bound to the specified batch. @@ -252,6 +322,26 @@ defmodule BlockScoutWeb.API.V2.TransactionController do handle_batch_transactions(conn, params, &ZkSyncReader.batch_transactions/2) end + operation :arbitrum_batch, + summary: "List L2 transactions in an Arbitrum batch", + description: "Retrieves L2 transactions bound to a specific Arbitrum batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"Arbitrum batch transactions.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 391_483_842, + "index" => 0, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/arbitrum-batch/:batch_number` endpoint. It renders the list of L2 transactions bound to the specified batch. @@ -261,15 +351,28 @@ defmodule BlockScoutWeb.API.V2.TransactionController do handle_batch_transactions(conn, params, &ArbitrumSettlementReader.batch_transactions/2) end + operation :external_transactions, + summary: "List external transactions linked to a transaction", + description: + "Retrieves external transactions that are linked to the specified transaction (e.g., Solana transactions in `neon` chain type).", + parameters: [transaction_hash_param() | base_params()], + responses: [ + ok: + {"Linked external transactions.", "application/json", + %Schema{type: :array, items: %Schema{type: :string}, nullable: false}}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ - Function to handle GET requests to `/api/v2/transactions/:tx_hash/external-transactions` endpoint. + Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/external-transactions` endpoint. It renders the list of external transactions that are somehow linked (eg. preceded or initiated by) to the selected one. The most common use case is for side-chains and rollups. Currently implemented only for Neon chain but could also be extended for similar cases. """ - @spec external_transactions(Plug.Conn.t(), %{required(String.t()) => String.t()}) :: Plug.Conn.t() - def external_transactions(conn, %{"transaction_hash_param" => transaction_hash} = _params) do - with {:format, {:ok, hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash)} do + @spec external_transactions(Plug.Conn.t(), %{required(atom()) => String.t()}) :: Plug.Conn.t() + def external_transactions(conn, %{transaction_hash_param: transaction_hash} = _params) do + with {:format, {:ok, hash}} <- {:format, Chain.string_to_full_hash(transaction_hash)} do case NeonSolanaTransactions.maybe_fetch(hash) do {:ok, linked_transactions} -> conn @@ -289,28 +392,64 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :optimism_batch, + summary: "List L2 transactions in an Optimism batch", + description: "Retrieves L2 transactions bound to a specific Optimism batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"Optimism batch transactions.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 142_678_440, + "index" => 5, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/optimism-batch/:batch_number` endpoint. It renders the list of L2 transactions bound to the specified batch. """ @spec optimism_batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def optimism_batch(conn, %{"batch_number" => batch_number_string} = params) do - {batch_number, ""} = Integer.parse(batch_number_string) - - l2_block_number_from = OptimismTransactionBatch.edge_l2_block_number(batch_number, :min) - l2_block_number_to = OptimismTransactionBatch.edge_l2_block_number(batch_number, :max) + def optimism_batch(conn, %{batch_number_param: batch_number} = params) do + l2_block_number_from = OptimismTransactionBatch.edge_l2_block_number(batch_number, :min, @api_true) + l2_block_number_to = OptimismTransactionBatch.edge_l2_block_number(batch_number, :max, @api_true) handle_block_range_transactions(conn, params, l2_block_number_from, l2_block_number_to) end + operation :scroll_batch, + summary: "List L2 transactions in a Scroll batch", + description: "Retrieves L2 transactions bound to a specific Scroll batch number.", + parameters: + base_params() ++ + [batch_number_param()] ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"Scroll batch transactions.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 14_127_868, + "index" => 0, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/scroll-batch/:batch_number` endpoint. It renders the list of L2 transactions bound to the specified batch. """ @spec scroll_batch(Plug.Conn.t(), map()) :: Plug.Conn.t() - def scroll_batch(conn, %{"batch_number" => batch_number_string} = params) do - {batch_number, ""} = Integer.parse(batch_number_string) - + def scroll_batch(conn, %{batch_number_param: batch_number} = params) do {l2_block_number_from, l2_block_number_to} = case ScrollReader.batch(batch_number, @api_true) do {:ok, batch} -> {batch.l2_block_range.from, batch.l2_block_range.to} @@ -353,18 +492,18 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end query - |> Chain.join_associations(@transaction_necessity_by_association) + |> Chain.join_associations(transactions_necessity_by_association()) |> preload([{:token_transfers, [:token, :from_address, :to_address]}]) |> Repo.replica().all() end {transactions, next_page} = split_list_by_page(transactions_plus_one) - next_page_params = next_page |> next_page_params(transactions, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(transactions, params) conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params }) end @@ -384,10 +523,10 @@ defmodule BlockScoutWeb.API.V2.TransactionController do # ## Returns # - Updated connection object with the transactions data rendered. @spec handle_batch_transactions(Plug.Conn.t(), map(), function()) :: Plug.Conn.t() - defp handle_batch_transactions(conn, %{"batch_number" => batch_number} = params, batch_transactions_fun) do + defp handle_batch_transactions(conn, %{batch_number_param: batch_number} = params, batch_transactions_fun) do full_options = [ - necessity_by_association: @transaction_necessity_by_association + necessity_by_association: transactions_necessity_by_association() ] |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) @@ -403,20 +542,44 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Chain.hashes_to_transactions(full_options) {transactions, next_page} = split_list_by_page(transactions_plus_one) - next_page_params = next_page |> next_page_params(transactions, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(transactions, params) conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params }) end - def execution_node(conn, %{"execution_node_hash_param" => execution_node_hash_string} = params) do + operation :execution_node, + summary: "List transactions executed on a specific execution node", + description: "Retrieves transactions that were executed on the specified execution node.", + parameters: + [execution_node_hash_param() | base_params()] ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"List of transactions.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 14_127_868, + "index" => 0, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/transactions/execution-node/:execution_node_hash_param` endpoint. + It renders the list of transactions that were executed on the specified execution node. + """ + @spec execution_node(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} + def execution_node(conn, %{execution_node_hash_param: execution_node_hash_string} = params) do with {:format, {:ok, execution_node_hash}} <- {:format, Chain.string_to_address_hash(execution_node_hash_string)} do full_options = - [necessity_by_association: @transaction_necessity_by_association] + [necessity_by_association: transactions_necessity_by_association()] |> Keyword.merge(put_key_value_to_paging_options(paging_options(params), :is_index_in_asc_order, true)) |> Keyword.merge(@api_true) @@ -426,49 +589,93 @@ defmodule BlockScoutWeb.API.V2.TransactionController do next_page_params = next_page - |> next_page_params(transactions, delete_parameters_from_next_page_params(params)) + |> next_page_params(transactions, params) conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params }) end end + operation :raw_trace, + summary: "Get step-by-step execution trace for a specific transaction", + description: + "Retrieves the raw execution trace for a transaction, showing the step-by-step execution path and all contract interactions.", + parameters: [transaction_hash_param() | base_params()], + responses: [ + ok: {"Raw execution trace for the specified transaction.", "application/json", Schemas.Transaction.RawTrace}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/raw-trace` endpoint. """ @spec raw_trace(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def raw_trace(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def raw_trace(conn, %{transaction_hash_param: transaction_hash_string} = params) do with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params) do - if is_nil(transaction.block_number) do - conn - |> put_status(200) - |> render(:raw_trace, %{internal_transactions: []}) - else - FirstTraceOnDemand.maybe_trigger_fetch(transaction, @api_true) + render_raw_trace_response(conn, transaction) + end + end - case Chain.fetch_transaction_raw_traces(transaction) do - {:ok, raw_traces} -> - conn - |> put_status(200) - |> render(:raw_trace, %{raw_traces: raw_traces}) + defp render_raw_trace_response(conn, transaction) do + if is_nil(transaction.block_number) do + conn + |> put_status(200) + |> render(:raw_trace, %{internal_transactions: []}) + else + FirstTraceOnDemand.maybe_trigger_fetch(transaction, @api_true) - {:error, error} -> - Logger.error("Raw trace fetching failed: #{inspect(error)}") - {500, "Error while raw trace fetching"} - end + case Chain.fetch_transaction_raw_traces(transaction) do + {:ok, raw_traces} -> + conn + |> put_status(200) + |> render(:raw_trace, %{raw_traces: raw_traces}) + + {:error, error} -> + Logger.error("Raw trace fetching failed: #{inspect(error)}") + {500, "Error while raw trace fetching"} end end end + operation :token_transfers, + summary: "List token transfers within a specific transaction", + description: + "Retrieves token transfers that occurred within a specific transaction, with optional filtering by token type.", + parameters: + base_params() ++ + [transaction_hash_param(), token_type_param()] ++ + define_paging_params([ + "index", + "block_number", + "batch_log_index", + "batch_block_hash", + "batch_transaction_hash", + "index_in_batch" + ]), + responses: [ + ok: + {"Token transfers within the specified transaction, with pagination.", "application/json", + paginated_response( + items: Schemas.TokenTransfer, + next_page_params_example: %{ + "index" => 442, + "block_number" => 21_307_214 + } + )}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/token-transfers` endpoint. """ @spec token_transfers(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def token_transfers(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def token_transfers(conn, %{transaction_hash_param: transaction_hash_string} = params) do with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do paging_options = paging_options(params) @@ -489,37 +696,62 @@ defmodule BlockScoutWeb.API.V2.TransactionController do next_page_params = next_page - |> token_transfers_next_page_params(token_transfers, delete_parameters_from_next_page_params(params)) + |> token_transfers_next_page_params(token_transfers, params) conn |> put_status(200) |> render(:token_transfers, %{ token_transfers: - token_transfers |> Instance.preload_nft(@api_true) |> maybe_preload_ens() |> maybe_preload_metadata(), + token_transfers + |> Instance.preload_nft(@api_true) + |> maybe_preload_ens_for_token_transfers() + |> maybe_preload_metadata(), next_page_params: next_page_params }) end end + operation :internal_transactions, + summary: "List internal transactions triggered during a specific transaction", + description: + "Retrieves internal transactions generated during the execution of a specific transaction. Useful for analyzing contract interactions and debugging failed transactions.", + parameters: + [transaction_hash_param() | base_params()] ++ + define_paging_params(["index", "block_number", "transaction_index", "items_count"]), + responses: [ + ok: + {"Internal transactions for the specified transaction, with pagination.", "application/json", + paginated_response( + items: Schemas.InternalTransaction, + next_page_params_example: %{ + "index" => 50, + "block_number" => 22_133_247, + "transaction_index" => 68, + "items_count" => 50 + } + )}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/internal-transactions` endpoint. """ @spec internal_transactions(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def internal_transactions(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do - with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do + def internal_transactions(conn, %{transaction_hash_param: transaction_hash_string} = params) do + with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params) do full_options = - @internal_transaction_necessity_by_association + @internal_transaction_address_preloads |> Keyword.merge(paging_options(params)) |> Keyword.merge(@api_true) - internal_transactions_plus_one = - InternalTransaction.transaction_to_internal_transactions(transaction_hash, full_options) + internal_transactions_plus_one = transaction_to_internal_transactions(transaction, full_options) {internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) next_page_params = next_page - |> next_page_params(internal_transactions, delete_parameters_from_next_page_params(params)) + |> next_page_params(internal_transactions, params) conn |> put_status(200) @@ -530,16 +762,39 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :logs, + summary: "List event logs emitted during a specific transaction", + description: + "Retrieves event logs emitted during the execution of a specific transaction. Logs contain information about contract events and state changes.", + parameters: + [transaction_hash_param() | base_params()] ++ + define_paging_params(["index", "block_number", "items_count"]), + responses: [ + ok: + {"Event logs for the specified transaction, with pagination.", "application/json", + paginated_response( + items: Schemas.Log, + next_page_params_example: %{ + "index" => 124, + "block_number" => 21_925_703, + "items_count" => 50 + } + )}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/logs` endpoint. """ @spec logs(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def logs(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def logs(conn, %{transaction_hash_param: transaction_hash_string} = params) do with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do full_options = [ necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_association()]] => :optional + [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional, + :block => :optional } ] |> Keyword.merge(paging_options(params)) @@ -551,7 +806,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do next_page_params = next_page - |> next_page_params(logs, delete_parameters_from_next_page_params(params)) + |> next_page_params(logs, params) conn |> put_status(200) @@ -563,11 +818,78 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :state_changes, + summary: "Get on-chain state changes caused by a specific transaction", + description: "Retrieves state changes (balance changes, token transfers) caused by a specific transaction.", + parameters: + [transaction_hash_param() | base_params()] ++ define_state_changes_paging_params(["state_changes", "items_count"]), + responses: [ + ok: { + "State changes caused by the specified transaction, with pagination.", + "application/json", + paginated_response( + items: Schemas.Transaction.StateChange, + next_page_params_example: %{ + "state_changes" => nil, + "items_count" => 50 + } + ) + }, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + operation :fhe_operations, + summary: "List FHE operations for a specific transaction", + description: + "Retrieves Fully Homomorphic Encryption (FHE) operations parsed from transaction logs. Includes operation details, HCU (Homomorphic Compute Unit) costs, operation types, and related metadata.", + parameters: [transaction_hash_param() | base_params()], + responses: [ + ok: + {"FHE operations for the specified transaction with transaction-level metrics.", "application/json", + Schemas.FheOperationsResponse}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Lists FHE operations for a specific transaction. + + Retrieves Fully Homomorphic Encryption (FHE) operations parsed from transaction + logs. Returns operation details, HCU (Homomorphic Compute Unit) costs, operation + types, and transaction-level metrics (total HCU, max depth, operation count). + + ## Parameters + - `conn` - The Plug.Conn. + - `params` - Map containing `:transaction_hash_param` (transaction hash string). + + ## Returns + - `Plug.Conn.t()` with 200 and JSON body on success. + - `{atom(), any()}` error tuple on validation failure (e.g. invalid hash). + """ + @spec fhe_operations(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} + def fhe_operations(conn, %{transaction_hash_param: transaction_hash_string} = params) do + with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do + # Fetch pre-parsed FHE operations from database + operations = FheOperation.by_transaction_hash(transaction_hash) + metrics = FheOperation.transaction_metrics(transaction_hash) + + conn + |> put_status(200) + |> render(:fhe_operations, + operations: operations, + total_hcu: metrics.total_hcu, + max_depth_hcu: metrics.max_depth_hcu, + operation_count: metrics.operation_count + ) + end + end + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/state-changes` endpoint. """ @spec state_changes(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def state_changes(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def state_changes(conn, %{transaction_hash_param: transaction_hash_string} = params) do with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params) do state_changes_plus_next_page = transaction @@ -582,7 +904,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do next_page_params = next_page - |> next_page_params(state_changes, delete_parameters_from_next_page_params(params)) + |> next_page_params(state_changes, params, true) conn |> put_status(200) @@ -590,6 +912,25 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :watchlist_transactions, + summary: "List transactions in a user's watchlist", + description: "Retrieves transactions in the authenticated user's watchlist.", + parameters: base_params() ++ define_paging_params(["block_number", "index", "items_count"]), + responses: [ + ok: + {"Watchlist transactions.", "application/json", + paginated_response( + items: Schemas.Transaction.Response, + next_page_params_example: %{ + "block_number" => 23_617_990, + "index" => 128, + "items_count" => 50 + } + )}, + forbidden: ForbiddenResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/watchlist` endpoint. """ @@ -598,28 +939,55 @@ defmodule BlockScoutWeb.API.V2.TransactionController do with {:auth, %{watchlist_id: watchlist_id}} <- {:auth, current_user(conn)} do full_options = [ - necessity_by_association: @transaction_necessity_by_association + necessity_by_association: transactions_necessity_by_association() ] |> Keyword.merge(paging_options(params, [:validated])) |> Keyword.merge(@api_true) - {watchlist_names, transactions_plus_one} = Chain.fetch_watchlist_transactions(watchlist_id, full_options) + {watchlist_names, transactions_plus_one} = + WatchlistAddress.fetch_watchlist_transactions(watchlist_id, full_options) {transactions, next_page} = split_list_by_page(transactions_plus_one) - next_page_params = next_page |> next_page_params(transactions, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(transactions, params) conn |> put_status(200) |> render(:transactions_watchlist, %{ - transactions: transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), next_page_params: next_page_params, watchlist_names: watchlist_names }) end end - def summary(conn, %{"transaction_hash_param" => transaction_hash_string, "just_request_body" => "true"} = params) do + operation :summary, + summary: "Get a human-readable, LLM-based transaction summary", + description: "Retrieves a human-readable summary of what a transaction did, presented in natural language.", + parameters: base_params() ++ [transaction_hash_param(), just_request_body_param()], + responses: [ + ok: + {"Human-readable summary of the specified transaction.", "application/json", + %Schema{ + anyOf: [ + Schemas.Transaction.Summary, + Schemas.Transaction.SummaryJustRequestBody + ] + }}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/summary` endpoint. + """ + @spec summary(Plug.Conn.t(), map()) :: + {:format, :error} + | {:not_found, {:error, :not_found}} + | {:restricted_access, true} + | {:transaction_interpreter_enabled, boolean} + | Plug.Conn.t() + def summary(conn, %{transaction_hash_param: transaction_hash_string, just_request_body: true} = params) do options = [ necessity_by_association: %{ @@ -637,16 +1005,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end - @doc """ - Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/summary` endpoint. - """ - @spec summary(Plug.Conn.t(), map()) :: - {:format, :error} - | {:not_found, {:error, :not_found}} - | {:restricted_access, true} - | {:transaction_interpreter_enabled, boolean} - | Plug.Conn.t() - def summary(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def summary(conn, %{transaction_hash_param: transaction_hash_string} = params) do options = [ necessity_by_association: %{ @@ -672,11 +1031,30 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :blobs, + summary: "List blobs for a transaction", + description: "Retrieves blobs for a specific transaction (Ethereum only).", + parameters: [transaction_hash_param() | base_params()], + responses: [ + ok: + {"Blobs for transaction.", "application/json", + %Schema{ + type: :object, + properties: %{ + items: %Schema{type: :array, items: Schemas.Blob.Response} + }, + nullable: false, + additionalProperties: false + }}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/blobs` endpoint. """ @spec blobs(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} - def blobs(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do + def blobs(conn, %{transaction_hash_param: transaction_hash_string} = params) do with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do full_options = @api_true @@ -689,6 +1067,35 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + operation :stats, + summary: "Get transaction statistics", + description: "Retrieves statistics for transactions, including counts and fee summaries for the last 24 hours.", + responses: [ + ok: + {"Transaction statistics.", "application/json", + %Schema{ + type: :object, + properties: %{ + transactions_count_24h: Schemas.General.IntegerString, + pending_transactions_count: Schemas.General.IntegerString, + transaction_fees_sum_24h: Schemas.General.IntegerString, + transaction_fees_avg_24h: Schemas.General.IntegerString + }, + required: [ + :transactions_count_24h, + :pending_transactions_count, + :transaction_fees_sum_24h, + :transaction_fees_avg_24h + ], + additionalProperties: false + }}, + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/transactions/stats` endpoint. + """ + @spec stats(Plug.Conn.t(), map()) :: Plug.Conn.t() def stats(conn, _params) do transactions_count = Transactions24hCount.fetch_count(@api_true) pending_transactions_count = NewPendingTransactionsCount.fetch(@api_true) @@ -708,6 +1115,87 @@ defmodule BlockScoutWeb.API.V2.TransactionController do ) end + operation :beacon_deposits, + summary: "List beacon deposits in a transaction", + description: "Retrieves beacon deposits included in a specific transaction with pagination support.", + parameters: [transaction_hash_param() | base_params()] ++ define_paging_params(["index", "items_count"]), + responses: [ + ok: + {"Beacon deposits for transaction.", "application/json", + paginated_response( + items: Schemas.Beacon.Deposit.Response, + next_page_params_example: %{ + "index" => 2_287_943, + "items_count" => 50 + } + )}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Handles `api/v2/transactions/:transaction_hash_param/beacon/deposits` endpoint. + Fetches beacon deposits included in a specific transaction with pagination support. + + This endpoint retrieves all beacon deposits that were included in the + specified transactions. The results include preloaded associations for both the from_address and + withdrawal_address, including scam badges, names, smart contracts, and proxy + implementations. The response is paginated and may include ENS and metadata + enrichment if those services are enabled. + + ## Parameters + - `conn`: The Plug connection. + - `params`: A map containing: + - `"transaction_hash_param"`: The transaction hash to fetch deposits from. + - Optional pagination parameter: + - `"index"`: non-negative integer, the starting index for pagination. + + ## Returns + - `{:error, :not_found}` - If the transaction is not found. + - `{:error, {:invalid, :hash}}` - If the transaction hash format is invalid. + - `Plug.Conn.t()` - A 200 response with rendered deposits and pagination + information when successful. + """ + @spec beacon_deposits(Plug.Conn.t(), map()) :: + {:format, :error} + | {:not_found, {:error, :not_found}} + | {:restricted_access, true} + | Plug.Conn.t() + def beacon_deposits(conn, %{transaction_hash_param: transaction_hash_string} = params) do + with {:ok, _transaction, transaction_hash} <- validate_transaction(transaction_hash_string, params) do + full_options = + [ + necessity_by_association: %{ + [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [withdrawal_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => + :optional + }, + api?: true + ] + |> Keyword.merge(DepositController.paging_options(params)) + + deposit_plus_one = BeaconDeposit.from_transaction_hash(transaction_hash, full_options) + {deposits, next_page} = split_list_by_page(deposit_plus_one) + + next_page_params = + next_page + |> next_page_params( + deposits, + params, + false, + DepositController.paging_function() + ) + + conn + |> put_status(200) + |> put_view(DepositView) + |> render(:deposits, %{ + deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + next_page_params: next_page_params + }) + end + end + @doc """ Checks if this valid transaction hash string, and this transaction doesn't belong to prohibited address """ @@ -717,7 +1205,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do | {:restricted_access, true} | {:ok, Transaction.t(), Hash.t()} def validate_transaction(transaction_hash_string, params, options \\ @api_true) do - with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_transaction_hash(transaction_hash_string)}, + with {:format, {:ok, transaction_hash}} <- {:format, Chain.string_to_full_hash(transaction_hash_string)}, {:not_found, {:ok, transaction}} <- {:not_found, Chain.hash_to_transaction(transaction_hash, options)}, {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/utils_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/utils_controller.ex index 233066aaff43..ee00f0e5b139 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/utils_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/utils_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.UtilsController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex index 05fa50d272ee..f35153699bf5 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/validator_controller.ex @@ -1,19 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ValidatorController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import Explorer.PagingOptions, only: [default_paging_options: 0] alias BlockScoutWeb.API.V2.ApiView + alias BlockScoutWeb.Schemas.API.V2.ErrorResponses.{BadRequestResponse, NotFoundResponse} alias Explorer.Chain.Blackfort.Validator, as: ValidatorBlackfort alias Explorer.Chain.Cache.Counters.{Blackfort, Stability} alias Explorer.Chain.Stability.Validator, as: ValidatorStability alias Explorer.Chain.Zilliqa.Hash.BLSPublicKey alias Explorer.Chain.Zilliqa.Staker, as: ValidatorZilliqa - alias Explorer.Helper import BlockScoutWeb.PagingHelper, only: [ - delete_parameters_from_next_page_params: 1, stability_validators_state_options: 1, validators_blackfort_sorting: 1, validators_stability_sorting: 1 @@ -23,13 +24,17 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do only: [ split_list_by_page: 1, paging_options: 1, - next_page_params: 4 + next_page_params: 5 ] @api_true api?: true action_fallback(BlockScoutWeb.API.V2.FallbackController) + plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true) + + operation :stability_validators_list, false + @doc """ Function to handle GET requests to `/api/v2/validators/stability` endpoint. """ @@ -52,7 +57,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do next_page |> next_page_params( validators, - delete_parameters_from_next_page_params(params), + params, + false, &ValidatorStability.next_page_params/1 ) @@ -60,6 +66,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do |> render(:stability_validators, %{validators: validators, next_page_params: next_page_params}) end + operation :stability_validators_counters, false + @doc """ Function to handle GET requests to `/api/v2/validators/stability/counters` endpoint. """ @@ -74,19 +82,15 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do conn |> json(%{ validators_count: validators_counter, - # todo: It should be removed in favour `validators_count` property with the next release after 8.0.0 - validators_counter: validators_counter, new_validators_count_24h: new_validators_counter, - # todo: It should be removed in favour `new_validators_count_24h` property with the next release after 8.0.0 - new_validators_counter_24h: new_validators_counter, active_validators_count: active_validators_counter, - # todo: It should be removed in favour `active_validators_count` property with the next release after 8.0.0 - active_validators_counter: active_validators_counter, active_validators_percentage: calculate_active_validators_percentage(active_validators_counter, validators_counter) }) end + operation :blackfort_validators_list, false + @doc """ Function to handle GET requests to `/api/v2/validators/blackfort` endpoint. """ @@ -108,7 +112,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do next_page |> next_page_params( validators, - delete_parameters_from_next_page_params(params), + params, + false, &ValidatorBlackfort.next_page_params/1 ) @@ -116,6 +121,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do |> render(:blackfort_validators, %{validators: validators, next_page_params: next_page_params}) end + operation :blackfort_validators_counters, false + @doc """ Function to handle GET requests to `/api/v2/validators/blackfort/counters` endpoint. """ @@ -129,11 +136,7 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do conn |> json(%{ validators_count: validators_counter, - # todo: It should be removed in favour `validators_count` property with the next release after 8.0.0 - validators_counter: validators_counter, - new_validators_count_24h: new_validators_counter, - # todo: It should be removed in favour `new_validators_count_24h` property with the next release after 8.0.0 - new_validators_counter_24h: new_validators_counter + new_validators_count_24h: new_validators_counter }) end @@ -147,23 +150,65 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do end end + tags(["zilliqa"]) + + operation :zilliqa_validators_list, + summary: "Zilliqa validators list.", + description: "Retrieves the list of Zilliqa validators.", + parameters: + base_params() ++ + define_paging_params(["index", "items_count"]) ++ + [ + %OpenApiSpex.Parameter{ + name: :sort, + in: :query, + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["index"], + nullable: false + }, + required: false + }, + %OpenApiSpex.Parameter{ + name: :order, + in: :query, + schema: %OpenApiSpex.Schema{ + type: :string, + enum: ["asc", "desc"], + nullable: false + }, + required: false + } + ], + responses: [ + ok: + {"List of validators.", "application/json", + paginated_response( + items: Schemas.Zilliqa.Staker, + next_page_params_example: %{ + "index" => 55, + "items_count" => 50 + }, + title_prefix: "Validators" + )}, + unprocessable_entity: JsonErrorResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/validators/zilliqa` endpoint. """ @spec zilliqa_validators_list(Plug.Conn.t(), map()) :: Plug.Conn.t() def zilliqa_validators_list(conn, params) do paging_options = - with {:ok, index} <- Map.fetch(params, "index"), - {:ok, index} <- Helper.safe_parse_non_negative_integer(index) do - %{default_paging_options() | key: %{index: index}} - else + case Map.fetch(params, :index) do + {:ok, index} -> %{default_paging_options() | key: %{index: index}} _ -> default_paging_options() end sorting_options = case params do - %{"sort" => "index", "order" => "asc"} -> [asc_nulls_first: :index] - %{"sort" => "index", "order" => "desc"} -> [desc_nulls_last: :index] + %{sort: "index", order: "asc"} -> [asc_nulls_first: :index] + %{sort: "index", order: "desc"} -> [desc_nulls_last: :index] _ -> [] end @@ -181,7 +226,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do next_page |> next_page_params( validators, - delete_parameters_from_next_page_params(params), + params, + false, &ValidatorZilliqa.next_page_params/1 ) @@ -192,11 +238,30 @@ defmodule BlockScoutWeb.API.V2.ValidatorController do }) end + operation :zilliqa_validator, + summary: "Zilliqa validator by its BLS public key.", + description: "Retrieves Zilliqa validator detailed info by the given BLS public key.", + parameters: [ + %OpenApiSpex.Parameter{ + name: :bls_public_key, + in: :path, + schema: Schemas.General.HexString, + required: true + } + | base_params() + ], + responses: [ + ok: {"Validator detailed info.", "application/json", Schemas.Zilliqa.Staker.Detailed}, + unprocessable_entity: JsonErrorResponse.response(), + bad_request: BadRequestResponse.response(), + not_found: NotFoundResponse.response() + ] + @doc """ Function to handle GET requests to `/api/v2/validators/zilliqa/:bls_public_key` endpoint. """ @spec zilliqa_validator(Plug.Conn.t(), map()) :: Plug.Conn.t() | :error | {:error, :not_found} - def zilliqa_validator(conn, %{"bls_public_key" => bls_public_key_string}) do + def zilliqa_validator(conn, %{bls_public_key: bls_public_key_string}) do options = [ necessity_by_association: %{ diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex index 442b38f82c9e..111d72467406 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/verification_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.VerificationController do use BlockScoutWeb, :controller use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -10,11 +11,13 @@ defmodule BlockScoutWeb.API.V2.VerificationController do alias BlockScoutWeb.API.V2.ApiView alias Explorer.Chain alias Explorer.Chain.SmartContract + alias Explorer.SmartContract.{CompilerVersion, RustVerifierInterface, Solidity.CodeCompiler, StylusVerifierInterface} + alias Explorer.SmartContract.Helper alias Explorer.SmartContract.Solidity.PublisherWorker, as: SolidityPublisherWorker alias Explorer.SmartContract.Solidity.PublishHelper alias Explorer.SmartContract.Stylus.PublisherWorker, as: StylusPublisherWorker alias Explorer.SmartContract.Vyper.PublisherWorker, as: VyperPublisherWorker - alias Explorer.SmartContract.{CompilerVersion, RustVerifierInterface, Solidity.CodeCompiler, StylusVerifierInterface} + alias Indexer.Fetcher.OnDemand.ContractCode action_fallback(BlockScoutWeb.API.V2.FallbackController) @@ -103,7 +106,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do ) do Logger.info("API v2 smart-contract #{address_hash_string} verification via flattened file") - with :validated <- validate_address(params) do + with :validated <- validate_address(conn, params) do verification_params = %{ "address_hash" => String.downcase(address_hash_string), @@ -120,7 +123,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do |> Map.put("constructor_arguments", Map.get(params, "constructor_args", "")) |> Map.put("name", Map.get(params, "contract_name", "")) |> Map.put("external_libraries", Map.get(params, "libraries", %{})) - |> Map.put("is_yul", Map.get(params, "is_yul_contract", false)) + |> Map.put("language", verification_language(params)) |> Map.put("license_type", Map.get(params, "license_type")) log_sc_verification_started(address_hash_string) @@ -138,7 +141,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do ) do Logger.info("API v2 smart-contract #{address_hash_string} verification via standard json input") - with {:json_input, json_input} <- validate_params_standard_json_input(params) do + with {:json_input, json_input} <- validate_params_standard_json_input(conn, params) do verification_params = %{ "address_hash" => String.downcase(address_hash_string), @@ -167,7 +170,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do with {:not_found, true} <- {:not_found, Application.get_env(:explorer, Explorer.ThirdPartyIntegrations.Sourcify)[:enabled]}, - :validated <- validate_address(params), + :validated <- validate_address(conn, params), files_array <- PublishHelper.prepare_files_array(files), {:no_json_file, %Plug.Upload{path: _path}} <- {:no_json_file, PublishHelper.get_one_json(files_array)}, @@ -194,7 +197,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do Logger.info("API v2 smart-contract #{address_hash_string} verification via multipart") with :verifier_enabled <- check_microservice(), - :validated <- validate_address(params), + :validated <- validate_address(conn, params), libraries <- Map.get(params, "libraries", "{}"), {:libs_format, {:ok, json}} <- {:libs_format, Jason.decode(libraries)} do verification_params = @@ -230,7 +233,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do %{"address_hash" => address_hash_string, "compiler_version" => compiler_version, "source_code" => source_code} = params ) do - with :validated <- validate_address(params) do + with :validated <- validate_address(conn, params) do verification_params = %{ "address_hash" => String.downcase(address_hash_string), @@ -258,7 +261,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do Logger.info("API v2 vyper smart-contract #{address_hash_string} verification") with :verifier_enabled <- check_microservice(), - :validated <- validate_address(params) do + :validated <- validate_address(conn, params) do interfaces = parse_interfaces(params["interfaces"]) verification_params = @@ -291,7 +294,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do Logger.info("API v2 vyper smart-contract #{address_hash_string} verification via standard json input") with :verifier_enabled <- check_microservice(), - {:json_input, json_input} <- validate_params_standard_json_input(params) do + {:json_input, json_input} <- validate_params_standard_json_input(conn, params) do verification_params = %{ "address_hash" => String.downcase(address_hash_string), "compiler_version" => compiler_version, @@ -351,7 +354,7 @@ defmodule BlockScoutWeb.API.V2.VerificationController do Logger.info("API v2 stylus smart-contract #{address_hash_string} verification via github repository") with {:not_found, true} <- {:not_found, StylusVerifierInterface.enabled?()}, - :validated <- validate_address(params) do + :validated <- validate_address(conn, params) do log_sc_verification_started(address_hash_string) Que.add(StylusPublisherWorker, {"github_repository", params}) @@ -383,8 +386,8 @@ defmodule BlockScoutWeb.API.V2.VerificationController do end # sobelow_skip ["Traversal.FileModule"] - defp validate_params_standard_json_input(%{"files" => files} = params) do - with :validated <- validate_address(params), + defp validate_params_standard_json_input(conn, %{"files" => files} = params) do + with :validated <- validate_address(conn, params), files_array <- PublishHelper.prepare_files_array(files), {:no_json_file, %Plug.Upload{path: path}} <- {:no_json_file, PublishHelper.get_one_json(files_array)}, @@ -393,10 +396,13 @@ defmodule BlockScoutWeb.API.V2.VerificationController do end end - defp validate_address(%{"address_hash" => address_hash_string} = params) do + defp validate_address(conn, %{"address_hash" => address_hash_string} = params) do with {:format, {:ok, address_hash}} <- {:format, Chain.string_to_address_hash(address_hash_string)}, - {:not_a_smart_contract, bytecode} when bytecode != "0x" <- - {:not_a_smart_contract, Chain.smart_contract_bytecode(address_hash, @api_true)}, + {:not_a_smart_contract, {:ok, _bytecode}} <- + {:not_a_smart_contract, + conn + |> AccessHelper.conn_to_ip_string() + |> ContractCode.get_or_fetch_bytecode(address_hash)}, {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params), {:already_verified, false} <- {:already_verified, SmartContract.verified_with_full_match?(address_hash, @api_true)} do @@ -410,6 +416,12 @@ defmodule BlockScoutWeb.API.V2.VerificationController do end end + defp verification_language(params) do + params + |> Helper.parse_solidity_verification_language() + |> Atom.to_string() + end + defp log_sc_verification_started(address_hash_string) do Logger.info("API v2 smart-contract #{address_hash_string} verification request sent to the microservice") end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex index 1cf03e589928..3b32c056a806 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex @@ -1,14 +1,38 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.WithdrawalController do use BlockScoutWeb, :controller + use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] - import BlockScoutWeb.PagingHelper, only: [delete_parameters_from_next_page_params: 1] import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] alias Explorer.Chain + alias Explorer.Chain.Withdrawal + + tags(["withdrawals"]) + + operation :withdrawals_list, + summary: "List validator withdrawal details on proof-of-stake networks", + description: + "Retrieves a paginated list of withdrawals, typically for proof-of-stake networks supporting validator withdrawals.", + parameters: + base_params() ++ + define_paging_params(["index", "items_count"]), + responses: [ + ok: + {"List of withdrawals with pagination.", "application/json", + paginated_response( + items: Schemas.Withdrawal, + next_page_params_example: %{ + "index" => 50, + "items_count" => 50 + } + )}, + unprocessable_entity: JsonErrorResponse.response() + ] def withdrawals_list(conn, params) do full_options = @@ -24,7 +48,7 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do withdrawals_plus_one = Chain.list_withdrawals(full_options) {withdrawals, next_page} = split_list_by_page(withdrawals_plus_one) - next_page_params = next_page |> next_page_params(withdrawals, delete_parameters_from_next_page_params(params)) + next_page_params = next_page |> next_page_params(withdrawals, params) conn |> put_status(200) @@ -34,14 +58,20 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do }) end + operation :withdrawals_counters, + summary: "Withdrawals counters", + description: "Returns total withdrawals count and sum from cache.", + parameters: base_params(), + responses: [ + ok: {"Withdrawals counters.", "application/json", Schemas.Withdrawal.Counter}, + unprocessable_entity: JsonErrorResponse.response() + ] + def withdrawals_counters(conn, _params) do conn |> json(%{ - withdrawals_count: Chain.count_withdrawals_from_cache(api?: true), - withdrawals_sum: Chain.sum_withdrawals_from_cache(api?: true), - # todo: Both properties below should be removed in favour `withdrawals_count` and `withdrawals_sum` properties with the next release after 8.0.0 - withdrawal_count: Chain.count_withdrawals_from_cache(api?: true), - withdrawal_sum: Chain.sum_withdrawals_from_cache(api?: true) + withdrawals_count: Withdrawal.count_withdrawals_from_cache(api?: true), + withdrawals_sum: Withdrawal.sum_withdrawals_from_cache(api?: true) }) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex index 8f96f0e1c2df..25a21adf4646 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/zksync_controller.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ZkSyncController do use BlockScoutWeb, :controller import BlockScoutWeb.Chain, only: [ - next_page_params: 4, + next_page_params: 5, paging_options: 1, split_list_by_page: 1 ] @@ -56,6 +57,7 @@ defmodule BlockScoutWeb.API.V2.ZkSyncController do next_page, batches, params, + false, fn %TransactionBatch{number: number} -> %{"number" => number} end ) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api_docs_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api_docs_controller.ex index 4a658c409e98..4f6995cf57bf 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api_docs_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api_docs_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.APIDocsController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex index ed117efab66b..03c48359f007 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex index 4a92e7c5ca52..83786797fbef 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_transaction_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockTransactionController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex index c6044d84ca43..95fc7d5f3c68 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/block_withdrawal_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockWithdrawalController do use BlockScoutWeb, :controller @@ -57,8 +58,8 @@ defmodule BlockScoutWeb.BlockWithdrawalController do {:error, :not_found} -> conn |> put_status(:not_found) + |> put_view(BlockTransactionView) |> render( - BlockTransactionView, "404.html", block: nil, block_above_tip: block_above_tip(formatted_block_hash_or_number) @@ -95,8 +96,8 @@ defmodule BlockScoutWeb.BlockWithdrawalController do {:error, :not_found} -> conn |> put_status(:not_found) + |> put_view(BlockTransactionView) |> render( - BlockTransactionView, "404.html", block: nil, block_above_tip: block_above_tip(formatted_block_hash_or_number) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex index 51cb05fb3e10..9b4d9f3d301d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain/market_history_chart_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Chain.MarketHistoryChartController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex index dec6f4f90ee3..af267e5bc043 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain/transaction_history_chart_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Chain.TransactionHistoryChartController do use BlockScoutWeb, :controller @@ -15,8 +16,8 @@ defmodule BlockScoutWeb.Chain.TransactionHistoryChartController do transaction_history_data = date_range - |> extract_history - |> encode_transaction_history_data + |> extract_history() + |> encode_transaction_history_data() json(conn, %{ history_data: transaction_history_data diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex index 1986aa013e90..5b2579ce8d62 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/chain_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ChainController do use BlockScoutWeb, :controller @@ -6,16 +7,15 @@ defmodule BlockScoutWeb.ChainController do alias BlockScoutWeb.API.V2.Helper alias BlockScoutWeb.{ChainView, Controller} alias Explorer.{Chain, PagingOptions, Repo} - alias Explorer.Chain.{Address, Block, Transaction} + alias Explorer.Chain.{Address, Block, Hash, Transaction} alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, BlocksCount, GasUsageSum, TransactionsCount} alias Explorer.Chain.Search alias Explorer.Chain.Supply.RSK - alias Explorer.Helper, as: ExplorerHelper alias Explorer.Market alias Phoenix.View def show(conn, _params) do - transaction_count = TransactionsCount.get() + transactions_count = TransactionsCount.get() total_gas_usage = GasUsageSum.total() block_count = BlocksCount.get() address_count = AddressesCount.fetch() @@ -50,7 +50,7 @@ defmodule BlockScoutWeb.ChainController do chart_config_json: Jason.encode!(chart_config), chart_data_paths: chart_data_paths, market_cap_calculation: market_cap_calculation, - transaction_estimated_count: transaction_count, + transaction_estimated_count: transactions_count, total_gas_usage: total_gas_usage, transactions_path: recent_transactions_path(conn, :index), transaction_stats: transaction_stats, @@ -99,7 +99,7 @@ defmodule BlockScoutWeb.ChainController do item = if transaction_hash_bytes do item - |> Map.replace(:transaction_hash, ExplorerHelper.add_0x_prefix(transaction_hash_bytes)) + |> Map.replace(:transaction_hash, full_hash_string(transaction_hash_bytes)) else item end @@ -107,7 +107,7 @@ defmodule BlockScoutWeb.ChainController do item = if block_hash_bytes do item - |> Map.replace(:block_hash, ExplorerHelper.add_0x_prefix(block_hash_bytes)) + |> Map.replace(:block_hash, full_hash_string(block_hash_bytes)) else item end @@ -176,4 +176,11 @@ defmodule BlockScoutWeb.ChainController do defp redirect_search_results(conn, _item, search_path) do redirect(conn, to: search_path) end + + defp full_hash_string(%Hash{} = hash), do: to_string(hash) + + defp full_hash_string(bytes) when is_binary(bytes) do + {:ok, hash} = Hash.Full.cast(bytes) + to_string(hash) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/common_components_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/common_components_controller.ex index 379bd15ed500..22a37e18887b 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/common_components_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/common_components_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.CommonComponentsController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/csv_export_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/csv_export_controller.ex index aa04ec19dad2..313623547ec3 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/csv_export_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/csv_export_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.CsvExportController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/page_not_found_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/page_not_found_controller.ex index fabcb3b733fb..560900b7d338 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/page_not_found_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/page_not_found_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.PageNotFoundController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex index f709855b000a..be42444a8202 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/pending_transaction_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.PendingTransactionController do use BlockScoutWeb, :controller @@ -6,6 +7,7 @@ defmodule BlockScoutWeb.PendingTransactionController do alias BlockScoutWeb.{Controller, TransactionView} alias Explorer.Chain + alias Explorer.Chain.Transaction alias Phoenix.View {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) @@ -64,7 +66,7 @@ defmodule BlockScoutWeb.PendingTransactionController do end defp get_pending_transactions_and_next_page(options) do - transactions_plus_one = Chain.recent_pending_transactions(options, true) + transactions_plus_one = Transaction.recent_pending_transactions(options, true) split_list_by_page(transactions_plus_one) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/recent_transactions_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/recent_transactions_controller.ex index 0863579eab95..150e4e976631 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/recent_transactions_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/recent_transactions_controller.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.RecentTransactionsController do use BlockScoutWeb, :controller import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] alias Explorer.{Chain, PagingOptions} - alias Explorer.Chain.{DenormalizationHelper, Hash} + alias Explorer.Chain.{DenormalizationHelper, Hash, Transaction} alias Phoenix.View {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) @@ -13,7 +14,7 @@ defmodule BlockScoutWeb.RecentTransactionsController do def index(conn, _params) do if ajax?(conn) do recent_transactions = - Chain.recent_collated_transactions( + Transaction.recent_collated_transactions( true, DenormalizationHelper.extend_block_necessity( [ diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/robots_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/robots_controller.ex index a482fb1a73c7..5e546ceef338 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/robots_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/robots_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.RobotsController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/search_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/search_controller.ex index c8292be760f6..bf21bc1a7de2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/search_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/search_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SearchController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex index 9a53ddd1e952..2355aa96a192 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/smart_contract_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SmartContractController do use BlockScoutWeb, :controller @@ -25,34 +26,14 @@ defmodule BlockScoutWeb.SmartContractController do true <- ajax?(conn), {:custom_abi, false} <- {:custom_abi, is_custom_abi}, {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options) do + {:ok, address} <- Address.find_contract_address(address_hash, address_options) do implementation_address_hash_string = implementation_address_hash(contract_type, address) functions = - if action == "write" do - if contract_type == "proxy" do - Writer.write_functions_proxy(implementation_address_hash_string) - else - Writer.write_functions(address.smart_contract) - end - else - if contract_type == "proxy" do - Reader.read_only_functions_proxy(address_hash, implementation_address_hash_string, nil) - else - Reader.read_only_functions(address.smart_contract, address_hash, params["from"]) - end - end + load_functions(action, contract_type, implementation_address_hash_string, address, address_hash, params) read_functions_required_wallet = - if action == "read" do - if contract_type == "proxy" do - Reader.read_functions_required_wallet_proxy(implementation_address_hash_string) - else - Reader.read_functions_required_wallet(address.smart_contract) - end - else - [] - end + load_read_functions_required_wallet(action, contract_type, implementation_address_hash_string, address) contract_abi = Poison.encode!(address.smart_contract.abi) @@ -99,10 +80,31 @@ defmodule BlockScoutWeb.SmartContractController do def index(conn, _), do: not_found(conn) + defp load_functions("write", "proxy", implementation_address_hash_string, _address, _address_hash, _params), + do: Writer.write_functions_proxy(implementation_address_hash_string) + + defp load_functions("write", _contract_type, _implementation_address_hash_string, address, _address_hash, _params), + do: Writer.write_functions(address.smart_contract) + + defp load_functions(_action, "proxy", implementation_address_hash_string, _address, address_hash, _params), + do: Reader.read_only_functions_proxy(address_hash, implementation_address_hash_string, nil) + + defp load_functions(_action, _contract_type, _implementation_address_hash_string, address, address_hash, params), + do: Reader.read_only_functions(address.smart_contract, address_hash, params["from"]) + + defp load_read_functions_required_wallet("read", "proxy", implementation_address_hash_string, _address), + do: Reader.read_functions_required_wallet_proxy(implementation_address_hash_string) + + defp load_read_functions_required_wallet("read", _contract_type, _implementation_address_hash_string, address), + do: Reader.read_functions_required_wallet(address.smart_contract) + + defp load_read_functions_required_wallet(_action, _contract_type, _implementation_address_hash_string, _address), + do: [] + defp implementation_address_hash(contract_type, address) do if contract_type == "proxy" do implementation = Implementation.get_implementation(address.smart_contract) - (implementation && implementation.address_hashes |> List.first()) || burn_address_hash_string() + (implementation && (implementation.address_hashes || []) |> List.first()) || burn_address_hash_string() else burn_address_hash_string() end @@ -159,8 +161,9 @@ defmodule BlockScoutWeb.SmartContractController do :names => :optional, :smart_contract => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional }, + preload_contract_creation_internal_transaction: true, ip: AccessHelper.conn_to_ip_string(conn) ] @@ -169,39 +172,13 @@ defmodule BlockScoutWeb.SmartContractController do with true <- ajax?(conn), {:ok, address_hash} <- Chain.string_to_address_hash(params["id"]), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options) do + {:ok, address} <- Address.find_contract_address(address_hash, address_options) do contract_type = if params["type"] == "proxy", do: :proxy, else: :regular - args = - if is_nil(params["args_count"]) do - # we should convert: %{"0" => _, "1" => _} to [_, _] - params["args"] |> convert_map_to_array() - else - {args_count, _} = Integer.parse(params["args_count"]) - - if args_count < 1, - do: [], - else: for(x <- 0..(args_count - 1), do: params["arg_" <> to_string(x)] |> convert_map_to_array()) - end + args = build_query_args(params) %{output: outputs, names: names} = - if custom_abi do - Reader.query_function_with_names_custom_abi( - address_hash, - %{method_id: params["method_id"], args: args}, - params["from"], - custom_abi.abi - ) - else - Reader.query_function_with_names( - address_hash, - %{method_id: params["method_id"], args: args}, - contract_type, - params["from"], - address.smart_contract && address.smart_contract.abi, - true - ) - end + query_function_with_names(custom_abi, address_hash, params, args, contract_type, address) conn |> put_status(200) @@ -226,6 +203,41 @@ defmodule BlockScoutWeb.SmartContractController do end end + defp build_query_args(%{"args_count" => nil} = params) do + # we should convert: %{"0" => _, "1" => _} to [_, _] + params["args"] |> convert_map_to_array() + end + + defp build_query_args(%{"args_count" => args_count} = params) do + {parsed_args_count, _} = Integer.parse(args_count) + + if parsed_args_count < 1, + do: [], + else: for(x <- 0..(parsed_args_count - 1), do: params["arg_" <> to_string(x)] |> convert_map_to_array()) + end + + defp build_query_args(params), do: params["args"] |> convert_map_to_array() + + defp query_function_with_names(custom_abi, address_hash, params, args, contract_type, address) do + if custom_abi do + Reader.query_function_with_names_custom_abi( + address_hash, + %{method_id: params["method_id"], args: args}, + params["from"], + custom_abi.abi + ) + else + Reader.query_function_with_names( + address_hash, + %{method_id: params["method_id"], args: args}, + contract_type, + params["from"], + address.smart_contract && address.smart_contract.abi, + true + ) + end + end + defp convert_map_to_array(map) do if turned_out_array?(map) do map |> Map.values() |> try_to_map_elements() diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/contract_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/contract_controller.ex index 1e9828a4735d..b2348f8c11c0 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/contract_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/contract_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.ContractController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex index 6e65b36521a1..b4e590157e78 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/holder_controller.ex @@ -1,14 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.HolderController do use BlockScoutWeb, :controller - import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] - - alias BlockScoutWeb.{AccessHelper, Controller} - alias BlockScoutWeb.Tokens.HolderView + alias BlockScoutWeb.AccessHelper + alias BlockScoutWeb.Tokens.{HolderView, TransferController} alias Explorer.Chain - alias Explorer.Chain.Address - alias Indexer.Fetcher.OnDemand.TokenTotalSupply, as: TokenTotalSupplyOnDemand alias Phoenix.View import BlockScoutWeb.Chain, @@ -57,31 +53,7 @@ defmodule BlockScoutWeb.Tokens.HolderController do end end - def index(conn, %{"token_id" => address_hash_string} = params) do - options = [necessity_by_association: %{[contract_address: :smart_contract] => :optional}] - ip = AccessHelper.conn_to_ip_string(conn) - - with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, token} <- Chain.token_from_address_hash(address_hash, options), - {:ok, false} <- AccessHelper.restricted_access?(address_hash_string, params) do - render( - conn, - "index.html", - current_path: Controller.current_full_path(conn), - token: token, - counters_path: token_path(conn, :token_counters, %{"id" => Address.checksum(address_hash)}), - token_total_supply_status: TokenTotalSupplyOnDemand.trigger_fetch(ip, address_hash), - tags: get_address_tags(address_hash, current_user(conn)) - ) - else - {:restricted_access, _} -> - not_found(conn) - - :error -> - not_found(conn) - - {:error, :not_found} -> - not_found(conn) - end + def index(conn, %{"token_id" => _address_hash_string} = params) do + TransferController.index(conn, params) end end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/helper.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/helper.ex index eb1c542679a1..45fb26b2ed20 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.Helper do @moduledoc """ Token instance controllers common helper diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex index e23f27835d4c..3b831717e5f4 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/holder_controller.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.HolderController do use BlockScoutWeb, :controller alias BlockScoutWeb.Tokens.HolderView alias BlockScoutWeb.Tokens.Instance.Helper alias Explorer.Chain - alias Explorer.Chain.Address + alias Explorer.Chain.{Address, Token} alias Explorer.Chain.Token.Instance alias Phoenix.View @@ -13,7 +14,7 @@ defmodule BlockScoutWeb.Tokens.Instance.HolderController do def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id_string, "type" => "JSON"} = params) do with {:ok, address_hash} <- Chain.string_to_address_hash(token_address_hash), {:ok, token} <- Chain.token_from_address_hash(address_hash), - false <- Chain.erc_20_token?(token), + false <- Chain.erc_20_token?(token) or Token.zrc_2_token?(token), {token_id, ""} <- Integer.parse(token_id_string), token_holders <- Chain.fetch_token_holders_from_token_hash_and_token_id(address_hash, token_id, paging_options(params)) do @@ -54,12 +55,13 @@ defmodule BlockScoutWeb.Tokens.Instance.HolderController do end end + # credo:disable-for-next-line Credo.Check.Design.DuplicatedCode def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id_string}) do options = [necessity_by_association: %{[contract_address: :smart_contract] => :optional}] with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), {:ok, token} <- Chain.token_from_address_hash(hash, options), - false <- Chain.erc_20_token?(token), + false <- Chain.erc_20_token?(token) or Token.zrc_2_token?(token), {token_id, ""} <- Integer.parse(token_id_string) do case Instance.nft_instance_by_token_id_and_token_address(token_id, hash) do {:ok, token_instance} -> Helper.render(conn, token_instance, hash, token_id, token) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/metadata_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/metadata_controller.ex index 55d6846f1cca..68c32afd3414 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/metadata_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/metadata_controller.ex @@ -1,8 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.MetadataController do use BlockScoutWeb, :controller alias BlockScoutWeb.Tokens.Instance.Helper alias Explorer.Chain + alias Explorer.Chain.Token alias Explorer.Chain.Token.Instance def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id_string}) do @@ -10,7 +12,7 @@ defmodule BlockScoutWeb.Tokens.Instance.MetadataController do with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), {:ok, token} <- Chain.token_from_address_hash(hash, options), - false <- Chain.erc_20_token?(token), + false <- Chain.erc_20_token?(token) or Token.zrc_2_token?(token), {token_id, ""} <- Integer.parse(token_id_string), {:ok, token_instance} <- Instance.nft_instance_by_token_id_and_token_address(token_id, hash) do diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex index ef95c9f21d2e..cf5004946611 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance/transfer_controller.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.TransferController do use BlockScoutWeb, :controller alias BlockScoutWeb.Tokens.Instance.Helper alias BlockScoutWeb.Tokens.TransferView alias Explorer.Chain - alias Explorer.Chain.Address + alias Explorer.Chain.{Address, Token} alias Explorer.Chain.Token.Instance alias Phoenix.View @@ -17,7 +18,7 @@ defmodule BlockScoutWeb.Tokens.Instance.TransferController do def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id_string, "type" => "JSON"} = params) do with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), {:ok, token} <- Chain.token_from_address_hash(hash), - false <- Chain.erc_20_token?(token), + false <- Chain.erc_20_token?(token) or Token.zrc_2_token?(token), {token_id, ""} <- Integer.parse(token_id_string), token_transfers <- Chain.fetch_token_transfers_from_token_hash_and_token_id(hash, token_id, paging_options(params)) do @@ -57,12 +58,13 @@ defmodule BlockScoutWeb.Tokens.Instance.TransferController do end end + # credo:disable-for-next-line Credo.Check.Design.DuplicatedCode def index(conn, %{"token_id" => token_address_hash, "instance_id" => token_id_string}) do options = [necessity_by_association: %{[contract_address: :smart_contract] => :optional}] with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), {:ok, token} <- Chain.token_from_address_hash(hash, options), - false <- Chain.erc_20_token?(token), + false <- Chain.erc_20_token?(token) or Token.zrc_2_token?(token), {token_id, ""} <- Integer.parse(token_id_string) do case Instance.nft_instance_by_token_id_and_token_address(token_id, hash) do {:ok, token_instance} -> Helper.render(conn, token_instance, hash, token_id, token) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance_controller.ex index fd222f9294e6..e1ba1ef2735c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/instance_controller.ex @@ -1,13 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.InstanceController do use BlockScoutWeb, :controller alias BlockScoutWeb.Controller alias Explorer.Chain + alias Explorer.Chain.Token def show(conn, %{"token_id" => token_address_hash, "id" => token_id}) do with {:ok, hash} <- Chain.string_to_address_hash(token_address_hash), {:ok, token} <- Chain.token_from_address_hash(hash), - false <- Chain.erc_20_token?(token) do + false <- Chain.erc_20_token?(token) or Token.zrc_2_token?(token) do token_instance_transfer_path = conn |> token_instance_transfer_path(:index, token_address_hash, token_id) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex index 787c25ce1bd4..bce53330861b 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.InventoryController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex index a9f5c60d8a6d..c7661debd323 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/token_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.TokenController do use BlockScoutWeb, :controller @@ -5,6 +6,7 @@ defmodule BlockScoutWeb.Tokens.TokenController do alias BlockScoutWeb.AccessHelper alias Explorer.Chain + alias Explorer.Chain.Token def show(conn, %{"id" => address_hash_string}) do redirect(conn, to: AccessHelper.get_path(conn, :token_transfer_path, :index, address_hash_string)) @@ -13,9 +15,9 @@ defmodule BlockScoutWeb.Tokens.TokenController do def token_counters(conn, %{"id" => address_hash_string}) do case Chain.string_to_address_hash(address_hash_string) do {:ok, address_hash} -> - {transfer_count, token_holder_count} = Chain.fetch_token_counters(address_hash, 30_000) + {transfers_count, holders_count} = Token.fetch_token_counters(address_hash, 30_000) - json(conn, %{transfer_count: transfer_count, token_holder_count: token_holder_count}) + json(conn, %{transfer_count: transfers_count, token_holder_count: holders_count}) _ -> not_found(conn) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex index 17ee2823fcd5..b14faa7a68bf 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/tokens_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TokensController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex index 544f10e00c53..a7aa73ec6e33 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/transfer_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.TransferController do use BlockScoutWeb, :controller diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_controller.ex index a170270449f6..feaae15b198d 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_controller.ex @@ -1,20 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - - import BlockScoutWeb.Chain, - only: [ - fetch_page_number: 1, - paging_options: 1, - next_page_params: 3, - update_page_parameters: 3, - split_list_by_page: 1 - ] - import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import BlockScoutWeb.Models.GetTransactionTags, only: [get_transaction_with_addresses_tags: 2] - import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] alias BlockScoutWeb.{ AccessHelper, @@ -26,8 +16,7 @@ defmodule BlockScoutWeb.TransactionController do alias Explorer.{Chain, Market} alias Explorer.Chain.Cache.Counters.TransactionsCount - alias Explorer.Chain.DenormalizationHelper - alias Phoenix.View + alias Explorer.Chain.Transaction @necessity_by_association %{ :block => :optional, @@ -38,85 +27,6 @@ defmodule BlockScoutWeb.TransactionController do :token_transfers => :optional } - {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) - @burn_address_hash burn_address_hash - - @default_options [ - necessity_by_association: %{ - [created_contract_address: :names] => :optional, - [from_address: :names] => :optional, - [to_address: :names] => :optional, - [created_contract_address: :smart_contract] => :optional, - [from_address: :smart_contract] => :optional, - [to_address: :smart_contract] => :optional - } - ] - - def index(conn, %{"type" => "JSON"} = params) do - options = - @default_options - |> DenormalizationHelper.extend_block_necessity(:required) - |> Keyword.merge(paging_options(params)) - - full_options = - options - |> Keyword.put( - :paging_options, - params - |> fetch_page_number() - |> update_page_parameters(Chain.default_page_size(), Keyword.get(options, :paging_options)) - ) - - %{total_transactions_count: transactions_count, transactions: transactions_plus_one} = - Chain.recent_collated_transactions_for_rap(full_options) - - {transactions, next_page} = - if fetch_page_number(params) == 1 do - split_list_by_page(transactions_plus_one) - else - {transactions_plus_one, nil} - end - - next_page_params = - if fetch_page_number(params) == 1 do - page_size = Chain.default_page_size() - - pages_limit = transactions_count |> Kernel./(page_size) |> Float.ceil() |> trunc() - - case next_page_params(next_page, transactions, params) do - nil -> - nil - - next_page_params -> - next_page_params - |> Map.delete("type") - |> Map.delete("items_count") - |> Map.put("pages_limit", pages_limit) - |> Map.put("page_size", page_size) - |> Map.put("page_number", 1) - end - else - Map.delete(params, "type") - end - - json( - conn, - %{ - items: - Enum.map(transactions, fn transaction -> - View.render_to_string( - TransactionView, - "_tile.html", - transaction: transaction, - burn_address_hash: @burn_address_hash, - conn: conn - ) - end), - next_page_params: next_page_params - } - ) - end - def index(conn, _params) do transaction_estimated_count = TransactionsCount.get() @@ -129,7 +39,7 @@ defmodule BlockScoutWeb.TransactionController do end def show(conn, %{"id" => transaction_hash_string, "type" => "JSON"}) do - case Chain.string_to_transaction_hash(transaction_hash_string) do + case Chain.string_to_full_hash(transaction_hash_string) do {:ok, transaction_hash} -> if Chain.transaction_has_token_transfers?(transaction_hash) do TransactionTokenTransferController.index(conn, %{ @@ -149,67 +59,9 @@ defmodule BlockScoutWeb.TransactionController do end def show(conn, %{"id" => id} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(id), - :ok <- Chain.check_transaction_exists(transaction_hash) do - if Chain.transaction_has_token_transfers?(transaction_hash) do - with {:ok, transaction} <- - Chain.hash_to_transaction(transaction_hash, necessity_by_association: @necessity_by_association), - {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), - {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.to_address_hash), params) do - render( - conn, - "show_token_transfers.html", - exchange_rate: Market.get_coin_exchange_rate(), - block_height: Chain.block_height(), - current_path: Controller.current_full_path(conn), - current_user: current_user(conn), - show_token_transfers: true, - transaction: transaction, - from_tags: get_address_tags(transaction.from_address_hash, current_user(conn)), - to_tags: get_address_tags(transaction.to_address_hash, current_user(conn)), - transaction_tags: - get_transaction_with_addresses_tags( - transaction, - current_user(conn) - ) - ) - else - {:error, :not_found} -> - set_not_found_view(conn, id) - - {:restricted_access, _} -> - set_not_found_view(conn, id) - end - else - with {:ok, transaction} <- - Chain.hash_to_transaction(transaction_hash, necessity_by_association: @necessity_by_association), - {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), - {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.to_address_hash), params) do - render( - conn, - "show_internal_transactions.html", - exchange_rate: Market.get_coin_exchange_rate(), - current_path: Controller.current_full_path(conn), - current_user: current_user(conn), - block_height: Chain.block_height(), - show_token_transfers: Chain.transaction_has_token_transfers?(transaction_hash), - transaction: transaction, - from_tags: get_address_tags(transaction.from_address_hash, current_user(conn)), - to_tags: get_address_tags(transaction.to_address_hash, current_user(conn)), - transaction_tags: - get_transaction_with_addresses_tags( - transaction, - current_user(conn) - ) - ) - else - {:error, :not_found} -> - set_not_found_view(conn, id) - - {:restricted_access, _} -> - set_not_found_view(conn, id) - end - end + with {:ok, transaction_hash} <- Chain.string_to_full_hash(id), + :ok <- Transaction.check_transaction_exists(transaction_hash) do + render_transaction_page(conn, id, transaction_hash, params) else :error -> unprocessable_entity(conn) @@ -219,6 +71,42 @@ defmodule BlockScoutWeb.TransactionController do end end + defp render_transaction_page(conn, id, transaction_hash, params) do + show_token_transfers? = Chain.transaction_has_token_transfers?(transaction_hash) + + template = + if show_token_transfers? do + "show_token_transfers.html" + else + "show_internal_transactions.html" + end + + with {:ok, transaction} <- + Chain.hash_to_transaction(transaction_hash, necessity_by_association: @necessity_by_association), + {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), + {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.to_address_hash), params) do + render( + conn, + template, + exchange_rate: Market.get_coin_exchange_rate(), + block_height: Chain.block_height(), + current_path: Controller.current_full_path(conn), + current_user: current_user(conn), + show_token_transfers: show_token_transfers?, + transaction: transaction, + from_tags: get_address_tags(transaction.from_address_hash, current_user(conn)), + to_tags: get_address_tags(transaction.to_address_hash, current_user(conn)), + transaction_tags: get_transaction_with_addresses_tags(transaction, current_user(conn)) + ) + else + {:error, :not_found} -> + set_not_found_view(conn, id) + + {:restricted_access, _} -> + set_not_found_view(conn, id) + end + end + def set_not_found_view(conn, transaction_hash_string) do conn |> put_status(404) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex index 7af84eb1ea46..a3bef06d6b4a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_internal_transaction_controller.ex @@ -1,39 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionInternalTransactionController do use BlockScoutWeb, :controller import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] + + import BlockScoutWeb.Chain, + only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1, transaction_to_internal_transactions: 2] + import BlockScoutWeb.Models.GetAddressTags, only: [get_address_tags: 2] import BlockScoutWeb.Models.GetTransactionTags, only: [get_transaction_with_addresses_tags: 2] alias BlockScoutWeb.{AccessHelper, Controller, InternalTransactionView, TransactionController} alias Explorer.{Chain, Market} - alias Explorer.Chain.{DenormalizationHelper, InternalTransaction} + alias Explorer.Chain.Transaction alias Phoenix.View def index(conn, %{"transaction_id" => transaction_hash_string, "type" => "JSON"} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), - :ok <- Chain.check_transaction_exists(transaction_hash), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), + :ok <- Transaction.check_transaction_exists(transaction_hash), {:ok, transaction} <- Chain.hash_to_transaction(transaction_hash, []), {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.to_address_hash), params) do full_options = [ - necessity_by_association: %{ - [created_contract_address: :names] => :optional, - [from_address: :names] => :optional, - [to_address: :names] => :optional, - [created_contract_address: :smart_contract] => :optional, - [from_address: :smart_contract] => :optional, - [to_address: :smart_contract] => :optional, - :transaction => :optional - } + address_preloads: [ + created_contract_address: [:names, :smart_contract], + from_address: [:names, :smart_contract], + to_address: [:names, :smart_contract] + ] ] - |> DenormalizationHelper.extend_transaction_block_necessity(:optional) |> Keyword.merge(paging_options(params)) - internal_transactions_plus_one = - InternalTransaction.transaction_to_internal_transactions(transaction_hash, full_options) + internal_transactions_plus_one = transaction_to_internal_transactions(transaction, full_options) {internal_transactions, next_page} = split_list_by_page(internal_transactions_plus_one) @@ -84,7 +82,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionController do end def index(conn, %{"transaction_id" => transaction_hash_string} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), {:ok, transaction} <- Chain.hash_to_transaction( transaction_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex index fe0c93ad5134..05f6fd9743d3 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_log_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionLogController do use BlockScoutWeb, :controller @@ -11,7 +12,7 @@ defmodule BlockScoutWeb.TransactionLogController do alias Phoenix.View def index(conn, %{"transaction_id" => transaction_hash_string, "type" => "JSON"} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), {:ok, transaction} <- Chain.hash_to_transaction(transaction_hash, necessity_by_association: %{[to_address: :smart_contract] => :optional} @@ -22,9 +23,7 @@ defmodule BlockScoutWeb.TransactionLogController do Keyword.merge( [ necessity_by_association: %{ - [address: :names] => :optional, - [address: :smart_contract] => :optional, - address: :optional + [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional } ], paging_options(params) @@ -75,7 +74,7 @@ defmodule BlockScoutWeb.TransactionLogController do end def index(conn, %{"transaction_id" => transaction_hash_string} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), {:ok, transaction} <- Chain.hash_to_transaction( transaction_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_raw_trace_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_raw_trace_controller.ex index 6beedaf261c9..c79a1a4e24a1 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_raw_trace_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_raw_trace_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionRawTraceController do use BlockScoutWeb, :controller @@ -11,7 +12,7 @@ defmodule BlockScoutWeb.TransactionRawTraceController do alias Indexer.Fetcher.OnDemand.FirstTrace, as: FirstTraceOnDemand def index(conn, %{"transaction_id" => hash_string} = params) do - with {:ok, hash} <- Chain.string_to_transaction_hash(hash_string), + with {:ok, hash} <- Chain.string_to_full_hash(hash_string), {:ok, transaction} <- Chain.hash_to_transaction( hash, @@ -26,16 +27,7 @@ defmodule BlockScoutWeb.TransactionRawTraceController do ), {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.from_address_hash), params), {:ok, false} <- AccessHelper.restricted_access?(to_string(transaction.to_address_hash), params) do - if is_nil(transaction.block_number) do - render_raw_trace(conn, [], transaction, hash) - else - FirstTraceOnDemand.maybe_trigger_fetch(transaction) - - case Chain.fetch_transaction_raw_traces(transaction) do - {:ok, raw_traces} -> render_raw_trace(conn, raw_traces, transaction, hash) - _error -> unprocessable_entity(conn) - end - end + render_fetched_trace(conn, transaction, hash) else {:restricted_access, _} -> TransactionController.set_not_found_view(conn, hash_string) @@ -48,6 +40,19 @@ defmodule BlockScoutWeb.TransactionRawTraceController do end end + defp render_fetched_trace(conn, transaction, hash) do + if is_nil(transaction.block_number) do + render_raw_trace(conn, [], transaction, hash) + else + FirstTraceOnDemand.maybe_trigger_fetch(transaction) + + case Chain.fetch_transaction_raw_traces(transaction) do + {:ok, raw_traces} -> render_raw_trace(conn, raw_traces, transaction, hash) + _error -> unprocessable_entity(conn) + end + end + end + defp render_raw_trace(conn, raw_traces, transaction, hash) do render( conn, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex index 00671841fbcb..42a1b318bb7e 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_state_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionStateController do use BlockScoutWeb, :controller @@ -23,7 +24,7 @@ defmodule BlockScoutWeb.TransactionStateController do @burn_address_hash burn_address_hash def index(conn, %{"transaction_id" => transaction_hash_string, "type" => "JSON"} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), {:ok, transaction} <- Chain.hash_to_transaction(transaction_hash), {:ok, false} <- @@ -83,7 +84,7 @@ defmodule BlockScoutWeb.TransactionStateController do end def index(conn, %{"transaction_id" => transaction_hash_string} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), {:ok, transaction} <- Chain.hash_to_transaction( transaction_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex index 0db5840c9827..f973c0bf43e2 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/transaction_token_transfer_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionTokenTransferController do use BlockScoutWeb, :controller @@ -9,14 +10,15 @@ defmodule BlockScoutWeb.TransactionTokenTransferController do alias BlockScoutWeb.{AccessHelper, Controller, TransactionController, TransactionTokenTransferView} alias Explorer.{Chain, Market} + alias Explorer.Chain.Transaction alias Phoenix.View {:ok, burn_address_hash} = Chain.string_to_address_hash(burn_address_hash_string()) @burn_address_hash burn_address_hash def index(conn, %{"transaction_id" => transaction_hash_string, "type" => "JSON"} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), - :ok <- Chain.check_transaction_exists(transaction_hash), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), + :ok <- Transaction.check_transaction_exists(transaction_hash), {:ok, transaction} <- Chain.hash_to_transaction( transaction_hash, @@ -87,7 +89,7 @@ defmodule BlockScoutWeb.TransactionTokenTransferController do end def index(conn, %{"transaction_id" => transaction_hash_string} = params) do - with {:ok, transaction_hash} <- Chain.string_to_transaction_hash(transaction_hash_string), + with {:ok, transaction_hash} <- Chain.string_to_full_hash(transaction_hash_string), {:ok, transaction} <- Chain.hash_to_transaction( transaction_hash, diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex index efaf6a85dbd2..10c07a33c1da 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/verified_contracts_controller.ex @@ -1,16 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.VerifiedContractsController do use BlockScoutWeb, :controller import BlockScoutWeb.Chain, - only: [next_page_params: 4, split_list_by_page: 1, fetch_page_number: 1] + only: [next_page_params: 5, split_list_by_page: 1, fetch_page_number: 1] import BlockScoutWeb.PagingHelper, only: [current_filter: 1, search_query: 1] import BlockScoutWeb.API.V2.SmartContractController, - only: [ - smart_contract_addresses_paging_options: 1, - smart_contract_addresses_paging_params: 1 - ] + only: [smart_contract_addresses_paging_options: 1] alias BlockScoutWeb.{Controller, VerifiedContractsView} alias Explorer.Chain @@ -18,7 +16,8 @@ defmodule BlockScoutWeb.VerifiedContractsController do alias Phoenix.View @necessity_by_association %{ - :token => :optional + :token => :optional, + :smart_contract => :optional } def index(conn, %{"type" => "JSON"} = params) do @@ -31,7 +30,9 @@ defmodule BlockScoutWeb.VerifiedContractsController do verified_contracts_plus_one = full_options |> SmartContract.verified_contract_addresses() - |> Enum.map(&%SmartContract{&1.smart_contract | address: &1}) + |> Enum.map(fn %{smart_contract: %SmartContract{} = smart_contract} = address -> + %SmartContract{smart_contract | address: address} + end) {verified_contracts, next_page} = split_list_by_page(verified_contracts_plus_one) @@ -48,7 +49,8 @@ defmodule BlockScoutWeb.VerifiedContractsController do next_page, verified_contracts, params, - &smart_contract_addresses_paging_params(&1.address) + false, + &%{id: &1.id} ) do nil -> nil next_page_params -> verified_contracts_path(conn, :index, Map.delete(next_page_params, "type")) diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/visualize_sol2uml_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/visualize_sol2uml_controller.ex index 025706a2c71c..aa71d42aefec 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/visualize_sol2uml_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/visualize_sol2uml_controller.ex @@ -1,7 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.VisualizeSol2umlController do use BlockScoutWeb, :controller alias BlockScoutWeb.AccessHelper alias Explorer.Chain + alias Explorer.Chain.Address alias Explorer.Visualize.Sol2uml def index(conn, %{"type" => "JSON", "address" => address_hash_string}) do @@ -12,32 +14,15 @@ defmodule BlockScoutWeb.VisualizeSol2umlController do ip: AccessHelper.conn_to_ip_string(conn) ] - if Sol2uml.enabled?() do - with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options), - # check that contract is verified. partial and bytecode twin verification is ok for this case - false <- is_nil(address.smart_contract) do - sources = - address.smart_contract.smart_contract_additional_sources - |> Enum.map(fn additional_source -> {additional_source.file_name, additional_source.contract_source_code} end) - |> Enum.into(%{}) - |> Map.merge(%{ - get_contract_filename(address.smart_contract.file_path) => address.smart_contract.contract_source_code - }) - - params = %{ - sources: sources - } - - case Sol2uml.visualize_contracts(params) do - {:ok, svg} -> json(conn, %{"address" => address.hash, "contract_svg" => svg, "error" => nil}) - {:error, error} -> json(conn, %{"address" => address.hash, "contract_svg" => nil, "error" => error}) - end - else - _ -> json(conn, %{error: "contract not found or unverified"}) - end + with true <- Sol2uml.enabled?(), + {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + {:ok, address} <- Address.find_contract_address(address_hash, address_options), + # check that contract is verified. partial and bytecode twin verification is ok for this case + false <- is_nil(address.smart_contract) do + render_visualize_json(conn, address) else - not_found(conn) + false -> not_found(conn) + _ -> json(conn, %{error: "contract not found or unverified"}) end end @@ -51,7 +36,7 @@ defmodule BlockScoutWeb.VisualizeSol2umlController do with true <- Sol2uml.enabled?(), {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - {:ok, address} <- Chain.find_contract_address(address_hash, address_options) do + {:ok, address} <- Address.find_contract_address(address_hash, address_options) do render(conn, "index.html", address: address, get_svg_path: visualize_sol2uml_path(conn, :index, %{"type" => "JSON", "address" => address_hash_string}) @@ -65,6 +50,23 @@ defmodule BlockScoutWeb.VisualizeSol2umlController do not_found(conn) end + defp render_visualize_json(conn, address) do + sources = + address.smart_contract.smart_contract_additional_sources + |> Enum.map(fn additional_source -> {additional_source.file_name, additional_source.contract_source_code} end) + |> Enum.into(%{}) + |> Map.merge(%{ + get_contract_filename(address.smart_contract.file_path) => address.smart_contract.contract_source_code + }) + + params = %{sources: sources} + + case Sol2uml.visualize_contracts(params) do + {:ok, svg} -> json(conn, %{"address" => address.hash, "contract_svg" => svg, "error" => nil}) + {:error, error} -> json(conn, %{"address" => address.hash, "contract_svg" => nil, "error" => error}) + end + end + def get_contract_filename(nil), do: "main.sol" def get_contract_filename(filename), do: filename end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex index 4df6d86f8448..96a662f0f62f 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/withdrawal_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.WithdrawalController do use BlockScoutWeb, :controller @@ -6,7 +7,7 @@ defmodule BlockScoutWeb.WithdrawalController do alias BlockScoutWeb.{Controller, WithdrawalView} alias Explorer.Chain - alias Explorer.Chain.Wei + alias Explorer.Chain.{Wei, Withdrawal} alias Phoenix.View def index(conn, %{"type" => "JSON"} = params) do @@ -35,8 +36,8 @@ defmodule BlockScoutWeb.WithdrawalController do render(conn, "index.html", current_path: Controller.current_full_path(conn), page_number: params |> fetch_page_number() |> Integer.to_string(), - withdrawals_count: Chain.count_withdrawals_from_cache(), - withdrawals_sum: Chain.sum_withdrawals_from_cache() |> Wei.from(:wei) + withdrawals_count: Withdrawal.count_withdrawals_from_cache(), + withdrawals_sum: Withdrawal.sum_withdrawals_from_cache() |> Wei.from(:wei) ) end end diff --git a/apps/block_scout_web/lib/block_scout_web/counters/blocks_indexed_counter.ex b/apps/block_scout_web/lib/block_scout_web/counters/blocks_indexed_counter.ex index 19be491d1ed4..9da0f0113859 100644 --- a/apps/block_scout_web/lib/block_scout_web/counters/blocks_indexed_counter.ex +++ b/apps/block_scout_web/lib/block_scout_web/counters/blocks_indexed_counter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Counters.BlocksIndexedCounter do @moduledoc """ Module responsible for fetching and consolidating the number blocks indexed. diff --git a/apps/block_scout_web/lib/block_scout_web/counters/internal_transactions_indexed_counter.ex b/apps/block_scout_web/lib/block_scout_web/counters/internal_transactions_indexed_counter.ex index 54aaa5c029b9..0f562a88167b 100644 --- a/apps/block_scout_web/lib/block_scout_web/counters/internal_transactions_indexed_counter.ex +++ b/apps/block_scout_web/lib/block_scout_web/counters/internal_transactions_indexed_counter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Counters.InternalTransactionsIndexedCounter do @moduledoc """ Module responsible for fetching and consolidating the number pending block operations (internal transactions) indexed. diff --git a/apps/block_scout_web/lib/block_scout_web/csp_header.ex b/apps/block_scout_web/lib/block_scout_web/csp_header.ex index 10e01eec7c7a..062d317de28a 100644 --- a/apps/block_scout_web/lib/block_scout_web/csp_header.ex +++ b/apps/block_scout_web/lib/block_scout_web/csp_header.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.CSPHeader do @moduledoc """ Plug to set content-security-policy with websocket endpoints diff --git a/apps/explorer/lib/explorer/chain/csv_export/address/internal_transactions.ex b/apps/block_scout_web/lib/block_scout_web/csv_export/address/internal_transactions.ex similarity index 75% rename from apps/explorer/lib/explorer/chain/csv_export/address/internal_transactions.ex rename to apps/block_scout_web/lib/block_scout_web/csv_export/address/internal_transactions.ex index 74b09cad11b6..62af25e51215 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/address/internal_transactions.ex +++ b/apps/block_scout_web/lib/block_scout_web/csv_export/address/internal_transactions.ex @@ -1,14 +1,17 @@ -defmodule Explorer.Chain.CsvExport.Address.InternalTransactions do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.CsvExport.Address.InternalTransactions do @moduledoc """ Exports internal transactions to a csv file. """ - alias Explorer.Chain - alias Explorer.Chain.{Address, Hash, Transaction, Wei} - alias Explorer.Chain.CsvExport.Helper + import BlockScoutWeb.Chain, only: [address_to_internal_transactions: 2] - @spec export(Hash.Address.t(), String.t(), String.t(), String.t() | nil, String.t() | nil) :: Enumerable.t() - def export(address_hash, from_period, to_period, _options, filter_type \\ nil, filter_value \\ nil) do + alias Explorer.Chain.{Address, Hash, InternalTransaction, Transaction, Wei} + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} + + @spec export(Hash.Address.t(), String.t(), String.t(), Keyword.t(), String.t() | nil, String.t() | nil) :: + Enumerable.t() + def export(address_hash, from_period, to_period, _options, filter_type, filter_value) do {from_block, to_block} = Helper.block_from_period(from_period, to_period) address_hash @@ -32,15 +35,13 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactions do |> Keyword.put(:paging_options, paging_options) |> Keyword.put(:from_block, from_block) |> Keyword.put(:to_block, to_block) - |> Keyword.put(:necessity_by_association, %{ - :transaction => :optional - }) + |> Keyword.put(:timeout, AsyncHelper.db_timeout()) |> (&if(Helper.valid_filter?(filter_type, filter_value, "internal_transactions"), do: &1 |> Keyword.put(:direction, String.to_atom(filter_value)), else: &1 )).() - Chain.address_to_internal_transactions(address_hash, options) + address_to_internal_transactions(address_hash, options) end defp to_csv_format(internal_transactions) do @@ -48,9 +49,7 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactions do "TxHash", "Index", "BlockNumber", - "BlockHash", "TxIndex", - "BlockIndex", "UnixTimestamp", "FromAddress", "ToAddress", @@ -78,18 +77,16 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactions do to_string(internal_transaction.transaction_hash), internal_transaction.index, internal_transaction.block_number, - internal_transaction.block_hash, - internal_transaction.block_index, internal_transaction.transaction_index, - internal_transaction.block.timestamp, + internal_transaction.block && internal_transaction.block.timestamp, Address.checksum(internal_transaction.from_address_hash), Address.checksum(internal_transaction.to_address_hash), Address.checksum(internal_transaction.created_contract_address_hash), internal_transaction.type, - internal_transaction.call_type, - internal_transaction.gas, + InternalTransaction.call_type(internal_transaction), + internal_transaction.gas || "0", internal_transaction.gas_used, - Wei.to(internal_transaction.value, :wei), + Wei.to(internal_transaction.value || Wei.zero(), :wei), internal_transaction.input, internal_transaction.output, internal_transaction.error, diff --git a/apps/block_scout_web/lib/block_scout_web/endpoint.ex b/apps/block_scout_web/lib/block_scout_web/endpoint.ex index 2bf0b6c7afae..67428fcd5d5c 100644 --- a/apps/block_scout_web/lib/block_scout_web/endpoint.ex +++ b/apps/block_scout_web/lib/block_scout_web/endpoint.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Endpoint do use Phoenix.Endpoint, otp_app: :block_scout_web use Absinthe.Phoenix.Endpoint @@ -6,7 +7,36 @@ defmodule BlockScoutWeb.Endpoint do disable_api?: [:block_scout_web, :disable_api?], sql_sandbox: [:block_scout_web, :sql_sandbox], cookie_domain: [:block_scout_web, :cookie_domain], - session_cookie_ttl: [:block_scout_web, :session_cookie_ttl] + session_cookie_ttl: [:block_scout_web, :session_cookie_ttl], + api_v2_temp_token_header_key: [:block_scout_web, :api_v2_temp_token_header_key] + + alias Explorer.ThirdPartyIntegrations.UniversalProxy + + @cors_plug_base [ + headers: + [ + "x-apollo-tracing", + "updated-gas-oracle", + "recaptcha-v2-response", + "recaptcha-v3-response", + "recaptcha-bypass-token", + "scoped-recaptcha-bypass-token", + "show-scam-tokens", + @api_v2_temp_token_header_key + ] ++ CORSPlug.defaults()[:headers], + expose: [ + "bypass-429-option", + "x-ratelimit-reset", + "x-ratelimit-limit", + "x-ratelimit-remaining", + @api_v2_temp_token_header_key + ] + ] + + @cors_plug_options Keyword.merge( + @cors_plug_base, + if(Mix.env() == :dev, do: [origin: ["http://localhost:3000"]], else: []) + ) if @sql_sandbox do plug(Phoenix.Ecto.SQL.Sandbox, repo: Explorer.Repo) @@ -77,7 +107,8 @@ defmodule BlockScoutWeb.Endpoint do plug(BlockScoutWeb.Prometheus.PublicExporter) # 'x-apollo-tracing' header for https://www.graphqlbin.com to work with our GraphQL endpoint - plug(CORSPlug, headers: ["x-apollo-tracing" | CORSPlug.defaults()[:headers]]) + # 'updated-gas-oracle' header for /api/v2/stats endpoint, added to support cross-origin requests (e.g. multichain search explorer) + plug(CORSPlug, @cors_plug_options) plug(BlockScoutWeb.Router) end @@ -85,9 +116,32 @@ defmodule BlockScoutWeb.Endpoint do def init(_key, config) do if config[:load_from_system_env] do port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" - {:ok, Keyword.put(config, :http, [:inet6, port: port])} + {:ok, Keyword.put(config, :http, [:inet6, port: port, dispatch: dispatch()])} else - {:ok, config} + {:ok, + config + |> Keyword.put(:http, Keyword.put_new(Keyword.get(config, :http), :dispatch, dispatch()))} end end + + defp dispatch do + websocket_proxies = UniversalProxy.websocket_proxies() + + universal_proxy_routes = + websocket_proxies + |> Enum.map(fn {platform_id, url} -> + {"/api/v2/proxy/3rdparty/#{platform_id}", Explorer.ThirdPartyIntegrations.UniversalProxy.SocketHandler, + [url: url]} + end) + + all_routes = + [ + {:_, Phoenix.Endpoint.Cowboy2Handler, {BlockScoutWeb.Endpoint, []}} | universal_proxy_routes + ] + |> Enum.reverse() + + [ + {:_, all_routes} + ] + end end diff --git a/apps/block_scout_web/lib/block_scout_web/etherscan.ex b/apps/block_scout_web/lib/block_scout_web/etherscan.ex index 2df0f79bd252..3f570417c8b7 100644 --- a/apps/block_scout_web/lib/block_scout_web/etherscan.ex +++ b/apps/block_scout_web/lib/block_scout_web/etherscan.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Etherscan do @moduledoc """ Documentation data for Etherscan-compatible API. @@ -82,7 +83,8 @@ defmodule BlockScoutWeb.Etherscan do "contractAddress" => "", "cumulativeGasUsed" => "122207", "gasUsed" => "122207", - "confirmations" => "5994246" + "confirmations" => "5994246", + "methodId" => "0xf00d4b5d" } ] } @@ -665,6 +667,12 @@ defmodule BlockScoutWeb.Etherscan do example: ~s("6005998") } + @method_id_type %{ + type: "string", + definition: "Method signature used in transaction (0x for simple coin transfers)", + example: ~s("0xf00d4b5d") + } + @transaction_index_type %{ type: "transaction index", definition: "Index of the transaction in it's block.", @@ -786,7 +794,8 @@ defmodule BlockScoutWeb.Etherscan do contractAddress: @address_hash_type, cumulativeGasUsed: @gas_type, gasUsed: @gas_type, - confirmations: @confirmation_type + confirmations: @confirmation_type, + methodId: @method_id_type } } diff --git a/apps/block_scout_web/lib/block_scout_web/gettext.ex b/apps/block_scout_web/lib/block_scout_web/gettext.ex index bb3588f5f64e..8e6cff13d859 100644 --- a/apps/block_scout_web/lib/block_scout_web/gettext.ex +++ b/apps/block_scout_web/lib/block_scout_web/gettext.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex b/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex index fe12ebfb1ca1..1c52b8f75bf8 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/body_reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.BodyReader do @moduledoc """ This module is responsible for reading the body of a graphql request and counting the number of queries in the body. diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer.ex index 0eb2a5a32375..2cf0742128b8 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Celo.Resolvers.TokenTransfer do @moduledoc """ Resolvers for token transfers, used in the CELO schema. diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer_transaction.ex b/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer_transaction.ex index 4b39ec0c4bd2..217453d8f5d8 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer_transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/celo/resolvers/token_transfer_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Celo.Resolvers.TokenTransferTransaction do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/query_fields.ex b/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/query_fields.ex index 8e2d277accc7..9c5e7107fdc1 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/query_fields.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/query_fields.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Celo.QueryFields do @moduledoc """ Query fields for the CELO schema. diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex index 61419d03e998..c821bceea5cd 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/celo/schema/types.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Celo.Schema.Types do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/middleware/api_enabled.ex b/apps/block_scout_web/lib/block_scout_web/graphql/middleware/api_enabled.ex index dfe2bdb57564..1efe690a778f 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/middleware/api_enabled.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/middleware/api_enabled.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Middleware.ApiEnabled do @moduledoc """ Middleware to check if the GraphQL API is enabled. diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/address.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/address.ex index 1cbc324a1076..8020b3a233d3 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/address.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Resolvers.Address do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/block.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/block.ex index 1b03f48dfc88..b0addd19de36 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/block.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Resolvers.Block do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex index 14e0093eef47..be0e4ab25bc2 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/internal_transaction.ex @@ -1,23 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Resolvers.InternalTransaction do @moduledoc false + import Explorer.Chain, only: [hash_to_transaction: 1] + alias Absinthe.Relay.Connection - alias Explorer.Chain.Transaction - alias Explorer.{GraphQL, Repo} + alias BlockScoutWeb.Chain + alias Explorer.Chain.{InternalTransaction, Transaction} + alias Explorer.{GraphQL, PagingOptions} + alias Indexer.Fetcher.OnDemand.InternalTransaction, as: InternalTransactionOnDemand + + def get_by(%{transaction_hash: transaction_hash, index: index} = args, _) do + case hash_to_transaction(transaction_hash) do + {:ok, transaction} -> + if InternalTransaction.present_in_db?(transaction.block_number) do + GraphQL.get_internal_transaction(args) + else + options = [paging_options: %PagingOptions{page_size: index + 1}] + + transaction + |> InternalTransactionOnDemand.fetch_by_transaction(options) + |> Enum.find(&(&1.index == index)) + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + |> case do + nil -> {:error, "Internal transaction not found."} + internal_transaction -> {:ok, internal_transaction} + end + end - def get_by(%{transaction_hash: _, index: _} = args, _) do - GraphQL.get_internal_transaction(args) + _ -> + {:error, "Internal transaction not found."} + end end def get_by(%Transaction{} = transaction, args, _) do transaction - |> GraphQL.transaction_to_internal_transactions_query() - |> Connection.from_query(&Repo.all/1, args, options(args)) + |> Chain.transaction_to_internal_transactions(options(args)) + |> Connection.from_list(args, options(args)) end defp options(%{before: _}), do: [] - defp options(%{count: count}), do: [count: count] + defp options(%{count: count}), do: [paging_options: %PagingOptions{page_size: count}] defp options(_), do: [] end diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token.ex index 55092ec02e75..6b1a9a636ff9 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Resolvers.Token do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex index e61622260e6d..6e3a6ba3f88a 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/token_transfer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Resolvers.TokenTransfer do @moduledoc false @@ -12,17 +13,21 @@ defmodule BlockScoutWeb.GraphQL.Resolvers.TokenTransfer do def get_by(_, %{token_contract_address_hash: token_contract_address_hash} = args, _) do connection_args = Map.take(args, [:after, :before, :first, :last]) + replica = Repo.replica() + token_contract_address_hash |> GraphQL.list_token_transfers_query() - |> Connection.from_query(&Repo.replica().all/1, connection_args, options(args)) + |> Connection.from_query(&replica.all/1, connection_args, options(args)) end def get_by(%Address{hash: address_hash}, args, _) do connection_args = Map.take(args, [:after, :before, :first, :last]) + replica = Repo.replica() + address_hash |> TokenTransfer.token_transfers_by_address_hash(nil, nil, [], nil, []) - |> Connection.from_query(&Repo.replica().all/1, connection_args, options(args)) + |> Connection.from_query(&replica.all/1, connection_args, options(args)) end defp options(%{before: _}), do: [] diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/transaction.ex b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/transaction.ex index c1406eed85fc..b2c8631ee4fe 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/resolvers/transaction.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Resolvers.Transaction do @moduledoc false alias Absinthe.Relay.Connection - alias Explorer.{GraphQL, Repo} alias Explorer.Chain.{Address, TokenTransfer} + alias Explorer.{GraphQL, Repo} def get_by(_, %{hash: hash}, _), do: GraphQL.get_transaction_by_hash(hash) @@ -11,9 +12,11 @@ defmodule BlockScoutWeb.GraphQL.Resolvers.Transaction do def get_by(%Address{hash: address_hash}, args, _) do connection_args = Map.take(args, [:after, :before, :first, :last]) + replica = Repo.replica() + address_hash |> GraphQL.address_to_transactions_query(args.order) - |> Connection.from_query(&Repo.replica().all/1, connection_args, options(args)) + |> Connection.from_query(&replica.all/1, connection_args, options(args)) end def get_by(%TokenTransfer{transaction_hash: hash}, _, _), diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex index b579a9dbc58f..9db86a1e7e38 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema do @moduledoc false use Absinthe.Schema use Absinthe.Relay.Schema, :modern - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] alias Absinthe.Middleware.Dataloader, as: AbsintheDataloaderMiddleware alias Absinthe.Plugin, as: AbsinthePlugin @@ -25,7 +26,7 @@ defmodule BlockScoutWeb.GraphQL.Schema do import_types(BlockScoutWeb.GraphQL.Schema.Types) - if @chain_type == :celo do + if @chain_identity == {:optimism, :celo} do import_types(BlockScoutWeb.GraphQL.Celo.Schema.Types) end @@ -50,16 +51,16 @@ defmodule BlockScoutWeb.GraphQL.Schema do resolve(fn %{type: :internal_transaction, id: id}, resolution -> %{"transaction_hash" => transaction_hash_string, "index" => index} = Jason.decode!(id) - {:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string) + {:ok, transaction_hash} = Chain.string_to_full_hash(transaction_hash_string) InternalTransaction.get_by(%{transaction_hash: transaction_hash, index: index}, resolution) %{type: :token_transfer, id: id}, resolution -> %{"transaction_hash" => transaction_hash_string, "log_index" => log_index} = Jason.decode!(id) - {:ok, transaction_hash} = Chain.string_to_transaction_hash(transaction_hash_string) + {:ok, transaction_hash} = Chain.string_to_full_hash(transaction_hash_string) TokenTransfer.get_by(%{transaction_hash: transaction_hash, log_index: log_index}, resolution) %{type: :transaction, id: transaction_hash_string}, resolution -> - {:ok, hash} = Chain.string_to_transaction_hash(transaction_hash_string) + {:ok, hash} = Chain.string_to_full_hash(transaction_hash_string) Transaction.get_by(%{}, %{hash: hash}, resolution) _, _ -> @@ -108,7 +109,7 @@ defmodule BlockScoutWeb.GraphQL.Schema do resolve(&Transaction.get_by/3) end - if @chain_type == :celo do + if @chain_identity == {:optimism, :celo} do require BlockScoutWeb.GraphQL.Celo.QueryFields alias BlockScoutWeb.GraphQL.Celo.QueryFields diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars.ex index e74af83ba582..1522106a3b32 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Scalars do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex index 530e2a01baa0..cd9a0889d09a 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema/scalars/JSON.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Scalars.JSON do @moduledoc """ The JSON scalar type allows arbitrary JSON values to be passed in and out. diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex index bcb49f5826db..b23da37c5107 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema/types.ex @@ -1,11 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Transaction do @moduledoc false - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] alias BlockScoutWeb.GraphQL.Resolvers.{Block, InternalTransaction} - case @chain_type do - :celo -> + case @chain_identity do + {:optimism, :celo} -> @chain_type_fields quote( do: [ field(:gas_token_contract_address_hash, :address_hash) @@ -107,10 +108,11 @@ end defmodule BlockScoutWeb.GraphQL.Schema.Types do @moduledoc false - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] require BlockScoutWeb.GraphQL.Schema.{Transaction, SmartContracts} + alias Ecto.Enum + use Absinthe.Schema.Notation use Absinthe.Relay.Schema.Notation, :modern @@ -124,23 +126,9 @@ defmodule BlockScoutWeb.GraphQL.Schema.Types do alias BlockScoutWeb.GraphQL.Schema.SmartContracts, as: SmartContractsSchema alias BlockScoutWeb.GraphQL.Schema.Transaction, as: TransactionSchema + alias Explorer.Chain.SmartContract - # TODO: leverage `Ecto.Enum.values(SmartContract, :language)` to deduplicate - # language definitions - @default_languages ~w(solidity vyper yul)a - - case @chain_type do - :arbitrum -> - @chain_type_languages ~w(stylus_rust)a - - :zilliqa -> - @chain_type_languages ~w(scilla)a - - _ -> - @chain_type_languages ~w()a - end - - enum(:language, values: @default_languages ++ @chain_type_languages) + enum(:language, values: Enum.values(SmartContract, :language)) import_types(Absinthe.Type.Custom) import_types(BlockScoutWeb.GraphQL.Schema.Scalars) @@ -226,8 +214,6 @@ defmodule BlockScoutWeb.GraphQL.Schema.Types do field(:transaction_hash, :full_hash) field(:block_number, :integer) field(:transaction_index, :integer) - field(:block_hash, :full_hash) - field(:block_index, :integer) end @desc """ diff --git a/apps/block_scout_web/lib/block_scout_web/graphql/schema_controller.ex b/apps/block_scout_web/lib/block_scout_web/graphql/schema_controller.ex index 02701360818b..1dfd3da8b9dc 100644 --- a/apps/block_scout_web/lib/block_scout_web/graphql/schema_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/graphql/schema_controller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.SchemaController do @moduledoc """ Controller for serving the GraphQL schema in SDL format. diff --git a/apps/block_scout_web/lib/block_scout_web/health_endpoint.ex b/apps/block_scout_web/lib/block_scout_web/health_endpoint.ex index d821ad689c85..216af7cd480c 100644 --- a/apps/block_scout_web/lib/block_scout_web/health_endpoint.ex +++ b/apps/block_scout_web/lib/block_scout_web/health_endpoint.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.HealthEndpoint do @moduledoc """ Health endpoint for health checks in case of indexer-only/standalone_media_worker setup diff --git a/apps/block_scout_web/lib/block_scout_web/health_router.ex b/apps/block_scout_web/lib/block_scout_web/health_router.ex index 9450539ca43c..a52b8f4c09b8 100644 --- a/apps/block_scout_web/lib/block_scout_web/health_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/health_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.HealthRouter do @moduledoc """ Router for health checks in case of indexer-only setup diff --git a/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex b/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex index f71cbaf14709..8e31ee31932a 100644 --- a/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex +++ b/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex @@ -1,16 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do @moduledoc """ Module to interact with Transaction Interpretation Service """ + import BlockScoutWeb.Chain, only: [transaction_to_internal_transactions: 2] + alias BlockScoutWeb.API.V2.{Helper, InternalTransactionView, TokenTransferView, TokenView, TransactionView} alias Ecto.Association.NotLoaded - alias Explorer.Chain - alias Explorer.Helper, as: ExplorerHelper + alias Explorer.{Chain, HttpClient} alias Explorer.Chain.{Data, InternalTransaction, Log, TokenTransfer, Transaction} - alias HTTPoison.Response + alias Explorer.Helper, as: ExplorerHelper + + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] + + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, + only: [proxy_implementations_association: 0, proxy_implementations_smart_contracts_association: 0] - import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.Utility.Microservice, only: [base_url: 2, check_enabled: 2] require Logger @@ -19,14 +25,19 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do @request_error_msg "Error while sending request to Transaction Interpretation Service" @api_true api?: true @items_limit 50 - @internal_transaction_necessity_by_association [ + @token_options [ + api?: true, necessity_by_association: %{ - [created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => - :optional, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional + reputation_association() => :optional } ] + @internal_transaction_address_preloads [ + address_preloads: [ + created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] + ] @doc """ Interpret transaction or user operation @@ -83,8 +94,8 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HTTPoison.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do + {:ok, %{body: body, status_code: 200}} -> body |> Jason.decode() |> preload_template_variables() error -> @@ -104,7 +115,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do end defp try_get_cached_value(hash) do - with {:ok, %Response{body: body, status_code: 200}} <- HTTPoison.get(cache_url(hash)), + with {:ok, %{body: body, status_code: 200}} <- HttpClient.get(cache_url(hash)), {:ok, json} <- body |> Jason.decode() do {:ok, json} |> preload_template_variables() else @@ -113,7 +124,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do end end - defp http_response_code({:ok, %Response{status_code: status_code}}), do: status_code + defp http_response_code({:ok, %{status_code: status_code}}), do: status_code defp http_response_code(_), do: 500 def enabled?, do: check_enabled(:block_scout_web, __MODULE__) == :ok @@ -198,7 +209,8 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do [ necessity_by_association: %{ [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional + [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [token: reputation_association()] => :optional } ] |> Keyword.merge(@api_true) @@ -215,12 +227,10 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do end defp fetch_internal_transactions(transaction) do - full_options = - @internal_transaction_necessity_by_association - |> Keyword.merge(@api_true) + full_options = Keyword.merge(@internal_transaction_address_preloads, @api_true) - transaction.hash - |> InternalTransaction.transaction_to_internal_transactions(full_options) + transaction + |> transaction_to_internal_transactions(full_options) |> Enum.take(@items_limit) end @@ -233,7 +243,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do full_options = [ necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_association()]] => :optional + [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional } ] |> Keyword.merge(@api_true) @@ -255,7 +265,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do log_options = [ necessity_by_association: %{ - [address: [:names, :smart_contract, proxy_implementations_association()]] => :optional + [address: [:names, :smart_contract, proxy_implementations_smart_contracts_association()]] => :optional }, limit: @items_limit ] @@ -277,7 +287,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do necessity_by_association: %{ [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - :token => :optional + [token: reputation_association()] => :optional } ] |> Keyword.merge(@api_true) @@ -308,10 +318,19 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do defp preload_template_variables(error), do: error + # TODO: remove this once we have updated the transaction interpretation service defp preload_template_variable(%{"type" => "token", "value" => %{"address" => address_hash_string} = value}), do: %{ "type" => "token", - "value" => address_hash_string |> Chain.token_from_address_hash(@api_true) |> token_from_db() |> Map.merge(value) + "value" => + address_hash_string |> Chain.token_from_address_hash(@token_options) |> token_from_db() |> Map.merge(value) + } + + defp preload_template_variable(%{"type" => "token", "value" => %{"address_hash" => address_hash_string} = value}), + do: %{ + "type" => "token", + "value" => + address_hash_string |> Chain.token_from_address_hash(@token_options) |> token_from_db() |> Map.merge(value) } defp preload_template_variable(%{"type" => "address", "value" => %{"hash" => address_hash_string} = value}), @@ -321,6 +340,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do address_hash_string |> Chain.hash_to_address( necessity_by_association: %{ + :scam_badge => :optional, :names => :optional, :smart_contract => :optional, proxy_implementations_association() => :optional @@ -373,12 +393,12 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do type: 0, value: "0", method: Transaction.method_name(mock_transaction, Transaction.format_decoded_input(decoded_input), true), - status: user_op["status"], - actions: [], + status: (user_op["status"] && :ok) || :error, transaction_types: [], raw_input: user_op_call_data, decoded_input: decoded_input_json, - token_transfers: prepared_token_transfers + token_transfers: prepared_token_transfers, + internal_transactions: [] }, logs_data: %{items: prepared_logs}, chain_id: :block_scout_web |> Application.get_env(:chain_id) |> ExplorerHelper.parse_integer() @@ -394,7 +414,7 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do def decode_user_op_calldata(user_op_hash, call_data) do {:ok, input} = Data.cast(call_data) - {:ok, op_hash} = Chain.string_to_transaction_hash(user_op_hash) + {:ok, op_hash} = Chain.string_to_full_hash(user_op_hash) mock_transaction = %Transaction{ to_address: %NotLoaded{}, diff --git a/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex b/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex index 468526130e38..e8dde140d33e 100644 --- a/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex +++ b/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Models.GetAddressTags do @moduledoc """ Get various types of tags associated with the address diff --git a/apps/block_scout_web/lib/block_scout_web/models/get_transaction_tags.ex b/apps/block_scout_web/lib/block_scout_web/models/get_transaction_tags.ex index 1aeed2d04391..d318188751db 100644 --- a/apps/block_scout_web/lib/block_scout_web/models/get_transaction_tags.ex +++ b/apps/block_scout_web/lib/block_scout_web/models/get_transaction_tags.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Models.GetTransactionTags do @moduledoc """ Get various types of tags associated with the transaction diff --git a/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex b/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex index 6987912cbf6d..137c7707534b 100644 --- a/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex @@ -1,16 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Models.TransactionStateHelper do @moduledoc """ Transaction state changes related functions """ + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] import Explorer.PagingOptions, only: [default_paging_options: 0] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] - alias Explorer.Chain.Transaction.StateChange alias Explorer.{Chain, PagingOptions, Repo} - alias Explorer.Chain.{BlockNumberHelper, InternalTransaction, Transaction, Wei} + + alias Explorer.Chain.{ + Address.CoinBalance, + BlockNumberHelper, + DenormalizationHelper, + InternalTransaction, + Transaction, + Wei + } + alias Explorer.Chain.Cache.StateChanges + alias Explorer.Chain.Transaction.StateChange alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand alias Indexer.Fetcher.OnDemand.TokenBalance, as: TokenBalanceOnDemand @@ -63,17 +74,15 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do api?: Keyword.get(options, :api?, false) ) |> Enum.filter(&(&1.index <= transaction.index)) - |> Repo.preload([:token_transfers, :internal_transactions]) + |> Repo.preload([:token_transfers]) + |> Transaction.preload_internal_transactions() transaction = block_transactions |> Enum.find(&(&1.hash == transaction.hash)) |> Repo.preload( token_transfers: [ - from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], - to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] - ], - internal_transactions: [ + token: reputation_association(), from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] ], @@ -81,6 +90,10 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] ) + |> Transaction.preload_internal_transactions( + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ) previous_block_number = BlockNumberHelper.previous_block_number(transaction.block_number) @@ -116,10 +129,17 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do ) end - defp internal_transaction_to_coin_balances(%InternalTransaction{call_type: :delegatecall}, _, _, acc), do: acc + defp internal_transaction_to_coin_balances( + %InternalTransaction{call_type: call_type, call_type_enum: call_type_enum}, + _, + _, + acc + ) + when :delegatecall in [call_type, call_type_enum], + do: acc defp internal_transaction_to_coin_balances(internal_transaction, previous_block_number, options, acc) do - if internal_transaction.value |> Wei.to(:wei) |> Decimal.positive?() do + if not is_nil(internal_transaction.value) and Decimal.positive?(Wei.to(internal_transaction.value, :wei)) do acc |> Map.put_new_lazy(internal_transaction.from_address_hash, fn -> {internal_transaction.from_address, @@ -135,17 +155,17 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do end defp coin_balance(address_hash, _block_number, _options) when is_nil(address_hash) do - %Wei{value: Decimal.new(0)} + Wei.zero() end defp coin_balance(address_hash, block_number, options) do - case Chain.get_coin_balance(address_hash, block_number, options) do + case CoinBalance.get_coin_balance(address_hash, block_number, options) do %{value: val} when not is_nil(val) -> val _ -> CoinBalanceOnDemand.trigger_historic_fetch(options[:ip], address_hash, block_number) - %Wei{value: Decimal.new(0)} + Wei.zero() end end @@ -186,36 +206,48 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do end defp token_transfers_to_balances_reducer(transfer, balances, prev_block, options) do - from = transfer.from_address - to = transfer.to_address - token_hash = transfer.token_contract_address_hash - - balances - |> case do - # from address already in the map - %{^from => %{^token_hash => _}} = balances -> - balances - - # we need to add from address into the map - balances -> - put_in( - balances, - Enum.map([from, token_hash], &Access.key(&1, %{})), - token_balances(from.hash, transfer, prev_block, options) - ) - end - |> case do - # to address already in the map - %{^to => %{^token_hash => _}} = balances -> - balances - - # we need to add to address into the map - balances -> - put_in( - balances, - Enum.map([to, token_hash], &Access.key(&1, %{})), - token_balances(to.hash, transfer, prev_block, options) - ) + token_type = + if DenormalizationHelper.tt_denormalization_finished?() do + transfer.token_type + else + transfer.token && transfer.token.type + end + + # Skip ERC-7984 (confidential) transfers - we can't track encrypted balances + if token_type == "ERC-7984" do + balances + else + from = transfer.from_address + to = transfer.to_address + token_hash = transfer.token_contract_address_hash + + balances + |> case do + # from address already in the map + %{^from => %{^token_hash => _}} = balances -> + balances + + # we need to add from address into the map + balances -> + put_in( + balances, + Enum.map([from, token_hash], &Access.key(&1, %{})), + token_balances(from.hash, transfer, prev_block, options) + ) + end + |> case do + # to address already in the map + %{^to => %{^token_hash => _}} = balances -> + balances + + # we need to add to address into the map + balances -> + put_in( + balances, + Enum.map([to, token_hash], &Access.key(&1, %{})), + token_balances(to.hash, transfer, prev_block, options) + ) + end end end end diff --git a/apps/block_scout_web/lib/block_scout_web/notifier.ex b/apps/block_scout_web/lib/block_scout_web/notifier.ex index 04e91a226980..82b1294e07fe 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifier.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifier.ex @@ -1,8 +1,21 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Notifier do @moduledoc """ Responds to events by sending appropriate channel updates to front-end. """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + use Utils.RuntimeEnvHelper, + block_broadcast_enrichment_disabled?: [ + :block_scout_web, + [BlockScoutWeb.Notifier, :block_broadcast_enrichment_disabled] + ], + block_broadcast_enrichment_timeout: [ + :block_scout_web, + [BlockScoutWeb.Notifier, :block_broadcast_enrichment_timeout] + ] require Logger @@ -13,7 +26,6 @@ defmodule BlockScoutWeb.Notifier do alias BlockScoutWeb.API.V2.{ AddressView, BlockView, - PolygonZkevmView, SmartContractView, TransactionView } @@ -31,21 +43,26 @@ defmodule BlockScoutWeb.Notifier do alias Explorer.Chain.{ Address, + Address.CoinBalance, + Address.Reputation, BlockNumberHelper, DenormalizationHelper, InternalTransaction, + Token, Token.Instance, - Transaction, - Wei + Transaction } alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime, Helper} alias Explorer.Chain.Supply.RSK alias Explorer.Chain.Transaction.History.TransactionStats + alias Explorer.MicroserviceInterfaces.{BENS, Metadata} alias Explorer.SmartContract.{CompilerVersion, Solidity.CodeCompiler} alias Phoenix.View alias Timex.Duration + import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens_to_block: 1] + import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata_to_block: 1] import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] @check_broadcast_sequence_period 500 @@ -62,10 +79,10 @@ defmodule BlockScoutWeb.Notifier do nil end - case @chain_type do - :celo -> + case @chain_identity do + {:optimism, :celo} -> @chain_type_transaction_associations [ - :gas_token + gas_token: Reputation.reputation_association() ] _ -> @@ -96,17 +113,23 @@ defmodule BlockScoutWeb.Notifier do addresses |> Stream.reject(fn %Address{fetched_coin_balance: fetched_coin_balance} -> is_nil(fetched_coin_balance) end) + |> Stream.filter(fn %Address{hash: hash} -> address_has_subscribers?(hash) end) |> Enum.each(&broadcast_balance/1) end def handle_event({:chain_event, :address_coin_balances, type, address_coin_balances}) when type in [:realtime, :on_demand] do - Enum.each(address_coin_balances, &broadcast_address_coin_balance/1) + address_coin_balances + |> Enum.reject(fn balance -> is_nil(balance[:value]) end) + |> Enum.filter(fn balance -> address_has_subscribers?(balance[:address_hash]) end) + |> Enum.each(&broadcast_address_coin_balance/1) end def handle_event({:chain_event, :address_token_balances, type, address_token_balances}) when type in [:realtime, :on_demand] do - Enum.each(address_token_balances, &broadcast_address_token_balance/1) + address_token_balances + |> Enum.filter(fn balance -> address_has_subscribers?(balance[:address_hash]) end) + |> Enum.each(&broadcast_address_token_balance/1) end def handle_event( @@ -184,18 +207,6 @@ defmodule BlockScoutWeb.Notifier do end) end - def handle_event({:chain_event, :zkevm_confirmed_batches, :realtime, batches}) do - batches - |> Enum.sort_by(& &1.number, :asc) - |> Enum.each(fn confirmed_batch -> - rendered_batch = PolygonZkevmView.render("zkevm_batch.json", %{batch: confirmed_batch, socket: nil}) - - Endpoint.broadcast("zkevm_batches:new_zkevm_confirmed_batch", "new_zkevm_confirmed_batch", %{ - batch: rendered_batch - }) - end) - end - def handle_event({:chain_event, :exchange_rate}) do exchange_rate = Market.get_coin_exchange_rate() @@ -230,9 +241,13 @@ defmodule BlockScoutWeb.Notifier do end def handle_event( - {:chain_event, :internal_transactions, :on_demand, - [%InternalTransaction{index: 0, transaction_hash: transaction_hash}]} + {:chain_event, :internal_transactions, :on_demand, [%InternalTransaction{index: 0} = internal_transaction]} ) do + transaction_hash = + internal_transaction + |> InternalTransaction.preload_transaction() + |> Map.get(:transaction_hash) + # TODO: delete duplicated event when old UI becomes deprecated Endpoint.broadcast("transactions_old:#{transaction_hash}", "raw_trace", %{raw_trace_origin: transaction_hash}) @@ -248,50 +263,66 @@ defmodule BlockScoutWeb.Notifier do # internal transactions broadcast disabled on the indexer level, therefore it out of scope of the refactoring within https://github.com/blockscout/blockscout/pull/7474 def handle_event({:chain_event, :internal_transactions, :realtime, internal_transactions}) do internal_transactions + |> Stream.filter(fn it -> + address_has_subscribers?(it.from_address_hash) or + address_has_subscribers?(it.to_address_hash) + end) |> Stream.map( - &(InternalTransaction.where_nonpending_block() - |> Repo.get_by(transaction_hash: &1.transaction_hash, index: &1.index) - |> Repo.preload([:from_address, :to_address, :block])) + &(InternalTransaction.where_nonpending_operation() + |> Repo.get_by(block_number: &1.block_number, transaction_index: &1.transaction_index, index: &1.index) + |> Repo.preload([:block]) + |> InternalTransaction.preload_addresses() + |> InternalTransaction.preload_transaction()) ) |> Enum.each(&broadcast_internal_transaction/1) end def handle_event({:chain_event, :token_transfers, :realtime, all_token_transfers}) do - all_token_transfers_full = - all_token_transfers - |> Repo.preload( - DenormalizationHelper.extend_transaction_preload([ - :token, - :transaction, - from_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ], - to_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ] - ]) - ) - |> Instance.preload_nft(@api_true) - - transfers_by_token = Enum.group_by(all_token_transfers_full, fn tt -> to_string(tt.token_contract_address_hash) end) + all_transfers_by_token = + Enum.group_by(all_token_transfers, fn tt -> to_string(tt.token_contract_address_hash) end) - broadcast_token_transfers_websocket_v2(all_token_transfers_full, transfers_by_token) - - for {token_contract_address_hash, token_transfers} <- transfers_by_token do + for {token_contract_address_hash, token_transfers} <- all_transfers_by_token do Subscription.publish( Endpoint, token_transfers, token_transfers: token_contract_address_hash ) + end - token_transfers - |> Enum.each(&broadcast_token_transfer/1) + relevant_transfers = Enum.filter(all_token_transfers, &token_transfer_has_subscribers?/1) + + if relevant_transfers != [] do + all_token_transfers_full = + relevant_transfers + |> Repo.preload( + DenormalizationHelper.extend_transaction_preload([ + [token: Reputation.reputation_association()], + :transaction, + from_address: [ + :scam_badge, + :names, + :smart_contract, + proxy_implementations_association() + ], + to_address: [ + :scam_badge, + :names, + :smart_contract, + proxy_implementations_association() + ] + ]) + ) + |> Instance.preload_nft(@api_true) + + transfers_by_token = + Enum.group_by(all_token_transfers_full, fn tt -> to_string(tt.token_contract_address_hash) end) + + broadcast_token_transfers_websocket_v2(all_token_transfers_full, transfers_by_token) + + for {_token_contract_address_hash, token_transfers} <- transfers_by_token do + token_transfers + |> Enum.each(&broadcast_token_transfer/1) + end end end @@ -303,7 +334,10 @@ defmodule BlockScoutWeb.Notifier do to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] ] - preloads = if API_V2.enabled?(), do: [:token_transfers | base_preloads], else: base_preloads + preloads = + if API_V2.enabled?(), + do: [{:token_transfers, [token: Reputation.reputation_association()]} | base_preloads], + else: base_preloads transactions |> Repo.preload(preloads) @@ -331,7 +365,7 @@ defmodule BlockScoutWeb.Notifier do def handle_event( {:chain_event, :token_total_supply, :on_demand, - [%Explorer.Chain.Token{contract_address_hash: contract_address_hash, total_supply: total_supply} = token]} + [%Token{contract_address_hash: contract_address_hash, total_supply: total_supply} = token]} ) when not is_nil(total_supply) do # TODO: delete duplicated event when old UI becomes deprecated @@ -360,7 +394,18 @@ defmodule BlockScoutWeb.Notifier do Endpoint.broadcast( "token_instances:#{token_contract_address_hash_string}", "fetched_token_instance_metadata", - %{token_id: token_id, fetched_metadata: fetched_token_instance_metadata} + %{token_id: to_string(token_id), fetched_metadata: fetched_token_instance_metadata} + ) + end + + def handle_event( + {:chain_event, :not_fetched_token_instance_metadata, :on_demand, + [token_contract_address_hash_string, token_id, reason]} + ) do + Endpoint.broadcast( + "token_instances:#{token_contract_address_hash_string}", + "not_fetched_token_instance_metadata", + %{token_id: to_string(token_id), reason: reason} ) end @@ -383,12 +428,24 @@ defmodule BlockScoutWeb.Notifier do end @current_token_balances_limit 50 - def handle_event({:chain_event, :address_current_token_balances, :on_demand, address_current_token_balances}) do - address_current_token_balances.address_current_token_balances - |> Enum.group_by(& &1.token_type) - |> Enum.each(fn {token_type, balances} -> - broadcast_token_balances(address_current_token_balances.address_hash, token_type, balances) - end) + def handle_event( + {:chain_event, :address_current_token_balances, type, + %{address_current_token_balances: address_current_token_balances, address_hash: address_hash}} + ) + when type in [:realtime, :on_demand] do + if address_has_subscribers?(address_hash) do + address_current_token_balances + |> Repo.preload(token: Reputation.reputation_association()) + |> Enum.group_by(& &1.token_type) + |> Enum.each(fn {token_type, balances} -> + broadcast_token_balances(address_hash, token_type, balances) + end) + end + end + + def handle_event({:chain_event, :address_current_token_balances, :realtime, _empty_balances_params}) do + # Don't broadcast empty balances params from realtime block fetcher + :ok end case @chain_type do @@ -444,17 +501,20 @@ defmodule BlockScoutWeb.Notifier do %{view: view, compiler: compiler} end + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp broadcast_token_balances(address_hash, token_type, balances) do sorted = Enum.sort_by( balances, fn ctb -> - value = - if ctb.token.decimals, - do: Decimal.div(ctb.value, Decimal.new(Integer.pow(10, Decimal.to_integer(ctb.token.decimals)))), - else: ctb.value + case ctb.token do + %Token{decimals: decimals, fiat_value: fiat_value} when not is_nil(decimals) -> + value = Decimal.div(ctb.value, Decimal.new(Integer.pow(10, Decimal.to_integer(decimals)))) + {(fiat_value && Decimal.mult(value, fiat_value)) || Decimal.new(0), value} - {(ctb.token.fiat_value && Decimal.mult(value, ctb.token.fiat_value)) || Decimal.new(0), value} + _ -> + {Decimal.new(0), ctb.value} + end end, fn {fiat_value_1, value_1}, {fiat_value_2, value_2} -> case {Decimal.compare(fiat_value_1, fiat_value_2), Decimal.compare(value_1, value_2)} do @@ -539,15 +599,17 @@ defmodule BlockScoutWeb.Notifier do end defp broadcast_address_coin_balance(%{address_hash: address_hash, block_number: block_number}) do - coin_balance = Chain.get_coin_balance(address_hash, block_number) + coin_balance = CoinBalance.get_coin_balance(address_hash, block_number, @api_true) - # TODO: delete duplicated event when old UI becomes deprecated - Endpoint.broadcast("addresses_old:#{address_hash}", "coin_balance", %{ - block_number: block_number, - coin_balance: coin_balance - }) + if coin_balance && coin_balance.delta && !Decimal.eq?(coin_balance.delta, Decimal.new(0)) do + # TODO: delete duplicated event when old UI becomes deprecated + Endpoint.broadcast("addresses_old:#{address_hash}", "coin_balance", %{ + block_number: block_number, + coin_balance: coin_balance + }) + end - if coin_balance.value && coin_balance.delta do + if coin_balance && coin_balance.value && coin_balance.delta && !Decimal.eq?(coin_balance.delta, Decimal.new(0)) do rendered_coin_balance = AddressView.render("coin_balance.json", %{coin_balance: coin_balance}) Endpoint.broadcast("addresses:#{address_hash}", "coin_balance", %{ @@ -555,7 +617,7 @@ defmodule BlockScoutWeb.Notifier do }) Endpoint.broadcast("addresses:#{address_hash}", "current_coin_balance", %{ - coin_balance: coin_balance.value || %Wei{value: Decimal.new(0)}, + coin_balance: coin_balance.value, exchange_rate: Market.get_coin_exchange_rate().fiat_value, block_number: block_number }) @@ -597,11 +659,15 @@ defmodule BlockScoutWeb.Notifier do defp broadcast_block(block) do preloaded_block = - Repo.preload(block, [ + block + |> Repo.preload([ [miner: [:names, :smart_contract, proxy_implementations_association()]], :transactions, :rewards ]) + # TODO: theoretically might introduce performance issues, + # consider async broadcast of enrichment data + |> maybe_preload_enrichment_for_broadcast() average_block_time = AverageBlockTime.average_block_time() @@ -629,6 +695,48 @@ defmodule BlockScoutWeb.Notifier do Endpoint.broadcast("blocks:#{to_string(block.miner_hash)}", "new_block", block_params_v2) end + defp maybe_preload_enrichment_for_broadcast(block) do + if !block_broadcast_enrichment_disabled?() and (BENS.enabled?() or Metadata.enabled?()) do + preload_enrichment_for_broadcast(block) + else + block + end + end + + defp preload_enrichment_for_broadcast(block) do + timeout = block_broadcast_enrichment_timeout() + + results = + Task.Supervisor.async_stream_nolink( + Explorer.TaskSupervisor, + [ + {:ens_domain_name, &maybe_preload_ens_to_block/1}, + {:metadata, &maybe_preload_metadata_to_block/1} + ], + fn {field, preload_fun} -> + {field, preload_fun.(block)} + end, + timeout: timeout, + on_timeout: :kill_task, + ordered: false + ) + + Enum.reduce(results, block, fn + {:ok, {field, enriched_block}}, acc -> merge_enriched_miner_field(acc, enriched_block, field) + _, acc -> acc + end) + end + + defp merge_enriched_miner_field(%{miner: %{} = miner} = block, %{miner: %{} = enriched_miner}, field) do + case Map.fetch(enriched_miner, field) do + {:ok, nil} -> block + {:ok, value} -> %{block | miner: Map.replace(miner, field, value)} + :error -> block + end + end + + defp merge_enriched_miner_field(block, _enriched_block, _field), do: block + defp broadcast_rewards(rewards) do preloaded_rewards = Repo.preload(rewards, [:address, :block]) emission_reward = Enum.find(preloaded_rewards, fn reward -> reward.address_type == :emission_funds end) @@ -692,14 +800,17 @@ defmodule BlockScoutWeb.Notifier do }) end - v2_params_function = fn transactions -> + relevant_transactions = Enum.filter(transactions, &transaction_has_subscribers?/1) + + prepared_transactions = TransactionView.render("transactions.json", %{ - transactions: Repo.preload(transactions, @transaction_associations), + transactions: Repo.preload(relevant_transactions, @transaction_associations), conn: nil }) - end - group_by_address_hashes_and_broadcast(transactions, event, :transactions, v2_params_function) + relevant_transactions + |> Enum.zip(prepared_transactions) + |> group_by_address_hashes_and_broadcast(event, :transactions, & &1["hash"]) end defp broadcast_transaction(%Transaction{block_number: nil} = pending) do @@ -737,14 +848,19 @@ defmodule BlockScoutWeb.Notifier do }) end - v2_params_function = fn transfers -> + prepared_token_transfers = TransactionView.render("token_transfers.json", %{ - token_transfers: transfers, + token_transfers: tokens_transfers, conn: nil }) - end - group_by_address_hashes_and_broadcast(tokens_transfers, "token_transfer", :token_transfers, v2_params_function) + tokens_transfers + |> Enum.zip(prepared_token_transfers) + |> group_by_address_hashes_and_broadcast( + "token_transfer", + :token_transfers, + &{&1["transaction_hash"], &1["block_hash"], &1["log_index"]} + ) end defp broadcast_token_transfer(token_transfer) do @@ -770,19 +886,19 @@ defmodule BlockScoutWeb.Notifier do end end - defp group_by_address_hashes_and_broadcast(elements, event, map_key, params_function) do + defp group_by_address_hashes_and_broadcast(elements, event, map_key, uniq_function) do grouped_by_from = elements - |> Enum.group_by(fn el -> el.from_address_hash end) + |> Enum.group_by(fn {el, _} -> el.from_address_hash end, fn {_, prepared_el} -> prepared_el end) grouped_by_to = elements - |> Enum.group_by(fn el -> el.to_address_hash end) + |> Enum.group_by(fn {el, _} -> el.to_address_hash end, fn {_, prepared_el} -> prepared_el end) - grouped = Map.merge(grouped_by_to, grouped_by_from, fn _k, v1, v2 -> Enum.uniq(v1 ++ v2) end) + grouped = Map.merge(grouped_by_to, grouped_by_from, fn _k, v1, v2 -> Enum.uniq_by(v1 ++ v2, uniq_function) end) for {address_hash, elements} <- grouped do - Endpoint.broadcast("addresses:#{address_hash}", event, %{map_key => params_function.(elements)}) + Endpoint.broadcast("addresses:#{address_hash}", event, %{map_key => elements}) end end @@ -800,4 +916,38 @@ defmodule BlockScoutWeb.Notifier do Endpoint.broadcast("addresses_old:#{to_string(address_hash)}", to_string(event), %{}) Endpoint.broadcast("addresses:#{to_string(address_hash)}", to_string(event), %{}) end + + defp has_subscribers?(topic) when is_binary(topic) do + Registry.lookup(BlockScoutWeb.PubSub, topic) != [] + end + + defp address_has_subscribers?(nil), do: false + + defp address_has_subscribers?(address_hash) do + hash_string = to_string(address_hash) + + has_subscribers?("addresses:" <> hash_string) or + has_subscribers?("addresses_old:" <> hash_string) + end + + defp token_transfer_has_subscribers?(tt) do + address_has_subscribers?(tt.from_address_hash) or + address_has_subscribers?(tt.to_address_hash) or + token_has_subscribers?(tt.token_contract_address_hash) + end + + defp token_has_subscribers?(nil), do: false + + defp token_has_subscribers?(token_address_hash) do + hash_string = to_string(token_address_hash) + + has_subscribers?("tokens:" <> hash_string) or + has_subscribers?("tokens_old:" <> hash_string) + end + + defp transaction_has_subscribers?(transaction) do + address_has_subscribers?(transaction.from_address_hash) or + address_has_subscribers?(transaction.to_address_hash) or + address_has_subscribers?(transaction.created_contract_address_hash) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex b/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex index 2b7589dc0f3b..b6bf0149aa1d 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifiers/arbitrum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Notifiers.Arbitrum do @moduledoc """ Module to handle and broadcast Arbitrum related events. diff --git a/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex b/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex index 7d9151c9eae7..0e9648bff511 100644 --- a/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex +++ b/apps/block_scout_web/lib/block_scout_web/notifiers/optimism.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Notifiers.Optimism do @moduledoc """ Module to handle and broadcast OP related events. @@ -9,7 +10,7 @@ defmodule BlockScoutWeb.Notifiers.Optimism do def handle_event({:chain_event, :new_optimism_batches, :realtime, batches}) do batches - |> Enum.sort_by(& &1.internal_id, :asc) + |> Enum.sort_by(& &1.number, :asc) |> Enum.each(fn batch -> Endpoint.broadcast("optimism:new_batch", "new_optimism_batch", %{ batch: batch @@ -24,12 +25,6 @@ defmodule BlockScoutWeb.Notifiers.Optimism do Endpoint.broadcast("optimism:new_deposits", "new_optimism_deposits", %{ deposits: deposits_count }) - - # todo: the `optimism_deposits:new_deposits` socket topic is for backward compatibility - # for the frontend and should be removed after the frontend starts to use the `optimism:new_deposits` - Endpoint.broadcast("optimism_deposits:new_deposits", "deposits", %{ - deposits: deposits_count - }) end end diff --git a/apps/block_scout_web/lib/block_scout_web/paging_helper.ex b/apps/block_scout_web/lib/block_scout_web/paging_helper.ex index 98e539817567..84d39f99f6ec 100644 --- a/apps/block_scout_web/lib/block_scout_web/paging_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/paging_helper.ex @@ -1,44 +1,32 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.PagingHelper do @moduledoc """ Helper for fetching filters and other url query parameters """ use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - import Explorer.Chain, only: [string_to_transaction_hash: 1] + import Explorer.Chain, only: [string_to_full_hash: 1] import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] + alias BlockScoutWeb.Schemas.API.V2.General alias Explorer.Chain.InternalTransaction.CallType, as: InternalTransactionCallType alias Explorer.Chain.InternalTransaction.Type, as: InternalTransactionType - alias Explorer.Chain.Stability.Validator, as: ValidatorStability alias Explorer.Chain.{SmartContract, Transaction} alias Explorer.{Helper, PagingOptions, SortingHelper} + alias Explorer.Stats.HotSmartContracts @page_size 50 @default_paging_options %PagingOptions{page_size: @page_size + 1} @allowed_filter_labels ["validated", "pending"] - - case @chain_type do - :ethereum -> - @allowed_type_labels [ - "coin_transfer", - "contract_call", - "contract_creation", - "token_transfer", - "token_creation", - "blob_transaction" - ] - - _ -> - @allowed_type_labels [ - "coin_transfer", - "contract_call", - "contract_creation", - "token_transfer", - "token_creation" - ] + @allowed_base_token_transfer_type_labels ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] + if @chain_type == :zilliqa do + @allowed_chain_type_token_transfer_type_labels ["ZRC-2"] + else + @allowed_chain_type_token_transfer_type_labels [] end - @allowed_token_transfer_type_labels ["ERC-20", "ERC-721", "ERC-1155", "ERC-404"] + @allowed_token_transfer_type_labels @allowed_base_token_transfer_type_labels ++ + @allowed_chain_type_token_transfer_type_labels @allowed_nft_type_labels ["ERC-721", "ERC-1155", "ERC-404"] @allowed_chain_id [1, 56, 99] @allowed_stability_validators_states ["active", "probation", "inactive"] @@ -55,9 +43,13 @@ defmodule BlockScoutWeb.PagingHelper do end end + def paging_options(%{block_number: block_number, index: index}, [:validated | _]) do + [paging_options: %{@default_paging_options | key: {block_number, index}}] + end + def paging_options(%{"inserted_at" => inserted_at_string, "hash" => hash_string}, [:pending | _]) do with {:ok, inserted_at, _} <- DateTime.from_iso8601(inserted_at_string), - {:ok, hash} <- string_to_transaction_hash(hash_string) do + {:ok, hash} <- string_to_full_hash(hash_string) do [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] else _ -> @@ -65,6 +57,16 @@ defmodule BlockScoutWeb.PagingHelper do end end + def paging_options(%{inserted_at: inserted_at, hash: hash_string}, [:pending | _]) do + case string_to_full_hash(hash_string) do + {:ok, hash} -> + [paging_options: %{@default_paging_options | key: {inserted_at, hash}, is_pending_transaction: true}] + + _ -> + [paging_options: @default_paging_options] + end + end + def paging_options(_params, _filter), do: [paging_options: @default_paging_options] @spec stability_validators_state_options(map()) :: [{:state, list()}, ...] @@ -74,6 +76,9 @@ defmodule BlockScoutWeb.PagingHelper do def stability_validators_state_options(_), do: [state: []] + @doc """ + Parse 'type' query parameter from request option map + """ @spec token_transfers_types_options(map()) :: [{:token_type, list}] def token_transfers_types_options(%{"type" => filters}) do [ @@ -81,13 +86,19 @@ defmodule BlockScoutWeb.PagingHelper do ] end + def token_transfers_types_options(%{type: filters}) do + [ + token_type: filters_to_list(filters, @allowed_token_transfer_type_labels) + ] + end + def token_transfers_types_options(_), do: [token_type: []] @doc """ Parse 'type' query parameter from request option map """ @spec nft_types_options(map()) :: [{:token_type, list}] - def nft_types_options(%{"type" => filters}) do + def nft_types_options(%{type: filters}) do [ token_type: filters_to_list(filters, @allowed_nft_type_labels) ] @@ -104,6 +115,11 @@ defmodule BlockScoutWeb.PagingHelper do if(filter == [], do: [fallback], else: filter) end + def filter_options(%{filter: filter}, fallback) do + filter = filter |> parse_filter(@allowed_filter_labels) |> Enum.map(&String.to_existing_atom/1) + if(filter == [], do: [fallback], else: filter) + end + def filter_options(_params, fallback), do: [fallback] def chain_ids_filter_options(%{"chain_ids" => chain_id}) do @@ -117,10 +133,25 @@ defmodule BlockScoutWeb.PagingHelper do ] end + def chain_ids_filter_options(%{chain_ids: chain_id}) do + [ + chain_ids: + chain_id + |> String.split(",") + |> Enum.uniq() + |> Enum.map(&Helper.parse_integer/1) + |> Enum.filter(&Enum.member?(@allowed_chain_id, &1)) + ] + end + def chain_ids_filter_options(_), do: [chain_id: []] def type_filter_options(%{"type" => type}) do - [type: type |> parse_filter(@allowed_type_labels) |> Enum.map(&String.to_existing_atom/1)] + [type: type |> parse_filter(General.allowed_transaction_types()) |> Enum.map(&String.to_existing_atom/1)] + end + + def type_filter_options(%{type: type}) do + [type: type |> parse_filter(General.allowed_transaction_types()) |> Enum.map(&String.to_existing_atom/1)] end def type_filter_options(_params), do: [type: []] @@ -130,6 +161,10 @@ defmodule BlockScoutWeb.PagingHelper do [type: type |> parse_filter(InternalTransactionType.values()) |> Enum.map(&String.to_existing_atom/1)] end + def internal_transaction_type_options(%{type: type}) do + [type: type |> parse_filter(InternalTransactionType.values()) |> Enum.map(&String.to_existing_atom/1)] + end + def internal_transaction_type_options(_params), do: [type: []] @spec internal_transaction_call_type_options(any()) :: [{:call_type, list()}] @@ -143,6 +178,10 @@ defmodule BlockScoutWeb.PagingHelper do [method: parse_method_filter(method)] end + def method_filter_options(%{method: method}) do + [method: parse_method_filter(method)] + end + def method_filter_options(_params), do: [method: []] def parse_filter("[" <> filter, allowed_labels) do @@ -170,7 +209,7 @@ defmodule BlockScoutWeb.PagingHelper do |> Enum.uniq() end - def select_block_type(%{"type" => type}) do + def select_block_type(%{type: type}) do case String.downcase(type) do "uncle" -> [ @@ -222,7 +261,22 @@ defmodule BlockScoutWeb.PagingHelper do def delete_parameters_from_next_page_params(params) when is_map(params) do params |> Map.drop([ + :address_hash_param, + :batch_number_param, + :batch_numbers, + :data_hash, + :block_hash_or_number_param, + :direction, + :transaction_hash_param, + :scale, + :token_id_param, + :token_id, + :type, + :apikey, + "apikey", "block_hash_or_number", + "block_hash_or_number_param", + "token_id_param", "transaction_hash_param", "address_hash_param", "type", @@ -234,12 +288,21 @@ defmodule BlockScoutWeb.PagingHelper do "state_filter", "l2_block_range_start", "l2_block_range_end", + # remove in favour :batch_number_param in the future when all batch - related API endpoints are covered with OpenAPI spec. "batch_number" ]) end def delete_parameters_from_next_page_params(_), do: nil + def delete_items_count_from_next_page_params(params) when is_map(params) do + params + |> Map.drop(["items_count"]) + end + + def delete_items_count_from_next_page_params(other), do: other + + # todo: it is used in the old UI only, consider removing it later def current_filter(%{"filter" => language_string}) do SmartContract.language_string_to_atom() |> Map.fetch(language_string) @@ -249,6 +312,15 @@ defmodule BlockScoutWeb.PagingHelper do end end + def current_filter(%{filter: language_string}) do + SmartContract.language_string_to_atom() + |> Map.fetch(language_string) + |> case do + {:ok, language} -> [filter: language] + :error -> [] + end + end + def current_filter(_), do: [] def search_query(%{"search" => ""}), do: [] @@ -257,12 +329,19 @@ defmodule BlockScoutWeb.PagingHelper do [search: search_string] end + # todo: it is used in the old UI only, consider removing it later def search_query(%{"q" => ""}), do: [] def search_query(%{"q" => search_string}) do [search: search_string] end + def search_query(%{q: ""}), do: [] + + def search_query(%{q: search_string}) do + [search: search_string] + end + def search_query(_), do: [] @spec tokens_sorting(%{required(String.t()) => String.t()}) :: [{:sorting, SortingHelper.sorting_params()}] @@ -270,30 +349,45 @@ defmodule BlockScoutWeb.PagingHelper do [sorting: do_tokens_sorting(sort_field, order)] end + def tokens_sorting(%{sort: sort_field, order: order}) do + [sorting: do_tokens_sorting(sort_field, order)] + end + def tokens_sorting(_), do: [] defp do_tokens_sorting("fiat_value", "asc"), do: [asc_nulls_first: :fiat_value] defp do_tokens_sorting("fiat_value", "desc"), do: [desc_nulls_last: :fiat_value] defp do_tokens_sorting("holders_count", "asc"), do: [asc_nulls_first: :holder_count] defp do_tokens_sorting("holders_count", "desc"), do: [desc_nulls_last: :holder_count] - # todo: Next 2 clauses should be removed in favour `holders_count` property with the next release after 8.0.0 - defp do_tokens_sorting("holder_count", "asc"), do: [asc_nulls_first: :holder_count] - defp do_tokens_sorting("holder_count", "desc"), do: [desc_nulls_last: :holder_count] defp do_tokens_sorting("circulating_market_cap", "asc"), do: [asc_nulls_first: :circulating_market_cap] defp do_tokens_sorting("circulating_market_cap", "desc"), do: [desc_nulls_last: :circulating_market_cap] defp do_tokens_sorting(_, _), do: [] - @spec address_transactions_sorting(%{required(String.t()) => String.t()}) :: [ + @spec address_transactions_sorting(%{required(atom()) => String.t()}) :: [ {:sorting, SortingHelper.sorting_params()} ] - def address_transactions_sorting(%{"sort" => sort_field, "order" => order}) do + def address_transactions_sorting(%{sort: sort_field, order: order}) do [sorting: do_address_transaction_sorting(sort_field, order)] end def address_transactions_sorting(_), do: [] - defp do_address_transaction_sorting("block_number", "asc"), do: [asc: :block_number, asc: :index] - defp do_address_transaction_sorting("block_number", "desc"), do: [desc: :block_number, desc: :index] + defp do_address_transaction_sorting("block_number", "asc"), + do: [ + asc: :block_number, + asc: :index, + asc: :inserted_at, + desc: :hash + ] + + defp do_address_transaction_sorting("block_number", "desc"), + do: [ + desc: :block_number, + desc: :index, + desc: :inserted_at, + asc: :hash + ] + defp do_address_transaction_sorting("value", "asc"), do: [asc: :value] defp do_address_transaction_sorting("value", "desc"), do: [desc: :value] defp do_address_transaction_sorting("fee", "asc"), do: [{:dynamic, :fee, :asc_nulls_first, Transaction.dynamic_fee()}] @@ -316,19 +410,13 @@ defmodule BlockScoutWeb.PagingHelper do defp do_validators_stability_sorting("state", "desc"), do: [desc_nulls_last: :state] defp do_validators_stability_sorting("address_hash", "asc"), do: [asc_nulls_first: :address_hash] defp do_validators_stability_sorting("address_hash", "desc"), do: [desc_nulls_last: :address_hash] - - defp do_validators_stability_sorting("blocks_validated", "asc"), - do: [{:dynamic, :blocks_validated, :asc_nulls_first, ValidatorStability.dynamic_validated_blocks()}] - - defp do_validators_stability_sorting("blocks_validated", "desc"), - do: [{:dynamic, :blocks_validated, :desc_nulls_last, ValidatorStability.dynamic_validated_blocks()}] + defp do_validators_stability_sorting("blocks_validated", "asc"), do: [asc_nulls_first: :blocks_validated] + defp do_validators_stability_sorting("blocks_validated", "desc"), do: [desc_nulls_last: :blocks_validated] defp do_validators_stability_sorting(_, _), do: [] - @spec mud_records_sorting(%{required(String.t()) => String.t()}) :: [ - {:sorting, SortingHelper.sorting_params()} - ] - def mud_records_sorting(%{"sort" => sort_field, "order" => order}) do + @spec mud_records_sorting(map()) :: [{:sorting, SortingHelper.sorting_params()}] + def mud_records_sorting(%{sort: sort_field, order: order}) do [sorting: do_mud_records_sorting(sort_field, order)] end @@ -381,7 +469,7 @@ defmodule BlockScoutWeb.PagingHelper do @spec addresses_sorting(%{required(String.t()) => String.t()}) :: [ {:sorting, SortingHelper.sorting_params()} ] - def addresses_sorting(%{"sort" => sort_field, "order" => order}) do + def addresses_sorting(%{sort: sort_field, order: order}) do [sorting: do_addresses_sorting(sort_field, order)] end @@ -392,4 +480,56 @@ defmodule BlockScoutWeb.PagingHelper do defp do_addresses_sorting("transactions_count", "asc"), do: [asc_nulls_first: :transactions_count] defp do_addresses_sorting("transactions_count", "desc"), do: [desc_nulls_last: :transactions_count] defp do_addresses_sorting(_, _), do: [] + + @spec hot_smart_contracts_sorting(%{sort: String.t(), order: String.t()}) :: [ + {:sorting, SortingHelper.sorting_params()} + ] + def hot_smart_contracts_sorting(%{sort: sort_field, order: order}) do + [sorting: do_hot_smart_contracts_sorting(sort_field, order)] + end + + @spec hot_smart_contracts_sorting(any()) :: [] + def hot_smart_contracts_sorting(_), do: [] + + defp do_hot_smart_contracts_sorting("transactions_count", "asc"), + do: %{ + aggregated_on_hot_smart_contracts: [ + {:dynamic, :transactions_count, :asc_nulls_first, HotSmartContracts.transactions_count_dynamic()} + ], + aggregated_on_transactions: [ + {:dynamic, :transactions_count, :asc_nulls_first, + HotSmartContracts.transactions_count_on_transactions_dynamic()} + ] + } + + defp do_hot_smart_contracts_sorting("transactions_count", "desc"), + do: %{ + aggregated_on_hot_smart_contracts: [ + {:dynamic, :transactions_count, :desc_nulls_last, HotSmartContracts.transactions_count_dynamic()} + ], + aggregated_on_transactions: [ + {:dynamic, :transactions_count, :desc_nulls_last, + HotSmartContracts.transactions_count_on_transactions_dynamic()} + ] + } + + defp do_hot_smart_contracts_sorting("total_gas_used", "asc"), + do: %{ + aggregated_on_hot_smart_contracts: [ + {:dynamic, :total_gas_used, :asc_nulls_first, HotSmartContracts.total_gas_used_dynamic()} + ], + aggregated_on_transactions: [ + {:dynamic, :total_gas_used, :asc_nulls_first, HotSmartContracts.total_gas_used_on_transactions_dynamic()} + ] + } + + defp do_hot_smart_contracts_sorting("total_gas_used", "desc"), + do: %{ + aggregated_on_hot_smart_contracts: [ + {:dynamic, :total_gas_used, :desc_nulls_last, HotSmartContracts.total_gas_used_dynamic()} + ], + aggregated_on_transactions: [ + {:dynamic, :total_gas_used, :desc_nulls_last, HotSmartContracts.total_gas_used_on_transactions_dynamic()} + ] + } end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/admin/check_owner_registered.ex b/apps/block_scout_web/lib/block_scout_web/plug/admin/check_owner_registered.ex index b1ff8053269b..155a595ea5d7 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/admin/check_owner_registered.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/admin/check_owner_registered.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.Admin.CheckOwnerRegistered do @moduledoc """ Checks that an admin owner has registered. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/admin/require_admin_role.ex b/apps/block_scout_web/lib/block_scout_web/plug/admin/require_admin_role.ex index 2a70d8a0e0bf..6a06f575f84e 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/admin/require_admin_role.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/admin/require_admin_role.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.Admin.RequireAdminRole do @moduledoc """ Authorization plug requiring a user to be authenticated and have an admin role. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/allow_iframe.ex b/apps/block_scout_web/lib/block_scout_web/plug/allow_iframe.ex index ee20311efc6d..2f03745d9ba0 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/allow_iframe.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/allow_iframe.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.AllowIframe do @moduledoc """ Allows for iframes by deleting the diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex index 80c5301bce15..a25d443d3a8e 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_account_api.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.CheckAccountAPI do @moduledoc """ Checks if the Account functionality enabled for API level. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_account_web.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_account_web.ex index 00a3af4e002e..d3e6f9749a87 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_account_web.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_account_web.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.CheckAccountWeb do @moduledoc """ Checks if the Account functionality enabled for web interface. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex index 95269a203932..9b320d6d5057 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_api_v2.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.CheckApiV2 do @moduledoc """ Checks if the API V2 enabled. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_chain_type.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_chain_type.ex index dc15b3d9ab24..bb0af044013a 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_chain_type.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_chain_type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.CheckChainType do @moduledoc """ A plug that restricts access to routes based on the current chain type. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/check_feature.ex b/apps/block_scout_web/lib/block_scout_web/plug/check_feature.ex index f858e4f5f50e..7f4b182c0708 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/check_feature.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/check_feature.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.CheckFeature do @moduledoc """ A configurable plug that conditionally allows access to an endpoint based on @@ -42,7 +43,7 @@ defmodule BlockScoutWeb.Plug.CheckFeature do @doc false @spec init(Keyword.t()) :: Keyword.t() def init(opts) do - unless Keyword.has_key?(opts, :feature_check) do + if !Keyword.has_key?(opts, :feature_check) do raise ArgumentError, "CheckFeature plug requires :feature_check option" end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/fetch_user_from_session.ex b/apps/block_scout_web/lib/block_scout_web/plug/fetch_user_from_session.ex index 963beac40c93..1dd95c78fb40 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/fetch_user_from_session.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/fetch_user_from_session.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.FetchUserFromSession do @moduledoc """ Fetches a `t:Explorer.Accounts.User.t/0` record if a user id is found in the session. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/graphql.ex b/apps/block_scout_web/lib/block_scout_web/plug/graphql.ex index 6e2a0c5bea22..6bc01ea0d1ce 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/graphql.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/graphql.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.GraphQL do @moduledoc """ Default query for GraphiQL interface. diff --git a/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex b/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex index be9f8dec0e53..249b03637b8f 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/graphql_schema_introspection.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.GraphQLSchemaIntrospection do @moduledoc """ A plug that handles GraphQL introspection queries by returning a pre-computed diff --git a/apps/block_scout_web/lib/block_scout_web/plug/logger.ex b/apps/block_scout_web/lib/block_scout_web/plug/logger.ex index b61c9cafe4c6..8249d939c449 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/logger.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/logger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.Logger do @moduledoc """ Extended version of Plug.Logger from https://github.com/elixir-plug/plug/blob/v1.14.0/lib/plug/logger.ex @@ -8,6 +9,7 @@ defmodule BlockScoutWeb.Plug.Logger do """ require Logger + alias BlockScoutWeb.API.RPC.RPCTranslator alias Plug.Conn @behaviour Plug @@ -50,7 +52,8 @@ defmodule BlockScoutWeb.Plug.Logger do defp endpoint(conn) do if conn.query_string do - "#{conn.request_path}?#{conn.query_string}" + redacted_query_string = RPCTranslator.redact_apikey(conn.query_string) + "#{conn.request_path}?#{redacted_query_string}" else conn.request_path end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex b/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex index 5f6de5f4fef1..9e21593dc7aa 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/rate_limit.ex @@ -1,31 +1,117 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.RateLimit do @moduledoc """ Rate limiting """ - alias BlockScoutWeb.AccessHelper + alias BlockScoutWeb.{AccessHelper, RateLimit} + alias Plug.Conn def init(opts), do: opts - def call(conn, graphql?: true) do - case AccessHelper.check_rate_limit(conn, graphql?: true) do - :ok -> - conn + def call(conn, _opts) do + config = fetch_rate_limit_config(conn) - true -> + conn + |> handle_call(config) + |> case do + {:deny, _time_to_reset, _limit, _period} = result -> conn + |> set_rate_limit_headers(result) + |> set_rate_limit_headers_for_frontend(config) + |> AccessHelper.handle_rate_limit_deny(!api_v2?(conn)) - _ -> - AccessHelper.handle_rate_limit_deny(conn, true) + result -> + conn + |> set_rate_limit_headers(result) + |> set_rate_limit_headers_for_frontend(config) end end - def call(conn, _opts) do - case AccessHelper.check_rate_limit(conn) do - :ok -> + defp set_rate_limit_headers(conn, result) do + case result do + {:allow, -1} -> + conn + |> Conn.put_resp_header("x-ratelimit-limit", "-1") + |> Conn.put_resp_header("x-ratelimit-remaining", "-1") + |> Conn.put_resp_header("x-ratelimit-reset", "-1") + + {:allow, count, limit, period} -> + now = System.system_time(:millisecond) + window = div(now, period) + expires_at = (window + 1) * period + conn + |> Conn.put_resp_header("x-ratelimit-limit", "#{limit}") + |> Conn.put_resp_header("x-ratelimit-remaining", "#{limit - count}") + |> Conn.put_resp_header("x-ratelimit-reset", "#{expires_at - now}") - :rate_limit_reached -> - AccessHelper.handle_rate_limit_deny(conn, true) + {:deny, time_to_reset, limit, _time_interval} -> + conn + |> Conn.put_resp_header("x-ratelimit-limit", "#{limit}") + |> Conn.put_resp_header("x-ratelimit-remaining", "0") + |> Conn.put_resp_header("x-ratelimit-reset", "#{time_to_reset}") end end + + defp set_rate_limit_headers_for_frontend(conn, config) do + user_agent = RateLimit.get_user_agent(conn) + + option = + cond do + config[:recaptcha_to_bypass_429] && user_agent -> "recaptcha" + config[:temporary_token] && user_agent -> "temporary_token" + !is_nil(config) -> "no_bypass" + true -> "no_bypass" + end + + conn + |> Conn.put_resp_header("bypass-429-option", option) + end + + defp handle_call(conn, config) do + if graphql?(conn) do + RateLimit.check_rate_limit_graphql(conn, 1) + else + RateLimit.rate_limit_with_config(conn, config) + end + end + + defp fetch_rate_limit_config(conn) do + request_path = request_path(conn) + config = :persistent_term.get(:rate_limit_config) + + if res = config[:static_match][request_path] do + res + else + find_endpoint_config(config, conn.path_info) || config[:static_match]["default"] + end + end + + defp find_endpoint_config(config, request_path_parts) do + config[:parametrized_match] + |> Enum.find({nil, nil}, fn {key, _config} -> + length(key) == length(request_path_parts) && + key |> Enum.zip(request_path_parts) |> Enum.all?(fn {k, r} -> k == r || k == ":param" end) + end) + |> elem(1) || + config[:wildcard_match] + |> Enum.find({nil, nil}, fn {{key, length}, _config} when is_integer(length) -> + Enum.take(request_path_parts, length) == key + end) + |> elem(1) + end + + defp graphql?(conn) do + request_path = request_path(conn) + request_path == "api/v1/graphql" or request_path == "graphiql" + end + + defp request_path(conn) do + Enum.join(conn.path_info, "/") + end + + defp api_v2?(conn) do + path_part = conn.path_info |> Enum.take(2) + path_part == ["api", "v2"] or path_part == ["api", "account"] + end end diff --git a/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex b/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex index 9b42d551d7d1..c93f2915a6d7 100644 --- a/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex +++ b/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex @@ -1,17 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.RedisCookie do @moduledoc """ Extended version of Plug.Session.COOKIE from https://github.com/elixir-plug/plug/blob/main/lib/plug/session/cookie.ex Added Redis to have a possibility to invalidate session """ - require Logger - @behaviour Plug.Session.Store - - import Explorer.ThirdPartyIntegrations.Auth0, only: [cookie_key: 1] + import Explorer.Helper, only: [redis_key: 1] alias Plug.Crypto alias Plug.Crypto.{KeyGenerator, MessageEncryptor, MessageVerifier} + require Logger + + @behaviour Plug.Session.Store + @impl true def init(opts) do opts @@ -196,7 +198,7 @@ defmodule BlockScoutWeb.Plug.RedisCookie do defp store_to_redis(cookie) do Redix.command(:redix, [ "SET", - cookie_key(hash(cookie)), + redis_key(hash(cookie)), 1, "EX", Application.get_env(:block_scout_web, :session_cookie_ttl) @@ -206,14 +208,14 @@ defmodule BlockScoutWeb.Plug.RedisCookie do end defp remove_from_redis(sid) do - Redix.command(:redix, ["DEL", cookie_key(sid)]) + Redix.command(:redix, ["DEL", redis_key(sid)]) end defp check_in_redis({sid, map}, _cookie) when is_nil(sid) or map == %{}, do: {nil, %{}} defp check_in_redis({_sid, session}, cookie) do hash = hash(cookie) - key = cookie_key(hash) + key = redis_key(hash) case Redix.command(:redix, ["GET", key]) do {:ok, one} when one in [1, "1"] -> diff --git a/apps/block_scout_web/lib/block_scout_web/prometheus/exporter.ex b/apps/block_scout_web/lib/block_scout_web/prometheus/exporter.ex index c5febc5173a4..25c17e5d88d3 100644 --- a/apps/block_scout_web/lib/block_scout_web/prometheus/exporter.ex +++ b/apps/block_scout_web/lib/block_scout_web/prometheus/exporter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Prometheus.Exporter do @moduledoc """ Exports `Prometheus` metrics at `/metrics` diff --git a/apps/block_scout_web/lib/block_scout_web/prometheus/instrumenter.ex b/apps/block_scout_web/lib/block_scout_web/prometheus/instrumenter.ex index d329c2751a28..c3626f37a5a5 100644 --- a/apps/block_scout_web/lib/block_scout_web/prometheus/instrumenter.ex +++ b/apps/block_scout_web/lib/block_scout_web/prometheus/instrumenter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Prometheus.Instrumenter do @moduledoc """ BlockScoutWeb metrics for `Prometheus`. diff --git a/apps/block_scout_web/lib/block_scout_web/prometheus/phoenix_instrumenter.ex b/apps/block_scout_web/lib/block_scout_web/prometheus/phoenix_instrumenter.ex deleted file mode 100644 index 5b25969f3f1b..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/prometheus/phoenix_instrumenter.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule BlockScoutWeb.Prometheus.PhoenixInstrumenter do - @moduledoc """ - Phoenix request metrics for `Prometheus`. - """ - - @dialyzer {:no_match, - [ - phoenix_channel_join: 3, - phoenix_channel_receive: 3, - phoenix_controller_call: 3, - phoenix_controller_render: 3, - setup: 0 - ]} - - use Prometheus.PhoenixInstrumenter -end diff --git a/apps/block_scout_web/lib/block_scout_web/prometheus/public_exporter.ex b/apps/block_scout_web/lib/block_scout_web/prometheus/public_exporter.ex index 620af0e0f0ff..e9b9c0cec734 100644 --- a/apps/block_scout_web/lib/block_scout_web/prometheus/public_exporter.ex +++ b/apps/block_scout_web/lib/block_scout_web/prometheus/public_exporter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Prometheus.PublicExporter do @moduledoc """ Exports `Prometheus` metrics at `/public-metrics` diff --git a/apps/block_scout_web/lib/block_scout_web/rate_limit.ex b/apps/block_scout_web/lib/block_scout_web/rate_limit.ex new file mode 100644 index 000000000000..821cd253da63 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/rate_limit.ex @@ -0,0 +1,395 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RateLimit do + @moduledoc """ + Rate limiting + """ + alias BlockScoutWeb.{AccessHelper, CaptchaHelper} + alias BlockScoutWeb.RateLimit.Hammer + alias Explorer.Account.Api.Key, as: ApiKey + alias Plug.{Conn, Crypto} + + require Logger + + @doc """ + Checks, if rate limit reached before making a new request. It is applied to GraphQL API. + """ + @spec check_rate_limit_graphql(Plug.Conn.t(), integer()) :: + {:allow, -1} | {:deny, integer(), integer(), integer()} | {:allow, integer(), integer(), integer()} + def check_rate_limit_graphql(conn, multiplier) do + config = Application.get_env(:block_scout_web, Api.GraphQL) + no_rate_limit_api_key = config[:no_rate_limit_api_key] + + cond do + config[:rate_limit_disabled?] -> + {:allow, -1} + + check_no_rate_limit_api_key(conn, no_rate_limit_api_key) -> + {:allow, -1} + + true -> + check_graphql_rate_limit_inner(conn, config, multiplier) + end + end + + defp check_graphql_rate_limit_inner(conn, config, multiplier) do + static_api_key = config[:static_api_key] + + ip_string = AccessHelper.conn_to_ip_string(conn) + + user_api_key = get_api_key(conn) + + with {:api_key, false} <- {:api_key, valid_api_key?(user_api_key) && user_api_key == static_api_key}, + {:plan, plan} when plan in [false, nil] <- {:plan, valid_api_key?(user_api_key) && get_plan(conn)} do + ip_result = + rate_limit("graphql_#{ip_string}", config[:time_interval_limit_by_ip], config[:limit_by_ip], multiplier) + + if match?({:allow, _}, ip_result) or match?({:allow, _, _, _}, ip_result) do + maybe_replace_result( + ip_result, + rate_limit("graphql", config[:time_interval_limit], config[:global_limit], multiplier) + ) + else + ip_result + end + else + {:api_key, true} -> + rate_limit(static_api_key, config[:time_interval_limit], config[:limit_by_key], multiplier) + + {:plan, {plan, api_key}} -> + rate_limit( + api_key, + config[:time_interval_limit], + min(plan.max_req_per_second, config[:limit_by_key]), + multiplier + ) + end + end + + defp maybe_replace_result(ip_result, {:allow, -1}) do + ip_result + end + + defp maybe_replace_result(ip_result, {:allow, _, _, _}) do + ip_result + end + + defp maybe_replace_result(_ip_result, global_result) do + global_result + end + + @spec rate_limit_with_config(Plug.Conn.t(), map()) :: + {:allow, -1} | {:deny, integer(), integer(), integer()} | {:allow, integer(), integer(), integer()} + def rate_limit_with_config(conn, config) do + config + |> prepare_pipeline(conn) + |> Enum.reject(&(is_nil(&1) || &1 == false)) + |> Enum.reduce_while(nil, fn fun, _acc -> + case fun.(conn) do + :skip -> {:cont, nil} + result -> {:halt, result} + end + end) + |> maybe_check_recaptcha_response( + conn, + get_user_agent(conn), + config[:recaptcha_to_bypass_429], + config[:bypass_token_scope] + ) + |> case do + nil -> + Logger.error("Misconfiguration issue for #{conn.request_path}") + {:allow, -1} + + result -> + result + end + end + + defp prepare_pipeline(config, conn) do + global_config = Application.get_env(:block_scout_web, :api_rate_limit) + + [ + global_config[:disabled] && fn _ -> {:allow, -1} end, + config[:ignore] && fn _ -> {:allow, -1} end, + check_no_rate_limit_api_key(conn, global_config[:no_rate_limit_api_key_value]) && fn _ -> {:allow, -1} end, + config[:temporary_token] && + (&rate_limit_by_temporary_token( + &1, + config[:temporary_token], + global_config[:temporary_token], + config[:bucket_key_prefix] + )), + config[:static_api_key] && + (&rate_limit_by_static_api_key( + &1, + config[:static_api_key], + global_config[:static_api_key], + global_config, + config[:bucket_key_prefix] + )), + config[:account_api_key] && + (&rate_limit_by_account_api_key( + &1, + config[:account_api_key], + global_config[:account_api_key], + config[:bucket_key_prefix] + )), + config[:whitelisted_ip] && + (&rate_limit_by_whitelisted_ip( + &1, + config[:whitelisted_ip], + global_config[:whitelisted_ip], + global_config, + config[:bucket_key_prefix] + )), + config[:ip] && + (&rate_limit_by_ip(&1, config[:ip], global_config[:ip], config[:bucket_key_prefix])) + ] + end + + defp maybe_check_recaptcha_response(result, conn, user_agent, true, scope) when not is_nil(user_agent) do + case result do + {:deny, _time_to_reset, limit, time_interval} -> + conn + |> check_recaptcha(scope) + |> case do + true -> + {:allow, limit, limit, time_interval} + + false -> + result + end + + _ -> + result + end + end + + defp maybe_check_recaptcha_response(result, _, _, _, _) do + result + end + + defp check_recaptcha(conn, scope) when is_binary(scope) do + conn + |> collect_recaptcha_headers() + |> CaptchaHelper.recaptcha_passed?(String.to_atom(scope)) + end + + defp check_recaptcha(conn, _) do + conn + |> collect_recaptcha_headers() + |> CaptchaHelper.recaptcha_passed?() + end + + defp collect_recaptcha_headers(conn) do + recaptcha_response = get_header_or_nil(conn, "recaptcha-v2-response") + recaptcha_v3_response = get_header_or_nil(conn, "recaptcha-v3-response") + recaptcha_bypass_token = get_header_or_nil(conn, "recaptcha-bypass-token") + scoped_recaptcha_bypass_token = get_header_or_nil(conn, "scoped-recaptcha-bypass-token") + + cond do + recaptcha_response -> + %{ + "recaptcha_response" => recaptcha_response + } + + recaptcha_v3_response -> + %{ + "recaptcha_v3_response" => recaptcha_v3_response + } + + scoped_recaptcha_bypass_token -> + %{ + "scoped_recaptcha_bypass_token" => scoped_recaptcha_bypass_token + } + + recaptcha_bypass_token -> + %{ + "recaptcha_bypass_token" => recaptcha_bypass_token + } + + true -> + %{} + end + end + + defp get_header_or_nil(conn, header_name) do + case Conn.get_req_header(conn, header_name) do + [response] -> + response + + _ -> + nil + end + end + + defp rate_limit_by_static_api_key(conn, route_config, default_config, global_config, bucket_key_prefix) do + config = config_or_default(route_config, default_config) + static_api_key = global_config[:static_api_key_value] + user_api_key = get_api_key(conn) + + if valid_api_key?(user_api_key) && user_api_key == static_api_key do + rate_limit(static_api_key, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + else + :skip + end + end + + @spec rate_limit_by_account_api_key(any(), any(), any(), String.t()) :: + :skip | {:allow, -1} | {:deny, integer(), integer(), integer()} | {:allow, integer(), integer(), integer()} + defp rate_limit_by_account_api_key(conn, route_config, global_config, bucket_key_prefix) do + config = config_or_default(route_config, global_config) + plan = get_plan(conn) + + if plan do + {plan, api_key} = plan + + rate_limit( + api_key, + config[:period], + config[:limit] || plan.max_req_per_second, + config[:cost] || 1, + bucket_key_prefix + ) + else + :skip + end + end + + defp rate_limit_by_whitelisted_ip(conn, route_config, default_config, global_config, bucket_key_prefix) do + config = config_or_default(route_config, default_config) + ip_string = AccessHelper.conn_to_ip_string(conn) + + if Enum.member?(whitelisted_ips(global_config), ip_string) do + rate_limit(ip_string, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + else + :skip + end + end + + defp rate_limit_by_temporary_token(conn, route_config, default_config, bucket_key_prefix) do + config = config_or_default(route_config, default_config) + ip_string = AccessHelper.conn_to_ip_string(conn) + token = get_ui_v2_token(conn, ip_string) + + if token && !is_nil(get_user_agent(conn)) do + rate_limit(token, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + else + :skip + end + end + + defp rate_limit_by_ip(conn, route_config, default_config, bucket_key_prefix) do + config = config_or_default(route_config, default_config) + ip_string = AccessHelper.conn_to_ip_string(conn) + + rate_limit(ip_string, config[:period], config[:limit], config[:cost] || 1, bucket_key_prefix) + end + + @spec config_or_default(any(), any()) :: any() + defp config_or_default(config, default) do + if is_map(config) do + config + else + default + end + end + + @spec rate_limit(String.t(), integer(), integer(), integer(), String.t()) :: + {:allow, integer(), integer(), integer()} | {:deny, integer(), integer(), integer()} | {:allow, -1} + def rate_limit(key, time_interval, limit, multiplier, bucket_key_prefix \\ "") do + case Hammer.hit(construct_bucket_key(key, bucket_key_prefix), time_interval, limit, multiplier) do + {:allow, count} -> + {:allow, count, limit, time_interval} + + {:deny, time_to_reset} -> + {:deny, time_to_reset, limit, time_interval} + + {:error, error} -> + Logger.error(fn -> ["Rate limit check error: ", inspect(error)] end) + {:allow, -1} + end + end + + @spec construct_bucket_key(String.t(), String.t()) :: String.t() + defp construct_bucket_key(key, bucket_key_prefix) do + chain_id = Application.get_env(:block_scout_web, :chain_id) + "#{chain_id}_#{bucket_key_prefix}#{key}" + end + + defp check_no_rate_limit_api_key(conn, no_rate_limit_api_key) do + user_api_key = get_api_key(conn) + + valid_api_key?(user_api_key) && user_api_key == no_rate_limit_api_key + end + + @doc """ + Get the user agent from the request headers. + """ + @spec get_user_agent(Plug.Conn.t()) :: nil | binary() + def get_user_agent(conn) do + case Conn.get_req_header(conn, "user-agent") do + [agent] -> + agent + + _ -> + nil + end + end + + defp get_ui_v2_token(conn, ip_string) do + api_v2_temp_token_cookie_key = Application.get_env(:block_scout_web, :api_v2_temp_token_cookie_key) + conn = Conn.fetch_cookies(conn, signed: [api_v2_temp_token_cookie_key]) + api_v2_temp_token_header_key = Application.get_env(:block_scout_web, :api_v2_temp_token_header_key) + + case Conn.get_req_header(conn, api_v2_temp_token_header_key) do + [token] -> + case Crypto.verify(conn.secret_key_base, api_v2_temp_token_header_key <> "-header", token, keys: Plug.Keys) do + {:ok, %{ip: ^ip_string}} -> token + _ -> nil + end + + _ -> + case conn.cookies[api_v2_temp_token_cookie_key] do + %{ip: ^ip_string} -> + conn.req_cookies[api_v2_temp_token_cookie_key] + + _ -> + nil + end + end + end + + defp whitelisted_ips(api_rate_limit_object) do + case api_rate_limit_object && api_rate_limit_object |> Keyword.fetch(:whitelisted_ips) do + {:ok, whitelisted_ips_string} -> + if whitelisted_ips_string, do: String.split(whitelisted_ips_string, ","), else: [] + + _ -> + [] + end + end + + defp valid_api_key?(api_key), do: !is_nil(api_key) && String.trim(api_key) !== "" + + defp get_api_key(conn) do + case Conn.get_req_header(conn, "x-api-key") do + [api_key] -> + api_key + + _ -> + Map.get(conn.query_params, :apikey) || Map.get(conn.query_params, "apikey") + end + end + + defp get_plan(conn) do + with true <- Application.get_env(:explorer, Explorer.Account)[:enabled], + api_key_value when not is_nil(api_key_value) <- get_api_key(conn), + api_key when not is_nil(api_key) <- ApiKey.api_key_with_plan_by_value(api_key_value) do + {api_key.identity.plan, to_string(api_key.value)} + else + _ -> + nil + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/rate_limit/hammer.ex b/apps/block_scout_web/lib/block_scout_web/rate_limit/hammer.ex new file mode 100644 index 000000000000..dc5a902415fd --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/rate_limit/hammer.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RateLimit.Hammer.ETS do + @moduledoc false + use Hammer, backend: Hammer.ETS +end + +defmodule BlockScoutWeb.RateLimit.Hammer.Redis do + @moduledoc false + use Hammer, backend: Hammer.Redis +end + +defmodule BlockScoutWeb.RateLimit.Hammer do + @moduledoc """ + Wrapper for the rate limit functions. Defines union of all functions from `BlockScoutWeb.RateLimit.Hammer.ETS` and `BlockScoutWeb.RateLimit.Hammer.Redis`. Resolves the backend to use based on `Application.get_env(:block_scout_web, :rate_limit_backend)` in runtime. + """ + + alias BlockScoutWeb.RateLimit.Hammer.{ETS, Redis} + alias Explorer.Helper + + functions = + (ETS.__info__(:functions) ++ Redis.__info__(:functions)) + |> Enum.uniq() + + for {name, arity} <- functions do + args = Macro.generate_arguments(arity, nil) + + def unquote(name)(unquote_splicing(args)) do + apply(Application.get_env(:block_scout_web, :api_rate_limit)[:rate_limit_backend], unquote(name), unquote(args)) + end + end + + def child_for_supervisor do + config = Application.get_env(:block_scout_web, :api_rate_limit) + + if config[:redis_url] || config[:redis_sentinel_urls] do + {BlockScoutWeb.RateLimit.Hammer.Redis, + Helper.redix_opts( + config[:redis_url], + config[:redis_ssl], + config[:redis_sentinel_urls], + config[:redis_sentinel_master_name] + )} + else + {BlockScoutWeb.RateLimit.Hammer.ETS, []} + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/realtime_event_handler.ex b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/main.ex similarity index 88% rename from apps/block_scout_web/lib/block_scout_web/realtime_event_handler.ex rename to apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/main.ex index f075509c6867..a21ac9cc705c 100644 --- a/apps/block_scout_web/lib/block_scout_web/realtime_event_handler.ex +++ b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/main.ex @@ -1,4 +1,5 @@ -defmodule BlockScoutWeb.RealtimeEventHandler do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RealtimeEventHandlers.Main do @moduledoc """ Subscribing process for broadcast events from realtime. """ @@ -38,15 +39,16 @@ defmodule BlockScoutWeb.RealtimeEventHandler do Subscriber.to(:block_rewards, :realtime) Subscriber.to(:internal_transactions, :realtime) Subscriber.to(:internal_transactions, :on_demand) - Subscriber.to(:token_transfers, :realtime) Subscriber.to(:addresses, :on_demand) Subscriber.to(:address_coin_balances, :on_demand) Subscriber.to(:address_current_token_balances, :on_demand) + Subscriber.to(:address_current_token_balances, :realtime) Subscriber.to(:address_token_balances, :on_demand) Subscriber.to(:token_total_supply, :on_demand) Subscriber.to(:changed_bytecode, :on_demand) Subscriber.to(:fetched_bytecode, :on_demand) Subscriber.to(:fetched_token_instance_metadata, :on_demand) + Subscriber.to(:not_fetched_token_instance_metadata, :on_demand) Subscriber.to(:zkevm_confirmed_batches, :realtime) # Does not come from the indexer Subscriber.to(:exchange_rate) diff --git a/apps/block_scout_web/lib/block_scout_web/main_page_realtime_event_handler.ex b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/main_page.ex similarity index 85% rename from apps/block_scout_web/lib/block_scout_web/main_page_realtime_event_handler.ex rename to apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/main_page.ex index e0f61898963b..5f1c23395510 100644 --- a/apps/block_scout_web/lib/block_scout_web/main_page_realtime_event_handler.ex +++ b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/main_page.ex @@ -1,4 +1,5 @@ -defmodule BlockScoutWeb.MainPageRealtimeEventHandler do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RealtimeEventHandlers.MainPage do @moduledoc """ Subscribing process for main page broadcast events from realtime. """ diff --git a/apps/block_scout_web/lib/block_scout_web/smart_contract_realtime_event_handler.ex b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/smart_contract.ex similarity index 86% rename from apps/block_scout_web/lib/block_scout_web/smart_contract_realtime_event_handler.ex rename to apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/smart_contract.ex index 73c222d0699c..748ac34ae585 100644 --- a/apps/block_scout_web/lib/block_scout_web/smart_contract_realtime_event_handler.ex +++ b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/smart_contract.ex @@ -1,4 +1,5 @@ -defmodule BlockScoutWeb.SmartContractRealtimeEventHandler do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RealtimeEventHandlers.SmartContract do @moduledoc """ Subscribing process for smart contract verification related broadcast events from realtime. """ diff --git a/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/token_transfer.ex new file mode 100644 index 000000000000..9baa74a04499 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/realtime_event_handlers/token_transfer.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RealtimeEventHandlers.TokenTransfer do + @moduledoc """ + Subscribing process for token transfer broadcast events from realtime. + """ + + use GenServer + + alias BlockScoutWeb.Notifier + alias Explorer.Chain.Events.Subscriber + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl true + def init([]) do + Subscriber.to(:token_transfers, :realtime) + {:ok, []} + end + + @impl true + def handle_info(event, state) do + Notifier.handle_event(event) + {:noreply, state} + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex index 05292b44f702..709df8da2c4b 100644 --- a/apps/block_scout_web/lib/block_scout_web/router.ex +++ b/apps/block_scout_web/lib/block_scout_web/router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Router do use BlockScoutWeb, :router @@ -44,6 +45,7 @@ defmodule BlockScoutWeb.Router do ) plug(BlockScoutWeb.Plug.Logger, application: :api) + plug(BlockScoutWeb.Plug.RateLimit) plug(:accepts, ["json"]) end @@ -57,12 +59,21 @@ defmodule BlockScoutWeb.Router do plug(BlockScoutWeb.Plug.Logger, application: :api) plug(:accepts, ["json"]) - plug(BlockScoutWeb.Plug.RateLimit, graphql?: true) + plug(BlockScoutWeb.Plug.RateLimit) + end + + pipeline :rate_limit do + plug(:fetch_query_params) + plug(:accepts, ["json"]) + plug(BlockScoutWeb.Plug.RateLimit) end match(:*, "/auth/*path", AccountRouter, []) - forward("/api", ApiRouter) + scope "/api" do + pipe_through(:rate_limit) + forward("/", ApiRouter) + end scope "/graphiql" do pipe_through(:api_v1_graphql) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex index 2a915b9d6881..00e09441ad52 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/account_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Routers.AccountRouter do @moduledoc """ Router for account-related requests @@ -36,7 +37,7 @@ defmodule BlockScoutWeb.Routers.AccountRouter do plug(BlockScoutWeb.ChecksumAddress) end - pipeline :account_api do + pipeline :account_api_v2 do plug( Plug.Parsers, parsers: [:urlencoded, :multipart, :json], @@ -46,14 +47,17 @@ defmodule BlockScoutWeb.Routers.AccountRouter do json_decoder: Poison ) - plug(BlockScoutWeb.Plug.Logger, application: :api) + plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(:fetch_session) plug(:protect_from_forgery) plug(CheckAccountAPI) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Private) end - pipeline :api do + pipeline :account_api_v2_no_protect_from_forgery do + plug(CheckAccountAPI) + plug( Plug.Parsers, parsers: [:urlencoded, :multipart, :json], @@ -63,8 +67,9 @@ defmodule BlockScoutWeb.Routers.AccountRouter do json_decoder: Poison ) - plug(BlockScoutWeb.Plug.Logger, application: :api) + plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Private) end scope "/auth", BlockScoutWeb do @@ -109,15 +114,10 @@ defmodule BlockScoutWeb.Routers.AccountRouter do only: [:new, :create, :edit, :update, :delete, :index], as: :custom_abi ) - - resources("/public_tags_request", Account.PublicTagsRequestController, - only: [:new, :create, :edit, :update, :delete, :index], - as: :public_tags_request - ) end scope "/v2", as: :account_v2 do - pipe_through(:account_api) + pipe_through(:account_api_v2) get("/authenticate", AuthenticateController, :authenticate_get) post("/authenticate", AuthenticateController, :authenticate_post) @@ -151,11 +151,6 @@ defmodule BlockScoutWeb.Routers.AccountRouter do post("/custom_abis", UserController, :create_custom_abi) put("/custom_abis/:id", UserController, :update_custom_abi) - get("/public_tags", UserController, :public_tags_requests) - delete("/public_tags/:id", UserController, :delete_public_tags_request) - post("/public_tags", UserController, :create_public_tags_request) - put("/public_tags/:id", UserController, :update_public_tags_request) - scope "/tags" do get("/address/", UserController, :tags_address) get("/address/:id", UserController, :tags_address) @@ -173,8 +168,7 @@ defmodule BlockScoutWeb.Routers.AccountRouter do end scope "/v2" do - pipe_through(:api) - pipe_through(:account_api) + pipe_through(:account_api_v2) scope "/tags" do get("/address/:address_hash", TagsController, :tags_address) @@ -184,9 +178,10 @@ defmodule BlockScoutWeb.Routers.AccountRouter do end scope "/v2" do - pipe_through(:api) + pipe_through(:account_api_v2_no_protect_from_forgery) post("/authenticate_via_wallet", AuthenticateController, :authenticate_via_wallet) + get("/authenticate_via_dynamic", AuthenticateController, :authenticate_via_dynamic) post("/send_otp", AuthenticateController, :send_otp) post("/confirm_otp", AuthenticateController, :confirm_otp) get("/siwe_message", AuthenticateController, :siwe_message) diff --git a/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex index 36edce1b8c70..72e5bec1d4be 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/address_badges_v2_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file in ignore list of `sobelow`, be careful while adding new endpoints here defmodule BlockScoutWeb.Routers.AddressBadgesApiV2Router do @moduledoc """ @@ -5,7 +6,7 @@ defmodule BlockScoutWeb.Routers.AddressBadgesApiV2Router do """ use BlockScoutWeb, :router alias BlockScoutWeb.API.V2 - alias BlockScoutWeb.Plug.{CheckApiV2, RateLimit} + alias BlockScoutWeb.Plug.CheckApiV2 @max_query_string_length 5_000 @@ -23,7 +24,6 @@ defmodule BlockScoutWeb.Routers.AddressBadgesApiV2Router do plug(CheckApiV2) plug(:fetch_session) plug(:protect_from_forgery) - plug(RateLimit) end pipeline :api_v2_no_forgery_protect do @@ -39,7 +39,6 @@ defmodule BlockScoutWeb.Routers.AddressBadgesApiV2Router do plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(CheckApiV2) - plug(RateLimit) plug(:fetch_session) end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex index d452980ae9ff..b9e3d72e3444 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/admin_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Routers.AdminRouter do @moduledoc """ Router for admin pages. @@ -5,8 +6,8 @@ defmodule BlockScoutWeb.Routers.AdminRouter do use BlockScoutWeb, :router - alias BlockScoutWeb.Plug.FetchUserFromSession alias BlockScoutWeb.Plug.Admin.{CheckOwnerRegistered, RequireAdminRole} + alias BlockScoutWeb.Plug.FetchUserFromSession pipeline :browser do plug( diff --git a/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex index 8c656e6cedab..b06f13a4c57b 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/api_key_v2_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Routers.APIKeyV2Router do @moduledoc """ Router for /api/v2/key. This route has separate router in order to avoid rate limiting diff --git a/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex index 236724dec0fa..a94af802da9c 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule RPCTranslatorForwarder do @moduledoc """ Phoenix router limits forwarding, @@ -17,16 +18,19 @@ defmodule BlockScoutWeb.Routers.ApiRouter do use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity], graphql_enabled: [:block_scout_web, [Api.GraphQL, :enabled]], graphql_max_complexity: [:block_scout_web, [Api.GraphQL, :max_complexity]], graphql_token_limit: [:block_scout_web, [Api.GraphQL, :token_limit]], reading_enabled: [:block_scout_web, [__MODULE__, :reading_enabled]], - writing_enabled: [:block_scout_web, [__MODULE__, :writing_enabled]] + writing_enabled: [:block_scout_web, [__MODULE__, :writing_enabled]], + mud_enabled_compile_time?: [:explorer, [Explorer.Chain.Mud, :enabled]] use Utils.RuntimeEnvHelper, mud_enabled?: [:explorer, [Explorer.Chain.Mud, :enabled]] alias BlockScoutWeb.Routers.{ + AccountRouter, AddressBadgesApiV2Router, APIKeyV2Router, SmartContractsApiV2Router, @@ -34,8 +38,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do UtilsApiV2Router } - alias BlockScoutWeb.Plug.{CheckApiV2, CheckFeature, RateLimit} - alias BlockScoutWeb.Routers.AccountRouter + alias BlockScoutWeb.Plug.{CheckApiV2, CheckFeature} @max_query_string_length 5_000 @@ -75,7 +78,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do plug(CheckApiV2) plug(:fetch_session) plug(:protect_from_forgery) - plug(RateLimit) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end pipeline :api_v2_no_session do @@ -90,7 +93,6 @@ defmodule BlockScoutWeb.Routers.ApiRouter do plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(CheckApiV2) - plug(RateLimit) end pipeline :api_v1_graphql do @@ -103,7 +105,6 @@ defmodule BlockScoutWeb.Routers.ApiRouter do plug(BlockScoutWeb.Plug.Logger, application: :api) plug(:accepts, ["json"]) - plug(RateLimit, graphql?: true) plug(BlockScoutWeb.Plug.GraphQLSchemaIntrospection) end @@ -111,7 +112,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do plug(CheckFeature, feature_check: &mud_enabled?/0) end - alias BlockScoutWeb.API.V2 + alias BlockScoutWeb.API.{Legacy, V2} forward("/account", AccountRouter) @@ -122,6 +123,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do delete("/token-info", V2.ImportController, :delete_token_info) get("/smart-contracts/:address_hash_param", V2.ImportController, :try_to_search_contract) + post("/smart-contracts/:address_hash_param/audit-reports", V2.ImportController, :import_audit_report) if @chain_type == :optimism do post("/optimism/interop/", V2.OptimismController, :interop_import) @@ -130,6 +132,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do scope "/v2", as: :api_v2 do pipe_through(:api_v2) + get("/openapi", OpenApiSpex.Plug.RenderSpec, []) scope "/search" do get("/", V2.SearchController, :search) @@ -138,34 +141,40 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end scope "/config" do + get("/backend", V2.ConfigController, :backend) get("/backend-version", V2.ConfigController, :backend_version) get("/csv-export", V2.ConfigController, :csv_export) + get("/indexer", V2.ConfigController, :indexer) get("/public-metrics", V2.ConfigController, :public_metrics) + get("/smart-contracts/languages", V2.ConfigController, :languages_list) + get("/db-background-migrations", V2.ConfigController, :db_background_migrations) + + if @chain_identity == {:optimism, :celo} do + get("/celo", V2.ConfigController, :celo) + end end + get("/csv-exports/:uuid_param", V2.CsvExportController, :get_csv_export) + scope "/transactions" do get("/", V2.TransactionController, :transactions) get("/watchlist", V2.TransactionController, :watchlist_transactions) get("/stats", V2.TransactionController, :stats) - if @chain_type == :polygon_zkevm do - get("/zkevm-batch/:batch_number", V2.TransactionController, :polygon_zkevm_batch) - end - if @chain_type == :zksync do - get("/zksync-batch/:batch_number", V2.TransactionController, :zksync_batch) + get("/zksync-batch/:batch_number_param", V2.TransactionController, :zksync_batch) end if @chain_type == :arbitrum do - get("/arbitrum-batch/:batch_number", V2.TransactionController, :arbitrum_batch) + get("/arbitrum-batch/:batch_number_param", V2.TransactionController, :arbitrum_batch) end if @chain_type == :optimism do - get("/optimism-batch/:batch_number", V2.TransactionController, :optimism_batch) + get("/optimism-batch/:batch_number_param", V2.TransactionController, :optimism_batch) end if @chain_type == :scroll do - get("/scroll-batch/:batch_number", V2.TransactionController, :scroll_batch) + get("/scroll-batch/:batch_number_param", V2.TransactionController, :scroll_batch) end if @chain_type == :suave do @@ -176,6 +185,8 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/:transaction_hash_param/token-transfers", V2.TransactionController, :token_transfers) get("/:transaction_hash_param/internal-transactions", V2.TransactionController, :internal_transactions) get("/:transaction_hash_param/logs", V2.TransactionController, :logs) + get("/:transaction_hash_param/fhe-operations", V2.TransactionController, :fhe_operations) + get("/:transaction_hash_param/raw-trace", V2.TransactionController, :raw_trace) get("/:transaction_hash_param/state-changes", V2.TransactionController, :state_changes) get("/:transaction_hash_param/summary", V2.TransactionController, :summary) @@ -187,6 +198,10 @@ defmodule BlockScoutWeb.Routers.ApiRouter do if @chain_type == :ethereum do get("/:transaction_hash_param/blobs", V2.TransactionController, :blobs) end + + if @chain_type == :ethereum do + get("/:transaction_hash_param/beacon/deposits", V2.TransactionController, :beacon_deposits) + end end scope "/token-transfers" do @@ -199,26 +214,26 @@ defmodule BlockScoutWeb.Routers.ApiRouter do scope "/blocks" do get("/", V2.BlockController, :blocks) - get("/:block_hash_or_number", V2.BlockController, :block) - get("/:block_hash_or_number/transactions", V2.BlockController, :transactions) - get("/:block_hash_or_number/internal-transactions", V2.BlockController, :internal_transactions) - get("/:block_hash_or_number/withdrawals", V2.BlockController, :withdrawals) + get("/:block_hash_or_number_param", V2.BlockController, :block) + get("/:block_hash_or_number_param/transactions", V2.BlockController, :transactions) + get("/:block_hash_or_number_param/internal-transactions", V2.BlockController, :internal_transactions) + get("/:block_hash_or_number_param/withdrawals", V2.BlockController, :withdrawals) + get("/:block_number_param/countdown", V2.BlockController, :block_countdown) if @chain_type == :arbitrum do - get("/arbitrum-batch/:batch_number", V2.BlockController, :arbitrum_batch) - end - - if @chain_type == :celo do - get("/:block_hash_or_number/epoch", V2.BlockController, :celo_epoch) - get("/:block_hash_or_number/election-rewards/:reward_type", V2.BlockController, :celo_election_rewards) + get("/arbitrum-batch/:batch_number_param", V2.BlockController, :arbitrum_batch) end if @chain_type == :optimism do - get("/optimism-batch/:batch_number", V2.BlockController, :optimism_batch) + get("/optimism-batch/:batch_number_param", V2.BlockController, :optimism_batch) end if @chain_type == :scroll do - get("/scroll-batch/:batch_number", V2.BlockController, :scroll_batch) + get("/scroll-batch/:batch_number_param", V2.BlockController, :scroll_batch) + end + + if @chain_type == :ethereum do + get("/:block_hash_or_number_param/beacon/deposits", V2.BlockController, :beacon_deposits) end end @@ -244,9 +259,13 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/:address_hash_param/nft", V2.AddressController, :nft_list) get("/:address_hash_param/nft/collections", V2.AddressController, :nft_collections) - if @chain_type == :celo do - get("/:address_hash_param/election-rewards", V2.AddressController, :celo_election_rewards) - get("/:address_hash_param/election-rewards/csv", V2.CsvExportController, :celo_election_rewards_csv) + if @chain_identity == {:optimism, :celo} do + get("/:address_hash_param/celo/election-rewards", V2.AddressController, :celo_election_rewards) + get("/:address_hash_param/celo/election-rewards/csv", V2.CsvExportController, :celo_election_rewards_csv) + end + + if @chain_type == :ethereum do + get("/:address_hash_param/beacon/deposits", V2.AddressController, :beacon_deposits) end end @@ -257,12 +276,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/indexing-status", V2.MainPageController, :indexing_status) if @chain_type == :optimism do - get("/optimism-deposits", V2.MainPageController, :optimism_deposits) - end - - if @chain_type == :polygon_zkevm do - get("/zkevm/batches/confirmed", V2.PolygonZkevmController, :batches_confirmed) - get("/zkevm/batches/latest-number", V2.PolygonZkevmController, :batch_latest_number) + get("/optimism-deposits", V2.OptimismController, :main_page_deposits) end if @chain_type == :zksync do @@ -279,6 +293,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do scope "/stats" do get("/", V2.StatsController, :stats) + get("/hot-smart-contracts", V2.StatsController, :hot_smart_contracts) scope "/charts" do get("/transactions", V2.StatsController, :transactions_chart) @@ -287,15 +302,12 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end end - scope "/optimism" do - if @chain_type == :optimism do - get("/txn-batches", V2.OptimismController, :transaction_batches) - get("/txn-batches/count", V2.OptimismController, :transaction_batches_count) - get("/txn-batches/:l2_block_range_start/:l2_block_range_end", V2.OptimismController, :transaction_batches) + if @chain_type == :optimism do + scope "/optimism" do get("/batches", V2.OptimismController, :batches) get("/batches/count", V2.OptimismController, :batches_count) get("/batches/da/celestia/:height/:commitment", V2.OptimismController, :batch_by_celestia_blob) - get("/batches/:internal_id", V2.OptimismController, :batch_by_internal_id) + get("/batches/:number", V2.OptimismController, :batch_by_number) get("/output-roots", V2.OptimismController, :output_roots) get("/output-roots/count", V2.OptimismController, :output_roots_count) get("/deposits", V2.OptimismController, :deposits) @@ -306,16 +318,16 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/games/count", V2.OptimismController, :games_count) get("/interop/messages", V2.OptimismController, :interop_messages) get("/interop/messages/count", V2.OptimismController, :interop_messages_count) + get("/interop/messages/:unique_id", V2.OptimismController, :interop_message) get("/interop/public-key", V2.OptimismController, :interop_public_key) end end - scope "/polygon-edge" do - chain_scope :polygon_edge do - get("/deposits", V2.PolygonEdgeController, :deposits) - get("/deposits/count", V2.PolygonEdgeController, :deposits_count) - get("/withdrawals", V2.PolygonEdgeController, :withdrawals) - get("/withdrawals/count", V2.PolygonEdgeController, :withdrawals_count) + if @chain_identity == {:optimism, :celo} do + scope "/celo/epochs" do + get("/", V2.CeloController, :epochs) + get("/:number", V2.CeloController, :epoch) + get("/:number/election-rewards/:type", V2.CeloController, :election_rewards) end end @@ -345,20 +357,8 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/counters", V2.WithdrawalController, :withdrawals_counters) end - scope "/zkevm" do - if @chain_type == :polygon_zkevm do - get("/batches", V2.PolygonZkevmController, :batches) - get("/batches/count", V2.PolygonZkevmController, :batches_count) - get("/batches/:batch_number", V2.PolygonZkevmController, :batch) - get("/deposits", V2.PolygonZkevmController, :deposits) - get("/deposits/count", V2.PolygonZkevmController, :deposits_count) - get("/withdrawals", V2.PolygonZkevmController, :withdrawals) - get("/withdrawals/count", V2.PolygonZkevmController, :withdrawals_count) - end - end - scope "/proxy" do - scope "/3dparty" do + scope "/3rdparty" do get("/:platform_id", V2.Proxy.UniversalProxyController, :index) scope "/noves-fi" do @@ -374,7 +374,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end scope "/solidityscan" do - get("/smart-contracts/:address_hash/report", V2.SmartContractController, :solidityscan_report) + get("/smart-contracts/:address_hash/report", V2.Proxy.SolidityScanController, :solidityscan_report) end end @@ -399,6 +399,13 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end end + if @chain_type == :ethereum do + scope "/beacon" do + get("/deposits", V2.Ethereum.DepositController, :list) + get("/deposits/count", V2.Ethereum.DepositController, :count) + end + end + scope "/blobs" do if @chain_type == :ethereum do get("/:blob_hash_param", V2.BlobController, :blob) @@ -436,17 +443,19 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end end - scope "/mud" do - pipe_through(:mud) - get("/worlds", V2.MudController, :worlds) - get("/worlds/count", V2.MudController, :worlds_count) - get("/worlds/:world/tables", V2.MudController, :world_tables) - get("/worlds/:world/systems", V2.MudController, :world_systems) - get("/worlds/:world/systems/:system", V2.MudController, :world_system) - get("/worlds/:world/tables/count", V2.MudController, :world_tables_count) - get("/worlds/:world/tables/:table_id/records", V2.MudController, :world_table_records) - get("/worlds/:world/tables/:table_id/records/count", V2.MudController, :world_table_records_count) - get("/worlds/:world/tables/:table_id/records/:record_id", V2.MudController, :world_table_record) + if @mud_enabled_compile_time? do + scope "/mud" do + pipe_through(:mud) + get("/worlds", V2.MudController, :worlds) + get("/worlds/count", V2.MudController, :worlds_count) + get("/worlds/:world/tables", V2.MudController, :world_tables) + get("/worlds/:world/systems", V2.MudController, :world_systems) + get("/worlds/:world/systems/:system", V2.MudController, :world_system) + get("/worlds/:world/tables/count", V2.MudController, :world_tables_count) + get("/worlds/:world/tables/:table_id/records", V2.MudController, :world_table_records) + get("/worlds/:world/tables/:table_id/records/count", V2.MudController, :world_table_records_count) + get("/worlds/:world/tables/:table_id/records/:record_id", V2.MudController, :world_table_record) + end end scope "/arbitrum" do @@ -458,12 +467,13 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/batches", V2.ArbitrumController, :batches) get("/batches/count", V2.ArbitrumController, :batches_count) get("/batches/:batch_number", V2.ArbitrumController, :batch) - get("/batches/da/anytrust/:data_hash", V2.ArbitrumController, :batch_by_data_availability_info) + get("/batches/da/anytrust/:data_hash", V2.ArbitrumController, :batch_by_anytrust_da_info) + get("/batches/da/eigenda/:data_hash", V2.ArbitrumController, :batch_by_eigenda_da_info) get( "/batches/da/celestia/:height/:transaction_commitment", V2.ArbitrumController, - :batch_by_data_availability_info + :batch_by_celestia_da_info ) end end @@ -475,6 +485,19 @@ defmodule BlockScoutWeb.Routers.ApiRouter do end end + scope "/legacy" do + pipe_through(:api_v2) + + scope "/logs" do + get("/get-logs", Legacy.LogsController, :get_logs) + end + + scope "/block" do + get("/get-block-number-by-time", Legacy.BlockController, :get_block_number_by_time) + get("/eth-block-number", Legacy.BlockController, :eth_block_number) + end + end + scope "/v1/graphql" do pipe_through(:api_v1_graphql) @@ -497,16 +520,6 @@ defmodule BlockScoutWeb.Routers.ApiRouter do # leave the same endpoint in v1 in order to keep backward compatibility get("/search", SearchController, :search) - # todo: remove these old CSV export related endpoints in 7.2.0 or higher since they are moved to /api/v2/** path - get("/transactions-csv", V2.CsvExportController, :transactions_csv) - get("/token-transfers-csv", V2.CsvExportController, :token_transfers_csv) - get("/internal-transactions-csv", V2.CsvExportController, :internal_transactions_csv) - get("/logs-csv", V2.CsvExportController, :logs_csv) - - if @chain_type == :celo do - get("/celo-election-rewards-csv", V2.CsvExportController, :celo_election_rewards_csv) - end - get("/gas-price-oracle", GasPriceOracleController, :gas_price_oracle) if @reading_enabled do @@ -538,7 +551,12 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/multichain-search-export", BlockScoutWeb.API.HealthController, :multichain_search_db_export) end - # For backward compatibility. Should be removed + scope "/v2" do + pipe_through(:api_v2) + + get("/*path", BlockScoutWeb.API.V2.FallbackController, :unknown_action) + end + scope "/" do pipe_through(:api) alias BlockScoutWeb.API.{EthRPC, RPC} @@ -546,15 +564,26 @@ defmodule BlockScoutWeb.Routers.ApiRouter do if @reading_enabled do post("/eth-rpc", EthRPC.EthController, :eth_request) - forward("/", RPCTranslatorForwarder, %{ - "block" => {RPC.BlockController, []}, - "account" => {RPC.AddressController, []}, - "logs" => {RPC.LogsController, []}, - "token" => {RPC.TokenController, []}, - "stats" => {RPC.StatsController, []}, - "contract" => {RPC.ContractController, [:verify]}, - "transaction" => {RPC.TransactionController, []} - }) + forward( + "/", + RPCTranslatorForwarder, + %{ + "block" => {RPC.BlockController, []}, + "account" => {RPC.AddressController, []}, + "logs" => {RPC.LogsController, []}, + "token" => {RPC.TokenController, []}, + "stats" => {RPC.StatsController, []}, + "contract" => {RPC.ContractController, [:verify]}, + "transaction" => {RPC.TransactionController, []} + } + |> then(fn options -> + if @chain_identity == {:optimism, :celo} do + Map.put(options, "epoch", {BlockScoutWeb.API.RPC.CeloController, []}) + else + options + end + end) + ) end end end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/chain_type_scope.ex b/apps/block_scout_web/lib/block_scout_web/routers/chain_type_scope.ex index 9d9e5bee4e2e..5285954d528d 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/chain_type_scope.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/chain_type_scope.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Routers.ChainTypeScope do @moduledoc """ Provides macros for defining chain-specific routes that are checked at @@ -15,8 +16,8 @@ defmodule BlockScoutWeb.Routers.ChainTypeScope do ## Examples - chain_scope :polygon_zkevm do - get("/zkevm-batch/:batch_number", V2.TransactionController, :polygon_zkevm_batch) + chain_scope :zksync do + get("/zksync-batch/:batch_number", V2.TransactionController, :zksync_batch) end """ defmacro chain_scope(chain_type, opts \\ [], do: block) do @@ -24,7 +25,7 @@ defmodule BlockScoutWeb.Routers.ChainTypeScope do quote do # Define pipeline if not already defined - unless Module.has_attribute?(__MODULE__, unquote(pipeline_name)) do + if !Module.has_attribute?(__MODULE__, unquote(pipeline_name)) do pipeline unquote(pipeline_name) do plug(BlockScoutWeb.Plug.CheckChainType, unquote(chain_type)) end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex index 0218d273994e..6de2a9d0e8ee 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/smart_contracts_api_v2_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file in ignore list of `sobelow`, be careful while adding new endpoints here defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do @moduledoc """ @@ -7,7 +8,7 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] alias BlockScoutWeb.API.V2 - alias BlockScoutWeb.Plug.{CheckApiV2, RateLimit} + alias BlockScoutWeb.Plug.CheckApiV2 @max_query_string_length 5_000 @@ -25,7 +26,7 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do plug(CheckApiV2) plug(:fetch_session) plug(:protect_from_forgery) - plug(RateLimit) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end pipeline :api_v2_no_forgery_protect do @@ -41,8 +42,8 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(CheckApiV2) - plug(RateLimit) plug(:fetch_session) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end scope "/", as: :api_v2 do @@ -50,8 +51,8 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do get("/", V2.SmartContractController, :smart_contracts_list) get("/counters", V2.SmartContractController, :smart_contracts_counters) - get("/:address_hash", V2.SmartContractController, :smart_contract) - get("/:address_hash/audit-reports", V2.SmartContractController, :audit_reports_list) + get("/:address_hash_param", V2.SmartContractController, :smart_contract) + get("/:address_hash_param/audit-reports", V2.SmartContractController, :audit_reports_list) get("/verification/config", V2.VerificationController, :config) end @@ -59,7 +60,7 @@ defmodule BlockScoutWeb.Routers.SmartContractsApiV2Router do scope "/", as: :api_v2 do pipe_through(:api_v2_no_forgery_protect) - post("/:address_hash/audit-reports", V2.SmartContractController, :audit_report_submission) + post("/:address_hash_param/audit-reports", V2.SmartContractController, :audit_report_submission) end scope "/:address_hash/verification/via", as: :api_v2 do diff --git a/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex index b61781cca79b..fcdaaa391e67 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/tokens_api_v2_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file in ignore list of `sobelow`, be careful while adding new endpoints here defmodule BlockScoutWeb.Routers.TokensApiV2Router do @moduledoc """ @@ -7,7 +8,7 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]] alias BlockScoutWeb.API.V2 - alias BlockScoutWeb.Plug.{CheckApiV2, RateLimit} + alias BlockScoutWeb.Plug.CheckApiV2 @max_query_string_length 5_000 @@ -25,7 +26,7 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do plug(CheckApiV2) plug(:fetch_session) plug(:protect_from_forgery) - plug(RateLimit) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end pipeline :api_v2_no_forgery_protect do @@ -41,14 +42,14 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(CheckApiV2) - plug(RateLimit) plug(:fetch_session) + plug(OpenApiSpex.Plug.PutApiSpec, module: BlockScoutWeb.Specs.Public) end scope "/", as: :api_v2 do pipe_through(:api_v2_no_forgery_protect) - patch("/:address_hash_param/instances/:token_id/refetch-metadata", V2.TokenController, :refetch_metadata) + patch("/:address_hash_param/instances/:token_id_param/refetch-metadata", V2.TokenController, :refetch_metadata) patch( "/:address_hash_param/instances/refetch-metadata", @@ -71,9 +72,14 @@ defmodule BlockScoutWeb.Routers.TokensApiV2Router do get("/:address_hash_param/holders", V2.TokenController, :holders) get("/:address_hash_param/holders/csv", V2.CsvExportController, :export_token_holders) get("/:address_hash_param/instances", V2.TokenController, :instances) - get("/:address_hash_param/instances/:token_id", V2.TokenController, :instance) - get("/:address_hash_param/instances/:token_id/transfers", V2.TokenController, :transfers_by_instance) - get("/:address_hash_param/instances/:token_id/holders", V2.TokenController, :holders_by_instance) - get("/:address_hash_param/instances/:token_id/transfers-count", V2.TokenController, :transfers_count_by_instance) + get("/:address_hash_param/instances/:token_id_param", V2.TokenController, :instance) + get("/:address_hash_param/instances/:token_id_param/transfers", V2.TokenController, :transfers_by_instance) + get("/:address_hash_param/instances/:token_id_param/holders", V2.TokenController, :holders_by_instance) + + get( + "/:address_hash_param/instances/:token_id_param/transfers-count", + V2.TokenController, + :transfers_count_by_instance + ) end end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex index 2e9b8ef66fd3..5f7adcf645a1 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/utils_api_v2_router.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file in ignore list of `sobelow`, be careful while adding new endpoints here defmodule BlockScoutWeb.Routers.UtilsApiV2Router do @moduledoc """ Router for /api/v2/utils. This route has separate router in order to ignore sobelow's warning about missing CSRF protection """ use BlockScoutWeb, :router - alias BlockScoutWeb.Plug.{CheckApiV2, RateLimit} + alias BlockScoutWeb.Plug.CheckApiV2 pipeline :api_v2_no_forgery_protect do plug( @@ -19,7 +20,6 @@ defmodule BlockScoutWeb.Routers.UtilsApiV2Router do plug(BlockScoutWeb.Plug.Logger, application: :api_v2) plug(:accepts, ["json"]) plug(CheckApiV2) - plug(RateLimit) plug(:fetch_session) end diff --git a/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex index 67339e758ced..95158d98af01 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/web_router.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Routers.WebRouter do @moduledoc """ Router for web app diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex new file mode 100644 index 000000000000..e482025dbd30 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/envelope.ex @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.Envelope do + @moduledoc false + + alias OpenApiSpex.Schema + + @doc """ + Returns an OpenAPI schema for the RPC response envelope: + `{"status": "0"|"1"|"2", "message": "...", "result": }`. + """ + @spec rpc_envelope(Schema.t()) :: Schema.t() + def rpc_envelope(result_schema) do + %Schema{ + type: :object, + properties: %{ + status: %Schema{ + type: :string, + enum: ["0", "1", "2"], + description: "`1` = OK, `0` = error, `2` = pending." + }, + message: %Schema{ + type: :string, + description: + "Human-readable status string — `OK` on success, " <> + "a descriptive error message otherwise." + }, + result: %Schema{ + description: "Endpoint-specific payload on success; `null` on error.", + nullable: true, + allOf: [result_schema] + } + }, + required: [:status, :message, :result], + additionalProperties: false + } + end + + @doc """ + Returns an OpenAPI schema for the JSON-RPC 2.0 envelope: + `{"jsonrpc": "2.0", "result": , "id": }`. + """ + @spec eth_rpc_envelope(Schema.t()) :: Schema.t() + def eth_rpc_envelope(result_schema) do + %Schema{ + type: :object, + properties: %{ + jsonrpc: %Schema{ + type: :string, + enum: ["2.0"], + description: "JSON-RPC protocol version, always `2.0`." + }, + result: %Schema{ + description: "Endpoint-specific payload.", + allOf: [result_schema] + }, + id: %Schema{ + anyOf: [%Schema{type: :integer}, %Schema{type: :string}], + description: + "Echoes the request id. When the client omits it, " <> + "the server echoes integer `1`." + } + }, + required: [:jsonrpc, :result, :id], + additionalProperties: false + } + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex new file mode 100644 index 000000000000..32e7ee60d8ba --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/eth_block_number_result.ex @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.EthBlockNumberResult do + @moduledoc false + require OpenApiSpex + + # nullable: true is kept defensively. In practice, BlockNumber.get_max/0 + # delegates to Block.fetch_max_block_number/0 which returns Repo.one(query) || 0 + # (with a rescue that also returns 0), so the v1 code path never produces a null + # result. The field is marked nullable to remain accurate if the underlying + # implementation ever changes. + OpenApiSpex.schema(%{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + nullable: true, + description: + "Hex-encoded latest block number on the chain. " <> + "Nullable in the schema for defensive reasons; always present in practice." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex new file mode 100644 index 000000000000..41115903a959 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/get_block_number_by_time_result.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.GetBlockNumberByTimeResult do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + blockNumber: %Schema{ + description: "Decimal-string block number.", + allOf: [General.IntegerString] + } + }, + required: [:blockNumber], + additionalProperties: false, + nullable: true + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex new file mode 100644 index 000000000000..ac578083497b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/legacy/log_item.ex @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.Legacy.LogItem do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + address: General.AddressHash, + # Each topic slot is nullable: LogsView.get_topics/1 at logs_view.ex:31-38 + # always returns a 4-element list, filling unset slots with nil. A non- + # nullable items schema would fail validation on any real log with <4 topics. + topics: %Schema{ + type: :array, + minItems: 4, + maxItems: 4, + description: "32-byte indexed event topics. Always a 4-element array; unfilled slots are `null`.", + items: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]{64}$/, + nullable: true + } + }, + data: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]*$/, + description: "Hex-encoded event data payload (`0x`-prefixed, arbitrary length)." + }, + blockNumber: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + description: "Hex-encoded block number." + }, + timeStamp: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + description: "Hex-encoded Unix timestamp in seconds of the block." + }, + gasPrice: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + description: "Hex-encoded gas price in wei." + }, + gasUsed: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + description: "Hex-encoded gas used by the transaction." + }, + logIndex: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + description: "Hex-encoded position of the log within the block." + }, + transactionHash: General.FullHash, + transactionIndex: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-fA-F]+$/, + description: "Hex-encoded position of the transaction within the block." + } + }, + required: [ + :address, + :topics, + :data, + :blockNumber, + :timeStamp, + :gasPrice, + :gasUsed, + :logIndex, + :transactionHash, + :transactionIndex + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account.ex new file mode 100644 index 000000000000..cbd6f0271a61 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account.ex @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Account do + @moduledoc """ + Provides OpenAPI specification schemas for account-related API V2 endpoints. + + This module defines request body and response schemas used by the account + authentication endpoints. It supports two authentication flows: + + - **Email OTP authentication**: Users request a one-time password via email + and confirm it to authenticate + - **Wallet authentication**: Users authenticate using Sign-In With Ethereum + (SIWE) by signing a message with their wallet + + The schemas defined here are used with OpenApiSpex to document and validate + the API request and response payloads in the BlockScout API documentation. + """ + + @spec send_otp_request_body() :: OpenApiSpex.RequestBody.t() + def send_otp_request_body do + %OpenApiSpex.RequestBody{ + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + email: %OpenApiSpex.Schema{type: :string, format: :email} + }, + required: [ + :email + ] + } + } + } + } + end + + @spec authenticate_via_wallet_request_body() :: OpenApiSpex.RequestBody.t() + def authenticate_via_wallet_request_body do + %OpenApiSpex.RequestBody{ + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + message: %OpenApiSpex.Schema{type: :string}, + signature: %OpenApiSpex.Schema{type: :string} + }, + required: [:message, :signature] + } + } + } + } + end + + @spec confirm_otp_request_body() :: OpenApiSpex.RequestBody.t() + def confirm_otp_request_body do + %OpenApiSpex.RequestBody{ + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + email: %OpenApiSpex.Schema{type: :string, format: :email}, + otp: %OpenApiSpex.Schema{type: :string} + }, + required: [ + :email, + :otp + ] + } + } + } + } + end + + @spec siwe_message_response_schema() :: OpenApiSpex.Schema.t() + def siwe_message_response_schema do + %OpenApiSpex.Schema{ + type: :object, + properties: %{ + siwe_message: %OpenApiSpex.Schema{type: :string} + }, + required: [:siwe_message] + } + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account/session.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account/session.ex new file mode 100644 index 000000000000..974bb81a1fb2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account/session.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Account.Session do + @moduledoc """ + This module defines the schema for the account session. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General.AddressHashNullable + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: %Schema{type: :integer, minimum: 0, nullable: false}, + uid: %Schema{type: :string, nullable: false}, + email: %Schema{type: :string, format: :email, nullable: true}, + name: %Schema{type: :string, nullable: true}, + nickname: %Schema{type: :string, nullable: true}, + avatar: %Schema{type: :string, format: :uri, nullable: true}, + address_hash: AddressHashNullable, + watchlist_id: %Schema{type: :integer, minimum: 0, nullable: false}, + email_verified: %Schema{type: :boolean, nullable: false} + }, + required: [:id, :uid, :email, :nickname, :avatar, :address_hash, :email_verified], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account/user.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account/user.ex new file mode 100644 index 000000000000..edc2e0b02c3a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/account/user.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Account.User do + @moduledoc """ + This module defines the schema for the user. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General.AddressHashNullable + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + email: %Schema{type: :string, format: :email, nullable: true}, + name: %Schema{type: :string, nullable: true}, + nickname: %Schema{type: :string, nullable: true}, + avatar: %Schema{type: :string, format: :uri, nullable: true}, + address_hash: AddressHashNullable + }, + required: [:email, :name, :nickname, :avatar, :address_hash], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex new file mode 100644 index 000000000000..81d8c6c3ed88 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address.ex @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.Helper + alias Ecto.Enum, as: EctoEnum + alias Explorer.Chain.Address + alias OpenApiSpex.Schema + + @filecoin_robust_address_schema %Schema{ + type: :string, + description: "Robust f0/f1/f2/f3/f4 Filecoin address", + example: "f25nml2cfbljvn4goqtclhifepvfnicv6g7mfmmvq", + nullable: true + } + + @doc """ + OpenAPI schema for Filecoin robust address. + """ + @spec filecoin_robust_address_schema() :: Schema.t() + def filecoin_robust_address_schema, do: @filecoin_robust_address_schema + + def chain_type_fields(schema) do + case Application.get_env(:explorer, :chain_type) do + :filecoin -> + schema + |> Helper.extend_schema( + properties: %{ + filecoin: %Schema{ + type: :object, + properties: %{ + id: %Schema{ + type: :string, + description: "Short f0 Filecoin address that may change during chain reorgs", + example: "f03248220", + nullable: true + }, + robust: @filecoin_robust_address_schema, + actor_type: %Schema{ + type: :string, + description: "Type of actor associated with the Filecoin address", + enum: EctoEnum.values(Address, :filecoin_actor_type), + nullable: true + } + }, + required: [:id, :robust, :actor_type], + additionalProperties: false + } + } + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Address do + @moduledoc """ + This module defines the schema for address struct, returned by BlockScoutWeb.API.V2.Helper.address_with_info/5. + + Note that BlockScoutWeb.Schemas.API.V2.Address.Response is defined in __after_compile__/2 callback. This is done to reuse the Address schema in the AddressResponse schema. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations + alias BlockScoutWeb.Schemas.API.V2.{General, Proxy} + alias Explorer.Chain.Address.Reputation + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + description: "Address", + type: :object, + properties: %{ + hash: General.AddressHash, + is_contract: %Schema{type: :boolean, description: "Has address contract code?", nullable: true}, + name: %Schema{type: :string, description: "Name associated with the address", nullable: true}, + is_scam: %Schema{type: :boolean, description: "Has address scam badge?", nullable: false}, + reputation: %Schema{ + type: :string, + enum: Reputation.enum_values(), + description: "Reputation of the address", + nullable: false + }, + proxy_type: General.ProxyType, + implementations: %Schema{ + description: "Implementations linked with the contract", + type: :array, + items: General.Implementation + }, + is_verified: %Schema{type: :boolean, description: "Has address associated source code?", nullable: true}, + ens_domain_name: %Schema{ + type: :string, + description: "ENS domain name associated with the address", + nullable: true + }, + metadata: %Schema{allOf: [Proxy.Metadata], nullable: true}, + private_tags: %Schema{ + description: "Private tags associated with the address", + type: :array, + items: General.Tag + }, + watchlist_names: %Schema{ + description: "Watchlist name associated with the address", + type: :array, + items: General.WatchlistName + }, + public_tags: %Schema{ + description: "Public tags associated with the address", + type: :array, + items: General.Tag + } + }, + required: [ + :hash, + :is_contract, + :name, + :is_scam, + :reputation, + :proxy_type, + :implementations, + :is_verified, + :ens_domain_name, + :metadata + ], + additionalProperties: false + } + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/counters.ex new file mode 100644 index 000000000000..30d3eb1be57c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/counters.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Address.Counters do + @moduledoc """ + This module defines the schema for address counters. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + title: "AddressCounters", + description: "Address counters", + type: :object, + properties: %{ + transactions_count: General.IntegerString, + token_transfers_count: General.IntegerString, + gas_usage_count: General.IntegerString, + validations_count: General.IntegerString + }, + required: [:transactions_count, :token_transfers_count, :gas_usage_count, :validations_count], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/response.ex new file mode 100644 index 000000000000..61179c58b5a9 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/response.ex @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Address.Response.ChainTypeCustomizations do + @moduledoc false + require OpenApiSpex + import BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations, only: [filecoin_robust_address_schema: 0] + + alias BlockScoutWeb.Schemas.{API.V2.Address, Helper} + alias OpenApiSpex.Schema + + use Utils.RuntimeEnvHelper, chain_identity: [:explorer, :chain_identity] + + @zilliqa_schema %Schema{ + type: :object, + properties: %{ + is_scilla_contract: %Schema{type: :boolean, nullable: false} + }, + required: [:is_scilla_contract], + additionalProperties: false + } + + @celo_schema %Schema{ + type: :object, + properties: %{ + account: %Schema{ + type: :object, + nullable: true, + properties: %{ + type: %Schema{ + type: :string, + enum: [:regular, :validator, :group], + nullable: false + }, + name: %Schema{type: :string, nullable: true}, + metadata_url: %Schema{type: :string, nullable: true}, + nonvoting_locked_celo: %Schema{type: :string, nullable: false}, + locked_celo: %Schema{type: :string, nullable: false}, + vote_signer_address: %Schema{allOf: [Address], nullable: true}, + validator_signer_address: %Schema{allOf: [Address], nullable: true}, + attestation_signer_address: %Schema{allOf: [Address], nullable: true} + }, + required: [ + :type, + :name, + :metadata_url, + :nonvoting_locked_celo, + :locked_celo, + :vote_signer_address, + :validator_signer_address, + :attestation_signer_address + ], + example: %{ + type: "validator", + name: "Celo Validator", + metadata_url: "https://example.com/metadata", + nonvoting_locked_celo: "1000000000000000000", + locked_celo: "2000000000000000000", + vote_signer_address: nil, + validator_signer_address: nil, + attestation_signer_address: nil + } + } + }, + required: [:account], + additionalProperties: false + } + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + @spec chain_type_fields(map()) :: map() + def chain_type_fields(schema) do + case chain_identity() do + {:filecoin, nil} -> + schema + |> Helper.extend_schema( + properties: %{creator_filecoin_robust_address: filecoin_robust_address_schema()}, + required: [:creator_filecoin_robust_address] + ) + + {:zilliqa, nil} -> + schema + |> Helper.extend_schema( + properties: %{zilliqa: @zilliqa_schema}, + required: [:zilliqa] + ) + + {:optimism, :celo} -> + schema + |> Helper.extend_schema( + properties: %{celo: @celo_schema}, + required: [:celo] + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Address.Response do + @moduledoc """ + This module defines the schema for address response from /api/v2/addresses/:hash. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias BlockScoutWeb.Schemas.API.V2.Address.Response.ChainTypeCustomizations + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Address.schema() + |> Helper.extend_schema( + title: "AddressResponse", + description: "Address response", + properties: %{ + creator_address_hash: General.AddressHashNullable, + creation_transaction_hash: General.FullHashNullable, + token: %Schema{allOf: [Token], nullable: true}, + coin_balance: General.IntegerStringNullable, + exchange_rate: General.FloatStringNullable, + block_number_balance_updated_at: %Schema{type: :integer, minimum: 0, nullable: true}, + has_validated_blocks: %Schema{type: :boolean, nullable: false}, + has_logs: %Schema{type: :boolean, nullable: false}, + has_tokens: %Schema{type: :boolean, nullable: false}, + has_token_transfers: %Schema{type: :boolean, nullable: false}, + watchlist_address_id: %Schema{type: :integer, nullable: true}, + has_beacon_chain_withdrawals: %Schema{type: :boolean, nullable: false}, + creation_status: %Schema{ + type: :string, + description: "Creation status of the contract", + enum: ["success", "failed", "selfdestructed"], + nullable: true + } + }, + required: [ + :creator_address_hash, + :creation_transaction_hash, + :token, + :coin_balance, + :exchange_rate, + :block_number_balance_updated_at, + :has_validated_blocks, + :has_logs, + :has_tokens, + :has_token_transfers, + :watchlist_address_id, + :has_beacon_chain_withdrawals, + :creation_status + ] + ) + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex new file mode 100644 index 000000000000..aef8224b8026 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/tabs_counters.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Address.TabsCounters do + @moduledoc """ + This module defines the schema for the Address.TabsCounters struct. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AddressTabsCounters", + description: "Counters for address tabs", + type: :object, + properties: %{ + transactions_count: %Schema{type: :integer, nullable: false}, + token_transfers_count: %Schema{type: :integer, nullable: false}, + token_balances_count: %Schema{type: :integer, nullable: false}, + logs_count: %Schema{type: :integer, nullable: false}, + withdrawals_count: %Schema{type: :integer, nullable: false}, + internal_transactions_count: %Schema{type: :integer, nullable: false}, + validations_count: %Schema{type: :integer, nullable: false}, + celo_election_rewards_count: %Schema{type: :integer, nullable: false}, + beacon_deposits_count: %Schema{type: :integer, nullable: false} + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/token_balance.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/token_balance.ex new file mode 100644 index 000000000000..f36d3b1fc13b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/token_balance.ex @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Address.TokenBalance do + @moduledoc """ + This module defines the schema for the TokenBalance struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, Token, TokenInstance} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + value: General.IntegerString, + token: %Schema{allOf: [Token], nullable: true}, + token_id: General.IntegerStringNullable, + token_instance: %Schema{allOf: [TokenInstance], nullable: true} + }, + required: [:value, :token, :token_id, :token_instance], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/top_address.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/top_address.ex new file mode 100644 index 000000000000..eb3c3793681e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address/top_address.ex @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Address.TopAddress do + @moduledoc """ + Schema for an item in the top-accounts list (`GET /api/v2/addresses`): + the base `Address` plus its native-coin balance and transactions count. + + Built by extending `Address.schema()` (rather than referencing `Address` via + `allOf`) because `Address` is a closed object (`additionalProperties: false`); + a composition branch would reject the extra fields during response validation. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Address.schema() + |> Helper.extend_schema( + title: "TopAddress", + description: "Address holding native coin, with its balance and transactions count", + properties: %{ + coin_balance: General.IntegerStringNullable, + transactions_count: %Schema{ + anyOf: [ + General.IntegerString, + # TODO: replace empty string with null? + General.EmptyString + ], + nullable: true + } + }, + required: [:coin_balance, :transactions_count] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address_nullable.ex new file mode 100644 index 000000000000..406a70686008 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/address_nullable.ex @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.AddressNullable do + @moduledoc """ + This module defines the schema for nullable address struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Address + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Address.schema() + |> Helper.extend_schema( + title: "AddressNullable", + description: "AddressNullable", + nullable: true + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex new file mode 100644 index 000000000000..90238b06d5b4 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter.ex @@ -0,0 +1,277 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter do + @moduledoc """ + Schema for a single item returned by the `/api/v2/advanced-filters` endpoint. + + Each item represents one unit of on-chain activity (a native value transfer, + an internal transaction or a token transfer) projected through the advanced + filters pipeline. See `Explorer.Chain.AdvancedFilter`. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token, TokenTransfer} + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AdvancedFilterItem", + type: :object, + properties: %{ + hash: General.FullHash, + # Keep this enum in sync with `Explorer.Chain.AdvancedFilter.assign_type/1` + # (the `coin_transfer`/`contract_interaction`/`contract_creation` branches) and + # `Explorer.Chain.Token.valid_types/0` (the `ERC-*` token-transfer labels). + type: %Schema{ + type: :string, + enum: [ + "coin_transfer", + "contract_interaction", + "contract_creation", + "ERC-20", + "ERC-721", + "ERC-1155", + "ERC-404", + "ERC-7984" + ], + description: + "Kind of activity represented by the item. Values `coin_transfer`, `contract_interaction`, and " <> + "`contract_creation` apply to top-level transactions and internal transactions; the `ERC-*` " <> + "values apply to token transfers.", + nullable: false + }, + status: %Schema{ + type: :string, + description: + "Execution status of the parent transaction. One of `pending`, `awaiting_internal_transactions`, " <> + "`success`, or a free-form error reason string when the transaction reverted (e.g. `Reverted`).", + nullable: false + }, + method: General.MethodNameNullable, + from: %Schema{ + allOf: [Address], + nullable: true, + description: "Sender address. `null` for contract-creation items." + }, + to: %Schema{ + allOf: [Address], + nullable: true, + description: "Recipient address. `null` for contract-creation items and some internal transactions." + }, + created_contract: %Schema{ + allOf: [Address], + nullable: true, + description: "Address of the contract deployed by this item. `null` unless the item is a contract creation." + }, + value: %Schema{ + type: :string, + pattern: General.non_negative_integer_pattern(), + nullable: true, + description: + "Native coin amount transferred, in the chain's base unit (e.g. wei). `null` for token-transfer items." + }, + total: %Schema{ + anyOf: [ + TokenTransfer.TotalERC721, + TokenTransfer.TotalERC1155, + TokenTransfer.TotalERC7984, + TokenTransfer.Total + ], + description: + "Token transfer amount (or token id for NFTs). Populated only for token-transfer items; `null` otherwise.", + nullable: true + }, + token: %Schema{ + allOf: [Token], + nullable: true, + description: "Token contract metadata. Populated only for token-transfer items; `null` otherwise." + }, + timestamp: + Helper.extend_schema(General.Timestamp.schema(), + description: "Block timestamp of the parent transaction." + ), + block_number: %Schema{ + type: :integer, + minimum: 0, + nullable: false, + description: "Number of the block that contains the parent transaction." + }, + transaction_index: %Schema{ + type: :integer, + minimum: 0, + nullable: false, + description: "Zero-based position of the parent transaction within its block." + }, + internal_transaction_index: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: + "Zero-based position of the internal transaction within its parent transaction. Populated only for " <> + "internal-transaction items; `null` otherwise." + }, + token_transfer_index: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: + "Zero-based position of the token transfer, unique per parent transaction. Populated only for " <> + "token-transfer items; `null` otherwise." + }, + token_transfer_batch_index: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: + "Zero-based position within an ERC-1155 batch token transfer. Populated only for items that belong " <> + "to a batch; `null` otherwise." + }, + fee: %Schema{ + type: :string, + pattern: General.non_negative_integer_pattern(), + nullable: false, + description: "Transaction fee paid by the sender, in the chain's base unit (e.g. wei)." + } + }, + required: [ + :hash, + :type, + :status, + :method, + :from, + :to, + :created_contract, + :value, + :total, + :token, + :timestamp, + :block_number, + :transaction_index, + :internal_transaction_index, + :token_transfer_index, + :token_transfer_batch_index, + :fee + ], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter.SearchParams do + @moduledoc """ + Auxiliary lookup map returned alongside advanced-filter items that echoes + the resolved human-readable names of the `methods` and `tokens` used in the + request filters. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Token + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AdvancedFilterSearchParams", + type: :object, + properties: %{ + methods: %Schema{ + type: :object, + description: + "Map of 4-byte method selectors (keys) to resolved method names (values) for the `methods` filter.", + additionalProperties: %Schema{type: :string, nullable: false} + }, + tokens: %Schema{ + type: :object, + description: + "Map of token contract address hashes (keys) to `Token` objects for tokens referenced in the " <> + "`token_contract_address_hashes_to_include`/`_exclude` filters. At most 20 entries are returned " <> + "(combined across both lists).", + additionalProperties: Token + } + }, + required: [:methods, :tokens], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter.Response do + @moduledoc """ + Schema for the paginated response returned by the `/api/v2/advanced-filters` endpoint. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.AdvancedFilter + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AdvancedFilterResponse", + type: :object, + properties: %{ + items: %Schema{type: :array, items: AdvancedFilter, nullable: false}, + search_params: AdvancedFilter.SearchParams, + next_page_params: %Schema{ + type: :object, + nullable: true, + additionalProperties: true, + example: %{ + "block_number" => 23_532_302, + "transaction_index" => 1, + "internal_transaction_index" => nil, + "token_transfer_index" => 0, + "token_transfer_batch_index" => nil, + "items_count" => 50 + } + } + }, + required: [:items, :search_params, :next_page_params], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter.CsvExportAccepted do + @moduledoc """ + Schema for the 202 Accepted JSON body returned by + `/api/v2/advanced-filters/csv` when asynchronous CSV export is enabled. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AdvancedFilterCsvExportAccepted", + description: + "Body returned when an asynchronous CSV export job has been queued. " <> + "Poll `/api/v2/csv-exports/{request_id}` with the returned `request_id` to check status.", + type: :object, + properties: %{ + request_id: %Schema{ + type: :string, + format: :uuid, + description: "UUID of the queued export request.", + nullable: false + } + }, + required: [:request_id], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter.CsvExportError do + @moduledoc """ + Schema for the JSON error body returned by `/api/v2/advanced-filters/csv` + when the asynchronous export job cannot be created (HTTP 409 or 500). + + Note: uses an `error` key rather than the `message` key carried by the + reusable `ErrorResponses.*` schemas — the CSV controller predates that + convention. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AdvancedFilterCsvExportError", + type: :object, + properties: %{ + error: %Schema{type: :string, nullable: false, description: "Human-readable error description."} + }, + required: [:error], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter/method.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter/method.ex new file mode 100644 index 000000000000..54cca53a200a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/advanced_filter/method.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.AdvancedFilter.Method do + @moduledoc "Schema for a contract method (id + name pair) returned by the advanced filters methods endpoint." + + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "AdvancedFilterMethod", + description: "Contract method identified by its 4-byte selector and human-readable name.", + type: :object, + required: [:method_id, :name], + properties: %{ + method_id: %Schema{ + type: :string, + pattern: ~r/^0x[0-9a-f]{8}$/, + description: "4-byte method selector prefixed with 0x (lowercase hex).", + example: "0xa9059cbb" + }, + name: %Schema{ + type: :string, + description: "Human-readable method name. Empty string if the name could not be resolved.", + example: "transfer" + } + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex new file mode 100644 index 000000000000..473af454e340 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch.ex @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Batch do + @moduledoc """ + Schema for a detailed Arbitrum batch response. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.CommitmentTransaction + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Detailed Arbitrum batch info.", + type: :object, + properties: %{ + number: %Schema{ + type: :integer, + minimum: 0, + description: "Sequential identifier assigned to this batch by the sequencer." + }, + transactions_count: %Schema{type: :integer, minimum: 0, description: "Number of transactions in the batch."}, + start_block_number: %Schema{ + type: :integer, + minimum: 0, + description: "First Rollup block included in the batch." + }, + end_block_number: %Schema{ + type: :integer, + minimum: 0, + description: "Last Rollup block included in the batch." + }, + before_acc_hash: %Schema{ + allOf: [General.FullHash], + description: + "Accumulator hash of the sequencer inbox before this batch was appended. " <> + "Forms a hash chain: must equal `after_acc_hash` of the previous batch." + }, + after_acc_hash: %Schema{ + allOf: [General.FullHash], + description: + "Accumulator hash of the sequencer inbox after this batch was appended. " <> + "Must equal `before_acc_hash` of the next batch." + }, + commitment_transaction: CommitmentTransaction, + data_availability: %Schema{ + oneOf: [ + DataAvailability.Base, + DataAvailability.Anytrust, + DataAvailability.Celestia, + DataAvailability.Eigenda + ], + description: "Data availability information. Structure varies by `batch_data_container` type." + } + }, + required: [ + :number, + :transactions_count, + :start_block_number, + :end_block_number, + :before_acc_hash, + :after_acc_hash, + :commitment_transaction, + :data_availability + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex new file mode 100644 index 000000000000..08fa523e5b57 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_anytrust.ex @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchByAnytrust do + @moduledoc """ + Arbitrum batch schema narrowed to AnyTrust data availability. + + Extends `Batch` by constraining `data_availability` to the AnyTrust variant only. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.Batch + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "Arbitrum.BatchByAnytrust", + description: "Arbitrum batch with AnyTrust data availability.", + properties: %{ + data_availability: DataAvailability.Anytrust + } + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex new file mode 100644 index 000000000000..f0ecb2bda19a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_celestia.ex @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchByCelestia do + @moduledoc """ + Arbitrum batch schema narrowed to Celestia data availability. + + Extends `Batch` by constraining `data_availability` to the Celestia variant only. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.Batch + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "Arbitrum.BatchByCelestia", + description: "Arbitrum batch with Celestia data availability.", + properties: %{ + data_availability: DataAvailability.Celestia + } + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex new file mode 100644 index 000000000000..4210d096ff1e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_by_eigenda.ex @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchByEigenda do + @moduledoc """ + Arbitrum batch schema narrowed to EigenDA data availability. + + Extends `Batch` by constraining `data_availability` to the EigenDA variant only. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.Batch + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + title: "Arbitrum.BatchByEigenda", + description: "Arbitrum batch with EigenDA data availability.", + properties: %{ + data_availability: DataAvailability.Eigenda + } + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex new file mode 100644 index 000000000000..a974c74082b3 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_data_container.ex @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchDataContainer do + @moduledoc """ + Data availability container type for Arbitrum batches. + """ + require OpenApiSpex + + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. + OpenApiSpex.schema(%{ + type: :string, + enum: ["in_blob4844", "in_calldata", "in_celestia", "in_anytrust", "in_eigenda"], + nullable: true, + description: "Data availability container type." + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex new file mode 100644 index 000000000000..85be537b7ad4 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/batch_for_list.ex @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.BatchForList do + @moduledoc """ + Schema for an Arbitrum batch item in list responses. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.{BatchDataContainer, CommitmentTransaction} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Arbitrum batch summary for list endpoints.", + type: :object, + properties: %{ + number: %Schema{ + type: :integer, + minimum: 0, + description: "Sequential identifier assigned to this batch by the sequencer." + }, + transactions_count: %Schema{type: :integer, minimum: 0, description: "Number of transactions in the batch."}, + blocks_count: %Schema{type: :integer, minimum: 0, description: "Number of blocks included in the batch."}, + batch_data_container: BatchDataContainer, + commitment_transaction: CommitmentTransaction + }, + required: [:number, :transactions_count, :blocks_count, :batch_data_container, :commitment_transaction], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex new file mode 100644 index 000000000000..a6b9cd3894ab --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/claim_message.ex @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.ClaimMessage do + @moduledoc "Schema for Arbitrum claim message response." + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "Arbitrum.ClaimMessage", + description: "Calldata and outbox contract address needed to execute a withdrawal on the Parent chain.", + type: :object, + required: [:calldata, :outbox_address_hash], + properties: %{ + calldata: %Schema{type: :string, description: "ABI-encoded calldata for the executeTransaction call."}, + outbox_address_hash: %Schema{ + allOf: [General.AddressHash], + description: + "Address of the Arbitrum Outbox contract on the Parent chain through which the withdrawal is executed." + } + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex new file mode 100644 index 000000000000..89f56b258394 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/commitment_transaction.ex @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.CommitmentTransaction do + @moduledoc """ + Parent chain transaction that committed a batch. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Parent chain transaction that committed the batch.", + type: :object, + properties: %{ + hash: General.FullHashNullable, + block_number: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: "Parent chain block number containing this transaction." + }, + timestamp: General.TimestampNullable, + status: %Schema{ + type: :string, + enum: ["unfinalized", "finalized"], + nullable: true, + description: "Finalization status of the Parent chain transaction." + } + }, + required: [:hash, :block_number, :timestamp, :status], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex new file mode 100644 index 000000000000..bba2c7f29495 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/anytrust.ex @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Anytrust do + @moduledoc """ + Data availability information for batches stored via AnyTrust committee. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "AnyTrust data availability certificate.", + type: :object, + properties: %{ + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. + batch_data_container: %Schema{type: :string, enum: ["in_anytrust"]}, + data_hash: %Schema{type: :string, nullable: true, description: "AnyTrust data hash."}, + timeout: %Schema{type: :string, nullable: true, description: "Data availability timeout (ISO 8601)."}, + bls_signature: %Schema{ + type: :string, + nullable: true, + description: "Aggregated BLS signature of committee members." + }, + signers: %Schema{ + type: :array, + description: "Committee members who guaranteed data availability.", + items: %Schema{ + type: :object, + properties: %{ + trusted: %Schema{type: :boolean, description: "Whether the signer is a trusted member."}, + key: %Schema{type: :string, description: "BLS public key."}, + proof: %Schema{type: :string, description: "Proof of possession (absent for trusted members)."} + }, + required: [:trusted, :key], + additionalProperties: false + } + } + }, + required: [:batch_data_container, :data_hash, :timeout, :bls_signature, :signers], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex new file mode 100644 index 000000000000..d13a70df6e90 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/base.ex @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Base do + @moduledoc """ + Data availability information for batches using base container types + (EIP-4844 blobs, calldata, or none). + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Data availability info for batches posted via EIP-4844 blobs, calldata, or with no DA.", + type: :object, + properties: %{ + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. + batch_data_container: %Schema{ + type: :string, + enum: ["in_blob4844", "in_calldata"], + nullable: true, + description: "Data availability container type." + } + }, + required: [:batch_data_container], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex new file mode 100644 index 000000000000..3098fd10953c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/celestia.ex @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Celestia do + @moduledoc """ + Data availability information for batches stored on Celestia. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Celestia data availability blob reference.", + type: :object, + properties: %{ + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. + batch_data_container: %Schema{type: :string, enum: ["in_celestia"]}, + height: %Schema{type: :integer, nullable: true, description: "Celestia block height."}, + transaction_commitment: %Schema{ + type: :string, + nullable: true, + description: "Celestia transaction commitment hash." + } + }, + required: [:batch_data_container, :height, :transaction_commitment], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex new file mode 100644 index 000000000000..0d55d63ea2ed --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/data_availability/eigenda.ex @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.DataAvailability.Eigenda do + @moduledoc """ + Data availability information for batches stored via EigenDA. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "EigenDA data availability blob reference.", + type: :object, + properties: %{ + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.L1Batch :batch_container field. + batch_data_container: %Schema{type: :string, enum: ["in_eigenda"]}, + blob_header: %Schema{ + type: :string, + nullable: true, + description: "ABI-encoded EigenDA blob header." + }, + blob_verification_proof: %Schema{ + type: :string, + nullable: true, + description: "ABI-encoded EigenDA blob verification proof." + } + }, + required: [:batch_data_container, :blob_header, :blob_verification_proof], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex new file mode 100644 index 000000000000..362ad5106aa5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/message.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Message do + @moduledoc """ + Full Arbitrum cross-chain message schema. + + Extends `MinimalMessage` with: `id`, `origination_address_hash`, `status`. + """ + + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Arbitrum.MinimalMessage + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + MinimalMessage.schema() + |> Helper.extend_schema( + title: "Arbitrum.Message", + description: "Full Arbitrum cross-chain message.", + properties: %{ + id: %Schema{ + type: :integer, + minimum: 0, + description: "Unique cross-chain message identifier assigned by the protocol." + }, + origination_address_hash: %Schema{ + allOf: [General.AddressHashNullable], + description: "Address that initiated the message on the originating chain." + }, + # Enum values must be kept in sync with Explorer.Chain.Arbitrum.Message :status field. + status: %Schema{ + type: :string, + enum: ["initiated", "sent", "confirmed", "relayed"], + description: + "Cross-chain message lifecycle. For Rollup→Parent messages: " <> + "`initiated` (L2ToL1Tx event emitted on Rollup) → " <> + "`sent` (included in a batch committed to Parent chain) → " <> + "`confirmed` (batch state root posted to the Outbox contract) → " <> + "`relayed` (executed on Parent chain). " <> + "For Parent→Rollup messages only `initiated` and `relayed` apply." + } + }, + required: [:id, :origination_address_hash, :status] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex new file mode 100644 index 000000000000..ba6c469d4a72 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/minimal_message.ex @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.MinimalMessage do + @moduledoc """ + Minimal Arbitrum cross-chain message schema with origination and completion fields only. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Minimal Arbitrum cross-chain message with origination and completion fields.", + type: :object, + properties: %{ + origination_transaction_hash: %Schema{ + allOf: [General.FullHashNullable], + description: "Hash of the transaction on the originating chain that initiated this message." + }, + origination_timestamp: General.TimestampNullable, + origination_transaction_block_number: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: "Block number on the originating chain containing the initiation transaction." + }, + completion_transaction_hash: %Schema{ + allOf: [General.FullHashNullable], + description: "Hash of the transaction on the destination chain that executed this message." + } + }, + required: [ + :origination_transaction_hash, + :origination_timestamp, + :origination_transaction_block_number, + :completion_transaction_hash + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex new file mode 100644 index 000000000000..815da48af1d4 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/arbitrum/withdrawal.ex @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Arbitrum.Withdrawal do + @moduledoc "Schema for an Arbitrum Rollup withdrawal message (L2ToL1Tx event)." + + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Arbitrum.Withdrawal", + description: "Arbitrum Rollup withdrawal message.", + type: :object, + required: [ + :id, + :status, + :caller_address_hash, + :destination_address_hash, + :arb_block_number, + :eth_block_number, + :l2_timestamp, + :callvalue, + :data, + :token, + :completion_transaction_hash + ], + properties: %{ + id: %Schema{type: :integer, minimum: 0, description: "Withdrawal message ID."}, + # Status values must be kept in sync with Explorer.Arbitrum.Withdraw :status type. + status: %Schema{ + type: :string, + enum: ["unknown", "initiated", "sent", "confirmed", "relayed"], + description: + "Withdrawal lifecycle status. " <> + "Progresses from `initiated` (L2ToL1Tx event emitted) through `sent` (included in an RBlock) " <> + "and `confirmed` (RBlock confirmed on Parent chain) to `relayed` (executed on Parent chain). " <> + "`unknown` indicates the status could not be determined, e.g. when the Parent chain RPC is unavailable." + }, + caller_address_hash: %Schema{ + allOf: [General.AddressHash], + description: "Address of the account that initiated the withdrawal on the Rollup." + }, + destination_address_hash: %Schema{ + allOf: [General.AddressHash], + description: "Recipient address on the Parent chain that will receive funds when the withdrawal is executed." + }, + arb_block_number: %Schema{type: :integer, minimum: 0, description: "Rollup block number."}, + eth_block_number: %Schema{type: :integer, minimum: 0, description: "Parent chain block number."}, + l2_timestamp: %Schema{type: :integer, minimum: 0, description: "Unix timestamp of the originating transaction."}, + callvalue: %Schema{ + allOf: [General.IntegerString], + description: "Native coin amount in wei attached to the withdrawal message." + }, + data: %Schema{ + allOf: [General.HexString], + description: + "ABI-encoded calldata passed to the destination address when the withdrawal is executed on the Parent chain. " <> + "Empty (`0x`) for plain native coin transfers." + }, + token: %Schema{ + type: :object, + nullable: true, + description: + "Token withdrawal details. Present when the withdrawal is for a bridged token, null for native coin.", + properties: %{ + address_hash: %Schema{ + allOf: [General.AddressHashNullable], + description: "Token contract address on the Parent chain." + }, + destination_address_hash: %Schema{ + allOf: [General.AddressHashNullable], + description: "Token recipient address on the Parent chain." + }, + amount: %Schema{ + allOf: [General.IntegerString], + description: "Token amount in the token's smallest unit." + }, + decimals: %Schema{type: :integer, minimum: 0, nullable: true}, + name: %Schema{type: :string, nullable: true}, + symbol: %Schema{type: :string, nullable: true} + }, + required: [:address_hash, :destination_address_hash, :amount, :decimals, :name, :symbol], + additionalProperties: false + }, + completion_transaction_hash: General.FullHashNullable + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex new file mode 100644 index 000000000000..f43bcc2b266e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit.ex @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Beacon.Deposit do + @moduledoc """ + This module defines the schema for the Beacon.Deposit struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, AddressNullable, Beacon.Deposit.Status, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + index: %Schema{type: :integer, nullable: false}, + transaction_hash: General.FullHash, + block_hash: General.FullHash, + block_number: %Schema{type: :integer, nullable: false}, + block_timestamp: General.Timestamp, + pubkey: %Schema{type: :string, pattern: ~r"^0x([A-Fa-f0-9]{96})$", nullable: false}, + withdrawal_credentials: %Schema{type: :string, pattern: General.full_hash_pattern(), nullable: false}, + withdrawal_address: AddressNullable, + amount: General.IntegerString, + signature: %Schema{type: :string, pattern: ~r"^0x([A-Fa-f0-9]{192})$", nullable: false}, + status: Status, + from_address: Address + }, + required: [ + :index, + :transaction_hash, + :block_hash, + :block_number, + :block_timestamp, + :pubkey, + :withdrawal_credentials, + :amount, + :signature, + :status, + :from_address + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/response.ex new file mode 100644 index 000000000000..79329bf0a4c3 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/response.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Beacon.Deposit.Response do + @moduledoc """ + This module defines the schema for beacon deposit response from /api/v2/transactions/:transaction_hash_param/beacon/deposits. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Beacon.Deposit + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Deposit.schema() + |> Helper.extend_schema( + title: "BeaconDepositResponse", + description: "BeaconDeposit response" + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex new file mode 100644 index 000000000000..8a9a8dc59c2e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/beacon/deposit/status.ex @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Beacon.Deposit.Status do + @moduledoc false + require OpenApiSpex + + alias Explorer.Chain.Beacon.Deposit + + OpenApiSpex.schema(%{title: "BeaconDepositStatus", type: :string, nullable: false, enum: Deposit.statuses()}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex new file mode 100644 index 000000000000..c38f4173def4 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob.ex @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Blob do + @moduledoc "OpenAPI schema for Blob responses." + + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + blob_data: General.HexString, + hash: General.FullHash, + kzg_commitment: General.HexString, + kzg_proof: General.HexString + }, + required: [:blob_data, :hash, :kzg_commitment, :kzg_proof], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob/response.ex new file mode 100644 index 000000000000..669dcfde0277 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/blob/response.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Blob.Response do + @moduledoc """ + This module defines the schema for blob response from /api/v2/transactions/:transaction_hash_param/blobs. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Blob + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Blob.schema() + |> Helper.extend_schema( + title: "BlobResponse", + description: "Blob response" + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex new file mode 100644 index 000000000000..d91499bd7cef --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex @@ -0,0 +1,550 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Block.ChainTypeCustomizations do + @moduledoc false + alias BlockScoutWeb.API.V2.ZkSyncView + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + @zksync_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + batch_number: %Schema{type: :integer, nullable: true}, + status: %Schema{ + type: :string, + enum: ZkSyncView.batch_status_enum(), + nullable: false + }, + commit_transaction_hash: General.FullHashNullable, + commit_transaction_timestamp: General.TimestampNullable, + prove_transaction_hash: General.FullHashNullable, + prove_transaction_timestamp: General.TimestampNullable, + execute_transaction_hash: General.FullHashNullable, + execute_transaction_timestamp: General.TimestampNullable + }, + required: [ + :batch_number, + :status, + :commit_transaction_hash, + :commit_transaction_timestamp, + :prove_transaction_hash, + :prove_transaction_timestamp, + :execute_transaction_hash, + :execute_transaction_timestamp + ], + additionalProperties: false + } + + @arbitrum_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + batch_number: %Schema{type: :integer, nullable: true}, + batch_data_container: %Schema{ + type: :string, + nullable: true, + enum: ["in_blob4844", "in_calldata", "in_celestia", "in_anytrust", "in_eigenda"] + }, + status: %Schema{ + type: :string, + enum: ["Confirmed on base", "Sent to base", "Sealed on rollup", "Processed on rollup"], + nullable: false + }, + commitment_transaction: %Schema{ + type: :object, + nullable: false, + properties: %{ + hash: General.FullHashNullable, + timestamp: General.TimestampNullable, + status: %Schema{type: :string, enum: ["unfinalized", "finalized"], nullable: true} + }, + required: [:hash, :timestamp, :status] + }, + confirmation_transaction: %Schema{ + type: :object, + nullable: false, + properties: %{ + hash: General.FullHashNullable, + timestamp: General.TimestampNullable, + status: %Schema{type: :string, enum: ["unfinalized", "finalized"], nullable: true} + }, + required: [:hash, :timestamp, :status] + }, + delayed_messages: %Schema{type: :integer, nullable: false}, + l1_block_number: %Schema{type: :integer, nullable: true}, + send_count: %Schema{type: :integer, nullable: true}, + send_root: General.FullHashNullable + }, + required: [:batch_number, :status, :commitment_transaction, :confirmation_transaction], + additionalProperties: false + } + + @blob4844_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + hash: General.FullHashNullable, + l1_transaction_hash: General.FullHashNullable, + l1_timestamp: General.TimestampNullable + }, + required: [:hash, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + } + + @doc """ + Returns the Blob4844 schema. + """ + @spec blob4844_schema() :: Schema.t() + def blob4844_schema, do: @blob4844_schema + + @celestia_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + height: %Schema{type: :integer, nullable: true}, + namespace: %Schema{type: :string, nullable: true}, + commitment: %Schema{type: :string, nullable: true}, + l1_transaction_hash: General.FullHashNullable, + l1_timestamp: General.TimestampNullable + }, + required: [:height, :namespace, :commitment, :l1_transaction_hash, :l1_timestamp], + additionalProperties: false + } + + @alt_da_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + commitment: %Schema{type: :string, nullable: true}, + l1_transaction_hash: General.FullHashNullable, + l1_timestamp: General.TimestampNullable + }, + required: [:commitment, :l1_transaction_hash, :l1_timestamp] + } + + @doc """ + Returns the Celestia schema. + """ + @spec celestia_schema() :: Schema.t() + def celestia_schema, do: @celestia_schema + + @optimism_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + number: %Schema{type: :integer, nullable: true}, + l1_timestamp: General.TimestampNullable, + l1_transaction_hashes: %Schema{type: :array, items: General.FullHash, nullable: false}, + batch_data_container: %Schema{ + type: :string, + nullable: false, + enum: ["in_blob4844", "in_celestia", "in_eigenda", "in_alt_da", "in_calldata"] + }, + blobs: %Schema{ + type: :array, + items: %Schema{anyOf: [@blob4844_schema, @celestia_schema, @alt_da_schema]}, + nullable: false + } + }, + required: [:number, :l1_timestamp, :l1_transaction_hashes, :batch_data_container], + additionalProperties: false + } + + @celo_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + is_epoch_block: %Schema{type: :boolean, nullable: false}, + epoch_number: %Schema{type: :integer, nullable: false}, + l1_era_finalized_epoch_number: %Schema{type: :integer, nullable: true}, + base_fee: %Schema{ + type: :object, + nullable: true, + properties: %{ + recipient: Address, + amount: General.IntegerString, + token: Token, + breakdown: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + address: Address, + amount: General.IntegerString, + percentage: %Schema{type: :number, nullable: false} + }, + required: [:address, :amount, :percentage], + additionalProperties: false + }, + nullable: false + } + }, + required: [:recipient, :amount, :token, :breakdown], + additionalProperties: false + } + }, + required: [:is_epoch_block, :epoch_number, :l1_era_finalized_epoch_number], + additionalProperties: false + } + + @zilliqa_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + view: %Schema{type: :integer, nullable: true}, + quorum_certificate: %Schema{ + type: :object, + nullable: false, + properties: %{ + view: %Schema{type: :integer, nullable: false}, + signature: General.HexString, + signers: %Schema{type: :array, items: %Schema{type: :integer, nullable: false}, nullable: false} + }, + required: [:view, :signature, :signers], + additionalProperties: false + }, + aggregate_quorum_certificate: %Schema{ + type: :object, + nullable: false, + properties: %{ + view: %Schema{type: :integer, nullable: false}, + signature: General.HexString, + signers: %Schema{type: :array, items: %Schema{type: :integer, nullable: false}, nullable: false}, + nested_quorum_certificates: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + view: %Schema{type: :integer, nullable: false}, + signature: General.HexString, + proposed_by_validator_index: %Schema{type: :integer, nullable: false}, + signers: %Schema{type: :array, items: %Schema{type: :integer, nullable: false}, nullable: false} + }, + required: [:view, :signature, :proposed_by_validator_index, :signers], + additionalProperties: false + }, + nullable: false + } + }, + required: [:view, :signature, :signers, :nested_quorum_certificates], + additionalProperties: false + } + }, + required: [:view], + additionalProperties: false + } + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + # The per-chain extension object (e.g. :arbitrum, :optimism, :zksync, :rsk) is + # always advertised in the schema, but BlockScoutWeb.API.V2.BlockView.chain_type_fields/3 + # only populates it for single-block responses (single_block? = true). This is + # consistent only because none of these extension fields appear in `required:`. + @spec chain_type_fields(map()) :: map() + def chain_type_fields(schema) do + chain_type() + |> case do + :rsk -> + schema + |> Helper.extend_schema( + properties: %{ + minimum_gas_price: General.IntegerString, + bitcoin_merged_mining_header: General.HexString, + bitcoin_merged_mining_coinbase_transaction: General.HexString, + bitcoin_merged_mining_merkle_proof: General.HexString, + hash_for_merged_mining: General.FullHash + } + ) + + :optimism -> + schema |> Helper.extend_schema(properties: %{optimism: @optimism_schema}) + + :zksync -> + schema |> Helper.extend_schema(properties: %{zksync: @zksync_schema}) + + :arbitrum -> + schema |> Helper.extend_schema(properties: %{arbitrum: @arbitrum_schema}) + + :ethereum -> + schema + |> Helper.extend_schema( + properties: %{ + blob_transactions_count: %Schema{type: :integer, minimum: 0, nullable: false}, + blob_gas_used: General.IntegerStringNullable, + excess_blob_gas: General.IntegerStringNullable, + blob_gas_price: General.IntegerStringNullable, + burnt_blob_fees: General.IntegerString, + beacon_deposits_count: %Schema{type: :integer, minimum: 0, nullable: true} + }, + required: [:blob_transactions_count, :blob_gas_used, :excess_blob_gas, :beacon_deposits_count] + ) + + :zilliqa -> + schema |> Helper.extend_schema(properties: %{zilliqa: @zilliqa_schema}) + + _ -> + schema + end + |> chain_identity_fields() + end + + defp chain_identity_fields(schema) do + case chain_identity() do + {:optimism, :celo} -> + schema |> Helper.extend_schema(properties: %{celo: @celo_schema}) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Block.Common do + @moduledoc """ + This module defines common schema fields for block. + """ + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + @required_fields [ + :height, + :timestamp, + :transactions_count, + :internal_transactions_count, + :miner, + :size, + :hash, + :parent_hash, + :difficulty, + :total_difficulty, + :gas_used, + :gas_limit, + :nonce, + :base_fee_per_gas, + :burnt_fees, + :priority_fee, + :uncles_hashes, + :rewards, + :gas_target_percentage, + :gas_used_percentage, + :burnt_fees_percentage, + :type, + :transaction_fees, + :withdrawals_count, + :is_pending_update + ] + + # List-response variant: `type` is the machine-readable enum. + @rewards_schema %Schema{ + type: :array, + description: + "Block rewards grouped by recipient category. Each entry describes a reward paid for this block; the set of categories depends on the chain and block type.", + items: %Schema{ + type: :object, + properties: %{ + reward: General.IntegerString, + # Enum values must be kept in sync with + # Explorer.Chain.Block.Reward.AddressType :t/0. + type: %Schema{ + type: :string, + nullable: false, + enum: ["emission_funds", "uncle", "validator"], + description: "Reward category (machine-readable identifier)." + } + }, + required: [:type, :reward], + additionalProperties: false + }, + nullable: false + } + + # Single-block-response variant: `type` is a human-readable label. + @rewards_schema_single_block %Schema{ + type: :array, + description: + "Block rewards grouped by recipient category. Single-block variant: `type` is a human-readable label rather than a machine identifier.", + items: %Schema{ + type: :object, + properties: %{ + reward: General.IntegerString, + type: %Schema{ + type: :string, + nullable: false, + description: "Human-readable reward category label (e.g. \"Miner Reward\", \"Uncle Reward\")." + } + }, + required: [:type, :reward], + additionalProperties: false + }, + nullable: false + } + + @doc """ + Returns the common rewards schema for block (list-response variant). + """ + @spec rewards_schema() :: Schema.t() + def rewards_schema, do: @rewards_schema + + @doc """ + Returns the rewards schema for a single-block response, where `type` is a + human-readable label rather than the `Explorer.Chain.Block.Reward.AddressType` enum. + """ + @spec rewards_schema_single_block() :: Schema.t() + def rewards_schema_single_block, do: @rewards_schema_single_block + + @doc """ + Returns the list of required fields for the block schema. + """ + @spec required_fields() :: [atom()] + def required_fields, do: @required_fields + + @doc """ + Returns the common schema for block. + """ + @spec schema() :: map() + def schema do + %{ + type: :object, + properties: %{ + height: %Schema{ + type: :integer, + nullable: false, + minimum: 0, + description: "Block number (zero-based index from genesis)." + }, + timestamp: General.Timestamp, + transactions_count: %Schema{type: :integer, nullable: false, minimum: 0}, + internal_transactions_count: %Schema{ + type: :integer, + nullable: true, + minimum: 0, + description: "Number of internal transactions in this block; null when the count is unavailable." + }, + miner: %Schema{ + allOf: [Address], + description: + "Address credited with the block — the miner on PoW chains, the fee recipient / proposer on PoS chains, and the sequencer on rollups." + }, + size: %Schema{ + type: :integer, + nullable: true, + minimum: 0, + description: "Block size in bytes (length of the RLP-encoded block)." + }, + hash: General.FullHash, + parent_hash: General.FullHash, + difficulty: %Schema{ + allOf: [General.IntegerStringNullable], + description: "Proof-of-work difficulty of this block. Zero on proof-of-stake chains." + }, + total_difficulty: %Schema{ + allOf: [General.IntegerStringNullable], + description: + "Cumulative chain difficulty through this block (sum of `difficulty` of this block and all ancestors)." + }, + gas_used: General.IntegerString, + gas_limit: General.IntegerString, + nonce: %Schema{ + allOf: [General.HexString], + description: "Proof-of-work nonce used to satisfy the difficulty target. Zero on proof-of-stake chains." + }, + base_fee_per_gas: %Schema{ + allOf: [General.IntegerStringNullable], + description: + "EIP-1559 base fee per gas, in wei. Null on blocks produced before EIP-1559 activation or on chains that do not implement EIP-1559." + }, + burnt_fees: %Schema{ + allOf: [General.IntegerStringNullable], + description: "Sum of EIP-1559 base fees burned by transactions in this block, in wei." + }, + priority_fee: %Schema{ + allOf: [General.IntegerStringNullable], + description: "Sum of validator tips (EIP-1559 priority fees) paid by transactions in this block, in wei." + }, + uncles_hashes: %Schema{ + type: :array, + description: "Hashes of ommer (uncle) blocks referenced by this block.", + items: %Schema{ + type: :object, + properties: %{hash: General.FullHash}, + required: [:hash], + nullable: false, + additionalProperties: false + }, + nullable: false + }, + rewards: @rewards_schema, + gas_target_percentage: %Schema{ + type: :number, + format: :float, + nullable: false, + description: "Percent above the EIP-1559 elasticity target (gas_used vs. gas_limit / elasticity_multiplier)." + }, + gas_used_percentage: %Schema{ + type: :number, + format: :float, + nullable: false, + description: "Gas used in this block as a percentage of `gas_limit`." + }, + burnt_fees_percentage: %Schema{ + type: :number, + format: :float, + nullable: true, + description: "Burned base fees as a percentage of total transaction fees in this block." + }, + # Values mirror BlockScoutWeb.BlockView.block_type/1 output, downcased. + type: %Schema{ + type: :string, + nullable: false, + enum: ["block", "uncle", "reorg"], + description: + "Block classification: `block` = main-chain consensus block; `uncle` = ommer (valid but not in main chain); `reorg` = former main-chain block lost to reorganization." + }, + transaction_fees: %Schema{ + allOf: [General.IntegerString], + description: "Sum of transaction fees (gas price × gas used) paid by transactions in this block, in wei." + }, + withdrawals_count: %Schema{ + type: :integer, + minimum: 0, + nullable: true, + description: "Number of withdrawals included in this block; null when the count is unavailable." + }, + is_pending_update: %Schema{ + type: :boolean, + nullable: true, + description: + "True when the block is scheduled for re-fetch; its fields may change once re-fetching completes." + } + }, + required: required_fields(), + additionalProperties: false + } + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Block do + @moduledoc """ + This module defines the schema for the Block struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Block.ChainTypeCustomizations, Block.Common} + + OpenApiSpex.schema( + Common.schema() + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/countdown.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/countdown.ex new file mode 100644 index 000000000000..1a3b9478a186 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/countdown.ex @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Block.Countdown do + @moduledoc """ + This module defines the schema for block countdown response from /api/v2/blocks/:block_number/countdown. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "BlockCountdown", + description: "Block countdown information showing estimated time until a target block is reached", + type: :object, + properties: %{ + current_block: %Schema{ + type: :integer, + description: "The current highest block number in the blockchain", + minimum: 0, + example: 22_566_361 + }, + countdown_block: %Schema{ + type: :integer, + description: "The target block number for the countdown", + minimum: 0, + example: 22_600_000 + }, + remaining_blocks: %Schema{ + type: :integer, + description: "Number of blocks remaining until the target block is reached", + minimum: 0, + example: 33_639 + }, + estimated_time_in_sec: %Schema{ + type: :number, + format: :float, + description: "Estimated time in seconds until the target block is reached", + minimum: 0, + example: 404_868.0 + } + }, + required: [ + :current_block, + :countdown_block, + :remaining_blocks, + :estimated_time_in_sec + ], + example: %{ + current_block: 22_566_361, + countdown_block: 22_600_000, + remaining_blocks: 33_639, + estimated_time_in_sec: 404_868.0 + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/response.ex new file mode 100644 index 000000000000..f4e6417aeb43 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/response.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Block.Response do + @moduledoc """ + This module defines the schema for block response from /api/v2/blocks/:hash_or_number. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Block + alias BlockScoutWeb.Schemas.API.V2.Block.Common + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Block.schema() + |> Helper.extend_schema( + title: "BlockResponse", + description: "Block response", + properties: %{rewards: Common.rewards_schema_single_block()} + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/election_reward.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/election_reward.ex new file mode 100644 index 000000000000..e703d11efc4c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/election_reward.ex @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.ElectionReward do + @moduledoc """ + This module defines the schema for the CeloElectionReward struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, Celo.ElectionReward.Type, General, Token} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + block_number: %Schema{type: :integer, nullable: false}, + block_timestamp: General.Timestamp, + block_hash: General.FullHash, + account: Address, + associated_account: Address, + amount: General.IntegerString, + type: Type, + epoch_number: %Schema{type: :integer, nullable: false}, + token: Token + }, + required: [ + :amount, + :account, + :associated_account + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/election_reward/type.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/election_reward/type.ex new file mode 100644 index 000000000000..dab4765ddbba --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/election_reward/type.ex @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.ElectionReward.Type do + @moduledoc false + require OpenApiSpex + + alias Explorer.Chain.Celo.ElectionReward + + # Uses `type_enum_with_legacy/0` instead of `types/0` so that + # CastAndValidate accepts both the canonical underscore form + # ("delegated_payment") and the legacy hyphenated URL form + # ("delegated-payment"). See `ElectionReward.type_enum_with_legacy/0` + # for details. + OpenApiSpex.schema(%{ + type: :string, + nullable: false, + enum: ElectionReward.type_enum_with_legacy(), + title: "CeloElectionRewardType" + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex new file mode 100644 index 000000000000..07722b1836d6 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch.ex @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch do + @moduledoc """ + This module defines the schema for a Celo epoch list item. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Celo epoch summary.", + type: :object, + properties: %{ + number: %Schema{type: :integer, nullable: false, minimum: 0}, + type: %Schema{type: :string, enum: ["L1", "L2"], nullable: false}, + start_block_number: %Schema{type: :integer, nullable: false, minimum: 0}, + end_block_number: %Schema{type: :integer, nullable: false, minimum: 0}, + timestamp: General.TimestampNullable, + is_finalized: %Schema{type: :boolean, nullable: false}, + distribution: %Schema{type: :object, nullable: true, additionalProperties: true} + }, + required: [ + :number, + :type, + :start_block_number, + :end_block_number, + :timestamp, + :is_finalized, + :distribution + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex new file mode 100644 index 000000000000..35d89f1034f2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/celo/epoch/detailed.ex @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Celo.Epoch.Detailed do + @moduledoc """ + This module defines the schema for detailed Celo epoch response. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Celo.Epoch, General} + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Epoch.schema() + |> Helper.extend_schema( + title: "CeloEpochDetailed", + nullable: false, + properties: %{ + start_processing_block_hash: General.FullHashNullable, + start_processing_block_number: %Schema{type: :integer, nullable: true, minimum: 0}, + end_processing_block_hash: General.FullHashNullable, + end_processing_block_number: %Schema{type: :integer, nullable: true, minimum: 0}, + aggregated_election_rewards: %Schema{ + type: :object, + nullable: true, + additionalProperties: %Schema{ + type: :object, + nullable: true, + properties: %{ + total: General.IntegerString, + count: %Schema{type: :integer, nullable: false, minimum: 0}, + token: %Schema{type: :object, nullable: true, additionalProperties: true} + }, + required: [:total, :count, :token], + additionalProperties: false + } + }, + distribution: %Schema{type: :object, nullable: true, additionalProperties: true} + }, + required: [ + :start_processing_block_hash, + :start_processing_block_number, + :end_processing_block_hash, + :end_processing_block_number, + :aggregated_election_rewards + ] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/coin_balance.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/coin_balance.ex new file mode 100644 index 000000000000..d41bad309267 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/coin_balance.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.CoinBalance do + @moduledoc """ + This module defines the schema for the CoinBalance struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + transaction_hash: General.FullHashNullable, + block_number: %Schema{type: :integer, nullable: false}, + delta: General.IntegerString, + value: General.IntegerString, + block_timestamp: General.Timestamp + }, + required: [ + :transaction_hash, + :block_number, + :delta, + :value, + :block_timestamp + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/coin_balance_by_day.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/coin_balance_by_day.ex new file mode 100644 index 000000000000..0b5eb8b7e946 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/coin_balance_by_day.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.CoinBalanceByDay do + @moduledoc """ + This module defines the schema for the CoinBalanceByDay struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + date: %Schema{type: :string, format: :date, nullable: false}, + value: General.IntegerString + }, + required: [:date, :value], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex new file mode 100644 index 000000000000..0750fbbeb84c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/csv_export/response.ex @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.CSVExport.Response do + @moduledoc false + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + status: %Schema{type: :string, nullable: false, enum: ["pending", "completed", "failed"]}, + file_id: %Schema{type: :string, nullable: true}, + expires_at: %Schema{type: :string, nullable: true, format: "date-time"} + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/error_responses.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/error_responses.ex new file mode 100644 index 000000000000..9e95a8fb7f50 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/error_responses.ex @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.ErrorResponses do + @moduledoc """ + This module contains the schemas for the error responses. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + defmodule ForbiddenResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "ForbiddenResponse", + description: "Response returned when the user is forbidden to access the resource", + type: :object, + properties: %{ + message: %Schema{ + type: :string, + description: "Error message indicating the user is forbidden to access the resource", + example: "Unverified email" + } + } + }) + + @spec response() :: {String.t(), String.t(), module()} + def response do + {"Forbidden", "application/json", __MODULE__} + end + end + + defmodule NotFoundResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "NotFoundResponse", + description: "Response returned when the requested resource is not found", + type: :object, + properties: %{ + message: %Schema{ + type: :string, + description: "Error message indicating the requested resource was not found", + example: "Resource not found" + } + } + }) + + @spec response() :: {String.t(), String.t(), module()} + def response do + {"Not Found", "application/json", __MODULE__} + end + end + + defmodule UnauthorizedResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "UnauthorizedResponse", + description: "Response returned when the user is not authorized to access the resource", + type: :object, + properties: %{ + message: %Schema{ + type: :string, + description: "Error message indicating the user is not authorized to access the resource", + example: "Unauthorized" + } + } + }) + + @spec response() :: {String.t(), String.t(), module()} + def response do + {"Unauthorized", "application/json", __MODULE__} + end + end + + defmodule BadRequestResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "BadRequestResponse", + description: "Response returned when the request is invalid", + type: :object, + properties: %{ + message: %Schema{ + type: :string, + description: "Error message indicating the request is invalid", + example: "Invalid request" + } + } + }) + + @spec response() :: {String.t(), String.t(), module()} + def response do + {"Bad Request", "application/json", __MODULE__} + end + end + + defmodule NotImplementedResponse do + @moduledoc false + OpenApiSpex.schema(%{ + title: "NotImplementedResponse", + description: "Response returned when the feature is not implemented", + type: :object, + properties: %{ + message: %Schema{ + type: :string, + description: "Error message indicating the feature is not implemented", + example: "Feature not implemented" + } + } + }) + + @spec response() :: {String.t(), String.t(), module()} + def response do + {"Not Implemented", "application/json", __MODULE__} + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex new file mode 100644 index 000000000000..d148cdd2b92d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/fhe_operation.ex @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.FheOperation do + @moduledoc """ + This module defines the schema for the FHE Operation struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + @fhe_operation_type_enum [ + "arithmetic", + "bitwise", + "comparison", + "unary", + "control", + "encryption", + "random" + ] + + @fhe_type_enum [ + "Bool", + "Uint8", + "Uint16", + "Uint32", + "Uint64", + "Uint128", + "Uint160", + "Uint256", + "Bytes64", + "Bytes128", + "Bytes256" + ] + + @fhe_operation_inputs_schema %Schema{ + type: :object, + properties: %{ + lhs: %Schema{type: :string, nullable: true}, + rhs: %Schema{type: :string, nullable: true}, + ct: %Schema{type: :string, nullable: true}, + control: %Schema{type: :string, nullable: true}, + if_true: %Schema{type: :string, nullable: true}, + if_false: %Schema{type: :string, nullable: true}, + plaintext: %Schema{type: :number, nullable: true} + }, + additionalProperties: false + } + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + log_index: %Schema{type: :integer, nullable: false}, + operation: %Schema{type: :string, nullable: false, example: "FheAdd"}, + type: %Schema{ + type: :string, + enum: @fhe_operation_type_enum, + nullable: false, + example: "arithmetic" + }, + fhe_type: %Schema{ + type: :string, + enum: @fhe_type_enum, + nullable: false, + example: "Uint8" + }, + is_scalar: %Schema{type: :boolean, nullable: false, example: false}, + hcu_cost: %Schema{type: :integer, nullable: false, example: 100, minimum: 0}, + hcu_depth: %Schema{type: :integer, nullable: false, example: 1, minimum: 0}, + caller: %Schema{allOf: [Address], nullable: true}, + inputs: @fhe_operation_inputs_schema, + result: General.HexString, + block_number: %Schema{type: :integer, nullable: false, example: 12_345_678} + }, + required: [ + :log_index, + :operation, + :type, + :fhe_type, + :is_scalar, + :hcu_cost, + :hcu_depth, + :inputs, + :result, + :block_number + ], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.FheOperationsResponse do + @moduledoc """ + This module defines the schema for the FHE Operations response from /api/v2/transactions/:transaction_hash_param/fhe-operations. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.FheOperation + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + items: %Schema{ + type: :array, + items: %Schema{allOf: [FheOperation], nullable: false}, + nullable: false + }, + total_hcu: %Schema{ + type: :integer, + nullable: false, + description: "Total HCU (Homomorphic Compute Units) cost for all operations in the transaction", + example: 500, + minimum: 0 + }, + max_depth_hcu: %Schema{ + type: :integer, + nullable: false, + description: "Maximum HCU depth across all operations in the transaction", + example: 3, + minimum: 0 + }, + operation_count: %Schema{ + type: :integer, + nullable: false, + description: "Total number of FHE operations in the transaction", + example: 5, + minimum: 0 + } + }, + required: [:items, :total_hcu, :max_depth_hcu, :operation_count], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex new file mode 100644 index 000000000000..383c05241c54 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general.ex @@ -0,0 +1,1632 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General do + @moduledoc """ + This module defines the schema for general types used in the API. + """ + require OpenApiSpex + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + alias BlockScoutWeb.Schemas.API.V2.Celo.ElectionReward.Type, as: CeloElectionRewardType + + alias BlockScoutWeb.Schemas.API.V2.General.{ + AddressHash, + AddressHashNullable, + EmptyString, + FloatString, + FullHash, + HexString, + IntegerString, + IntegerStringNullable, + NullString + } + + alias BlockScoutWeb.Schemas.API.V2.Token.Type, as: TokenType + alias Explorer.Chain.InternalTransaction.CallType + alias OpenApiSpex.{Parameter, Schema} + @integer_pattern ~r"^-?([1-9][0-9]*|0)$" + @non_negative_integer_pattern ~r"^([1-9][0-9]*|0)$" + @float_pattern ~r"^([1-9][0-9]*|0)(\.[0-9]+)?$" + @address_hash_pattern ~r"^0x([A-Fa-f0-9]{40})$" + @full_hash_pattern ~r"^0x([A-Fa-f0-9]{64})$" + @hex_string_pattern ~r"^0x([A-Fa-f0-9]*)$" + + if @chain_type == :zilliqa do + @token_type_pattern ~r/^\[?(ERC-20|ERC-721|ERC-1155|ERC-404|ZRC-2|ERC-7984)(,(ERC-20|ERC-721|ERC-1155|ERC-404|ZRC-2|ERC-7984))*\]?$/i + else + @token_type_pattern ~r/^\[?(ERC-20|ERC-721|ERC-1155|ERC-404|ERC-7984)(,(ERC-20|ERC-721|ERC-1155|ERC-404|ERC-7984))*\]?$/i + end + + # Matches ISO-like datetime strings where separators between time fields can be ':' or percent-encoded '%3A'. + # Accepts examples like: + # - "2025-10-12T09" + # - "2025-10-12T09:51" + # - "2025-10-12T09:51:00.000Z" + # - "2025-10-12T09%3A51%3A00.000Z" + # - With timezone offsets: "2025-10-12T09:51:00+02:00" or encoded as "%2B02%3A00" + @iso_date_or_datetime_pattern ~r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?:T(?:[01]\d|2[0-3])(?:(?::|%3A)[0-5]\d(?:(?::|%3A)[0-5]\d(?:\.\d{1,9})?)?)?(?:Z|(?:\+|%2B|-)(?:[01]\d|2[0-3])(?:(?::|%3A)[0-5]\d)?)?)?$"i + + @base_transaction_types [ + "coin_transfer", + "contract_call", + "contract_creation", + "token_transfer", + "token_creation" + ] + + case @chain_type do + :ethereum -> + @allowed_transaction_types ["blob_transaction" | @base_transaction_types] + + _ -> + @allowed_transaction_types @base_transaction_types + end + + @doc """ + Returns a parameter definition for an address hash in the path. + """ + @spec address_hash_param() :: Parameter.t() + def address_hash_param do + %Parameter{ + name: :address_hash_param, + in: :path, + schema: AddressHash, + required: true, + description: "Address hash in the path" + } + end + + @doc """ + Returns a parameter definition for the start of the time period. + """ + @spec from_period_param() :: Parameter.t() + def from_period_param do + %Parameter{ + name: :from_period, + in: :query, + schema: %Schema{ + anyOf: [%Schema{type: :string, nullable: false, pattern: @iso_date_or_datetime_pattern}, NullString] + }, + required: true, + description: "Start of the time period (ISO 8601 format) in CSV export" + } + end + + @doc """ + Returns a parameter definition for the end of the time period. + """ + @spec to_period_param() :: Parameter.t() + def to_period_param do + %Parameter{ + name: :to_period, + in: :query, + schema: %Schema{ + anyOf: [%Schema{type: :string, nullable: false, pattern: @iso_date_or_datetime_pattern}, NullString] + }, + required: true, + description: "End of the time period (ISO 8601 format) In CSV export" + } + end + + @doc """ + Returns an optional parameter definition for the start of the time period. + Used for endpoints like token holders CSV that don't require a time range. + """ + @spec optional_from_period_param() :: Parameter.t() + def optional_from_period_param do + %{from_period_param() | required: false} + end + + @doc """ + Returns an optional parameter definition for the end of the time period. + Used for endpoints like token holders CSV that don't require a time range. + """ + @spec optional_to_period_param() :: Parameter.t() + def optional_to_period_param do + %{to_period_param() | required: false} + end + + @doc """ + Returns a parameter definition for chain IDs in the query. + """ + @spec chain_ids_param() :: Parameter.t() + def chain_ids_param do + %Parameter{ + name: :chain_ids, + in: :query, + schema: %Schema{type: :string, nullable: true}, + required: false, + description: "Chain IDs filter in Bridged tokens" + } + end + + @doc """ + Returns a parameter definition for a search query in the query. + """ + @spec q_param() :: Parameter.t() + def q_param do + %Parameter{ + name: :q, + in: :query, + schema: %Schema{type: :string, nullable: true}, + required: false, + description: "Search query filter" + } + end + + @doc """ + Returns a parameter definition for a limit result items in the response. + """ + @spec limit_param() :: Parameter.t() + def limit_param do + %Parameter{ + name: :limit, + in: :query, + schema: %Schema{type: :integer, nullable: true}, + required: false, + description: "Limit result items in the response" + } + end + + @doc """ + Returns a parameter definition for a filter type in the query. + """ + @spec filter_type_param() :: Parameter.t() + def filter_type_param do + %Parameter{ + name: :filter_type, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :string, enum: ["address"], nullable: true}, NullString]}, + required: false, + description: "Filter type in CSV export" + } + end + + @doc """ + Returns a parameter definition for a filter value in the query. + """ + @spec filter_value_param() :: Parameter.t() + def filter_value_param do + %Parameter{ + name: :filter_value, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :string, enum: ["to", "from"], nullable: true}, NullString]}, + required: false, + description: "Filter value in CSV export" + } + end + + @doc """ + Returns a parameter definition for a token holder address hash in the query. + """ + @spec holder_address_hash_param() :: Parameter.t() + def holder_address_hash_param do + %Parameter{ + name: :holder_address_hash, + in: :query, + schema: AddressHash, + required: false, + description: "Token holder address hash in the query" + } + end + + @doc """ + Returns a parameter definition for an execution_node hash in the path. + """ + @spec execution_node_hash_param() :: Parameter.t() + def execution_node_hash_param do + %Parameter{ + name: :execution_node_hash_param, + in: :path, + schema: AddressHash, + required: true, + description: "Execution node hash in the path" + } + end + + @doc """ + Returns a parameter definition for a transaction hash in the path. + """ + @spec transaction_hash_param() :: Parameter.t() + def transaction_hash_param do + %Parameter{ + name: :transaction_hash_param, + in: :path, + schema: FullHash, + required: true, + description: "Transaction hash in the path" + } + end + + @doc """ + Returns a parameter definition for a transaction hash in the query. + """ + @spec query_transaction_hash_param() :: Parameter.t() + def query_transaction_hash_param do + %Parameter{ + name: :transaction_hash, + in: :query, + schema: FullHash, + required: false, + description: "Transaction hash in the query" + } + end + + @doc """ + Returns a parameter definition for a block hash or number in the path. + """ + @spec block_hash_or_number_param() :: Parameter.t() + def block_hash_or_number_param do + %Parameter{ + name: :block_hash_or_number_param, + in: :path, + schema: %Schema{anyOf: [%Schema{type: :integer, minimum: 0}, FullHash]}, + required: true, + description: "Block hash or number in the path" + } + end + + @doc """ + Returns a parameter definition for a block number in the path. + """ + @spec block_number_param() :: Parameter.t() + def block_number_param do + %Parameter{ + name: :block_number_param, + in: :path, + schema: %Schema{type: :integer, minimum: 0}, + required: true, + description: "Block number in the path" + } + end + + @doc """ + Returns a parameter definition for filtering blocks by type (uncle or reorg). + """ + @spec block_type_param() :: Parameter.t() + def block_type_param do + %Parameter{ + name: :type, + in: :query, + schema: %Schema{type: :string, enum: ["uncle", "reorg", "block"], default: "block"}, + required: false, + description: """ + Filter by block type: + * block - Standard blocks in the main chain + * uncle - Uncle/ommer blocks (valid but not in main chain) + * reorg - Blocks from chain reorganizations + If omitted, default value "block" is used. + """ + } + end + + @doc """ + Returns a parameter definition for filtering transactions by type (validated or pending). + """ + @spec transaction_filter_param() :: Parameter.t() + def transaction_filter_param do + %Parameter{ + name: :filter, + in: :query, + schema: %Schema{type: :string, enum: ["validated", "pending"]}, + required: false, + description: """ + Filter transactions by status: + * pending - Transactions waiting to be mined/validated + * validated - Confirmed transactions included in blocks + If omitted, default value "validated" is used. + """ + } + end + + @doc """ + Returns a parameter definition for a request body used in the summary endpoint. + """ + @spec just_request_body_param() :: Parameter.t() + def just_request_body_param do + %Parameter{ + name: :just_request_body, + in: :query, + schema: %Schema{type: :boolean}, + required: false, + description: "If true, returns only the request body in the summary endpoint" + } + end + + @doc """ + Returns a reusable OpenApiSpex.RequestBody for audit report submission. + """ + @spec audit_report_request_body() :: OpenApiSpex.RequestBody.t() + def audit_report_request_body do + %OpenApiSpex.RequestBody{ + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + submitter_name: %Schema{type: :string}, + submitter_email: %Schema{type: :string}, + is_project_owner: %Schema{type: :boolean}, + project_name: %Schema{type: :string}, + project_url: %Schema{type: :string}, + audit_company_name: %Schema{type: :string}, + audit_report_url: %Schema{type: :string}, + audit_publish_date: %Schema{type: :string, format: :date}, + comment: %Schema{type: :string, nullable: true} + }, + required: [ + :submitter_name, + :submitter_email, + :is_project_owner, + :project_name, + :project_url, + :audit_company_name, + :audit_report_url, + :audit_publish_date + ] + } + } + } + } + end + + @doc """ + Returns a parameter definition for a batch number in the path. + """ + @spec batch_number_param() :: Parameter.t() + def batch_number_param do + %Parameter{ + name: :batch_number_param, + in: :path, + schema: %Schema{type: :integer, minimum: 0}, + required: true, + description: "Batch number" + } + end + + @doc """ + Returns a parameter definition for filtering transactions by type. + """ + @spec transaction_type_param() :: Parameter.t() + def transaction_type_param do + %Parameter{ + name: :type, + in: :query, + schema: %Schema{type: :string, enum: ["blob_transaction"]}, + required: false, + description: """ + Filter by transaction type. Comma-separated list of: + * blob_transaction - Only show blob transactions + """ + } + end + + @doc """ + Returns a parameter definition for filtering block transactions by type. + """ + @spec block_transaction_type_param() :: Parameter.t() + def block_transaction_type_param do + %Parameter{ + name: :type, + in: :query, + schema: %Schema{type: :string, enum: @allowed_transaction_types}, + required: false, + description: """ + Filter by transaction type. Comma-separated list of: + * token_transfer - Token transfer transactions + * contract_creation - Contract deployment transactions + * contract_call - Contract method call transactions + * coin_transfer - Native coin transfer transactions + * token_creation - Token creation transactions + * blob_transaction - Only show blob transactions (Ethereum only) + """ + } + end + + @doc """ + Returns a parameter definition for filtering internal transactions by type. + """ + @spec internal_transaction_type_param() :: Parameter.t() + def internal_transaction_type_param do + %Parameter{ + name: :internal_type, + in: :query, + schema: %Schema{ + type: :string, + enum: Explorer.Chain.InternalTransaction.Type.values() + }, + required: false, + description: """ + Filter internal transactions by type: + * all - Show all internal transactions (default) + * call - Only show call internal transactions + * create - Only show create internal transactions + * create2 - Only show create2 internal transactions + * reward - Only show reward internal transactions + * selfdestruct - Only show selfdestruct internal transactions + * stop - Only show stop internal transactions + * invalid - Only show invalid internal transactions (Arbitrum only) + """ + } + end + + @doc """ + Returns a parameter definition for filtering internal transactions by call type. + """ + @spec internal_transaction_call_type_param() :: Parameter.t() + def internal_transaction_call_type_param do + %Parameter{ + name: :call_type, + in: :query, + schema: %Schema{ + type: :string, + enum: CallType.values() + }, + required: false, + description: """ + Filter internal transactions by call type: + * all - Show all internal transactions (default) + * call - Only show call internal transactions + * callcode - Only show callcode internal transactions + * delegatecall - Only show delegatecall internal transactions + * staticcall - Only show staticcall internal transactions + * invalid - Only show invalid internal transactions (Arbitrum only) + """ + } + end + + @doc """ + Returns a parameter definition for filtering transactions by direction (to/from). + """ + @spec direction_filter_param() :: Parameter.t() + def direction_filter_param do + %Parameter{ + name: :filter, + in: :query, + schema: %Schema{type: :string, enum: ["to", "from"]}, + required: false, + description: """ + Filter transactions by direction: + * to - Only show transactions sent to this address + * from - Only show transactions sent from this address + If omitted, all transactions involving the address are returned. + """ + } + end + + @doc """ + Returns a parameter definition for sorting by specified fields. + """ + @spec sort_param([String.t()]) :: Parameter.t() + def sort_param(sort_fields) do + description = sort_description(sort_fields) + + %Parameter{ + name: :sort, + in: :query, + schema: %Schema{ + type: :string, + enum: sort_fields + }, + required: false, + description: description + } + end + + @sort_field_descriptions %{ + "balance" => "Sort by account balance", + "block_number" => "Sort by block number", + "circulating_market_cap" => "Sort by circulating market cap of the token", + "fee" => "Sort by transaction fee", + "fiat_value" => "Sort by fiat value", + "holders_count" => "Sort by number of token holders", + "key0" => "Sort by MUD record key0", + "key1" => "Sort by MUD record key1", + "key_bytes" => "Sort by MUD record key_bytes", + "total_gas_used" => "Sort by total gas used", + "transactions_count" => "Sort by number of transactions", + "value" => "Sort by transaction value" + } + + defp sort_description(sort_fields) do + field_descriptions = + sort_fields + |> Enum.map_join("\n", fn field -> "* #{field} - #{sort_field_description(field)}" end) + + """ + Sort results by: + #{field_descriptions} + Should be used together with `order` parameter. + """ + end + + defp sort_field_description(field), do: Map.get(@sort_field_descriptions, field, "Sort by #{field}") + + @doc """ + Returns a parameter definition for sorting order (asc/desc). + """ + @spec order_param() :: Parameter.t() + def order_param do + %Parameter{ + in: :query, + schema: %Schema{ + type: :string, + enum: ["asc", "desc"] + }, + required: false, + description: """ + Sort order: + * asc - Ascending order + * desc - Descending order + Should be used together with `sort` parameter. + """, + name: :order + } + end + + @token_type_param_description """ + Filter by token type. Comma-separated list of: + * ERC-20 - Fungible tokens + * ERC-721 - Non-fungible tokens + * ERC-1155 - Multi-token standard + * ERC-404 - Hybrid fungible/non-fungible tokens + #{if @chain_type == :zilliqa do + """ + * ZRC-2 - Fungible tokens on Zilliqa + """ + else + "" + end} + + Example: `ERC-20,ERC-721` to show both fungible and NFT transfers + """ + + @doc """ + Returns a parameter definition for filtering by token type. + """ + @spec token_type_param() :: Parameter.t() + def token_type_param do + %Parameter{ + name: :type, + in: :query, + schema: %Schema{ + anyOf: [ + EmptyString, + %Schema{ + type: :string, + pattern: @token_type_pattern + } + ] + }, + required: false, + description: @token_type_param_description + } + end + + @doc """ + Returns a parameter definition for filtering by NFT token type. + """ + @spec nft_token_type_param() :: Parameter.t() + def nft_token_type_param do + %Parameter{ + name: :type, + in: :query, + schema: %Schema{ + anyOf: [ + EmptyString, + %Schema{ + type: :string, + pattern: @token_type_pattern + } + ] + }, + required: false, + description: """ + Filter by token type. Comma-separated list of: + * ERC-721 - Non-fungible tokens + * ERC-1155 - Multi-token standard + * ERC-404 - Hybrid fungible/non-fungible tokens + + Example: `ERC-721,ERC-1155` to show both NFT and multi-token transfers + """ + } + end + + @doc """ + Returns a parameter definition for filtering logs by topic. + """ + @spec topic_param() :: Parameter.t() + def topic_param do + %Parameter{ + name: :topic, + in: :query, + schema: HexString, + required: false, + description: "Log topic param in the query" + } + end + + @doc """ + Returns a parameter definition for filtering token transfers by token contract address. + """ + @spec token_filter_param() :: Parameter.t() + def token_filter_param do + %Parameter{ + name: :token, + in: :query, + schema: AddressHash, + required: false, + description: "Filter token transfers by token contract address." + } + end + + @doc """ + Returns a parameter definition for a token ID in the path. + """ + @spec token_id_param() :: Parameter.t() + def token_id_param do + %Parameter{ + name: :token_id_param, + in: :path, + schema: IntegerStringNullable, + required: true, + description: "Token ID for ERC-721/1155/404 tokens" + } + end + + @doc """ + Returns a parameter definition for API key for sensitive endpoints in the query string. + """ + @spec admin_api_key_param_query() :: Parameter.t() + def admin_api_key_param_query do + %Parameter{ + name: :api_key, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "API key required for sensitive endpoints" + } + end + + @doc """ + Returns a parameter definition for API key header for sensitive endpoints. + """ + @spec admin_api_key_param() :: Parameter.t() + def admin_api_key_param do + %Parameter{ + name: :"x-api-key", + in: :header, + schema: %Schema{type: :string}, + required: false, + description: "API key required for sensitive endpoints" + } + end + + @doc """ + Returns a parameter definition for API key for sensitive endpoints in the request body. + """ + @spec admin_api_key_request_body() :: OpenApiSpex.RequestBody.t() + def admin_api_key_request_body do + %OpenApiSpex.RequestBody{ + content: %{ + "application/json" => %OpenApiSpex.MediaType{ + schema: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + api_key: %Schema{type: :string} + }, + required: [ + :api_key + ] + } + } + } + } + end + + @doc """ + Returns a parameter definition for reCAPTCHA response token. + """ + @spec recaptcha_response_param() :: Parameter.t() + def recaptcha_response_param do + %Parameter{ + name: :recaptcha_response, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "reCAPTCHA response token" + } + end + + @doc """ + Returns a parameter definition for API key used in rate limiting. + """ + @spec api_key_param() :: Parameter.t() + def api_key_param do + %Parameter{ + name: :apikey, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "API key for rate limiting or for sensitive endpoints" + } + end + + @doc """ + Returns a parameter definition for secret key used to access restricted resources. + """ + @spec key_param() :: Parameter.t() + def key_param do + %Parameter{ + name: :key, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Secret key for getting access to restricted resources" + } + end + + @doc """ + Returns a parameter definition for scale for hot contracts. + """ + @spec hot_smart_contracts_scale_param() :: Parameter.t() + def hot_smart_contracts_scale_param do + %Parameter{ + in: :query, + schema: %Schema{type: :string, enum: ["5m", "1h", "3h", "1d", "7d", "30d"], nullable: false}, + required: true, + description: + "Time scale for hot contracts aggregation (5m=5 minutes, 1h=1 hour, 3h=3 hours, 1d=1 day, 7d=7 days, 30d=30 days)", + name: :scale + } + end + + @doc """ + Returns a parameter definition for MUD world address hash. + """ + @spec world_param() :: Parameter.t() + def world_param do + %Parameter{ + name: :world, + in: :path, + schema: AddressHash, + required: true, + description: "MUD world address hash in the path" + } + end + + @doc """ + Returns a parameter definition for MUD system address hash. + """ + @spec system_param() :: Parameter.t() + def system_param do + %Parameter{ + name: :system, + in: :path, + schema: AddressHash, + required: true, + description: "MUD system address hash in the path" + } + end + + @doc """ + Returns a parameter definition for MUD table ID. + """ + @spec table_id_param() :: Parameter.t() + def table_id_param do + %Parameter{ + name: :table_id, + in: :path, + schema: FullHash, + required: true, + description: "MUD table ID in the path" + } + end + + @doc """ + Returns a parameter definition for MUD record ID. + """ + @spec record_id_param() :: Parameter.t() + def record_id_param do + %Parameter{ + name: :record_id, + in: :path, + schema: HexString, + required: true, + description: "MUD record ID in the path" + } + end + + @doc """ + Returns a parameter definition for MUD tables namespace filter. + """ + @spec filter_namespace_param() :: Parameter.t() + def filter_namespace_param do + %Parameter{ + name: :filter_namespace, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Filter by namespace" + } + end + + @doc """ + Returns a parameter definition for MUD table records key0 filter. + """ + @spec filter_key0_param() :: Parameter.t() + def filter_key0_param do + %Parameter{ + name: :filter_key0, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Filter by key0" + } + end + + @doc """ + Returns a parameter definition for MUD table records key1 filter. + """ + @spec filter_key1_param() :: Parameter.t() + def filter_key1_param do + %Parameter{ + name: :filter_key1, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Filter by key1" + } + end + + @doc """ + Returns a parameter definition for a user operation hash in the path. + """ + @spec operation_hash_param() :: Parameter.t() + def operation_hash_param do + %Parameter{ + name: :operation_hash_param, + in: :path, + schema: FullHash, + required: true, + description: "User operation hash in the path" + } + end + + @doc """ + Returns a parameter definition for a user operation sender address hash in the query. + """ + @spec sender_address_hash_param() :: Parameter.t() + def sender_address_hash_param do + %Parameter{ + name: :sender, + in: :query, + schema: AddressHash, + required: false, + description: "User operation sender address hash" + } + end + + @doc """ + Returns a parameter definition for a user operation bundler address hash in the query. + """ + @spec bundler_address_hash_param() :: Parameter.t() + def bundler_address_hash_param do + %Parameter{ + name: :bundler, + in: :query, + schema: AddressHash, + required: false, + description: "User operation bundler address hash" + } + end + + @doc """ + Returns a parameter definition for a user operation paymaster address hash in the query. + """ + @spec paymaster_address_hash_param() :: Parameter.t() + def paymaster_address_hash_param do + %Parameter{ + name: :paymaster, + in: :query, + schema: AddressHash, + required: false, + description: "User operation paymaster address hash" + } + end + + @doc """ + Returns a parameter definition for a user operation factory address hash in the query. + """ + @spec factory_address_hash_param() :: Parameter.t() + def factory_address_hash_param do + %Parameter{ + name: :factory, + in: :query, + schema: AddressHash, + required: false, + description: "User operation factory address hash" + } + end + + @doc """ + Returns a parameter definition for a user operation entry point address hash in the query. + """ + @spec entry_point_address_hash_param() :: Parameter.t() + def entry_point_address_hash_param do + %Parameter{ + name: :entry_point, + in: :query, + schema: AddressHash, + required: false, + description: "User operation entry point address hash" + } + end + + @doc """ + Returns a parameter definition for a user operation bundle index in the query. + """ + @spec bundle_index_param() :: Parameter.t() + def bundle_index_param do + %Parameter{ + name: :bundle_index, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "User operation bundle index" + } + end + + @doc """ + Returns a parameter definition for a user operation block number in the query. + """ + @spec query_block_number_param() :: Parameter.t() + def query_block_number_param do + %Parameter{ + name: :block_number, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "User operation block number" + } + end + + @doc """ + Returns a parameter definition for a UUID in the path. + """ + @spec uuid_param() :: Parameter.t() + def uuid_param do + %Parameter{ + name: :uuid_param, + in: :path, + schema: %Schema{ + type: :string, + format: :uuid, + pattern: ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + }, + required: true, + description: "UUID for CSV export" + } + end + + @doc """ + Returns a list of base parameters (api_key and key). + """ + @spec base_params() :: [Parameter.t()] + def base_params do + [api_key_param(), key_param()] + end + + @doc """ + Returns a schema definition for paginated response. + """ + @spec paginated_response(Keyword.t()) :: Schema.t() + def paginated_response(options) do + items_schema = Keyword.fetch!(options, :items) + next_page_params_example = Keyword.fetch!(options, :next_page_params_example) + + %Schema{ + type: :object, + properties: %{ + items: %Schema{type: :array, items: items_schema, nullable: false}, + next_page_params: %Schema{ + type: :object, + nullable: true, + additionalProperties: true, + example: next_page_params_example + } + }, + required: [:items, :next_page_params], + nullable: false, + additionalProperties: false + } + end + + @doc """ + Returns a schema definition for a simple message response. + """ + @spec message_response_schema :: Schema.t() + def message_response_schema do + %Schema{ + type: :object, + properties: %{ + message: %Schema{type: :string} + }, + required: [:message], + nullable: false, + additionalProperties: false + } + end + + # `%Schema{anyOf: [%Schema{type: :integer}, EmptyString]}` is used because, + # `allowEmptyValue: true` does not allow empty string for some reasons (at least in this case) + + @paging_params %{ + "block_number" => %Parameter{ + name: :block_number, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "Block number for paging" + }, + "block_number_nullable" => %Parameter{ + name: :block_number, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :integer}, EmptyString, NullString]}, + required: false, + description: "Block number for paging" + }, + "block_number_no_casting" => %Parameter{ + name: :block_number, + in: :query, + schema: IntegerString, + required: false, + description: "Block number for paging" + }, + "l1_block_number" => %Parameter{ + name: :l1_block_number, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "L1 block number for paging" + }, + "epoch_number" => %Parameter{ + name: :epoch_number, + in: :query, + schema: IntegerString, + required: false, + description: "Epoch number for paging" + }, + "nonce" => %Parameter{ + name: :nonce, + in: :query, + schema: IntegerString, + required: false, + description: "Nonce for paging" + }, + "index" => %Parameter{ + name: :index, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "Item index for paging" + }, + "index_nullable" => %Parameter{ + name: :index, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :integer}, EmptyString, NullString]}, + required: false, + description: "Transaction index for paging" + }, + "inserted_at" => %Parameter{ + name: :inserted_at, + in: :query, + schema: %Schema{type: :string, format: :"date-time"}, + required: false, + description: "Inserted at timestamp for paging (ISO8601)" + }, + "hash" => %Parameter{ + name: :hash, + in: :query, + schema: FullHash, + required: false, + description: "Transaction hash for paging" + }, + "transaction_hash" => %Parameter{ + name: :transaction_hash, + in: :query, + schema: FullHash, + required: false, + description: "Transaction hash for paging" + }, + # TODO: consider refactoring, to avoid ambiguity with hash param (the same name) + "address_hash" => %Parameter{ + name: :hash, + in: :query, + schema: AddressHash, + required: false, + description: "Address hash for paging" + }, + "address_hash_param" => %Parameter{ + name: :address_hash, + in: :query, + schema: AddressHash, + required: false, + description: "Address hash for paging" + }, + "contract_address_hash" => %Parameter{ + name: :contract_address_hash, + in: :query, + schema: AddressHashNullable, + required: false, + description: "Contract address hash for paging" + }, + "value" => %Parameter{ + name: :value, + in: :query, + schema: %Schema{anyOf: [IntegerString, EmptyString, NullString]}, + required: false, + description: "Transaction value for paging" + }, + "fiat_value" => %Parameter{ + name: :fiat_value, + in: :query, + schema: %Schema{anyOf: [FloatString, EmptyString, NullString]}, + required: false, + description: "Fiat value for paging" + }, + "fee" => %Parameter{ + name: :fee, + in: :query, + schema: IntegerString, + required: false, + description: "Transaction fee for paging" + }, + "items_count" => %Parameter{ + name: :items_count, + in: :query, + schema: %Schema{type: :integer, minimum: 1, maximum: 50}, + required: false, + description: "Number of items returned per page" + }, + "holders_count" => %Parameter{ + name: :holders_count, + in: :query, + schema: %Schema{anyOf: [IntegerString, EmptyString, NullString]}, + required: false, + description: "Number of holders returned per page" + }, + "is_name_null" => %Parameter{ + name: :is_name_null, + in: :query, + schema: %Schema{type: :boolean}, + required: false, + description: "Is name null for paging" + }, + "market_cap" => %Parameter{ + name: :market_cap, + in: :query, + schema: %Schema{anyOf: [FloatString, EmptyString, NullString]}, + required: false, + description: "Market cap for paging" + }, + "name" => %Parameter{ + name: :name, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Name for paging" + }, + "batch_log_index" => %Parameter{ + name: :batch_log_index, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "Batch log index for paging" + }, + "batch_block_hash" => %Parameter{ + name: :batch_block_hash, + in: :query, + schema: FullHash, + required: false, + description: "Batch block hash for paging" + }, + "batch_transaction_hash" => %Parameter{ + name: :batch_transaction_hash, + in: :query, + schema: FullHash, + required: false, + description: "Batch transaction hash for paging" + }, + "index_in_batch" => %Parameter{ + name: :index_in_batch, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "Index in batch for paging" + }, + "transaction_index" => %Parameter{ + name: :transaction_index, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "Transaction index for paging" + }, + "fiat_value_nullable" => %Parameter{ + name: :fiat_value, + in: :query, + schema: %Schema{anyOf: [FloatString, EmptyString, NullString]}, + required: false, + description: "Fiat value for paging" + }, + "id" => %Parameter{ + name: :id, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "ID for paging" + }, + "smart_contract_id" => %Parameter{ + name: :smart_contract_id, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "Smart-contract ID for paging" + }, + "number" => %Parameter{ + name: :number, + in: :query, + schema: %Schema{type: :integer}, + required: false, + description: "Number for paging" + }, + "fetched_coin_balance" => %Parameter{ + name: :fetched_coin_balance, + in: :query, + schema: IntegerStringNullable, + required: false, + description: "Fetched coin balance for paging" + }, + "transactions_count" => %Parameter{ + name: :transactions_count, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :integer}, EmptyString, NullString]}, + required: false, + description: "Transactions count for paging" + }, + "token_contract_address_hash" => %Parameter{ + name: :token_contract_address_hash, + in: :query, + schema: AddressHash, + required: false, + description: "Token contract address hash for paging" + }, + "token_id" => %Parameter{ + name: :token_id, + in: :query, + schema: IntegerStringNullable, + required: false, + description: "Token ID for paging" + }, + # todo: eliminate in favour token_id + "unique_token" => %Parameter{ + name: :unique_token, + in: :query, + schema: IntegerStringNullable, + required: false, + description: "Token ID for paging" + }, + "token_type" => %Parameter{ + name: :token_type, + in: :query, + schema: TokenType, + required: false, + description: "Token type for paging" + }, + "amount" => %Parameter{ + name: :amount, + in: :query, + schema: IntegerStringNullable, + required: false, + description: "Amount for paging" + }, + "account_address_hash" => %Parameter{ + name: :account_address_hash, + in: :query, + schema: AddressHash, + required: false, + description: "Account address hash for paging" + }, + "associated_account_address_hash" => %Parameter{ + name: :associated_account_address_hash, + in: :query, + schema: AddressHash, + required: false, + description: "Associated account address hash for paging" + }, + "type" => %Parameter{ + name: :type, + in: :query, + schema: CeloElectionRewardType.schema(), + required: false, + description: "Type for paging" + }, + "filter" => %Parameter{ + name: :filter, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Filter for paging" + }, + "deposit_index" => %Parameter{ + name: :index, + in: :query, + schema: %Schema{type: :integer, minimum: 0, maximum: 9_223_372_036_854_775_807}, + required: false, + description: "Deposit index for paging" + }, + "total_gas_used" => %Parameter{ + name: :total_gas_used, + in: :query, + schema: %Schema{type: :integer, minimum: 0}, + required: false, + description: "Total gas used for paging" + }, + "contract_address_hash_not_nullable" => %Parameter{ + name: :contract_address_hash, + in: :query, + schema: AddressHash, + required: false, + description: "Contract address hash for paging" + }, + "transactions_count_positive" => %Parameter{ + name: :transactions_count, + in: :query, + schema: %Schema{type: :integer, minimum: 1}, + required: false, + description: "Transactions count for paging" + }, + "coin_balance" => %Parameter{ + name: :coin_balance, + in: :query, + schema: %Schema{anyOf: [%Schema{type: :integer}, EmptyString, NullString]}, + required: false, + description: "Coin balance for paging" + }, + # todo: remove in the future as this param is unused in the pagination of state changes + "state_changes" => %Parameter{ + name: :state_changes, + in: :query, + schema: %Schema{type: :integer, nullable: true}, + required: false, + description: "State changes for paging" + }, + "world" => %Parameter{ + name: :world, + in: :query, + schema: AddressHash, + required: false, + description: "MUD world address hash for paging" + }, + "table_id" => %Parameter{ + name: :table_id, + in: :query, + schema: FullHash, + required: false, + description: "MUD table ID for paging" + }, + "key_bytes" => %Parameter{ + name: :key_bytes, + in: :query, + schema: HexString, + required: false, + description: "MUD record key_bytes for paging" + }, + "key0" => %Parameter{ + name: :key0, + in: :query, + schema: FullHash, + required: false, + description: "MUD record key0 for paging" + }, + "key1" => %Parameter{ + name: :key1, + in: :query, + schema: FullHash, + required: false, + description: "MUD record key1 for paging" + }, + "page_size" => %Parameter{ + name: :page_size, + in: :query, + schema: %Schema{type: :integer, minimum: 1, maximum: 50}, + required: false, + description: "Number of items returned per page" + }, + "page_token" => %Parameter{ + name: :page_token, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Page token for paging" + } + } + + @state_changes_paging_params %{ + # "items_count" is used for pagination for the list of transactions's state changes and it can be higher than 50. + # Thus, we extracted it to a separate map. + "items_count" => %Parameter{ + name: :items_count, + in: :query, + schema: %Schema{type: :integer, minimum: 1}, + required: false, + description: "Cumulative number of items to skip for keyset-based pagination of state changes" + }, + # todo: remove in the future as this param is unused in the pagination of state changes + "state_changes" => %Parameter{ + name: :state_changes, + in: :query, + schema: %Schema{type: :string, nullable: true}, + required: false, + description: "State changes for paging" + } + } + + @search_paging_params %{ + "q" => %Parameter{ + name: :q, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Search query for paging" + }, + "next_page_params_type" => %Parameter{ + name: :next_page_params_type, + in: :query, + schema: %Schema{type: :string}, + required: false, + description: "Next page params type for paging" + }, + "label" => %Parameter{ + name: :label, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Label for paging in the search results" + }, + "token" => %Parameter{ + name: :token, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Token for paging in the search results" + }, + "tac_operation" => %Parameter{ + name: :tac_operation, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "TAC operation for paging in the search results" + }, + "contract" => %Parameter{ + name: :contract, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Contract for paging in the search results" + }, + "metadata_tag" => %Parameter{ + name: :metadata_tag, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Metadata tag for paging in the search results" + }, + "block" => %Parameter{ + name: :block, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Block for paging in the search results" + }, + "blob" => %Parameter{ + name: :blob, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Blob for paging in the search results" + }, + "user_operation" => %Parameter{ + name: :user_operation, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "User operation for paging in the search results" + }, + "address" => %Parameter{ + name: :address, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "Address for paging in the search results" + }, + "ens_domain" => %Parameter{ + name: :ens_domain, + in: :query, + schema: %Schema{type: :object}, + required: false, + description: "ENS domain for paging in the search results" + } + } + + @doc """ + Returns a list of paging parameters based on the provided field names. + """ + @spec define_paging_params([String.t()]) :: [Parameter.t()] + def define_paging_params(fields) do + Enum.map(fields, fn field -> + Map.get(@paging_params, field) || raise "Unknown paging param: #{field}" + end) + end + + @doc """ + Returns a list of pagination parameters for `/api/v2/transactions/:transaction_hash_param/state-changes` API endpoint + """ + @spec define_state_changes_paging_params([String.t()]) :: [Parameter.t()] + def define_state_changes_paging_params(fields) do + Enum.map(fields, fn field -> + Map.get(@state_changes_paging_params, field) || raise "Unknown paging param: #{field}" + end) + end + + @doc """ + Returns a list of pagination parameters for `/api/v2/search` API endpoint + """ + @spec define_search_paging_params([String.t()]) :: [Parameter.t()] + def define_search_paging_params(fields) do + Enum.map(fields, fn field -> + Map.get(@search_paging_params, field) || raise "Unknown paging param: #{field}" + end) + end + + @doc """ + Returns the list of allowed transaction type labels based on the configured chain type. + """ + @spec allowed_transaction_types() :: [String.t()] + def allowed_transaction_types, do: @allowed_transaction_types + + @doc """ + Returns the integer pattern. + """ + @spec integer_pattern() :: Regex.t() + def integer_pattern, do: @integer_pattern + + @doc """ + Returns the non-negative integer pattern. + """ + @spec non_negative_integer_pattern() :: Regex.t() + def non_negative_integer_pattern, do: @non_negative_integer_pattern + + @doc """ + Returns the float pattern. + """ + @spec float_pattern() :: Regex.t() + def float_pattern, do: @float_pattern + + @doc """ + Returns the regex pattern for validating address hashes. + """ + @spec address_hash_pattern() :: Regex.t() + def address_hash_pattern, do: @address_hash_pattern + + @doc """ + Returns the regex pattern for validating full hashes. + """ + @spec full_hash_pattern() :: Regex.t() + def full_hash_pattern, do: @full_hash_pattern + + @doc """ + Returns the regex pattern for validating hex strings. + """ + @spec hex_string_pattern() :: Regex.t() + def hex_string_pattern, do: @hex_string_pattern +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/address_hash.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/address_hash.ex new file mode 100644 index 000000000000..9121049f708d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/address_hash.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.AddressHash do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.address_hash_pattern(), nullable: false}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/address_hash_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/address_hash_nullable.ex new file mode 100644 index 000000000000..8d499f9d5cb6 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/address_hash_nullable.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.AddressHashNullable do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.address_hash_pattern(), nullable: true}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_input.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_input.ex new file mode 100644 index 000000000000..f08fcc89b8b7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_input.ex @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.DecodedInput do + @moduledoc false + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + method_id: %Schema{type: :string, nullable: true}, + method_call: %Schema{type: :string, nullable: true}, + parameters: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string, nullable: false}, + type: %Schema{type: :string, nullable: false}, + value: %Schema{ + anyOf: [ + %Schema{type: :object}, + %Schema{ + type: :array, + items: %Schema{ + anyOf: [ + %Schema{type: :object}, + %Schema{type: :array, items: %Schema{type: :string}}, + %Schema{type: :string} + ] + } + }, + %Schema{type: :string} + ], + nullable: false + } + }, + nullable: false, + additionalProperties: false + } + } + }, + required: [:method_id, :method_call, :parameters], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex new file mode 100644 index 000000000000..3d80320d0abf --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/decoded_log_input.ex @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.DecodedLogInput do + @moduledoc false + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + method_id: %Schema{type: :string, nullable: true}, + method_call: %Schema{type: :string, nullable: true}, + parameters: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string, nullable: false}, + type: %Schema{type: :string, nullable: false}, + indexed: %Schema{type: :boolean, nullable: false}, + value: %Schema{ + anyOf: [ + %Schema{type: :object}, + %Schema{ + type: :array, + items: %Schema{ + anyOf: [ + %Schema{type: :object}, + %Schema{type: :array, items: %Schema{type: :string}}, + %Schema{type: :string} + ] + } + }, + %Schema{type: :string} + ], + nullable: false + } + }, + required: [:name, :type, :indexed, :value], + nullable: false, + additionalProperties: false + }, + nullable: false + } + }, + required: [:method_id, :method_call, :parameters], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/empty_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/empty_string.ex new file mode 100644 index 000000000000..f6b10c12740d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/empty_string.ex @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.EmptyString do + @moduledoc false + require OpenApiSpex + OpenApiSpex.schema(%{type: :string, minLength: 0, maxLength: 0}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/float_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/float_string.ex new file mode 100644 index 000000000000..2a9780f7e05e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/float_string.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.FloatString do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.float_pattern()}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/float_string_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/float_string_nullable.ex new file mode 100644 index 000000000000..1ffeddb8babc --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/float_string_nullable.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.FloatStringNullable do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.float_pattern(), nullable: true}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/full_hash.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/full_hash.ex new file mode 100644 index 000000000000..8f0b7d13a045 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/full_hash.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.FullHash do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.full_hash_pattern(), nullable: false}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/full_hash_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/full_hash_nullable.ex new file mode 100644 index 000000000000..3e7f05ff19d9 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/full_hash_nullable.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.FullHashNullable do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.full_hash_pattern(), nullable: true}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex new file mode 100644 index 000000000000..589f1e057b40 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.HexString do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.hex_string_pattern(), nullable: false}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex new file mode 100644 index 000000000000..3cb6b0a0f21b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/hex_string_nullable.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.HexStringNullable do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.hex_string_pattern(), nullable: true}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex new file mode 100644 index 000000000000..709dd6c7d7c8 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.Implementation do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.General.Implementation.ChainTypeCustomizations + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + description: "Proxy smart contract implementation", + type: :object, + properties: %{ + address_hash: General.AddressHash, + name: %Schema{type: :string, nullable: true} + }, + required: [:address_hash, :name], + additionalProperties: false + } + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation/chain_type_customizations.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation/chain_type_customizations.ex new file mode 100644 index 000000000000..27142ae3215e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/implementation/chain_type_customizations.ex @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.Implementation.ChainTypeCustomizations do + @moduledoc false + import BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations, only: [filecoin_robust_address_schema: 0] + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + @spec chain_type_fields(map()) :: map() + def chain_type_fields(schema) do + alias BlockScoutWeb.Schemas.Helper + + case Application.get_env(:explorer, :chain_type) do + :filecoin -> + schema + |> Helper.extend_schema( + properties: %{ + filecoin_robust_address: filecoin_robust_address_schema() + }, + required: [:filecoin_robust_address] + ) + + _ -> + schema + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string.ex new file mode 100644 index 000000000000..47afd2dce40e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.IntegerString do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.integer_pattern(), nullable: false}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string_nullable.ex new file mode 100644 index 000000000000..4880c707af0c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string_nullable.ex @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.IntegerStringNullable do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + OpenApiSpex.schema(%{type: :string, pattern: General.integer_pattern(), nullable: true}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string_or_empty_or_null_literal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string_or_empty_or_null_literal.ex new file mode 100644 index 000000000000..0bc03b67b373 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/integer_string_or_empty_or_null_literal.ex @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.IntegerStringOrEmptyOrNullLiteral do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + oneOf: [ + %Schema{type: :string, pattern: General.non_negative_integer_pattern()}, + %Schema{type: :string, enum: ["", "null"]} + ] + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/method_name_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/method_name_nullable.ex new file mode 100644 index 000000000000..c40f32fba4fe --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/method_name_nullable.ex @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.MethodNameNullable do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :string, + nullable: true, + example: "transfer", + description: "Method name or hex method id" + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/null_string.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/null_string.ex new file mode 100644 index 000000000000..824a2a9bbcbf --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/null_string.ex @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.NullString do + @moduledoc false + require OpenApiSpex + OpenApiSpex.schema(%{type: :string, pattern: ~r"^null$"}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/proxy_type.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/proxy_type.ex new file mode 100644 index 000000000000..7fa9c8f7f7fa --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/proxy_type.ex @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.ProxyType do + @moduledoc false + require OpenApiSpex + alias Ecto.Enum + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation + + OpenApiSpex.schema(%{ + type: :string, + enum: Enum.values(Implementation, :proxy_type), + nullable: true + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/tag.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/tag.ex new file mode 100644 index 000000000000..44c27af13ec5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/tag.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.Tag do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Address tag struct", + type: :object, + properties: %{ + address_hash: General.AddressHash, + display_name: %Schema{type: :string, nullable: false}, + label: %Schema{type: :string, nullable: false} + }, + required: [:address_hash, :display_name, :label], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/timestamp.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/timestamp.ex new file mode 100644 index 000000000000..5ca99bf91857 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/timestamp.ex @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.Timestamp do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :string, + format: :"date-time", + nullable: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/timestamp_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/timestamp_nullable.ex new file mode 100644 index 000000000000..789b381dcbff --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/timestamp_nullable.ex @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.TimestampNullable do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :string, + format: :"date-time", + nullable: true + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/url_nullable.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/url_nullable.ex new file mode 100644 index 000000000000..148f6c5d0fde --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/url_nullable.ex @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.URLNullable do + @moduledoc false + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :string, + format: :uri, + example: "https://example.com", + nullable: true + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/watchlist_name.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/watchlist_name.ex new file mode 100644 index 000000000000..1488dc358969 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/general/watchlist_name.ex @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.General.WatchlistName do + @moduledoc false + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Watchlist name struct", + type: :object, + properties: %{ + display_name: %Schema{type: :string, nullable: false}, + label: %Schema{type: :string, nullable: false} + }, + required: [:display_name, :label], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex new file mode 100644 index 000000000000..614b8dce064f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/internal_transaction.ex @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.InternalTransaction do + @moduledoc """ + This module defines the schema for the InternalTransaction struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + error: %Schema{ + type: :string, + nullable: true + }, + success: %Schema{ + type: :boolean, + nullable: false + }, + type: %Schema{ + type: :string, + nullable: false, + description: "Type of the internal transaction (call, create, etc.)" + }, + transaction_hash: General.FullHash, + transaction_index: %Schema{ + type: :integer, + nullable: false, + description: "The index of the parent transaction inside the block." + }, + from: Address, + to: %Schema{allOf: [Address], nullable: true}, + created_contract: %Schema{allOf: [Address], nullable: true}, + value: General.IntegerString, + block_number: %Schema{ + type: :integer, + nullable: false + }, + timestamp: General.Timestamp, + index: %Schema{ + type: :integer, + description: "The index of this internal transaction inside the transaction.", + nullable: false + }, + gas_limit: General.IntegerStringNullable + }, + required: [ + :error, + :success, + :type, + :transaction_hash, + :transaction_index, + :from, + :to, + :created_contract, + :value, + :block_number, + :timestamp, + :index, + :gas_limit + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex new file mode 100644 index 000000000000..a51d5a8c478b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/log.ex @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Log do + @moduledoc """ + This module defines the schema for the Log struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + transaction_hash: General.FullHash, + address: Address, + topics: %Schema{type: :array, items: General.HexStringNullable, nullable: false}, + data: General.HexString, + index: %Schema{type: :integer, nullable: false}, + decoded: %Schema{allOf: [General.DecodedLogInput], nullable: true}, + smart_contract: %Schema{allOf: [Address], nullable: true}, + block_hash: General.FullHash, + block_number: %Schema{type: :integer, nullable: false}, + block_timestamp: General.TimestampNullable + }, + required: [ + :transaction_hash, + :address, + :topics, + :data, + :index, + :decoded, + :smart_contract, + :block_hash, + :block_number, + :block_timestamp + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex new file mode 100644 index 000000000000..99aec57da86d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/record.ex @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.Record do + @moduledoc """ + This module defines the schema for the MUD Record struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "MUD Record struct.", + type: :object, + properties: %{ + id: General.HexString, + raw: %Schema{ + type: :object, + properties: %{ + block_number: General.IntegerString, + log_index: General.IntegerString, + dynamic_data: General.HexString, + encoded_lengths: General.FullHash, + key0: General.FullHash, + key1: General.FullHash, + key_bytes: General.HexString, + static_data: General.HexString + }, + nullable: false + }, + timestamp: General.Timestamp, + decoded: %Schema{type: :object, nullable: true}, + is_deleted: %Schema{type: :boolean, nullable: false} + }, + required: [ + :id, + :raw, + :timestamp, + :decoded, + :is_deleted + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex new file mode 100644 index 000000000000..4f25d6311796 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system.ex @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.System do + @moduledoc """ + This module defines the schema for the MUD System struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "MUD System struct.", + type: :object, + properties: %{ + name: %Schema{type: :string, nullable: false}, + address_hash: General.AddressHash + }, + required: [ + :name, + :address_hash + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex new file mode 100644 index 000000000000..877e5df31ba5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/system_details.ex @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.SystemDetails do + @moduledoc """ + This module defines the schema for the MUD SystemDetails struct. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "MUD SystemDetails struct.", + type: :object, + properties: %{ + name: %Schema{type: :string, nullable: false}, + abi: %Schema{type: :array, items: %Schema{type: :object}, nullable: false} + }, + required: [ + :name, + :abi + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex new file mode 100644 index 000000000000..ba7195ffc6b3 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table.ex @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.Table do + @moduledoc """ + This module defines the schema for the MUD Table struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "MUD Table struct.", + type: :object, + properties: %{ + table_id: General.FullHash, + table_full_name: %Schema{type: :string, nullable: false}, + table_type: %Schema{type: :string, enum: ["offchain", "onchain", "unknown"], nullable: false}, + table_namespace: %Schema{type: :string, nullable: false}, + table_name: %Schema{type: :string, nullable: false} + }, + required: [ + :table_id, + :table_full_name, + :table_type, + :table_namespace, + :table_name + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex new file mode 100644 index 000000000000..4761fb42743e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_schema.ex @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.TableSchema do + @moduledoc """ + This module defines the schema for the MUD TableSchema struct. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "MUD TableSchema struct.", + type: :object, + properties: %{ + key_names: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false}, + key_types: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false}, + value_names: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false}, + value_types: %Schema{type: :array, items: %Schema{type: :string, nullable: false}, nullable: false} + }, + required: [ + :key_names, + :key_types, + :value_names, + :value_types + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex new file mode 100644 index 000000000000..701e3703377d --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/table_with_schema.ex @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.TableWithSchema do + @moduledoc """ + This module defines the schema for the MUD Table with TableSchema struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.MUD.{Table, TableSchema} + + OpenApiSpex.schema(%{ + description: "MUD Table with TableSchema struct.", + type: :object, + properties: %{ + table: Table, + schema: TableSchema + }, + required: [ + :table, + :schema + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex new file mode 100644 index 000000000000..9be1ce3c5970 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/mud/world.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.MUD.World do + @moduledoc """ + This module defines the schema for the MUD World struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "MUD World struct.", + type: :object, + properties: %{ + address: Address.schema(), + coin_balance: General.IntegerStringNullable, + transactions_count: %Schema{type: :integer, nullable: true} + }, + required: [ + :address, + :coin_balance, + :transactions_count + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/nft_collection.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/nft_collection.ex new file mode 100644 index 000000000000..ae808fd06999 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/nft_collection.ex @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.NFTCollection do + @moduledoc """ + This module defines the schema for the NFTCollection struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, Token, TokenInstanceInList} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + token: Token, + amount: General.IntegerStringNullable, + token_instances: %Schema{ + type: :array, + items: TokenInstanceInList, + nullable: false + } + }, + required: [:token, :amount, :token_instances], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex new file mode 100644 index 000000000000..a535a05338f7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch.ex @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch do + @moduledoc """ + This module defines the schema for the Optimism Batch struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Optimism Batch struct.", + type: :object, + properties: %{ + number: %Schema{type: :integer}, + transactions_count: %Schema{type: :integer}, + l1_timestamp: General.Timestamp, + l1_transaction_hashes: %Schema{ + type: :array, + items: General.FullHash, + nullable: false + }, + batch_data_container: %Schema{ + type: :string, + enum: ["in_blob4844", "in_celestia", "in_eigenda", "in_alt_da", "in_calldata"], + nullable: true + }, + l2_end_block_number: %Schema{type: :integer}, + l2_start_block_number: %Schema{type: :integer} + }, + required: [ + :number, + :transactions_count, + :l1_timestamp, + :l1_transaction_hashes, + :batch_data_container, + :l2_end_block_number, + :l2_start_block_number + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex new file mode 100644 index 000000000000..fb63d718f8be --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/batch/detailed.ex @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Batch.Detailed do + @moduledoc """ + This module defines the schema for batch response from /api/v2/optimism/batches/:number and + /api/v2/optimism/batches/da/celestia/:height/:commitment + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Optimism.Batch + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Batch.schema() + |> Helper.extend_schema( + nullable: true, + properties: %{ + blobs: %Schema{ + type: :array, + items: %Schema{ + description: "Blob struct bound with Optimism batch.", + type: :object, + properties: %{ + hash: %Schema{ + type: :string, + pattern: General.hex_string_pattern(), + nullable: false, + description: "EIP-4844 blob hash." + }, + l1_transaction_hash: %Schema{ + type: :string, + pattern: General.full_hash_pattern(), + nullable: false, + description: "L1 transaction hash bound with the blob." + }, + l1_timestamp: %Schema{ + type: :string, + format: :"date-time", + nullable: false, + description: "L1 transaction timestamp bound with the blob." + }, + height: %Schema{type: :integer, nullable: false, description: "Celestia block height."}, + namespace: %Schema{ + type: :string, + pattern: General.hex_string_pattern(), + nullable: false, + description: "Celestia blob namespace." + }, + commitment: %Schema{ + type: :string, + pattern: General.hex_string_pattern(), + nullable: false, + description: "Celestia or Alt-DA blob commitment." + }, + cert: %Schema{ + type: :string, + pattern: General.hex_string_pattern(), + nullable: false, + description: "EigenDA cert raw bytes." + } + }, + required: [ + :l1_transaction_hash, + :l1_timestamp + ], + additionalProperties: false + }, + nullable: false + } + } + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex new file mode 100644 index 000000000000..f93befbee16f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit.ex @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Deposit do + @moduledoc """ + This module defines the schema for the Optimism Deposit struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Optimism Deposit struct.", + type: :object, + properties: %{ + l1_block_number: %Schema{type: :integer}, + l1_block_timestamp: General.Timestamp, + l1_transaction_hash: General.FullHash, + l1_transaction_origin: General.AddressHash, + l2_transaction_gas_limit: General.IntegerString, + l2_transaction_hash: General.FullHash + }, + required: [ + :l1_block_number, + :l1_block_timestamp, + :l1_transaction_hash, + :l1_transaction_origin, + :l2_transaction_gas_limit, + :l2_transaction_hash + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex new file mode 100644 index 000000000000..504592a56528 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/deposit/main_page.ex @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Deposit.MainPage do + @moduledoc """ + This module defines the schema for the Optimism Deposit struct for the main page. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Optimism Deposit struct for the main page.", + type: :object, + properties: %{ + l1_block_number: %Schema{type: :integer}, + l1_block_timestamp: General.Timestamp, + l1_transaction_hash: General.FullHash, + l2_transaction_hash: General.FullHash + }, + required: [ + :l1_block_number, + :l1_block_timestamp, + :l1_transaction_hash, + :l2_transaction_hash + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex new file mode 100644 index 000000000000..2aa97fc3adf7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/game.ex @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Game do + @moduledoc """ + This module defines the schema for the Optimism Dispute Game struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Optimism Dispute Game struct.", + type: :object, + properties: %{ + contract_address_hash: General.AddressHash, + created_at: General.Timestamp, + game_type: %Schema{type: :integer}, + index: %Schema{type: :integer}, + l2_block_number: %Schema{type: :integer}, + resolved_at: General.TimestampNullable, + status: %Schema{ + type: :string, + enum: ["In progress", "Challenger wins", "Defender wins"], + nullable: false + } + }, + required: [ + :contract_address_hash, + :created_at, + :game_type, + :index, + :l2_block_number, + :resolved_at, + :status + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex new file mode 100644 index 000000000000..63d8b5f19c00 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/output_root.ex @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.OutputRoot do + @moduledoc """ + This module defines the schema for the Optimism Output Root struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Optimism Output Root struct.", + type: :object, + properties: %{ + l1_block_number: %Schema{type: :integer}, + l1_timestamp: General.Timestamp, + l1_transaction_hash: General.FullHash, + l2_block_number: %Schema{type: :integer}, + l2_output_index: %Schema{type: :integer}, + output_root: General.FullHash + }, + required: [ + :l1_block_number, + :l1_timestamp, + :l1_transaction_hash, + :l2_block_number, + :l2_output_index, + :output_root + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex new file mode 100644 index 000000000000..6ba9cf2dbbbd --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/optimism/withdrawal.ex @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Optimism.Withdrawal do + @moduledoc """ + This module defines the schema for the Optimism Withdrawal struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{AddressNullable, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Optimism Withdrawal struct.", + type: :object, + properties: %{ + challenge_period_end: General.TimestampNullable, + from: AddressNullable, + l1_transaction_hash: General.FullHashNullable, + l2_timestamp: General.TimestampNullable, + l2_transaction_hash: General.FullHash, + msg_data: General.HexStringNullable, + msg_gas_limit: General.IntegerStringNullable, + msg_nonce: %Schema{type: :integer}, + msg_nonce_raw: General.IntegerString, + msg_nonce_version: %Schema{type: :integer}, + msg_sender_address_hash: General.FullHashNullable, + msg_target_address_hash: General.FullHashNullable, + msg_value: General.IntegerStringNullable, + portal_contract_address_hash: General.AddressHashNullable, + status: %Schema{ + type: :string, + enum: [ + "Waiting for state root", + "Ready to prove", + "Waiting a game to resolve", + "In challenge period", + "Ready for relay", + "Proven", + "Relayed" + ], + nullable: false + } + }, + required: [ + :challenge_period_end, + :from, + :l1_transaction_hash, + :l2_timestamp, + :l2_transaction_hash, + :msg_data, + :msg_gas_limit, + :msg_nonce, + :msg_nonce_raw, + :msg_nonce_version, + :msg_sender_address_hash, + :msg_target_address_hash, + :msg_value, + :portal_contract_address_hash, + :status + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex new file mode 100644 index 000000000000..8d06d1d96fc0 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/account.ex @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Account do + @moduledoc """ + This module defines the schema for the Account struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, AddressNullable, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Account struct.", + type: :object, + properties: %{ + address: Address, + creation_op_hash: General.FullHashNullable, + creation_transaction_hash: General.FullHashNullable, + creation_timestamp: General.TimestampNullable, + factory: AddressNullable, + total_ops: %Schema{type: :integer, nullable: false, minimum: 0} + }, + required: [ + :address, + :creation_op_hash, + :creation_transaction_hash, + :creation_timestamp, + :factory, + :total_ops + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex new file mode 100644 index 000000000000..e7703f5b67d1 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundle.ex @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Bundle do + @moduledoc """ + This module defines the schema for the Bundle struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Bundle struct.", + type: :object, + properties: %{ + transaction_hash: General.FullHash, + bundler: Address, + block_number: General.IntegerString, + bundle_index: %Schema{type: :integer, nullable: false, minimum: 0}, + timestamp: General.Timestamp, + total_ops: %Schema{type: :integer, nullable: false, minimum: 0} + }, + required: [ + :transaction_hash, + :bundler, + :block_number, + :bundle_index, + :timestamp, + :total_ops + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex new file mode 100644 index 000000000000..fbb782dcf2e3 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/bundler.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Bundler do + @moduledoc """ + This module defines the schema for the Bundler struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Address + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Bundler struct.", + type: :object, + properties: %{ + address: Address, + total_bundles: %Schema{type: :integer, nullable: false, minimum: 0}, + total_ops: %Schema{type: :integer, nullable: false, minimum: 0} + }, + required: [ + :address, + :total_bundles, + :total_ops + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex new file mode 100644 index 000000000000..3f0391f7033c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/factory.ex @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Factory do + @moduledoc """ + This module defines the schema for the Factory struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Address + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Factory struct.", + type: :object, + properties: %{ + address: Address, + total_accounts: %Schema{type: :integer, nullable: false, minimum: 0} + }, + required: [ + :address, + :total_accounts + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex new file mode 100644 index 000000000000..92bfdf9d137a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/paymaster.ex @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Paymaster do + @moduledoc """ + This module defines the schema for the Paymaster struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Address + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Paymaster struct.", + type: :object, + properties: %{ + address: Address, + total_ops: %Schema{type: :integer, nullable: false, minimum: 0} + }, + required: [ + :address, + :total_ops + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/status.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/status.ex new file mode 100644 index 000000000000..124453af09e6 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/status.ex @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.Status do + @moduledoc """ + This module defines the schema for the Status struct. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "AccountAbstractionStatus", + description: "Status struct.", + type: :object, + properties: %{ + finished_past_indexing: %Schema{type: :boolean, nullable: false} + }, + required: [ + :finished_past_indexing + ], + additionalProperties: %Schema{ + type: :object, + properties: %{ + enabled: %Schema{type: :boolean, nullable: false}, + live: %Schema{type: :boolean, nullable: false}, + past_db_logs_indexing_finished: %Schema{type: :boolean, nullable: false}, + past_rpc_logs_indexing_finished: %Schema{type: :boolean, nullable: false} + }, + additionalProperties: false + }, + nullable: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex new file mode 100644 index 000000000000..13847ecc7b0e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation.ex @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperation do + @moduledoc """ + This module defines the schema for the UserOperation struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, AddressNullable, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "UserOperation struct.", + type: :object, + properties: %{ + hash: General.FullHash, + sender: Address, + nonce: General.FullHash, + call_data: General.HexString, + call_gas_limit: General.IntegerString, + verification_gas_limit: General.IntegerString, + pre_verification_gas: General.IntegerString, + max_fee_per_gas: General.IntegerString, + max_priority_fee_per_gas: General.IntegerString, + signature: General.HexString, + raw: %Schema{ + description: "Raw user operation data.", + anyOf: [ + %Schema{ + description: "Raw user operation data v0.6.", + type: :object, + properties: %{ + sender: General.AddressHash, + nonce: General.IntegerString, + init_code: General.HexString, + call_data: General.HexString, + call_gas_limit: General.IntegerString, + verification_gas_limit: General.IntegerString, + pre_verification_gas: General.IntegerString, + max_fee_per_gas: General.IntegerString, + max_priority_fee_per_gas: General.IntegerString, + paymaster_and_data: General.HexString, + signature: General.HexString + }, + required: [ + :sender, + :nonce, + :init_code, + :call_data, + :call_gas_limit, + :verification_gas_limit, + :pre_verification_gas, + :max_fee_per_gas, + :max_priority_fee_per_gas, + :paymaster_and_data, + :signature + ], + nullable: false, + additionalProperties: false + }, + %Schema{ + description: "Raw user operation data v0.7-v0.9.", + type: :object, + properties: %{ + sender: General.AddressHash, + nonce: General.IntegerString, + init_code: General.HexString, + call_data: General.HexString, + account_gas_limits: General.FullHash, + pre_verification_gas: General.IntegerString, + gas_fees: General.FullHash, + paymaster_and_data: General.HexString, + signature: General.HexString + }, + required: [ + :sender, + :nonce, + :init_code, + :call_data, + :account_gas_limits, + :pre_verification_gas, + :gas_fees, + :paymaster_and_data, + :signature + ], + nullable: false, + additionalProperties: false + } + ] + }, + aggregator: General.AddressHashNullable, + aggregator_signature: General.HexStringNullable, + entry_point: Address, + entry_point_version: %Schema{ + type: :string, + enum: ["v0.6", "v0.7", "v0.8", "v0.9"], + nullable: false + }, + transaction_hash: General.FullHash, + block_number: General.IntegerString, + block_hash: General.FullHash, + bundler: Address, + bundle_index: %Schema{type: :integer, nullable: false}, + index: %Schema{type: :integer, nullable: false}, + factory: AddressNullable, + paymaster: AddressNullable, + status: %Schema{type: :boolean, nullable: false}, + revert_reason: General.HexStringNullable, + gas: General.IntegerString, + gas_price: General.IntegerString, + gas_used: General.IntegerString, + sponsor_type: %Schema{ + type: :string, + enum: ["wallet_deposit", "wallet_balance", "paymaster_sponsor", "paymaster_hybrid"], + nullable: false + }, + user_logs_start_index: %Schema{type: :integer, nullable: false}, + user_logs_count: %Schema{type: :integer, nullable: false}, + fee: General.IntegerString, + consensus: %Schema{type: :boolean, nullable: true}, + timestamp: General.TimestampNullable, + execute_target: AddressNullable, + execute_call_data: General.HexStringNullable, + decoded_call_data: %Schema{allOf: [General.DecodedInput], nullable: true}, + decoded_execute_call_data: %Schema{allOf: [General.DecodedInput], nullable: true} + }, + required: [ + :hash, + :sender, + :nonce, + :call_data, + :call_gas_limit, + :verification_gas_limit, + :pre_verification_gas, + :max_fee_per_gas, + :max_priority_fee_per_gas, + :signature, + :raw, + :aggregator, + :aggregator_signature, + :entry_point, + :entry_point_version, + :transaction_hash, + :block_number, + :block_hash, + :bundler, + :bundle_index, + :index, + :factory, + :paymaster, + :status, + :revert_reason, + :gas, + :gas_price, + :gas_used, + :sponsor_type, + :user_logs_start_index, + :user_logs_count, + :fee, + :consensus, + :timestamp, + :execute_target, + :execute_call_data, + :decoded_call_data, + :decoded_execute_call_data + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation_in_list.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation_in_list.ex new file mode 100644 index 000000000000..640ba8e3a4c5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/account_abstraction/user_operation_in_list.ex @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.AccountAbstraction.UserOperationInList do + @moduledoc """ + This module defines the schema for the UserOperationInList struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "UserOperationInList struct.", + type: :object, + properties: %{ + hash: General.FullHash, + address: Address, + entry_point: Address, + entry_point_version: %Schema{ + type: :string, + enum: ["v0.6", "v0.7", "v0.8", "v0.9"], + nullable: false + }, + transaction_hash: General.FullHash, + block_number: General.IntegerString, + status: %Schema{type: :boolean, nullable: false}, + fee: General.IntegerString, + timestamp: General.TimestampNullable + }, + required: [ + :hash, + :address, + :entry_point, + :entry_point_version, + :transaction_hash, + :block_number, + :status, + :fee, + :timestamp + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/metadata.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/metadata.ex new file mode 100644 index 000000000000..c7bc0f148901 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/metadata.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.Metadata do + @moduledoc """ + This module defines the schema for the Metadata struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Proxy.MetadataTag + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Metadata struct", + type: :object, + properties: %{ + tags: %Schema{description: "Metadata tags linked with the address", type: :array, items: MetadataTag} + }, + required: [:tags], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/metadata_tag.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/metadata_tag.ex new file mode 100644 index 000000000000..48c9282cb4de --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/proxy/metadata_tag.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Proxy.MetadataTag do + @moduledoc """ + This module defines the schema for the MetadataTag struct. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Metadata tag struct", + type: :object, + properties: %{ + slug: %Schema{type: :string, nullable: false}, + name: %Schema{type: :string, nullable: false}, + tagType: %Schema{ + type: :string, + enum: ["name", "generic", "classifier", "information", "note", "protocol"], + nullable: false + }, + ordinal: %Schema{type: :integer, nullable: false}, + meta: %Schema{type: :object, nullable: true, additionalProperties: true} + }, + required: [:slug, :name, :tagType, :ordinal, :meta], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex new file mode 100644 index 000000000000..1593fe3f759b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/batch.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Scroll.Batch do + @moduledoc """ + This module defines the schema for the Scroll Batch struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Scroll Batch struct.", + type: :object, + properties: %{ + number: %Schema{type: :integer}, + transactions_count: %Schema{type: :integer, nullable: true}, + start_block_number: %Schema{type: :integer, nullable: true}, + end_block_number: %Schema{type: :integer, nullable: true}, + data_availability: %Schema{ + type: :object, + properties: %{ + batch_data_container: %Schema{ + type: :string, + enum: ["in_blob4844", "in_calldata"], + nullable: false + } + }, + required: [:batch_data_container], + additionalProperties: false + }, + commitment_transaction: %Schema{ + type: :object, + properties: %{ + block_number: %Schema{type: :integer}, + hash: General.FullHash, + timestamp: General.Timestamp + }, + required: [:block_number, :hash, :timestamp], + additionalProperties: false + }, + confirmation_transaction: %Schema{ + type: :object, + properties: %{ + block_number: %Schema{type: :integer, nullable: true}, + hash: General.FullHashNullable, + timestamp: General.TimestampNullable + }, + required: [:block_number, :hash, :timestamp], + additionalProperties: false + } + }, + required: [ + :number, + :transactions_count, + :start_block_number, + :end_block_number, + :data_availability, + :commitment_transaction, + :confirmation_transaction + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex new file mode 100644 index 000000000000..a2df8336ae5f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/scroll/bridge.ex @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Scroll.Bridge do + @moduledoc """ + This module defines the schema for the Scroll Bridge item struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Scroll Bridge item struct.", + type: :object, + properties: %{ + completion_transaction_hash: General.FullHashNullable, + id: %Schema{type: :integer, nullable: true}, + origination_timestamp: General.TimestampNullable, + origination_transaction_block_number: %Schema{type: :integer, nullable: true}, + origination_transaction_hash: General.FullHashNullable, + value: General.IntegerStringNullable + }, + required: [ + :completion_transaction_hash, + :id, + :origination_timestamp, + :origination_transaction_block_number, + :origination_transaction_hash, + :value + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex new file mode 100644 index 000000000000..8d8484375ac1 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/search/results.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Search.Results do + @moduledoc """ + This module defines the schema for search results response. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "SearchResult", + description: "Search results containing blocks, transactions, and addresses", + type: :object, + properties: %{ + items: %Schema{type: :array, items: %Schema{type: :object}}, + next_page_params: %Schema{type: :object, nullable: true, additionalProperties: true} + }, + required: [] + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/signed_authorization.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/signed_authorization.ex new file mode 100644 index 000000000000..e690c4ee5b89 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/signed_authorization.ex @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.SignedAuthorization do + @moduledoc """ + This module defines the schema for the SignedAuthorization struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + address_hash: General.AddressHash, + chain_id: %Schema{type: :integer, nullable: false}, + nonce: General.IntegerString, + r: General.IntegerString, + s: General.IntegerString, + v: %Schema{type: :integer, nullable: false}, + authority: General.AddressHash, + status: %Schema{ + type: :string, + enum: ["ok", "invalid_chain_id", "invalid_signature", "invalid_nonce"], + nullable: true + } + }, + required: [:address_hash, :chain_id, :nonce, :r, :s, :v, :authority, :status], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex new file mode 100644 index 000000000000..221e3d4a851a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract.ex @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations do + @moduledoc false + import BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations, only: [filecoin_robust_address_schema: 0] + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + @spec chain_type_fields(map()) :: map() + # credo:disable-for-next-line + def chain_type_fields(schema) do + case Application.get_env(:explorer, :chain_type) do + :arbitrum -> + schema + |> Helper.extend_schema( + properties: %{ + package_name: %Schema{type: :string, nullable: true}, + github_repository_metadata: %Schema{type: :object, nullable: true} + }, + required: [] + ) + + :zksync -> + schema + |> Helper.extend_schema( + properties: %{ + zk_compiler_version: %Schema{type: :string, nullable: true} + }, + required: [] + ) + + :filecoin -> + schema + |> Helper.extend_schema( + properties: %{ + verified_twin_filecoin_robust_address: filecoin_robust_address_schema() + }, + required: [] + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.SmartContract do + @moduledoc """ + OpenAPI schema for a smart contract response. + + Matches the API response shape returned by the `SmartContractController.smart_contract/2` action. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + description: "Smart contract", + type: :object, + properties: %{ + file_path: %Schema{type: :string, nullable: true}, + creation_status: %Schema{type: :string, nullable: true}, + source_code: %Schema{type: :string, nullable: true}, + deployed_bytecode: %Schema{type: :string, nullable: true}, + coin_balance: %Schema{type: :string, nullable: true}, + compiler_version: %Schema{type: :string, nullable: true}, + has_constructor_args: %Schema{type: :boolean, nullable: true}, + language: %Schema{ + type: :string, + enum: ["solidity", "vyper", "yul", "scilla", "stylus_rust", "geas"], + nullable: true + }, + license_type: %Schema{type: :string, nullable: true}, + market_cap: %Schema{type: :string, nullable: true}, + optimization_enabled: %Schema{type: :boolean, nullable: true}, + reputation: %Schema{type: :string, nullable: true}, + transactions_count: %Schema{type: :integer, nullable: true}, + verified_at: %Schema{type: :string, format: :"date-time", nullable: true}, + verification_metadata: %Schema{type: :object, nullable: true}, + verified_twin_address_hash: %Schema{type: :string, nullable: true}, + compiler_settings: %Schema{type: :object, nullable: true}, + optimization_runs: %Schema{type: :integer, nullable: true}, + sourcify_repo_url: %Schema{type: :string, nullable: true}, + decoded_constructor_args: %Schema{ + type: :array, + items: %Schema{type: :array, items: %Schema{anyOf: [%Schema{type: :object}, %Schema{type: :string}]}}, + nullable: true + }, + is_verified_via_verifier_alliance: %Schema{type: :boolean, nullable: true}, + implementations: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + address_hash: General.AddressHash, + name: %Schema{type: :string, nullable: true} + } + }, + nullable: true + }, + proxy_type: %Schema{type: :string, nullable: true}, + external_libraries: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{name: %Schema{type: :string, nullable: true}, address_hash: General.AddressHash} + }, + nullable: true + }, + creation_bytecode: %Schema{type: :string, nullable: true}, + name: %Schema{type: :string, nullable: true}, + is_blueprint: %Schema{type: :boolean, nullable: true}, + is_verified: %Schema{type: :boolean, nullable: true}, + is_fully_verified: %Schema{type: :boolean, nullable: true}, + is_verified_via_eth_bytecode_db: %Schema{type: :boolean, nullable: true}, + evm_version: %Schema{type: :string, nullable: true}, + can_be_visualized_via_sol2uml: %Schema{type: :boolean, nullable: true}, + is_verified_via_sourcify: %Schema{type: :boolean, nullable: true}, + additional_sources: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{file_path: %Schema{type: :string}, source_code: %Schema{type: :string}} + }, + nullable: true + }, + certified: %Schema{type: :boolean}, + conflicting_implementations: %Schema{type: :array, nullable: true, items: %Schema{type: :object}}, + abi: %Schema{type: :array, nullable: true, items: %Schema{type: :object}}, + is_changed_bytecode: %Schema{type: :boolean, nullable: true}, + is_partially_verified: %Schema{type: :boolean, nullable: true}, + constructor_args: %Schema{type: :string, nullable: true} + }, + required: [ + :proxy_type, + :implementations, + :conflicting_implementations, + :deployed_bytecode, + :creation_bytecode, + :creation_status + ], + additionalProperties: false + } + |> ChainTypeCustomizations.chain_type_fields() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.ListItem do + @moduledoc """ + OpenAPI schema for a smart contract list item. + + Matches the response shape of individual items returned by the + `SmartContractController.smart_contracts_list/2` action. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.SmartContract.ChainTypeCustomizations + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + title: "SmartContractListItem", + description: "Smart contract list item", + type: :object, + properties: %{ + address: BlockScoutWeb.Schemas.API.V2.Address, + compiler_version: %Schema{type: :string, nullable: true}, + optimization_enabled: %Schema{type: :boolean, nullable: true}, + transactions_count: %Schema{type: :integer, nullable: true}, + language: %Schema{type: :string, nullable: true}, + verified_at: %Schema{type: :string, format: :"date-time", nullable: true}, + market_cap: %Schema{type: :string, nullable: true}, + has_constructor_args: %Schema{type: :boolean, nullable: true}, + coin_balance: %Schema{type: :string, nullable: true}, + license_type: %Schema{type: :string, nullable: true}, + certified: %Schema{type: :boolean}, + reputation: %Schema{type: :string, nullable: true} + }, + required: [ + :address, + :compiler_version, + :optimization_enabled, + :language, + :verified_at, + :has_constructor_args, + :license_type, + :certified, + :reputation + ], + additionalProperties: false + } + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/audit_report.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/audit_report.ex new file mode 100644 index 000000000000..0e9fd7c17a0f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/audit_report.ex @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.AuditReport do + @moduledoc false + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Smart contract audit report item", + type: :object, + properties: %{ + audit_company_name: %Schema{type: :string}, + audit_publish_date: %Schema{type: :string, format: :date}, + audit_report_url: %Schema{type: :string} + }, + required: [:audit_company_name, :audit_publish_date, :audit_report_url] + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex new file mode 100644 index 000000000000..1efe6233dc70 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/counters.ex @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.Counters do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + description: "Smart contracts counters", + type: :object, + properties: %{ + smart_contracts: General.IntegerString, + new_smart_contracts_24h: General.IntegerString, + verified_smart_contracts: General.IntegerString, + new_verified_smart_contracts_24h: General.IntegerString + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/language.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/language.ex new file mode 100644 index 000000000000..697cd37b2df8 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/smart_contract/language.ex @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.SmartContract.Language do + @moduledoc false + require OpenApiSpex + + alias Explorer.Chain.SmartContract + + OpenApiSpex.schema(%{type: :string, enum: SmartContract.language_strings()}) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/hot_smart_contract.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/hot_smart_contract.ex new file mode 100644 index 000000000000..c5b6df98a317 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/hot_smart_contract.ex @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Stats.HotContract do + @moduledoc """ + This module defines the schema for the HotContract struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + contract_address: Address, + transactions_count: %Schema{type: :integer, minimum: 1}, + total_gas_used: %Schema{type: :integer, minimum: 1}, + balance: General.IntegerStringNullable + }, + required: [:contract_address, :transactions_count, :total_gas_used, :balance], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex new file mode 100644 index 000000000000..be2bd076a4a5 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/stats/response.ex @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Stats.Response.ChainTypeCustomizations do + @moduledoc false + require OpenApiSpex + + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + @spec chain_type_fields(map()) :: map() + def chain_type_fields(schema) do + case chain_type() do + :rsk -> + schema + |> Helper.extend_schema( + properties: %{ + rootstock_locked_btc: %Schema{type: :string, nullable: true, description: "Present on RSK chains only"} + } + ) + + :optimism -> + schema + |> Helper.extend_schema( + properties: %{ + last_output_root_size: %Schema{ + type: :string, + nullable: true, + description: "Present on Optimism chains only" + } + } + ) + + _ -> + schema + end + end + + @doc """ + Applies chain identity-specific field customizations to the given schema based on the configured chain identity. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain identity-specific properties added based on the current chain identity configuration + """ + @spec chain_identity_fields(map()) :: map() + def chain_identity_fields(schema) do + case chain_identity() do + {:optimism, :celo} -> + schema + |> Helper.extend_schema( + properties: %{ + celo: %Schema{ + type: :object, + nullable: true, + properties: %{epoch_number: %Schema{type: :integer, nullable: true}} + } + } + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Stats.Response do + @moduledoc """ + This module defines the schema for response from /api/v2/stats. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Stats.Response.ChainTypeCustomizations + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %Schema{ + type: :object, + title: "StatsResponse", + description: "Stats response", + properties: %{ + average_block_time: %Schema{type: :number, format: :float}, + coin_image: %Schema{type: :string, nullable: true}, + coin_price: General.FloatStringNullable, + coin_price_change_percentage: %Schema{type: :number, format: :float, nullable: true}, + gas_price_updated_at: General.TimestampNullable, + gas_prices: %Schema{type: :object, nullable: true}, + gas_prices_update_in: %Schema{type: :integer, nullable: true}, + gas_used_today: %Schema{anyOf: [General.IntegerStringNullable, %Schema{type: :integer}]}, + market_cap: General.FloatString, + network_utilization_percentage: %Schema{type: :number, nullable: true}, + secondary_coin_image: %Schema{type: :string, nullable: true}, + secondary_coin_price: General.FloatStringNullable, + static_gas_price: General.IntegerStringNullable, + total_addresses: General.IntegerString, + total_blocks: General.IntegerString, + total_gas_used: General.IntegerString, + total_transactions: General.IntegerString, + transactions_today: General.IntegerString, + tvl: General.IntegerStringNullable + } + } + |> ChainTypeCustomizations.chain_type_fields() + |> ChainTypeCustomizations.chain_identity_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex new file mode 100644 index 000000000000..788d4504a2d8 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token.ex @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Token.ChainTypeCustomizations do + @moduledoc false + require OpenApiSpex + import BlockScoutWeb.Schemas.API.V2.Address.ChainTypeCustomizations, only: [filecoin_robust_address_schema: 0] + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias Explorer.Chain.BridgedToken + alias OpenApiSpex.Schema + + def chain_type_fields(schema) do + case Application.get_env(:explorer, :chain_type) do + :filecoin -> + schema + |> Helper.extend_schema( + properties: %{filecoin_robust_address: filecoin_robust_address_schema()}, + required: [:filecoin_robust_address] + ) + + :zilliqa -> + # Added by `ZilliqaView.extend_token_json_response/2` for ZRC-2 tokens only, + # hence optional (not added to `required`). + schema + |> Helper.extend_schema( + properties: %{ + zilliqa: %Schema{ + type: :object, + nullable: false, + properties: %{zrc2_address_hash: General.AddressHashNullable}, + required: [:zrc2_address_hash], + additionalProperties: false + } + } + ) + + _ -> + schema + end + end + + def maybe_append_bridged_info(schema) do + if BridgedToken.enabled?() do + schema + |> put_in([:properties, :is_bridged], %Schema{type: :boolean, nullable: false}) + |> update_in([:required], &[:is_bridged | &1]) + else + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Token do + @moduledoc """ + This module defines the schema for the Token struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Token.{ChainTypeCustomizations, Type} + alias Explorer.Chain.Address.Reputation + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + description: "Token struct", + type: :object, + properties: %{ + address_hash: General.AddressHash, + symbol: %Schema{type: :string, nullable: true}, + name: %Schema{type: :string, nullable: true}, + decimals: General.IntegerStringNullable, + type: %Schema{allOf: [Type], nullable: true}, + holders_count: General.IntegerStringNullable, + exchange_rate: General.FloatStringNullable, + volume_24h: General.FloatStringNullable, + total_supply: General.IntegerStringNullable, + icon_url: General.URLNullable, + circulating_market_cap: General.FloatStringNullable, + circulating_supply: General.FloatStringNullable, + reputation: %Schema{ + type: :string, + enum: Reputation.enum_values(), + description: "Reputation of the token", + nullable: true + }, + bridge_type: %Schema{ + type: :string, + enum: ["omni", "amb"], + description: "Type of bridge used for this bridged token", + nullable: true + }, + foreign_address: %Schema{type: :string, pattern: General.address_hash_pattern(), nullable: true}, + origin_chain_id: General.IntegerStringNullable + }, + required: [ + :address_hash, + :symbol, + :name, + :decimals, + :type, + :holders_count, + :exchange_rate, + :volume_24h, + :total_supply, + :icon_url, + :circulating_market_cap, + :circulating_supply, + :reputation + ], + additionalProperties: false + } + |> ChainTypeCustomizations.chain_type_fields() + |> ChainTypeCustomizations.maybe_append_bridged_info() + ) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Token.Type do + @moduledoc """ + This module defines the schema for the Token type. + """ + require OpenApiSpex + + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + @token_types ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] + + if @chain_type == :zilliqa do + @chain_type_token_types ["ZRC-2"] + else + @chain_type_token_types [] + end + + OpenApiSpex.schema(%{ + title: "TokenType", + type: :string, + enum: @token_types ++ @chain_type_token_types + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex new file mode 100644 index 000000000000..beb1c8cae0f7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/counters.ex @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Token.Counters do + @moduledoc """ + This module defines the schema for the response from /api/v2/tokens/:address_hash_param/counters. + Example response: {"token_holders_count":"0","transfers_count":"0"} + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%Schema{ + title: "TokenCountersResponse", + description: "Token counters response", + type: :object, + properties: %{ + token_holders_count: General.IntegerString, + transfers_count: General.IntegerString + }, + required: [:token_holders_count, :transfers_count], + additionalProperties: false, + example: %{token_holders_count: "0", transfers_count: "0"} + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/holder.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/holder.ex new file mode 100644 index 000000000000..f57320e1393b --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/holder.ex @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Token.Holder do + @moduledoc """ + This module defines the schema for a token holder response. + Example response: + { + "address": { ... }, + "token_id": null, + "value": "19474530513868000" + } + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%Schema{ + title: "TokenHolderResponse", + description: "Token holder response", + type: :object, + properties: %{ + address: Address.schema(), + token_id: General.IntegerStringNullable, + value: General.IntegerStringNullable + }, + required: [:address, :token_id, :value], + additionalProperties: false, + example: %{ + address: %{ + ens_domain_name: nil, + hash: "0xF977814e90dA44bFA03b6295A0616a897441aceC", + implementations: [], + is_contract: false, + is_scam: false, + is_verified: false, + metadata: %{ + tags: [ + %{ + meta: %{main_entity: "Binance", tooltipUrl: "https://www.binance.com/"}, + name: "Binance: Hot Wallet 20", + ordinal: 10, + slug: "binance-hot-wallet-20", + tagType: "name" + }, + %{ + meta: %{tooltipUrl: "https://www.binance.com"}, + name: "Binance 8", + ordinal: 10, + slug: "binance-8", + tagType: "name" + }, + %{ + meta: %{}, + name: "HOT WALLET", + ordinal: 0, + slug: "hot-wallet", + tagType: "generic" + }, + %{ + meta: %{}, + name: "Exchange", + ordinal: 0, + slug: "exchange", + tagType: "generic" + }, + %{ + meta: %{}, + name: "Binance", + ordinal: 0, + slug: "binance", + tagType: "protocol" + } + ] + }, + name: nil, + private_tags: [], + proxy_type: nil, + public_tags: [], + reputation: "ok", + watchlist_names: [] + }, + token_id: nil, + value: "19474530513868000" + } + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/response.ex new file mode 100644 index 000000000000..f300c4a5f240 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token/response.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Token.Response do + @moduledoc """ + This module defines the schema for token response from /api/v2/tokens/:token_address_param. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Token + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Token.schema() + |> Helper.extend_schema( + title: "TokenResponse", + description: "Token response" + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex new file mode 100644 index 000000000000..96078c5488a9 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance.ex @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.TokenInstance do + @moduledoc """ + This module defines the schema for the TokenInstance struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: General.IntegerString, + metadata: %Schema{ + type: :object, + nullable: true, + additionalProperties: true, + example: %{"name" => "Test", "description" => "Test", "image" => "https://example.com/image.png"} + }, + owner: %Schema{allOf: [Address], nullable: true}, + token: %Schema{allOf: [Token], nullable: true}, + token_type: %Schema{allOf: [BlockScoutWeb.Schemas.API.V2.Token.Type], nullable: true}, + value: General.IntegerStringNullable, + external_app_url: General.URLNullable, + animation_url: General.URLNullable, + image_url: General.URLNullable, + is_unique: %Schema{type: :boolean, nullable: true}, + thumbnails: %Schema{ + type: :object, + properties: %{ + "500x500" => %Schema{type: :string, format: :uri}, + "250x250" => %Schema{type: :string, format: :uri}, + "60x60" => %Schema{type: :string, format: :uri}, + "original" => %Schema{type: :string, format: :uri} + }, + required: ["original"], + nullable: true + }, + media_type: %Schema{ + type: :string, + example: "image/png", + description: "Mime type of the media in media_url", + nullable: true + }, + media_url: General.URLNullable + }, + required: [ + :id, + :metadata, + :owner, + :token, + :external_app_url, + :animation_url, + :image_url, + :is_unique, + :thumbnails, + :media_type, + :media_url + ], + nullable: false, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_list.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_list.ex new file mode 100644 index 000000000000..f03d436b7b74 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_instance_in_list.ex @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.TokenInstanceInList do + @moduledoc """ + This module defines the schema for the TokenInstanceInList struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, Token.Type, TokenInstance} + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + TokenInstance.schema() + |> Helper.extend_schema( + title: "TokenInstanceInList", + properties: %{ + token_type: Type, + value: General.IntegerStringNullable + }, + required: [:token_type, :value] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex new file mode 100644 index 000000000000..b150575f2be7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/token_transfer.ex @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.TransactionHashCustomization do + @moduledoc false + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + require OpenApiSpex + alias OpenApiSpex.Schema + + alias BlockScoutWeb.Schemas.API.V2.General + + def schema do + case chain_identity() do + {:optimism, :celo} -> + General.FullHashNullable + + _ -> + General.FullHash + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer do + @moduledoc """ + Schema for token transfer + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + + alias BlockScoutWeb.Schemas.API.V2.TokenTransfer.{ + Total, + TotalERC1155, + TotalERC721, + TotalERC7984, + TransactionHashCustomization + } + + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + transaction_hash: TransactionHashCustomization.schema(), + from: Address, + to: Address, + total: %Schema{ + anyOf: [ + TotalERC721, + TotalERC1155, + TotalERC7984, + Total + ], + nullable: true + }, + token: Token, + type: %Schema{type: :string, enum: ["token_burning", "token_minting", "token_spawning", "token_transfer"]}, + timestamp: General.TimestampNullable, + method: General.MethodNameNullable, + block_hash: General.FullHash, + block_number: %Schema{type: :integer, nullable: false}, + log_index: %Schema{type: :integer, nullable: false}, + token_type: Token.Type + }, + required: [ + :transaction_hash, + :from, + :to, + :total, + :token, + :type, + :timestamp, + :method, + :block_hash, + :block_number, + :log_index, + :token_type + ], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.TotalERC721 do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, TokenInstance} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + token_id: General.IntegerStringNullable, + token_instance: %Schema{allOf: [TokenInstance], nullable: true} + }, + required: [:token_id, :token_instance], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.TotalERC1155 do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, TokenInstance} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + token_id: General.IntegerStringNullable, + value: General.IntegerStringNullable, + decimals: General.IntegerStringNullable, + token_instance: %Schema{allOf: [TokenInstance], nullable: true} + }, + required: [:token_id, :value, :decimals, :token_instance], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.Total do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + value: General.IntegerStringNullable, + decimals: General.IntegerStringNullable + }, + required: [:value, :decimals], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.TokenTransfer.TotalERC7984 do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + value: General.IntegerStringNullable, + decimals: General.IntegerStringNullable + }, + required: [:value, :decimals], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex new file mode 100644 index 000000000000..dd1cbe1ff5e7 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex @@ -0,0 +1,487 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do + @moduledoc false + alias BlockScoutWeb.API.V2.ZkSyncView + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias BlockScoutWeb.Schemas.API.V2.Transaction.Fee + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + @zksync_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + batch_number: %Schema{type: :integer, nullable: true}, + status: %Schema{ + type: :string, + enum: ZkSyncView.batch_status_enum(), + nullable: true + }, + commit_transaction_hash: General.FullHashNullable, + commit_transaction_timestamp: General.TimestampNullable, + prove_transaction_hash: General.FullHashNullable, + prove_transaction_timestamp: General.TimestampNullable, + execute_transaction_hash: General.FullHashNullable, + execute_transaction_timestamp: General.TimestampNullable + }, + required: [ + :batch_number, + :status, + :commit_transaction_hash, + :commit_transaction_timestamp, + :prove_transaction_hash, + :prove_transaction_timestamp, + :execute_transaction_hash, + :execute_transaction_timestamp + ], + additionalProperties: false + } + + @arbitrum_commitment_transaction_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + hash: General.FullHashNullable, + timestamp: General.TimestampNullable, + status: %Schema{type: :string, enum: ["unfinalized", "finalized"], nullable: true} + }, + required: [:hash, :timestamp, :status], + additionalProperties: false + } + + @arbitrum_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + batch_data_container: %Schema{type: :string, nullable: true}, + batch_number: %Schema{type: :integer, nullable: true}, + status: %Schema{ + type: :string, + enum: ["Confirmed on base", "Sent to base", "Sealed on rollup", "Processed on rollup"], + nullable: false + }, + commitment_transaction: @arbitrum_commitment_transaction_schema, + confirmation_transaction: @arbitrum_commitment_transaction_schema, + contains_message: %Schema{type: :string, enum: ["incoming", "outcoming"], nullable: true}, + message_related_info: %Schema{ + type: :object, + nullable: false, + properties: %{ + message_id: %Schema{type: :integer, nullable: false}, + associated_l1_transaction_hash: General.FullHashNullable, + message_status: %Schema{ + type: :string, + enum: [ + "Syncing with base layer", + "Relayed", + "Settlement pending", + "Waiting for confirmation", + "Ready for relay" + ], + nullable: false + } + }, + additionalProperties: false + }, + gas_used_for_l1: General.IntegerString, + gas_used_for_l2: General.IntegerString, + poster_fee: General.IntegerString, + network_fee: General.IntegerString + }, + required: [:gas_used_for_l1, :gas_used_for_l2, :poster_fee, :network_fee], + additionalProperties: false + } + + @optimism_withdrawal_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + nonce: %Schema{type: :integer}, + status: %Schema{type: :string, nullable: false}, + l1_transaction_hash: General.FullHashNullable + }, + required: [:nonce, :status, :l1_transaction_hash], + additionalProperties: false + } + + @scroll_schema %Schema{ + type: :object, + nullable: false, + properties: %{ + l1_fee_scalar: %Schema{type: :integer, nullable: true}, + l1_fee_commit_scalar: %Schema{type: :integer, nullable: true}, + l1_fee_blob_scalar: %Schema{type: :integer, nullable: true}, + l1_fee_overhead: %Schema{type: :integer, nullable: true}, + l1_base_fee: %Schema{type: :integer, nullable: true}, + l1_blob_base_fee: %Schema{type: :integer, nullable: true}, + l1_gas_used: %Schema{type: :integer, nullable: true}, + l2_fee: Fee, + l2_block_status: %Schema{ + type: :string, + enum: ["Committed", "Finalized", "Confirmed by Sequencer"], + nullable: false + }, + l1_fee: General.IntegerString, + queue_index: %Schema{type: :integer, nullable: false} + }, + required: [ + :l1_fee_scalar, + :l1_fee_commit_scalar, + :l1_fee_blob_scalar, + :l1_fee_overhead, + :l1_base_fee, + :l1_blob_base_fee, + :l1_gas_used, + :l2_fee, + :l2_block_status + ], + additionalProperties: false + } + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + @spec chain_type_fields(map()) :: map() + # credo:disable-for-next-line + def chain_type_fields(schema) do + chain_type() + |> case do + :zksync -> + schema |> Helper.extend_schema(properties: %{zksync: @zksync_schema}) + + :arbitrum -> + schema |> Helper.extend_schema(properties: %{arbitrum: @arbitrum_schema}) + + :optimism -> + schema + |> Helper.extend_schema( + properties: %{ + l1_fee: General.IntegerString, + l1_fee_scalar: General.IntegerString, + l1_gas_price: General.IntegerString, + l1_gas_used: General.IntegerString, + op_withdrawals: %Schema{ + type: :array, + items: @optimism_withdrawal_schema, + nullable: false + }, + op_interop_messages: %Schema{ + type: :object, + nullable: false, + properties: %{ + nonce: %Schema{type: :integer}, + status: %Schema{type: :string, nullable: false, enum: ["Sent", "Relayed", "Failed"]}, + sender_address_hash: General.AddressHashNullable, + target_address_hash: General.AddressHashNullable, + payload: General.HexString, + relay_chain: %Schema{type: :object, nullable: true}, + relay_transaction_hash: General.FullHashNullable, + init_chain: %Schema{type: :object, nullable: true}, + init_transaction_hash: General.FullHashNullable + }, + required: [:nonce, :status, :sender_address_hash, :target_address_hash, :payload], + additionalProperties: false + }, + operator_fee: General.IntegerString + } + ) + + :scroll -> + schema |> Helper.extend_schema(properties: %{scroll: @scroll_schema}) + + :suave -> + schema + |> Helper.extend_schema( + properties: %{ + allowed_peekers: %Schema{type: :array, items: General.AddressHash, nullable: false}, + execution_node: Address, + wrapped: %Schema{ + type: :object, + nullable: false, + properties: %{ + type: %Schema{type: :integer}, + nonce: %Schema{type: :integer, nullable: true}, + to: Address, + gas_limit: General.IntegerStringNullable, + gas_price: General.IntegerStringNullable, + fee: Fee, + max_priority_fee_per_gas: General.IntegerStringNullable, + max_fee_per_gas: General.IntegerStringNullable, + value: General.IntegerStringNullable, + hash: General.FullHash, + method: General.MethodNameNullable, + decoded_input: %Schema{allOf: [General.DecodedInput], nullable: true}, + raw_input: General.HexString + }, + additionalProperties: false + } + } + ) + + :stability -> + schema + |> Helper.extend_schema( + properties: %{ + stability_fee: %Schema{ + type: :object, + nullable: false, + properties: %{ + token: Token, + validator_address: Address, + dapp_address: Address, + total_fee: General.IntegerString, + dapp_fee: General.IntegerString, + validator_fee: General.IntegerString + }, + required: [:token, :validator_address, :dapp_address, :total_fee, :dapp_fee, :validator_fee], + additionalProperties: false + } + } + ) + + :ethereum -> + schema + |> Helper.extend_schema( + properties: %{ + max_fee_per_blob_gas: General.IntegerString, + blob_versioned_hashes: %Schema{type: :array, items: General.FullHash, nullable: false}, + blob_gas_used: General.IntegerString, + blob_gas_price: General.IntegerString, + burnt_blob_fee: General.IntegerString + } + ) + + :zilliqa -> + schema + |> Helper.extend_schema( + properties: %{ + zilliqa: %Schema{ + type: :object, + nullable: false, + properties: %{ + is_scilla: %Schema{type: :boolean, nullable: false} + }, + required: [:is_scilla], + additionalProperties: false + } + }, + required: [:zilliqa] + ) + + _ -> + schema + end + |> chain_identity_fields() + end + + defp chain_identity_fields(schema) do + case chain_identity() do + {:optimism, :celo} -> + schema + |> Helper.extend_schema( + properties: %{ + celo: %Schema{ + type: :object, + nullable: false, + properties: %{gas_token: %Schema{allOf: [Token], nullable: true}}, + required: [:gas_token], + additionalProperties: false + } + }, + required: [:celo] + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Transaction do + @moduledoc """ + This module defines the schema for the Transaction struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General, SignedAuthorization, TokenTransfer} + alias BlockScoutWeb.Schemas.API.V2.Transaction.{ChainTypeCustomizations, Fee} + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + %{ + type: :object, + properties: %{ + hash: General.FullHash, + result: %Schema{ + anyOf: [ + %Schema{ + type: :string, + enum: ["pending", "awaiting_internal_transactions", "success", "dropped/replaced"] + }, + %Schema{ + type: :string, + description: "Error message", + example: "out of gas" + } + ], + nullable: false + }, + status: %Schema{ + type: :string, + enum: ["ok", "error"], + nullable: true + }, + block_number: %Schema{ + type: :integer, + nullable: true + }, + timestamp: General.TimestampNullable, + from: Address, + to: Address, + created_contract: %Schema{allOf: [Address], nullable: true}, + confirmations: %Schema{ + type: :integer, + minimum: 0 + }, + confirmation_duration: %Schema{ + type: :array, + items: %Schema{ + type: :integer, + minimum: 0, + description: "Duration in milliseconds" + }, + description: + "Array of time intervals in milliseconds. Can be empty [] (no info), single value [interval] (means that the transaction was confirmed within {interval} milliseconds), or two values [short_interval, long_interval] (means that the transaction's confirmation took from {short_interval} to {long_interval} milliseconds)", + example: [1000, 2000], + maxItems: 2 + }, + value: General.IntegerString, + fee: Fee, + gas_price: General.IntegerStringNullable, + type: %Schema{ + type: :integer, + nullable: true + }, + gas_used: General.IntegerStringNullable, + gas_limit: General.IntegerString, + max_fee_per_gas: General.IntegerStringNullable, + max_priority_fee_per_gas: General.IntegerStringNullable, + base_fee_per_gas: General.IntegerStringNullable, + priority_fee: General.IntegerStringNullable, + transaction_burnt_fee: General.IntegerStringNullable, + nonce: %Schema{ + type: :integer, + nullable: false, + minimum: 0 + }, + position: %Schema{ + type: :integer, + nullable: true, + minimum: 0 + }, + revert_reason: %Schema{ + oneOf: [ + General.DecodedInput, + %Schema{ + type: :object, + properties: %{raw: %Schema{anyOf: [General.HexString, %Schema{type: :string}], nullable: true}}, + required: [:raw], + nullable: false, + additionalProperties: false + } + ], + nullable: true + }, + raw_input: General.HexString, + decoded_input: %Schema{allOf: [General.DecodedInput], nullable: true}, + token_transfers: %Schema{type: :array, items: TokenTransfer, nullable: true}, + token_transfers_overflow: %Schema{type: :boolean, nullable: true}, + exchange_rate: General.FloatStringNullable, + historic_exchange_rate: General.FloatStringNullable, + method: General.MethodNameNullable, + transaction_types: %Schema{ + type: :array, + items: %Schema{ + type: :string, + enum: [ + "coin_transfer", + "contract_call", + "contract_creation", + "rootstock_bridge", + "rootstock_remasc", + "token_creation", + "token_transfer", + "blob_transaction", + "set_code_transaction" + ] + } + }, + transaction_tag: %Schema{ + type: :string, + nullable: true, + example: "personal", + description: "Transaction tag set in My Account" + }, + has_error_in_internal_transactions: %Schema{type: :boolean, nullable: true}, + authorization_list: %Schema{type: :array, items: SignedAuthorization, nullable: true}, + is_pending_update: %Schema{type: :boolean, nullable: true}, + fhe_operations_count: %Schema{ + type: :integer, + description: "Number of FHE (Fully Homomorphic Encryption) operations in the transaction", + nullable: false + } + }, + required: [ + :hash, + :result, + :status, + :block_number, + :timestamp, + :from, + :to, + :created_contract, + :confirmations, + :confirmation_duration, + :value, + :fee, + :gas_price, + :type, + :gas_used, + :gas_limit, + :max_fee_per_gas, + :max_priority_fee_per_gas, + :base_fee_per_gas, + :priority_fee, + :transaction_burnt_fee, + :nonce, + :position, + :revert_reason, + :raw_input, + :decoded_input, + :token_transfers, + :token_transfers_overflow, + :exchange_rate, + :historic_exchange_rate, + :method, + :transaction_types, + :transaction_tag, + :has_error_in_internal_transactions, + :authorization_list, + :is_pending_update, + :fhe_operations_count + ], + additionalProperties: false + } + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/fee.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/fee.ex new file mode 100644 index 000000000000..a8ed5a8b02b9 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/fee.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Fee do + @moduledoc """ + This module defines the schema for the Transaction fee. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + required: [:type, :value], + properties: %{ + type: %Schema{ + type: :string, + enum: ["maximum", "actual"] + }, + value: General.IntegerStringNullable + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex new file mode 100644 index 000000000000..d56725b4187a --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/raw_trace.ex @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.RawTrace do + @moduledoc """ + This module defines the schema for a transaction raw trace API response. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + action_schema = %Schema{ + type: :object, + properties: %{ + callType: %Schema{type: :string, enum: ["call", "callcode", "delegatecall", "staticcall"]}, + from: General.AddressHash, + gas: General.HexString, + input: General.HexString, + init: General.HexString, + to: General.AddressHash, + value: General.HexString + }, + required: [:from, :gas, :input, :value], + additionalProperties: false + } + + result_schema = %Schema{ + type: :object, + properties: %{ + gasUsed: General.HexString, + output: General.HexString + }, + required: [:gasUsed, :output], + additionalProperties: false + } + + trace_schema = %Schema{ + type: :object, + properties: %{ + action: action_schema, + result: result_schema, + subtraces: %Schema{type: :integer, minimum: 0}, + traceAddress: %Schema{type: :array, items: %Schema{type: :integer}}, + transactionHash: General.FullHashNullable, + type: %Schema{type: :string, enum: ["call", "create", "create2", "reward", "selfdestruct", "stop", "invalid"]} + }, + required: [:action, :subtraces, :traceAddress, :type], + additionalProperties: false + } + + OpenApiSpex.schema(%Schema{ + type: :array, + items: trace_schema + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex new file mode 100644 index 000000000000..7fcf4f0b8a49 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/response.ex @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Response.ChainTypeCustomizations do + @moduledoc false + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + @op_interop_message_schema %Schema{ + type: :object, + description: "Interop message included in an Optimism transaction", + nullable: false, + properties: %{ + nonce: %Schema{type: :integer, minimum: 0}, + payload: General.HexString, + relay_chain: %Schema{ + type: :object, + properties: %{ + instance_url: %Schema{type: :string, nullable: true}, + chain_id: General.IntegerString, + chain_name: %Schema{type: :string, nullable: true}, + chain_logo: %Schema{type: :string, nullable: true} + }, + nullable: true + }, + relay_transaction_hash: General.FullHash, + sender_address_hash: General.AddressHash, + status: %Schema{type: :string, enum: ["Sent", "Relayed", "Failed"]}, + target_address_hash: General.AddressHash, + unique_id: %Schema{type: :string} + }, + required: [:payload], + example: %{ + "nonce" => 0, + "payload" => "0x30787849009c24f10a91a327a9f2ed94ebc49ee9", + "relay_chain" => nil, + "relay_transaction_hash" => "0x0000000000000000000000000000000000000000000000000000000000000002", + "sender_address_hash" => "0x0000000000000000000000000000000000000003", + "status" => "Relayed", + "target_address_hash" => "0x0000000000000000000000000000000000000004", + "unique_id" => "0000000100000000" + } + } + + @doc """ + Applies chain-specific field customizations to the given schema based on the configured chain type. + + ## Parameters + - `schema`: The base schema map to be customized + + ## Returns + - The schema map with chain-specific properties added based on the current chain type configuration + """ + @spec chain_type_fields(map()) :: map() + def chain_type_fields(schema) do + case Application.get_env(:explorer, :chain_type) do + :optimism -> + schema + |> Helper.extend_schema( + properties: %{op_interop_messages: %Schema{type: :array, items: @op_interop_message_schema, nullable: false}} + ) + + _ -> + schema + end + end +end + +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Response do + @moduledoc """ + This module defines the schema for transaction response from /api/v2/transactions/:transaction_hash_param. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.Transaction + alias BlockScoutWeb.Schemas.API.V2.Transaction.Response.ChainTypeCustomizations + alias BlockScoutWeb.Schemas.Helper + + OpenApiSpex.schema( + Transaction.schema() + |> Helper.extend_schema( + title: "TransactionResponse", + description: "Transaction response" + ) + |> ChainTypeCustomizations.chain_type_fields() + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex new file mode 100644 index 000000000000..e3385bb71b0f --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/state_change.ex @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.StateChange do + @moduledoc """ + This module defines the schema for a transaction state change API response. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + required: [ + :address, + :balance_after, + :balance_before, + :change, + :is_miner, + :token, + :type + ], + properties: %{ + # Should reference Address schema if available + address: Address, + balance_after: General.IntegerStringNullable, + balance_before: General.IntegerStringNullable, + change: General.IntegerStringNullable, + is_miner: %Schema{type: :boolean}, + token: %Schema{allOf: [Token], nullable: true}, + token_id: General.IntegerStringNullable, + type: %Schema{type: :string, enum: ["token", "coin"]} + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary.ex new file mode 100644 index 000000000000..22c50e68a844 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Summary do + @moduledoc """ + This module defines the schema for a transaction summary API response. + """ + require OpenApiSpex + + alias OpenApiSpex.Schema + + summary_variable_schema = %Schema{ + type: :object, + required: [:type, :value], + properties: %{ + type: %Schema{type: :string}, + value: %Schema{ + anyOf: [ + %Schema{type: :string}, + %Schema{type: :number}, + # For address or other complex types + %Schema{type: :object} + ] + } + }, + additionalProperties: false + } + + summary_template_variables_schema = %Schema{ + type: :object, + additionalProperties: summary_variable_schema + } + + summary_schema = %Schema{ + type: :object, + required: [:summary_template, :summary_template_variables], + properties: %{ + summary_template: %Schema{type: :string}, + summary_template_variables: summary_template_variables_schema + }, + additionalProperties: false + } + + OpenApiSpex.schema(%{ + type: :object, + required: [:data, :success], + properties: %{ + data: %Schema{ + type: :object, + required: [:summaries], + properties: %{ + summaries: %Schema{type: :array, items: summary_schema} + }, + additionalProperties: false + }, + success: %Schema{type: :boolean} + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex new file mode 100644 index 000000000000..ccbf520583a2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.SummaryJustRequestBody do + @moduledoc """ + OpenAPI schema for the transaction summary just request body response. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{Address, InternalTransaction, Log, TokenTransfer} + alias OpenApiSpex.Schema + + logs_data_schema = %Schema{ + type: :object, + required: [:items], + properties: %{ + items: %Schema{type: :array, items: %Schema{allOf: [Log], nullable: true}} + }, + additionalProperties: false + } + + data_schema = %Schema{ + type: :object, + required: [ + :status, + :type, + :value, + :hash, + :from, + :to, + :method, + :token_transfers, + :internal_transactions, + :transaction_types, + :decoded_input, + :raw_input + ], + properties: %{ + status: %Schema{type: :string}, + type: %Schema{type: :integer, nullable: true}, + value: %Schema{type: :string}, + hash: %Schema{type: :string}, + from: %Schema{allOf: [Address], nullable: true}, + to: %Schema{allOf: [Address], nullable: true}, + method: %Schema{type: :string, nullable: true}, + token_transfers: %Schema{type: :array, items: %Schema{allOf: [TokenTransfer], nullable: true}}, + internal_transactions: %Schema{type: :array, items: %Schema{allOf: [InternalTransaction], nullable: true}}, + transaction_types: %Schema{type: :array, items: %Schema{type: :string}}, + decoded_input: %Schema{type: :object, nullable: true}, + raw_input: %Schema{type: :string} + }, + additionalProperties: false + } + + OpenApiSpex.schema(%{ + type: :object, + required: [:data], + properties: %{ + chain_id: %Schema{type: :integer, nullable: true}, + data: data_schema, + logs_data: logs_data_schema + }, + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex new file mode 100644 index 000000000000..b2ef898500af --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Withdrawal do + @moduledoc """ + This module defines the schema for the Withdrawal struct. + """ + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.{Address, General} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + index: %Schema{type: :integer, nullable: false}, + validator_index: %Schema{type: :integer, nullable: false}, + amount: General.IntegerString, + block_number: %Schema{type: :integer, nullable: false}, + receiver: Address, + timestamp: General.Timestamp + }, + required: [ + :index, + :validator_index, + :amount + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex new file mode 100644 index 000000000000..eca66a8109dc --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/withdrawal/counter.ex @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Withdrawal.Counter do + @moduledoc """ + This module defines the schema for the Withdrawal counters struct. + """ + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + withdrawals_count: General.IntegerString, + withdrawals_sum: General.IntegerString + }, + required: [ + :withdrawals_count, + :withdrawals_sum + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex new file mode 100644 index 000000000000..0f4ef2e2b8b2 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker.ex @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Zilliqa.Staker do + @moduledoc """ + This module defines the schema for the Zilliqa Staker struct. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Zilliqa Staker struct.", + type: :object, + properties: %{ + balance: General.IntegerString, + bls_public_key: General.HexString, + index: %Schema{type: :integer, nullable: false} + }, + required: [ + :balance, + :bls_public_key, + :index + ], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex new file mode 100644 index 000000000000..02b88bda0423 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/zilliqa/staker/detailed.ex @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Zilliqa.Staker.Detailed do + @moduledoc """ + This module defines the schema for Zilliqa validator info response from /api/v2/validators/zilliqa/:bls_public_key + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{AddressNullable, General} + alias BlockScoutWeb.Schemas.API.V2.Zilliqa.Staker + alias BlockScoutWeb.Schemas.Helper + alias OpenApiSpex.Schema + + OpenApiSpex.schema( + Staker.schema() + |> Helper.extend_schema( + title: "StakerDetailed", + properties: %{ + peer_id: General.HexStringNullable, + control_address: AddressNullable, + reward_address: AddressNullable, + signing_address: AddressNullable, + added_at_block_number: %Schema{type: :integer, nullable: false}, + stake_updated_at_block_number: %Schema{type: :integer, nullable: false} + }, + required: [ + :peer_id, + :control_address, + :reward_address, + :signing_address, + :added_at_block_number, + :stake_updated_at_block_number + ] + ) + ) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex b/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex new file mode 100644 index 000000000000..37b26f08152c --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/helper.ex @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.Helper do + @moduledoc false + + alias OpenApiSpex.Schema + + @doc """ + Extends a schema with additional properties and required fields. + """ + @spec extend_schema(Schema.t() | map(), Keyword.t()) :: Schema.t() | map() + def extend_schema(schema, options) do + updated_schema_with_properties = + if Keyword.has_key?(options, :properties), + do: + Map.update!(schema, :properties, fn properties -> + Map.merge(properties, Keyword.get(options, :properties, %{})) + end), + else: schema + + updated_schema_with_required = + if Keyword.has_key?(options, :required), + do: + Map.update!(updated_schema_with_properties, :required, fn required -> + required ++ Keyword.get(options, :required, []) + end), + else: updated_schema_with_properties + + updated_schema_with_title = + if Keyword.has_key?(options, :title), + do: Map.put(updated_schema_with_required, :title, Keyword.get(options, :title)), + else: updated_schema_with_required + + updated_schema_with_description = + if Keyword.has_key?(options, :description), + do: Map.put(updated_schema_with_title, :description, Keyword.get(options, :description)), + else: updated_schema_with_title + + updated_schema_with_nullable = + if Keyword.has_key?(options, :nullable), + do: Map.put(updated_schema_with_description, :nullable, Keyword.get(options, :nullable)), + else: updated_schema_with_description + + updated_schema_with_enum = + if Keyword.has_key?(options, :enum), + do: Map.put(updated_schema_with_nullable, :enum, Keyword.get(options, :enum)), + else: updated_schema_with_nullable + + updated_schema_with_enum + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/social_media.ex b/apps/block_scout_web/lib/block_scout_web/social_media.ex index ba0f760f05aa..ac40f8b2a7b9 100644 --- a/apps/block_scout_web/lib/block_scout_web/social_media.ex +++ b/apps/block_scout_web/lib/block_scout_web/social_media.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SocialMedia do @moduledoc """ This module provides social media links diff --git a/apps/block_scout_web/lib/block_scout_web/specs.ex b/apps/block_scout_web/lib/block_scout_web/specs.ex new file mode 100644 index 000000000000..e7f09f567a2e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/specs.ex @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Specs do + @moduledoc """ + Provides utility functions for working with Phoenix router routes in API specification modules. + + This module contains shared functionality used by `BlockScoutWeb.Specs.Public` and + `BlockScoutWeb.Specs.Private` for route manipulation and processing. + """ + + @doc """ + Retrieves all routes from the given router module and prepends a prefix to each route's path. + + ## Parameters + - `router`: A Phoenix router module that implements `__routes__/0`. + - `prefix`: A string to prepend to each route's path. + + ## Returns + - A list of route maps with the `:path` values modified to include the prefix. + """ + @spec routes_with_prefix(module(), String.t()) :: [map()] + def routes_with_prefix(router, prefix) do + router.__routes__() + |> Enum.map(fn %{path: path} = route -> %{route | path: prefix <> path} end) + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/specs/private.ex b/apps/block_scout_web/lib/block_scout_web/specs/private.ex new file mode 100644 index 000000000000..d34e073e0e02 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/specs/private.ex @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Specs.Private do + @moduledoc """ + This module defines the private API specification for the BlockScoutWeb application. + """ + + alias BlockScoutWeb.Routers.AccountRouter + alias BlockScoutWeb.Specs + alias OpenApiSpex.{Components, Contact, Info, OpenApi, Paths, SecurityScheme, Server} + alias Utils.Helper + + @behaviour OpenApi + + @impl OpenApi + def spec do + %OpenApi{ + servers: [ + %Server{url: to_string(Helper.instance_url() |> URI.append_path("/api"))} + ], + info: %Info{ + title: "Blockscout Private API", + version: to_string(Application.spec(:block_scout_web, :vsn)), + contact: %Contact{ + email: "info@blockscout.com" + } + }, + paths: Paths.from_routes(Specs.routes_with_prefix(AccountRouter, "/account")), + components: %Components{ + securitySchemes: %{"dynamic_jwt" => %SecurityScheme{type: "http", scheme: "bearer", bearerFormat: "JWT"}} + } + } + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/specs/public.ex b/apps/block_scout_web/lib/block_scout_web/specs/public.ex new file mode 100644 index 000000000000..e9941b2397c0 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/specs/public.ex @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Specs.Public do + @moduledoc """ + This module defines the public API specification for the BlockScoutWeb application. + """ + + alias BlockScoutWeb.Routers.{ApiRouter, SmartContractsApiV2Router, TokensApiV2Router} + alias BlockScoutWeb.Specs + alias OpenApiSpex.{Contact, Info, OpenApi, Paths, Server, Tag} + alias Utils.Helper + + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + use Utils.RuntimeEnvHelper, + mud_enabled?: [:explorer, [Explorer.Chain.Mud, :enabled]] + + @behaviour OpenApi + + @default_api_categories [ + "blocks", + "transactions", + "addresses", + "internal-transactions", + "tokens", + "token-transfers", + "smart-contracts", + "config", + "main-page", + "search", + "stats", + "csv-export", + "account-abstraction", + "withdrawals", + "advanced-filters" + ] + + # todo: if new chain type is covered with OpenAPI specs + # modify this to support proper ordering: + # 1. default endpoints + # 2. chain-type specific endpoints (e.g. optimism, celo, scroll, zilliqa) + # 3. legacy endpoints + case @chain_identity do + {:optimism, :celo} -> + @chain_type_category_tags [%Tag{name: "optimism"}, %Tag{name: "celo"}] + defp chain_type_category_tags, do: @chain_type_category_tags + + {:optimism, nil} -> + defp chain_type_category_tags do + if mud_enabled?() do + [%Tag{name: "optimism"}, %Tag{name: "mud"}] + else + [%Tag{name: "optimism"}] + end + end + + {chain_type, nil} when chain_type in [:arbitrum, :scroll, :zilliqa] -> + @chain_type_category_tags [%Tag{name: to_string(chain_type)}] + defp chain_type_category_tags, do: @chain_type_category_tags + + _ -> + @chain_type_category_tags [] + defp chain_type_category_tags, do: @chain_type_category_tags + end + + @impl OpenApi + def spec do + %OpenApi{ + servers: [ + %Server{url: to_string(Helper.instance_url() |> URI.append_path("/api"))} + ], + info: %Info{ + title: "Blockscout", + version: to_string(Application.spec(:block_scout_web, :vsn)), + contact: %Contact{ + email: "info@blockscout.com" + } + }, + paths: + ApiRouter + |> Paths.from_router() + |> Map.merge(Paths.from_routes(Specs.routes_with_prefix(TokensApiV2Router, "/v2/tokens"))) + |> Map.merge(Paths.from_routes(Specs.routes_with_prefix(SmartContractsApiV2Router, "/v2/smart-contracts"))), + tags: + Enum.map(@default_api_categories, fn category -> %Tag{name: category} end) ++ + chain_type_category_tags() ++ [%Tag{name: "legacy"}] + } + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex index 81667180d7d6..f5397976b145 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/common/_nav.html.eex @@ -18,8 +18,5 @@ - diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/custom_abi/row.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/custom_abi/row.html.eex index 36c2740aa809..85521bc965c7 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/custom_abi/row.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/custom_abi/row.html.eex @@ -9,7 +9,7 @@
-
+ <%= gettext("Remove") %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex deleted file mode 100644 index a0b78f313da7..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex +++ /dev/null @@ -1,8 +0,0 @@ -
- <%= label @f, :addresses, gettext("Address*"), class: "control-label", style: "font-size: 14px" %> -
- <%= array_input @f, :addresses, maxlength: 42, size: 70, placeholder: gettext "Smart contract / Address (0x...)" %> - <%= array_add_button @f, :addresses, maxlength: 42, size: 70, placeholder: gettext "Smart contract / Address (0x...)" %> -
- <%= error_tag @f, :addresses, class: "text-danger form-error pt-0" %> -
\ No newline at end of file diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/form.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/form.html.eex deleted file mode 100644 index 9a2cec122069..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/form.html.eex +++ /dev/null @@ -1,72 +0,0 @@ -
-
- <%= render BlockScoutWeb.Account.CommonView, "_nav.html", conn: @conn, active_item: :public_tags %> -
-
-
-

<%=if @method == :update, do: gettext("Request to edit a public tag/label"), else: gettext("Request a public tag/label") %>

- -
-
-
-
- -
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/index.html.eex deleted file mode 100644 index b3a51558682e..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/index.html.eex +++ /dev/null @@ -1,44 +0,0 @@ -
-
- <%= render BlockScoutWeb.Account.CommonView, "_nav.html", conn: @conn, active_item: :public_tags %> -
-
-
-

<%= gettext "Public tags" %>

-
-
-
- <%= gettext "You can request a public category tag which is displayed to all Blockscout users. Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag. Clicking a tag opens a page with related information and helps provide context and data organization. Requests are sent to a moderator for review and approval. This process can take several days." %> -
-
-
-
- <%= if @public_tags_requests != [] do %> - - - - - - - - - - - - <%= Enum.map(@public_tags_requests, fn x -> - render("row.html", public_tags_request: x, conn: @conn) - end) %> - -
<%= gettext "Public tag" %><%= gettext "Smart contract / Address" %><%= gettext "Submission date" %>
- <% end %> -
-
- <%= if Enum.count(@public_tags_requests) < PublicTagsRequest.get_max_public_tags_request_count() do %> - <%= gettext "Request to add public tag" %> - <% end %> -
-
-
-
- -
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/row.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/row.html.eex deleted file mode 100644 index f7d2772e479c..000000000000 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/public_tags_request/row.html.eex +++ /dev/null @@ -1,20 +0,0 @@ - - - <%= for tag <- String.split(@public_tags_request.tags, ";") do %> -
<%= tag %>
- <% end %> - - <%= Enum.join(@public_tags_request.addresses, "\n") %> - <%= Calendar.strftime(@public_tags_request.inserted_at, "%b %d, %Y") %> - - <%= link (render BlockScoutWeb.CommonComponentsView, "_svg_pen.html"), to: public_tags_request_path(@conn, :edit, @public_tags_request.id) %> - - -
- - - -
- <%= (render BlockScoutWeb.CommonComponentsView, "_svg_trash.html") %> - - \ No newline at end of file diff --git a/apps/block_scout_web/lib/block_scout_web/templates/account/watchlist_address/row.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/account/watchlist_address/row.html.eex index 0755957ac208..daf4a0dce5d2 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/account/watchlist_address/row.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/account/watchlist_address/row.html.eex @@ -18,7 +18,7 @@ data-placement="top" data-toggle="tooltip" data-html="true" - title='<%= "@ " <> to_string(@exchange_rate.fiat_value) <> "/" <> Explorer.coin_name() %>' + title='<%= "@ " <> to_string(@exchange_rate.fiat_value) <> "/" <> Explorer.coin_name() %>' > ) diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/_block_link.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/_block_link.html.eex index 935d6e76e07b..50f9e2998a34 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/_block_link.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/_block_link.html.eex @@ -1 +1 @@ -<%= link(@block_number, to: block_path(@conn, :show, @block_number), class: "tile-title-lg") %> \ No newline at end of file +<%= link(@block_number, to: block_path(@conn, :show, @block_number), class: "tile-title-lg") %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/_verify_other_explorer_modal.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/_verify_other_explorer_modal.html.eex index 462ec8ed6e81..0dfca9df5022 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/_verify_other_explorer_modal.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/_verify_other_explorer_modal.html.eex @@ -19,4 +19,4 @@ <% end %> - \ No newline at end of file + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex index 3deb067abad2..f3db1f9251ba 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address/overview.html.eex @@ -43,7 +43,7 @@

<%= @address %>

<% address_name = primary_name(@address) %> <%= cond do %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_contract/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_contract/index.html.eex index f0cbbff4f55b..bb412386a61b 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_contract/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_contract/index.html.eex @@ -105,7 +105,7 @@ diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex index b77093a0b3e4..6980544a0df1 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_common_fields/_yul_contracts_switcher.html.eex @@ -1,20 +1,20 @@
- <%= label @f, :is_yul, gettext("Is Yul contract") %> + <%= label @f, :language, gettext("Is Yul contract") %>
- <%= radio_button @f, :is_yul, false, class: "form-check-input autodetectfalse" %> + <%= radio_button @f, :language, "solidity", class: "form-check-input language-solidity" %>
- <%= label @f, :is_yul_false, gettext("No"), class: "radio-text" %> + <%= label @f, :language_solidity, gettext("No"), class: "radio-text" %>
- <%= radio_button @f, :is_yul, true, class: "form-check-input autodetecttrue", "aria-describedby": "is_yul-help-block" %> + <%= radio_button @f, :language, "yul", class: "form-check-input language-yul", "aria-describedby": "language-help-block" %>
- <%= label @f, :is_yul_true, gettext("Yes"), class: "radio-text" %> + <%= label @f, :language_yul, gettext("Yes"), class: "radio-text" %>
- <%= error_tag @f, :is_yul, id: "is_yul-help-block", class: "text-danger form-error" %> + <%= error_tag @f, :language, id: "language-help-block", class: "text-danger form-error" %>
<%= gettext("Select Yes if you want to verify Yul contract.") %>
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex index bd60af3a5b34..75145090b88d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex @@ -98,8 +98,6 @@ > <%= render BlockScoutWeb.CommonComponentsView, "_loading_spinner.html", loading_text: gettext("Loading...") %> - <%#= submit gettext("Verify & publish"), class: "btn-full-primary mr-2", "data-button-loading": "animation", "data-submit-button": "" %> - <%# verify-via-multi-part-files-submit %> <%= reset gettext("Reset"), class: "btn-line mr-2 js-smart-contract-form-reset" %> <%= diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex index 2096d38a7cb7..ec5efcfe32c9 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_token_balances.html.eex @@ -73,6 +73,15 @@ type: "ERC-20" ) %> <% end %> + + <%= if Enum.any?(@token_balances, fn token_balance -> token_balance.token.type == "ERC-7984" end) do %> + <%= render( + "_tokens.html", + conn: @conn, + token_balances: filter_by_type(@token_balances, "ERC-7984"), + type: "ERC-7984" + ) %> + <% end %>
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex index 1350b792df96..3b245f17efd4 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/address_token_balance/_tokens.html.eex @@ -49,10 +49,13 @@ \ No newline at end of file + diff --git a/apps/block_scout_web/lib/block_scout_web/templates/layout/_account_menu_item.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/layout/_account_menu_item.html.eex index e7742d231f98..1c7446f20c98 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/layout/_account_menu_item.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/layout/_account_menu_item.html.eex @@ -17,7 +17,6 @@
<%= gettext "Transaction Tags" %> <%= gettext "API keys" %> <%= gettext "Custom ABI" %> - <%= gettext "Public Tags" %> <% else %> <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex index 996abc8c2bc8..65ec79a5a044 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/layout/app.html.eex @@ -5,7 +5,7 @@ - <%= case @view_module do %> + <%= case Phoenix.Controller.view_module(@conn) do %> <% Elixir.BlockScoutWeb.ChainView -> %> "> " as="script"> @@ -27,7 +27,7 @@ " as="image" crossorigin> " as="style" onload="this.onload=null;this.rel='stylesheet'"> "> - <%= render_existing(@view_module, "styles.html", assigns) %> + <%= render_existing(Phoenix.Controller.view_module(@conn), "styles.html", assigns) %> "> "> @@ -39,7 +39,7 @@ "> - <%= render_existing(@view_module, "_metatags.html", assigns) || render("_default_title.html") %> + <%= render_existing(Phoenix.Controller.view_module(@conn), "_metatags.html", assigns) || render("_default_title.html") %> <% end %> @@ -159,20 +159,20 @@ render BlockScoutWeb.CommonComponentsView, "_modal_status.html", status: status end %> - <%= render_existing(@view_module, "scripts.html", assigns) %> - <%= if @view_module == Elixir.BlockScoutWeb.ChainView do %> + <%= render_existing(Phoenix.Controller.view_module(@conn), "scripts.html", assigns) %> + <%= if Phoenix.Controller.view_module(@conn) == Elixir.BlockScoutWeb.ChainView do %> <% end %> - <%= if @view_module == Elixir.BlockScoutWeb.TransactionView do %> + <%= if Phoenix.Controller.view_module(@conn) == Elixir.BlockScoutWeb.TransactionView do %> <% end %> - <%= if @view_module in [Elixir.BlockScoutWeb.AddressContractVerificationView, Elixir.BlockScoutWeb.AddressContractVerificationVyperView, Elixir.BlockScoutWeb.AddressContractVerificationViaFlattenedCodeView] do %> + <%= if Phoenix.Controller.view_module(@conn) in [Elixir.BlockScoutWeb.AddressContractVerificationView, Elixir.BlockScoutWeb.AddressContractVerificationVyperView, Elixir.BlockScoutWeb.AddressContractVerificationViaFlattenedCodeView] do %> <% end %> - <%= if @view_module in [Elixir.BlockScoutWeb.AddressContractVerificationViaMultiPartFilesView, Elixir.BlockScoutWeb.AddressContractVerificationViaJsonView, Elixir.BlockScoutWeb.AddressContractVerificationViaStandardJsonInputView] do %> + <%= if Phoenix.Controller.view_module(@conn) in [Elixir.BlockScoutWeb.AddressContractVerificationViaMultiPartFilesView, Elixir.BlockScoutWeb.AddressContractVerificationViaJsonView, Elixir.BlockScoutWeb.AddressContractVerificationViaStandardJsonInputView] do %> <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_instance.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_instance.html.eex index 88273f4bc093..0302e9b20b9d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_instance.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_instance.html.eex @@ -1 +1 @@ -<%= "[" %><%= link(short_token_id(@token_id, 30), to: token_instance_path(BlockScoutWeb.Endpoint, :show, @transfer.token.contract_address_hash, to_string(@token_id)), "data-test": "token_link") %><%= "]" %> \ No newline at end of file +<%= "[" %><%= link(short_token_id(@token_id, 30), to: token_instance_path(BlockScoutWeb.Endpoint, :show, @transfer.token.contract_address_hash, to_string(@token_id)), "data-test": "token_link") %><%= "]" %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_symbol.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_symbol.html.eex index 73cb4a2c2cd5..d1c8ccc3e602 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_symbol.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction/_link_to_token_symbol.html.eex @@ -1 +1 @@ -<%= link(token_symbol(@transfer.token), to: token_path(BlockScoutWeb.Endpoint, :show, @transfer.token.contract_address_hash), "data-test": "token_link") %> \ No newline at end of file +<%= link(token_symbol(@transfer.token), to: token_path(BlockScoutWeb.Endpoint, :show, @transfer.token.contract_address_hash), "data-test": "token_link") %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction/_tabs.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction/_tabs.html.eex index 75f28203e232..7e513a0fbcf7 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction/_tabs.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction/_tabs.html.eex @@ -29,6 +29,6 @@ gettext("State changes"), class: "card-tab #{tab_status("state", @conn.request_path)}", to: AccessHelper.get_path(@conn, :transaction_state_path, :index, @transaction) - ) + ) %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex index dad9e730ce7d..e6b709a1060c 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction/index.html.eex @@ -11,7 +11,7 @@

<%= gettext "Validated Transactions" %>

- <%= render BlockScoutWeb.CommonComponentsView, "_rap_pagination_container.html", position: "top", showing_limit: if(Chain.transactions_available_count() == Chain.limit_showing_transactions(), do: Chain.limit_showing_transactions(), else: nil) %> + <%= render BlockScoutWeb.CommonComponentsView, "_rap_pagination_container.html", position: "top", showing_limit: if(Transaction.transactions_available_count() == Transaction.limit_showing_transactions(), do: Transaction.limit_showing_transactions(), else: nil) %>
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/transaction/overview.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/transaction/overview.html.eex index 2caf404faf1a..f8be8cc5893a 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/transaction/overview.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/transaction/overview.html.eex @@ -215,29 +215,6 @@ <% end %> - - <% %{transaction_actions: transaction_actions} = transaction_actions(@transaction) %> - <%= unless Enum.empty?(transaction_actions) do %> -
-
- <%= render BlockScoutWeb.CommonComponentsView, "_i_tooltip_2.html", - text: gettext("Highlighted events of the transaction.") %> - <%= gettext "Transaction Action" %> -

<%= gettext "Scroll to see more" %>

-
-
-
- <% transaction_actions_indexed = Enum.with_index(transaction_actions) %> - <% transaction_actions_length = Enum.count(transaction_actions) %> - <%= for {action, i} <- transaction_actions_indexed do %> - <% action_assigns = Map.put(assigns, :action, action) %> - <% action_assigns = Map.put(action_assigns, :isLast, (i == transaction_actions_length - 1)) %> - <%= render BlockScoutWeb.TransactionView, "_actions.html", action_assigns %> - <% end %> -
-
-
- <% end %>
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/_contract.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/_contract.html.eex index 37e0145e3de8..df7794099b46 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/_contract.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/_contract.html.eex @@ -25,7 +25,11 @@ - <%= if @contract.is_vyper_contract, do: gettext("Vyper"), else: gettext("Solidity") %> + <%= case @contract.language do %> + <% :vyper -> %><%= gettext("Vyper") %> + <% :yul -> %><%= gettext("Yul") %> + <% _ -> %><%= gettext("Solidity") %> + <% end %> diff --git a/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/index.html.eex index 1149eeab1f3c..a4bcd583757c 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/verified_contracts/index.html.eex @@ -42,7 +42,7 @@ ) %>
- +
" placeholder="<%= gettext "Contract name or address" %>" id="search-text-input">
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/visualize_sol2uml/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/visualize_sol2uml/index.html.eex index b78d3ef514ec..199ae079854d 100644 --- a/apps/block_scout_web/lib/block_scout_web/templates/visualize_sol2uml/index.html.eex +++ b/apps/block_scout_web/lib/block_scout_web/templates/visualize_sol2uml/index.html.eex @@ -5,15 +5,6 @@
<%= gettext("UML diagram") %>

<%= gettext("For contract") %> - <%= link to: address_contract_path(@conn, :index, @address), "data-test": "address_hash_link" do %> - <%= render( - BlockScoutWeb.AddressView, - "_responsive_hash.html", - address: @address, - contract: true, - use_custom_tooltip: false - ) %> - <% end %>

diff --git a/apps/block_scout_web/lib/block_scout_web/tracer.ex b/apps/block_scout_web/lib/block_scout_web/tracer.ex index 6bca34d5645a..f801b4442333 100644 --- a/apps/block_scout_web/lib/block_scout_web/tracer.ex +++ b/apps/block_scout_web/lib/block_scout_web/tracer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tracer do @moduledoc false diff --git a/apps/block_scout_web/lib/block_scout_web/utility/event_handlers_metrics.ex b/apps/block_scout_web/lib/block_scout_web/utility/event_handlers_metrics.ex index ed2709bddce1..318511481973 100644 --- a/apps/block_scout_web/lib/block_scout_web/utility/event_handlers_metrics.ex +++ b/apps/block_scout_web/lib/block_scout_web/utility/event_handlers_metrics.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Utility.EventHandlersMetrics do @moduledoc """ Module responsible for periodically setting current event handlers queue length metrics. @@ -5,7 +6,13 @@ defmodule BlockScoutWeb.Utility.EventHandlersMetrics do use GenServer - alias BlockScoutWeb.{MainPageRealtimeEventHandler, RealtimeEventHandler, SmartContractRealtimeEventHandler} + alias BlockScoutWeb.RealtimeEventHandlers.{ + Main, + MainPage, + SmartContract, + TokenTransfer + } + alias BlockScoutWeb.Prometheus.Instrumenter @interval :timer.minutes(1) @@ -29,13 +36,21 @@ defmodule BlockScoutWeb.Utility.EventHandlersMetrics do end defp set_metrics do - set_handler_metric(MainPageRealtimeEventHandler, :main_page) - set_handler_metric(RealtimeEventHandler, :common) - set_handler_metric(SmartContractRealtimeEventHandler, :smart_contract) + set_handler_metric(MainPage, :main_page) + set_handler_metric(Main, :common) + set_handler_metric(SmartContract, :smart_contract) + set_handler_metric(TokenTransfer, :token_transfer) end defp set_handler_metric(handler, label) do - {_, queue_length} = Process.info(Process.whereis(handler), :message_queue_len) + queue_length = + with pid when is_pid(pid) <- Process.whereis(handler), + {:message_queue_len, length} <- Process.info(pid, :message_queue_len) do + length + else + _ -> 0 + end + Instrumenter.event_handler_queue_length(label, queue_length) end diff --git a/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex b/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex new file mode 100644 index 000000000000..612346f8cb3e --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/utility/rate_limit_config_helper.ex @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Utility.RateLimitConfigHelper do + @moduledoc """ + Fetches the rate limit config from the config url and parses it into a map. + """ + require Logger + + alias Explorer.HttpClient + alias Utils.ConfigHelper + + @doc """ + Fetches the rate limit config from the config url and puts it into the persistent term under the key `:rate_limit_config`. + """ + @spec store_rate_limit_config() :: :ok + def store_rate_limit_config do + :persistent_term.put(:rate_limit_config, fetch_config()) + end + + defp fetch_config do + url = Application.get_env(:block_scout_web, :api_rate_limit)[:config_url] + + with {:ok, config} <- download_config(url), + parsed_config <- parse_config(config) do + parsed_config + else + {:error, reason} -> + Logger.error("Failed to fetch rate limit config: #{inspect(reason)}. Fallback to local config.") + fallback_config() + end + rescue + error -> + Logger.error("Failed to fetch config: #{inspect(error)}. Fallback to local config.") + fallback_config() + end + + defp fallback_config do + with {:ok, config} <- + :block_scout_web + |> Application.app_dir("priv/rate_limit_config.json") + |> File.read(), + {:ok, config} <- decode_config(config), + config <- parse_config(config) do + config + else + {:error, reason} -> + Logger.error("Failed to parse local config: #{inspect(reason)}. Using default rate limits from ENVs.") + %{} + end + end + + defp download_config(url) when is_binary(url) do + url + |> HttpClient.get([], follow_redirect: true) + |> case do + {:ok, %{status_code: 200, body: body}} -> + decode_config(body) + + {:ok, %{status_code: status}} -> + Logger.error("Failed to fetch config from #{url}: #{status}") + {:error, status} + + {:error, reason} -> + {:error, reason} + end + end + + defp download_config(_), do: {:error, :invalid_config_url} + + defp decode_config(config) do + to_atom? = + Map.from_keys( + [ + "account_api_key", + "bypass_token_scope", + "cost", + "ip", + "ignore", + "limit", + "period", + "recaptcha_to_bypass_429", + "static_api_key", + "temporary_token", + "whitelisted_ip", + "isolate_rate_limit?" + ], + true + ) + + config + |> Jason.decode( + keys: fn key -> + if to_atom?[key] do + String.to_atom(key) + else + key + end + end + ) + end + + defp parse_config(config) do + config = decode_time_values(config) + + config + |> Map.keys() + |> Enum.reduce(%{wildcard_match: %{}, parametrized_match: %{}, static_match: %{}}, fn key, acc -> + {type, value} = process_endpoint_path(key) + + prefix = + if config[key][:isolate_rate_limit?] do + "#{key}_" + else + "" + end + + config = Map.put(config[key], :bucket_key_prefix, prefix) + + Map.update(acc, type, %{value => config}, fn existing_value -> + Map.put(existing_value, value, config) + end) + end) + end + + defp process_endpoint_path(key) do + path_parts = key |> String.trim("/") |> String.split("/") + + cond do + String.contains?(key, "*") -> + if Enum.find_index(path_parts, &Kernel.==(&1, "*")) == length(path_parts) - 1 do + {:wildcard_match, {Enum.drop(path_parts, -1), length(path_parts) - 1}} + else + raise "wildcard `*` allowed only at the end of the path" + end + + String.contains?(key, ":param") -> + {:parametrized_match, path_parts} + + true -> + {:static_match, key} + end + end + + defp decode_time_values(config) when is_map(config) do + config + |> Enum.map(fn + {:period, value} when is_binary(value) -> + {:period, parse_time_string(value)} + + {key, value} when is_map(value) -> + {key, decode_time_values(value)} + + entry -> + entry + end) + |> Enum.into(%{}) + end + + defp parse_time_string(value) do + case ConfigHelper.parse_time_value(value) do + :error -> + raise "Invalid time format in rate limit config: #{value}" + + time -> + time + end + end +end diff --git a/apps/block_scout_web/lib/block_scout_web/views/abi_encoded_value_view.ex b/apps/block_scout_web/lib/block_scout_web/views/abi_encoded_value_view.ex index d4d31fdbf091..2b152b789cc2 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/abi_encoded_value_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/abi_encoded_value_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ABIEncodedValueView do @moduledoc """ Renders a decoded value that is encoded according to an ABI. @@ -6,11 +7,9 @@ defmodule BlockScoutWeb.ABIEncodedValueView do values via `
` tags, and that is hard to do in an eex template.
   """
   use BlockScoutWeb, :view
-
-  import Phoenix.LiveView.Helpers, only: [sigil_H: 2]
+  use Phoenix.LiveView
 
   alias ABI.FunctionSelector
-  alias Explorer.Helper, as: ExplorerHelper
   alias Phoenix.HTML
   alias Phoenix.HTML.Safe
 
@@ -45,7 +44,7 @@ defmodule BlockScoutWeb.ABIEncodedValueView do
   end
 
   defp do_copy_text({:bytes, _type}, value) do
-    ExplorerHelper.add_0x_prefix(value)
+    "0x" <> Base.encode16(value, case: :lower)
   end
 
   defp do_copy_text({:array, type, _}, value) do
@@ -66,11 +65,11 @@ defmodule BlockScoutWeb.ABIEncodedValueView do
   end
 
   defp do_copy_text(_, {:dynamic, value}) do
-    ExplorerHelper.add_0x_prefix(value)
+    "0x" <> Base.encode16(value, case: :lower)
   end
 
   defp do_copy_text(type, value) when type in [:bytes, :address] do
-    ExplorerHelper.add_0x_prefix(value)
+    "0x" <> Base.encode16(value, case: :lower)
   end
 
   defp do_copy_text({:tuple, types}, value) do
@@ -161,14 +160,14 @@ defmodule BlockScoutWeb.ABIEncodedValueView do
   defp base_value_html(_, {:dynamic, value}, _no_links) do
     assigns = %{value: value}
 
-    ~H|<%= ExplorerHelper.add_0x_prefix(@value) %>|
+    ~H|<%= "0x" <> Base.encode16(@value, case: :lower) %>|
   end
 
   defp base_value_html(:address, value, no_links) do
     if no_links do
       base_value_html(:address_text, value, no_links)
     else
-      address = ExplorerHelper.add_0x_prefix(value)
+      address = "0x" <> Base.encode16(value, case: :lower)
       path = address_path(BlockScoutWeb.Endpoint, :show, address)
 
       assigns = %{address: address, path: path}
@@ -180,13 +179,13 @@ defmodule BlockScoutWeb.ABIEncodedValueView do
   defp base_value_html(:address_text, value, _no_links) do
     assigns = %{value: value}
 
-    ~H|<%= ExplorerHelper.add_0x_prefix(@value) %>|
+    ~H|<%= "0x" <> Base.encode16(@value, case: :lower) %>|
   end
 
   defp base_value_html(:bytes, value, _no_links) do
     assigns = %{value: value}
 
-    ~H|<%= ExplorerHelper.add_0x_prefix(@value) %>|
+    ~H|<%= "0x" <> Base.encode16(@value, case: :lower) %>|
   end
 
   defp base_value_html(_, value, _no_links), do: HTML.html_escape(value)
diff --git a/apps/block_scout_web/lib/block_scout_web/views/access_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/access_helper.ex
index c8c27436ea52..aa1efd48cac4 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/access_helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/access_helper.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AccessHelper do
   @moduledoc """
   Helper to restrict access to some pages filtering by address
@@ -10,7 +11,6 @@ defmodule BlockScoutWeb.AccessHelper do
   alias BlockScoutWeb.API.V2.ApiView
   alias BlockScoutWeb.Routers.WebRouter.Helpers
   alias Explorer.{AccessHelper, Chain}
-  alias Explorer.Account.Api.Key, as: ApiKey
   alias Plug.Conn
 
   alias RemoteIp
@@ -67,164 +67,30 @@ defmodule BlockScoutWeb.AccessHelper do
     apply(Helpers, path, full_args)
   end
 
-  def handle_rate_limit_deny(conn, api_v2? \\ false) do
+  @doc """
+  Handles a rate limit deny.
+
+  ## Parameters
+  - conn: The connection to handle.
+  - api_v1?: Whether the API is v1.
+
+  ## Returns
+  - A connection with the status code 429 and the view set to ApiView if api_v2? is true, otherwise the view is set to RPCView.
+  """
+  @spec handle_rate_limit_deny(Plug.Conn.t(), boolean()) :: Plug.Conn.t()
+  def handle_rate_limit_deny(conn, api_v1?) do
     APILogger.message("API rate limit reached")
 
-    view = if api_v2?, do: ApiView, else: RPCView
-    tag = if api_v2?, do: :message, else: :error
+    view = if api_v1?, do: RPCView, else: ApiView
+    tag = if api_v1?, do: :error, else: :message
 
     conn
     |> Conn.put_status(429)
     |> put_view(view)
-    |> render(tag, %{tag => "429 Too Many Requests"})
+    |> render(tag, %{tag => "Too many requests. Increase limits now at https://dev.blockscout.com"})
     |> Conn.halt()
   end
 
-  @doc """
-  Checks, if rate limit reached before making a new request. It is applied to GraphQL API.
-  """
-  @spec check_rate_limit(Plug.Conn.t(), list()) :: :ok | :rate_limit_reached | true | false
-  def check_rate_limit(conn, graphql?: true) do
-    rate_limit_config = Application.get_env(:block_scout_web, Api.GraphQL)
-    no_rate_limit_api_key = rate_limit_config[:no_rate_limit_api_key]
-
-    cond do
-      rate_limit_config[:rate_limit_disabled?] ->
-        :ok
-
-      check_no_rate_limit_api_key(conn, no_rate_limit_api_key) ->
-        :ok
-
-      true ->
-        check_graphql_rate_limit_inner(conn, rate_limit_config)
-    end
-  end
-
-  defp check_graphql_rate_limit_inner(conn, rate_limit_config) do
-    global_limit = rate_limit_config[:global_limit]
-    limit_by_key = rate_limit_config[:limit_by_key]
-    time_interval_limit = rate_limit_config[:time_interval_limit]
-    static_api_key = rate_limit_config[:static_api_key]
-    limit_by_ip = rate_limit_config[:limit_by_ip]
-    time_interval_by_ip = rate_limit_config[:time_interval_limit_by_ip]
-
-    ip_string = conn_to_ip_string(conn)
-    plan = get_plan(conn.query_params)
-
-    user_api_key = get_api_key(conn)
-
-    cond do
-      check_api_key(conn) && user_api_key == static_api_key ->
-        rate_limit(static_api_key, time_interval_limit, limit_by_key)
-
-      check_api_key(conn) && !is_nil(plan) ->
-        rate_limit(user_api_key, time_interval_limit, min(plan.max_req_per_second, limit_by_key))
-
-      true ->
-        rate_limit("graphql_#{ip_string}", limit_by_ip, time_interval_by_ip) == :ok &&
-          rate_limit("graphql", time_interval_limit, global_limit) == :ok
-    end
-  end
-
-  @doc """
-  Checks, if rate limit reached before making a new request. It is applied to API v1, ETH RPC API.
-  """
-  @spec check_rate_limit(Plug.Conn.t()) :: :ok | :rate_limit_reached
-  def check_rate_limit(conn) do
-    rate_limit_config = Application.get_env(:block_scout_web, :api_rate_limit)
-    no_rate_limit_api_key = rate_limit_config[:no_rate_limit_api_key]
-
-    cond do
-      rate_limit_config[:disabled] ->
-        :ok
-
-      check_no_rate_limit_api_key(conn, no_rate_limit_api_key) ->
-        :ok
-
-      true ->
-        check_rate_limit_inner(conn, rate_limit_config)
-    end
-  end
-
-  defp check_no_rate_limit_api_key(conn, no_rate_limit_api_key) do
-    user_api_key = get_api_key(conn)
-
-    check_api_key(conn) && !is_nil(user_api_key) && String.trim(user_api_key) !== "" &&
-      user_api_key == no_rate_limit_api_key
-  end
-
-  # credo:disable-for-next-line /Complexity/
-  defp check_rate_limit_inner(conn, rate_limit_config) do
-    global_limit = rate_limit_config[:global_limit]
-    limit_by_key = rate_limit_config[:limit_by_key]
-    limit_by_whitelisted_ip = rate_limit_config[:limit_by_whitelisted_ip]
-    time_interval_limit = rate_limit_config[:time_interval_limit]
-    static_api_key = rate_limit_config[:static_api_key]
-    limit_by_ip = rate_limit_config[:limit_by_ip]
-    api_v2_ui_limit = rate_limit_config[:api_v2_ui_limit]
-    time_interval_by_ip = rate_limit_config[:time_interval_limit_by_ip]
-
-    ip_string = conn_to_ip_string(conn)
-
-    plan = get_plan(conn.query_params)
-    token = get_ui_v2_token(conn, ip_string)
-
-    user_agent = get_user_agent(conn)
-
-    user_api_key = get_api_key(conn)
-
-    cond do
-      check_api_key(conn) && user_api_key == static_api_key ->
-        rate_limit(static_api_key, time_interval_limit, limit_by_key)
-
-      check_api_key(conn) && !is_nil(plan) ->
-        rate_limit(user_api_key, time_interval_limit, plan.max_req_per_second)
-
-      Enum.member?(whitelisted_ips(rate_limit_config), ip_string) ->
-        rate_limit(ip_string, time_interval_limit, limit_by_whitelisted_ip)
-
-      api_v2_request?(conn) && !is_nil(token) && !is_nil(user_agent) ->
-        rate_limit(token, time_interval_limit, api_v2_ui_limit)
-
-      api_v2_request?(conn) && !is_nil(user_agent) ->
-        rate_limit(ip_string, time_interval_by_ip, limit_by_ip)
-
-      true ->
-        rate_limit("api", time_interval_limit, global_limit)
-    end
-  end
-
-  defp check_api_key(conn), do: Map.has_key?(conn.query_params, "apikey")
-
-  defp get_api_key(conn), do: Map.get(conn.query_params, "apikey")
-
-  defp get_plan(query_params) do
-    with true <- query_params && Map.has_key?(query_params, "apikey"),
-         api_key_value <- Map.get(query_params, "apikey"),
-         api_key <- ApiKey.api_key_with_plan_by_value(api_key_value),
-         false <- is_nil(api_key) do
-      api_key.identity.plan
-    else
-      _ ->
-        nil
-    end
-  end
-
-  @spec rate_limit(String.t(), integer(), integer()) :: :ok | :rate_limit_reached
-  defp rate_limit(key, time_interval, limit) do
-    case Hammer.check_rate(key, time_interval, limit) do
-      {:allow, _count} ->
-        :ok
-
-      {:deny, _limit} ->
-        :rate_limit_reached
-
-      {:error, error} ->
-        Logger.error(fn -> ["Rate limit check error: ", inspect(error)] end)
-        :ok
-    end
-  end
-
   defp get_restricted_key(%Phoenix.Socket{}), do: nil
 
   defp get_restricted_key(conn) do
@@ -232,48 +98,22 @@ defmodule BlockScoutWeb.AccessHelper do
     conn_with_params.query_params["key"]
   end
 
-  defp whitelisted_ips(api_rate_limit_object) do
-    case api_rate_limit_object && api_rate_limit_object |> Keyword.fetch(:whitelisted_ips) do
-      {:ok, whitelisted_ips_string} ->
-        if whitelisted_ips_string, do: String.split(whitelisted_ips_string, ","), else: []
-
-      _ ->
-        []
-    end
-  end
+  @doc """
+  Converts a connection to an IP string.
 
-  defp api_v2_request?(%Plug.Conn{request_path: "/api/v2/" <> _}), do: true
-  defp api_v2_request?(_), do: false
+  ## Parameters
+  - conn: The connection to convert.
 
+  ## Returns
+  - A string representing the IP address. If the connection is behind a proxy, the IP address from the headers is used.
+  """
+  @spec conn_to_ip_string(Plug.Conn.t()) :: String.t()
   def conn_to_ip_string(conn) do
-    is_blockscout_behind_proxy = Application.get_env(:block_scout_web, :api_rate_limit)[:is_blockscout_behind_proxy]
+    config = Application.get_env(:block_scout_web, :remote_ip)
 
-    remote_ip_from_headers = is_blockscout_behind_proxy && RemoteIp.from(conn.req_headers)
+    remote_ip_from_headers = config[:is_blockscout_behind_proxy] && RemoteIp.from(conn.req_headers, config)
     ip = remote_ip_from_headers || conn.remote_ip
 
     to_string(:inet_parse.ntoa(ip))
   end
-
-  defp get_ui_v2_token(conn, ip_string) do
-    api_v2_temp_token_key = Application.get_env(:block_scout_web, :api_v2_temp_token_key)
-    conn = Conn.fetch_cookies(conn, signed: [api_v2_temp_token_key])
-
-    case api_v2_request?(conn) && conn.cookies[api_v2_temp_token_key] do
-      %{ip: ^ip_string} ->
-        conn.req_cookies[api_v2_temp_token_key]
-
-      _ ->
-        nil
-    end
-  end
-
-  defp get_user_agent(conn) do
-    case Conn.get_req_header(conn, "user-agent") do
-      [agent] ->
-        agent
-
-      _ ->
-        nil
-    end
-  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/account_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/account_view.ex
index 3a41871dc0c2..abfa84194ff7 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/account_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/account_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.API.V2.AccountView do
   def render("message.json", %{message: message}) do
     %{
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/tags_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/tags_view.ex
index 50403cf0f2f1..7bf19c534586 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/tags_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/tags_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.API.V2.TagsView do
   def render("address_tags.json", %{tags_map: tags_map}) do
     tags_map
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/user_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/user_view.ex
index 18f4c176d38e..0ef611e036df 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/user_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/api/v2/user_view.ex
@@ -1,9 +1,10 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.API.V2.UserView do
   use BlockScoutWeb, :view
 
   alias BlockScoutWeb.Account.API.V2.AccountView
   alias BlockScoutWeb.API.V2.Helper
-  alias Ecto.Changeset
+  alias BlockScoutWeb.ErrorHelper
   alias Explorer.Account.WatchlistAddress
   alias Explorer.Chain
   alias Explorer.Chain.Address
@@ -84,35 +85,16 @@ defmodule BlockScoutWeb.Account.API.V2.UserView do
     prepare_custom_abi(custom_abi)
   end
 
-  def render("public_tags_requests.json", %{public_tags_requests: public_tags_requests}) do
-    Enum.map(public_tags_requests, &prepare_public_tags_request/1)
-  end
-
-  def render("public_tags_request.json", %{public_tags_request: public_tags_request}) do
-    prepare_public_tags_request(public_tags_request)
-  end
-
   def render("changeset_errors.json", %{changeset: changeset}) do
     %{
-      "errors" =>
-        Changeset.traverse_errors(changeset, fn {msg, opts} ->
-          Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
-            opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
-          end)
-        end)
+      "errors" => ErrorHelper.changeset_to_errors(changeset)
     }
   end
 
   @spec prepare_watchlist_address(WatchlistAddress.t(), Chain.Address.t(), map()) :: map
   defp prepare_watchlist_address(watchlist, address, exchange_rate) do
-    %{
-      "id" => watchlist.id,
-      "address" => Helper.address_with_info(nil, address, watchlist.address_hash, false),
-      "address_hash" => watchlist.address_hash,
-      "name" => watchlist.name,
-      "address_balance" => if(address && address.fetched_coin_balance, do: address.fetched_coin_balance.value),
-      "exchange_rate" => exchange_rate.fiat_value,
-      "notification_settings" => %{
+    notification_settings =
+      %{
         "native" => %{
           "incoming" => watchlist.watch_coin_input,
           "outcoming" => watchlist.watch_coin_output
@@ -133,7 +115,26 @@ defmodule BlockScoutWeb.Account.API.V2.UserView do
           "incoming" => watchlist.watch_erc_404_input,
           "outcoming" => watchlist.watch_erc_404_output
         }
-      },
+      }
+
+    notification_settings_extended =
+      if not is_nil(Map.get(watchlist, :watch_zrc_2_input)) and not is_nil(Map.get(watchlist, :watch_zrc_2_output)) do
+        Map.put(notification_settings, "ZRC-2", %{
+          "incoming" => watchlist.watch_zrc_2_input,
+          "outcoming" => watchlist.watch_zrc_2_output
+        })
+      else
+        notification_settings
+      end
+
+    %{
+      "id" => watchlist.id,
+      "address" => Helper.address_with_info(nil, address, watchlist.address_hash, false),
+      "address_hash" => watchlist.address_hash,
+      "name" => watchlist.name,
+      "address_balance" => if(address && address.fetched_coin_balance, do: address.fetched_coin_balance.value),
+      "exchange_rate" => exchange_rate.fiat_value,
+      "notification_settings" => notification_settings_extended,
       "notification_methods" => %{
         "email" => watchlist.notify_email
       },
@@ -194,27 +195,4 @@ defmodule BlockScoutWeb.Account.API.V2.UserView do
       "name" => transaction_tag.name
     }
   end
-
-  defp prepare_public_tags_request(public_tags_request) do
-    addresses = Address.get_addresses_by_hashes(public_tags_request.addresses)
-
-    addresses_with_info =
-      Enum.map(addresses, fn address ->
-        Helper.address_with_info(nil, address, address.hash, false)
-      end)
-
-    %{
-      "id" => public_tags_request.id,
-      "full_name" => public_tags_request.full_name,
-      "email" => public_tags_request.email,
-      "company" => public_tags_request.company,
-      "website" => public_tags_request.website,
-      "tags" => public_tags_request.tags,
-      "addresses" => public_tags_request.addresses,
-      "addresses_with_info" => addresses_with_info,
-      "additional_comment" => public_tags_request.additional_comment,
-      "is_owner" => public_tags_request.is_owner,
-      "submission_date" => public_tags_request.inserted_at
-    }
-  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex
index a0b21b79da2d..5f13c99ac1e6 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/api_key_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.ApiKeyView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/auth_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/auth_view.ex
index cfbeb001e0fb..b73d0b8d9d55 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/auth_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/auth_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.AuthView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/common_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/common_view.ex
index e296ca90d696..1cf4237bf319 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/common_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/common_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.CommonView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex
index 3b38effa417b..5388692e12b0 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/custom_abi_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.CustomABIView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/public_tags_request_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/public_tags_request_view.ex
deleted file mode 100644
index 2a13dd8d7ea3..000000000000
--- a/apps/block_scout_web/lib/block_scout_web/views/account/public_tags_request_view.ex
+++ /dev/null
@@ -1,70 +0,0 @@
-defmodule BlockScoutWeb.Account.PublicTagsRequestView do
-  use BlockScoutWeb, :view
-  use Phoenix.HTML
-
-  alias Explorer.Account.PublicTagsRequest
-  alias Phoenix.HTML.Form
-
-  def array_input(form, field, attrs \\ []) do
-    values = Form.input_value(form, field) || [""]
-    id = Form.input_id(form, field)
-
-    content_tag :ul,
-      id: container_id(id),
-      data: [index: Enum.count(values), multiple_input_field_container: ""],
-      class: "multiple-input-fields-container" do
-      values
-      |> Enum.map(fn v ->
-        form_elements(form, field, to_string(v), attrs)
-      end)
-    end
-  end
-
-  def array_add_button(form, field, attrs \\ []) do
-    id = Form.input_id(form, field)
-
-    content =
-      form
-      |> form_elements(field, "", attrs)
-      |> safe_to_string
-
-    data = [
-      prototype: content,
-      container: container_id(id)
-    ]
-
-    content_tag(:button, render(BlockScoutWeb.CommonComponentsView, "_svg_plus.html"),
-      data: data,
-      class: "add-form-field"
-    )
-  end
-
-  defp form_elements(form, field, k, attrs) do
-    type = Form.input_type(form, field)
-    id = Form.input_id(form, field)
-
-    input_opts =
-      [
-        name: new_field_name(form, field),
-        value: k,
-        id: id,
-        class: "form-control public-tags-address"
-      ] ++ attrs
-
-    content_tag :li, class: "public-tags-address form-group" do
-      [
-        apply(Form, type, [form, field, input_opts]),
-        content_tag(:button, render(BlockScoutWeb.CommonComponentsView, "_svg_minus.html"),
-          data: [container: container_id(id)],
-          class: "remove-form-field ml-1"
-        )
-      ]
-    end
-  end
-
-  defp container_id(id), do: id <> "_container"
-
-  defp new_field_name(form, field) do
-    Form.input_name(form, field) <> "[]"
-  end
-end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/tag_address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/tag_address_view.ex
index 74886c33e9e5..499d134c0303 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/tag_address_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/tag_address_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.TagAddressView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/tag_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/tag_transaction_view.ex
index 7edfa1e3403e..422b4ce913ae 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/tag_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/tag_transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.TagTransactionView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_address_view.ex
index 29246e3a1556..79b2ede1f4ab 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_address_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_address_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.WatchlistAddressView do
   use BlockScoutWeb, :view
   import BlockScoutWeb.AddressView, only: [trimmed_hash: 1]
diff --git a/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_view.ex b/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_view.ex
index b30802d118dc..6ce08801e55f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/account/watchlist_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Account.WatchlistView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_coin_balance_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_coin_balance_view.ex
index 6e0363dab30c..d607d764fa1e 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_coin_balance_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_coin_balance_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressCoinBalanceView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_common_fields_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_common_fields_view.ex
index 933980752dfb..0066790cce2e 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_common_fields_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_common_fields_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationCommonFieldsView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_flattened_code_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_flattened_code_view.ex
index e5adf9b9d4ae..ad9d63227f89 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_flattened_code_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_flattened_code_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationViaFlattenedCodeView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_json_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_json_view.ex
index 79f8681233d3..69d788ea2c48 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_json_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_json_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationViaJsonView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_multi_part_files_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_multi_part_files_view.ex
index 76f88f059ac0..d6f21da31d40 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_multi_part_files_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_multi_part_files_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationViaMultiPartFilesView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_standard_json_input_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_standard_json_input_view.ex
index 9a4298a01c4b..7f4fd7f0cfa2 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_standard_json_input_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_via_standard_json_input_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationViaStandardJsonInputView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_view.ex
index 385d76c1c12e..2ca8889b2014 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_vyper_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_vyper_view.ex
index 47da5eab9093..b37b9a47101d 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_vyper_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_verification_vyper_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractVerificationVyperView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex
index bb47c8ad11ab..eb1a095d5d2f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_contract_view.ex
@@ -1,17 +1,17 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressContractView do
   use BlockScoutWeb, :view
+  use Phoenix.LiveView
 
   require Logger
 
   import Explorer.Helper, only: [decode_data: 2]
-  import Phoenix.LiveView.Helpers, only: [sigil_H: 2]
 
   alias ABI.FunctionSelector
   alias Explorer.Chain
   alias Explorer.Chain.{Address, Data, InternalTransaction, Transaction}
   alias Explorer.Chain.SmartContract
   alias Explorer.Chain.SmartContract.Proxy.EIP1167
-  alias Explorer.Helper, as: ExplorerHelper
   alias Explorer.SmartContract.Helper, as: SmartContractHelper
   alias Phoenix.HTML.Safe
 
@@ -65,7 +65,7 @@ defmodule BlockScoutWeb.AddressContractView do
         val_to_string_if_array(val, type, conn)
 
       type =~ "address" ->
-        address_hash = ExplorerHelper.add_0x_prefix(val)
+        address_hash = "0x" <> Base.encode16(val, case: :lower)
 
         address = Chain.string_to_address_hash_or_nil(address_hash)
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_internal_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_internal_transaction_view.ex
index 81cb6ca558b1..4b0f198404b9 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_internal_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_internal_transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressInternalTransactionView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_logs_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_logs_view.ex
index e1a4862fe21a..4ace8db298b9 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_logs_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_logs_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressLogsView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_read_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_read_contract_view.ex
index e13b14768e9b..a55776dcf062 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_read_contract_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_read_contract_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressReadContractView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_read_proxy_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_read_proxy_view.ex
index 447f362c3c5e..a9e535a164d7 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_read_proxy_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_read_proxy_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressReadProxyView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex
index 46f0e6a6c759..bfd5038dd2d1 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_token_balance_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressTokenBalanceView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_token_transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_token_transfer_view.ex
index c053e54af567..8a743bacf35b 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_token_transfer_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_token_transfer_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressTokenTransferView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_token_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_token_view.ex
index 941cfe292497..2552d3899606 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_token_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_token_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressTokenView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_transaction_view.ex
index b6539a49a2db..3f35d2c43fc9 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressTransactionView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_validation_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_validation_view.ex
index 1625fb33cdfd..94d3cc851199 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_validation_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_validation_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressValidationView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_view.ex
index 7b7ae76eeba0..8f1e916ef707 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressView do
   use BlockScoutWeb, :view
 
@@ -5,10 +6,11 @@ defmodule BlockScoutWeb.AddressView do
 
   alias BlockScoutWeb.{AccessHelper, LayoutView}
   alias BlockScoutWeb.API.V2.Helper, as: APIV2Helper
+  alias BlockScoutWeb.API.V2.TransactionView, as: APIV2TransactionView
   alias Explorer.Account.CustomABI
   alias Explorer.{Chain, CustomContractsHelper, Repo}
-  alias Explorer.Chain.Address.Counters
   alias Explorer.Chain.{Address, Hash, InternalTransaction, Log, SmartContract, Token, TokenTransfer, Transaction, Wei}
+  alias Explorer.Chain.Address.Counters
   alias Explorer.Chain.Block.Reward
   alias Explorer.Chain.SmartContract.Proxy.Models.Implementation
   alias Explorer.Market.Token, as: TokenExchangeRate
@@ -274,7 +276,7 @@ defmodule BlockScoutWeb.AddressView do
   end
 
   def transaction_hash(%Address{contract_creation_internal_transaction: %InternalTransaction{}} = address) do
-    address.contract_creation_internal_transaction.transaction_hash
+    InternalTransaction.preload_transaction(address.contract_creation_internal_transaction).transaction_hash
   end
 
   def transaction_hash(%Address{contract_creation_transaction: %Transaction{}} = address) do
@@ -475,7 +477,12 @@ defmodule BlockScoutWeb.AddressView do
           | {:error, atom(), list()}
           | {{:error, :contract_not_verified, list()}, any()}
   def decode(log, transaction) do
-    {result, _full_abi_per_address_hash_contracts_acc, _events_acc} = Log.decode(log, transaction, [], true, false)
+    full_abi =
+      (APIV2TransactionView.extract_implementations_abi(log.address.proxy_implementations) ++
+         APIV2TransactionView.try_to_get_abi(log.address.smart_contract))
+      |> Enum.uniq()
+
+    {result, _events_acc} = Log.decode(log, transaction, [], true, false, full_abi)
     result
   end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex
index 80572ba12789..bf9e08dba961 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_withdrawal_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressWithdrawalView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_write_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_write_contract_view.ex
index dc8d88ce5cc3..40c8d2479002 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_write_contract_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_write_contract_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressWriteContractView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/address_write_proxy_view.ex b/apps/block_scout_web/lib/block_scout_web/views/address_write_proxy_view.ex
index b27e5df50319..2971a6f38fa8 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/address_write_proxy_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/address_write_proxy_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.AddressWriteProxyView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/admin/dashboard_view.ex b/apps/block_scout_web/lib/block_scout_web/views/admin/dashboard_view.ex
index 536ef373a23c..f7f59afbf795 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/admin/dashboard_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/admin/dashboard_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Admin.DashboardView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/admin/session_view.ex b/apps/block_scout_web/lib/block_scout_web/views/admin/session_view.ex
index 0c0080118f68..d3176aa79016 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/admin/session_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/admin/session_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Admin.SessionView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/admin/setup_view.ex b/apps/block_scout_web/lib/block_scout_web/views/admin/setup_view.ex
index ab2df94f5f54..3b737fb05eda 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/admin/setup_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/admin/setup_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Admin.SetupView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/advertisement/banners_ad_view.ex b/apps/block_scout_web/lib/block_scout_web/views/advertisement/banners_ad_view.ex
index 47f461ff0eaa..41df4c9c80d4 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/advertisement/banners_ad_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/advertisement/banners_ad_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Advertisement.BannersAdView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/advertisement/text_ad_view.ex b/apps/block_scout_web/lib/block_scout_web/views/advertisement/text_ad_view.ex
index 8d73eb3d291e..0bc4e48c0757 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/advertisement/text_ad_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/advertisement/text_ad_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Advertisement.TextAdView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex
index 0e22ee5ba630..ca8757f2b924 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/eth_rpc/view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.EthRPC.View do
   @moduledoc """
   Views for /eth-rpc API endpoints
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/legacy/block_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/block_view.ex
new file mode 100644
index 000000000000..4be78d8953fb
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/block_view.ex
@@ -0,0 +1,16 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
+defmodule BlockScoutWeb.API.Legacy.BlockView do
+  @moduledoc false
+
+  alias BlockScoutWeb.API.RPC.BlockView, as: V1BlockView
+
+  # The v1 controller calls render/2 without a template name on the success path;
+  # Phoenix derives it from conn.private.phoenix_action, which for this wrapper is
+  # :get_block_number_by_time → "get_block_number_by_time.json". Bridge that to the
+  # v1 template name so the delegate view resolves correctly.
+  def render("get_block_number_by_time.json", assigns) do
+    V1BlockView.render("getblocknobytime.json", assigns)
+  end
+
+  defdelegate render(template, assigns), to: V1BlockView
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex
new file mode 100644
index 000000000000..bd0141a642bc
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/legacy/logs_view.ex
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
+defmodule BlockScoutWeb.API.Legacy.LogsView do
+  @moduledoc false
+  defdelegate render(template, assigns), to: BlockScoutWeb.API.RPC.LogsView
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex
index dc7f3947df19..bf97405552ec 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/address_view.ex
@@ -1,9 +1,10 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.AddressView do
   use BlockScoutWeb, :view
 
   alias BlockScoutWeb.API.EthRPC.View, as: EthRPCView
   alias BlockScoutWeb.API.RPC.RPCView
-  alias Explorer.Chain.DenormalizationHelper
+  alias Explorer.Chain.{DenormalizationHelper, InternalTransaction, Transaction}
 
   def render("listaccounts.json", %{accounts: accounts}) do
     accounts = Enum.map(accounts, &prepare_account/1)
@@ -39,13 +40,16 @@ defmodule BlockScoutWeb.API.RPC.AddressView do
     RPCView.render("show.json", data: data)
   end
 
-  def render("tokentx.json", %{token_transfers: token_transfers}) do
-    data = Enum.map(token_transfers, &prepare_token_transfer/1)
-    RPCView.render("show.json", data: data)
-  end
+  def render("tokentx.json", %{token_transfers: token_transfers, max_block_number: max_block_number}) do
+    transactions = token_transfers |> Enum.map(& &1.transaction) |> Transaction.decode_transactions(true, api?: true)
+
+    data =
+      token_transfers
+      |> Enum.zip(transactions)
+      |> Enum.map(fn {token_transfer, decoded_input} ->
+        prepare_token_transfer(token_transfer, max_block_number, decoded_input)
+      end)
 
-  def render("tokennfttx.json", %{token_transfers: token_transfers, max_block_number: max_block_number}) do
-    data = Enum.map(token_transfers, &prepare_nft_transfer(&1, max_block_number))
     RPCView.render("show.json", data: data)
   end
 
@@ -71,6 +75,11 @@ defmodule BlockScoutWeb.API.RPC.AddressView do
     EthRPCView.render("error.json", %{error: message, id: 0})
   end
 
+  def render("pending_internal_transaction.json", %{data: data} = assigns) do
+    prepared_internal_transactions = Enum.map(data, &prepare_internal_transaction/1)
+    RPCView.render("pending_internal_transaction.json", Map.put(assigns, :data, prepared_internal_transactions))
+  end
+
   def render("error.json", assigns) do
     RPCView.render("error.json", assigns)
   end
@@ -126,123 +135,129 @@ defmodule BlockScoutWeb.API.RPC.AddressView do
       "contractAddress" => "#{transaction.created_contract_address_hash}",
       "cumulativeGasUsed" => "#{transaction.cumulative_gas_used}",
       "gasUsed" => "#{transaction.gas_used}",
-      "confirmations" => "#{transaction.confirmations}"
+      "confirmations" => "#{transaction.confirmations}",
+      "methodId" => Transaction.method_id(transaction)
     }
   end
 
+  # Prepares an internal transaction for API response.
+  @spec prepare_internal_transaction(InternalTransaction.t()) :: map()
   defp prepare_internal_transaction(internal_transaction) do
     %{
       "blockNumber" => "#{internal_transaction.block_number}",
       "timeStamp" => "#{DateTime.to_unix(internal_transaction.block_timestamp)}",
       "from" => "#{internal_transaction.from_address_hash}",
       "to" => "#{internal_transaction.to_address_hash}",
-      "value" => "#{internal_transaction.value.value}",
+      "value" => "#{(internal_transaction.value && internal_transaction.value.value) || 0}",
       "contractAddress" => "#{internal_transaction.created_contract_address_hash}",
       "transactionHash" => to_string(internal_transaction.transaction_hash),
       "index" => to_string(internal_transaction.index),
       "input" => "#{internal_transaction.input}",
       "type" => "#{internal_transaction.type}",
-      "callType" => "#{internal_transaction.call_type}",
-      "gas" => "#{internal_transaction.gas}",
+      "callType" => "#{InternalTransaction.call_type(internal_transaction)}",
+      "gas" => to_string(internal_transaction.gas || 0),
       "gasUsed" => "#{internal_transaction.gas_used}",
       "isError" => if(internal_transaction.error, do: "1", else: "0"),
       "errCode" => "#{internal_transaction.error}"
     }
   end
 
-  defp prepare_common_token_transfer(token_transfer) do
+  defp prepare_common_token_transfer(token_transfer, max_block_number, decoded_input) do
+    tt_denormalization_fields =
+      if DenormalizationHelper.tt_denormalization_finished?() do
+        %{
+          "timeStamp" =>
+            if(token_transfer.transaction.block_timestamp,
+              do: to_string(DateTime.to_unix(token_transfer.transaction.block_timestamp)),
+              else: ""
+            )
+        }
+      else
+        %{
+          "timeStamp" =>
+            if(token_transfer.block.timestamp,
+              do: to_string(DateTime.to_unix(token_transfer.block.timestamp)),
+              else: ""
+            )
+        }
+      end
+
     %{
       "blockNumber" => to_string(token_transfer.block_number),
-      "timeStamp" => to_string(DateTime.to_unix(token_transfer.block_timestamp)),
       "hash" => to_string(token_transfer.transaction_hash),
-      "nonce" => to_string(token_transfer.transaction_nonce),
+      "nonce" => to_string(token_transfer.transaction.nonce),
       "blockHash" => to_string(token_transfer.block_hash),
       "from" => to_string(token_transfer.from_address_hash),
       "contractAddress" => to_string(token_transfer.token_contract_address_hash),
       "to" => to_string(token_transfer.to_address_hash),
-      "logIndex" => to_string(token_transfer.token_log_index),
-      "tokenName" => token_transfer.token_name,
-      "tokenSymbol" => token_transfer.token_symbol,
-      "tokenDecimal" => to_string(token_transfer.token_decimals),
-      "transactionIndex" => to_string(token_transfer.transaction_index),
-      "gas" => to_string(token_transfer.transaction_gas),
-      "gasPrice" => to_string(token_transfer.transaction_gas_price && token_transfer.transaction_gas_price.value),
-      "gasUsed" => to_string(token_transfer.transaction_gas_used),
-      "cumulativeGasUsed" => to_string(token_transfer.transaction_cumulative_gas_used),
-      "input" => to_string(token_transfer.transaction_input),
-      "confirmations" => to_string(token_transfer.confirmations)
+      "tokenName" => token_transfer.token.name,
+      "tokenSymbol" => token_transfer.token.symbol,
+      "tokenDecimal" => to_string(token_transfer.token.decimals),
+      "transactionIndex" => to_string(token_transfer.transaction.index),
+      "gas" => to_string(token_transfer.transaction.gas),
+      "gasPrice" => to_string(token_transfer.transaction.gas_price && token_transfer.transaction.gas_price.value),
+      "gasUsed" => to_string(token_transfer.transaction.gas_used),
+      "cumulativeGasUsed" => to_string(token_transfer.transaction.cumulative_gas_used),
+      "input" => to_string(token_transfer.transaction.input),
+      "confirmations" => to_string(max_block_number - token_transfer.block_number)
     }
+    |> Map.merge(tt_denormalization_fields)
+    |> Map.merge(prepare_decoded_input(decoded_input))
   end
 
-  defp prepare_token_transfer(%{token_type: "ERC-721"} = token_transfer) do
+  defp prepare_token_transfer(%{token_type: "ERC-721"} = token_transfer, max_block_number, decoded_input) do
     token_transfer
-    |> prepare_common_token_transfer()
+    |> prepare_common_token_transfer(max_block_number, decoded_input)
     |> Map.put_new(:tokenID, List.first(token_transfer.token_ids))
   end
 
-  # todo: Mark tokenID field as deprecated in release notes, and delete it in the next release
-  # when tokenID will be deleted, merge this, and next `prepare_token_transfer/1` clauses
-  defp prepare_token_transfer(%{token_type: "ERC-1155", token_ids: [token_id]} = token_transfer) do
+  defp prepare_token_transfer(%{token_type: "ERC-1155"} = token_transfer, max_block_number, decoded_input) do
     token_transfer
-    |> prepare_common_token_transfer()
-    |> Map.put_new(:tokenID, token_id)
-    |> Map.put_new(:tokenIDs, token_transfer.token_ids)
-    |> Map.put_new(:values, token_transfer.amounts)
+    |> prepare_common_token_transfer(max_block_number, decoded_input)
+    |> Map.put_new(:tokenID, token_transfer.token_id)
+    |> Map.put_new(:tokenValue, token_transfer.amount)
   end
 
-  defp prepare_token_transfer(%{token_type: "ERC-1155"} = token_transfer) do
+  defp prepare_token_transfer(%{token_type: "ERC-404"} = token_transfer, max_block_number, decoded_input) do
     token_transfer
-    |> prepare_common_token_transfer()
-    |> Map.put_new(:tokenIDs, token_transfer.token_ids)
-    |> Map.put_new(:values, token_transfer.amounts)
+    |> prepare_common_token_transfer(max_block_number, decoded_input)
+    |> Map.put_new(:tokenID, to_string(List.first(token_transfer.token_ids)))
+    |> Map.put_new(:value, to_string(List.first(token_transfer.amounts)))
   end
 
-  defp prepare_token_transfer(%{token_type: "ERC-404"} = token_transfer) do
+  defp prepare_token_transfer(%{token_type: "ERC-20"} = token_transfer, max_block_number, decoded_input) do
     token_transfer
-    |> prepare_common_token_transfer()
-    |> Map.put_new(:tokenIDs, token_transfer.token_ids)
-    |> Map.put_new(:values, token_transfer.amounts)
+    |> prepare_common_token_transfer(max_block_number, decoded_input)
+    |> Map.put_new(:value, to_string(token_transfer.amount))
   end
 
-  defp prepare_token_transfer(%{token_type: "ERC-20"} = token_transfer) do
+  defp prepare_token_transfer(%{token_type: "ZRC-2"} = token_transfer, max_block_number, decoded_input) do
     token_transfer
-    |> prepare_common_token_transfer()
+    |> prepare_common_token_transfer(max_block_number, decoded_input)
     |> Map.put_new(:value, to_string(token_transfer.amount))
   end
 
-  defp prepare_token_transfer(token_transfer) do
-    prepare_common_token_transfer(token_transfer)
+  defp prepare_token_transfer(%{token_type: "ERC-7984"} = token_transfer, max_block_number, decoded_input) do
+    token_transfer
+    |> prepare_common_token_transfer(max_block_number, decoded_input)
+    |> Map.put_new(:value, nil)
   end
 
-  defp prepare_nft_transfer(token_transfer, max_block_number) do
-    timestamp =
-      if DenormalizationHelper.tt_denormalization_finished?() do
-        to_string(DateTime.to_unix(token_transfer.transaction.block_timestamp))
-      else
-        to_string(DateTime.to_unix(token_transfer.block.timestamp))
-      end
+  defp prepare_token_transfer(token_transfer, max_block_number, decoded_input) do
+    prepare_common_token_transfer(token_transfer, max_block_number, decoded_input)
+  end
 
+  defp prepare_decoded_input({:ok, method_id, text, _mapping}) do
     %{
-      "blockNumber" => to_string(token_transfer.block_number),
-      "timeStamp" => timestamp,
-      "hash" => to_string(token_transfer.transaction_hash),
-      "nonce" => to_string(token_transfer.transaction.nonce),
-      "blockHash" => to_string(token_transfer.block_hash),
-      "from" => to_string(token_transfer.from_address_hash),
-      "contractAddress" => to_string(token_transfer.token_contract_address_hash),
-      "to" => to_string(token_transfer.to_address_hash),
-      "tokenID" => to_string(List.first(token_transfer.token_ids)),
-      "logIndex" => to_string(token_transfer.log_index),
-      "tokenName" => token_transfer.token.name,
-      "tokenSymbol" => token_transfer.token.symbol,
-      "tokenDecimal" => to_string(token_transfer.token.decimals || 0),
-      "transactionIndex" => to_string(token_transfer.transaction.index),
-      "gas" => to_string(token_transfer.transaction.gas),
-      "gasPrice" => to_string(token_transfer.transaction.gas_price && token_transfer.transaction.gas_price.value),
-      "gasUsed" => to_string(token_transfer.transaction.gas_used),
-      "cumulativeGasUsed" => to_string(token_transfer.transaction.cumulative_gas_used),
-      "input" => "deprecated",
-      "confirmations" => to_string(max_block_number - token_transfer.block_number)
+      "methodId" => method_id,
+      "functionName" => text
+    }
+  end
+
+  defp prepare_decoded_input(_) do
+    %{
+      "methodId" => "",
+      "functionName" => ""
     }
   end
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/block_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/block_view.ex
index b0262aaf937c..82be719bb9fb 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/block_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/block_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.BlockView do
   use BlockScoutWeb, :view
 
@@ -9,14 +10,14 @@ defmodule BlockScoutWeb.API.RPC.BlockView do
   def render("block_reward.json", %{block: %Block{rewards: [_ | _]} = block}) do
     reward_as_string =
       block.rewards
-      |> Enum.find(%{reward: %Wei{value: Decimal.new(0)}}, &(&1.address_type == :validator))
+      |> Enum.find(%{reward: Wei.zero()}, &(&1.address_type == :validator))
       |> Map.get(:reward)
       |> Wei.to(:wei)
       |> Decimal.to_string(:normal)
 
     static_reward =
       block.rewards
-      |> Enum.find(%{reward: %Wei{value: Decimal.new(0)}}, &(&1.address_type == :emission_funds))
+      |> Enum.find(%{reward: Wei.zero()}, &(&1.address_type == :emission_funds))
       |> Map.get(:reward)
       |> Wei.to(:wei)
 
@@ -78,11 +79,14 @@ defmodule BlockScoutWeb.API.RPC.BlockView do
   end
 
   def render("getblocknobytime.json", %{block_number: block_number}) do
-    data = %{
-      "blockNumber" => to_string(block_number)
-    }
+    # TODO: migrate to the following format in the next release
+    # RPCView.render("show.json", data: to_string(block_number))
 
-    RPCView.render("show.json", data: data)
+    RPCView.render("show.json",
+      data: %{
+        "blockNumber" => to_string(block_number)
+      }
+    )
   end
 
   def render("eth_block_number.json", %{number: number, id: id}) do
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/celo_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/celo_view.ex
new file mode 100644
index 000000000000..98811d3b8772
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/celo_view.ex
@@ -0,0 +1,45 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
+defmodule BlockScoutWeb.API.RPC.CeloView do
+  use BlockScoutWeb, :view
+
+  alias BlockScoutWeb.API.RPC.RPCView
+  alias BlockScoutWeb.API.V2.CeloView, as: CeloViewV2
+
+  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
+  def render("celo_epoch.json", %{epoch: epoch, aggregated_rewards: aggregated_rewards}) do
+    distribution = epoch.distribution
+
+    data = %{
+      "epochNumber" => to_string(epoch.number),
+      "type" => CeloViewV2.epoch_type(epoch),
+      "isFinalized" => epoch.fetched?,
+      "timestamp" =>
+        epoch.end_processing_block &&
+          epoch.end_processing_block.timestamp |> DateTime.to_unix() |> to_string(),
+      "startProcessingBlockHash" => epoch.start_processing_block && to_string(epoch.start_processing_block.hash),
+      "startProcessingBlockNumber" => epoch.start_processing_block && to_string(epoch.start_processing_block.number),
+      "endProcessingBlockHash" => epoch.end_processing_block && to_string(epoch.end_processing_block.hash),
+      "endProcessingBlockNumber" => epoch.end_processing_block && to_string(epoch.end_processing_block.number),
+      "carbonOffsettingTargetEpochRewards" =>
+        distribution &&
+          distribution.carbon_offsetting_transfer &&
+          distribution.carbon_offsetting_transfer.amount,
+      "communityTargetEpochRewards" =>
+        distribution &&
+          distribution.community_transfer &&
+          distribution.community_transfer.amount,
+      "reserveBolster" =>
+        distribution &&
+          distribution.reserve_bolster_transfer &&
+          distribution.reserve_bolster_transfer.amount,
+      "voterTargetEpochRewards" => aggregated_rewards.voter && aggregated_rewards.voter.total,
+      "validatorTargetEpochRewards" => aggregated_rewards.validator && aggregated_rewards.validator.total
+    }
+
+    RPCView.render("show.json", data: data)
+  end
+
+  def render("error.json", %{error: error}) do
+    RPCView.render("error.json", error: error)
+  end
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex
index 02cbbe6bc96c..9fb8db287f2d 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/contract_view.ex
@@ -1,15 +1,21 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.ContractView do
   use BlockScoutWeb, :view
 
-  alias BlockScoutWeb.AddressView
   alias BlockScoutWeb.API.RPC.RPCView
   alias BlockScoutWeb.API.V2.Helper, as: APIV2Helper
-  alias Explorer.Chain.{Address, SmartContract}
+
+  alias Explorer.Chain.{
+    Address,
+    InternalTransaction,
+    SmartContract,
+    Transaction
+  }
 
   defguardp is_empty_string(input) when input == "" or input == nil
 
   def render("getcontractcreation.json", %{addresses: addresses}) do
-    contracts = addresses |> Enum.map(&address_to_response/1) |> Enum.reject(&is_nil/1)
+    contracts = addresses |> Enum.map(&prepare_contract_creation_info/1) |> Enum.reject(&is_nil/1)
 
     RPCView.render("show.json", data: contracts)
   end
@@ -238,15 +244,45 @@ defmodule BlockScoutWeb.API.RPC.ContractView do
     end
   end
 
-  defp address_to_response(address) do
-    creator_hash = AddressView.from_address_hash(address)
-    creation_transaction = creator_hash && AddressView.transaction_hash(address)
+  @spec prepare_contract_creation_info(Address.t()) :: %{binary() => binary()} | nil
+  defp prepare_contract_creation_info(%Address{
+         contract_creation_internal_transaction:
+           %InternalTransaction{
+             transaction: %Transaction{} = transaction
+           } = internal_transaction
+       }) do
+    %{
+      "contractAddress" => to_string(internal_transaction.created_contract_address_hash),
+      "contractFactory" => to_string(internal_transaction.from_address_hash),
+      "creationBytecode" => to_string(internal_transaction.init)
+    }
+    |> with_creation_transaction_info(transaction)
+  end
 
-    creation_transaction &&
-      %{
-        "contractAddress" => to_string(address.hash),
-        "contractCreator" => to_string(creator_hash),
-        "txHash" => to_string(creation_transaction)
-      }
+  defp prepare_contract_creation_info(%Address{
+         contract_creation_transaction: %Transaction{} = transaction
+       }) do
+    %{
+      "contractAddress" => to_string(transaction.created_contract_address_hash),
+      "contractFactory" => "",
+      "creationBytecode" => to_string(transaction.input)
+    }
+    |> with_creation_transaction_info(transaction)
+  end
+
+  defp prepare_contract_creation_info(_), do: nil
+
+  @spec with_creation_transaction_info(%{binary() => binary()}, Transaction.t()) ::
+          %{binary() => binary()}
+  defp with_creation_transaction_info(info, transaction) do
+    unix_timestamp = DateTime.to_unix(transaction.block_timestamp, :second)
+
+    %{
+      "contractCreator" => to_string(transaction.from_address_hash),
+      "txHash" => to_string(transaction.hash),
+      "blockNumber" => to_string(transaction.block_number),
+      "timestamp" => to_string(unix_timestamp)
+    }
+    |> Map.merge(info)
   end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex
index e5bbe190efee..98d13f9c3c65 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/logs_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.LogsView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/rpc_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/rpc_view.ex
index f877d947d064..30cfc693f4d4 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/rpc_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/rpc_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.RPCView do
   use BlockScoutWeb, :view
 
@@ -9,12 +10,12 @@ defmodule BlockScoutWeb.API.RPC.RPCView do
     }
   end
 
-  def render("show_value.json", %{data: data}) do
-    {value, _} =
-      data
-      |> Float.parse()
-
-    value
+  def render("pending_internal_transaction.json", %{data: data, message: message}) do
+    %{
+      "status" => "2",
+      "message" => message,
+      "result" => data
+    }
   end
 
   def render("error.json", %{error: message} = assigns) do
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/stats_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/stats_view.ex
index 7b83e5589aec..41851279c023 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/stats_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/stats_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.StatsView do
   use BlockScoutWeb, :view
 
@@ -16,7 +17,7 @@ defmodule BlockScoutWeb.API.RPC.StatsView do
   end
 
   def render("coinsupply.json", %{total_supply: total_supply}) do
-    RPCView.render("show_value.json", data: total_supply)
+    RPCView.render("show.json", data: total_supply)
   end
 
   def render("ethprice.json", %{rates: rates}) do
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex
index f2c8de82dd6b..4e10cc43812f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/token_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.TokenView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex
index 74c8ba5a32e1..b2b99387cdad 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/rpc/transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.RPC.TransactionView do
   use BlockScoutWeb, :view
 
@@ -61,7 +62,8 @@ defmodule BlockScoutWeb.API.RPC.TransactionView do
       "hash" => "#{transaction.hash}",
       "timeStamp" => "#{DateTime.to_unix(Transaction.block_timestamp(transaction))}",
       "blockNumber" => "#{transaction.block_number}",
-      "confirmations" => "#{block_height - transaction.block_number}",
+      "confirmations" =>
+        if(is_nil(transaction.block_number), do: "0", else: "#{block_height - transaction.block_number}"),
       "success" => if(transaction.status == :ok, do: true, else: false),
       "from" => "#{transaction.from_address_hash}",
       "to" => "#{transaction.to_address_hash}",
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v1/supply_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v1/supply_view.ex
index df76632e2776..4600f8a21e44 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v1/supply_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v1/supply_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V1.SupplyView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_badge_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_badge_view.ex
index 4e17eda4e55e..5858894a33c9 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_badge_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_badge_view.ex
@@ -1,7 +1,8 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.AddressBadgeView do
   use BlockScoutWeb, :view
 
-  alias Explorer.Helper, as: ExplorerHelper
+  alias Explorer.Chain.Address
 
   def render("badge_to_address.json", %{badge_to_address_list: badge_to_address_list, status: status}) do
     prepare_badge_to_address(badge_to_address_list, status)
@@ -28,7 +29,7 @@ defmodule BlockScoutWeb.API.V2.AddressBadgeView do
     badge_to_address_list
     |> Enum.map(fn badge_to_address ->
       %{
-        address_hash: ExplorerHelper.add_0x_prefix(badge_to_address.address_hash)
+        address_hash: Address.checksum(badge_to_address.address_hash)
       }
     end)
   end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex
index 40cd38ce49bb..8f62977ef208 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex
@@ -1,11 +1,13 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.AddressView do
   use BlockScoutWeb, :view
-  use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
+  use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity]
 
   import BlockScoutWeb.Account.AuthController, only: [current_user: 1]
 
-  alias BlockScoutWeb.AddressView
+  alias BlockScoutWeb.{AddressContractView, AddressView}
   alias BlockScoutWeb.API.V2.{ApiView, Helper, TokenView}
+  alias Explorer.Account.WatchlistAddress
   alias Explorer.{Chain, Market}
   alias Explorer.Chain.Address
   alias Explorer.Chain.Address.Counters
@@ -84,7 +86,7 @@ defmodule BlockScoutWeb.API.V2.AddressView do
     - Map containing:
       - `:hash` - address hash
       - `:coin_balance` - current coin balance value
-      - `:transaction_count` - number of transactions as string
+      - `:transactions_count` - number of transactions as string
       - Additional address info fields from Helper.address_with_info/4
   """
   @spec prepare_address_for_list(Address.t()) :: map()
@@ -92,8 +94,6 @@ defmodule BlockScoutWeb.API.V2.AddressView do
     nil
     |> Helper.address_with_info(address, address.hash, true)
     |> Map.put(:transactions_count, to_string(address.transactions_count))
-    # todo: It should be removed in favour `transaction_count` property with the next release after 8.0.0
-    |> Map.put(:transaction_count, to_string(address.transactions_count))
     |> Map.put(:coin_balance, if(address.fetched_coin_balance, do: address.fetched_coin_balance.value))
   end
 
@@ -113,6 +113,7 @@ defmodule BlockScoutWeb.API.V2.AddressView do
       Map.merge(base_info, %{
         "creator_address_hash" => creator_hash && Address.checksum(creator_hash),
         "creation_transaction_hash" => creation_transaction_hash,
+        "creation_status" => creation_status(address),
         "token" => token,
         "coin_balance" => balance,
         "exchange_rate" => exchange_rate,
@@ -121,7 +122,7 @@ defmodule BlockScoutWeb.API.V2.AddressView do
         "has_logs" => Counters.check_if_logs_at_address(address.hash, @api_true),
         "has_tokens" => Counters.check_if_tokens_at_address(address.hash, @api_true),
         "has_token_transfers" => Counters.check_if_token_transfers_at_address(address.hash, @api_true),
-        "watchlist_address_id" => Chain.select_watchlist_address_id(get_watchlist_id(conn), address.hash),
+        "watchlist_address_id" => WatchlistAddress.select_watchlist_address_id(get_watchlist_id(conn), address.hash),
         "has_beacon_chain_withdrawals" => Counters.check_if_withdrawals_at_address(address.hash, @api_true)
       })
 
@@ -229,7 +230,7 @@ defmodule BlockScoutWeb.API.V2.AddressView do
              @api_true
            ) do
         # `%{hash: address_hash}` will match with `address_with_info(_, address_hash)` clause in `BlockScoutWeb.API.V2.Helper`
-        {:ok, token_instance} ->
+        {:ok, %Instance{} = token_instance} ->
           %Instance{
             token_instance
             | owner: %{hash: address_hash},
@@ -255,12 +256,26 @@ defmodule BlockScoutWeb.API.V2.AddressView do
     })
   end
 
+  @spec creation_status(Address.t()) :: :success | :failed | :selfdestructed | nil
+  defp creation_status(address) do
+    with true <- Address.smart_contract?(address),
+         {status, _bytecode} <- AddressContractView.contract_creation_code(address) do
+      case status do
+        :ok -> :success
+        :failed -> :failed
+        :selfdestructed -> :selfdestructed
+      end
+    else
+      _ -> nil
+    end
+  end
+
   @spec chain_type_fields(
           map(),
           %{address: Address.t(), creation_transaction_from_address: Address.t()}
         ) :: map()
-  case @chain_type do
-    :filecoin ->
+  case @chain_identity do
+    {:filecoin, nil} ->
       defp chain_type_fields(result, %{creation_transaction_from_address: creation_transaction_from_address}) do
         # credo:disable-for-next-line Credo.Check.Design.AliasUsage
         BlockScoutWeb.API.V2.FilecoinView.put_filecoin_robust_address(result, %{
@@ -269,7 +284,13 @@ defmodule BlockScoutWeb.API.V2.AddressView do
         })
       end
 
-    :zilliqa ->
+    {:optimism, :celo} ->
+      defp chain_type_fields(result, %{address: address}) do
+        # credo:disable-for-next-line Credo.Check.Design.AliasUsage
+        BlockScoutWeb.API.V2.CeloView.extend_address_json_response(result, address)
+      end
+
+    {:zilliqa, nil} ->
       defp chain_type_fields(result, %{address: address}) do
         # credo:disable-for-next-line Credo.Check.Design.AliasUsage
         BlockScoutWeb.API.V2.ZilliqaView.extend_address_json_response(result, address)
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex
index 4cd0ecd3658a..35ef4658c8b5 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/advanced_filter_view.ex
@@ -1,11 +1,9 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.AdvancedFilterView do
   use BlockScoutWeb, :view
 
-  alias BlockScoutWeb.API.V2.{Helper, TokenTransferView, TokenView}
+  alias BlockScoutWeb.API.V2.{Helper, TokenTransferView, TokenView, TransactionView}
   alias Explorer.Chain.{Address, Data, Transaction}
-  alias Explorer.Helper, as: ExplorerHelper
-  alias Explorer.Market
-  alias Explorer.Market.MarketHistory
 
   def render("advanced_filters.json", %{
         advanced_filters: advanced_filters,
@@ -30,91 +28,13 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterView do
     methods
   end
 
-  def to_csv_format(advanced_filters) do
-    exchange_rate = Market.get_coin_exchange_rate()
-
-    date_to_prices =
-      Enum.reduce(advanced_filters, %{}, fn af, acc ->
-        date = DateTime.to_date(af.timestamp)
-
-        if Map.has_key?(acc, date) do
-          acc
-        else
-          market_history = MarketHistory.price_at_date(date)
-
-          Map.put(
-            acc,
-            date,
-            {market_history && market_history.opening_price, market_history && market_history.closing_price}
-          )
-        end
-      end)
-
-    row_names = [
-      "TxHash",
-      "Type",
-      "MethodId",
-      "UtcTimestamp",
-      "FromAddress",
-      "ToAddress",
-      "CreatedContractAddress",
-      "Value",
-      "TokenContractAddressHash",
-      "TokenDecimals",
-      "TokenSymbol",
-      "BlockNumber",
-      "Fee",
-      "CurrentPrice",
-      "TxDateOpeningPrice",
-      "TxDateClosingPrice"
-    ]
-
-    af_lists =
-      advanced_filters
-      |> Stream.map(fn advanced_filter ->
-        method_id =
-          case advanced_filter.input do
-            %{bytes: <>} -> ExplorerHelper.add_0x_prefix(method_id)
-            _ -> nil
-          end
-
-        {opening_price, closing_price} = date_to_prices[DateTime.to_date(advanced_filter.timestamp)]
-
-        [
-          to_string(advanced_filter.hash),
-          advanced_filter.type,
-          method_id,
-          advanced_filter.timestamp,
-          Address.checksum(advanced_filter.from_address_hash),
-          Address.checksum(advanced_filter.to_address_hash),
-          Address.checksum(advanced_filter.created_contract_address_hash),
-          decimal_to_string_xsd(advanced_filter.value),
-          if(advanced_filter.type != "coin_transfer",
-            do: Address.checksum(advanced_filter.token_transfer.token.contract_address_hash),
-            else: nil
-          ),
-          if(advanced_filter.type != "coin_transfer",
-            do: decimal_to_string_xsd(advanced_filter.token_transfer.token.decimals),
-            else: nil
-          ),
-          if(advanced_filter.type != "coin_transfer", do: advanced_filter.token_transfer.token.symbol, else: nil),
-          advanced_filter.block_number,
-          decimal_to_string_xsd(advanced_filter.fee),
-          decimal_to_string_xsd(exchange_rate.fiat_value),
-          decimal_to_string_xsd(opening_price),
-          decimal_to_string_xsd(closing_price)
-        ]
-      end)
-
-    Stream.concat([row_names], af_lists)
-  end
-
   defp prepare_advanced_filter(advanced_filter, decoded_input) do
     %{
       hash: advanced_filter.hash,
       type: advanced_filter.type,
+      status: TransactionView.format_status(advanced_filter.status),
       method:
-        if(advanced_filter.type != "coin_transfer",
+        if(advanced_filter.created_from == :token_transfer,
           do:
             Transaction.method_name(
               %Transaction{
@@ -155,12 +75,12 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterView do
         ),
       value: advanced_filter.value,
       total:
-        if(advanced_filter.type != "coin_transfer",
+        if(advanced_filter.created_from == :token_transfer,
           do: TokenTransferView.prepare_token_transfer_total(advanced_filter.token_transfer),
           else: nil
         ),
       token:
-        if(advanced_filter.type != "coin_transfer",
+        if(advanced_filter.created_from == :token_transfer,
           do: TokenView.render("token.json", %{token: advanced_filter.token_transfer.token}),
           else: nil
         ),
@@ -182,7 +102,4 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterView do
 
     %{methods: method_ids, tokens: tokens_map}
   end
-
-  defp decimal_to_string_xsd(nil), do: nil
-  defp decimal_to_string_xsd(decimal), do: Decimal.to_string(decimal, :xsd)
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/api_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/api_view.ex
index 1fde984d4a41..288890e4ab75 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/api_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/api_view.ex
@@ -1,7 +1,19 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ApiView do
+  use BlockScoutWeb, :view
+
+  alias BlockScoutWeb.ErrorHelper
+
   def render("message.json", %{message: message}) do
     %{
       "message" => message
     }
   end
+
+  def render("smart_contract_audit_report_changeset_errors.json", %{changeset: changeset}) do
+    %{
+      "errors" => ErrorHelper.changeset_to_errors(changeset),
+      "message" => "Error on inserting audit report"
+    }
+  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/arbitrum_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/arbitrum_view.ex
index 005e54bc67ca..2eeafce33d70 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/arbitrum_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/arbitrum_view.ex
@@ -1,11 +1,12 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ArbitrumView do
   use BlockScoutWeb, :view
 
   alias BlockScoutWeb.API.V2.ApiView
   alias BlockScoutWeb.API.V2.Helper, as: APIV2Helper
-  alias Explorer.Chain.{Block, Hash, Transaction, Wei}
   alias Explorer.Chain.Arbitrum.{L1Batch, LifecycleTransaction}
   alias Explorer.Chain.Arbitrum.Reader.API.Settlement, as: SettlementReader
+  alias Explorer.Chain.{Block, Hash, Transaction, Wei}
 
   @doc """
     Function to render error\\text responses for GET requests
@@ -29,8 +30,6 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
         %{
           "id" => msg.message_id,
           "origination_address_hash" => msg.originator_address,
-          # todo: It should be removed in favour `origination_address_hash` property with the next release after 8.0.0
-          "origination_address" => msg.originator_address,
           "origination_transaction_hash" => msg.originating_transaction_hash,
           "origination_timestamp" => msg.origination_timestamp,
           "origination_transaction_block_number" => msg.originating_transaction_block_number,
@@ -76,9 +75,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
   def render("arbitrum_claim_message.json", %{calldata: calldata, address: address}) do
     %{
       "calldata" => calldata,
-      "outbox_address_hash" => address,
-      # todo: It should be removed in favour `contract_address_hash` property with the next release after 8.0.0
-      "outbox_address" => address
+      "outbox_address_hash" => address
     }
   end
 
@@ -93,11 +90,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
           "id" => withdraw.message_id,
           "status" => withdraw.status,
           "caller_address_hash" => withdraw.caller,
-          # todo: "caller"" should be removed in favour `caller_address_hash` property with the next release after 8.0.0
-          "caller" => withdraw.caller,
           "destination_address_hash" => withdraw.destination,
-          # todo: "destination" should be removed in favour `destination_address_hash` property with the next release after 8.0.0
-          "destination" => withdraw.destination,
           "arb_block_number" => withdraw.arb_block_number,
           "eth_block_number" => withdraw.eth_block_number,
           "l2_timestamp" => withdraw.l2_timestamp,
@@ -124,16 +117,8 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
       "transactions_count" => batch.transactions_count,
       "start_block_number" => batch.start_block,
       "end_block_number" => batch.end_block,
-      # todo: It should be removed in favour `start_block_number` property with the next release after 8.0.0
-      "start_block" => batch.start_block,
-      # todo: It should be removed in favour `end_block_number` property with the next release after 8.0.0
-      "end_block" => batch.end_block,
       "before_acc_hash" => batch.before_acc,
-      # todo: It should be removed in favour `before_acc_hash` property with the next release after 8.0.0
-      "before_acc" => batch.before_acc,
-      "after_acc_hash" => batch.after_acc,
-      # todo: It should be removed in favour `after_acc_hash` property with the next release after 8.0.0
-      "after_acc" => batch.after_acc
+      "after_acc_hash" => batch.after_acc
     }
     |> add_l1_transaction_info(batch)
     |> add_da_info(batch)
@@ -389,8 +374,9 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
   # Adds data availability (DA) information to the given output JSON based on the batch container type.
   #
   # This function enriches the output JSON with data availability information based on
-  # the type of batch container. It handles different DA types, including AnyTrust and
-  # Celestia, and generates the appropriate DA data for inclusion in the output.
+  # the type of batch container. It handles different DA types, including AnyTrust,
+  # Celestia, and EigenDA, and generates the appropriate DA data for inclusion in the
+  # output.
   #
   # ## Parameters
   # - `out_json`: The initial JSON map to be enriched with DA information.
@@ -400,7 +386,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
   # - An updated JSON map containing the data availability information.
   @spec add_da_info(map(), %{
           :__struct__ => L1Batch,
-          :batch_container => :in_anytrust | :in_celestia | atom() | nil,
+          :batch_container => :in_anytrust | :in_celestia | :in_eigenda | atom() | nil,
           :number => non_neg_integer(),
           optional(any()) => any()
         }) :: map()
@@ -410,6 +396,7 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
         nil -> %{"batch_data_container" => nil}
         :in_anytrust -> generate_anytrust_certificate(batch.number)
         :in_celestia -> generate_celestia_da_info(batch.number)
+        :in_eigenda -> generate_eigen_da_info(batch.number)
         value -> %{"batch_data_container" => to_string(value)}
       end
 
@@ -487,6 +474,20 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
     })
   end
 
+  # Generates EigenDA information for the given batch number.
+  @spec generate_eigen_da_info(non_neg_integer()) :: map()
+  defp generate_eigen_da_info(batch_number) do
+    out = %{"batch_data_container" => "in_eigenda"}
+
+    da_info = SettlementReader.get_da_info_by_batch_number(batch_number)
+
+    out
+    |> Map.merge(%{
+      "blob_header" => Map.get(da_info, "blob_header"),
+      "blob_verification_proof" => Map.get(da_info, "blob_verification_proof")
+    })
+  end
+
   # Augments an output JSON with commit and confirm transaction details and their statuses.
   @spec add_l1_transactions_info_and_status(map(), %{
           optional(:commitment_transaction) => any(),
@@ -670,8 +671,6 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
     %{
       "message_id" => APIV2Helper.get_2map_data(arbitrum_transaction, :arbitrum_message_from_l2, :message_id),
       "associated_l1_transaction_hash" => l1_transaction,
-      # todo: It should be removed in favour `associated_l1_transaction_hash` property with the next release after 8.0.0
-      "associated_l1_transaction" => l1_transaction,
       "message_status" => status
     }
   end
@@ -686,10 +685,10 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
     # Map.get is only needed for the case when the module is compiled with
     # chain_type different from "arbitrum", `|| 0` is used to avoid nil values
     # for the transaction prior to the migration to Arbitrum specific BS build.
-    gas_used_for_l1 = Map.get(arbitrum_transaction, :gas_used_for_l1, 0) || 0
+    gas_used_for_l1 = Map.get(arbitrum_transaction, :gas_used_for_l1) || Decimal.new(0)
 
-    gas_used = Map.get(arbitrum_transaction, :gas_used, 0) || 0
-    gas_price = Map.get(arbitrum_transaction, :gas_price, 0) || 0
+    gas_used = Map.get(arbitrum_transaction, :gas_used) || Decimal.new(0)
+    gas_price = Map.get(arbitrum_transaction, :gas_price) || %Wei{value: Decimal.new(0)}
 
     gas_used_for_l2 =
       gas_used
@@ -724,8 +723,6 @@ defmodule BlockScoutWeb.API.V2.ArbitrumView do
     out_json
     |> Map.put("delayed_messages", Hash.to_integer(arbitrum_block.nonce))
     |> Map.put("l1_block_number", Map.get(arbitrum_block, :l1_block_number))
-    # todo: It should be removed in favour `l1_block_number` property with the next release after 8.0.0
-    |> Map.put("l1_block_height", Map.get(arbitrum_block, :l1_block_number))
     |> Map.put("send_count", Map.get(arbitrum_block, :send_count))
     |> Map.put("send_root", Map.get(arbitrum_block, :send_root))
   end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/blob_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/blob_view.ex
index fcb792ac095e..ddea3a0b4934 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/blob_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/blob_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.BlobView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex
index 157fdc0957f3..8d5b13d8edcf 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/block_view.ex
@@ -1,11 +1,14 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.BlockView do
   use BlockScoutWeb, :view
-  use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
 
-  alias BlockScoutWeb.BlockView
+  use Utils.CompileTimeEnvHelper,
+    chain_type: [:explorer, :chain_type],
+    chain_identity: [:explorer, :chain_identity]
+
   alias BlockScoutWeb.API.V2.{ApiView, Helper}
+  alias BlockScoutWeb.BlockView
   alias Explorer.Chain.Block
-  alias Explorer.Chain.Cache.Counters.BlockPriorityFeeCount
 
   def render("message.json", assigns) do
     ApiView.render("message.json", assigns)
@@ -28,18 +31,30 @@ defmodule BlockScoutWeb.API.V2.BlockView do
     prepare_block(block, nil, false)
   end
 
-  def prepare_block(block, _conn, single_block? \\ false) do
-    burnt_fees = Block.burnt_fees(block.transactions, block.base_fee_per_gas)
-    priority_fee = block.base_fee_per_gas && BlockPriorityFeeCount.fetch(block.hash)
+  def render("block_countdown.json", %{
+        current_block: current_block,
+        countdown_block: countdown_block,
+        remaining_blocks: remaining_blocks,
+        estimated_time_in_sec: estimated_time_in_sec
+      }) do
+    %{
+      current_block_number: current_block,
+      countdown_block_number: countdown_block,
+      remaining_blocks_count: remaining_blocks,
+      estimated_time_in_seconds: to_string(estimated_time_in_sec)
+    }
+  end
 
-    transaction_fees = Block.transaction_fees(block.transactions)
+  def prepare_block(block, _conn, single_block? \\ false) do
+    block = Block.aggregate_transactions(block)
 
     %{
       "height" => block.number,
       "timestamp" => block.timestamp,
-      "transactions_count" => count_transactions(block),
-      # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-      "transaction_count" => count_transactions(block),
+      # Callers must preload :transactions; Block.aggregate_transactions/1 leaves
+      # transactions_count as nil when transactions is %NotLoaded{}.
+      "transactions_count" => block.transactions_count,
+      "internal_transactions_count" => count_internal_transactions(block),
       "miner" => Helper.address_with_info(nil, block.miner, block.miner_hash, false),
       "size" => block.size,
       "hash" => block.hash,
@@ -50,42 +65,40 @@ defmodule BlockScoutWeb.API.V2.BlockView do
       "gas_limit" => block.gas_limit,
       "nonce" => block.nonce,
       "base_fee_per_gas" => block.base_fee_per_gas,
-      "burnt_fees" => burnt_fees,
-      "priority_fee" => priority_fee,
+      "burnt_fees" => block.burnt_fees,
+      "priority_fee" => block.priority_fees,
       # "extra_data" => "TODO",
       "uncles_hashes" => prepare_uncles(block.uncle_relations),
       # "state_root" => "TODO",
       "rewards" => prepare_rewards(block.rewards, block, single_block?),
       "gas_target_percentage" => Block.gas_target(block),
       "gas_used_percentage" => Block.gas_used_percentage(block),
-      "burnt_fees_percentage" => burnt_fees_percentage(burnt_fees, transaction_fees),
+      "burnt_fees_percentage" => burnt_fees_percentage(block.burnt_fees, block.transactions_fees),
       "type" => block |> BlockView.block_type() |> String.downcase(),
-      "transaction_fees" => transaction_fees,
-      "withdrawals_count" => count_withdrawals(block)
+      "transaction_fees" => block.transactions_fees,
+      "withdrawals_count" => count_withdrawals(block),
+      "is_pending_update" => block.refetch_needed
     }
     |> chain_type_fields(block, single_block?)
+    |> chain_identity_fields(block, single_block?)
   end
 
-  def prepare_rewards(rewards, block, single_block?) do
+  defp prepare_rewards(rewards, block, single_block?) do
     Enum.map(rewards, &prepare_reward(&1, block, single_block?))
   end
 
-  def prepare_reward(reward, block, single_block?) do
+  defp prepare_reward(reward, block, single_block?) do
     %{
       "reward" => reward.reward,
       "type" => if(single_block?, do: BlockView.block_reward_text(reward, block.miner.hash), else: reward.address_type)
     }
   end
 
-  def prepare_uncles(uncles_relations) when is_list(uncles_relations) do
-    Enum.map(uncles_relations, &prepare_uncle/1)
+  defp prepare_uncles(uncles_relations) when is_list(uncles_relations) do
+    Enum.map(uncles_relations, &%{"hash" => &1.uncle_hash})
   end
 
-  def prepare_uncles(_), do: []
-
-  def prepare_uncle(uncle_relation) do
-    %{"hash" => uncle_relation.uncle_hash}
-  end
+  defp prepare_uncles(_), do: []
 
   def burnt_fees_percentage(_, %Decimal{coef: 0}), do: nil
 
@@ -96,11 +109,14 @@ defmodule BlockScoutWeb.API.V2.BlockView do
 
   def burnt_fees_percentage(_, _), do: nil
 
-  def count_transactions(%Block{transactions: transactions}) when is_list(transactions), do: Enum.count(transactions)
-  def count_transactions(_), do: nil
+  defp count_internal_transactions(%Block{internal_transactions: internal_transactions})
+       when is_list(internal_transactions),
+       do: Enum.count(internal_transactions, &(&1.type != :call or &1.index > 0))
+
+  defp count_internal_transactions(_), do: nil
 
-  def count_withdrawals(%Block{withdrawals: withdrawals}) when is_list(withdrawals), do: Enum.count(withdrawals)
-  def count_withdrawals(_), do: nil
+  defp count_withdrawals(%Block{withdrawals: withdrawals}) when is_list(withdrawals), do: Enum.count(withdrawals)
+  defp count_withdrawals(_), do: nil
 
   case @chain_type do
     :rsk ->
@@ -149,12 +165,6 @@ defmodule BlockScoutWeb.API.V2.BlockView do
         BlockScoutWeb.API.V2.EthereumView.extend_block_json_response(result, block, single_block?)
       end
 
-    :celo ->
-      defp chain_type_fields(result, block, single_block?) do
-        # credo:disable-for-next-line Credo.Check.Design.AliasUsage
-        BlockScoutWeb.API.V2.CeloView.extend_block_json_response(result, block, single_block?)
-      end
-
     :zilliqa ->
       defp chain_type_fields(result, block, single_block?) do
         # credo:disable-for-next-line Credo.Check.Design.AliasUsage
@@ -166,4 +176,15 @@ defmodule BlockScoutWeb.API.V2.BlockView do
         result
       end
   end
+
+  case @chain_identity do
+    {:optimism, :celo} ->
+      defp chain_identity_fields(result, block, single_block?) do
+        # credo:disable-for-next-line Credo.Check.Design.AliasUsage
+        BlockScoutWeb.API.V2.CeloView.extend_block_json_response(result, block, single_block?)
+      end
+
+    _ ->
+      defp chain_identity_fields(result, _block, _single_block?), do: result
+  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/celo_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/celo_view.ex
index ebde6640a938..bdebdb5d968f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/celo_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/celo_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.CeloView do
   @moduledoc """
   View functions for rendering Celo-related data in JSON format.
@@ -8,14 +9,12 @@ defmodule BlockScoutWeb.API.V2.CeloView do
 
   import Explorer.Chain.SmartContract, only: [dead_address_hash_string: 0]
 
-  alias BlockScoutWeb.API.V2.{Helper, TokenView, TransactionView}
-  alias Ecto.Association.NotLoaded
+  alias BlockScoutWeb.API.V2.{Helper, TokenTransferView, TokenView, TransactionView}
   alias Explorer.Chain
-  alias Explorer.Chain.Cache.CeloCoreContracts
+  alias Explorer.Chain.{Address, Address.Reputation, Block, Token, TokenTransfer, Transaction, Wei}
+  alias Explorer.Chain.Cache.{CeloCoreContracts, CeloEpochs}
+  alias Explorer.Chain.Celo.{Account, Epoch, EpochReward}
   alias Explorer.Chain.Celo.Helper, as: CeloHelper
-  alias Explorer.Chain.Celo.{ElectionReward, EpochReward}
-  alias Explorer.Chain.Hash
-  alias Explorer.Chain.{Block, Token, Transaction, Wei}
 
   @address_params [
     necessity_by_association: %{
@@ -26,66 +25,48 @@ defmodule BlockScoutWeb.API.V2.CeloView do
     api?: true
   ]
 
-  def render("celo_epoch.json", %{epoch_number: epoch_number, epoch_distribution: nil}) do
+  @token_params [
+    necessity_by_association: %{
+      Reputation.reputation_association() => :optional
+    },
+    api?: true
+  ]
+
+  def render("celo_epochs.json", %{
+        epochs: epochs,
+        next_page_params: next_page_params
+      }) do
     %{
-      number: epoch_number,
-      distribution: nil,
-      aggregated_election_rewards: nil
+      items: Enum.map(epochs, &prepare_epoch/1),
+      next_page_params: next_page_params
     }
   end
 
-  def render(
-        "celo_epoch.json",
-        %{
-          epoch_number: epoch_number,
-          epoch_distribution: %EpochReward{
-            reserve_bolster_transfer: reserve_bolster_transfer,
-            community_transfer: community_transfer,
-            carbon_offsetting_transfer: carbon_offsetting_transfer
-          },
-          aggregated_election_rewards: aggregated_election_rewards
-        }
-      ) do
+  def render("celo_epoch.json", %{
+        epoch: epoch,
+        aggregated_election_rewards: aggregated_election_rewards
+      }) do
     distribution_json =
-      Map.new(
-        [
-          reserve_bolster_transfer: reserve_bolster_transfer,
-          community_transfer: community_transfer,
-          carbon_offsetting_transfer: carbon_offsetting_transfer
-        ],
-        fn {field, token_transfer} ->
-          token_transfer_json =
-            token_transfer &&
-              TransactionView.render(
-                "token_transfer.json",
-                %{token_transfer: token_transfer, conn: nil}
-              )
-
-          {field, token_transfer_json}
-        end
-      )
+      epoch.distribution
+      |> prepare_distribution()
 
     aggregated_election_rewards_json =
-      Map.new(
-        aggregated_election_rewards,
-        fn {type, %{total: total, count: count, token: token}} ->
-          {type,
-           %{
-             total: total,
-             count: count,
-             token:
-               TokenView.render("token.json", %{
-                 token: token,
-                 contract_address_hash: token && token.contract_address_hash
-               })
-           }}
-        end
-      )
+      epoch
+      |> prepare_aggregated_election_rewards(aggregated_election_rewards)
 
     %{
-      number: epoch_number,
+      number: epoch.number,
+      type: epoch_type(epoch),
+      is_finalized: epoch.fetched?,
+      start_block_number: epoch.start_block_number,
+      end_block_number: epoch.end_block_number,
       distribution: distribution_json,
-      aggregated_election_rewards: aggregated_election_rewards_json
+      aggregated_election_rewards: aggregated_election_rewards_json,
+      timestamp: epoch.end_processing_block && epoch.end_processing_block.timestamp,
+      start_processing_block_hash: epoch.start_processing_block && epoch.start_processing_block.hash,
+      start_processing_block_number: epoch.start_processing_block && epoch.start_processing_block.number,
+      end_processing_block_hash: epoch.end_processing_block && epoch.end_processing_block.hash,
+      end_processing_block_number: epoch.end_processing_block && epoch.end_processing_block.number
     }
   end
 
@@ -112,16 +93,111 @@ defmodule BlockScoutWeb.API.V2.CeloView do
     end
   end
 
-  def render("celo_election_rewards.json", %{
+  def render("celo_epoch_election_rewards.json", %{
+        rewards: rewards,
+        next_page_params: next_page_params
+      }) do
+    rewards_json =
+      rewards
+      |> Enum.map(fn reward ->
+        %{
+          amount: reward.amount,
+          account:
+            Helper.address_with_info(
+              reward.account_address,
+              reward.account_address_hash
+            ),
+          associated_account:
+            Helper.address_with_info(
+              reward.associated_account_address,
+              reward.associated_account_address_hash
+            )
+        }
+      end)
+
+    %{
+      items: rewards_json,
+      next_page_params: next_page_params
+    }
+  end
+
+  def render("celo_address_election_rewards.json", %{
         rewards: rewards,
         next_page_params: next_page_params
       }) do
+    rewards_json =
+      rewards
+      |> Enum.map(fn reward ->
+        %{
+          amount: reward.amount,
+          epoch_number: reward.epoch_number,
+          block_timestamp: reward.epoch.end_processing_block.timestamp,
+          account:
+            Helper.address_with_info(
+              reward.account_address,
+              reward.account_address_hash
+            ),
+          associated_account:
+            Helper.address_with_info(
+              reward.associated_account_address,
+              reward.associated_account_address_hash
+            ),
+          type: reward.type,
+          token:
+            TokenView.render("token.json", %{
+              token: reward.token,
+              contract_address_hash: reward.token.contract_address_hash
+            })
+        }
+      end)
+
     %{
-      "items" => Enum.map(rewards, &prepare_election_reward/1),
+      "items" => rewards_json,
       "next_page_params" => next_page_params
     }
   end
 
+  defp prepare_aggregated_election_rewards(%Epoch{fetched?: false}, _), do: nil
+
+  defp prepare_aggregated_election_rewards(%Epoch{} = epoch, aggregated_election_rewards) do
+    aggregated_election_rewards
+    |> Map.new(fn {type, %{total: total, count: count, token: token}} ->
+      {type,
+       %{
+         total: total,
+         count: count,
+         token:
+           TokenView.render("token.json", %{
+             token: token,
+             contract_address_hash: token && token.contract_address_hash
+           })
+       }}
+    end)
+    # For L2, delegated payments are implemented differently. They're
+    # distributed on-demand via direct payments rather than through epoch
+    # processing, so we need to handle them separately.
+    |> then(fn rewards ->
+      if CeloHelper.pre_migration_epoch_number?(epoch.number) do
+        rewards
+      else
+        rewards
+        |> Map.put(:delegated_payment, nil)
+      end
+    end)
+  end
+
+  @doc """
+  Returns the type of the epoch based on its number. If the epoch number is less
+  than the migration epoch number, it returns "L1", otherwise "L2". This is used
+  to differentiate between the two eras of Celo epochs.
+  """
+  @spec epoch_type(Epoch.t()) :: String.t()
+  def epoch_type(epoch) do
+    epoch.number
+    |> CeloHelper.pre_migration_epoch_number?()
+    |> if(do: "L1", else: "L2")
+  end
+
   @doc """
   Extends the JSON output with a sub-map containing information related to Celo,
   such as the epoch number, whether the block is an epoch block, and the routing
@@ -135,15 +211,66 @@ defmodule BlockScoutWeb.API.V2.CeloView do
   ## Returns
   - A map extended with data related to Celo.
   """
-  def extend_block_json_response(out_json, %Block{} = block, single_block?) do
+  def extend_block_json_response(out_json, block, single_block?) do
+    epoch_number = CeloEpochs.block_number_to_epoch_number(block.number)
+
+    l1_era_finalized_epoch_number =
+      if CeloHelper.pre_migration_block_number?(block.number) and
+           CeloHelper.epoch_block_number?(block.number) do
+        epoch_number - 1
+      else
+        nil
+      end
+
     celo_json =
       %{
-        "is_epoch_block" => CeloHelper.epoch_block_number?(block.number),
-        "epoch_number" => CeloHelper.block_number_to_epoch_number(block.number)
+        # todo: keep `is_epoch_block = false` for compatibility with frontend and remove
+        # when new frontend is bound to `l1_era_finalized_epoch_number` property
+        is_epoch_block: false,
+        l1_era_finalized_epoch_number: l1_era_finalized_epoch_number,
+        epoch_number: epoch_number
       }
       |> maybe_add_base_fee_info(block, single_block?)
 
-    Map.put(out_json, "celo", celo_json)
+    Map.put(out_json, :celo, celo_json)
+  end
+
+  def extend_address_json_response(out_json, %Address{} = address) do
+    account_json =
+      address
+      |> Map.get(:celo_account)
+      |> case do
+        %Account{} = account ->
+          %{
+            type: account.type,
+            name: account.name,
+            metadata_url: account.metadata_url,
+            nonvoting_locked_celo: account.nonvoting_locked_celo,
+            locked_celo: account.locked_celo,
+            vote_signer_address:
+              Helper.address_with_info(
+                account.vote_signer_address,
+                account.vote_signer_address_hash
+              ),
+            validator_signer_address:
+              Helper.address_with_info(
+                account.validator_signer_address,
+                account.validator_signer_address_hash
+              ),
+            attestation_signer_address:
+              Helper.address_with_info(
+                account.attestation_signer_address,
+                account.attestation_signer_address_hash
+              )
+          }
+
+        _ ->
+          nil
+      end
+
+    Map.put(out_json, :celo, %{
+      account: account_json
+    })
   end
 
   @doc """
@@ -189,55 +316,165 @@ defmodule BlockScoutWeb.API.V2.CeloView do
     Map.put(out_json, "celo", %{"gas_token" => token_json})
   end
 
-  @spec prepare_election_reward(Explorer.Chain.Celo.ElectionReward.t()) :: %{
-          :account => nil | %{optional(String.t()) => any()},
-          :amount => Decimal.t(),
-          :associated_account => nil | %{optional(String.t()) => any()},
-          optional(:block_hash) => Hash.Full.t(),
-          optional(:block_number) => Block.block_number(),
-          optional(:epoch_number) => non_neg_integer(),
-          optional(:type) => ElectionReward.type()
+  @spec prepare_epoch(Epoch.t()) :: map()
+  defp prepare_epoch(epoch) do
+    distribution_json =
+      if epoch.distribution do
+        community_transfer =
+          epoch.distribution.community_transfer &&
+            epoch.distribution.community_transfer
+            |> TokenTransferView.prepare_token_transfer_total()
+
+        carbon_offsetting_transfer =
+          epoch.distribution.carbon_offsetting_transfer &&
+            epoch.distribution.carbon_offsetting_transfer
+            |> TokenTransferView.prepare_token_transfer_total()
+
+        reserve_bolster_transfer =
+          epoch.distribution.reserve_bolster_transfer &&
+            epoch.distribution.reserve_bolster_transfer
+            |> TokenTransferView.prepare_token_transfer_total()
+
+        result = calculate_total_epoch_rewards(epoch.distribution)
+
+        %{
+          community_transfer: community_transfer,
+          carbon_offsetting_transfer: carbon_offsetting_transfer,
+          reserve_bolster_transfer: reserve_bolster_transfer,
+          transfers_total: result && result.total
         }
-  defp prepare_election_reward(%ElectionReward{block: %NotLoaded{}} = reward) do
+      end
+
     %{
-      amount: reward.amount,
-      account:
-        Helper.address_with_info(
-          reward.account_address,
-          reward.account_address_hash
-        ),
-      associated_account:
-        Helper.address_with_info(
-          reward.associated_account_address,
-          reward.associated_account_address_hash
-        )
+      number: epoch.number,
+      type: epoch_type(epoch),
+      start_block_number: epoch.start_block_number,
+      end_block_number: epoch.end_block_number,
+      timestamp: epoch.end_processing_block && epoch.end_processing_block.timestamp,
+      is_finalized: epoch.fetched?,
+      distribution: distribution_json
     }
   end
 
-  defp prepare_election_reward(%ElectionReward{token: %Token{}, block: %Block{}} = reward) do
-    %{
-      amount: reward.amount,
-      block_number: reward.block.number,
-      block_hash: reward.block_hash,
-      block_timestamp: reward.block.timestamp,
-      epoch_number: reward.block.number |> CeloHelper.block_number_to_epoch_number(),
-      account:
-        Helper.address_with_info(
-          reward.account_address,
-          reward.account_address_hash
-        ),
-      associated_account:
-        Helper.address_with_info(
-          reward.associated_account_address,
-          reward.associated_account_address_hash
-        ),
-      type: reward.type,
-      token:
-        TokenView.render("token.json", %{
-          token: reward.token,
-          contract_address_hash: reward.token.contract_address_hash
-        })
-    }
+  @spec prepare_distribution(EpochReward.t() | nil) ::
+          %{
+            optional(:reserve_bolster_transfer) => nil | %{optional(String.t()) => any()},
+            optional(:community_transfer) => nil | %{optional(String.t()) => any()},
+            optional(:carbon_offsetting_transfer) => nil | %{optional(String.t()) => any()}
+          }
+          | nil
+  defp prepare_distribution(%EpochReward{} = distribution) do
+    transfers_json =
+      Map.new(
+        [
+          reserve_bolster_transfer: distribution.reserve_bolster_transfer,
+          community_transfer: distribution.community_transfer,
+          carbon_offsetting_transfer: distribution.carbon_offsetting_transfer
+        ],
+        fn {field, token_transfer} ->
+          token_transfer_json =
+            token_transfer &&
+              TransactionView.render(
+                "token_transfer.json",
+                %{token_transfer: token_transfer, conn: nil}
+              )
+
+          {field, token_transfer_json}
+        end
+      )
+
+    total = calculate_total_epoch_rewards(distribution)
+
+    transfers_json
+    |> Map.put(:transfers_total, total)
+  end
+
+  defp prepare_distribution(_), do: nil
+
+  @doc """
+  Calculates the total sum of all epoch reward transfers with token information.
+
+  This function sums up all non-nil token transfers (reserve_bolster_transfer,
+  community_transfer, carbon_offsetting_transfer) and ensures they all use the
+  same token. If different tokens are found, it raises an error.
+
+  ## Parameters
+    - `transfers_map` (`map()`): Map containing the rendered token transfers.
+
+  ## Returns
+    - `%{token: map(), total: %{decimals: Decimal.t(), value: Decimal.t()}}`:
+      Token info and total sum, or `nil` if no transfers exist.
+
+  ## Raises
+    - `ArgumentError`: If transfers use different tokens.
+
+  ## Example
+      iex> transfers = %{
+      ...>   reserve_bolster_transfer: %{"token" => %{"address_hash" => "0xABC..."}, "total" => %{"value" => Decimal.new("100")}},
+      ...>   community_transfer: %{"token" => %{"address_hash" => "0xABC..."}, "total" => %{"value" => Decimal.new("200")}}
+      ...> }
+      iex> calculate_total_epoch_rewards(transfers)
+      %{
+        token: %{"address_hash" => "0xABC...", ...},
+        total: %{decimals: Decimal.new("18"), value: Decimal.new("300")}
+      }
+  """
+  @spec calculate_total_epoch_rewards(map()) :: map() | nil
+  def calculate_total_epoch_rewards(distribution) do
+    transfers =
+      [
+        distribution.reserve_bolster_transfer,
+        distribution.community_transfer,
+        distribution.carbon_offsetting_transfer
+      ]
+      |> Enum.reject(&is_nil/1)
+
+    case transfers do
+      [] ->
+        nil
+
+      [first_transfer | rest_transfers] ->
+        case validate_and_extract_token(first_transfer, rest_transfers) do
+          {:ok, token} ->
+            total_value =
+              transfers
+              |> Enum.map(&(&1 |> TokenTransferView.prepare_token_transfer_total() |> Map.get("value")))
+              |> Enum.reduce(Decimal.new(0), &Decimal.add/2)
+
+            token_json =
+              TokenView.render("token.json", %{
+                token: token,
+                contract_address_hash: token.contract_address_hash
+              })
+
+            %{
+              token: token_json,
+              total: %{
+                decimals: token.decimals,
+                value: total_value
+              }
+            }
+
+          :error ->
+            raise ArgumentError,
+                  "All transfers must use the same token, but found different tokens: #{inspect(transfers)}"
+        end
+    end
+  end
+
+  @spec validate_and_extract_token(TokenTransfer.t(), [TokenTransfer.t()]) ::
+          {:ok, Token.t()} | :error
+  defp validate_and_extract_token(first_transfer, rest_transfers) do
+    with token when not is_nil(token) <- first_transfer.token,
+         true <-
+           Enum.all?(
+             rest_transfers,
+             &(&1.token && &1.token.contract_address_hash == token.contract_address_hash)
+           ) do
+      {:ok, token}
+    else
+      _ -> :error
+    end
   end
 
   # Get the breakdown of the base fee for the case when FeeHandler is a contract
@@ -258,7 +495,7 @@ defmodule BlockScoutWeb.API.V2.CeloView do
   defp fee_handler_base_fee_breakdown(base_fee, block_number) do
     with {:ok, fee_handler_contract_address_hash} <-
            CeloCoreContracts.get_address(:fee_handler, block_number),
-         {:ok, %{"address" => fee_beneficiary_address_hash}} <-
+         {:ok, %{"address_hash" => fee_beneficiary_address_hash}} <-
            CeloCoreContracts.get_event(:fee_handler, :fee_beneficiary_set, block_number),
          {:ok, %{"value" => burn_fraction_fixidity_lib}} <-
            CeloCoreContracts.get_event(:fee_handler, :burn_fraction_set, block_number),
@@ -308,7 +545,7 @@ defmodule BlockScoutWeb.API.V2.CeloView do
           }
         )
 
-      celo_token = Token.get_by_contract_address_hash(celo_token_address_hash, api?: true)
+      celo_token = Token.get_by_contract_address_hash(celo_token_address_hash, @token_params)
 
       %{
         recipient: fee_handler_contract_address_info,
@@ -375,7 +612,7 @@ defmodule BlockScoutWeb.API.V2.CeloView do
           address_hash
         )
 
-      celo_token = Token.get_by_contract_address_hash(celo_token_address_hash, api?: true)
+      celo_token = Token.get_by_contract_address_hash(celo_token_address_hash, @token_params)
 
       %{
         recipient: address_with_info,
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/config_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/config_view.ex
index 3e744ccd4ddd..8bcac1e4534d 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/config_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/config_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ConfigView do
   def render("backend_version.json", %{version: version}) do
     %{
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/csv_export_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/csv_export_view.ex
new file mode 100644
index 000000000000..43643d65e0b6
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/csv_export_view.ex
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
+defmodule BlockScoutWeb.API.V2.CsvExportView do
+  @moduledoc """
+  View for CSV export API endpoints.
+  """
+  use BlockScoutWeb, :view
+
+  def render("csv_export.json", %{request: %{status: status, file_id: file_id, expires_at: expires_at}}) do
+    %{
+      status: status,
+      file_id: file_id,
+      expires_at: expires_at
+    }
+  end
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum/deposit_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum/deposit_view.ex
new file mode 100644
index 000000000000..267a99343a62
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum/deposit_view.ex
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
+defmodule BlockScoutWeb.API.V2.Ethereum.DepositView do
+  use BlockScoutWeb, :view
+
+  alias BlockScoutWeb.API.V2.Helper
+  alias Explorer.Chain.Beacon.Deposit
+
+  def render("deposits.json", %{deposits: deposits, next_page_params: next_page_params}) do
+    %{"items" => Enum.map(deposits, &prepare_deposit/1), "next_page_params" => next_page_params}
+  end
+
+  @spec prepare_deposit(Deposit.t()) :: map()
+  def prepare_deposit(deposit) do
+    %{
+      "index" => deposit.index,
+      "transaction_hash" => deposit.transaction_hash,
+      "block_hash" => deposit.block_hash,
+      "block_number" => deposit.block_number,
+      "block_timestamp" => deposit.block_timestamp,
+      "pubkey" => deposit.pubkey,
+      "withdrawal_credentials" => deposit.withdrawal_credentials,
+      "withdrawal_address" =>
+        Helper.address_with_info(
+          nil,
+          deposit.withdrawal_address,
+          deposit.withdrawal_address_hash,
+          false
+        ),
+      "amount" => deposit.amount,
+      "signature" => deposit.signature,
+      "status" => deposit.status,
+      "from_address" =>
+        Helper.address_with_info(
+          nil,
+          deposit.from_address,
+          deposit.from_address_hash,
+          false
+        )
+    }
+  end
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum_view.ex
index 1285aea438b9..23aa47a6320b 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/ethereum_view.ex
@@ -1,12 +1,7 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.EthereumView do
   alias Explorer.Chain.{Block, Transaction}
 
-  defp count_blob_transactions(%Block{transactions: transactions}) when is_list(transactions),
-    # EIP-2718 blob transaction type
-    do: Enum.count(transactions, &(&1.type == 3))
-
-  defp count_blob_transactions(_), do: nil
-
   def extend_transaction_json_response(out_json, %Transaction{} = transaction) do
     case Map.get(transaction, :beacon_blob_transaction) do
       nil ->
@@ -28,14 +23,13 @@ defmodule BlockScoutWeb.API.V2.EthereumView do
   def extend_block_json_response(out_json, %Block{} = block, single_block?) do
     blob_gas_used = Map.get(block, :blob_gas_used)
     excess_blob_gas = Map.get(block, :excess_blob_gas)
+    beacon_deposits = Map.get(block, :beacon_deposits, [])
 
-    blob_transaction_count = count_blob_transactions(block)
+    blob_transaction_count = block.blob_transactions_count
 
     extended_out_json =
       out_json
       |> Map.put("blob_transactions_count", blob_transaction_count)
-      # todo: It should be removed in favour `blob_transactions_count` property with the next release after 8.0.0
-      |> Map.put("blob_transaction_count", blob_transaction_count)
       |> Map.put("blob_gas_used", blob_gas_used)
       |> Map.put("excess_blob_gas", excess_blob_gas)
 
@@ -46,8 +40,10 @@ defmodule BlockScoutWeb.API.V2.EthereumView do
       extended_out_json
       |> Map.put("blob_gas_price", blob_gas_price)
       |> Map.put("burnt_blob_fees", burnt_blob_transaction_fees)
+      |> Map.put("beacon_deposits_count", Enum.count(beacon_deposits))
     else
       extended_out_json
+      |> Map.put("beacon_deposits_count", nil)
     end
   end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/filecoin_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/filecoin_view.ex
index e6c09d839aca..2481545b3b35 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/filecoin_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/filecoin_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.FilecoinView do
   @moduledoc """
   View functions for rendering Filecoin-related data in JSON format.
@@ -5,9 +6,8 @@ defmodule BlockScoutWeb.API.V2.FilecoinView do
   use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
 
   if @chain_type == :filecoin do
-    # TODO: remove when https://github.com/elixir-lang/elixir/issues/13975 comes to elixir release
-    alias Explorer.Chain, warn: false
-    alias Explorer.Chain.Address, warn: false
+    alias Explorer.Chain
+    alias Explorer.Chain.Address
 
     @api_true [api?: true]
 
@@ -34,9 +34,16 @@ defmodule BlockScoutWeb.API.V2.FilecoinView do
           }) ::
             map()
     def preload_and_put_filecoin_robust_address(result, %{address_hash: address_hash} = params) do
-      address = address_hash && Address.get(address_hash, @api_true)
-
-      put_filecoin_robust_address(result, Map.put(params, :address, address))
+      address =
+        case address_hash do
+          hash when is_binary(hash) and hash != "" -> Address.get(hash, @api_true)
+          _ -> nil
+        end
+
+      params
+      |> Map.put_new(:field_prefix, nil)
+      |> Map.put(:address, address)
+      |> then(&put_filecoin_robust_address(result, &1))
     end
 
     def preload_and_put_filecoin_robust_address(result, _params) do
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex
index 11837656a86e..767fe06a4ee4 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.Helper do
   @moduledoc """
     API V2 helper
@@ -5,7 +6,7 @@ defmodule BlockScoutWeb.API.V2.Helper do
   use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
 
   alias Ecto.Association.NotLoaded
-  alias Explorer.Chain.{Address, SmartContract}
+  alias Explorer.Chain.{Address, Address.Reputation}
   alias Explorer.Chain.SmartContract.Proxy
   alias Explorer.Chain.Transaction.History.TransactionStats
 
@@ -82,6 +83,7 @@ defmodule BlockScoutWeb.API.V2.Helper do
       "is_contract" => smart_contract?,
       "name" => address_name(address),
       "is_scam" => address_marked_as_scam?(address),
+      "reputation" => (address_marked_as_scam?(address) && "scam") || address_reputation_if_loaded(address),
       "proxy_type" => proxy_implementations && proxy_implementations.proxy_type,
       "implementations" => Proxy.proxy_object_info(proxy_implementations),
       "is_verified" => smart_contract_verified?(address) || verified_as_proxy?(proxy_implementations),
@@ -111,6 +113,8 @@ defmodule BlockScoutWeb.API.V2.Helper do
       "hash" => Address.checksum(address_hash),
       "is_contract" => false,
       "name" => nil,
+      "is_scam" => false,
+      "reputation" => "ok",
       "proxy_type" => nil,
       "implementations" => [],
       "is_verified" => nil,
@@ -119,6 +123,14 @@ defmodule BlockScoutWeb.API.V2.Helper do
     }
   end
 
+  defp address_reputation_if_loaded(%Address{reputation: %Reputation{reputation: reputation}}) do
+    reputation
+  end
+
+  defp address_reputation_if_loaded(_) do
+    "ok"
+  end
+
   case @chain_type do
     :filecoin ->
       defp address_chain_type_fields(result, address) do
@@ -144,7 +156,7 @@ defmodule BlockScoutWeb.API.V2.Helper do
     case Enum.find(address_names, &(&1.primary == true)) do
       nil ->
         # take last created address name, if there is no `primary` one.
-        %Address.Name{name: name} = Enum.max_by(address_names, & &1.id)
+        %Address.Name{name: name} = Enum.max_by(address_names, & &1.inserted_at)
         name
 
       %Address.Name{name: name} ->
@@ -176,15 +188,27 @@ defmodule BlockScoutWeb.API.V2.Helper do
     - `false` if the smart contract is `NotLoaded`.
     - `true` if the smart contract is present and does not have metadata from a verified bytecode twin.
   """
-  @spec smart_contract_verified?(Address.t()) :: boolean()
-  def smart_contract_verified?(%Address{smart_contract: nil}), do: false
-  def smart_contract_verified?(%Address{smart_contract: %{metadata_from_verified_bytecode_twin: true}}), do: false
-  def smart_contract_verified?(%Address{smart_contract: %NotLoaded{}}), do: nil
-  def smart_contract_verified?(%Address{smart_contract: %SmartContract{}}), do: true
+  @spec smart_contract_verified?(Address.t()) :: boolean() | nil
+  def smart_contract_verified?(%Address{verified: verified}) when is_boolean(verified), do: verified
+  def smart_contract_verified?(%Address{verified: nil}), do: nil
+  def smart_contract_verified?(_), do: false
+
+  def market_cap(:standard, %{
+        available_supply: available_supply,
+        fiat_value: fiat_value,
+        market_cap: %Decimal{} = market_cap
+      })
+      when is_nil(available_supply) or is_nil(fiat_value) do
+    Decimal.max(Decimal.new(0), market_cap)
+  end
 
-  def market_cap(:standard, %{available_supply: available_supply, fiat_value: fiat_value, market_cap: market_cap})
+  def market_cap(:standard, %{
+        available_supply: available_supply,
+        fiat_value: fiat_value,
+        market_cap: nil
+      })
       when is_nil(available_supply) or is_nil(fiat_value) do
-    max(Decimal.new(0), market_cap)
+    Decimal.new(0)
   end
 
   def market_cap(:standard, %{available_supply: available_supply, fiat_value: fiat_value}) do
@@ -197,7 +221,7 @@ defmodule BlockScoutWeb.API.V2.Helper do
 
   def get_transaction_stats do
     stats_scale = date_range(1)
-    transaction_stats = TransactionStats.by_date_range(stats_scale.earliest, stats_scale.latest)
+    transaction_stats = TransactionStats.by_date_range(stats_scale.earliest, stats_scale.latest, api?: true)
 
     # Need datapoint for legend if none currently available.
     if Enum.empty?(transaction_stats) do
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex
index d67b9f55fe0d..676fcb2a5490 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/internal_transaction_view.ex
@@ -1,8 +1,9 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.InternalTransactionView do
   use BlockScoutWeb, :view
 
   alias BlockScoutWeb.API.V2.Helper
-  alias Explorer.Chain.{Block, InternalTransaction}
+  alias Explorer.Chain.{Block, InternalTransaction, Wei}
 
   def render("internal_transaction.json", %{internal_transaction: nil}) do
     nil
@@ -33,7 +34,7 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionView do
     %{
       "error" => internal_transaction.error,
       "success" => is_nil(internal_transaction.error),
-      "type" => internal_transaction.call_type || internal_transaction.type,
+      "type" => InternalTransaction.call_type(internal_transaction) || internal_transaction.type,
       "transaction_hash" => internal_transaction.transaction_hash,
       "transaction_index" => internal_transaction.transaction_index,
       "from" =>
@@ -47,12 +48,11 @@ defmodule BlockScoutWeb.API.V2.InternalTransactionView do
           internal_transaction.created_contract_address_hash,
           false
         ),
-      "value" => internal_transaction.value,
+      "value" => internal_transaction.value || Wei.zero(),
       "block_number" => internal_transaction.block_number,
-      "timestamp" => (block && block.timestamp) || internal_transaction.block.timestamp,
+      "timestamp" => (block && block.timestamp) || (internal_transaction.block && internal_transaction.block.timestamp),
       "index" => internal_transaction.index,
-      "gas_limit" => internal_transaction.gas,
-      "block_index" => internal_transaction.block_index
+      "gas_limit" => internal_transaction.gas || Decimal.new(0)
     }
   end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex
index 6a49913d632f..0c4dbd7dd934 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/mud_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.MudView do
   use BlockScoutWeb, :view
 
@@ -82,12 +83,8 @@ defmodule BlockScoutWeb.API.V2.MudView do
 
   defp prepare_world_for_list(%Address{} = address) do
     %{
-      "address_hash" => Helper.address_with_info(address, address.hash),
-      # todo: "address" should be removed in favour `address_hash` property with the next release after 8.0.0
       "address" => Helper.address_with_info(address, address.hash),
       "transactions_count" => address.transactions_count,
-      # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-      "transaction_count" => address.transactions_count,
       "coin_balance" => if(address.fetched_coin_balance, do: address.fetched_coin_balance.value)
     }
   end
@@ -95,9 +92,7 @@ defmodule BlockScoutWeb.API.V2.MudView do
   defp prepare_system_for_list({system_id, system}) do
     %{
       name: system_id |> Table.from() |> Map.get(:table_full_name),
-      address_hash: system,
-      # todo: "address" should be removed in favour `address_hash` property with the next release after 8.0.0
-      address: system
+      address_hash: system
     }
   end
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex
index c412214f623d..6faebc9b2798 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/optimism_view.ex
@@ -1,13 +1,16 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.OptimismView do
   use BlockScoutWeb, :view
 
   import Ecto.Query, only: [from: 2]
+  import Explorer.Helper, only: [truncate_address_hash: 1, decode_data: 2]
 
   alias BlockScoutWeb.API.V2.Helper
   alias Explorer.{Chain, Repo}
-  alias Explorer.Helper, as: ExplorerHelper
-  alias Explorer.Chain.{Block, Transaction}
-  alias Explorer.Chain.Optimism.{FrameSequence, FrameSequenceBlob, InteropMessage, Withdrawal}
+  alias Explorer.Chain.{Block, Data, Hash, Transaction}
+  alias Explorer.Chain.Optimism.{DisputeGame, FrameSequence, FrameSequenceBlob, InteropMessage, Withdrawal}
+
+  @api_true [api?: true]
 
   @doc """
     Function to render GET requests to `/api/v2/optimism/txn-batches` endpoint.
@@ -21,7 +24,7 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
       batches
       |> Enum.map(fn batch ->
         Task.async(fn ->
-          transaction_count =
+          transactions_count =
             Repo.replica().aggregate(
               from(
                 t in Transaction,
@@ -35,9 +38,7 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
 
           %{
             "l2_block_number" => batch.l2_block_number,
-            "transactions_count" => transaction_count,
-            # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-            "transaction_count" => transaction_count,
+            "transactions_count" => transactions_count,
             "l1_transaction_hashes" => batch.frame_sequence.l1_transaction_hashes,
             "l1_timestamp" => batch.frame_sequence.l1_timestamp
           }
@@ -121,14 +122,12 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
               2 -> "Defender wins"
             end
 
-          [l2_block_number] = ExplorerHelper.decode_data(g.extra_data, [{:uint, 256}])
+          l2_block_number = DisputeGame.l2_block_number_from_extra_data(g.extra_data)
 
           %{
             "index" => g.index,
             "game_type" => g.game_type,
-            # todo: It should be removed in favour `contract_address_hash` property with the next release after 8.0.0
-            "contract_address" => g.address,
-            "contract_address_hash" => g.address,
+            "contract_address_hash" => g.address_hash,
             "l2_block_number" => l2_block_number,
             "created_at" => g.created_at,
             "status" => status,
@@ -155,7 +154,7 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
             "l1_block_timestamp" => deposit.l1_block_timestamp,
             "l1_transaction_hash" => deposit.l1_transaction_hash,
             "l1_transaction_origin" => deposit.l1_transaction_origin,
-            "l2_transaction_gas_limit" => deposit.l2_transaction.gas
+            "l2_transaction_gas_limit" => deposit.l2_transaction_gas_limit
           }
         end),
       next_page_params: next_page_params
@@ -184,7 +183,8 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
         next_page_params: next_page_params,
         conn: conn
       }) do
-    respected_games = Withdrawal.respected_games()
+    respected_games = Withdrawal.respected_games(@api_true)
+    portal_contract_address_hash = Withdrawal.portal_contract_address()
 
     %{
       items:
@@ -214,7 +214,10 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
               _ -> {nil, nil}
             end
 
-          {status, challenge_period_end} = Withdrawal.status(w, respected_games)
+          {status, challenge_period_end} = Withdrawal.status(w, respected_games, @api_true)
+
+          {sender_address_hash, target_address_hash, msg_value, msg_gas_limit, msg_data} =
+            withdrawal_msg_transaction_fields(w)
 
           %{
             "msg_nonce_raw" => Decimal.to_string(w.msg_nonce, :normal),
@@ -225,7 +228,13 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
             "l2_timestamp" => w.l2_timestamp,
             "status" => status,
             "l1_transaction_hash" => w.l1_transaction_hash,
-            "challenge_period_end" => challenge_period_end
+            "challenge_period_end" => challenge_period_end,
+            "portal_contract_address_hash" => portal_contract_address_hash,
+            "msg_sender_address_hash" => sender_address_hash,
+            "msg_target_address_hash" => target_address_hash,
+            "msg_value" => msg_value,
+            "msg_gas_limit" => msg_gas_limit,
+            "msg_data" => msg_data
           }
         end),
       next_page_params: next_page_params
@@ -251,18 +260,15 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
         Enum.map(messages, fn message ->
           msg =
             %{
+              "unique_id" => InteropMessage.message_unique_id(message),
               "nonce" => message.nonce,
               "timestamp" => message.timestamp,
               "status" => message.status,
               "init_transaction_hash" => message.init_transaction_hash,
               "relay_transaction_hash" => message.relay_transaction_hash,
               "sender_address_hash" => message.sender_address_hash,
-              # todo: keep next line for compatibility with frontend and remove when new frontend is bound to `sender_address_hash` property
-              "sender" => message.sender_address_hash,
               "target_address_hash" => message.target_address_hash,
-              # todo: keep next line for compatibility with frontend and remove when new frontend is bound to `target_address_hash` property
-              "target" => message.target_address_hash,
-              "payload" => ExplorerHelper.add_0x_prefix(message.payload)
+              "payload" => message.payload
             }
 
           # add chain info depending on whether this is incoming or outgoing message
@@ -274,6 +280,30 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
     }
   end
 
+  @doc """
+    Function to render GET requests to `/api/v2/optimism/interop/messages/:unique_id` endpoint.
+  """
+  def render("optimism_interop_message.json", %{message: message}) do
+    %{
+      "init_chain" => message.init_chain,
+      "init_transaction_hash" => message.init_transaction_hash,
+      "timestamp" => message.timestamp,
+      "sender_address_hash" => message.sender_address_hash,
+      "relay_chain" => message.relay_chain,
+      "relay_transaction_hash" => message.relay_transaction_hash,
+      "relay_transaction_failed" => message.failed,
+      "target_address_hash" => message.target_address_hash,
+      "transfer_token" => message.transfer_token,
+      "transfer_amount" => message.transfer_amount,
+      "transfer_from_address_hash" => message.transfer_from_address_hash,
+      "transfer_to_address_hash" => message.transfer_to_address_hash,
+      "nonce" => message.nonce,
+      "direction" => message.direction,
+      "status" => message.status,
+      "payload" => message.payload
+    }
+  end
+
   @doc """
     Function to render GET requests to `/api/v2/optimism/interop/public-key` endpoint.
   """
@@ -306,7 +336,7 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
       "target_address_hash" => target_address_hash,
       "init_transaction_hash" => init_transaction_hash,
       "timestamp" => if(not is_nil(timestamp), do: DateTime.to_unix(timestamp)),
-      "payload" => ExplorerHelper.add_0x_prefix(payload)
+      "payload" => payload
     }
   end
 
@@ -316,10 +346,10 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
   # includes basic batch information.
   #
   # ## Parameters
-  # - `internal_id`: The internal ID of the batch.
+  # - `number`: The internal ID of the batch.
   # - `l2_block_number_from`: Start L2 block number of the batch block range.
   # - `l2_block_number_to`: End L2 block number of the batch block range.
-  # - `transaction_count`: The L2 transaction count included into the blocks of the range.
+  # - `transactions_count`: The L2 transaction count included into the blocks of the range.
   # - `batch`: Either an `Explorer.Chain.Optimism.FrameSequence` entry or a map with
   #            the corresponding fields.
   #
@@ -334,23 +364,19 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
           | %{:l1_timestamp => DateTime.t(), :l1_transaction_hashes => list(), optional(any()) => any()}
         ) :: %{
           :number => non_neg_integer(),
-          :internal_id => non_neg_integer(),
           :l1_timestamp => DateTime.t(),
           :l2_start_block_number => non_neg_integer(),
-          :l2_block_start => non_neg_integer(),
           :l2_end_block_number => non_neg_integer(),
-          :l2_block_end => non_neg_integer(),
           :transactions_count => non_neg_integer(),
-          :transaction_count => non_neg_integer(),
           :l1_transaction_hashes => list(),
-          :batch_data_container => :in_blob4844 | :in_celestia | :in_calldata | nil
+          :batch_data_container => :in_blob4844 | :in_celestia | :in_eigenda | :in_alt_da | :in_calldata | nil
         }
-  defp render_base_info_for_batch(internal_id, l2_block_number_from, l2_block_number_to, transaction_count, batch) do
+  defp render_base_info_for_batch(number, l2_block_number_from, l2_block_number_to, transactions_count, batch) do
     FrameSequence.prepare_base_info_for_batch(
-      internal_id,
+      number,
       l2_block_number_from,
       l2_block_number_to,
-      transaction_count,
+      transactions_count,
       batch.batch_data_container,
       batch
     )
@@ -384,8 +410,6 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
       batch_info =
         %{
           "number" => frame_sequence.id,
-          # todo: It should be removed in favour `number` property with the next release after 8.0.0
-          "internal_id" => frame_sequence.id,
           "l1_timestamp" => frame_sequence.l1_timestamp,
           "l1_transaction_hashes" => frame_sequence.l1_transaction_hashes,
           "batch_data_container" => batch_data_container
@@ -412,7 +436,7 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
     - `transaction`: transaction structure containing extra Optimism-related info.
 
     ## Returns
-    An extended map containing `l1_*` and `op_withdrawals` items related to Optimism.
+    An extended map containing `l1_*`, `op_withdrawals`, `op_interop_messages`, and other items related to Optimism.
   """
   @spec extend_transaction_json_response(map(), %{
           :__struct__ => Explorer.Chain.Transaction,
@@ -424,7 +448,8 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
     |> add_optional_transaction_field(transaction, :l1_fee_scalar)
     |> add_optional_transaction_field(transaction, :l1_gas_price)
     |> add_optional_transaction_field(transaction, :l1_gas_used)
-    |> add_optimism_fields(transaction.hash)
+    |> add_optional_transaction_field(transaction, :da_footprint_gas_scalar)
+    |> add_optimism_fields(transaction)
   end
 
   defp add_optional_transaction_field(out_json, transaction, field) do
@@ -434,28 +459,61 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
     end
   end
 
-  defp add_optimism_fields(out_json, transaction_hash) do
+  # Extends the json output for a transaction adding Optimism-related info to the output
+  # (such as related withdrawals, operator fee, interop messages).
+  #
+  # ## Parameters
+  # - `out_json`: A map defining output json which will be extended.
+  # - `transaction`: transaction structure containing necessary data for the OP fields.
+  #
+  # ## Returns
+  # - An extended map containing `op_withdrawals`, `operator_fee` (optional), `op_interop_messages` (optional).
+  #   If the operator fee is zero, it's not presented in the resulting map.
+  @spec add_optimism_fields(map(), Transaction.t()) :: map()
+  defp add_optimism_fields(out_json, transaction) do
+    portal_contract_address_hash = Withdrawal.portal_contract_address()
+
     withdrawals =
-      transaction_hash
+      transaction.hash
       |> Withdrawal.transaction_statuses()
-      |> Enum.map(fn {nonce, status, l1_transaction_hash} ->
+      |> Enum.map(fn {nonce, status, w} ->
+        {sender_address_hash, target_address_hash, value, gas_limit, data} = withdrawal_msg_transaction_fields(w)
+
         %{
           "nonce" => nonce,
           "status" => status,
-          "l1_transaction_hash" => l1_transaction_hash
+          "l1_transaction_hash" => w.l1_transaction_hash,
+          "portal_contract_address_hash" => portal_contract_address_hash,
+          "msg_nonce_raw" => w.msg_nonce,
+          "msg_sender_address_hash" => sender_address_hash,
+          "msg_target_address_hash" => target_address_hash,
+          "msg_value" => value,
+          "msg_gas_limit" => gas_limit,
+          "msg_data" => data
         }
       end)
 
-    interop_message =
-      transaction_hash
-      |> InteropMessage.message_by_transaction()
+    interop_messages =
+      transaction.hash
+      |> InteropMessage.messages_by_transaction()
 
     out_json = Map.put(out_json, "op_withdrawals", withdrawals)
 
-    if is_nil(interop_message) do
+    operator_fee = Transaction.operator_fee(transaction)
+
+    # credo:disable-for-next-line
+    out_json =
+      if Decimal.gt?(operator_fee, Decimal.new(0)) do
+        Map.put(out_json, "operator_fee", operator_fee)
+      else
+        out_json
+      end
+
+    if interop_messages == [] do
       out_json
     else
-      Map.put(out_json, "op_interop", interop_message)
+      out_json
+      |> Map.put("op_interop_messages", interop_messages)
     end
   end
 
@@ -465,4 +523,43 @@ defmodule BlockScoutWeb.API.V2.OptimismView do
       _ -> msg
     end
   end
+
+  # Retrieves withdrawal message transaction fields from the `MessagePassed` event emitted by `L2ToL1MessagePasser` contract.
+  #
+  # The event looks as follows:
+  # MessagePassed(uint256 indexed nonce, address indexed sender, address indexed target, uint256 value, uint256 gasLimit, bytes data, bytes32 withdrawalHash)
+  #
+  # ## Parameters
+  # - `w`: A map containing `msg_log_sender_address_hash`, `msg_log_target_address_hash`, and `msg_log_data` components
+  #        of the `MessagePassed` event.
+  #
+  # ## Returns
+  # - A tuple containing the following fields in form of string (each one can be `nil`):
+  #   {sender address, target address, value, gas limit, data}
+  @spec withdrawal_msg_transaction_fields(%{
+          msg_log_sender_address_hash: Hash.t(),
+          msg_log_target_address_hash: Hash.t(),
+          msg_log_data: Data.t()
+        }) :: {String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil}
+  defp withdrawal_msg_transaction_fields(w) do
+    sender_address_hash =
+      if not is_nil(w.msg_log_sender_address_hash) do
+        truncate_address_hash(w.msg_log_sender_address_hash)
+      end
+
+    target_address_hash =
+      if not is_nil(w.msg_log_target_address_hash) do
+        truncate_address_hash(w.msg_log_target_address_hash)
+      end
+
+    if is_nil(w.msg_log_data) do
+      {sender_address_hash, target_address_hash, nil, nil, nil}
+    else
+      [msg_value, msg_gas_limit, msg_data, _withdrawal_hash] =
+        decode_data(w.msg_log_data, [{:uint, 256}, {:uint, 256}, :bytes, {:bytes, 32}])
+
+      {sender_address_hash, target_address_hash, to_string(msg_value), to_string(msg_gas_limit),
+       "0x" <> Base.encode16(msg_data, case: :lower)}
+    end
+  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/polygon_edge_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/polygon_edge_view.ex
deleted file mode 100644
index 7bdef247e90b..000000000000
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/polygon_edge_view.ex
+++ /dev/null
@@ -1,101 +0,0 @@
-defmodule BlockScoutWeb.API.V2.PolygonEdgeView do
-  use BlockScoutWeb, :view
-
-  alias BlockScoutWeb.API.V2.Helper
-  alias Explorer.Chain
-  alias Explorer.Chain.PolygonEdge.Reader
-
-  @spec render(String.t(), map()) :: map()
-  def render("polygon_edge_deposits.json", %{
-        deposits: deposits,
-        next_page_params: next_page_params
-      }) do
-    %{
-      items:
-        Enum.map(deposits, fn deposit ->
-          %{
-            "msg_id" => deposit.msg_id,
-            "from" => deposit.from,
-            "to" => deposit.to,
-            "l1_transaction_hash" => deposit.l1_transaction_hash,
-            "l1_timestamp" => deposit.l1_timestamp,
-            "success" => deposit.success,
-            "l2_transaction_hash" => deposit.l2_transaction_hash
-          }
-        end),
-      next_page_params: next_page_params
-    }
-  end
-
-  def render("polygon_edge_withdrawals.json", %{
-        withdrawals: withdrawals,
-        next_page_params: next_page_params
-      }) do
-    %{
-      items:
-        Enum.map(withdrawals, fn withdrawal ->
-          %{
-            "msg_id" => withdrawal.msg_id,
-            "from" => withdrawal.from,
-            "to" => withdrawal.to,
-            "l2_transaction_hash" => withdrawal.l2_transaction_hash,
-            "l2_timestamp" => withdrawal.l2_timestamp,
-            "success" => withdrawal.success,
-            "l1_transaction_hash" => withdrawal.l1_transaction_hash
-          }
-        end),
-      next_page_params: next_page_params
-    }
-  end
-
-  def render("polygon_edge_items_count.json", %{count: count}) do
-    count
-  end
-
-  def extend_transaction_json_response(out_json, transaction_hash, connection) do
-    out_json
-    |> Map.put("polygon_edge_deposit", polygon_edge_deposit(transaction_hash, connection))
-    |> Map.put("polygon_edge_withdrawal", polygon_edge_withdrawal(transaction_hash, connection))
-  end
-
-  defp polygon_edge_deposit(transaction_hash, conn) do
-    transaction_hash
-    |> Reader.deposit_by_transaction_hash()
-    |> polygon_edge_deposit_or_withdrawal(conn)
-  end
-
-  defp polygon_edge_withdrawal(transaction_hash, conn) do
-    transaction_hash
-    |> Reader.withdrawal_by_transaction_hash()
-    |> polygon_edge_deposit_or_withdrawal(conn)
-  end
-
-  defp polygon_edge_deposit_or_withdrawal(item, conn) do
-    if not is_nil(item) do
-      {from_address, from_address_hash} = hash_to_address_and_hash(item.from)
-      {to_address, to_address_hash} = hash_to_address_and_hash(item.to)
-
-      item
-      |> Map.put(:from, Helper.address_with_info(conn, from_address, from_address_hash, item.from))
-      |> Map.put(:to, Helper.address_with_info(conn, to_address, to_address_hash, item.to))
-    end
-  end
-
-  defp hash_to_address_and_hash(hash) do
-    with false <- is_nil(hash),
-         {:ok, address} <-
-           Chain.hash_to_address(
-             hash,
-             necessity_by_association: %{
-               :names => :optional,
-               :smart_contract => :optional,
-               proxy_implementations_association() => :optional
-             },
-             api?: true
-           ) do
-      {address, address.hash}
-    else
-      _ -> {nil, nil}
-    end
-  end
-end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/polygon_zkevm_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/polygon_zkevm_view.ex
deleted file mode 100644
index 112c36c5ed92..000000000000
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/polygon_zkevm_view.ex
+++ /dev/null
@@ -1,192 +0,0 @@
-defmodule BlockScoutWeb.API.V2.PolygonZkevmView do
-  use BlockScoutWeb, :view
-
-  alias Explorer.Chain.PolygonZkevm.Reader
-  alias Explorer.Chain.Transaction
-
-  @doc """
-    Function to render GET requests to `/api/v2/zkevm/batches/:batch_number` endpoint.
-  """
-  @spec render(binary(), map()) :: map() | non_neg_integer()
-  def render("zkevm_batch.json", %{batch: batch}) do
-    sequence_transaction_hash =
-      if Map.has_key?(batch, :sequence_transaction) and not is_nil(batch.sequence_transaction) do
-        batch.sequence_transaction.hash
-      end
-
-    verify_transaction_hash =
-      if Map.has_key?(batch, :verify_transaction) and not is_nil(batch.verify_transaction) do
-        batch.verify_transaction.hash
-      end
-
-    l2_transactions =
-      if Map.has_key?(batch, :l2_transactions) do
-        Enum.map(batch.l2_transactions, fn transaction -> transaction.hash end)
-      end
-
-    %{
-      "number" => batch.number,
-      "status" => batch_status(batch),
-      "timestamp" => batch.timestamp,
-      "transactions" => l2_transactions,
-      "global_exit_root" => batch.global_exit_root,
-      "acc_input_hash" => batch.acc_input_hash,
-      "sequence_transaction_hash" => sequence_transaction_hash,
-      "verify_transaction_hash" => verify_transaction_hash,
-      "state_root" => batch.state_root
-    }
-  end
-
-  @doc """
-    Function to render GET requests to `/api/v2/zkevm/batches` endpoint.
-  """
-  def render("zkevm_batches.json", %{
-        batches: batches,
-        next_page_params: next_page_params
-      }) do
-    %{
-      items: render_zkevm_batches(batches),
-      next_page_params: next_page_params
-    }
-  end
-
-  @doc """
-    Function to render GET requests to `/api/v2/main-page/zkevm/batches/confirmed` endpoint.
-  """
-  def render("zkevm_batches.json", %{batches: batches}) do
-    %{items: render_zkevm_batches(batches)}
-  end
-
-  @doc """
-    Function to render GET requests to `/api/v2/zkevm/batches/count` endpoint.
-  """
-  def render("zkevm_batches_count.json", %{count: count}) do
-    count
-  end
-
-  @doc """
-    Function to render GET requests to `/api/v2/main-page/zkevm/batches/latest-number` endpoint.
-  """
-  def render("zkevm_batch_latest_number.json", %{number: number}) do
-    number
-  end
-
-  @doc """
-    Function to render GET requests to `/api/v2/zkevm/deposits` and `/api/v2/zkevm/withdrawals` endpoints.
-  """
-  def render("polygon_zkevm_bridge_items.json", %{
-        items: items,
-        next_page_params: next_page_params
-      }) do
-    env = Application.get_all_env(:indexer)[Indexer.Fetcher.PolygonZkevm.BridgeL1]
-
-    %{
-      items:
-        Enum.map(items, fn item ->
-          l1_token = if is_nil(Map.get(item, :l1_token)), do: %{}, else: Map.get(item, :l1_token)
-          l2_token = if is_nil(Map.get(item, :l2_token)), do: %{}, else: Map.get(item, :l2_token)
-
-          decimals =
-            cond do
-              not is_nil(Map.get(l1_token, :decimals)) -> Reader.sanitize_decimals(Map.get(l1_token, :decimals))
-              not is_nil(Map.get(l2_token, :decimals)) -> Reader.sanitize_decimals(Map.get(l2_token, :decimals))
-              true -> env[:native_decimals]
-            end
-
-          symbol =
-            cond do
-              not is_nil(Map.get(l1_token, :symbol)) -> Map.get(l1_token, :symbol)
-              not is_nil(Map.get(l2_token, :symbol)) -> Map.get(l2_token, :symbol)
-              true -> env[:native_symbol]
-            end
-
-          %{
-            "block_number" => item.block_number,
-            "index" => item.index,
-            "l1_transaction_hash" => item.l1_transaction_hash,
-            "timestamp" => item.block_timestamp,
-            "l2_transaction_hash" => item.l2_transaction_hash,
-            "value" => fractional(Decimal.new(item.amount), Decimal.new(decimals)),
-            "symbol" => symbol
-          }
-        end),
-      next_page_params: next_page_params
-    }
-  end
-
-  @doc """
-    Function to render GET requests to `/api/v2/zkevm/deposits/count` and `/api/v2/zkevm/withdrawals/count` endpoints.
-  """
-  def render("polygon_zkevm_bridge_items_count.json", %{count: count}) do
-    count
-  end
-
-  defp batch_status(batch) do
-    sequence_id = Map.get(batch, :sequence_id)
-    verify_id = Map.get(batch, :verify_id)
-
-    cond do
-      is_nil(sequence_id) && is_nil(verify_id) -> "Unfinalized"
-      !is_nil(sequence_id) && is_nil(verify_id) -> "L1 Sequence Confirmed"
-      !is_nil(verify_id) -> "Finalized"
-    end
-  end
-
-  defp fractional(%Decimal{} = amount, %Decimal{} = decimals) do
-    amount.sign
-    |> Decimal.new(amount.coef, amount.exp - Decimal.to_integer(decimals))
-    |> Decimal.normalize()
-    |> Decimal.to_string(:normal)
-  end
-
-  defp render_zkevm_batches(batches) do
-    Enum.map(batches, fn batch ->
-      sequence_transaction_hash =
-        if not is_nil(batch.sequence_transaction) do
-          batch.sequence_transaction.hash
-        end
-
-      verify_transaction_hash =
-        if not is_nil(batch.verify_transaction) do
-          batch.verify_transaction.hash
-        end
-
-      %{
-        "number" => batch.number,
-        "status" => batch_status(batch),
-        "timestamp" => batch.timestamp,
-        "transactions_count" => batch.l2_transactions_count,
-        # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-        "transaction_count" => batch.l2_transactions_count,
-        "sequence_transaction_hash" => sequence_transaction_hash,
-        "verify_transaction_hash" => verify_transaction_hash
-      }
-    end)
-  end
-
-  def extend_transaction_json_response(out_json, %Transaction{} = transaction) do
-    extended_result =
-      out_json
-      |> add_optional_transaction_field(transaction, "zkevm_batch_number", :zkevm_batch, :number)
-      |> add_optional_transaction_field(transaction, "zkevm_sequence_hash", :zkevm_sequence_transaction, :hash)
-      |> add_optional_transaction_field(transaction, "zkevm_verify_hash", :zkevm_verify_transaction, :hash)
-
-    Map.put(extended_result, "zkevm_status", zkevm_status(extended_result))
-  end
-
-  defp zkevm_status(result_map) do
-    if is_nil(Map.get(result_map, "zkevm_sequence_hash")) do
-      "Confirmed by Sequencer"
-    else
-      "L1 Confirmed"
-    end
-  end
-
-  defp add_optional_transaction_field(out_json, transaction, out_field, association, association_field) do
-    case Map.get(transaction, association) do
-      nil -> out_json
-      %Ecto.Association.NotLoaded{} -> out_json
-      item -> Map.put(out_json, out_field, Map.get(item, association_field))
-    end
-  end
-end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/proxy/metadata_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/proxy/metadata_view.ex
index b8de924ade76..df442722ecb5 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/proxy/metadata_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/proxy/metadata_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.Proxy.MetadataView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/rootstock_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/rootstock_view.ex
index 06d4d8e68f21..bf5e8e108a4f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/rootstock_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/rootstock_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.RootstockView do
   alias Explorer.Chain.Block
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/scroll_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/scroll_view.ex
index f0614e568dcc..0941bdc4b17f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/scroll_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/scroll_view.ex
@@ -1,9 +1,10 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ScrollView do
   use BlockScoutWeb, :view
 
   alias BlockScoutWeb.API.V2.TransactionView
-  alias Explorer.Chain.Scroll.{Batch, L1FeeParam, Reader}
   alias Explorer.Chain.{Data, Transaction}
+  alias Explorer.Chain.Scroll.{Batch, L1FeeParam, Reader}
 
   @api_true [api?: true]
 
@@ -126,18 +127,13 @@ defmodule BlockScoutWeb.API.V2.ScrollView do
       },
       "start_block_number" => start_block_number,
       "end_block_number" => end_block_number,
-      # todo: It should be removed in favour `start_block_number` property with the next release after 8.0.0
-      "start_block" => start_block_number,
-      # todo: It should be removed in favour `end_block_number` property with the next release after 8.0.0
-      "end_block" => end_block_number,
-      "transactions_count" => transactions_count,
-      # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-      "transaction_count" => transactions_count
+      "transactions_count" => transactions_count
     }
   end
 
   @doc """
     Extends the json output with a sub-map containing information related Scroll.
+    For pending transactions the output is not extended.
 
     ## Parameters
     - `out_json`: A map defining output json which will be extended.
@@ -148,11 +144,16 @@ defmodule BlockScoutWeb.API.V2.ScrollView do
   """
   @spec extend_transaction_json_response(map(), %{
           :__struct__ => Transaction,
-          :block_number => non_neg_integer(),
+          :block_number => non_neg_integer() | nil,
           :index => non_neg_integer(),
           :input => Data.t(),
           optional(any()) => any()
         }) :: map()
+  def extend_transaction_json_response(out_json, %Transaction{block_number: nil}) do
+    # this is a pending transaction
+    out_json
+  end
+
   def extend_transaction_json_response(out_json, %Transaction{} = transaction) do
     config = Application.get_all_env(:explorer)[L1FeeParam]
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex
index 65a6fc7a6a80..5d1d09a2549a 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/search_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.SearchView do
   use BlockScoutWeb, :view
   use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
@@ -5,7 +6,6 @@ defmodule BlockScoutWeb.API.V2.SearchView do
   alias BlockScoutWeb.{BlockView, Endpoint}
   alias Explorer.Chain
   alias Explorer.Chain.{Address, Beacon.Blob, Block, Hash, Transaction, UserOperation}
-  alias Explorer.Helper, as: ExplorerHelper
   alias Plug.Conn.Query
 
   def render("search_results.json", %{search_results: search_results, next_page_params: next_page_params}) do
@@ -33,8 +33,6 @@ defmodule BlockScoutWeb.API.V2.SearchView do
       "name" => search_result.name,
       "symbol" => search_result.symbol,
       "address_hash" => search_result.address_hash,
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => search_result.address_hash,
       "token_url" => token_path(Endpoint, :show, search_result.address_hash),
       "address_url" => address_path(Endpoint, :show, search_result.address_hash),
       "icon_url" => search_result.icon_url,
@@ -46,7 +44,9 @@ defmodule BlockScoutWeb.API.V2.SearchView do
         search_result.circulating_market_cap && to_string(search_result.circulating_market_cap),
       "is_verified_via_admin_panel" => search_result.is_verified_via_admin_panel,
       "certified" => search_result.certified || false,
-      "priority" => search_result.priority
+      "priority" => search_result.priority,
+      "reputation" => search_result.reputation,
+      "is_smart_contract_address" => search_result.is_smart_contract_address
     }
   end
 
@@ -55,13 +55,13 @@ defmodule BlockScoutWeb.API.V2.SearchView do
       "type" => search_result.type,
       "name" => search_result.name,
       "address_hash" => search_result.address_hash,
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => search_result.address_hash,
       "url" => address_path(Endpoint, :show, search_result.address_hash),
       "is_smart_contract_verified" => search_result.verified,
       "ens_info" => search_result[:ens_info],
       "certified" => if(search_result.certified, do: search_result.certified, else: false),
-      "priority" => search_result.priority
+      "priority" => search_result.priority,
+      "reputation" => search_result.reputation,
+      "is_smart_contract_address" => search_result.is_smart_contract_address
     }
   end
 
@@ -71,13 +71,13 @@ defmodule BlockScoutWeb.API.V2.SearchView do
       "type" => search_result.type,
       "name" => search_result.name,
       "address_hash" => search_result.address_hash,
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => search_result.address_hash,
-      "url" => address_path(Endpoint, :show, search_result.address_hash),
+      "url" => search_result.address_hash && address_path(Endpoint, :show, search_result.address_hash),
       "is_smart_contract_verified" => search_result.verified,
       "ens_info" => search_result[:ens_info],
       "certified" => if(search_result.certified, do: search_result.certified, else: false),
-      "priority" => search_result.priority
+      "priority" => search_result.priority,
+      "reputation" => search_result.reputation,
+      "is_smart_contract_address" => search_result.is_smart_contract_address
     }
   end
 
@@ -86,20 +86,18 @@ defmodule BlockScoutWeb.API.V2.SearchView do
       "type" => search_result.type,
       "name" => search_result.name,
       "address_hash" => search_result.address_hash,
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => search_result.address_hash,
       "url" => address_path(Endpoint, :show, search_result.address_hash),
       "is_smart_contract_verified" => search_result.verified,
       "ens_info" => search_result[:ens_info],
       "certified" => if(search_result.certified, do: search_result.certified, else: false),
       "priority" => search_result.priority,
-      "metadata" => search_result.metadata
+      "metadata" => search_result.metadata,
+      "reputation" => search_result.reputation,
+      "is_smart_contract_address" => search_result.is_smart_contract_address
     }
   end
 
   def prepare_search_result(%{type: "block"} = search_result) do
-    block_hash = ExplorerHelper.add_0x_prefix(search_result.block_hash)
-
     {:ok, block} =
       Chain.hash_to_block(hash(search_result.block_hash),
         necessity_by_association: %{
@@ -111,8 +109,8 @@ defmodule BlockScoutWeb.API.V2.SearchView do
     %{
       "type" => search_result.type,
       "block_number" => search_result.block_number,
-      "block_hash" => block_hash,
-      "url" => block_path(Endpoint, :show, block_hash),
+      "block_hash" => block.hash,
+      "url" => block_path(Endpoint, :show, block.hash),
       "timestamp" => search_result.timestamp,
       "block_type" => block |> BlockView.block_type() |> String.downcase(),
       "priority" => search_result.priority
@@ -120,7 +118,7 @@ defmodule BlockScoutWeb.API.V2.SearchView do
   end
 
   def prepare_search_result(%{type: "transaction"} = search_result) do
-    transaction_hash = ExplorerHelper.add_0x_prefix(search_result.transaction_hash)
+    transaction_hash = hash_to_string(search_result.transaction_hash)
 
     %{
       "type" => search_result.type,
@@ -132,27 +130,38 @@ defmodule BlockScoutWeb.API.V2.SearchView do
   end
 
   def prepare_search_result(%{type: "user_operation"} = search_result) do
-    user_operation_hash = ExplorerHelper.add_0x_prefix(search_result.user_operation_hash)
-
     %{
       "type" => search_result.type,
-      "user_operation_hash" => user_operation_hash,
+      "user_operation_hash" => hash_to_string(search_result.user_operation_hash),
       "timestamp" => search_result.timestamp,
       "priority" => search_result.priority
     }
   end
 
   def prepare_search_result(%{type: "blob"} = search_result) do
-    blob_hash = ExplorerHelper.add_0x_prefix(search_result.blob_hash)
-
     %{
       "type" => search_result.type,
-      "blob_hash" => blob_hash,
+      "blob_hash" => hash_to_string(search_result.blob_hash),
       "timestamp" => search_result.timestamp,
       "priority" => search_result.priority
     }
   end
 
+  def prepare_search_result(%{type: "tac_operation"} = search_result) do
+    %{
+      "type" => search_result.type,
+      "tac_operation" => search_result.tac_operation,
+      "priority" => search_result.priority
+    }
+  end
+
+  defp hash_to_string(%Hash{} = hash), do: to_string(hash)
+
+  defp hash_to_string(bytes) do
+    {:ok, hash} = Hash.Full.cast(bytes)
+    to_string(hash)
+  end
+
   defp hash(%Hash{} = hash), do: hash
 
   defp hash(bytes),
@@ -165,10 +174,14 @@ defmodule BlockScoutWeb.API.V2.SearchView do
     %{"type" => "address", "parameter" => Address.checksum(item.hash)}
   end
 
-  defp redirect_search_results(%{address_hash: address_hash}) do
+  defp redirect_search_results(%{address_hash: address_hash}) when not is_nil(address_hash) do
     %{"type" => "address", "parameter" => address_hash}
   end
 
+  defp redirect_search_results(%{name: name, protocol: _protocol}) do
+    %{"type" => "ens_domain", "parameter" => name}
+  end
+
   defp redirect_search_results(%Block{} = item) do
     %{"type" => "block", "parameter" => to_string(item.hash)}
   end
@@ -204,11 +217,11 @@ defmodule BlockScoutWeb.API.V2.SearchView do
       |> Query.encode()
       |> URI.decode_query()
       |> Enum.map(fn {k, v} ->
-        {k, unless(v == "", do: v)}
+        {k, if(v != "", do: v)}
       end)
       |> Enum.into(%{})
 
-    unless result == %{} do
+    if result != %{} do
       result
     end
   end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex
index de9101961f8a..cbbfe81408b6 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/shibarium_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ShibariumView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/smart_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/smart_contract_view.ex
index 4a1295728936..b77459a2af3d 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/smart_contract_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/smart_contract_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.SmartContractView do
   use BlockScoutWeb, :view
   use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
@@ -5,10 +6,10 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
   import Explorer.SmartContract.Reader, only: [zip_tuple_values_with_types: 2]
 
   alias ABI.FunctionSelector
+  alias BlockScoutWeb.{AddressContractView, SmartContractView}
   alias BlockScoutWeb.API.V2.Helper, as: APIV2Helper
   alias BlockScoutWeb.API.V2.TransactionView
-  alias BlockScoutWeb.{AddressContractView, SmartContractView}
-  alias Ecto.Changeset
+  alias BlockScoutWeb.ErrorHelper
   alias Explorer.Chain
   alias Explorer.Chain.{Address, SmartContract, SmartContractAdditionalSource}
   alias Explorer.Chain.SmartContract.Proxy
@@ -38,11 +39,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
   end
 
   def render("changeset_errors.json", %{changeset: changeset}) do
-    Changeset.traverse_errors(changeset, fn {msg, opts} ->
-      Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
-        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
-      end)
-    end)
+    ErrorHelper.changeset_to_errors(changeset)
   end
 
   def render("audit_reports.json", %{reports: reports}) do
@@ -172,7 +169,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
 
     proxy_type = implementations && implementations.proxy_type
 
-    minimal_proxy? = proxy_type in ["eip1167", "clone_with_immutable_arguments", "erc7760"]
+    minimal_proxy? = proxy_type in [:eip1167, :eip7702, :clone_with_immutable_arguments, :erc7760]
 
     target_contract =
       if smart_contract_verified, do: smart_contract, else: bytecode_twin_contract
@@ -195,12 +192,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
       "is_verified_via_verifier_alliance" => smart_contract.verified_via_verifier_alliance,
       "proxy_type" => proxy_type,
       "implementations" => Proxy.proxy_object_info(implementations),
+      "conflicting_implementations" => Proxy.conflicting_implementations_info(implementations),
       "sourcify_repo_url" =>
         if(smart_contract_verified_via_sourcify,
           do: AddressContractView.sourcify_repo_url(address.hash, smart_contract.partially_verified)
         ),
       "can_be_visualized_via_sol2uml" =>
-        visualize_sol2uml_enabled && target_contract && SmartContract.language(target_contract) == :solidity,
+        visualize_sol2uml_enabled && target_contract && target_contract.language == :solidity,
       "name" => target_contract && target_contract.name,
       "compiler_version" => target_contract && target_contract.compiler_version,
       "optimization_enabled" => target_contract && target_contract.optimization,
@@ -218,7 +216,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
         if(smart_contract_verified,
           do: SmartContract.format_constructor_arguments(smart_contract.abi, smart_contract.constructor_arguments)
         ),
-      "language" => SmartContract.language(smart_contract),
+      "language" => smart_contract.language,
       "license_type" => smart_contract.license_type,
       "certified" => if(smart_contract.certified, do: smart_contract.certified, else: false),
       "is_blueprint" => if(smart_contract.is_blueprint, do: smart_contract.is_blueprint, else: false)
@@ -237,7 +235,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
   defp prepare_smart_contract(%Address{proxy_implementations: implementations} = address, _conn) do
     %{
       "proxy_type" => implementations && implementations.proxy_type,
-      "implementations" => Proxy.proxy_object_info(implementations)
+      "implementations" => Proxy.proxy_object_info(implementations),
+      "conflicting_implementations" => Proxy.conflicting_implementations_info(implementations)
     }
     |> Map.merge(bytecode_info(address))
   end
@@ -265,26 +264,23 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
     case AddressContractView.contract_creation_code(address) do
       {:selfdestructed, init} ->
         %{
-          "is_self_destructed" => true,
           "deployed_bytecode" => nil,
           "creation_bytecode" => init,
-          "status" => "selfdestructed"
+          "creation_status" => "selfdestructed"
         }
 
       {:failed, creation_code} ->
         %{
-          "is_self_destructed" => false,
           "deployed_bytecode" => "0x",
           "creation_bytecode" => creation_code,
-          "status" => "failed"
+          "creation_status" => "failed"
         }
 
       {:ok, contract_code} ->
         %{
-          "is_self_destructed" => false,
           "deployed_bytecode" => contract_code,
           "creation_bytecode" => AddressContractView.creation_code(address),
-          "status" => "success"
+          "creation_status" => "success"
         }
     end
   end
@@ -307,7 +303,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
   defp prepare_smart_contract_address_for_list(
          %Address{
            smart_contract: %SmartContract{} = smart_contract,
-           token: token
+           token: token,
+           reputation: reputation
          } = address
        ) do
     smart_contract_info =
@@ -316,15 +313,14 @@ defmodule BlockScoutWeb.API.V2.SmartContractView do
         "compiler_version" => smart_contract.compiler_version,
         "optimization_enabled" => smart_contract.optimization,
         "transactions_count" => address.transactions_count,
-        # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-        "transaction_count" => address.transactions_count,
-        "language" => SmartContract.language(smart_contract),
+        "language" => smart_contract.language,
         "verified_at" => smart_contract.inserted_at,
         "market_cap" => token && token.circulating_market_cap,
         "has_constructor_args" => !is_nil(smart_contract.constructor_arguments),
         "coin_balance" => if(address.fetched_coin_balance, do: address.fetched_coin_balance.value),
         "license_type" => smart_contract.license_type,
-        "certified" => if(smart_contract.certified, do: smart_contract.certified, else: false)
+        "certified" => if(smart_contract.certified, do: smart_contract.certified, else: false),
+        "reputation" => reputation
       }
 
     smart_contract_info
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex
index 3bd0c2a6f1e7..386f0853c835 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/stability_view.ex
@@ -1,6 +1,7 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.StabilityView do
   alias BlockScoutWeb.API.V2.{Helper, TokenView}
-  alias Explorer.Chain.{Log, Token, Transaction}
+  alias Explorer.Chain.{Address.Reputation, Log, Token, Transaction}
 
   @api_true [api?: true]
   @transaction_fee_event_signature "0x99e7b0ba56da2819c37c047f0511fd2bf6c9b4e27b4a979a19d6da0f74be8155"
@@ -89,7 +90,7 @@ defmodule BlockScoutWeb.API.V2.StabilityView do
 
   defp do_extend_with_stability_fees_info(transactions) when is_list(transactions) do
     {transactions, _tokens_acc} =
-      Enum.map_reduce(transactions, %{}, fn transaction, tokens_acc ->
+      Enum.map_reduce(transactions, %{}, fn transaction = %Transaction{}, tokens_acc ->
         case Log.fetch_log_by_transaction_hash_and_first_topic(
                transaction.hash,
                @transaction_fee_event_signature,
@@ -122,7 +123,11 @@ defmodule BlockScoutWeb.API.V2.StabilityView do
     if Map.has_key?(tokens_acc, token_address_hash) do
       {tokens_acc[token_address_hash], tokens_acc}
     else
-      token = Token.get_by_contract_address_hash(token_address_hash, @api_true)
+      token =
+        Token.get_by_contract_address_hash(token_address_hash,
+          api?: true,
+          necessity_by_association: %{Reputation.reputation_association() => :optional}
+        )
 
       {token, Map.put(tokens_acc, token_address_hash, token)}
     end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/stats_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/stats_view.ex
new file mode 100644
index 000000000000..1b6d8c9013d3
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/stats_view.ex
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
+defmodule BlockScoutWeb.API.V2.StatsView do
+  use BlockScoutWeb, :view
+
+  alias BlockScoutWeb.API.V2.Helper
+
+  def render("hot_smart_contracts.json", %{hot_smart_contracts: hot_smart_contracts, next_page_params: next_page_params}) do
+    %{
+      items: Enum.map(hot_smart_contracts, &prepare_hot_contract/1),
+      next_page_params: next_page_params
+    }
+  end
+
+  defp prepare_hot_contract(hot_contract) do
+    balance =
+      hot_contract.contract_address.fetched_coin_balance && hot_contract.contract_address.fetched_coin_balance.value
+
+    %{
+      contract_address:
+        Helper.address_with_info(nil, hot_contract.contract_address, hot_contract.contract_address_hash, false),
+      transactions_count: to_string(hot_contract.transactions_count),
+      total_gas_used: to_string(hot_contract.total_gas_used),
+      balance: balance
+    }
+  end
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/suave_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/suave_view.ex
index a267486d52c5..0db167583cf4 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/suave_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/suave_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.SuaveView do
   alias BlockScoutWeb.API.V2.Helper, as: APIHelper
   alias BlockScoutWeb.API.V2.TransactionView
@@ -5,7 +6,7 @@ defmodule BlockScoutWeb.API.V2.SuaveView do
   alias Explorer.Helper, as: ExplorerHelper
 
   alias Ecto.Association.NotLoaded
-  alias Explorer.Chain.{Hash, Transaction}
+  alias Explorer.Chain.{Address, Hash, Transaction}
 
   @suave_bid_event "0x83481d5b04dea534715acad673a8177a46fc93882760f36bdc16ccac439d504e"
 
@@ -115,7 +116,7 @@ defmodule BlockScoutWeb.API.V2.SuaveView do
         ExplorerHelper.decode_data(bid_event.data, [{:bytes, 16}, {:uint, 64}, {:array, :address}])
 
       Enum.map(allowed_peekers, fn peeker ->
-        ExplorerHelper.add_0x_prefix(peeker)
+        Address.checksum(peeker)
       end)
     end
   end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex
index 81c28e3eca35..250d08f6be8b 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_transfer_view.ex
@@ -1,8 +1,8 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.TokenTransferView do
   use BlockScoutWeb, :view
 
   alias BlockScoutWeb.API.V2.{Helper, TokenView, TransactionView}
-  alias BlockScoutWeb.Tokens.Helper, as: TokensHelper
   alias Ecto.Association.NotLoaded
   alias Explorer.Chain
   alias Explorer.Chain.{TokenTransfer, Transaction}
@@ -59,17 +59,21 @@ defmodule BlockScoutWeb.API.V2.TokenTransferView do
       "method" => Transaction.method_name(token_transfer.transaction, decoded_input, true),
       "block_hash" => to_string(token_transfer.block_hash),
       "block_number" => token_transfer.block_number,
-      "log_index" => token_transfer.log_index
+      "log_index" => token_transfer.log_index,
+      "token_type" => token_transfer.token_type
     }
   end
 
+  # NOTE: Duplicated with `token_instance` reduction in
+  # Explorer.Chain.CsvExport.AdvancedFilter
   @doc """
-    Prepares token transfer total value/id transferred to be returned in the API v2 endpoints.
+  Prepares token transfer total value/id transferred to be returned in the
+  API v2 endpoints.
   """
   @spec prepare_token_transfer_total(TokenTransfer.t()) :: map()
   # credo:disable-for-next-line /Complexity/
   def prepare_token_transfer_total(token_transfer) do
-    case TokensHelper.token_transfer_amount_for_api(token_transfer) do
+    case TokenTransfer.token_transfer_amount_for_api(token_transfer) do
       {:ok, :erc721_instance} ->
         %{
           "token_id" => token_transfer.token_ids && List.first(token_transfer.token_ids),
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex
index b896ea82c037..2b093d72ffac 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/token_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.TokenView do
   use BlockScoutWeb, :view
   use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
@@ -11,19 +12,18 @@ defmodule BlockScoutWeb.API.V2.TokenView do
   def render("token.json", %{token: nil = token, contract_address_hash: contract_address_hash}) do
     %{
       "address_hash" => Address.checksum(contract_address_hash),
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => Address.checksum(contract_address_hash),
       "symbol" => nil,
       "name" => nil,
       "decimals" => nil,
       "type" => nil,
       "holders_count" => nil,
-      # todo: It should be removed in favour `holders_count` property with the next release after 8.0.0
-      "holders" => nil,
       "exchange_rate" => nil,
+      "volume_24h" => nil,
       "total_supply" => nil,
       "icon_url" => nil,
-      "circulating_market_cap" => nil
+      "circulating_market_cap" => nil,
+      "circulating_supply" => nil,
+      "reputation" => nil
     }
     |> maybe_append_bridged_info(token)
   end
@@ -35,20 +35,18 @@ defmodule BlockScoutWeb.API.V2.TokenView do
   def render("token.json", %{token: token}) do
     %{
       "address_hash" => Address.checksum(token.contract_address_hash),
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => Address.checksum(token.contract_address_hash),
       "symbol" => token.symbol,
       "name" => token.name,
       "decimals" => token.decimals,
       "type" => token.type,
       "holders_count" => prepare_holders_count(token.holder_count),
-      # todo: It should be removed in favour `holders_count` property with the next release after 8.0.0
-      "holders" => prepare_holders_count(token.holder_count),
       "exchange_rate" => exchange_rate(token),
       "volume_24h" => token.volume_24h,
       "total_supply" => token.total_supply,
       "icon_url" => token.icon_url,
-      "circulating_market_cap" => token.circulating_market_cap
+      "circulating_market_cap" => token.circulating_market_cap,
+      "circulating_supply" => token.circulating_supply,
+      "reputation" => token.reputation
     }
     |> maybe_append_bridged_info(token)
     |> chain_type_fields(%{address: token.contract_address, field_prefix: nil})
@@ -158,6 +156,12 @@ defmodule BlockScoutWeb.API.V2.TokenView do
         BlockScoutWeb.API.V2.FilecoinView.put_filecoin_robust_address(result, params)
       end
 
+    :zilliqa ->
+      defp chain_type_fields(result, params) do
+        # credo:disable-for-next-line Credo.Check.Design.AliasUsage
+        BlockScoutWeb.API.V2.ZilliqaView.extend_token_json_response(result, params.address)
+      end
+
     _ ->
       defp chain_type_fields(result, _params) do
         result
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex
index f9420d9a12a7..1fca46ba67d8 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex
@@ -1,6 +1,11 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.TransactionView do
   use BlockScoutWeb, :view
-  use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
+
+  use Utils.RuntimeEnvHelper,
+    chain_type: [:explorer, :chain_type],
+    chain_identity: [:explorer, :chain_identity],
+    miner_gets_burnt_fees?: [:explorer, [Explorer.Chain.Transaction, :block_miner_gets_burnt_fees?]]
 
   alias BlockScoutWeb.API.V2.{ApiView, Helper, InternalTransactionView, TokenTransferView, TokenView}
 
@@ -8,9 +13,22 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
   alias BlockScoutWeb.{TransactionStateView, TransactionView}
   alias Ecto.Association.NotLoaded
   alias Explorer.{Chain, Market}
-  alias Explorer.Chain.{Address, Block, DecodingHelper, Log, SignedAuthorization, Token, Transaction, Wei}
+
+  alias Explorer.Chain.{
+    Address,
+    Block,
+    DecodingHelper,
+    Log,
+    SignedAuthorization,
+    SmartContract,
+    Token,
+    Transaction,
+    Wei
+  }
+
   alias Explorer.Chain.Block.Reward
   alias Explorer.Chain.Cache.Counters.AverageBlockTime
+  alias Explorer.Chain.SmartContract.Proxy.Models.Implementation, as: ProxyImplementation
   alias Explorer.Chain.Transaction.StateChange
   alias Timex.Duration
 
@@ -69,7 +87,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
         |> with_chain_type_transformations()
         |> Enum.zip(decoded_transactions)
         |> Enum.map(fn {transaction, decoded_input} ->
-          prepare_transaction(transaction, conn, false, block_height, decoded_input)
+          prepare_transaction(transaction, conn, false, block_height, nil, decoded_input)
         end),
       "next_page_params" => next_page_params
     }
@@ -89,7 +107,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     |> with_chain_type_transformations()
     |> Enum.zip(decoded_transactions)
     |> Enum.map(fn {transaction, decoded_input} ->
-      prepare_transaction(transaction, conn, false, block_height, decoded_input)
+      prepare_transaction(transaction, conn, false, block_height, nil, decoded_input)
     end)
   end
 
@@ -99,7 +117,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
 
     transaction
     |> with_chain_type_transformations()
-    |> prepare_transaction(conn, true, block_height, decoded_input)
+    |> prepare_transaction(conn, true, block_height, nil, decoded_input)
   end
 
   def render("raw_trace.json", %{raw_traces: raw_traces}) do
@@ -145,10 +163,6 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     TokenTransferView.prepare_token_transfer(token_transfer, conn, decoded_transaction)
   end
 
-  def render("transaction_actions.json", %{actions: actions}) do
-    Enum.map(actions, &prepare_transaction_action(&1))
-  end
-
   def render("internal_transactions.json", %{
         internal_transactions: internal_transactions,
         next_page_params: next_page_params,
@@ -221,35 +235,86 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     |> Enum.map(&prepare_signed_authorization/1)
   end
 
+  @doc """
+  Renders FHE operations for a transaction as JSON.
+
+  Returns a map with items (list of operation objects), total_hcu, max_depth_hcu,
+  and operation_count. Each item includes log_index, operation, type, fhe_type,
+  is_scalar, hcu_cost, hcu_depth, caller, inputs, result, and block_number.
+
+  ## Parameters
+  - `assigns` - Map with `:operations` (list of FheOperation.t()), `:total_hcu`,
+    `:max_depth_hcu`, and `:operation_count`.
+
+  ## Returns
+  - Map with "items", "total_hcu", "max_depth_hcu", "operation_count" keys.
+  """
+  def render("fhe_operations.json", %{
+        operations: operations,
+        total_hcu: total_hcu,
+        max_depth_hcu: max_depth_hcu,
+        operation_count: operation_count
+      }) do
+    %{
+      "items" => Enum.map(operations, &prepare_fhe_operation/1),
+      "total_hcu" => total_hcu,
+      "max_depth_hcu" => max_depth_hcu,
+      "operation_count" => operation_count
+    }
+  end
+
+  @doc """
+  Returns the ABI of a smart contract or an empty list if the smart contract is nil
+  """
+  @spec try_to_get_abi(SmartContract.t() | nil) :: [map()]
+  def try_to_get_abi(smart_contract) do
+    (smart_contract && smart_contract.abi) || []
+  end
+
+  @doc """
+  Returns the ABI of a proxy implementations or an empty list if the proxy implementations is nil
+  """
+  @spec extract_implementations_abi(ProxyImplementation.t() | nil) :: [map()]
+  def extract_implementations_abi(nil) do
+    []
+  end
+
+  def extract_implementations_abi(proxy_implementations) do
+    proxy_implementations.smart_contracts
+    |> Enum.flat_map(fn smart_contract ->
+      try_to_get_abi(smart_contract)
+    end)
+  end
+
   @doc """
     Decodes list of logs
   """
-  @spec decode_logs([Log.t()], boolean) :: [tuple]
+  @spec decode_logs([Log.t()], boolean()) :: [tuple() | nil]
   def decode_logs(logs, skip_sig_provider?) do
-    unique_log_address_hashes =
-      logs
-      |> Enum.map(fn log -> log.address_hash end)
-      |> Enum.uniq()
-
     full_abi_per_address_hash =
-      Log.accumulate_abi_by_address_hashes(%{}, unique_log_address_hashes, @api_true)
+      Enum.reduce(logs, %{}, fn log, acc ->
+        full_abi =
+          (extract_implementations_abi(log.address.proxy_implementations) ++
+             try_to_get_abi(log.address.smart_contract))
+          |> Enum.uniq()
 
-    {all_logs, _, _} =
-      Enum.reduce(logs, {[], full_abi_per_address_hash, %{}}, fn log,
-                                                                 {results, full_abi_per_address_hash_acc, events_acc} ->
-        {result, full_abi_per_address_hash_acc, events_acc} =
+        Map.put(acc, log.address_hash, full_abi)
+      end)
+
+    {all_logs, _} =
+      Enum.reduce(logs, {[], %{}}, fn log, {results, events_acc} ->
+        {result, events_acc} =
           Log.decode(
             log,
             %Transaction{hash: log.transaction_hash},
             @api_true,
             skip_sig_provider?,
             true,
-            full_abi_per_address_hash_acc[log.address_hash],
-            full_abi_per_address_hash_acc,
+            full_abi_per_address_hash[log.address_hash],
             events_acc
           )
 
-        {[result | results], full_abi_per_address_hash_acc, events_acc}
+        {[result | results], events_acc}
       end)
 
     all_logs_with_index =
@@ -258,17 +323,22 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
       |> Enum.with_index(fn element, index -> {index, element} end)
 
     %{
-      :already_decoded_logs => already_decoded_logs,
+      :already_decoded_or_ignored_logs => already_decoded_or_ignored_logs,
       :input_for_sig_provider_batched_request => input_for_sig_provider_batched_request
     } =
       all_logs_with_index
       |> Enum.reduce(
         %{
-          :already_decoded_logs => [],
+          :already_decoded_or_ignored_logs => [],
           :input_for_sig_provider_batched_request => []
         },
         fn {index, result}, acc ->
           case result do
+            {:error, :try_with_sig_provider, {log, _transaction_hash}} when is_nil(log.first_topic) ->
+              Map.put(acc, :already_decoded_or_ignored_logs, [
+                {index, {:error, :could_not_decode}} | acc.already_decoded_or_ignored_logs
+              ])
+
             {:error, :try_with_sig_provider, {log, transaction_hash}} ->
               Map.put(acc, :input_for_sig_provider_batched_request, [
                 {index,
@@ -280,7 +350,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
               ])
 
             _ ->
-              Map.put(acc, :already_decoded_logs, [{index, result} | acc.already_decoded_logs])
+              Map.put(acc, :already_decoded_or_ignored_logs, [{index, result} | acc.already_decoded_or_ignored_logs])
           end
         end
       )
@@ -288,7 +358,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     decoded_with_sig_provider_logs =
       Log.decode_events_batch_via_sig_provider(input_for_sig_provider_batched_request, skip_sig_provider?)
 
-    full_logs = already_decoded_logs ++ decoded_with_sig_provider_logs
+    full_logs = already_decoded_or_ignored_logs ++ decoded_with_sig_provider_logs
 
     full_logs
     |> Enum.sort_by(fn {index, _log} -> index end, :asc)
@@ -322,7 +392,12 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
       "decoded" => decoded,
       "smart_contract" => smart_contract_info(transaction_or_hash),
       "block_number" => log.block_number,
-      "block_hash" => log.block_hash
+      "block_hash" => log.block_hash,
+      "block_timestamp" =>
+        case log.block do
+          %Block{timestamp: timestamp} -> timestamp
+          _ -> nil
+        end
     }
   end
 
@@ -339,14 +414,13 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
   def prepare_signed_authorization(signed_authorization) do
     %{
       "address_hash" => Address.checksum(signed_authorization.address),
-      # todo: It should be removed in favour `address_hash` property with the next release after 8.0.0
-      "address" => Address.checksum(signed_authorization.address),
       "chain_id" => signed_authorization.chain_id,
       "nonce" => signed_authorization.nonce,
       "r" => signed_authorization.r,
       "s" => signed_authorization.s,
       "v" => signed_authorization.v,
-      "authority" => Address.checksum(signed_authorization.authority)
+      "authority" => Address.checksum(signed_authorization.authority),
+      "status" => signed_authorization.status
     }
   end
 
@@ -368,7 +442,14 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     end
   end
 
-  defp prepare_transaction(transaction, conn, single_transaction?, block_height, watchlist_names \\ nil, decoded_input)
+  defp prepare_transaction(
+         transaction,
+         conn,
+         single_transaction?,
+         block_height,
+         watchlist_names,
+         decoded_input
+       )
 
   defp prepare_transaction(
          {%Reward{} = emission_reward, %Reward{} = validator_reward},
@@ -407,26 +488,26 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
          watchlist_names,
          decoded_input
        ) do
-    base_fee_per_gas = transaction.block && transaction.block.base_fee_per_gas
+    base_fee_per_gas = base_fee_per_gas(transaction)
     max_priority_fee_per_gas = transaction.max_priority_fee_per_gas
     max_fee_per_gas = transaction.max_fee_per_gas
 
     priority_fee_per_gas = Transaction.priority_fee_per_gas(max_priority_fee_per_gas, base_fee_per_gas, max_fee_per_gas)
 
-    burnt_fees = burnt_fees(transaction, max_fee_per_gas, base_fee_per_gas)
-
     status = transaction |> Chain.transaction_to_status() |> format_status()
 
     revert_reason = revert_reason(status, transaction, single_transaction?)
 
     decoded_input_data = decoded_input(decoded_input)
 
+    block_timestamp = block_timestamp(transaction)
+
     result = %{
       "hash" => transaction.hash,
       "result" => status,
       "status" => transaction.status,
       "block_number" => transaction.block_number,
-      "timestamp" => block_timestamp(transaction),
+      "timestamp" => block_timestamp,
       "from" =>
         Helper.address_with_info(
           single_transaction? && conn,
@@ -455,15 +536,15 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
       "confirmation_duration" => processing_time_duration(transaction),
       "value" => transaction.value,
       "fee" => transaction |> Transaction.fee(:wei) |> format_fee(),
-      "gas_price" => transaction.gas_price || Transaction.effective_gas_price(transaction),
+      "gas_price" => gas_price_for_display(transaction),
       "type" => transaction.type,
       "gas_used" => transaction.gas_used,
       "gas_limit" => transaction.gas,
       "max_fee_per_gas" => transaction.max_fee_per_gas,
       "max_priority_fee_per_gas" => transaction.max_priority_fee_per_gas,
       "base_fee_per_gas" => base_fee_per_gas,
-      "priority_fee" => priority_fee_per_gas && Wei.mult(priority_fee_per_gas, transaction.gas_used),
-      "transaction_burnt_fee" => burnt_fees,
+      "priority_fee" => priority_fee_display(priority_fee_per_gas, transaction),
+      "transaction_burnt_fee" => burnt_fees(transaction, base_fee_per_gas),
       "nonce" => transaction.nonce,
       "position" => transaction.index,
       "revert_reason" => revert_reason,
@@ -471,22 +552,60 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
       "decoded_input" => decoded_input_data,
       "token_transfers" => token_transfers(transaction.token_transfers, conn, single_transaction?),
       "token_transfers_overflow" => token_transfers_overflow(transaction.token_transfers, single_transaction?),
-      "actions" => transaction_actions(transaction.transaction_actions),
       "exchange_rate" => Market.get_coin_exchange_rate().fiat_value,
-      "historic_exchange_rate" =>
-        Market.get_coin_exchange_rate_at_date(block_timestamp(transaction), @api_true).fiat_value,
+      "historic_exchange_rate" => historic_exchange_rate(block_timestamp),
       "method" => Transaction.method_name(transaction, decoded_input),
       "transaction_types" => transaction_types(transaction),
       "transaction_tag" =>
         GetTransactionTags.get_transaction_tags(transaction.hash, current_user(single_transaction? && conn)),
       "has_error_in_internal_transactions" => transaction.has_error_in_internal_transactions,
-      "authorization_list" => authorization_list(transaction.signed_authorizations)
+      "authorization_list" => authorization_list(transaction.signed_authorizations),
+      "is_pending_update" => transaction_pending_update?(transaction),
+      "fhe_operations_count" => fhe_operations_count_display(transaction)
     }
 
     result
     |> with_chain_type_fields(transaction, single_transaction?, conn, watchlist_names)
   end
 
+  defp base_fee_per_gas(transaction) do
+    transaction.block && transaction.block.base_fee_per_gas
+  end
+
+  defp gas_price_for_display(transaction) do
+    transaction.gas_price || Transaction.effective_gas_price(transaction)
+  end
+
+  defp priority_fee_display(priority_fee_per_gas, transaction) do
+    priority_fee_per_gas && Wei.mult(priority_fee_per_gas, transaction.gas_used)
+  end
+
+  defp transaction_pending_update?(transaction) do
+    transaction.block && transaction.block.refetch_needed
+  end
+
+  defp fhe_operations_count_display(transaction) do
+    transaction.fhe_operations_count || 0
+  end
+
+  # Calculates burnt fees for a transaction.
+  #
+  # ## Parameters
+  # - `transaction`: The transaction entity containing info needed to calculate the fees.
+  # - `base_fee_per_gas`: Base fee per gas in Wei.
+  #
+  # ## Returns
+  # - The calculated amount of the burnt fees.
+  # - `nil` if the fees cannot be calculated.
+  @spec burnt_fees(Transaction.t(), Wei.t() | nil) :: Wei.t() | nil
+  defp burnt_fees(transaction, base_fee_per_gas) do
+    if miner_gets_burnt_fees?() do
+      Wei.zero()
+    else
+      Transaction.burnt_fees(transaction.gas_used, transaction.max_fee_per_gas, base_fee_per_gas)
+    end
+  end
+
   def token_transfers(_, _conn, false), do: nil
   def token_transfers(%NotLoaded{}, _conn, _), do: nil
 
@@ -503,15 +622,6 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
   def token_transfers_overflow(token_transfers, _),
     do: Enum.count(token_transfers) > Chain.get_token_transfers_per_transaction_preview_count()
 
-  def transaction_actions(%NotLoaded{}), do: []
-
-  @doc """
-    Renders transaction actions
-  """
-  def transaction_actions(actions) do
-    render("transaction_actions.json", %{actions: actions})
-  end
-
   @doc """
     Renders the authorization list for a transaction.
 
@@ -529,18 +639,6 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     render("authorization_list.json", %{signed_authorizations: signed_authorizations})
   end
 
-  defp burnt_fees(transaction, max_fee_per_gas, base_fee_per_gas) do
-    if !is_nil(max_fee_per_gas) and !is_nil(transaction.gas_used) and !is_nil(base_fee_per_gas) do
-      if Decimal.compare(max_fee_per_gas.value, 0) == :eq do
-        %Wei{value: Decimal.new(0)}
-      else
-        Wei.mult(base_fee_per_gas, transaction.gas_used)
-      end
-    else
-      nil
-    end
-  end
-
   defp revert_reason(status, transaction, single_transaction?) do
     reverted? = is_binary(status) && status |> String.downcase() |> String.contains?("reverted")
 
@@ -601,8 +699,19 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     end)
   end
 
-  defp format_status({:error, reason}), do: reason
-  defp format_status(status), do: status
+  @spec format_status(
+          :pending
+          | :awaiting_internal_transactions
+          | :success
+          | {:error, :awaiting_internal_transactions}
+          | {:error, reason :: String.t()}
+        ) ::
+          :pending
+          | :awaiting_internal_transactions
+          | :success
+          | String.t()
+  def format_status({:error, reason}), do: reason
+  def format_status(status), do: status
 
   defp format_decoded_log_input({:error, :could_not_decode}), do: nil
   defp format_decoded_log_input({:ok, _method_id, _text, _mapping} = decoded), do: decoded
@@ -782,7 +891,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
   @doc """
   Returns block's timestamp from Block/Transaction
   """
-  @spec block_timestamp(any()) :: :utc_datetime_usec | nil
+  @spec block_timestamp(any()) :: DateTime.t() | nil
   def block_timestamp(%Transaction{block_timestamp: block_ts}) when not is_nil(block_ts), do: block_ts
   def block_timestamp(%Transaction{block: %Block{} = block}), do: block.timestamp
   def block_timestamp(%Block{} = block), do: block.timestamp
@@ -838,9 +947,16 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     Map.merge(map, %{"change" => change})
   end
 
+  defp historic_exchange_rate(nil), do: nil
+
+  defp historic_exchange_rate(block_timestamp) do
+    if DateTime.before?(block_timestamp, DateTime.shift(DateTime.utc_now(), day: -1)) do
+      Market.get_coin_exchange_rate_at_date(block_timestamp, @api_true).fiat_value
+    end
+  end
+
   defp with_chain_type_transformations(transactions) do
-    chain_type = Application.get_env(:explorer, :chain_type)
-    do_with_chain_type_transformations(chain_type, transactions)
+    do_with_chain_type_transformations(chain_type(), transactions)
   end
 
   defp do_with_chain_type_transformations(:stability, transactions) do
@@ -853,55 +969,44 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
   end
 
   defp with_chain_type_fields(result, transaction, single_transaction?, conn, watchlist_names) do
-    chain_type = Application.get_env(:explorer, :chain_type)
-    do_with_chain_type_fields(chain_type, result, transaction, single_transaction?, conn, watchlist_names)
-  end
-
-  defp do_with_chain_type_fields(
-         :polygon_edge,
-         result,
-         transaction,
-         true = _single_transaction?,
-         conn,
-         _watchlist_names
-       ) do
-    # credo:disable-for-next-line Credo.Check.Design.AliasUsage
-    BlockScoutWeb.API.V2.PolygonEdgeView.extend_transaction_json_response(result, transaction.hash, conn)
-  end
-
-  defp do_with_chain_type_fields(
-         :polygon_zkevm,
-         result,
-         transaction,
-         true = _single_transaction?,
-         _conn,
-         _watchlist_names
-       ) do
-    # credo:disable-for-next-line Credo.Check.Design.AliasUsage
-    BlockScoutWeb.API.V2.PolygonZkevmView.extend_transaction_json_response(result, transaction)
+    result
+    |> do_with_chain_type_fields(
+      chain_type(),
+      transaction,
+      single_transaction?,
+      conn,
+      watchlist_names
+    )
+    |> do_with_chain_identity_fields(
+      chain_identity(),
+      transaction,
+      single_transaction?,
+      conn,
+      watchlist_names
+    )
   end
 
-  defp do_with_chain_type_fields(:zksync, result, transaction, true = _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :zksync, transaction, true = _single_transaction?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.ZkSyncView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:arbitrum, result, transaction, true = _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :arbitrum, transaction, true = _single_transaction?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.ArbitrumView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:optimism, result, transaction, true = _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :optimism, transaction, true = _single_transaction?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.OptimismView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:scroll, result, transaction, true = _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :scroll, transaction, true = _single_transaction?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.ScrollView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:suave, result, transaction, true = single_transaction?, conn, watchlist_names) do
+  defp do_with_chain_type_fields(result, :suave, transaction, true = single_transaction?, conn, watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.SuaveView.extend_transaction_json_response(
       transaction,
@@ -912,27 +1017,70 @@ defmodule BlockScoutWeb.API.V2.TransactionView do
     )
   end
 
-  defp do_with_chain_type_fields(:stability, result, transaction, _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :stability, transaction, _single_transaction?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.StabilityView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:ethereum, result, transaction, _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :ethereum, transaction, _single_transaction?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
     BlockScoutWeb.API.V2.EthereumView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:celo, result, transaction, _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, :zilliqa, transaction, _single_tx?, _conn, _watchlist_names) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
-    BlockScoutWeb.API.V2.CeloView.extend_transaction_json_response(result, transaction)
+    BlockScoutWeb.API.V2.ZilliqaView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(:zilliqa, result, transaction, _single_tx?, _conn, _watchlist_names) do
+  defp do_with_chain_type_fields(result, _chain_type, _transaction, _single_transaction?, _conn, _watchlist_names) do
+    result
+  end
+
+  defp do_with_chain_identity_fields(
+         result,
+         {:optimism, :celo},
+         transaction,
+         _single_transaction?,
+         _conn,
+         _watchlist_names
+       ) do
     # credo:disable-for-next-line Credo.Check.Design.AliasUsage
-    BlockScoutWeb.API.V2.ZilliqaView.extend_transaction_json_response(result, transaction)
+    BlockScoutWeb.API.V2.CeloView.extend_transaction_json_response(result, transaction)
   end
 
-  defp do_with_chain_type_fields(_chain_type, result, _transaction, _single_transaction?, _conn, _watchlist_names) do
+  defp do_with_chain_identity_fields(
+         result,
+         _chain_identity,
+         _transaction,
+         _single_transaction?,
+         _conn,
+         _watchlist_names
+       ) do
     result
   end
+
+  defp prepare_fhe_operation(operation) do
+    caller_info =
+      if operation.caller do
+        # operation.caller is an Explorer.Chain.Hash struct
+        address_hash = operation.caller
+        Helper.address_with_info(nil, %{hash: address_hash}, address_hash, false)
+      else
+        nil
+      end
+
+    %{
+      "log_index" => operation.log_index,
+      "operation" => operation.operation,
+      "type" => operation.operation_type,
+      "fhe_type" => operation.fhe_type,
+      "is_scalar" => operation.is_scalar,
+      "hcu_cost" => operation.hcu_cost,
+      "hcu_depth" => operation.hcu_depth,
+      "caller" => caller_info,
+      "inputs" => operation.input_handles,
+      "result" => "0x" <> Base.encode16(operation.result_handle, case: :lower),
+      "block_number" => operation.block_number
+    }
+  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/validator_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/validator_view.ex
index 354340be5b5f..24d4b532864e 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/validator_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/validator_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ValidatorView do
   use BlockScoutWeb, :view
 
@@ -49,11 +50,7 @@ defmodule BlockScoutWeb.API.V2.ValidatorView do
         "slashed" => validator.slashing_status_is_slashed,
         "block_number" => validator.slashing_status_by_block,
         "multiplier" => validator.slashing_status_multiplier
-      },
-      # todo: Next 3 props should be removed in favour `slashing_status` property with the next release after 8.0.0
-      "slashing_status_is_slashed" => validator.slashing_status_is_slashed,
-      "slashing_status_by_block" => validator.slashing_status_by_block,
-      "slashing_status_multiplier" => validator.slashing_status_multiplier
+      }
     }
   end
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex
index 252a36f784e8..1c014d062239 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/withdrawal_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.WithdrawalView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/zilliqa_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/zilliqa_view.ex
index 2b9ad89ea1c2..061a6cf3de4c 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/zilliqa_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/zilliqa_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ZilliqaView do
   @moduledoc """
   View functions for rendering Zilliqa-related data in JSON format.
@@ -5,10 +6,13 @@ defmodule BlockScoutWeb.API.V2.ZilliqaView do
   use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type]
 
   if @chain_type == :zilliqa do
-    # TODO: remove when https://github.com/elixir-lang/elixir/issues/13975 comes to elixir release
-    import Explorer.Chain.Zilliqa.Helper, only: [scilla_transaction?: 1], warn: false
-    alias Explorer.Chain.{Address, Block, Transaction}, warn: false
-    alias Explorer.Chain.Zilliqa.{AggregateQuorumCertificate, QuorumCertificate}, warn: false
+    import Explorer.Chain.Zilliqa.Helper, only: [scilla_transaction?: 1]
+    alias Ecto.Association.NotLoaded
+    alias Explorer.Chain.{Address, Block, Transaction}
+    alias Explorer.Chain.Zilliqa.{AggregateQuorumCertificate, QuorumCertificate}
+    alias Explorer.Chain.Zilliqa.Zrc2.TokenAdapter
+
+    @api_true [api?: true]
 
     @doc """
     Extends the JSON output with a sub-map containing information related to Zilliqa,
@@ -53,6 +57,28 @@ defmodule BlockScoutWeb.API.V2.ZilliqaView do
       })
     end
 
+    @doc """
+    Extends the JSON output with a sub-map containing information related to Zilliqa,
+    such as ZRC-2 contract address.
+
+    ## Parameters
+    - `out_json`: A map defining the output JSON which will be extended.
+    - `adapter_address`: The adapter contract address bound with the ZRC-2 contract address.
+
+    ## Returns
+    - A map extended with data related to Zilliqa.
+    """
+    @spec extend_token_json_response(map(), Address.t()) :: map()
+    def extend_token_json_response(%{"type" => "ZRC-2"} = out_json, %Address{} = adapter_address) do
+      Map.put(out_json, :zilliqa, %{
+        zrc2_address_hash: TokenAdapter.adapter_address_hash_to_zrc2_address_hash(adapter_address.hash, @api_true)
+      })
+    end
+
+    def extend_token_json_response(out_json, _) do
+      out_json
+    end
+
     @doc """
     Extends the JSON output with a sub-map containing information related to
     Zilliqa, such as if the address is a Scilla smart contract.
@@ -64,8 +90,8 @@ defmodule BlockScoutWeb.API.V2.ZilliqaView do
     ## Returns
     - A map extended with data related to Zilliqa.
     """
-    @spec extend_address_json_response(map(), Address.t()) :: map()
-    def extend_address_json_response(out_json, %Address{} = address) do
+    @spec extend_address_json_response(map(), Address.t() | nil | NotLoaded.t()) :: map()
+    def extend_address_json_response(out_json, address) do
       is_scilla_contract =
         case address do
           %Address{
@@ -82,8 +108,6 @@ defmodule BlockScoutWeb.API.V2.ZilliqaView do
       })
     end
 
-    def extend_address_json_response(out_json, _address), do: out_json
-
     @spec add_quorum_certificate(map(), Block.t()) :: map()
     defp add_quorum_certificate(
            zilliqa_json,
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex
index 03327049c479..d161fd844091 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/zksync_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.API.V2.ZkSyncView do
   use BlockScoutWeb, :view
 
@@ -16,19 +17,11 @@ defmodule BlockScoutWeb.API.V2.ZkSyncView do
       "timestamp" => batch.timestamp,
       "root_hash" => batch.root_hash,
       "l1_transactions_count" => batch.l1_transaction_count,
-      # todo: It should be removed in favour `l1_transactions_count` property with the next release after 8.0.0
-      "l1_transaction_count" => batch.l1_transaction_count,
       "l2_transactions_count" => batch.l2_transaction_count,
-      # todo: It should be removed in favour `l2_transactions_count` property with the next release after 8.0.0
-      "l2_transaction_count" => batch.l2_transaction_count,
       "l1_gas_price" => batch.l1_gas_price,
       "l2_fair_gas_price" => batch.l2_fair_gas_price,
       "start_block_number" => batch.start_block,
-      "end_block_number" => batch.end_block,
-      # todo: It should be removed in favour `start_block_number` property with the next release after 8.0.0
-      "start_block" => batch.start_block,
-      # todo: It should be removed in favour `end_block_number` property with the next release after 8.0.0
-      "end_block" => batch.end_block
+      "end_block_number" => batch.end_block
     }
     |> add_l1_transactions_info_and_status(batch)
   end
@@ -72,9 +65,7 @@ defmodule BlockScoutWeb.API.V2.ZkSyncView do
       %{
         "number" => batch.number,
         "timestamp" => batch.timestamp,
-        "transactions_count" => batch.l1_transaction_count + batch.l2_transaction_count,
-        # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0
-        "transaction_count" => batch.l1_transaction_count + batch.l2_transaction_count
+        "transactions_count" => batch.l1_transaction_count + batch.l2_transaction_count
       }
       |> add_l1_transactions_info_and_status(batch)
     end)
@@ -204,4 +195,12 @@ defmodule BlockScoutWeb.API.V2.ZkSyncView do
       true -> "Processed on L2"
     end
   end
+
+  @doc """
+  Returns a list of possible batch statuses.
+  """
+  @spec batch_status_enum() :: [String.t()]
+  def batch_status_enum do
+    ["Executed on L1", "Validated on L1", "Sent to L1", "Sealed on L2", "Processed on L2"]
+  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/api_docs_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api_docs_view.ex
index 77a1b36824aa..540ef5deff77 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/api_docs_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/api_docs_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.APIDocsView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/block_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/block_transaction_view.ex
index 025f2782d5c6..c4e0e4643fcf 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/block_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/block_transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.BlockTransactionView do
   use BlockScoutWeb, :view
 
@@ -7,6 +8,10 @@ defmodule BlockScoutWeb.BlockTransactionView do
     gettext("Easy Cowboy! This block does not exist yet!")
   end
 
+  def block_not_found_message({:error, :not_found}) do
+    gettext("Easy Cowboy! This block does not exist yet!")
+  end
+
   def block_not_found_message({:ok, false}) do
     gettext("This block has not been processed yet.")
   end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/block_view.ex b/apps/block_scout_web/lib/block_scout_web/views/block_view.ex
index 11311a747b55..5d3c3f85e74f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/block_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/block_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.BlockView do
   use BlockScoutWeb, :view
 
@@ -5,9 +6,9 @@ defmodule BlockScoutWeb.BlockView do
 
   alias Ecto.Association.NotLoaded
   alias Explorer.Chain
-  alias Explorer.Chain.Cache.Counters.{BlockBurntFeeCount, BlockPriorityFeeCount}
   alias Explorer.Chain.{Block, Wei}
   alias Explorer.Chain.Block.Reward
+  alias Explorer.Chain.Cache.Counters.{BlockBurntFeeCount, BlockPriorityFeeCount}
 
   @dialyzer :no_match
 
@@ -56,7 +57,7 @@ defmodule BlockScoutWeb.BlockView do
   def show_reward?(_), do: true
 
   def block_reward_text(%Reward{address_hash: beneficiary_address, address_type: :validator}, block_miner_address) do
-    if Application.get_env(:explorer, Explorer.Chain.Block.Reward, %{})[:keys_manager_contract_address] do
+    if Application.get_env(:explorer, Reward, %{})[:keys_manager_contract_address] do
       %{payout_key: block_miner_payout_address} = Reward.get_validator_payout_key_by_mining_from_db(block_miner_address)
 
       if beneficiary_address == block_miner_payout_address do
@@ -79,7 +80,7 @@ defmodule BlockScoutWeb.BlockView do
 
   def combined_rewards_value(block) do
     block
-    |> Chain.block_combined_rewards()
+    |> Block.block_combined_rewards()
     |> format_wei_value(:ether)
   end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex
index f6b22b30f7a7..177f0aa1180f 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/block_withdrawal_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.BlockWithdrawalView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/bridged_tokens_view.ex b/apps/block_scout_web/lib/block_scout_web/views/bridged_tokens_view.ex
index 3cf7e32512c7..05baabbfb7f2 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/bridged_tokens_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/bridged_tokens_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.BridgedTokensView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/chain_view.ex b/apps/block_scout_web/lib/block_scout_web/views/chain_view.ex
index bbbcdebe866e..5ab8f85788a7 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/chain_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/chain_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.ChainView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/cldr_helper/number.ex b/apps/block_scout_web/lib/block_scout_web/views/cldr_helper/number.ex
index 0d1c0e1e139f..65efefaf9b55 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/cldr_helper/number.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/cldr_helper/number.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.CldrHelper.Number do
   @moduledoc """
   Work-arounds for `Cldr.Number` bugs
diff --git a/apps/block_scout_web/lib/block_scout_web/views/common_components_view.ex b/apps/block_scout_web/lib/block_scout_web/views/common_components_view.ex
index f950e795a298..d1fc03328519 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/common_components_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/common_components_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.CommonComponentsView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/csv_export.ex b/apps/block_scout_web/lib/block_scout_web/views/csv_export.ex
index 95515843f897..4eec86ec512a 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/csv_export.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/csv_export.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.CsvExportView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/currency_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/currency_helper.ex
index 7dc513fcdc83..7c070e1de3a1 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/currency_helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/currency_helper.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.CurrencyHelper do
   @moduledoc """
   Helper functions for interacting with `t:BlockScoutWeb.ExchangeRates.USD.t/0` values.
diff --git a/apps/block_scout_web/lib/block_scout_web/views/error_422.ex b/apps/block_scout_web/lib/block_scout_web/views/error_422.ex
index b813dc9383ae..cb3af4a19cfb 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/error_422.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/error_422.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Error422View do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/error_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/error_helper.ex
index 916746eb83d1..dfcb631d7ff0 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/error_helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/error_helper.ex
@@ -1,9 +1,10 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.ErrorHelper do
   @moduledoc """
   Conveniences for translating and building error messages.
   """
 
-  use Phoenix.HTML
+  use PhoenixHTMLHelpers
 
   alias Ecto.Changeset
   alias Phoenix.HTML.Form
@@ -56,4 +57,16 @@ defmodule BlockScoutWeb.ErrorHelper do
       Gettext.dgettext(BlockScoutWeb.Gettext, "errors", msg, opts)
     end
   end
+
+  @doc """
+  Converts a changeset to a list of errors.
+  """
+  @spec changeset_to_errors(Changeset.t()) :: any()
+  def changeset_to_errors(changeset) do
+    Changeset.traverse_errors(changeset, fn {msg, opts} ->
+      Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
+        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+      end)
+    end)
+  end
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/error_view.ex b/apps/block_scout_web/lib/block_scout_web/views/error_view.ex
index d800b719b3b2..7d77cb5de535 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/error_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/error_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.ErrorView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/form_view.ex b/apps/block_scout_web/lib/block_scout_web/views/form_view.ex
index 369fc47b48d1..13d8a7203748 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/form_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/form_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.FormView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/icons_view.ex b/apps/block_scout_web/lib/block_scout_web/views/icons_view.ex
index 40c5ee3a1d98..f84c862a27b9 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/icons_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/icons_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.IconsView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/internal_server_error_view.ex b/apps/block_scout_web/lib/block_scout_web/views/internal_server_error_view.ex
index 837ecb3367e0..b00f0a049492 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/internal_server_error_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/internal_server_error_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.InternalServerErrorView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/internal_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/internal_transaction_view.ex
index 3d4cafd9f3f0..03b148ac125e 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/internal_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/internal_transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.InternalTransactionView do
   use BlockScoutWeb, :view
 
@@ -18,11 +19,16 @@ defmodule BlockScoutWeb.InternalTransactionView do
   iex> BlockScoutWeb.InternalTransactionView.type(%Explorer.Chain.InternalTransaction{type: :call, call_type: :delegatecall})
   "Delegate Call"
   """
-  def type(%InternalTransaction{type: :call, call_type: :call}), do: gettext("Call")
-  def type(%InternalTransaction{type: :call, call_type: :callcode}), do: gettext("Call Code")
-  def type(%InternalTransaction{type: :call, call_type: :delegatecall}), do: gettext("Delegate Call")
-  def type(%InternalTransaction{type: :call, call_type: :staticcall}), do: gettext("Static Call")
-  def type(%InternalTransaction{type: :call, call_type: :invalid}), do: gettext("Invalid")
+  def type(%InternalTransaction{type: :call} = it) do
+    case InternalTransaction.call_type(it) do
+      :call -> gettext("Call")
+      :callcode -> gettext("Call Code")
+      :delegatecall -> gettext("Delegate Call")
+      :staticcall -> gettext("Static Call")
+      :invalid -> gettext("Invalid")
+    end
+  end
+
   def type(%InternalTransaction{type: :create}), do: gettext("Create")
   def type(%InternalTransaction{type: :create2}), do: gettext("Create2")
   def type(%InternalTransaction{type: :selfdestruct}), do: gettext("Self-Destruct")
diff --git a/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex b/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex
index 46977c4e1f51..c9470ae5d4f4 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/layout_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.LayoutView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/log_view.ex b/apps/block_scout_web/lib/block_scout_web/views/log_view.ex
index fe12ba9cce04..b5e1827a9735 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/log_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/log_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.LogView do
   use BlockScoutWeb, :view
 end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex
index ee86d1f9ad23..d19df7d939b9 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/nft_helper.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.NFTHelper do
   @moduledoc """
     Module with functions for NFT view
@@ -19,7 +20,7 @@ defmodule BlockScoutWeb.NFTHelper do
         metadata["image"] ->
           retrieve_image(metadata["image"])
 
-        image = metadata["properties"]["image"] ->
+        image = is_map(metadata["properties"]) && metadata["properties"]["image"] ->
           if is_map(image), do: image["description"], else: image
 
         true ->
diff --git a/apps/block_scout_web/lib/block_scout_web/views/page_not_found.ex b/apps/block_scout_web/lib/block_scout_web/views/page_not_found.ex
index b5a18f0434d7..aae83b2804ca 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/page_not_found.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/page_not_found.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.PageNotFoundView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/pending_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/pending_transaction_view.ex
index 12ba2fe742ee..a69579127829 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/pending_transaction_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/pending_transaction_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.PendingTransactionView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/render_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/render_helper.ex
index b367dc7d6538..cdca3600c19d 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/render_helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/render_helper.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.RenderHelper do
   @moduledoc """
   Helper functions to render partials from view modules
diff --git a/apps/block_scout_web/lib/block_scout_web/views/robots_view.ex b/apps/block_scout_web/lib/block_scout_web/views/robots_view.ex
index 628ed672e019..415691f1d2dd 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/robots_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/robots_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.RobotsView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/script_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/script_helper.ex
index f3d0c6cc43e8..3a792dfda753 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/script_helper.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/script_helper.ex
@@ -1,9 +1,10 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.Views.ScriptHelper do
   @moduledoc """
   Helper for rendering view specific script tags.
   """
+  use Phoenix.LiveView
 
-  import Phoenix.LiveView.Helpers, only: [sigil_H: 2]
   import BlockScoutWeb.Router.Helpers, only: [static_path: 2]
 
   alias Phoenix.HTML.Safe
diff --git a/apps/block_scout_web/lib/block_scout_web/views/search_view.ex b/apps/block_scout_web/lib/block_scout_web/views/search_view.ex
index 51bf1b856dea..24739005d98e 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/search_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/search_view.ex
@@ -1,3 +1,4 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.SearchView do
   use BlockScoutWeb, :view
 
diff --git a/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex
index eaef178ab610..c2f4d4318724 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/smart_contract_view.ex
@@ -1,10 +1,10 @@
+# SPDX-License-Identifier: LicenseRef-Blockscout
 defmodule BlockScoutWeb.SmartContractView do
   use BlockScoutWeb, :view
 
   import Explorer.SmartContract.Reader, only: [zip_tuple_values_with_types: 2]
 
   alias Explorer.Chain
-  alias Explorer.Helper, as: ExplorerHelper
   alias Explorer.Chain.{Address, Transaction}
   alias Explorer.Chain.Hash.Address, as: HashAddress
   alias Explorer.Chain.SmartContract
@@ -110,22 +110,22 @@ defmodule BlockScoutWeb.SmartContractView do
   end
 
   def values_with_type(value, string, names, index, _components) when string in ["string", :string],
-    do: render_type_value("string", Helper.sanitize_input(value), fetch_name(names, index))
+    do: render_type_value("string", Helper.escape_minimal(value), fetch_name(names, index))
 
   def values_with_type(value, "bytes" <> _ = bytes_type, names, index, _components),
-    do: render_type_value(bytes_type, Helper.sanitize_input(value), fetch_name(names, index))
+    do: render_type_value(bytes_type, Helper.escape_minimal(value), fetch_name(names, index))
 
   def values_with_type(value, bytes, names, index, _components) when bytes in [:bytes],
-    do: render_type_value("bytes", Helper.sanitize_input(value), fetch_name(names, index))
+    do: render_type_value("bytes", Helper.escape_minimal(value), fetch_name(names, index))
 
   def values_with_type(value, bool, names, index, _components) when bool in ["bool", :bool],
-    do: render_type_value("bool", Helper.sanitize_input(to_string(value)), fetch_name(names, index))
+    do: render_type_value("bool", Helper.escape_minimal(to_string(value)), fetch_name(names, index))
 
   def values_with_type(value, type, names, index, _components),
-    do: render_type_value(type, Helper.sanitize_input(value), fetch_name(names, index))
+    do: render_type_value(type, Helper.escape_minimal(value), fetch_name(names, index))
 
   def values_with_type(value, :error, _components),
-    do: render_type_value("error", Helper.sanitize_input(value), "error")
+    do: render_type_value("error", Helper.escape_minimal(value), "error")
 
   def cast_address(value) do
     case HashAddress.cast(value) do
@@ -166,26 +166,12 @@ defmodule BlockScoutWeb.SmartContractView do
     end)
   end
 
-  def binary_to_utf_string(item) do
-    case Integer.parse(to_string(item)) do
-      {item_integer, ""} ->
-        to_string(item_integer)
-
-      _ ->
-        if is_binary(item) do
-          ExplorerHelper.add_0x_prefix(item)
-        else
-          to_string(item)
-        end
-    end
-  end
-
   defp render_type_value(type, value, type) do
-    "
(#{Helper.sanitize_input(type)}) : #{value}
" + "
(#{Helper.escape_minimal(type)}) : #{value}
" end defp render_type_value(type, value, name) do - "
#{Helper.sanitize_input(name)} (#{Helper.sanitize_input(type)}) : #{value}
" + "
#{Helper.escape_minimal(name)} (#{Helper.escape_minimal(type)}) : #{value}
" end defp render_array_type_value(type, values, name) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/tab_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/tab_helper.ex index 8e2ec70997b2..bab7481e9676 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tab_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tab_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TabHelper do @moduledoc """ Helper functions for dealing with tabs, which are very common between pages. diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/contract_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/contract_view.ex index 0b90df1ba981..cbca931001af 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/contract_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/contract_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.ContractView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex index b8757f444499..58d5d260b472 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Helper do @moduledoc """ Helper functions for interacting with `t:BlockScoutWeb.Chain.Token` attributes. @@ -9,7 +10,7 @@ defmodule BlockScoutWeb.Tokens.Helper do @doc """ Returns the token transfers' amount according to the token's type and decimals. - When the token's type is ERC-20, then we are going to format the amount according to the token's + When the token's type is ERC-20 or ZRC-2, then we are going to format the amount according to the token's decimals considering 0 when the decimals is nil. Case the amount is nil, this function will return the symbol `--`. @@ -58,122 +59,65 @@ defmodule BlockScoutWeb.Tokens.Helper do end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount(%Token{type: "ERC-721"}, nil, _amount, _amounts, _token_ids) do - {:ok, :erc721_instance} + defp do_token_transfer_amount(%Token{type: "ZRC-2"}, nil, nil, nil, _token_ids) do + {:ok, "--"} end - defp do_token_transfer_amount(_token, "ERC-721", _amount, _amounts, _token_ids) do - {:ok, :erc721_instance} + defp do_token_transfer_amount(_token, "ZRC-2", nil, nil, _token_ids) do + {:ok, "--"} end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount(%Token{type: type, decimals: decimals}, nil, amount, amounts, token_ids) - when type in ["ERC-1155", "ERC-404"] do - if amount do - {:ok, :erc1155_erc404_instance, CurrencyHelper.format_according_to_decimals(amount, decimals)} - else - {:ok, :erc1155_erc404_instance, amounts, token_ids, decimals} - end - end - - defp do_token_transfer_amount(%Token{decimals: decimals}, type, amount, amounts, token_ids) - when type in ["ERC-1155", "ERC-404"] do - if amount do - {:ok, :erc1155_erc404_instance, CurrencyHelper.format_according_to_decimals(amount, decimals)} - else - {:ok, :erc1155_erc404_instance, amounts, token_ids, decimals} - end - end - - defp do_token_transfer_amount(_token, _token_type, _amount, _amounts, _token_ids) do - nil - end - - def token_transfer_amount_for_api(%{ - token: token, - token_type: token_type, - amount: amount, - amounts: amounts, - token_ids: token_ids - }) do - do_token_transfer_amount_for_api(token, token_type, amount, amounts, token_ids) + defp do_token_transfer_amount(%Token{type: "ZRC-2", decimals: nil}, nil, amount, _amounts, _token_ids) do + {:ok, CurrencyHelper.format_according_to_decimals(amount, Decimal.new(0))} end - def token_transfer_amount_for_api(%{token: token, token_type: token_type, amount: amount, token_ids: token_ids}) do - do_token_transfer_amount_for_api(token, token_type, amount, nil, token_ids) + defp do_token_transfer_amount(%Token{decimals: nil}, "ZRC-2", amount, _amounts, _token_ids) do + {:ok, CurrencyHelper.format_according_to_decimals(amount, Decimal.new(0))} end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount_for_api(%Token{type: "ERC-20"}, nil, nil, nil, _token_ids) do - {:ok, nil} - end - - defp do_token_transfer_amount_for_api(_token, "ERC-20", nil, nil, _token_ids) do - {:ok, nil} + defp do_token_transfer_amount(%Token{type: "ZRC-2", decimals: decimals}, nil, amount, _amounts, _token_ids) do + {:ok, CurrencyHelper.format_according_to_decimals(amount, decimals)} end - # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount_for_api( - %Token{type: "ERC-20", decimals: decimals}, - nil, - amount, - _amounts, - _token_ids - ) do - {:ok, amount, decimals} - end - - defp do_token_transfer_amount_for_api( - %Token{decimals: decimals}, - "ERC-20", - amount, - _amounts, - _token_ids - ) do - {:ok, amount, decimals} + defp do_token_transfer_amount(%Token{decimals: decimals}, "ZRC-2", amount, _amounts, _token_ids) do + {:ok, CurrencyHelper.format_according_to_decimals(amount, decimals)} end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount_for_api(%Token{type: "ERC-721"}, nil, _amount, _amounts, _token_ids) do + defp do_token_transfer_amount(%Token{type: "ERC-721"}, nil, _amount, _amounts, _token_ids) do {:ok, :erc721_instance} end - defp do_token_transfer_amount_for_api(_token, "ERC-721", _amount, _amounts, _token_ids) do + defp do_token_transfer_amount(_token, "ERC-721", _amount, _amounts, _token_ids) do {:ok, :erc721_instance} end # TODO: remove this clause along with token transfer denormalization - defp do_token_transfer_amount_for_api( - %Token{type: type, decimals: decimals}, - nil, - amount, - amounts, - token_ids - ) + defp do_token_transfer_amount(%Token{type: type, decimals: decimals}, nil, amount, amounts, token_ids) when type in ["ERC-1155", "ERC-404"] do if amount do - {:ok, :erc1155_erc404_instance, amount, decimals} + {:ok, :erc1155_erc404_instance, CurrencyHelper.format_according_to_decimals(amount, decimals)} else {:ok, :erc1155_erc404_instance, amounts, token_ids, decimals} end end - defp do_token_transfer_amount_for_api( - %Token{decimals: decimals}, - type, - amount, - amounts, - token_ids - ) + defp do_token_transfer_amount(%Token{decimals: decimals}, type, amount, amounts, token_ids) when type in ["ERC-1155", "ERC-404"] do if amount do - {:ok, :erc1155_erc404_instance, amount, decimals} + {:ok, :erc1155_erc404_instance, CurrencyHelper.format_according_to_decimals(amount, decimals)} else {:ok, :erc1155_erc404_instance, amounts, token_ids, decimals} end end - defp do_token_transfer_amount_for_api(_token, _token_type, _amount, _amounts, _token_ids) do + defp do_token_transfer_amount(%Token{decimals: _decimals}, "ERC-7984", _amount, _amounts, _token_ids) do + {:ok, "*confidential*"} + end + + defp do_token_transfer_amount(_token, _token_type, _amount, _amounts, _token_ids) do nil end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex index e916a69fe2b7..cd49ff8c9ae9 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/holder_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.HolderView do use BlockScoutWeb, :view @@ -66,6 +67,10 @@ defmodule BlockScoutWeb.Tokens.HolderView do format_according_to_decimals(value, decimals) end + def format_token_balance_value(value, _id, %Token{type: "ZRC-2", decimals: decimals}) do + format_according_to_decimals(value, decimals) + end + def format_token_balance_value(value, id, %Token{type: "ERC-1155", decimals: decimals}) do to_string(format_according_to_decimals(value, decimals)) <> " TokenID " <> to_string(id) end @@ -80,6 +85,10 @@ defmodule BlockScoutWeb.Tokens.HolderView do end end + def format_token_balance_value(_value, _id, %Token{type: "ERC-7984"}) do + "*confidential*" + end + def format_token_balance_value(value, _id, _token) do value end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/holder_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/holder_view.ex index 38cf207bc435..6833c3a463ff 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/holder_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/holder_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.HolderView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex index 758e6a3bd276..142e1ececca3 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/metadata_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.MetadataView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/overview_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/overview_view.ex index 717353e05c68..2bea7a3dfe94 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/overview_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/overview_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.OverviewView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/transfer_view.ex index 2cf314efa170..b60c45bd46e4 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/transfer_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance/transfer_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.TransferView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance_view.ex index c18c5b5e1f5e..05c7c4fc9949 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/instance_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/instance_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.InstanceView do use BlockScoutWeb, :view end diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex index 547d6dd33a31..eb68ea82b466 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.InventoryView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex index d9dbe6e265de..104797f47992 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.OverviewView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex index 3ea5d8484010..e8e177df86d9 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/transfer_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.TransferView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens_view.ex index 704d7a94f731..d529ea918029 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/tokens_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/tokens_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TokensView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_internal_transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_internal_transaction_view.ex index 74ff6042cce7..64e67c1e0e30 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_internal_transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_internal_transaction_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionInternalTransactionView do use BlockScoutWeb, :view @dialyzer :no_match diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_log_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_log_view.ex index 1a7357a82e9a..59b977d2f8fb 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_log_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_log_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionLogView do use BlockScoutWeb, :view @dialyzer :no_match diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex index 09fd46168b29..46944d75304b 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_raw_trace_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionRawTraceView do use BlockScoutWeb, :view @dialyzer :no_match diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex index 88fc2098312b..2c6db6e6d91d 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionStateView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_token_transfer_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_token_transfer_view.ex index 3e31c0f2c351..b58cc38a99d0 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_token_transfer_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_token_transfer_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionTokenTransferView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex index f5dcb2c6a717..158186b8f003 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionView do use BlockScoutWeb, :view @@ -5,8 +6,8 @@ defmodule BlockScoutWeb.TransactionView do alias BlockScoutWeb.Account.AuthController alias BlockScoutWeb.Cldr.Number alias Explorer.{Chain, CustomContractsHelper, Repo} - alias Explorer.Chain.Block.Reward alias Explorer.Chain.{Address, Block, InternalTransaction, Transaction, Wei} + alias Explorer.Chain.Block.Reward alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Market.Token alias Timex.Duration @@ -64,10 +65,6 @@ defmodule BlockScoutWeb.TransactionView do if type, do: {type, transaction_with_transfers_filtered}, else: {nil, transaction_with_transfers_filtered} end - def transaction_actions(transaction) do - Repo.preload(transaction, :transaction_actions) - end - def aggregate_token_transfers(token_transfers) do %{ transfers: {ft_transfers, nft_transfers}, @@ -224,6 +221,8 @@ defmodule BlockScoutWeb.TransactionView do :erc721 -> gettext("ERC-721 ") :erc1155 -> gettext("ERC-1155 ") :erc404 -> gettext("ERC-404 ") + :zrc2 -> gettext("ZRC-2 ") + :erc7984 -> gettext("ERC-7984 ") _ -> "" end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/verified_contracts_view.ex b/apps/block_scout_web/lib/block_scout_web/views/verified_contracts_view.ex index 50934a2afe05..b414fec4b910 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/verified_contracts_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/verified_contracts_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.VerifiedContractsView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/block_scout_web/views/visualize_sol2uml_view.ex b/apps/block_scout_web/lib/block_scout_web/views/visualize_sol2uml_view.ex index 827deeeecdec..c729a3dd10c2 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/visualize_sol2uml_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/visualize_sol2uml_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.VisualizeSol2umlView do use BlockScoutWeb, :view end diff --git a/apps/block_scout_web/lib/block_scout_web/views/wei_helper.ex b/apps/block_scout_web/lib/block_scout_web/views/wei_helper.ex index 188303e0c9eb..6a61cf53b40a 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/wei_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/wei_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.WeiHelper do @moduledoc """ Helper functions for interacting with `t:Explorer.Chain.Wei.t/0` values. diff --git a/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex b/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex index 296304d622ab..63abf8c9dbe1 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/withdrawal_view.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.WithdrawalView do use BlockScoutWeb, :view diff --git a/apps/block_scout_web/lib/phoenix/html/safe.ex b/apps/block_scout_web/lib/phoenix/html/safe.ex index cddc3d5e1eca..cf26cc2004c8 100644 --- a/apps/block_scout_web/lib/phoenix/html/safe.ex +++ b/apps/block_scout_web/lib/phoenix/html/safe.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout alias Explorer.Chain alias Explorer.Chain.{Address, Block, Data, Hash, Transaction} diff --git a/apps/block_scout_web/lib/phoenix/param.ex b/apps/block_scout_web/lib/phoenix/param.ex index f9c138e4f9e1..82ee5c28193e 100644 --- a/apps/block_scout_web/lib/phoenix/param.ex +++ b/apps/block_scout_web/lib/phoenix/param.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout alias Explorer.Chain.{Address, Block, Hash, Transaction} defimpl Phoenix.Param, for: Transaction do diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index c43b97ea1c67..9823a3a25590 100644 --- a/apps/block_scout_web/mix.exs +++ b/apps/block_scout_web/mix.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Mixfile do use Mix.Project @@ -14,19 +15,14 @@ defmodule BlockScoutWeb.Mixfile do plt_add_deps: :app_tree, ignore_warnings: "../../.dialyzer_ignore.exs" ], - elixir: "~> 1.17", + elixir: "~> 1.19", elixirc_paths: elixirc_paths(Mix.env(), Application.get_env(:block_scout_web, :disable_api?)), lockfile: "../../mix.lock", package: package(), - preferred_cli_env: [ - credo: :test, - dialyzer: :test - ], start_permanent: Mix.env() == :prod, - version: "8.0.2", + version: "11.2.2", xref: [ exclude: [ - Explorer.Chain.PolygonZkevm.Reader, Explorer.Chain.Beacon.Reader, Explorer.Chain.Cache.OptimismFinalizationPeriod, Explorer.Chain.Optimism.OutputRoot, @@ -48,8 +44,13 @@ defmodule BlockScoutWeb.Mixfile do ] end + def cli do + [preferred_envs: [credo: :test, dialyzer: :test]] + end + # Specifies which paths to compile per environment. - defp elixirc_paths(:test, _), do: ["test/support", "test/block_scout_web/features/pages"] ++ elixirc_paths() + defp elixirc_paths(:test, _), + do: ["test/support", "test/block_scout_web/features/pages", "benchmarks/support"] ++ elixirc_paths() defp elixirc_paths(_, true), do: [ @@ -85,6 +86,7 @@ defmodule BlockScoutWeb.Mixfile do {:absinthe_plug, git: "https://github.com/blockscout/absinthe_plug.git", tag: "1.5.8", override: true}, # Absinthe support for the Relay framework {:absinthe_relay, "~> 1.5"}, + {:benchee, "~> 1.5.0", only: :test}, {:bypass, "~> 2.1", only: :test}, # To add (CORS)(https://www.w3.org/TR/cors/) {:cors_plug, "~> 3.0"}, @@ -107,7 +109,8 @@ defmodule BlockScoutWeb.Mixfile do {:floki, "~> 0.31"}, {:flow, "~> 1.2"}, {:gettext, "~> 0.26.1"}, - {:hammer, "~> 6.0"}, + {:hammer, "~> 7.0"}, + {:hammer_backend_redis, "~> 7.0"}, {:httpoison, "~> 2.0"}, {:indexer, in_umbrella: true, runtime: false}, # JSON parser and generator @@ -115,26 +118,25 @@ defmodule BlockScoutWeb.Mixfile do {:junit_formatter, ">= 0.0.0", only: [:test], runtime: false}, # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, + {:logger_json, "~> 7.0"}, {:math, "~> 0.7.0"}, {:mock, "~> 0.3.0", only: [:test], runtime: false}, {:number, "~> 1.0.1"}, - {:phoenix, "== 1.5.14"}, + {:phoenix, "== 1.6.16"}, {:phoenix_ecto, "~> 4.1"}, - {:phoenix_html, "== 3.3.4"}, - {:phoenix_live_reload, "~> 1.2", only: [:dev]}, - {:phoenix_live_view, "~> 0.17"}, + {:phoenix_html, "== 4.2.1"}, + {:phoenix_html_helpers, "~> 1.0"}, + {:phoenix_live_reload, "~> 1.6", only: [:dev]}, + {:phoenix_live_view, "~> 1.1"}, {:phoenix_pubsub, "~> 2.0"}, - {:prometheus_ex, git: "https://github.com/lanodan/prometheus.ex", branch: "fix/elixir-1.14", override: true}, + {:prometheus_ex, "~> 5.1.0", override: true}, # use `:cowboy` for WebServer with `:plug` {:plug_cowboy, "~> 2.2"}, # Waiting for the Pretty Print to be implemented at the Jason lib # https://github.com/michalmuskala/jason/issues/15 - {:poison, "~> 4.0.1"}, + {:poison, "~> 5.0.0"}, {:postgrex, ">= 0.0.0"}, - # For compatibility with `prometheus_process_collector`, which hasn't been updated yet - {:prometheus, "~> 4.0", override: true}, - # Gather methods for Phoenix requests - {:prometheus_phoenix, "~> 1.2"}, + {:prometheus, "~> 6.0", override: true}, # Expose metrics from URL Prometheus server can scrape {:prometheus_plugs, "~> 1.1"}, # OS process metrics for Prometheus, custom ref to include https://github.com/deadtrickster/prometheus_process_collector/pull/30 @@ -154,12 +156,13 @@ defmodule BlockScoutWeb.Mixfile do {:timex, "~> 3.7.1"}, {:wallaby, "~> 0.30", only: :test, runtime: false}, # `:cowboy` `~> 2.0` and Phoenix 1.4 compatibility - {:ex_json_schema, "~> 0.10.1"}, + {:ex_json_schema, "~> 0.11.0"}, {:ueberauth, "~> 0.7"}, {:ueberauth_auth0, "~> 2.0"}, {:utils, in_umbrella: true}, {:bureaucrat, "~> 0.2.9", only: :test}, - {:logger_json, "~> 5.1"} + {:open_api_spex, "~> 3.21"}, + {:ymlr, "~> 5.1"} ] end @@ -187,7 +190,7 @@ defmodule BlockScoutWeb.Mixfile do defp package do [ maintainers: ["Blockscout"], - licenses: ["GPL 3.0"], + licenses: ["Blockscout Software Licence"], links: %{"GitHub" => "https://github.com/blockscout/blockscout"} ] end diff --git a/apps/block_scout_web/priv/gettext/default.pot b/apps/block_scout_web/priv/gettext/default.pot index 7d24722e1e1e..67fde89acea4 100644 --- a/apps/block_scout_web/priv/gettext/default.pot +++ b/apps/block_scout_web/priv/gettext/default.pot @@ -86,7 +86,7 @@ msgstr "" msgid "%{withdrawals_count} withdrawals processed and %{withdrawals_sum} withdrawn." msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:375 +#: lib/block_scout_web/views/transaction_view.ex:373 #, elixir-autogen, elixir-format msgid "(Awaiting internal transactions for status)" msgstr "" @@ -217,12 +217,12 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:484 +#: lib/block_scout_web/templates/transaction/overview.html.eex:461 #, elixir-autogen, elixir-format msgid "Actual gas amount used by the transaction on L2." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:488 +#: lib/block_scout_web/templates/transaction/overview.html.eex:465 #, elixir-autogen, elixir-format msgid "Actual gas amount used by the transaction." msgstr "" @@ -288,17 +288,17 @@ msgstr "" #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:29 #: lib/block_scout_web/templates/transaction_state/index.html.eex:34 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:60 -#: lib/block_scout_web/views/address_view.ex:109 +#: lib/block_scout_web/views/address_view.ex:110 #, elixir-autogen, elixir-format msgid "Address" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:263 +#: lib/block_scout_web/templates/transaction/overview.html.eex:240 #, elixir-autogen, elixir-format msgid "Address (external or contract) receiving the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:245 +#: lib/block_scout_web/templates/transaction/overview.html.eex:222 #, elixir-autogen, elixir-format msgid "Address (external or contract) sending the transaction." msgstr "" @@ -325,11 +325,6 @@ msgstr "" msgid "Address used in token mintings and burnings." msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex:2 -#, elixir-autogen, elixir-format -msgid "Address*" -msgstr "" - #: lib/block_scout_web/templates/address/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Addresses" @@ -376,7 +371,7 @@ msgstr "" msgid "Amount" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:469 +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "Amount of" msgstr "" @@ -406,7 +401,7 @@ msgstr "" msgid "Average" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:102 +#: lib/block_scout_web/templates/chain/show.html.eex:104 #, elixir-autogen, elixir-format msgid "Average block time" msgstr "" @@ -488,7 +483,7 @@ msgstr "" msgid "Beacon chain, Withdrawals, %{subnetwork}, %{coin}" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:545 +#: lib/block_scout_web/templates/transaction/overview.html.eex:522 #, elixir-autogen, elixir-format msgid "Binary data included with the transaction. See input / logs below for additional info." msgstr "" @@ -539,7 +534,7 @@ msgstr "" msgid "Block difficulty for miner, used to calibrate block generation time (Note: constant in POA based networks)." msgstr "" -#: lib/block_scout_web/views/block_transaction_view.ex:15 +#: lib/block_scout_web/views/block_transaction_view.ex:19 #, elixir-autogen, elixir-format msgid "Block not found, please try again later." msgstr "" @@ -569,7 +564,7 @@ msgstr "" msgid "Blockchain" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:156 +#: lib/block_scout_web/templates/chain/show.html.eex:158 #: lib/block_scout_web/templates/layout/_topnav.html.eex:34 #: lib/block_scout_web/templates/layout/_topnav.html.eex:38 #, elixir-autogen, elixir-format @@ -584,7 +579,7 @@ msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:56 #: lib/block_scout_web/templates/address/overview.html.eex:277 #: lib/block_scout_web/templates/address_validation/index.html.eex:11 -#: lib/block_scout_web/views/address_view.ex:356 +#: lib/block_scout_web/views/address_view.ex:362 #, elixir-autogen, elixir-format msgid "Blocks Validated" msgstr "" @@ -621,21 +616,20 @@ msgid "CSV" msgstr "" #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 -#: lib/block_scout_web/views/internal_transaction_view.ex:21 +#: lib/block_scout_web/views/internal_transaction_view.ex:23 #, elixir-autogen, elixir-format msgid "Call" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:22 +#: lib/block_scout_web/views/internal_transaction_view.ex:24 #, elixir-autogen, elixir-format msgid "Call Code" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:62 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:120 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:115 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:41 -#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:107 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:105 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:55 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:51 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:47 @@ -684,13 +678,13 @@ msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:187 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:126 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:149 -#: lib/block_scout_web/views/address_view.ex:350 +#: lib/block_scout_web/views/address_view.ex:356 #, elixir-autogen, elixir-format msgid "Code" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:42 -#: lib/block_scout_web/views/address_view.ex:355 +#: lib/block_scout_web/views/address_view.ex:361 #, elixir-autogen, elixir-format msgid "Coin Balance History" msgstr "" @@ -700,16 +694,6 @@ msgstr "" msgid "Collapse" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:20 -#, elixir-autogen, elixir-format -msgid "Company name" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:32 -#, elixir-autogen, elixir-format -msgid "Company website" -msgstr "" - #: lib/block_scout_web/templates/address_contract_verification_common_fields/_compiler_field.html.eex:3 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:69 #, elixir-autogen, elixir-format @@ -726,7 +710,7 @@ msgstr "" msgid "Compiler version" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:368 +#: lib/block_scout_web/views/transaction_view.ex:366 #, elixir-autogen, elixir-format msgid "Confirmed" msgstr "" @@ -786,7 +770,7 @@ msgid "Constructor args" msgstr "" #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:52 -#: lib/block_scout_web/templates/transaction/overview.html.eex:273 +#: lib/block_scout_web/templates/transaction/overview.html.eex:250 #, elixir-autogen, elixir-format msgid "Contract" msgstr "" @@ -799,24 +783,24 @@ msgstr "" #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:29 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_address_field.html.eex:3 -#: lib/block_scout_web/views/address_view.ex:107 +#: lib/block_scout_web/views/address_view.ex:108 #, elixir-autogen, elixir-format msgid "Contract Address" msgstr "" #: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:16 -#: lib/block_scout_web/views/address_view.ex:47 -#: lib/block_scout_web/views/address_view.ex:81 +#: lib/block_scout_web/views/address_view.ex:48 +#: lib/block_scout_web/views/address_view.ex:82 #, elixir-autogen, elixir-format msgid "Contract Address Pending" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:497 +#: lib/block_scout_web/views/transaction_view.ex:495 #, elixir-autogen, elixir-format msgid "Contract Call" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:494 +#: lib/block_scout_web/views/transaction_view.ex:492 #, elixir-autogen, elixir-format msgid "Contract Creation" msgstr "" @@ -932,8 +916,8 @@ msgstr "" #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:17 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:18 -#: lib/block_scout_web/templates/transaction/overview.html.eex:253 -#: lib/block_scout_web/templates/transaction/overview.html.eex:254 +#: lib/block_scout_web/templates/transaction/overview.html.eex:230 +#: lib/block_scout_web/templates/transaction/overview.html.eex:231 #, elixir-autogen, elixir-format msgid "Copy From Address" msgstr "" @@ -968,10 +952,10 @@ msgstr "" #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:34 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:35 -#: lib/block_scout_web/templates/transaction/overview.html.eex:280 -#: lib/block_scout_web/templates/transaction/overview.html.eex:281 -#: lib/block_scout_web/templates/transaction/overview.html.eex:288 -#: lib/block_scout_web/templates/transaction/overview.html.eex:289 +#: lib/block_scout_web/templates/transaction/overview.html.eex:257 +#: lib/block_scout_web/templates/transaction/overview.html.eex:258 +#: lib/block_scout_web/templates/transaction/overview.html.eex:265 +#: lib/block_scout_web/templates/transaction/overview.html.eex:266 #, elixir-autogen, elixir-format msgid "Copy To Address" msgstr "" @@ -992,26 +976,26 @@ msgstr "" msgid "Copy Txn Hash" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:571 +#: lib/block_scout_web/templates/transaction/overview.html.eex:548 #, elixir-autogen, elixir-format msgid "Copy Txn Hex Input" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:577 +#: lib/block_scout_web/templates/transaction/overview.html.eex:554 #, elixir-autogen, elixir-format msgid "Copy Txn UTF-8 Input" msgstr "" #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:20 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:41 -#: lib/block_scout_web/templates/transaction/overview.html.eex:570 -#: lib/block_scout_web/templates/transaction/overview.html.eex:576 +#: lib/block_scout_web/templates/transaction/overview.html.eex:547 +#: lib/block_scout_web/templates/transaction/overview.html.eex:553 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:8 #, elixir-autogen, elixir-format msgid "Copy Value" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:26 +#: lib/block_scout_web/views/internal_transaction_view.ex:31 #, elixir-autogen, elixir-format msgid "Create" msgstr "" @@ -1026,7 +1010,7 @@ msgstr "" msgid "Create an API key to use with your RPC and EthRPC API requests." msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:27 +#: lib/block_scout_web/views/internal_transaction_view.ex:32 #, elixir-autogen, elixir-format msgid "Create2" msgstr "" @@ -1067,7 +1051,7 @@ msgstr "" msgid "Custom ABI from account" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:70 +#: lib/block_scout_web/templates/chain/show.html.eex:72 #, elixir-autogen, elixir-format msgid "Daily Transactions" msgstr "" @@ -1107,7 +1091,7 @@ msgstr "" msgid "Decoded" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:23 +#: lib/block_scout_web/views/internal_transaction_view.ex:25 #, elixir-autogen, elixir-format msgid "Delegate Call" msgstr "" @@ -1126,11 +1110,6 @@ msgstr "" msgid "Description" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:56 -#, elixir-autogen, elixir-format -msgid "Description*" -msgstr "" - #: lib/block_scout_web/templates/address/overview.html.eex:30 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:166 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:127 @@ -1179,22 +1158,17 @@ msgstr "" msgid "Drop the standard input JSON file or click here" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:27 -#, elixir-autogen, elixir-format -msgid "E-mail*" -msgstr "" - #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:6 #, elixir-autogen, elixir-format msgid "EIP-1167" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:225 +#: lib/block_scout_web/views/transaction_view.ex:221 #, elixir-autogen, elixir-format msgid "ERC-1155 " msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:223 +#: lib/block_scout_web/views/transaction_view.ex:219 #, elixir-autogen, elixir-format msgid "ERC-20 " msgstr "" @@ -1204,12 +1178,12 @@ msgstr "" msgid "ERC-20 tokens (beta)" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:226 +#: lib/block_scout_web/views/transaction_view.ex:222 #, elixir-autogen, elixir-format msgid "ERC-404 " msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:224 +#: lib/block_scout_web/views/transaction_view.ex:220 #, elixir-autogen, elixir-format msgid "ERC-721 " msgstr "" @@ -1238,6 +1212,7 @@ msgid "EVM version details" msgstr "" #: lib/block_scout_web/views/block_transaction_view.ex:7 +#: lib/block_scout_web/views/block_transaction_view.ex:11 #, elixir-autogen, elixir-format msgid "Easy Cowboy! This block does not exist yet!" msgstr "" @@ -1300,12 +1275,12 @@ msgstr "" msgid "Error trying to fetch balances." msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:379 +#: lib/block_scout_web/views/transaction_view.ex:377 #, elixir-autogen, elixir-format msgid "Error: %{reason}" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:377 +#: lib/block_scout_web/views/transaction_view.ex:375 #, elixir-autogen, elixir-format msgid "Error: (Awaiting internal transactions for reason)" msgstr "" @@ -1424,7 +1399,7 @@ msgstr "" #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:38 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:40 #: lib/block_scout_web/templates/address_transaction/index.html.eex:34 -#: lib/block_scout_web/templates/transaction/overview.html.eex:246 +#: lib/block_scout_web/templates/transaction/overview.html.eex:223 #: lib/block_scout_web/views/address_internal_transaction_view.ex:11 #: lib/block_scout_web/views/address_token_transfer_view.ex:11 #: lib/block_scout_web/views/address_transaction_view.ex:11 @@ -1439,12 +1414,12 @@ msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:67 #: lib/block_scout_web/templates/block/overview.html.eex:189 -#: lib/block_scout_web/templates/transaction/overview.html.eex:430 +#: lib/block_scout_web/templates/transaction/overview.html.eex:407 #, elixir-autogen, elixir-format msgid "Gas Limit" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:404 +#: lib/block_scout_web/templates/transaction/overview.html.eex:381 #, elixir-autogen, elixir-format msgid "Gas Price" msgstr "" @@ -1456,7 +1431,7 @@ msgstr "" msgid "Gas Used" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:466 #, elixir-autogen, elixir-format msgid "Gas Used by Transaction" msgstr "" @@ -1508,17 +1483,12 @@ msgstr "" msgid "Hash" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:553 -#: lib/block_scout_web/templates/transaction/overview.html.eex:557 +#: lib/block_scout_web/templates/transaction/overview.html.eex:530 +#: lib/block_scout_web/templates/transaction/overview.html.eex:534 #, elixir-autogen, elixir-format msgid "Hex (Default)" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:224 -#, elixir-autogen, elixir-format -msgid "Highlighted events of the transaction." -msgstr "" - #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:108 #, elixir-autogen, elixir-format msgid "Holders" @@ -1580,7 +1550,7 @@ msgstr "" msgid "Index" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:537 +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Index position of Transaction in the block." msgstr "" @@ -1600,7 +1570,7 @@ msgstr "" msgid "Input" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:265 +#: lib/block_scout_web/templates/transaction/overview.html.eex:242 #, elixir-autogen, elixir-format msgid "Interacted With (To)" msgstr "" @@ -1614,8 +1584,8 @@ msgstr "" #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:17 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:11 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:6 -#: lib/block_scout_web/views/address_view.ex:347 -#: lib/block_scout_web/views/transaction_view.ex:552 +#: lib/block_scout_web/views/address_view.ex:353 +#: lib/block_scout_web/views/transaction_view.ex:550 #, elixir-autogen, elixir-format msgid "Internal Transactions" msgstr "" @@ -1625,7 +1595,7 @@ msgstr "" msgid "Internal server error" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:25 +#: lib/block_scout_web/views/internal_transaction_view.ex:27 #, elixir-autogen, elixir-format msgid "Invalid" msgstr "" @@ -1647,35 +1617,35 @@ msgstr "" msgid "L1 Block" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:523 -#: lib/block_scout_web/templates/transaction/overview.html.eex:524 +#: lib/block_scout_web/templates/transaction/overview.html.eex:500 +#: lib/block_scout_web/templates/transaction/overview.html.eex:501 #, elixir-autogen, elixir-format msgid "L1 Fee Scalar" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:512 -#: lib/block_scout_web/templates/transaction/overview.html.eex:513 +#: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:490 #, elixir-autogen, elixir-format msgid "L1 Gas Price" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:501 -#: lib/block_scout_web/templates/transaction/overview.html.eex:502 +#: lib/block_scout_web/templates/transaction/overview.html.eex:478 +#: lib/block_scout_web/templates/transaction/overview.html.eex:479 #, elixir-autogen, elixir-format msgid "L1 Gas Used by Transaction" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:426 +#: lib/block_scout_web/templates/transaction/overview.html.eex:403 #, elixir-autogen, elixir-format msgid "L2 Gas Limit" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:400 +#: lib/block_scout_web/templates/transaction/overview.html.eex:377 #, elixir-autogen, elixir-format msgid "L2 Gas Price" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:485 +#: lib/block_scout_web/templates/transaction/overview.html.eex:462 #, elixir-autogen, elixir-format msgid "L2 Gas Used by Transaction" msgstr "" @@ -1718,22 +1688,22 @@ msgstr "" msgid "License ID" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:351 +#: lib/block_scout_web/templates/transaction/overview.html.eex:328 #, elixir-autogen, elixir-format msgid "List of ERC-1155 tokens created in the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:335 +#: lib/block_scout_web/templates/transaction/overview.html.eex:312 #, elixir-autogen, elixir-format msgid "List of token burnt in the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:318 +#: lib/block_scout_web/templates/transaction/overview.html.eex:295 #, elixir-autogen, elixir-format msgid "List of token minted in the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:302 +#: lib/block_scout_web/templates/transaction/overview.html.eex:279 #, elixir-autogen, elixir-format msgid "List of token transferred in the transaction." msgstr "" @@ -1774,8 +1744,8 @@ msgstr "" #: lib/block_scout_web/templates/address_logs/index.html.eex:10 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:17 #: lib/block_scout_web/templates/transaction_log/index.html.eex:8 -#: lib/block_scout_web/views/address_view.ex:357 -#: lib/block_scout_web/views/transaction_view.ex:553 +#: lib/block_scout_web/views/address_view.ex:363 +#: lib/block_scout_web/views/transaction_view.ex:551 #, elixir-autogen, elixir-format msgid "Logs" msgstr "" @@ -1785,10 +1755,10 @@ msgstr "" msgid "Main Networks" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:53 +#: lib/block_scout_web/templates/chain/show.html.eex:55 #: lib/block_scout_web/templates/layout/app.html.eex:50 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:85 -#: lib/block_scout_web/views/address_view.ex:147 +#: lib/block_scout_web/views/address_view.ex:148 #, elixir-autogen, elixir-format msgid "Market Cap" msgstr "" @@ -1798,32 +1768,32 @@ msgstr "" msgid "Market cap" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:440 +#: lib/block_scout_web/templates/transaction/overview.html.eex:417 #, elixir-autogen, elixir-format msgid "Max Fee per Gas" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:450 +#: lib/block_scout_web/templates/transaction/overview.html.eex:427 #, elixir-autogen, elixir-format msgid "Max Priority Fee per Gas" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:331 +#: lib/block_scout_web/views/transaction_view.ex:329 #, elixir-autogen, elixir-format msgid "Max of" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:425 +#: lib/block_scout_web/templates/transaction/overview.html.eex:402 #, elixir-autogen, elixir-format msgid "Maximum gas amount approved for the transaction on L2." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:429 +#: lib/block_scout_web/templates/transaction/overview.html.eex:406 #, elixir-autogen, elixir-format msgid "Maximum gas amount approved for the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:439 +#: lib/block_scout_web/templates/transaction/overview.html.eex:416 #, elixir-autogen, elixir-format msgid "Maximum total amount per unit of gas a user is willing to pay for a transaction, including base fee and priority fee." msgstr "" @@ -1885,7 +1855,7 @@ msgid "More internal transactions have come in" msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:46 -#: lib/block_scout_web/templates/chain/show.html.eex:219 +#: lib/block_scout_web/templates/chain/show.html.eex:221 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:13 #: lib/block_scout_web/templates/transaction/index.html.eex:19 #, elixir-autogen, elixir-format @@ -1905,7 +1875,7 @@ msgstr "" #: lib/block_scout_web/templates/tokens/_tile.html.eex:40 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:21 -#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:61 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:65 #, elixir-autogen, elixir-format msgid "N/A" msgstr "" @@ -2015,7 +1985,7 @@ msgid "No trace entries found." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:198 -#: lib/block_scout_web/templates/transaction/overview.html.eex:535 +#: lib/block_scout_web/templates/transaction/overview.html.eex:512 #, elixir-autogen, elixir-format msgid "Nonce" msgstr "" @@ -2135,9 +2105,9 @@ msgid "Parent Hash" msgstr "" #: lib/block_scout_web/templates/layout/_topnav.html.eex:63 -#: lib/block_scout_web/views/transaction_view.ex:374 -#: lib/block_scout_web/views/transaction_view.ex:419 -#: lib/block_scout_web/views/transaction_view.ex:427 +#: lib/block_scout_web/views/transaction_view.ex:372 +#: lib/block_scout_web/views/transaction_view.ex:417 +#: lib/block_scout_web/views/transaction_view.ex:425 #, elixir-autogen, elixir-format msgid "Pending" msgstr "" @@ -2153,7 +2123,7 @@ msgstr "" msgid "Play" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:22 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:21 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Please confirm your email address to use the My Account feature." @@ -2169,7 +2139,7 @@ msgstr "" msgid "Please select what types of notifications you will receive:" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:537 +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Position" msgstr "" @@ -2194,7 +2164,7 @@ msgstr "" msgid "Press / and focus will be moved to the search field" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:42 +#: lib/block_scout_web/templates/chain/show.html.eex:44 #: lib/block_scout_web/templates/layout/app.html.eex:51 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:96 #, elixir-autogen, elixir-format @@ -2206,18 +2176,18 @@ msgstr "" msgid "Price per token on the exchanges" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:399 +#: lib/block_scout_web/templates/transaction/overview.html.eex:376 #, elixir-autogen, elixir-format msgid "Price per unit of gas specified by the sender on L2. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:403 +#: lib/block_scout_web/templates/transaction/overview.html.eex:380 #, elixir-autogen, elixir-format msgid "Price per unit of gas specified by the sender. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:227 -#: lib/block_scout_web/templates/transaction/overview.html.eex:460 +#: lib/block_scout_web/templates/transaction/overview.html.eex:437 #, elixir-autogen, elixir-format msgid "Priority Fee / Tip" msgstr "" @@ -2233,27 +2203,6 @@ msgstr "" msgid "Profile" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:20 -#, elixir-autogen, elixir-format -msgid "Public Tags" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:20 -#, elixir-autogen, elixir-format -msgid "Public tag" -msgstr "" - -#: lib/block_scout_web/templates/account/common/_nav.html.eex:22 -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:7 -#, elixir-autogen, elixir-format -msgid "Public tags" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:50 -#, elixir-autogen, elixir-format -msgid "Public tags* (2 tags maximum, please use \";\" as a divider)" -msgstr "" - #: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:10 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:5 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:83 @@ -2271,21 +2220,21 @@ msgstr "" msgid "RPC" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:546 +#: lib/block_scout_web/templates/transaction/overview.html.eex:523 #, elixir-autogen, elixir-format msgid "Raw Input" msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:24 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:1 -#: lib/block_scout_web/views/transaction_view.ex:554 +#: lib/block_scout_web/views/transaction_view.ex:552 #, elixir-autogen, elixir-format msgid "Raw Trace" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:81 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:27 -#: lib/block_scout_web/views/address_view.ex:351 +#: lib/block_scout_web/views/address_view.ex:357 #: lib/block_scout_web/views/tokens/overview_view.ex:41 #, elixir-autogen, elixir-format msgid "Read Contract" @@ -2293,7 +2242,7 @@ msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:88 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:41 -#: lib/block_scout_web/views/address_view.ex:352 +#: lib/block_scout_web/views/address_view.ex:358 #, elixir-autogen, elixir-format msgid "Read Proxy" msgstr "" @@ -2319,26 +2268,11 @@ msgstr "" msgid "Request URL" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:7 -#, elixir-autogen, elixir-format -msgid "Request a public tag/label" -msgstr "" - #: lib/block_scout_web/templates/error422/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Request cannot be processed" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:37 -#, elixir-autogen, elixir-format -msgid "Request to add public tag" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:7 -#, elixir-autogen, elixir-format -msgid "Request to edit a public tag/label" -msgstr "" - #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Resend verification email" @@ -2346,7 +2280,7 @@ msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:112 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:38 -#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:104 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:102 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:52 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:48 #, elixir-autogen, elixir-format @@ -2377,7 +2311,7 @@ msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:52 #: lib/block_scout_web/templates/chain/_block.html.eex:27 -#: lib/block_scout_web/views/internal_transaction_view.ex:29 +#: lib/block_scout_web/views/internal_transaction_view.ex:34 #, elixir-autogen, elixir-format msgid "Reward" msgstr "" @@ -2396,11 +2330,6 @@ msgstr "" msgid "Save" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:226 -#, elixir-autogen, elixir-format -msgid "Scroll to see more" -msgstr "" - #: lib/block_scout_web/templates/address_logs/index.html.eex:16 #: lib/block_scout_web/templates/layout/_search.html.eex:34 #, elixir-autogen, elixir-format @@ -2432,16 +2361,11 @@ msgstr "" msgid "Select yes if you want to show nightly builds." msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:28 +#: lib/block_scout_web/views/internal_transaction_view.ex:33 #, elixir-autogen, elixir-format msgid "Self-Destruct" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:63 -#, elixir-autogen, elixir-format -msgid "Send request" -msgstr "" - #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:163 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:124 #, elixir-autogen, elixir-format @@ -2478,12 +2402,12 @@ msgstr "" msgid "Shows total assets held in the address" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:33 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:32 #, elixir-autogen, elixir-format msgid "Sign in" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:24 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:23 #, elixir-autogen, elixir-format msgid "Sign out" msgstr "" @@ -2508,18 +2432,7 @@ msgstr "" msgid "Slow" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:21 -#, elixir-autogen, elixir-format -msgid "Smart contract / Address" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex:4 -#: lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex:5 -#, elixir-autogen, elixir-format -msgid "Smart contract / Address (0x...)" -msgstr "" - -#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:28 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:31 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:26 #: lib/block_scout_web/views/verified_contracts_view.ex:10 #, elixir-autogen, elixir-format @@ -2536,7 +2449,7 @@ msgstr "" #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:20 #: lib/block_scout_web/templates/block_transaction/index.html.eex:14 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:14 -#: lib/block_scout_web/templates/chain/show.html.eex:160 +#: lib/block_scout_web/templates/chain/show.html.eex:162 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:18 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:24 #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:23 @@ -2554,7 +2467,7 @@ msgstr "" msgid "Something went wrong, click to reload." msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:225 +#: lib/block_scout_web/templates/chain/show.html.eex:227 #, elixir-autogen, elixir-format msgid "Something went wrong, click to retry." msgstr "" @@ -2591,12 +2504,12 @@ msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:29 #: lib/block_scout_web/templates/transaction_state/index.html.eex:6 -#: lib/block_scout_web/views/transaction_view.ex:555 +#: lib/block_scout_web/views/transaction_view.ex:553 #, elixir-autogen, elixir-format msgid "State changes" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:24 +#: lib/block_scout_web/views/internal_transaction_view.ex:26 #, elixir-autogen, elixir-format msgid "Static Call" msgstr "" @@ -2606,18 +2519,13 @@ msgstr "" msgid "Status" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:22 -#, elixir-autogen, elixir-format -msgid "Submission date" -msgstr "" - #: lib/block_scout_web/templates/layout/_footer.html.eex:41 #, elixir-autogen, elixir-format msgid "Submit an Issue" msgstr "" #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:8 -#: lib/block_scout_web/views/transaction_view.ex:376 +#: lib/block_scout_web/views/transaction_view.ex:374 #, elixir-autogen, elixir-format msgid "Success" msgstr "" @@ -2843,7 +2751,7 @@ msgstr "" msgid "This API is provided to support some rpc methods in the exact format specified for ethereum nodes, which can be found " msgstr "" -#: lib/block_scout_web/views/block_transaction_view.ex:11 +#: lib/block_scout_web/views/block_transaction_view.ex:15 #, elixir-autogen, elixir-format msgid "This block has not been processed yet." msgstr "" @@ -2888,7 +2796,7 @@ msgstr "" #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:34 #: lib/block_scout_web/templates/address_transaction/index.html.eex:28 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:29 -#: lib/block_scout_web/templates/transaction/overview.html.eex:267 +#: lib/block_scout_web/templates/transaction/overview.html.eex:244 #: lib/block_scout_web/templates/withdrawal/index.html.eex:32 #: lib/block_scout_web/views/address_internal_transaction_view.ex:10 #: lib/block_scout_web/views/address_token_transfer_view.ex:10 @@ -2921,13 +2829,13 @@ msgid "Token" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:3 -#: lib/block_scout_web/views/transaction_view.ex:488 +#: lib/block_scout_web/views/transaction_view.ex:486 #, elixir-autogen, elixir-format msgid "Token Burning" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:7 -#: lib/block_scout_web/views/transaction_view.ex:489 +#: lib/block_scout_web/views/transaction_view.ex:487 #, elixir-autogen, elixir-format msgid "Token Creation" msgstr "" @@ -2955,14 +2863,14 @@ msgid "Token ID" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:5 -#: lib/block_scout_web/views/transaction_view.ex:487 +#: lib/block_scout_web/views/transaction_view.ex:485 #, elixir-autogen, elixir-format msgid "Token Minting" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:9 #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:11 -#: lib/block_scout_web/views/transaction_view.ex:490 +#: lib/block_scout_web/views/transaction_view.ex:488 #, elixir-autogen, elixir-format msgid "Token Transfer" msgstr "" @@ -2975,10 +2883,10 @@ msgstr "" #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:15 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:4 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:7 -#: lib/block_scout_web/views/address_view.ex:349 +#: lib/block_scout_web/views/address_view.ex:355 #: lib/block_scout_web/views/tokens/instance/overview_view.ex:74 #: lib/block_scout_web/views/tokens/overview_view.ex:39 -#: lib/block_scout_web/views/transaction_view.ex:551 +#: lib/block_scout_web/views/transaction_view.ex:549 #, elixir-autogen, elixir-format msgid "Token Transfers" msgstr "" @@ -2999,27 +2907,27 @@ msgstr "" #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:13 #: lib/block_scout_web/templates/layout/_topnav.html.eex:84 #: lib/block_scout_web/templates/tokens/index.html.eex:10 -#: lib/block_scout_web/views/address_view.ex:346 +#: lib/block_scout_web/views/address_view.ex:352 #, elixir-autogen, elixir-format msgid "Tokens" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:336 +#: lib/block_scout_web/templates/transaction/overview.html.eex:313 #, elixir-autogen, elixir-format msgid "Tokens Burnt" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:352 +#: lib/block_scout_web/templates/transaction/overview.html.eex:329 #, elixir-autogen, elixir-format msgid "Tokens Created" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:319 +#: lib/block_scout_web/templates/transaction/overview.html.eex:296 #, elixir-autogen, elixir-format msgid "Tokens Minted" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:303 +#: lib/block_scout_web/templates/transaction/overview.html.eex:280 #, elixir-autogen, elixir-format msgid "Tokens Transferred" msgstr "" @@ -3061,7 +2969,7 @@ msgstr "" msgid "Total Supply * Price" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:133 +#: lib/block_scout_web/templates/chain/show.html.eex:135 #, elixir-autogen, elixir-format msgid "Total blocks" msgstr "" @@ -3081,12 +2989,12 @@ msgstr "" msgid "Total supply" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:383 +#: lib/block_scout_web/templates/transaction/overview.html.eex:360 #, elixir-autogen, elixir-format msgid "Total transaction fee." msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:112 +#: lib/block_scout_web/templates/chain/show.html.eex:114 #, elixir-autogen, elixir-format msgid "Total transactions" msgstr "" @@ -3094,7 +3002,7 @@ msgstr "" #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:23 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:19 -#: lib/block_scout_web/views/transaction_view.ex:500 +#: lib/block_scout_web/views/transaction_view.ex:498 #, elixir-autogen, elixir-format msgid "Transaction" msgstr "" @@ -3109,12 +3017,7 @@ msgstr "" msgid "Transaction %{transaction}, %{subnetwork} %{transaction}" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:225 -#, elixir-autogen, elixir-format -msgid "Transaction Action" -msgstr "" - -#: lib/block_scout_web/templates/transaction/overview.html.eex:470 +#: lib/block_scout_web/templates/transaction/overview.html.eex:447 #, elixir-autogen, elixir-format msgid "Transaction Burnt Fee" msgstr "" @@ -3124,7 +3027,7 @@ msgstr "" msgid "Transaction Details" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:384 +#: lib/block_scout_web/templates/transaction/overview.html.eex:361 #, elixir-autogen, elixir-format msgid "Transaction Fee" msgstr "" @@ -3147,17 +3050,17 @@ msgstr "" msgid "Transaction Tags" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:414 +#: lib/block_scout_web/templates/transaction/overview.html.eex:391 #, elixir-autogen, elixir-format msgid "Transaction Type" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:534 +#: lib/block_scout_web/templates/transaction/overview.html.eex:511 #, elixir-autogen, elixir-format msgid "Transaction number from the sending address. Each transaction sent from an address increments the nonce by 1." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:413 +#: lib/block_scout_web/templates/transaction/overview.html.eex:390 #, elixir-autogen, elixir-format msgid "Transaction type, introduced in EIP-2718." msgstr "" @@ -3170,9 +3073,9 @@ msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:13 #: lib/block_scout_web/templates/block/_tabs.html.eex:4 #: lib/block_scout_web/templates/block/overview.html.eex:80 -#: lib/block_scout_web/templates/chain/show.html.eex:216 +#: lib/block_scout_web/templates/chain/show.html.eex:218 #: lib/block_scout_web/templates/layout/_topnav.html.eex:49 -#: lib/block_scout_web/views/address_view.ex:348 +#: lib/block_scout_web/views/address_view.ex:354 #, elixir-autogen, elixir-format msgid "Transactions" msgstr "" @@ -3233,7 +3136,7 @@ msgstr "" msgid "UML diagram" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:560 +#: lib/block_scout_web/templates/transaction/overview.html.eex:537 #, elixir-autogen, elixir-format msgid "UTF-8" msgstr "" @@ -3249,7 +3152,7 @@ msgstr "" msgid "Uncles" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:367 +#: lib/block_scout_web/views/transaction_view.ex:365 #, elixir-autogen, elixir-format msgid "Unconfirmed" msgstr "" @@ -3270,12 +3173,12 @@ msgstr "" msgid "Update" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:449 +#: lib/block_scout_web/templates/transaction/overview.html.eex:426 #, elixir-autogen, elixir-format msgid "User defined maximum fee (tip) per unit of gas paid to validator for transaction prioritization." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:459 +#: lib/block_scout_web/templates/transaction/overview.html.eex:436 #, elixir-autogen, elixir-format msgid "User-defined tip sent to validator for transaction priority/inclusion." msgstr "" @@ -3317,12 +3220,12 @@ msgstr "" msgid "Validator index" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:369 +#: lib/block_scout_web/templates/transaction/overview.html.eex:346 #, elixir-autogen, elixir-format msgid "Value" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:368 +#: lib/block_scout_web/templates/transaction/overview.html.eex:345 #, elixir-autogen, elixir-format msgid "Value sent in the native token (and USD) if applicable." msgstr "" @@ -3371,7 +3274,7 @@ msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:111 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:37 -#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:103 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:101 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:47 #, elixir-autogen, elixir-format @@ -3411,12 +3314,12 @@ msgstr "" msgid "Via multi-part files" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:155 +#: lib/block_scout_web/templates/chain/show.html.eex:157 #, elixir-autogen, elixir-format msgid "View All Blocks" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:215 +#: lib/block_scout_web/templates/chain/show.html.eex:217 #, elixir-autogen, elixir-format msgid "View All Transactions" msgstr "" @@ -3472,7 +3375,7 @@ msgstr "" msgid "View transaction %{transaction} on %{subnetwork}" msgstr "" -#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:28 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:29 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:32 #: lib/block_scout_web/views/verified_contracts_view.ex:11 #, elixir-autogen, elixir-format @@ -3494,7 +3397,7 @@ msgstr "" msgid "Waiting for transaction's confirmation..." msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:141 +#: lib/block_scout_web/templates/chain/show.html.eex:143 #, elixir-autogen, elixir-format msgid "Wallet addresses" msgstr "" @@ -3537,14 +3440,14 @@ msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:95 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:34 -#: lib/block_scout_web/views/address_view.ex:353 +#: lib/block_scout_web/views/address_view.ex:359 #, elixir-autogen, elixir-format msgid "Write Contract" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:102 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:48 -#: lib/block_scout_web/views/address_view.ex:354 +#: lib/block_scout_web/views/address_view.ex:360 #, elixir-autogen, elixir-format msgid "Write Proxy" msgstr "" @@ -3568,11 +3471,6 @@ msgstr "" msgid "You can create up to 15 Custom ABIs per account." msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:11 -#, elixir-autogen, elixir-format -msgid "You can request a public category tag which is displayed to all Blockscout users. Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag. Clicking a tag opens a page with related information and helps provide context and data organization. Requests are sent to a moderator for review and approval. This process can take several days." -msgstr "" - #: lib/block_scout_web/templates/account/tag_address/index.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have address tags yet" @@ -3588,21 +3486,12 @@ msgstr "" msgid "You don't have transaction tags yet" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:15 -#, elixir-autogen, elixir-format -msgid "Your name" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:14 -#, elixir-autogen, elixir-format -msgid "Your name*" -msgstr "" - #: lib/block_scout_web/templates/error422/index.html.eex:8 #, elixir-autogen, elixir-format msgid "Your request contained an error, perhaps a mistyped tx/block/address hash. Try again, and check the developer tools console for more info." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:30 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:38 #: lib/block_scout_web/views/verified_contracts_view.ex:12 #, elixir-autogen, elixir-format @@ -3619,7 +3508,7 @@ msgstr "" msgid "balance of the address" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:469 +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "burnt for this transaction. Equals Block Base Fee per Gas * Gas Used." msgstr "" @@ -3634,7 +3523,7 @@ msgstr "" msgid "button" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:276 +#: lib/block_scout_web/templates/transaction/overview.html.eex:253 #, elixir-autogen, elixir-format msgid "created" msgstr "" @@ -3734,3 +3623,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "truffle flattener" msgstr "" + +#: lib/block_scout_web/views/transaction_view.ex:224 +#, elixir-autogen, elixir-format +msgid "ERC-7984 " +msgstr "" + +#: lib/block_scout_web/views/transaction_view.ex:223 +#, elixir-autogen, elixir-format +msgid "ZRC-2 " +msgstr "" diff --git a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po index 2499baa8b4a6..2221ff8ea4f5 100644 --- a/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po +++ b/apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po @@ -86,7 +86,7 @@ msgstr "" msgid "%{withdrawals_count} withdrawals processed and %{withdrawals_sum} withdrawn." msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:375 +#: lib/block_scout_web/views/transaction_view.ex:373 #, elixir-autogen, elixir-format msgid "(Awaiting internal transactions for status)" msgstr "" @@ -217,12 +217,12 @@ msgstr "" msgid "Actions" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:484 +#: lib/block_scout_web/templates/transaction/overview.html.eex:461 #, elixir-autogen, elixir-format msgid "Actual gas amount used by the transaction on L2." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:488 +#: lib/block_scout_web/templates/transaction/overview.html.eex:465 #, elixir-autogen, elixir-format, fuzzy msgid "Actual gas amount used by the transaction." msgstr "" @@ -288,17 +288,17 @@ msgstr "" #: lib/block_scout_web/templates/transaction_log/_logs.html.eex:29 #: lib/block_scout_web/templates/transaction_state/index.html.eex:34 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:60 -#: lib/block_scout_web/views/address_view.ex:109 +#: lib/block_scout_web/views/address_view.ex:110 #, elixir-autogen, elixir-format msgid "Address" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:263 +#: lib/block_scout_web/templates/transaction/overview.html.eex:240 #, elixir-autogen, elixir-format msgid "Address (external or contract) receiving the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:245 +#: lib/block_scout_web/templates/transaction/overview.html.eex:222 #, elixir-autogen, elixir-format msgid "Address (external or contract) sending the transaction." msgstr "" @@ -325,11 +325,6 @@ msgstr "" msgid "Address used in token mintings and burnings." msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex:2 -#, elixir-autogen, elixir-format -msgid "Address*" -msgstr "" - #: lib/block_scout_web/templates/address/index.html.eex:5 #, elixir-autogen, elixir-format msgid "Addresses" @@ -376,7 +371,7 @@ msgstr "" msgid "Amount" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:469 +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "Amount of" msgstr "" @@ -406,7 +401,7 @@ msgstr "" msgid "Average" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:102 +#: lib/block_scout_web/templates/chain/show.html.eex:104 #, elixir-autogen, elixir-format msgid "Average block time" msgstr "" @@ -488,7 +483,7 @@ msgstr "" msgid "Beacon chain, Withdrawals, %{subnetwork}, %{coin}" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:545 +#: lib/block_scout_web/templates/transaction/overview.html.eex:522 #, elixir-autogen, elixir-format msgid "Binary data included with the transaction. See input / logs below for additional info." msgstr "" @@ -539,7 +534,7 @@ msgstr "" msgid "Block difficulty for miner, used to calibrate block generation time (Note: constant in POA based networks)." msgstr "" -#: lib/block_scout_web/views/block_transaction_view.ex:15 +#: lib/block_scout_web/views/block_transaction_view.ex:19 #, elixir-autogen, elixir-format msgid "Block not found, please try again later." msgstr "" @@ -569,7 +564,7 @@ msgstr "" msgid "Blockchain" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:156 +#: lib/block_scout_web/templates/chain/show.html.eex:158 #: lib/block_scout_web/templates/layout/_topnav.html.eex:34 #: lib/block_scout_web/templates/layout/_topnav.html.eex:38 #, elixir-autogen, elixir-format @@ -584,7 +579,7 @@ msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:56 #: lib/block_scout_web/templates/address/overview.html.eex:277 #: lib/block_scout_web/templates/address_validation/index.html.eex:11 -#: lib/block_scout_web/views/address_view.ex:356 +#: lib/block_scout_web/views/address_view.ex:362 #, elixir-autogen, elixir-format msgid "Blocks Validated" msgstr "" @@ -621,21 +616,20 @@ msgid "CSV" msgstr "" #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:10 -#: lib/block_scout_web/views/internal_transaction_view.ex:21 +#: lib/block_scout_web/views/internal_transaction_view.ex:23 #, elixir-autogen, elixir-format msgid "Call" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:22 +#: lib/block_scout_web/views/internal_transaction_view.ex:24 #, elixir-autogen, elixir-format msgid "Call Code" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:62 #: lib/block_scout_web/templates/address_contract_verification/new.html.eex:120 #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:115 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:41 -#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:107 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:105 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:55 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:51 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:47 @@ -684,13 +678,13 @@ msgstr "" #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:187 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:126 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:149 -#: lib/block_scout_web/views/address_view.ex:350 +#: lib/block_scout_web/views/address_view.ex:356 #, elixir-autogen, elixir-format msgid "Code" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:42 -#: lib/block_scout_web/views/address_view.ex:355 +#: lib/block_scout_web/views/address_view.ex:361 #, elixir-autogen, elixir-format msgid "Coin Balance History" msgstr "" @@ -700,16 +694,6 @@ msgstr "" msgid "Collapse" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:20 -#, elixir-autogen, elixir-format -msgid "Company name" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:32 -#, elixir-autogen, elixir-format -msgid "Company website" -msgstr "" - #: lib/block_scout_web/templates/address_contract_verification_common_fields/_compiler_field.html.eex:3 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:69 #, elixir-autogen, elixir-format @@ -726,7 +710,7 @@ msgstr "" msgid "Compiler version" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:368 +#: lib/block_scout_web/views/transaction_view.ex:366 #, elixir-autogen, elixir-format msgid "Confirmed" msgstr "" @@ -786,7 +770,7 @@ msgid "Constructor args" msgstr "" #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:52 -#: lib/block_scout_web/templates/transaction/overview.html.eex:273 +#: lib/block_scout_web/templates/transaction/overview.html.eex:250 #, elixir-autogen, elixir-format msgid "Contract" msgstr "" @@ -799,24 +783,24 @@ msgstr "" #: lib/block_scout_web/templates/account/custom_abi/form.html.eex:18 #: lib/block_scout_web/templates/account/custom_abi/index.html.eex:29 #: lib/block_scout_web/templates/address_contract_verification_common_fields/_contract_address_field.html.eex:3 -#: lib/block_scout_web/views/address_view.ex:107 +#: lib/block_scout_web/views/address_view.ex:108 #, elixir-autogen, elixir-format msgid "Contract Address" msgstr "" #: lib/block_scout_web/templates/transaction/_pending_tile.html.eex:16 -#: lib/block_scout_web/views/address_view.ex:47 -#: lib/block_scout_web/views/address_view.ex:81 +#: lib/block_scout_web/views/address_view.ex:48 +#: lib/block_scout_web/views/address_view.ex:82 #, elixir-autogen, elixir-format msgid "Contract Address Pending" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:497 +#: lib/block_scout_web/views/transaction_view.ex:495 #, elixir-autogen, elixir-format msgid "Contract Call" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:494 +#: lib/block_scout_web/views/transaction_view.ex:492 #, elixir-autogen, elixir-format msgid "Contract Creation" msgstr "" @@ -932,8 +916,8 @@ msgstr "" #: lib/block_scout_web/templates/account/watchlist_address/row.html.eex:7 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:17 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:18 -#: lib/block_scout_web/templates/transaction/overview.html.eex:253 -#: lib/block_scout_web/templates/transaction/overview.html.eex:254 +#: lib/block_scout_web/templates/transaction/overview.html.eex:230 +#: lib/block_scout_web/templates/transaction/overview.html.eex:231 #, elixir-autogen, elixir-format msgid "Copy From Address" msgstr "" @@ -968,10 +952,10 @@ msgstr "" #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:34 #: lib/block_scout_web/templates/transaction/_total_transfers_from_to.html.eex:35 -#: lib/block_scout_web/templates/transaction/overview.html.eex:280 -#: lib/block_scout_web/templates/transaction/overview.html.eex:281 -#: lib/block_scout_web/templates/transaction/overview.html.eex:288 -#: lib/block_scout_web/templates/transaction/overview.html.eex:289 +#: lib/block_scout_web/templates/transaction/overview.html.eex:257 +#: lib/block_scout_web/templates/transaction/overview.html.eex:258 +#: lib/block_scout_web/templates/transaction/overview.html.eex:265 +#: lib/block_scout_web/templates/transaction/overview.html.eex:266 #, elixir-autogen, elixir-format msgid "Copy To Address" msgstr "" @@ -992,26 +976,26 @@ msgstr "" msgid "Copy Txn Hash" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:571 +#: lib/block_scout_web/templates/transaction/overview.html.eex:548 #, elixir-autogen, elixir-format msgid "Copy Txn Hex Input" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:577 +#: lib/block_scout_web/templates/transaction/overview.html.eex:554 #, elixir-autogen, elixir-format msgid "Copy Txn UTF-8 Input" msgstr "" #: lib/block_scout_web/templates/log/_data_decoded_view.html.eex:20 #: lib/block_scout_web/templates/transaction/_decoded_input_body.html.eex:41 -#: lib/block_scout_web/templates/transaction/overview.html.eex:570 -#: lib/block_scout_web/templates/transaction/overview.html.eex:576 +#: lib/block_scout_web/templates/transaction/overview.html.eex:547 +#: lib/block_scout_web/templates/transaction/overview.html.eex:553 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:8 #, elixir-autogen, elixir-format msgid "Copy Value" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:26 +#: lib/block_scout_web/views/internal_transaction_view.ex:31 #, elixir-autogen, elixir-format msgid "Create" msgstr "" @@ -1026,7 +1010,7 @@ msgstr "" msgid "Create an API key to use with your RPC and EthRPC API requests." msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:27 +#: lib/block_scout_web/views/internal_transaction_view.ex:32 #, elixir-autogen, elixir-format msgid "Create2" msgstr "" @@ -1067,7 +1051,7 @@ msgstr "" msgid "Custom ABI from account" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:70 +#: lib/block_scout_web/templates/chain/show.html.eex:72 #, elixir-autogen, elixir-format msgid "Daily Transactions" msgstr "" @@ -1107,7 +1091,7 @@ msgstr "" msgid "Decoded" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:23 +#: lib/block_scout_web/views/internal_transaction_view.ex:25 #, elixir-autogen, elixir-format msgid "Delegate Call" msgstr "" @@ -1126,11 +1110,6 @@ msgstr "" msgid "Description" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:56 -#, elixir-autogen, elixir-format -msgid "Description*" -msgstr "" - #: lib/block_scout_web/templates/address/overview.html.eex:30 #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:166 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:127 @@ -1179,22 +1158,17 @@ msgstr "" msgid "Drop the standard input JSON file or click here" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:27 -#, elixir-autogen, elixir-format -msgid "E-mail*" -msgstr "" - #: lib/block_scout_web/templates/common_components/_minimal_proxy_pattern.html.eex:6 #, elixir-autogen, elixir-format msgid "EIP-1167" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:225 +#: lib/block_scout_web/views/transaction_view.ex:221 #, elixir-autogen, elixir-format msgid "ERC-1155 " msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:223 +#: lib/block_scout_web/views/transaction_view.ex:219 #, elixir-autogen, elixir-format msgid "ERC-20 " msgstr "" @@ -1204,12 +1178,12 @@ msgstr "" msgid "ERC-20 tokens (beta)" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:226 +#: lib/block_scout_web/views/transaction_view.ex:222 #, elixir-autogen, elixir-format, fuzzy msgid "ERC-404 " msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:224 +#: lib/block_scout_web/views/transaction_view.ex:220 #, elixir-autogen, elixir-format msgid "ERC-721 " msgstr "" @@ -1238,6 +1212,7 @@ msgid "EVM version details" msgstr "" #: lib/block_scout_web/views/block_transaction_view.ex:7 +#: lib/block_scout_web/views/block_transaction_view.ex:11 #, elixir-autogen, elixir-format msgid "Easy Cowboy! This block does not exist yet!" msgstr "" @@ -1300,12 +1275,12 @@ msgstr "" msgid "Error trying to fetch balances." msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:379 +#: lib/block_scout_web/views/transaction_view.ex:377 #, elixir-autogen, elixir-format msgid "Error: %{reason}" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:377 +#: lib/block_scout_web/views/transaction_view.ex:375 #, elixir-autogen, elixir-format msgid "Error: (Awaiting internal transactions for reason)" msgstr "" @@ -1424,7 +1399,7 @@ msgstr "" #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:38 #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:40 #: lib/block_scout_web/templates/address_transaction/index.html.eex:34 -#: lib/block_scout_web/templates/transaction/overview.html.eex:246 +#: lib/block_scout_web/templates/transaction/overview.html.eex:223 #: lib/block_scout_web/views/address_internal_transaction_view.ex:11 #: lib/block_scout_web/views/address_token_transfer_view.ex:11 #: lib/block_scout_web/views/address_transaction_view.ex:11 @@ -1439,12 +1414,12 @@ msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:67 #: lib/block_scout_web/templates/block/overview.html.eex:189 -#: lib/block_scout_web/templates/transaction/overview.html.eex:430 +#: lib/block_scout_web/templates/transaction/overview.html.eex:407 #, elixir-autogen, elixir-format msgid "Gas Limit" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:404 +#: lib/block_scout_web/templates/transaction/overview.html.eex:381 #, elixir-autogen, elixir-format, fuzzy msgid "Gas Price" msgstr "" @@ -1456,7 +1431,7 @@ msgstr "" msgid "Gas Used" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:466 #, elixir-autogen, elixir-format, fuzzy msgid "Gas Used by Transaction" msgstr "" @@ -1508,17 +1483,12 @@ msgstr "" msgid "Hash" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:553 -#: lib/block_scout_web/templates/transaction/overview.html.eex:557 +#: lib/block_scout_web/templates/transaction/overview.html.eex:530 +#: lib/block_scout_web/templates/transaction/overview.html.eex:534 #, elixir-autogen, elixir-format msgid "Hex (Default)" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:224 -#, elixir-autogen, elixir-format -msgid "Highlighted events of the transaction." -msgstr "" - #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:108 #, elixir-autogen, elixir-format msgid "Holders" @@ -1580,7 +1550,7 @@ msgstr "" msgid "Index" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:537 +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Index position of Transaction in the block." msgstr "" @@ -1600,7 +1570,7 @@ msgstr "" msgid "Input" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:265 +#: lib/block_scout_web/templates/transaction/overview.html.eex:242 #, elixir-autogen, elixir-format msgid "Interacted With (To)" msgstr "" @@ -1614,8 +1584,8 @@ msgstr "" #: lib/block_scout_web/templates/address_internal_transaction/index.html.eex:17 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:11 #: lib/block_scout_web/templates/transaction_internal_transaction/index.html.eex:6 -#: lib/block_scout_web/views/address_view.ex:347 -#: lib/block_scout_web/views/transaction_view.ex:552 +#: lib/block_scout_web/views/address_view.ex:353 +#: lib/block_scout_web/views/transaction_view.ex:550 #, elixir-autogen, elixir-format msgid "Internal Transactions" msgstr "" @@ -1625,7 +1595,7 @@ msgstr "" msgid "Internal server error" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:25 +#: lib/block_scout_web/views/internal_transaction_view.ex:27 #, elixir-autogen, elixir-format msgid "Invalid" msgstr "" @@ -1647,35 +1617,35 @@ msgstr "" msgid "L1 Block" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:523 -#: lib/block_scout_web/templates/transaction/overview.html.eex:524 +#: lib/block_scout_web/templates/transaction/overview.html.eex:500 +#: lib/block_scout_web/templates/transaction/overview.html.eex:501 #, elixir-autogen, elixir-format msgid "L1 Fee Scalar" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:512 -#: lib/block_scout_web/templates/transaction/overview.html.eex:513 +#: lib/block_scout_web/templates/transaction/overview.html.eex:489 +#: lib/block_scout_web/templates/transaction/overview.html.eex:490 #, elixir-autogen, elixir-format, fuzzy msgid "L1 Gas Price" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:501 -#: lib/block_scout_web/templates/transaction/overview.html.eex:502 +#: lib/block_scout_web/templates/transaction/overview.html.eex:478 +#: lib/block_scout_web/templates/transaction/overview.html.eex:479 #, elixir-autogen, elixir-format msgid "L1 Gas Used by Transaction" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:426 +#: lib/block_scout_web/templates/transaction/overview.html.eex:403 #, elixir-autogen, elixir-format, fuzzy msgid "L2 Gas Limit" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:400 +#: lib/block_scout_web/templates/transaction/overview.html.eex:377 #, elixir-autogen, elixir-format, fuzzy msgid "L2 Gas Price" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:485 +#: lib/block_scout_web/templates/transaction/overview.html.eex:462 #, elixir-autogen, elixir-format msgid "L2 Gas Used by Transaction" msgstr "" @@ -1718,22 +1688,22 @@ msgstr "" msgid "License ID" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:351 +#: lib/block_scout_web/templates/transaction/overview.html.eex:328 #, elixir-autogen, elixir-format msgid "List of ERC-1155 tokens created in the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:335 +#: lib/block_scout_web/templates/transaction/overview.html.eex:312 #, elixir-autogen, elixir-format msgid "List of token burnt in the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:318 +#: lib/block_scout_web/templates/transaction/overview.html.eex:295 #, elixir-autogen, elixir-format msgid "List of token minted in the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:302 +#: lib/block_scout_web/templates/transaction/overview.html.eex:279 #, elixir-autogen, elixir-format msgid "List of token transferred in the transaction." msgstr "" @@ -1774,8 +1744,8 @@ msgstr "" #: lib/block_scout_web/templates/address_logs/index.html.eex:10 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:17 #: lib/block_scout_web/templates/transaction_log/index.html.eex:8 -#: lib/block_scout_web/views/address_view.ex:357 -#: lib/block_scout_web/views/transaction_view.ex:553 +#: lib/block_scout_web/views/address_view.ex:363 +#: lib/block_scout_web/views/transaction_view.ex:551 #, elixir-autogen, elixir-format msgid "Logs" msgstr "" @@ -1785,10 +1755,10 @@ msgstr "" msgid "Main Networks" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:53 +#: lib/block_scout_web/templates/chain/show.html.eex:55 #: lib/block_scout_web/templates/layout/app.html.eex:50 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:85 -#: lib/block_scout_web/views/address_view.ex:147 +#: lib/block_scout_web/views/address_view.ex:148 #, elixir-autogen, elixir-format msgid "Market Cap" msgstr "" @@ -1798,32 +1768,32 @@ msgstr "" msgid "Market cap" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:440 +#: lib/block_scout_web/templates/transaction/overview.html.eex:417 #, elixir-autogen, elixir-format msgid "Max Fee per Gas" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:450 +#: lib/block_scout_web/templates/transaction/overview.html.eex:427 #, elixir-autogen, elixir-format msgid "Max Priority Fee per Gas" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:331 +#: lib/block_scout_web/views/transaction_view.ex:329 #, elixir-autogen, elixir-format msgid "Max of" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:425 +#: lib/block_scout_web/templates/transaction/overview.html.eex:402 #, elixir-autogen, elixir-format msgid "Maximum gas amount approved for the transaction on L2." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:429 +#: lib/block_scout_web/templates/transaction/overview.html.eex:406 #, elixir-autogen, elixir-format, fuzzy msgid "Maximum gas amount approved for the transaction." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:439 +#: lib/block_scout_web/templates/transaction/overview.html.eex:416 #, elixir-autogen, elixir-format msgid "Maximum total amount per unit of gas a user is willing to pay for a transaction, including base fee and priority fee." msgstr "" @@ -1885,7 +1855,7 @@ msgid "More internal transactions have come in" msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:46 -#: lib/block_scout_web/templates/chain/show.html.eex:219 +#: lib/block_scout_web/templates/chain/show.html.eex:221 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:13 #: lib/block_scout_web/templates/transaction/index.html.eex:19 #, elixir-autogen, elixir-format @@ -1905,7 +1875,7 @@ msgstr "" #: lib/block_scout_web/templates/tokens/_tile.html.eex:40 #: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:21 -#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:61 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:65 #, elixir-autogen, elixir-format msgid "N/A" msgstr "" @@ -2015,7 +1985,7 @@ msgid "No trace entries found." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:198 -#: lib/block_scout_web/templates/transaction/overview.html.eex:535 +#: lib/block_scout_web/templates/transaction/overview.html.eex:512 #, elixir-autogen, elixir-format msgid "Nonce" msgstr "" @@ -2135,9 +2105,9 @@ msgid "Parent Hash" msgstr "" #: lib/block_scout_web/templates/layout/_topnav.html.eex:63 -#: lib/block_scout_web/views/transaction_view.ex:374 -#: lib/block_scout_web/views/transaction_view.ex:419 -#: lib/block_scout_web/views/transaction_view.ex:427 +#: lib/block_scout_web/views/transaction_view.ex:372 +#: lib/block_scout_web/views/transaction_view.ex:417 +#: lib/block_scout_web/views/transaction_view.ex:425 #, elixir-autogen, elixir-format msgid "Pending" msgstr "" @@ -2153,7 +2123,7 @@ msgstr "" msgid "Play" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:22 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:21 #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Please confirm your email address to use the My Account feature." @@ -2169,7 +2139,7 @@ msgstr "" msgid "Please select what types of notifications you will receive:" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:537 +#: lib/block_scout_web/templates/transaction/overview.html.eex:514 #, elixir-autogen, elixir-format msgid "Position" msgstr "" @@ -2194,7 +2164,7 @@ msgstr "" msgid "Press / and focus will be moved to the search field" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:42 +#: lib/block_scout_web/templates/chain/show.html.eex:44 #: lib/block_scout_web/templates/layout/app.html.eex:51 #: lib/block_scout_web/templates/tokens/overview/_details.html.eex:96 #, elixir-autogen, elixir-format @@ -2206,18 +2176,18 @@ msgstr "" msgid "Price per token on the exchanges" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:399 +#: lib/block_scout_web/templates/transaction/overview.html.eex:376 #, elixir-autogen, elixir-format msgid "Price per unit of gas specified by the sender on L2. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:403 +#: lib/block_scout_web/templates/transaction/overview.html.eex:380 #, elixir-autogen, elixir-format, fuzzy msgid "Price per unit of gas specified by the sender. Higher gas prices can prioritize transaction inclusion during times of high usage." msgstr "" #: lib/block_scout_web/templates/block/overview.html.eex:227 -#: lib/block_scout_web/templates/transaction/overview.html.eex:460 +#: lib/block_scout_web/templates/transaction/overview.html.eex:437 #, elixir-autogen, elixir-format msgid "Priority Fee / Tip" msgstr "" @@ -2233,27 +2203,6 @@ msgstr "" msgid "Profile" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:20 -#, elixir-autogen, elixir-format -msgid "Public Tags" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:20 -#, elixir-autogen, elixir-format -msgid "Public tag" -msgstr "" - -#: lib/block_scout_web/templates/account/common/_nav.html.eex:22 -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:7 -#, elixir-autogen, elixir-format -msgid "Public tags" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:50 -#, elixir-autogen, elixir-format -msgid "Public tags* (2 tags maximum, please use \";\" as a divider)" -msgstr "" - #: lib/block_scout_web/templates/common_components/_btn_qr_code.html.eex:10 #: lib/block_scout_web/templates/common_components/_modal_qr_code.html.eex:5 #: lib/block_scout_web/templates/tokens/instance/overview/_details.html.eex:83 @@ -2271,21 +2220,21 @@ msgstr "" msgid "RPC" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:546 +#: lib/block_scout_web/templates/transaction/overview.html.eex:523 #, elixir-autogen, elixir-format msgid "Raw Input" msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:24 #: lib/block_scout_web/templates/transaction_raw_trace/_card_body.html.eex:1 -#: lib/block_scout_web/views/transaction_view.ex:554 +#: lib/block_scout_web/views/transaction_view.ex:552 #, elixir-autogen, elixir-format msgid "Raw Trace" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:81 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:27 -#: lib/block_scout_web/views/address_view.ex:351 +#: lib/block_scout_web/views/address_view.ex:357 #: lib/block_scout_web/views/tokens/overview_view.ex:41 #, elixir-autogen, elixir-format msgid "Read Contract" @@ -2293,7 +2242,7 @@ msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:88 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:41 -#: lib/block_scout_web/views/address_view.ex:352 +#: lib/block_scout_web/views/address_view.ex:358 #, elixir-autogen, elixir-format msgid "Read Proxy" msgstr "" @@ -2319,26 +2268,11 @@ msgstr "" msgid "Request URL" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:7 -#, elixir-autogen, elixir-format -msgid "Request a public tag/label" -msgstr "" - #: lib/block_scout_web/templates/error422/index.html.eex:7 #, elixir-autogen, elixir-format msgid "Request cannot be processed" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:37 -#, elixir-autogen, elixir-format -msgid "Request to add public tag" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:7 -#, elixir-autogen, elixir-format -msgid "Request to edit a public tag/label" -msgstr "" - #: lib/block_scout_web/templates/layout/app.html.eex:100 #, elixir-autogen, elixir-format msgid "Resend verification email" @@ -2346,7 +2280,7 @@ msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:112 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:38 -#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:104 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:102 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:52 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:48 #, elixir-autogen, elixir-format @@ -2377,7 +2311,7 @@ msgstr "" #: lib/block_scout_web/templates/block/_tile.html.eex:52 #: lib/block_scout_web/templates/chain/_block.html.eex:27 -#: lib/block_scout_web/views/internal_transaction_view.ex:29 +#: lib/block_scout_web/views/internal_transaction_view.ex:34 #, elixir-autogen, elixir-format msgid "Reward" msgstr "" @@ -2396,11 +2330,6 @@ msgstr "" msgid "Save" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:226 -#, elixir-autogen, elixir-format -msgid "Scroll to see more" -msgstr "" - #: lib/block_scout_web/templates/address_logs/index.html.eex:16 #: lib/block_scout_web/templates/layout/_search.html.eex:34 #, elixir-autogen, elixir-format @@ -2432,16 +2361,11 @@ msgstr "" msgid "Select yes if you want to show nightly builds." msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:28 +#: lib/block_scout_web/views/internal_transaction_view.ex:33 #, elixir-autogen, elixir-format msgid "Self-Destruct" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:63 -#, elixir-autogen, elixir-format -msgid "Send request" -msgstr "" - #: lib/block_scout_web/templates/api_docs/_action_tile.html.eex:163 #: lib/block_scout_web/templates/api_docs/_eth_rpc_item.html.eex:124 #, elixir-autogen, elixir-format @@ -2478,12 +2402,12 @@ msgstr "" msgid "Shows total assets held in the address" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:33 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:32 #, elixir-autogen, elixir-format msgid "Sign in" msgstr "" -#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:24 +#: lib/block_scout_web/templates/layout/_account_menu_item.html.eex:23 #, elixir-autogen, elixir-format msgid "Sign out" msgstr "" @@ -2508,18 +2432,7 @@ msgstr "" msgid "Slow" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:21 -#, elixir-autogen, elixir-format -msgid "Smart contract / Address" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex:4 -#: lib/block_scout_web/templates/account/public_tags_request/address_field.html.eex:5 -#, elixir-autogen, elixir-format -msgid "Smart contract / Address (0x...)" -msgstr "" - -#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:28 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:31 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:26 #: lib/block_scout_web/views/verified_contracts_view.ex:10 #, elixir-autogen, elixir-format @@ -2536,7 +2449,7 @@ msgstr "" #: lib/block_scout_web/templates/address_withdrawal/index.html.eex:20 #: lib/block_scout_web/templates/block_transaction/index.html.eex:14 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:14 -#: lib/block_scout_web/templates/chain/show.html.eex:160 +#: lib/block_scout_web/templates/chain/show.html.eex:162 #: lib/block_scout_web/templates/pending_transaction/index.html.eex:18 #: lib/block_scout_web/templates/tokens/holder/index.html.eex:24 #: lib/block_scout_web/templates/tokens/instance/holder/index.html.eex:23 @@ -2554,7 +2467,7 @@ msgstr "" msgid "Something went wrong, click to reload." msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:225 +#: lib/block_scout_web/templates/chain/show.html.eex:227 #, elixir-autogen, elixir-format msgid "Something went wrong, click to retry." msgstr "" @@ -2591,12 +2504,12 @@ msgstr "" #: lib/block_scout_web/templates/transaction/_tabs.html.eex:29 #: lib/block_scout_web/templates/transaction_state/index.html.eex:6 -#: lib/block_scout_web/views/transaction_view.ex:555 +#: lib/block_scout_web/views/transaction_view.ex:553 #, elixir-autogen, elixir-format msgid "State changes" msgstr "" -#: lib/block_scout_web/views/internal_transaction_view.ex:24 +#: lib/block_scout_web/views/internal_transaction_view.ex:26 #, elixir-autogen, elixir-format msgid "Static Call" msgstr "" @@ -2606,18 +2519,13 @@ msgstr "" msgid "Status" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:22 -#, elixir-autogen, elixir-format -msgid "Submission date" -msgstr "" - #: lib/block_scout_web/templates/layout/_footer.html.eex:41 #, elixir-autogen, elixir-format msgid "Submit an Issue" msgstr "" #: lib/block_scout_web/templates/transaction/_emission_reward_tile.html.eex:8 -#: lib/block_scout_web/views/transaction_view.ex:376 +#: lib/block_scout_web/views/transaction_view.ex:374 #, elixir-autogen, elixir-format msgid "Success" msgstr "" @@ -2843,7 +2751,7 @@ msgstr "" msgid "This API is provided to support some rpc methods in the exact format specified for ethereum nodes, which can be found " msgstr "" -#: lib/block_scout_web/views/block_transaction_view.ex:11 +#: lib/block_scout_web/views/block_transaction_view.ex:15 #, elixir-autogen, elixir-format msgid "This block has not been processed yet." msgstr "" @@ -2888,7 +2796,7 @@ msgstr "" #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:34 #: lib/block_scout_web/templates/address_transaction/index.html.eex:28 #: lib/block_scout_web/templates/block_withdrawal/index.html.eex:29 -#: lib/block_scout_web/templates/transaction/overview.html.eex:267 +#: lib/block_scout_web/templates/transaction/overview.html.eex:244 #: lib/block_scout_web/templates/withdrawal/index.html.eex:32 #: lib/block_scout_web/views/address_internal_transaction_view.ex:10 #: lib/block_scout_web/views/address_token_transfer_view.ex:10 @@ -2921,13 +2829,13 @@ msgid "Token" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:3 -#: lib/block_scout_web/views/transaction_view.ex:488 +#: lib/block_scout_web/views/transaction_view.ex:486 #, elixir-autogen, elixir-format msgid "Token Burning" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:7 -#: lib/block_scout_web/views/transaction_view.ex:489 +#: lib/block_scout_web/views/transaction_view.ex:487 #, elixir-autogen, elixir-format msgid "Token Creation" msgstr "" @@ -2955,14 +2863,14 @@ msgid "Token ID" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:5 -#: lib/block_scout_web/views/transaction_view.ex:487 +#: lib/block_scout_web/views/transaction_view.ex:485 #, elixir-autogen, elixir-format msgid "Token Minting" msgstr "" #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:9 #: lib/block_scout_web/templates/common_components/_token_transfer_type_display_name.html.eex:11 -#: lib/block_scout_web/views/transaction_view.ex:490 +#: lib/block_scout_web/views/transaction_view.ex:488 #, elixir-autogen, elixir-format msgid "Token Transfer" msgstr "" @@ -2975,10 +2883,10 @@ msgstr "" #: lib/block_scout_web/templates/tokens/transfer/index.html.eex:15 #: lib/block_scout_web/templates/transaction/_tabs.html.eex:4 #: lib/block_scout_web/templates/transaction_token_transfer/index.html.eex:7 -#: lib/block_scout_web/views/address_view.ex:349 +#: lib/block_scout_web/views/address_view.ex:355 #: lib/block_scout_web/views/tokens/instance/overview_view.ex:74 #: lib/block_scout_web/views/tokens/overview_view.ex:39 -#: lib/block_scout_web/views/transaction_view.ex:551 +#: lib/block_scout_web/views/transaction_view.ex:549 #, elixir-autogen, elixir-format msgid "Token Transfers" msgstr "" @@ -2999,27 +2907,27 @@ msgstr "" #: lib/block_scout_web/templates/address_token_transfer/index.html.eex:13 #: lib/block_scout_web/templates/layout/_topnav.html.eex:84 #: lib/block_scout_web/templates/tokens/index.html.eex:10 -#: lib/block_scout_web/views/address_view.ex:346 +#: lib/block_scout_web/views/address_view.ex:352 #, elixir-autogen, elixir-format msgid "Tokens" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:336 +#: lib/block_scout_web/templates/transaction/overview.html.eex:313 #, elixir-autogen, elixir-format msgid "Tokens Burnt" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:352 +#: lib/block_scout_web/templates/transaction/overview.html.eex:329 #, elixir-autogen, elixir-format msgid "Tokens Created" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:319 +#: lib/block_scout_web/templates/transaction/overview.html.eex:296 #, elixir-autogen, elixir-format msgid "Tokens Minted" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:303 +#: lib/block_scout_web/templates/transaction/overview.html.eex:280 #, elixir-autogen, elixir-format msgid "Tokens Transferred" msgstr "" @@ -3061,7 +2969,7 @@ msgstr "" msgid "Total Supply * Price" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:133 +#: lib/block_scout_web/templates/chain/show.html.eex:135 #, elixir-autogen, elixir-format msgid "Total blocks" msgstr "" @@ -3081,12 +2989,12 @@ msgstr "" msgid "Total supply" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:383 +#: lib/block_scout_web/templates/transaction/overview.html.eex:360 #, elixir-autogen, elixir-format msgid "Total transaction fee." msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:112 +#: lib/block_scout_web/templates/chain/show.html.eex:114 #, elixir-autogen, elixir-format msgid "Total transactions" msgstr "" @@ -3094,7 +3002,7 @@ msgstr "" #: lib/block_scout_web/templates/account/tag_transaction/form.html.eex:11 #: lib/block_scout_web/templates/account/tag_transaction/index.html.eex:23 #: lib/block_scout_web/templates/address_logs/_logs.html.eex:19 -#: lib/block_scout_web/views/transaction_view.ex:500 +#: lib/block_scout_web/views/transaction_view.ex:498 #, elixir-autogen, elixir-format msgid "Transaction" msgstr "" @@ -3109,12 +3017,7 @@ msgstr "" msgid "Transaction %{transaction}, %{subnetwork} %{transaction}" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:225 -#, elixir-autogen, elixir-format, fuzzy -msgid "Transaction Action" -msgstr "" - -#: lib/block_scout_web/templates/transaction/overview.html.eex:470 +#: lib/block_scout_web/templates/transaction/overview.html.eex:447 #, elixir-autogen, elixir-format msgid "Transaction Burnt Fee" msgstr "" @@ -3124,7 +3027,7 @@ msgstr "" msgid "Transaction Details" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:384 +#: lib/block_scout_web/templates/transaction/overview.html.eex:361 #, elixir-autogen, elixir-format msgid "Transaction Fee" msgstr "" @@ -3147,17 +3050,17 @@ msgstr "" msgid "Transaction Tags" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:414 +#: lib/block_scout_web/templates/transaction/overview.html.eex:391 #, elixir-autogen, elixir-format msgid "Transaction Type" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:534 +#: lib/block_scout_web/templates/transaction/overview.html.eex:511 #, elixir-autogen, elixir-format msgid "Transaction number from the sending address. Each transaction sent from an address increments the nonce by 1." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:413 +#: lib/block_scout_web/templates/transaction/overview.html.eex:390 #, elixir-autogen, elixir-format msgid "Transaction type, introduced in EIP-2718." msgstr "" @@ -3170,9 +3073,9 @@ msgstr "" #: lib/block_scout_web/templates/address_transaction/index.html.eex:13 #: lib/block_scout_web/templates/block/_tabs.html.eex:4 #: lib/block_scout_web/templates/block/overview.html.eex:80 -#: lib/block_scout_web/templates/chain/show.html.eex:216 +#: lib/block_scout_web/templates/chain/show.html.eex:218 #: lib/block_scout_web/templates/layout/_topnav.html.eex:49 -#: lib/block_scout_web/views/address_view.ex:348 +#: lib/block_scout_web/views/address_view.ex:354 #, elixir-autogen, elixir-format msgid "Transactions" msgstr "" @@ -3233,7 +3136,7 @@ msgstr "" msgid "UML diagram" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:560 +#: lib/block_scout_web/templates/transaction/overview.html.eex:537 #, elixir-autogen, elixir-format msgid "UTF-8" msgstr "" @@ -3249,7 +3152,7 @@ msgstr "" msgid "Uncles" msgstr "" -#: lib/block_scout_web/views/transaction_view.ex:367 +#: lib/block_scout_web/views/transaction_view.ex:365 #, elixir-autogen, elixir-format msgid "Unconfirmed" msgstr "" @@ -3270,12 +3173,12 @@ msgstr "" msgid "Update" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:449 +#: lib/block_scout_web/templates/transaction/overview.html.eex:426 #, elixir-autogen, elixir-format msgid "User defined maximum fee (tip) per unit of gas paid to validator for transaction prioritization." msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:459 +#: lib/block_scout_web/templates/transaction/overview.html.eex:436 #, elixir-autogen, elixir-format msgid "User-defined tip sent to validator for transaction priority/inclusion." msgstr "" @@ -3317,12 +3220,12 @@ msgstr "" msgid "Validator index" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:369 +#: lib/block_scout_web/templates/transaction/overview.html.eex:346 #, elixir-autogen, elixir-format msgid "Value" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:368 +#: lib/block_scout_web/templates/transaction/overview.html.eex:345 #, elixir-autogen, elixir-format msgid "Value sent in the native token (and USD) if applicable." msgstr "" @@ -3371,7 +3274,7 @@ msgstr "" #: lib/block_scout_web/templates/address_contract_verification_via_flattened_code/new.html.eex:111 #: lib/block_scout_web/templates/address_contract_verification_via_json/new.html.eex:37 -#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:103 +#: lib/block_scout_web/templates/address_contract_verification_via_multi_part_files/new.html.eex:101 #: lib/block_scout_web/templates/address_contract_verification_via_standard_json_input/new.html.eex:51 #: lib/block_scout_web/templates/address_contract_verification_vyper/new.html.eex:47 #, elixir-autogen, elixir-format @@ -3411,12 +3314,12 @@ msgstr "" msgid "Via multi-part files" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:155 +#: lib/block_scout_web/templates/chain/show.html.eex:157 #, elixir-autogen, elixir-format msgid "View All Blocks" msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:215 +#: lib/block_scout_web/templates/chain/show.html.eex:217 #, elixir-autogen, elixir-format msgid "View All Transactions" msgstr "" @@ -3472,7 +3375,7 @@ msgstr "" msgid "View transaction %{transaction} on %{subnetwork}" msgstr "" -#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:28 +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:29 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:32 #: lib/block_scout_web/views/verified_contracts_view.ex:11 #, elixir-autogen, elixir-format @@ -3494,7 +3397,7 @@ msgstr "" msgid "Waiting for transaction's confirmation..." msgstr "" -#: lib/block_scout_web/templates/chain/show.html.eex:141 +#: lib/block_scout_web/templates/chain/show.html.eex:143 #, elixir-autogen, elixir-format msgid "Wallet addresses" msgstr "" @@ -3537,14 +3440,14 @@ msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:95 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:34 -#: lib/block_scout_web/views/address_view.ex:353 +#: lib/block_scout_web/views/address_view.ex:359 #, elixir-autogen, elixir-format msgid "Write Contract" msgstr "" #: lib/block_scout_web/templates/address/_tabs.html.eex:102 #: lib/block_scout_web/templates/tokens/overview/_tabs.html.eex:48 -#: lib/block_scout_web/views/address_view.ex:354 +#: lib/block_scout_web/views/address_view.ex:360 #, elixir-autogen, elixir-format msgid "Write Proxy" msgstr "" @@ -3568,11 +3471,6 @@ msgstr "" msgid "You can create up to 15 Custom ABIs per account." msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/index.html.eex:11 -#, elixir-autogen, elixir-format -msgid "You can request a public category tag which is displayed to all Blockscout users. Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag. Clicking a tag opens a page with related information and helps provide context and data organization. Requests are sent to a moderator for review and approval. This process can take several days." -msgstr "" - #: lib/block_scout_web/templates/account/tag_address/index.html.eex:14 #, elixir-autogen, elixir-format msgid "You don't have address tags yet" @@ -3588,21 +3486,12 @@ msgstr "" msgid "You don't have transaction tags yet" msgstr "" -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:15 -#, elixir-autogen, elixir-format -msgid "Your name" -msgstr "" - -#: lib/block_scout_web/templates/account/public_tags_request/form.html.eex:14 -#, elixir-autogen, elixir-format -msgid "Your name*" -msgstr "" - #: lib/block_scout_web/templates/error422/index.html.eex:8 #, elixir-autogen, elixir-format msgid "Your request contained an error, perhaps a mistyped tx/block/address hash. Try again, and check the developer tools console for more info." msgstr "" +#: lib/block_scout_web/templates/verified_contracts/_contract.html.eex:30 #: lib/block_scout_web/templates/verified_contracts/index.html.eex:38 #: lib/block_scout_web/views/verified_contracts_view.ex:12 #, elixir-autogen, elixir-format @@ -3619,7 +3508,7 @@ msgstr "" msgid "balance of the address" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:469 +#: lib/block_scout_web/templates/transaction/overview.html.eex:446 #, elixir-autogen, elixir-format msgid "burnt for this transaction. Equals Block Base Fee per Gas * Gas Used." msgstr "" @@ -3634,7 +3523,7 @@ msgstr "" msgid "button" msgstr "" -#: lib/block_scout_web/templates/transaction/overview.html.eex:276 +#: lib/block_scout_web/templates/transaction/overview.html.eex:253 #, elixir-autogen, elixir-format msgid "created" msgstr "" @@ -3734,3 +3623,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "truffle flattener" msgstr "" + +#: lib/block_scout_web/views/transaction_view.ex:224 +#, elixir-autogen, elixir-format, fuzzy +msgid "ERC-7984 " +msgstr "" + +#: lib/block_scout_web/views/transaction_view.ex:223 +#, elixir-autogen, elixir-format, fuzzy +msgid "ZRC-2 " +msgstr "" diff --git a/apps/block_scout_web/priv/rate_limit_config.json b/apps/block_scout_web/priv/rate_limit_config.json new file mode 100644 index 000000000000..1b1b72180b75 --- /dev/null +++ b/apps/block_scout_web/priv/rate_limit_config.json @@ -0,0 +1,137 @@ +{ + "api/v2/key": { + "ignore": true + }, + "api/v2/import/token-info": { + "ignore": true + }, + "api/v2/import/smart-contracts/:param": { + "ignore": true + }, + "api/account/v2/authenticate_via_wallet": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 1 + }, + "isolate_rate_limit?": true + }, + "api/account/v2/send_otp": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 1 + }, + "isolate_rate_limit?": true + }, + "api/account/v2/user/info": { + "static_api_key": true, + "whitelisted_ip": true, + "ip": true, + "temporary_token": true, + "isolate_rate_limit?": true + }, + "api/v2/tokens/:param/instances/:param/refetch-metadata": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "bypass_token_scope": "token_instance_refetch_metadata", + "isolate_rate_limit?": true + }, + "api/v2/advanced-filters/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/tokens/:param/holders/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/addresses/:param/transactions/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/addresses/:param/token-transfers/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/addresses/:param/internal-transactions/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/addresses/:param/logs/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/addresses/:param/election-rewards/csv": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/v2/smart-contracts/:param/audit-reports": { + "temporary_token": { + "period": "1h", + "limit": 50 + }, + "ip": { + "period": "1h", + "limit": 50 + }, + "isolate_rate_limit?": true + }, + "api/health/*": { + "ip": { + "period": "1s", + "limit": 5 + }, + "isolate_rate_limit?": true + }, + "api/v2/*": { + "static_api_key": true, + "account_api_key": true, + "whitelisted_ip": true, + "ip": true, + "temporary_token": true + }, + "api/account/v2/*": { + "static_api_key": true, + "whitelisted_ip": true, + "ip": true, + "temporary_token": true + }, + "default": { + "static_api_key": true, + "account_api_key": true, + "whitelisted_ip": true, + "ip": true, + "temporary_token": false + } +} diff --git a/apps/block_scout_web/rate_limits.md b/apps/block_scout_web/rate_limits.md new file mode 100644 index 000000000000..6eaa1eaf6f5c --- /dev/null +++ b/apps/block_scout_web/rate_limits.md @@ -0,0 +1,99 @@ +# Rate Limits Configuration + +Rate limits can be defined in a JSON configuration file `apps/block_scout_web/priv/rate_limit_config.json` or passed to `API_RATE_LIMIT_CONFIG_URL` as a URL to the JSON file. + +## Configuration Structure + +The JSON configuration is a map where: +- **Keys**: API endpoint paths +- **Values**: Rate limit configuration + +### Example Configuration +```json +"api/account/v2/send_otp": { + "recaptcha_to_bypass_429": true, + "ip": { + "period": "1h", + "limit": 1 + } +} +``` + +### Path Rules +- Paths should not contain query parameters +- Paths should not contain trailing slashes +- Paths can contain: + - `*` - Works as a wildcard (matches any path starting from the asterisk) + - Example: `api/v2/*` matches `api/v2` and `api/v2/addresses` + - ⚠️ Wildcard `*` allowed only at the end of the path + - `:param` - Represents a variable parameter in the endpoint path + - Example: `api/v2/addresses/:param` matches `api/v2/addresses/0x00000..000` +- ⚠️ It's not allowed to use `*` and `:param` simultaneously + +### Path Matching Priority +1. Paths without `:param` and `*` +2. Paths with `:param` +3. Paths with `*` + +### Default Configuration +The config must contain a `default` key that defines the default API rate limit configuration. This will be used for endpoints that don't match any defined paths in the config. + +> **Note**: GraphQL endpoints are out of scope for this config. Their rate limits are based on ENVs: `API_GRAPHQL_RATE_LIMIT_*` + +## Rate Limit Options + +Each rate limit entry can contain the following keys: + +### Rate Limit Methods +- `account_api_key` - Allows using API key emitted in My Account + > **Important**: When overriding `account_api_key`, ensure your limits are much lower than the default ones +- `whitelisted_ip` - Allows rate limiting by whitelisted IP +- `static_api_key` - Allows rate limiting by static API key +- `temporary_token` - Allows rate limiting by temporary token (cookie) issued by `/api/v2/key` +- `ip` - Allows rate limiting by IP address + +### Additional Options +- `cost` - Integer value used to decrease allowed limit (default: `1`) +- `ignore` - If `true`, the endpoint won't be rate limited +- `recaptcha_to_bypass_429` - If `true`, allows passing recaptcha header with response to bypass 429 errors. +- `bypass_token_scope` - Scope of recaptcha bypass token (currently only supports `token_instance_refetch_metadata`) +- `isolate_rate_limit?` - If `true`, creates a separate rate limit bucket for this endpoint. Instead of using the shared rate limit key, it prepends the endpoint path to create an isolated bucket (e.g., `api/v2/address_127.0.0.1` for the `/api/v2/address` endpoint with IP-based rate limiting). + +⚠️ It is recommended to use either `recaptcha_to_bypass_429` or `temporary_token`, not both. + +### Rate Limit Option Values +Each rate limit method can have one of these values: +- `true` - Rate limit option is allowed, limits pulled from ENVs +- `false` or omitted - Rate limit option is disabled +- Map with configuration: + - `limit` - Integer value representing max requests allowed per period + - `period` - Rate limit time period in [time format](https://docs.blockscout.com/setup/env-variables/backend-env-variables#time-format) + +## ReCAPTCHA Implementation + +ReCAPTCHA responses should be passed via headers: +- `recaptcha-v2-response` - For V2 captcha +- `recaptcha-v3-response` - For V3 captcha +- `recaptcha-bypass-token` - For non-scoped bypass recaptcha token +- `scoped-recaptcha-bypass-token` - For scoped bypass recaptcha token (currently only supports `token_instance_refetch_metadata` scope) + +> **Note**: ReCAPTCHA for `/api/v2/key` endpoint should be sent in the request body. + +## Rate Limit Headers + +The backend returns informational headers: +- `X-RateLimit-Limit` - Total limit per timeframe +- `X-RateLimit-Remaining` - Remaining requests within current time window +- `X-RateLimit-Reset` - Time to reset rate limits in milliseconds + +These headers may return `-1` in case of: +- Internal errors +- `API_NO_RATE_LIMIT_API_KEY` is used +- Rate limits are disabled on the backend +- The endpoint has `"ignore": true` parameter set + +### Bypass Options Header +The `bypass-429-option` header indicates how to bypass rate limits: +- `recaptcha` - Use ReCAPTCHA response in headers +- `temporary_token` - Get temporary cookie from `/api/v2/key` endpoint +- `no_bypass` - No way to bypass 429 error diff --git a/apps/block_scout_web/test/block_scout_web/chain_test.exs b/apps/block_scout_web/test/block_scout_web/chain_test.exs index efbde82db628..6ba2fcacd50b 100644 --- a/apps/block_scout_web/test/block_scout_web/chain_test.exs +++ b/apps/block_scout_web/test/block_scout_web/chain_test.exs @@ -1,9 +1,82 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ChainTest do use Explorer.DataCase - alias Explorer.Chain.{Address, Block, Transaction} + alias Explorer.Chain.{Address, Block, Transaction, TokenTransfer} alias BlockScoutWeb.Chain + describe "next_page_params/4" do + # https://github.com/blockscout/blockscout/issues/12984 + test "does not return duplicated keys" do + assert Chain.next_page_params([nil], [%{id: 123}], %{"id" => 178}, false, fn x -> x end) == %{ + items_count: 1, + id: 123 + } + end + end + + describe "token_transfers_next_page_params/3" do + # https://github.com/blockscout/blockscout/issues/12984 + test "does not return duplicated keys" do + assert Chain.token_transfers_next_page_params( + [%TokenTransfer{block_number: 1, log_index: 3}], + [%TokenTransfer{block_number: 1, log_index: 2}], + %{ + "block_number" => 5, + "index" => 4 + } + ) == %{ + block_number: 1, + index: 2 + } + end + + test "does not return duplicated keys with batch transfer" do + assert Chain.token_transfers_next_page_params( + [ + %TokenTransfer{ + block_number: 1, + log_index: 2, + block_hash: "0x123", + transaction_hash: "0x456", + index_in_batch: 1 + } + ], + [ + %TokenTransfer{ + block_number: 1, + log_index: 2, + block_hash: "0xabc", + transaction_hash: "0xdef", + index_in_batch: 3 + }, + %TokenTransfer{ + block_number: 1, + log_index: 2, + block_hash: "0x123", + transaction_hash: "0x456", + index_in_batch: 2 + } + ], + %{ + "block_number" => 5, + "index" => 4, + "batch_log_index" => 3, + "batch_block_hash" => "0x789", + "batch_transaction_hash" => "0xabc", + "index_in_batch" => 2 + } + ) == %{ + :block_number => 1, + :index => 2, + :batch_block_hash => "0x123", + :batch_log_index => 2, + :batch_transaction_hash => "0x456", + :index_in_batch => 2 + } + end + end + describe "current_filter/1" do test "sets direction based on to filter" do assert [direction: :to] = Chain.current_filter(%{"filter" => "to"}) @@ -58,11 +131,11 @@ defmodule BlockScoutWeb.ChainTest do end end - describe "Poison.encode!" do + describe "Jason.encode!" do test "correctly encodes decimal values" do val = Decimal.from_float(5.55) - assert "\"5.55\"" == Poison.encode!(val) + assert "\"5.55\"" == Jason.encode!(val) end end end diff --git a/apps/block_scout_web/test/block_scout_web/channels/address_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/address_channel_test.exs index d4da4238a8d8..a5e18fb3dd0d 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/address_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/address_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressChannelTest do use BlockScoutWeb.ChannelCase, # ETS tables are shared in `Explorer.Chain.Cache.Counters.AddressesCount` @@ -165,9 +166,9 @@ defmodule BlockScoutWeb.AddressChannelTest do :internal_transaction, transaction: transaction, from_address: address, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) Notifier.handle_event({:chain_event, :internal_transactions, :realtime, [internal_transaction]}) @@ -183,7 +184,7 @@ defmodule BlockScoutWeb.AddressChannelTest do :timer.seconds(5) assert address_hash == address.hash - assert {transaction_hash, index} == {internal_transaction.transaction_hash, internal_transaction.index} + assert {transaction_hash, index} == {transaction.hash, internal_transaction.index} end test "notified of new_internal_transaction for matching to_address", %{address: address, topic: topic} do @@ -194,11 +195,10 @@ defmodule BlockScoutWeb.AddressChannelTest do internal_transaction = insert(:internal_transaction, - transaction: transaction, to_address: address, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) Notifier.handle_event({:chain_event, :internal_transactions, :realtime, [internal_transaction]}) @@ -208,13 +208,19 @@ defmodule BlockScoutWeb.AddressChannelTest do event: "internal_transaction", payload: %{ address: %{hash: address_hash}, - internal_transaction: %{transaction_hash: transaction_hash, index: index} + internal_transaction: %{ + block_number: block_number, + transaction_index: transaction_index, + index: index + } } }, :timer.seconds(5) assert address_hash == address.hash - assert {transaction_hash, index} == {internal_transaction.transaction_hash, internal_transaction.index} + + assert {block_number, transaction_index, index} == + {internal_transaction.block_number, internal_transaction.transaction_index, internal_transaction.index} end test "not notified twice of new_internal_transaction if to and from address are equal", %{ @@ -228,12 +234,11 @@ defmodule BlockScoutWeb.AddressChannelTest do internal_transaction = insert(:internal_transaction, - transaction: transaction, from_address: address, to_address: address, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) Notifier.handle_event({:chain_event, :internal_transactions, :realtime, [internal_transaction]}) @@ -243,13 +248,13 @@ defmodule BlockScoutWeb.AddressChannelTest do event: "internal_transaction", payload: %{ address: %{hash: address_hash}, - internal_transaction: %{transaction_hash: transaction_hash, index: index} + internal_transaction: %{transaction_index: transaction_index, index: index} } }, :timer.seconds(5) assert address_hash == address.hash - assert {transaction_hash, index} == {internal_transaction.transaction_hash, internal_transaction.index} + assert {transaction_index, index} == {internal_transaction.transaction_index, internal_transaction.index} refute_receive _, 100, "Received duplicate broadcast." end diff --git a/apps/block_scout_web/test/block_scout_web/channels/block_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/block_channel_test.exs index bde9e587fd48..467f9eeb7da5 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/block_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/block_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockChannelTest do use BlockScoutWeb.ChannelCase diff --git a/apps/block_scout_web/test/block_scout_web/channels/exchange_rate_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/exchange_rate_channel_test.exs index 87bd37080bd0..546791d48862 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/exchange_rate_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/exchange_rate_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ExchangeRateChannelTest do use BlockScoutWeb.ChannelCase @@ -6,7 +7,7 @@ defmodule BlockScoutWeb.ExchangeRateChannelTest do alias BlockScoutWeb.Notifier alias Explorer.Market alias Explorer.Market.Fetcher.Coin - alias Explorer.Market.{MarketHistory, Token} + alias Explorer.Market.{MarketHistory, MarketHistoryCache, Token} alias Explorer.Market.Source.TestSource setup :verify_on_exit! @@ -32,7 +33,8 @@ defmodule BlockScoutWeb.ExchangeRateChannelTest do symbol: Explorer.coin(), fiat_value: Decimal.new("2.5"), volume_24h: Decimal.new("1000.0"), - image_url: nil + image_url: nil, + circulating_supply: nil } on_exit(fn -> @@ -46,8 +48,8 @@ defmodule BlockScoutWeb.ExchangeRateChannelTest do describe "new_rate" do test "subscribed user is notified", %{token: token} do Coin.handle_info({nil, {{:ok, token}, false}}, %{}) - Supervisor.terminate_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()}) - Supervisor.restart_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()}) + ConCache.delete(MarketHistoryCache.cache_name(), MarketHistoryCache.updated_at_key()) + ConCache.delete(MarketHistoryCache.cache_name(), MarketHistoryCache.data_key()) topic = "exchange_rate_old:new_rate" @endpoint.subscribe(topic) @@ -65,9 +67,16 @@ defmodule BlockScoutWeb.ExchangeRateChannelTest do end test "subscribed user is notified with market history", %{token: token} do + initial_value = :persistent_term.get(:market_history_fetcher_enabled, false) + :persistent_term.put(:market_history_fetcher_enabled, true) + + on_exit(fn -> + :persistent_term.put(:market_history_fetcher_enabled, initial_value) + end) + Coin.handle_info({nil, {{:ok, token}, false}}, %{}) - Supervisor.terminate_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()}) - Supervisor.restart_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()}) + ConCache.delete(MarketHistoryCache.cache_name(), MarketHistoryCache.updated_at_key()) + ConCache.delete(MarketHistoryCache.cache_name(), MarketHistoryCache.data_key()) today = Date.utc_today() @@ -83,7 +92,10 @@ defmodule BlockScoutWeb.ExchangeRateChannelTest do MarketHistory.bulk_insert(records) - Market.fetch_recent_history() + ConCache.delete(MarketHistoryCache.cache_name(), MarketHistoryCache.updated_at_key()) + ConCache.delete(MarketHistoryCache.cache_name(), MarketHistoryCache.data_key()) + + assert Enum.map(Market.fetch_recent_history(), &Map.take(&1, [:date, :closing_price])) == records topic = "exchange_rate_old:new_rate" @endpoint.subscribe(topic) diff --git a/apps/block_scout_web/test/block_scout_web/channels/reward_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/reward_channel_test.exs index 06d6241c9d88..84b2bf24cf51 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/reward_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/reward_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.RewardChannelTest do use BlockScoutWeb.ChannelCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/channels/transaction_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/transaction_channel_test.exs index b41454e11071..c13128ecac4d 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/transaction_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/transaction_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionChannelTest do use BlockScoutWeb.ChannelCase diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/address_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/address_channel_test.exs index b7a245e2b7dd..17dd49c362e7 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/address_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/address_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.AddressChannelTest do use BlockScoutWeb.ChannelCase, # ETS tables are shared in `Explorer.Chain.Cache.Counters.AddressesCount` diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs index 2610a730baf3..0afa7f4b0b0a 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/block_channel_test.exs @@ -1,13 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.BlockChannelTest do use BlockScoutWeb.ChannelCase alias BlockScoutWeb.Notifier + alias Explorer.Chain.Address alias Explorer.Chain.Cache.Counters.AverageBlockTime + alias Plug.Conn - test "subscribed user is notified of new_block event" do + setup do + old_notifier = Application.get_env(:block_scout_web, Notifier, []) topic = "blocks:new_block" @endpoint.subscribe(topic) + on_exit(fn -> + Application.put_env(:block_scout_web, Notifier, old_notifier) + Phoenix.PubSub.unsubscribe(BlockScoutWeb.PubSub, topic) + end) + + {:ok, topic: topic} + end + + test "subscribed user is notified of new_block event", %{topic: topic} do block = insert(:block, number: 1) start_supervised!(AverageBlockTime) @@ -27,4 +40,199 @@ defmodule BlockScoutWeb.V2.BlockChannelTest do assert false, "Expected message received nothing." end end + + test "user is able to join to common channels", %{topic: topic} do + common_channels = ["new_block", "indexing", "indexing_internal_transactions"] + + Enum.each(common_channels, fn channel -> + assert {:ok, _reply, _socket} = + BlockScoutWeb.V2.UserSocket + |> socket("no_id", %{}) + |> subscribe_and_join("blocks:#{channel}") + end) + end + + test "new_block payload includes miner ENS and metadata when microservices are enabled", %{topic: topic} do + bypass = Bypass.open() + + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + old_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + old_tesla_adapter = Application.get_env(:tesla, :adapter) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true, + protocols: [] + ) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata) + Application.put_env(:tesla, :adapter, old_tesla_adapter) + end) + + miner = insert(:address) + + Bypass.expect_once(bypass, "POST", "/api/v1/#{chain_id}/addresses:batch_resolve_names", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "names" => %{ + Address.checksum(miner.hash) => "miner.eth" + } + }) + ) + end) + + Bypass.expect_once(bypass, "GET", "/api/v1/metadata", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "addresses" => %{ + Address.checksum(miner.hash) => %{ + "tags" => [] + } + } + }) + ) + end) + + block = insert(:block, number: 1, miner: miner) + + start_supervised!(AverageBlockTime) + Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + + on_exit(fn -> + Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) + end) + + Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) + + receive do + %Phoenix.Socket.Broadcast{topic: ^topic, event: "new_block", payload: %{block: block_payload}} -> + assert block_payload["miner"]["ens_domain_name"] == "miner.eth" + assert block_payload["miner"]["metadata"] == %{"tags" => []} + after + :timer.seconds(5) -> + assert false, "Expected message received nothing." + end + end + + test "new_block broadcast skips enrichment when DISABLE_BLOCK_BROADCAST_ENRICHMENT is set", %{topic: topic} do + bypass = Bypass.open() + + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + old_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + + Application.put_env(:block_scout_web, :chain_id, 1) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true, + protocols: [] + ) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_disabled: true) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata) + end) + + # No Bypass.expect calls — any HTTP call to the microservices would cause Bypass to raise + Bypass.pass(bypass) + + miner = insert(:address) + block = insert(:block, number: 1, miner: miner) + + start_supervised!(AverageBlockTime) + Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + + on_exit(fn -> + Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) + end) + + Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) + + receive do + %Phoenix.Socket.Broadcast{topic: ^topic, event: "new_block", payload: %{block: block_payload}} -> + assert is_nil(block_payload["miner"]["ens_domain_name"]) + after + :timer.seconds(5) -> + assert false, "Expected message received nothing." + end + end + + test "new_block broadcast falls back quickly when enrichment services are unavailable", %{topic: topic} do + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + old_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + old_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + old_tesla_adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + + Application.put_env(:block_scout_web, Notifier, block_broadcast_enrichment_timeout: 50) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://127.0.0.1:9", + enabled: true, + protocols: [] + ) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, + service_url: "http://127.0.0.1:9", + enabled: true + ) + + on_exit(fn -> + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata) + Application.put_env(:tesla, :adapter, old_tesla_adapter) + end) + + miner = insert(:address) + + block = insert(:block, number: 1, miner: miner) + + start_supervised!(AverageBlockTime) + Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + + on_exit(fn -> + Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) + end) + + timeout = + Application.get_env(:block_scout_web, Notifier, []) + |> Keyword.get(:block_broadcast_enrichment_timeout, 200) + + Notifier.handle_event({:chain_event, :blocks, :realtime, [block]}) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: "new_block", payload: %{block: _}}, timeout + 200 + end end diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/exchange_rate_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/exchange_rate_channel_test.exs index bb4df6801ecd..58e1806d3ec4 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/exchange_rate_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/exchange_rate_channel_test.exs @@ -1,11 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.ExchangeRateChannelTest do use BlockScoutWeb.ChannelCase import Mox alias BlockScoutWeb.Notifier - alias Explorer.Market.Fetcher.{Coin, History} - alias Explorer.Market.{MarketHistory, Token} + alias Explorer.Market.Fetcher.Coin + alias Explorer.Market.{MarketHistory, MarketHistoryCache, Token} alias Explorer.Market.Source.OneCoinSource alias Explorer.Market @@ -31,7 +32,8 @@ defmodule BlockScoutWeb.V2.ExchangeRateChannelTest do symbol: Explorer.coin(), fiat_value: Decimal.new(1), volume_24h: Decimal.new(1), - image_url: nil + image_url: nil, + circulating_supply: nil } on_exit(fn -> @@ -64,7 +66,31 @@ defmodule BlockScoutWeb.V2.ExchangeRateChannelTest do end test "subscribed user is notified with market history", %{token: token} do - Coin.handle_info({nil, {{:ok, token}, false}}, %{}) + initial_market_history_fetcher_enabled_value = :persistent_term.get(:market_history_fetcher_enabled, false) + :persistent_term.put(:market_history_fetcher_enabled, true) + Supervisor.terminate_child(Explorer.Supervisor, {ConCache, MarketHistoryCache.cache_name()}) + Supervisor.restart_child(Explorer.Supervisor, {ConCache, MarketHistoryCache.cache_name()}) + + source_configuration = Application.get_env(:explorer, Explorer.Market.Source) + fetcher_configuration = Application.get_env(:explorer, Coin) + + Application.put_env(:explorer, Explorer.Market.Source, + native_coin_source: OneCoinSource, + secondary_coin_source: OneCoinSource + ) + + Application.put_env(:explorer, Coin, Keyword.merge(fetcher_configuration, table_name: :rates, enabled: true)) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, source_configuration) + Application.put_env(:explorer, Coin, fetcher_configuration) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) + :persistent_term.put(:market_history_fetcher_enabled, initial_market_history_fetcher_enabled_value) + end) + + {:ok, state} = Coin.init([]) + Coin.handle_info({nil, {{:ok, token}, false}}, state) Supervisor.terminate_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()}) Supervisor.restart_child(Explorer.Supervisor, {ConCache, Explorer.Market.MarketHistoryCache.cache_name()}) @@ -94,7 +120,7 @@ defmodule BlockScoutWeb.V2.ExchangeRateChannelTest do assert payload.exchange_rate == token.fiat_value assert payload.chart_data == records after - :timer.seconds(5) -> + :timer.seconds(10) -> assert false, "Expected message received nothing." end end diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/reward_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/reward_channel_test.exs index 41709a8c6af4..5c22fac40868 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/reward_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/reward_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.RewardChannelTest do use BlockScoutWeb.ChannelCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/transaction_channel_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/transaction_channel_test.exs index c1c7c428d6d8..45e7be81f1ea 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/transaction_channel_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/transaction_channel_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.TransactionChannelTest do use BlockScoutWeb.ChannelCase diff --git a/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs b/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs index 6b974d7d994e..448a4f616f8a 100644 --- a/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs +++ b/apps/block_scout_web/test/block_scout_web/channels/v2/websocket_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.V2.WebsocketTest do use BlockScoutWeb.ChannelCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/authenticate_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/authenticate_controller_test.exs new file mode 100644 index 000000000000..d525d34bdb46 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/authenticate_controller_test.exs @@ -0,0 +1,678 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Account.API.V2.AuthenticateControllerTest do + use BlockScoutWeb.ConnCase, async: false + + alias Explorer.Account.Identity + alias Explorer.Chain.Address + alias Explorer.Helper + alias Explorer.ThirdPartyIntegrations.Auth0.Internal + alias Explorer.ThirdPartyIntegrations.Dynamic + alias Explorer.ThirdPartyIntegrations.Dynamic.Strategy + + import Mox + + setup do + Redix.command(:redix, ["DEL", Helper.redis_key(Internal.redis_key())]) + + :ok + end + + describe "POST api/account/v2/send_otp" do + test "send OTP successfully", %{conn: conn} do + Tesla.Test.expect_tesla_call( + times: 3, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + query: [], + headers: [{"Content-type", "application/json"}], + body: + ~s|{"audience":"https://example.com/api/v2/","client_id":"client_id","client_secret":"secrets","grant_type":"client_credentials"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token": "test_token", "expires_in": 86400}) + }} + + %Tesla.Env{ + method: :get, + url: "https://example.com/api/v2/users", + query: [q: ~s|email:"test@example.com" OR user_metadata.email:"test@example.com"|], + headers: [{"accept", "application/json"}, {"authorization", "Bearer test_token"}], + body: "" + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|[]| + }} + + %{ + method: :post, + url: "https://example.com/passwordless/start", + query: %{}, + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ], + body: + ~s|{"send":"code","connection":"email","email":"test@example.com","client_id":"client_id","client_secret":"secrets"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|{"_id":"123","email":"test@example.com","email_verified":false}| + }} + end + ) + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/send_otp", JSON.encode!(%{"email" => "test@example.com"})) + |> json_response(200) + + assert response == %{"message" => "Success"} + end + + test "send OTP for linking email to an existing account successfully", %{conn: conn} do + auth = :auth |> build() |> put_in([Access.key!(:info), Access.key!(:email)], nil) + {:ok, user} = Identity.find_or_create(auth) + conn_with_user = Plug.Test.init_test_session(conn, current_user: user) + + Tesla.Test.expect_tesla_call( + times: 3, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + query: [], + headers: [{"Content-type", "application/json"}], + body: + ~s|{"audience":"https://example.com/api/v2/","client_id":"client_id","client_secret":"secrets","grant_type":"client_credentials"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token": "test_token", "expires_in": 86400}) + }} + + %Tesla.Env{ + method: :get, + url: "https://example.com/api/v2/users", + query: [q: ~s|email:"test@example.com" OR user_metadata.email:"test@example.com"|], + headers: [{"accept", "application/json"}, {"authorization", "Bearer test_token"}], + body: "" + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|[]| + }} + + %{ + method: :post, + url: "https://example.com/passwordless/start", + query: %{}, + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ], + body: + ~s|{"send":"code","connection":"email","email":"test@example.com","client_id":"client_id","client_secret":"secrets"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|{"_id":"123","email":"test@example.com","email_verified":false}| + }} + end + ) + + response = + conn_with_user + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/send_otp", JSON.encode!(%{"email" => "test@example.com"})) + |> json_response(200) + + assert response == %{"message" => "Success"} + end + + test "do not send OTP for linking email to an existing account when email is already linked", %{conn: conn} do + auth = :auth |> build() |> put_in([Access.key!(:info), Access.key!(:email)], nil) + {:ok, user} = Identity.find_or_create(auth) + conn_with_user = Plug.Test.init_test_session(conn, current_user: user) + + Tesla.Test.expect_tesla_call( + times: 3, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + query: [], + headers: [{"Content-type", "application/json"}], + body: + ~s|{"audience":"https://example.com/api/v2/","client_id":"client_id","client_secret":"secrets","grant_type":"client_credentials"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token": "test_token", "expires_in": 86400}) + }} + + %Tesla.Env{ + method: :get, + url: "https://example.com/api/v2/users", + query: [q: ~s|email:"test@example.com" OR user_metadata.email:"test@example.com"|], + headers: [{"accept", "application/json"}, {"authorization", "Bearer test_token"}], + body: "" + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + ~s([{"identities":[{"connection":"email","user_id":"123","provider":"email","isSocial":false}],"user_id":"email|123","email":"test@example.com"}]) + }} + + %{ + method: :post, + url: "https://example.com/passwordless/start", + query: %{}, + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ], + body: + ~s|{"send":"code","connection":"email","email":"test@example.com","client_id":"client_id","client_secret":"secrets"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|{"_id":"123","email":"test@example.com","email_verified":false}| + }} + end + ) + + response = + conn_with_user + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/send_otp", JSON.encode!(%{"email" => "test@example.com"})) + |> json_response(500) + + assert response == %{"message" => "Account with this email already exists"} + end + + test "do nothing for an account with an existing email", %{conn: conn} do + auth = build(:auth) + {:ok, user} = Identity.find_or_create(auth) + conn_with_user = Plug.Test.init_test_session(conn, current_user: user) + + Tesla.Test.expect_tesla_call( + times: 3, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + query: [], + headers: [{"Content-type", "application/json"}], + body: + ~s|{"audience":"https://example.com/api/v2/","client_id":"client_id","client_secret":"secrets","grant_type":"client_credentials"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token": "test_token", "expires_in": 86400}) + }} + + %Tesla.Env{ + method: :get, + url: "https://example.com/api/v2/users", + query: [q: ~s|email:"test@example.com" OR user_metadata.email:"test@example.com"|], + headers: [{"accept", "application/json"}, {"authorization", "Bearer test_token"}], + body: "" + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|[]| + }} + + %{ + method: :post, + url: "https://example.com/passwordless/start", + query: %{}, + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ], + body: + ~s|{"send":"code","connection":"email","email":"test@example.com","client_id":"client_id","client_secret":"secrets"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s|{"_id":"123","email":"test@example.com","email_verified":false}| + }} + end + ) + + response = + conn_with_user + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/send_otp", JSON.encode!(%{"email" => "test@example.com"})) + |> json_response(500) + + assert response == %{"message" => "This account already has an email"} + end + end + + describe "POST api/account/v2/confirm_otp" do + setup do + initial_config = Application.get_env(:ueberauth, Ueberauth.Strategy.Auth0.OAuth) + + Application.put_env( + :ueberauth, + Ueberauth.Strategy.Auth0.OAuth, + Keyword.put(initial_config, :auth0_application_id, "test_app") + ) + + on_exit(fn -> + Application.put_env(:ueberauth, Ueberauth.Strategy.Auth0.OAuth, initial_config) + end) + + :ok + end + + # Regression test: after OpenApiSpex integration, confirm_otp must read + # email and otp from conn.body_params instead of the action's params argument. + # See commit dbf589ae25. + test "confirm OTP successfully", %{conn: conn} do + id_token = build_test_jwt(%{"sub" => "email|123", "email" => "test@example.com"}) + + user_json = + JSON.encode!(%{ + "user_id" => "email|123", + "email" => "test@example.com", + "email_verified" => true, + "name" => "Test User", + "nickname" => "test", + "picture" => "https://example.com/avatar.png", + "user_metadata" => %{ + "test_app" => %{ + "user_id" => "email|123", + "name" => "Test User", + "nickname" => "test", + "picture" => "https://example.com/avatar.png" + } + } + }) + + Tesla.Test.expect_tesla_call( + times: 3, + returns: fn + # OTP confirmation via OAuth2.Client + %{ + method: :post, + url: "https://example.com/oauth/token", + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ] + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token":"test_access","id_token":"#{id_token}","token_type":"Bearer"}) + }} + + # M2M JWT via HttpClient + %{ + method: :post, + url: "https://example.com/oauth/token", + query: [], + headers: [{"Content-type", "application/json"}] + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token": "test_token", "expires_in": 86400}) + }} + + # Get user by ID via OAuth2.Client + %Tesla.Env{ + method: :get, + url: "https://example.com/api/v2/users/" <> _, + headers: [{"accept", "application/json"}, {"authorization", "Bearer test_token"}], + body: "" + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: user_json + }} + end + ) + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/confirm_otp", JSON.encode!(%{"email" => "test@example.com", "otp" => "123456"})) + |> json_response(200) + + assert response["email"] == "test@example.com" + assert response["name"] == "Test User" + end + + test "return error for wrong verification code", %{conn: conn} do + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ] + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 403, + body: ~s({"error":"invalid_grant","error_description":"Wrong email or verification code."}) + }} + end + ) + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/confirm_otp", JSON.encode!(%{"email" => "test@example.com", "otp" => "000000"})) + |> json_response(500) + + assert response == %{"message" => "Wrong verification code."} + end + + test "return error when max attempts reached", %{conn: conn} do + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + headers: [ + {"accept", "application/json"}, + {"auth0-forwarded-for", _ip}, + {"content-type", "application/json"} + ] + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 403, + body: + ~s({"error":"invalid_grant","error_description":"You've reached the maximum number of attempts. Please try to login again."}) + }} + end + ) + + response = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/account/v2/confirm_otp", JSON.encode!(%{"email" => "test@example.com", "otp" => "000000"})) + |> json_response(500) + + assert response == %{"message" => "Max attempts reached. Please resend code."} + end + end + + describe "GET api/account/v2/siwe_message" do + test "get SIWE message successfully", %{conn: conn} do + address = build(:address) + + response = + conn + |> get("/api/account/v2/siwe_message?address=#{address.hash}") + |> json_response(200) + + assert String.contains?(response["siwe_message"], Address.checksum(address)) + end + + test "return error for an invalid address", %{conn: conn} do + response = + conn + |> get("/api/account/v2/siwe_message?address=invalid_address") + |> json_response(422) + + assert response == %{ + "errors" => [ + %{ + "title" => "Invalid value", + "source" => %{"pointer" => "/address"}, + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/" + } + ] + } + end + end + + describe "POST api/account/v2/authenticate_via_wallet" do + test "authenticate via wallet successfully", %{conn: conn} do + private_key = :crypto.strong_rand_bytes(32) + {:ok, <<0x04, public_key_raw::binary-size(64)>>} = ExSecp256k1.create_public_key(private_key) + + <<_::binary-size(12), address_bytes::binary-size(20)>> = + ExKeccak.hash_256(public_key_raw) + + address_string = Address.checksum(address_bytes) + + Tesla.Test.expect_tesla_call( + times: 2, + returns: fn + %{ + method: :post, + url: "https://example.com/oauth/token", + query: [], + headers: [{"Content-type", "application/json"}], + body: + ~s|{"audience":"https://example.com/api/v2/","client_id":"client_id","client_secret":"secrets","grant_type":"client_credentials"}| + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: ~s({"access_token": "test_token", "expires_in": 86400}) + }} + + %Tesla.Env{ + method: :get, + url: "https://example.com/api/v2/users", + query: _, + headers: [{"accept", "application/json"}, {"authorization", "Bearer test_token"}], + body: "" + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + ~s([{"identities":[{"connection":"email","user_id":"123","provider":"email","isSocial":false}],"user_id":"email|123","email":"test@example.com","user_metadata":{"web3_address_hash":"#{address_string}"}}]) + }} + end + ) + + message = + conn + |> get("/api/account/v2/siwe_message?address=#{address_string}") + |> json_response(200) + |> Map.get("siwe_message") + + # cspell:disable-next-line + hash = ExKeccak.hash_256("\x19Ethereum Signed Message:\n#{byte_size(message)}" <> message) + + {:ok, {rs_binary, v}} = ExSecp256k1.sign_compact(hash, private_key) + signature = "0x" <> Base.encode16(rs_binary <> <>, case: :lower) + + response = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/account/v2/authenticate_via_wallet", + JSON.encode!(%{"message" => message, "signature" => signature}) + ) + |> json_response(200) + + assert response["email"] == "test@example.com" and response["address_hash"] == address_string + end + + test "return error for invalid signature", %{conn: conn} do + response = + conn + |> put_req_header("content-type", "application/json") + |> post( + "/api/account/v2/authenticate_via_wallet", + JSON.encode!(%{"message" => "test_message", "signature" => "0x1234"}) + ) + |> json_response(500) + + assert %{"message" => _error_message} = response + end + end + + describe "GET api/account/v2/authenticate_via_dynamic" do + setup :set_mox_global + + test "authenticate via dynamic successfully", %{conn: conn} do + initial_dynamic_env = Application.get_env(:explorer, Dynamic) + initial_strategy_env = Application.get_env(:explorer, Strategy) + + Application.put_env( + :explorer, + Dynamic, + Keyword.merge(initial_dynamic_env, + enabled: true, + env_id: "test_env", + url: "https://app.dynamic.xyz/api/v0/sdk/test_env/.well-known/jwks" + ) + ) + + Application.put_env( + :explorer, + Strategy, + Keyword.merge(initial_strategy_env, enabled: true) + ) + + on_exit(fn -> + Application.put_env(:explorer, Dynamic, initial_dynamic_env) + Application.put_env(:explorer, Strategy, initial_strategy_env) + end) + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://app.dynamic.xyz/api/v0/sdk/test_env/.well-known/jwks"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/json"}], + # cspell:disable + body: + ~s|{"keys":[{"alg":"RS256","e":"AQAB","ext":true,"key_ops":["verify"],"kty":"RSA","n":"xoFNCjaQg7I6oFW1LP3H733NWnvXwHCz8igFgJ9VhyjZkHfbETNIEVOSHmIHrLZln10UrPM1lwUnjV_Q27mApf0k_mNIQlH94npvAt4K9sC9tVx1TOzylBIynTEJv0u7Q2feRjwku2th6yBx2pSZxthbXzcy2trIxQE8NZHzQXgll4vJynemGFcqBS-uxlM6zdJDzfJGgs2q2d8GgZ6izc5N410zmbh7rmEuiNhVRhdBaxv2YSslI-dZiXdrcLhjLBpczBvxjJ-T6rQ7SrJTy7ELlolvP84gE0InuWDK6-RMCC_W_xc44sxPj1JRSUcH7MsGP2rzISA-HdNlSrWJEw","kid":"3SLxTe6F2vUW71mEKH0/tbt3/GxDVSb/rwqsefdZVCM=","use":"sig"}]}| + # cspell:enable + }} + end + ) + + start_supervised!(Strategy) + + :timer.sleep(500) + + response = + conn + |> put_req_header( + "authorization", + # cspell:disable-next-line + "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjNTTHhUZTZGMnZVVzcxbUVLSDAvdGJ0My9HeERWU2Ivcndxc2VmZFpWQ009In0.eyJraWQiOiI1ZDE0YzMzMS1lNWRjLTQwZDgtOGM5Yy00Zjc1N2YxMDYzMTgiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAiLCJpc3MiOiJhcHAuZHluYW1pY2F1dGguY29tL3Rlc3RfZW52Iiwic3ViIjoiNDBhM2NkYTEtNjU2Yy00NzM3LTkyZjgtZWMwYzAzNjZjNTVhIiwic2lkIjoiNDkwZjA3MzgtY2IwNi00MjFkLWIxNGEtZDJhMzJhODA1NzdjIiwic2Vzc2lvbl9wdWJsaWNfa2V5IjoiMDMwNTY0OGI0Nzc4MzU3MzUwZmZhNDk3ZmJmNzQ5ZjAwOWQ3Njk2ODkzNmI5Y2E4ZGI3MzY4OGY2MzIwN2RhZGE0IiwiYWxpYXMiOiJhbGlhcyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImVudmlyb25tZW50X2lkIjoiOTA0ZTdiZmEtNDEyZi00NzY4LTk4YzUtOWEwMmY3Yzc0MTJkIiwiZmFtaWx5X25hbWUiOiJsbiIsImdpdmVuX25hbWUiOiJmbiIsImxpc3RzIjpbXSwibWlzc2luZ19maWVsZHMiOltdLCJ1c2VybmFtZSI6InVzZXJuYW1lIiwidmVyaWZpZWRfY3JlZGVudGlhbHMiOlt7ImFkZHJlc3MiOiIweDAzYzM2M2Y0OGM0RkUwRjJFYzZlZmJENDlGN2IxMTRiOEE2MWMxNGIiLCJjaGFpbiI6ImVpcDE1NSIsImlkIjoiNDQ2NDI4YTUtNTQxYS00OWY0LThkYmItYmIyYjhjODgwMjczIiwibmFtZV9zZXJ2aWNlIjp7fSwicHVibGljX2lkZW50aWZpZXIiOiIweDAzYzM2M2Y0OGM0RkUwRjJFYzZlZmJENDlGN2IxMTRiOEE2MWMxNGIiLCJ3YWxsZXRfbmFtZSI6Im1ldGFtYXNrIiwid2FsbGV0X3Byb3ZpZGVyIjoiYnJvd3NlckV4dGVuc2lvbiIsImZvcm1hdCI6ImJsb2NrY2hhaW4iLCJsYXN0U2VsZWN0ZWRBdCI6IjIwMjYtMDEtMDhUMDc6NDg6MTAuMzM0WiIsInNpZ25JbkVuYWJsZWQiOnRydWV9LHsiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiaWQiOiI2NDU4MWExMi0yZjMwLTRmZjgtOTU4OC1lMGM5NGVhMWM4OWIiLCJwdWJsaWNfaWRlbnRpZmllciI6InRlc3RAZXhhbXBsZS5jb20iLCJmb3JtYXQiOiJlbWFpbCIsInNpZ25JbkVuYWJsZWQiOnRydWUsInZlcmlmaWVkQXQiOiIyMDI1LTEyLTIzVDEwOjU1OjA5LjM2NVoifV0sImxhc3RfdmVyaWZpZWRfY3JlZGVudGlhbF9pZCI6IjQ0NjQyOGE1LTU0MWEtNDlmNC04ZGJiLWJiMmI4Yzg4MDI3MyIsImZpcnN0X3Zpc2l0IjoiMjAyNS0xMi0xOFQxMzo1Mjo1OC4yMDFaIiwibGFzdF92aXNpdCI6IjIwMjYtMDEtMDhUMDc6NDg6MTAuMzIxWiIsIm5ld191c2VyIjpmYWxzZSwibWV0YWRhdGEiOnt9LCJ2ZXJpZmllZENyZWRlbnRpYWxzSGFzaGVzIjp7ImJsb2NrY2hhaW4iOiI1NTAwMTUyZDMwMjc2MzIwMDNmZmUxOTRlMmM3YzFiYyIsImVtYWlsIjoiMjg1OGY0OWQ2YjE2Nzg3NTFlNTA0ZDQ3ODM0ZDcwMGEiLCJvYXV0aCI6ImZmMTA5N2I1MGVkMDNhNDA4MWRhMTA2NjBlMDEzMzg5In0sImhhc2hlZF9pcCI6ImMxNWE3NTgzNGVkY2JjZjI3NmQyYTQ3NmFmNmJjMTVmIiwicmVmcmVzaEV4cCI6MTc3MDQ1MDQ5MCwiaWF0IjoxNzY3ODU4NDkwLCJleHAiOjMwNjc4NjU2OTB9.S-9hkUbqr5P69xtu4qcSDbNrjUiUa4BnhvUHHSSCZ-7FHUvjRH8LXj4lGrbGIpoLAEMMdRzi8l9HkQSH7ASACP2-cm3JRDr5-p2-IN4Qm5GTo0o2ewzxxhqpNCQocUkPld6JUY-3O1XobaVCL7PNLnBUV4-jCGKkQbgpye50dezq7dqjV3CXxhpKt-80gmWxlVyIEkGENKawlvw6AUShtMYHhvqon-RqCtJsYRzGQXMdsAOkvV-0vXN8PVLk5fKJ6GInuW8hYB_i_V_HRChQnkvHsswMBj3-hEmwh5x6lZY2kq3fcoVsQI1lSaYK5ZctO-ij476o1VDgBIVmvw2Bug" + ) + |> get("/api/account/v2/authenticate_via_dynamic") + |> json_response(200) + + assert response["email"] == "test@example.com" + end + + test "without bearer token returns error", %{conn: conn} do + initial_dynamic_env = Application.get_env(:explorer, Dynamic) + initial_strategy_env = Application.get_env(:explorer, Strategy) + + Application.put_env( + :explorer, + Dynamic, + Keyword.merge(initial_dynamic_env, + enabled: true, + env_id: "test_env", + url: "https://app.dynamic.xyz/api/v0/sdk/test_env/.well-known/jwks" + ) + ) + + Application.put_env( + :explorer, + Strategy, + Keyword.merge(initial_strategy_env, enabled: true) + ) + + on_exit(fn -> + Application.put_env(:explorer, Dynamic, initial_dynamic_env) + Application.put_env(:explorer, Strategy, initial_strategy_env) + end) + + response = + conn + |> get("/api/account/v2/authenticate_via_dynamic") + |> json_response(401) + + assert response == %{"message" => "No Bearer token"} + end + + test "without config returns error", %{conn: conn} do + response = + conn + |> put_req_header( + "authorization", + "Bearer some_token" + ) + |> get("/api/account/v2/authenticate_via_dynamic") + |> json_response(404) + + assert response == %{"message" => "This endpoint is not configured"} + end + end + + defp build_test_jwt(claims) do + header = Base.url_encode64(JSON.encode!(%{"alg" => "HS256", "typ" => "JWT"}), padding: false) + payload = Base.url_encode64(JSON.encode!(claims), padding: false) + signature = Base.url_encode64("test_signature", padding: false) + "#{header}.#{payload}.#{signature}" + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/user_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/user_controller_test.exs index 339be0b0a2ae..ac673257400d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/user_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/account/api/v2/user_controller_test.exs @@ -1,5 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.Api.V2.UserControllerTest do use BlockScoutWeb.ConnCase + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] alias Explorer.Account.{ Identity, @@ -20,6 +22,15 @@ defmodule BlockScoutWeb.Account.Api.V2.UserControllerTest do end describe "Test account/api/account/v2/user" do + setup do + initial_value = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + + on_exit(fn -> + :persistent_term.put(:market_token_fetcher_enabled, initial_value) + end) + end + test "get user info", %{conn: conn, user: user} do result_conn = conn @@ -566,6 +577,32 @@ defmodule BlockScoutWeb.Account.Api.V2.UserControllerTest do Application.put_env(:explorer, Explorer.Account, old_env) end + test "can't insert contract watchlist address", %{conn: conn} do + address = insert(:contract_address) + + response = + conn + |> post( + "/api/account/v2/user/watchlist", + build(:watchlist_address) |> Map.put("address_hash", to_string(address.hash)) + ) + |> json_response(422) + + assert response == %{"errors" => %{"address_hash" => ["This address isn't EOA"]}} + end + + test "can insert EOA with code watchlist address", %{conn: conn, user: _user} do + address = insert(:contract_address, contract_code: "0xef01000000000000000000000000000000000000000123") + + _response = + conn + |> post( + "/api/account/v2/user/watchlist", + build(:watchlist_address) |> Map.put("address_hash", to_string(address.hash)) + ) + |> json_response(200) + end + test "check watchlist tags pagination", %{conn: conn, user: user} do tags_address = for _ <- 0..50 do @@ -1052,169 +1089,6 @@ defmodule BlockScoutWeb.Account.Api.V2.UserControllerTest do end end - describe "public tags" do - test "create public tags request", %{conn: conn} do - public_tags_request = build(:public_tags_request) - - post_public_tags_request_response = - conn - |> post( - "/api/account/v2/user/public_tags", - public_tags_request - ) - |> doc(description: "Submit request to add a public tag") - |> json_response(200) - - assert post_public_tags_request_response["full_name"] == public_tags_request["full_name"] - assert post_public_tags_request_response["email"] == public_tags_request["email"] - assert post_public_tags_request_response["tags"] == public_tags_request["tags"] - assert post_public_tags_request_response["website"] == public_tags_request["website"] - assert post_public_tags_request_response["additional_comment"] == public_tags_request["additional_comment"] - assert post_public_tags_request_response["addresses"] == public_tags_request["addresses"] - assert post_public_tags_request_response["company"] == public_tags_request["company"] - assert post_public_tags_request_response["is_owner"] == public_tags_request["is_owner"] - assert post_public_tags_request_response["id"] - end - - test "get one public tags requests", %{conn: conn} do - public_tags_request = build(:public_tags_request) - - post_public_tags_request_response = - conn - |> post( - "/api/account/v2/user/public_tags", - public_tags_request - ) - |> json_response(200) - - assert post_public_tags_request_response["full_name"] == public_tags_request["full_name"] - assert post_public_tags_request_response["email"] == public_tags_request["email"] - assert post_public_tags_request_response["tags"] == public_tags_request["tags"] - assert post_public_tags_request_response["website"] == public_tags_request["website"] - assert post_public_tags_request_response["additional_comment"] == public_tags_request["additional_comment"] - assert post_public_tags_request_response["addresses"] == public_tags_request["addresses"] - assert post_public_tags_request_response["company"] == public_tags_request["company"] - assert post_public_tags_request_response["is_owner"] == public_tags_request["is_owner"] - assert post_public_tags_request_response["id"] - - assert conn - |> get("/api/account/v2/user/public_tags") - |> json_response(200) - |> Enum.map(&convert_date/1) == - [post_public_tags_request_response] - |> Enum.map(&convert_date/1) - end - - test "get and delete several public tags requests", %{conn: conn} do - public_tags_list = build_list(10, :public_tags_request) - - final_list = - public_tags_list - |> Enum.map(fn request -> - response = - conn - |> post( - "/api/account/v2/user/public_tags", - request - ) - |> json_response(200) - - assert response["full_name"] == request["full_name"] - assert response["email"] == request["email"] - assert response["tags"] == request["tags"] - assert response["website"] == request["website"] - assert response["additional_comment"] == request["additional_comment"] - assert response["addresses"] == request["addresses"] - assert response["company"] == request["company"] - assert response["is_owner"] == request["is_owner"] - assert response["id"] - - convert_date(response) - end) - |> Enum.reverse() - - assert conn - |> get("/api/account/v2/user/public_tags") - |> doc(description: "Get list of requests to add a public tag") - |> json_response(200) - |> Enum.map(&convert_date/1) == final_list - - %{"id" => id} = Enum.at(final_list, 0) - - assert conn - |> delete("/api/account/v2/user/public_tags/#{id}", %{"remove_reason" => "reason"}) - |> doc(description: "Delete public tags request") - |> json_response(200) == %{"message" => "OK"} - - Enum.each(Enum.drop(final_list, 1), fn request -> - assert conn - |> delete("/api/account/v2/user/public_tags/#{request["id"]}", %{"remove_reason" => "reason"}) - |> json_response(200) == %{"message" => "OK"} - end) - - assert conn - |> get("/api/account/v2/user/public_tags") - |> json_response(200) == [] - end - - test "edit public tags request", %{conn: conn} do - public_tags_request = build(:public_tags_request) - - post_public_tags_request_response = - conn - |> post( - "/api/account/v2/user/public_tags", - public_tags_request - ) - |> json_response(200) - - assert post_public_tags_request_response["full_name"] == public_tags_request["full_name"] - assert post_public_tags_request_response["email"] == public_tags_request["email"] - assert post_public_tags_request_response["tags"] == public_tags_request["tags"] - assert post_public_tags_request_response["website"] == public_tags_request["website"] - assert post_public_tags_request_response["additional_comment"] == public_tags_request["additional_comment"] - assert post_public_tags_request_response["addresses"] == public_tags_request["addresses"] - assert post_public_tags_request_response["company"] == public_tags_request["company"] - assert post_public_tags_request_response["is_owner"] == public_tags_request["is_owner"] - assert post_public_tags_request_response["id"] - - assert conn - |> get("/api/account/v2/user/public_tags") - |> json_response(200) - |> Enum.map(&convert_date/1) == - [post_public_tags_request_response] - |> Enum.map(&convert_date/1) - - new_public_tags_request = build(:public_tags_request) - - put_public_tags_request_response = - conn - |> put( - "/api/account/v2/user/public_tags/#{post_public_tags_request_response["id"]}", - new_public_tags_request - ) - |> doc(description: "Edit request to add a public tag") - |> json_response(200) - - assert put_public_tags_request_response["full_name"] == new_public_tags_request["full_name"] - assert put_public_tags_request_response["email"] == new_public_tags_request["email"] - assert put_public_tags_request_response["tags"] == new_public_tags_request["tags"] - assert put_public_tags_request_response["website"] == new_public_tags_request["website"] - assert put_public_tags_request_response["additional_comment"] == new_public_tags_request["additional_comment"] - assert put_public_tags_request_response["addresses"] == new_public_tags_request["addresses"] - assert put_public_tags_request_response["company"] == new_public_tags_request["company"] - assert put_public_tags_request_response["is_owner"] == new_public_tags_request["is_owner"] - assert put_public_tags_request_response["id"] == post_public_tags_request_response["id"] - - assert conn - |> get("/api/account/v2/user/public_tags") - |> json_response(200) - |> Enum.map(&convert_date/1) == - [put_public_tags_request_response] - |> Enum.map(&convert_date/1) - end - end - def convert_date(request) do {:ok, time, _} = DateTime.from_iso8601(request["submission_date"]) %{request | "submission_date" => Calendar.strftime(time, "%b %d, %Y")} @@ -1253,12 +1127,22 @@ defmodule BlockScoutWeb.Account.Api.V2.UserControllerTest do } } + notification_settings_extended = + if chain_type() == :zilliqa do + Map.put(notification_settings, "ZRC-2", %{ + "incoming" => watchlist.watch_zrc_2_input, + "outcoming" => watchlist.watch_zrc_2_output + }) + else + notification_settings + end + assert json["address_hash"] == to_string(watchlist.address_hash) assert json["name"] == watchlist.name assert json["id"] == watchlist.id assert json["address"]["hash"] == Address.checksum(watchlist.address_hash) assert json["notification_methods"]["email"] == watchlist.notify_email - assert json["notification_settings"] == notification_settings + assert json["notification_settings"] == notification_settings_extended end defp check_paginated_response(first_page_resp, second_page_resp, list) do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/account/custom_abi_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/account/custom_abi_controller_test.exs index 6f0608f128d3..90041f9affb3 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/account/custom_abi_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/account/custom_abi_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Account.CustomABIControllerTest do use BlockScoutWeb.ConnCase @@ -9,6 +10,12 @@ defmodule BlockScoutWeb.Account.CustomABIControllerTest do setup %{conn: conn} do auth = build(:auth) + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + {:ok, user} = Identity.find_or_create(auth) {:ok, conn: Plug.Test.init_test_session(conn, current_user: user)} @@ -165,7 +172,8 @@ defmodule BlockScoutWeb.Account.CustomABIControllerTest do "[{\"type\":\"function\",\"outputs\":[{\"type\":\"string\",\"name\":\"\"}],\"name\":\"name\",\"inputs\":[],\"constant\":true},{\"type\":\"function\",\"outputs\":[{\"type\":\"bool\",\"name\":\"success\"}],\"name\":\"approve\",\"inputs\":[{\"type\":\"address\",\"name\":\"_spender\"},{\"type\":\"uint256\",\"name\":\"_value\"}],\"constant\":false}]" } - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() result_conn = conn diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_coin_balance_by_day_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_coin_balance_by_day_controller_test.exs index 8d94957b9c02..8a590764ef1d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_coin_balance_by_day_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_coin_balance_by_day_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressCoinBalanceByDayControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_contract_controller_test.exs index 62e23e20139c..cf7fdba8912c 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractControllerTest do use BlockScoutWeb.ConnCase, async: true @@ -7,6 +8,14 @@ defmodule BlockScoutWeb.AddressContractControllerTest do alias Explorer.Market.Token alias Explorer.{Factory, TestHelper} + setup do + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + end + describe "GET index/3" do test "returns not found for nonexistent address", %{conn: conn} do nonexistent_address_hash = Hash.to_string(Factory.address_hash()) @@ -42,12 +51,13 @@ defmodule BlockScoutWeb.AddressContractControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() conn = get(conn, address_contract_path(BlockScoutWeb.Endpoint, :index, Address.checksum(address))) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs index d896d7b94633..b26e75f5d890 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressControllerTest do use BlockScoutWeb.ConnCase, # ETS tables are shared in `Explorer.Counters.*` @@ -25,7 +26,7 @@ defmodule BlockScoutWeb.AddressControllerTest do test "returns top addresses", %{conn: conn} do address_hashes = - 4..1 + 4..1//-1 |> Enum.map(&insert(:address, fetched_coin_balance: &1)) |> Enum.map(& &1.hash) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs index bfb0e2c80d77..229eba81563f 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_internal_transaction_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do use BlockScoutWeb.ConnCase, async: true @@ -51,24 +52,18 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_internal_transaction = insert(:internal_transaction, - transaction: transaction, from_address: address, index: 1, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1 + transaction_index: transaction.index ) to_internal_transaction = insert(:internal_transaction, - transaction: transaction, to_address: address, index: 2, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2 + transaction_index: transaction.index ) path = address_internal_transaction_path(conn, :index, Address.checksum(address), %{"type" => "JSON"}) @@ -78,7 +73,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do assert Enum.all?([from_internal_transaction, to_internal_transaction], fn internal_transaction -> Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{internal_transaction.index}\"") end) end) @@ -94,24 +89,18 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_internal_transaction = insert(:internal_transaction, - transaction: transaction, from_address: address, index: 1, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1 + transaction_index: transaction.index ) to_internal_transaction = insert(:internal_transaction, - transaction: transaction, to_address: address, index: 2, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2 + transaction_index: transaction.index ) path = @@ -125,12 +114,12 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do internal_transaction_tiles = json_response(conn, 200)["items"] assert Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(from_internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{from_internal_transaction.index}\"") end) refute Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(to_internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{to_internal_transaction.index}\"") end) end @@ -145,24 +134,18 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_internal_transaction = insert(:internal_transaction, - transaction: transaction, from_address: address, index: 1, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1 + transaction_index: transaction.index ) to_internal_transaction = insert(:internal_transaction, - transaction: transaction, to_address: address, index: 2, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2 + transaction_index: transaction.index ) path = @@ -173,12 +156,12 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do internal_transaction_tiles = json_response(conn, 200)["items"] assert Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(to_internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{to_internal_transaction.index}\"") end) refute Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(from_internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{from_internal_transaction.index}\"") end) end @@ -193,25 +176,19 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_internal_transaction = insert(:internal_transaction, - transaction: transaction, from_address: address, index: 1, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1 + transaction_index: transaction.index ) to_internal_transaction = - insert(:internal_transaction, - transaction: transaction, + insert(:internal_transaction_create, to_address: nil, created_contract_address: address, index: 2, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2 + transaction_index: transaction.index ) path = @@ -222,12 +199,12 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do internal_transaction_tiles = json_response(conn, 200)["items"] assert Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(to_internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{to_internal_transaction.index}\"") end) refute Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(from_internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{from_internal_transaction.index}\"") end) end @@ -262,9 +239,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_address: address, index: index, block_number: transaction_1.block_number, - transaction_index: transaction_1.index, - block_hash: a_block.hash, - block_index: index + transaction_index: transaction_1.index ) end) @@ -277,9 +252,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_address: address, index: index, block_number: transaction_2.block_number, - transaction_index: transaction_2.index, - block_hash: a_block.hash, - block_index: 20 + index + transaction_index: transaction_2.index ) end) @@ -292,9 +265,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_address: address, index: index, block_number: transaction_3.block_number, - transaction_index: transaction_3.index, - block_hash: b_block.hash, - block_index: index + transaction_index: transaction_3.index ) end) @@ -307,9 +278,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_address: address, index: 11, block_number: transaction_3.block_number, - transaction_index: transaction_3.index, - block_hash: b_block.hash, - block_index: 11 + transaction_index: transaction_3.index ) conn = @@ -324,7 +293,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do assert Enum.all?(second_page, fn internal_transaction -> Enum.any?(internal_transaction_tiles, fn tile -> - String.contains?(tile, to_string(internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(internal_transaction.transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{internal_transaction.index}\"") end) end) @@ -360,9 +329,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_address: address, index: index, block_number: transaction_1.block_number, - transaction_index: transaction_1.index, - block_hash: a_block.hash, - block_index: index + transaction_index: transaction_1.index ) end) @@ -375,9 +342,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do to_address: address, index: index, block_number: transaction_2.block_number, - transaction_index: transaction_2.index, - block_hash: a_block.hash, - block_index: 55 + index + transaction_index: transaction_2.index ) end) @@ -385,14 +350,13 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do 1..55 |> Enum.map(fn index -> insert( - :internal_transaction, + :internal_transaction_create, transaction: transaction_3, created_contract_address: address, + to_address: nil, index: index, block_number: transaction_3.block_number, - transaction_index: transaction_3.index, - block_hash: b_block.hash, - block_index: index + transaction_index: transaction_3.index ) end) @@ -461,7 +425,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do assert Enum.all?(first_page_items, fn internal_transaction -> Enum.any?(first_page_response, fn tile -> - String.contains?(tile, to_string(internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(internal_transaction.transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{internal_transaction.index}\"") end) end) @@ -470,7 +434,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do assert Enum.all?(second_page_items, fn internal_transaction -> Enum.any?(second_page_response, fn tile -> - String.contains?(tile, to_string(internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(internal_transaction.transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{internal_transaction.index}\"") end) end) @@ -479,7 +443,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do assert Enum.all?(third_page_items, fn internal_transaction -> Enum.any?(third_page_response, fn tile -> - String.contains?(tile, to_string(internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(internal_transaction.transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{internal_transaction.index}\"") end) end) @@ -488,7 +452,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do assert Enum.all?(fourth_page_items, fn internal_transaction -> Enum.any?(fourth_page_response, fn tile -> - String.contains?(tile, to_string(internal_transaction.transaction_hash)) && + String.contains?(tile, to_string(internal_transaction.transaction.hash)) && String.contains?(tile, "data-internal-transaction-index=\"#{internal_transaction.index}\"") end) end) @@ -512,9 +476,7 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do from_address: address, index: index, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index ) end) @@ -528,10 +490,10 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do expected_response = address_internal_transaction_path(BlockScoutWeb.Endpoint, :index, address.hash, %{ - "block_number" => number, - "index" => 11, - "transaction_index" => transaction_index, - "items_count" => "50" + block_number: number, + index: 11, + transaction_index: transaction_index, + items_count: "50" }) assert expected_response == json_response(conn, 200)["next_page_path"] @@ -551,9 +513,8 @@ defmodule BlockScoutWeb.AddressInternalTransactionControllerTest do :internal_transaction, transaction: transaction, from_address: address, + transaction_index: transaction.index, index: index, - block_hash: transaction.block_hash, - block_index: index, block_number: transaction.block_number ) end) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_read_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_read_contract_controller_test.exs index b982eeace979..ba33d4bcb9c1 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_read_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_read_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressReadContractControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false @@ -36,14 +37,15 @@ defmodule BlockScoutWeb.AddressReadContractControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:smart_contract, address_hash: contract_address.hash, contract_code_md5: "123") - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() conn = get(conn, address_read_contract_path(BlockScoutWeb.Endpoint, :index, Address.checksum(contract_address.hash))) @@ -62,9 +64,9 @@ defmodule BlockScoutWeb.AddressReadContractControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) conn = diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_read_proxy_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_read_proxy_controller_test.exs index e9ce06774223..0e4973146447 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_read_proxy_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_read_proxy_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressReadProxyControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false @@ -36,14 +37,15 @@ defmodule BlockScoutWeb.AddressReadProxyControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:smart_contract, address_hash: contract_address.hash, contract_code_md5: "123") - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() conn = get(conn, address_read_proxy_path(BlockScoutWeb.Endpoint, :index, Address.checksum(contract_address.hash))) @@ -61,9 +63,9 @@ defmodule BlockScoutWeb.AddressReadProxyControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) conn = get(conn, address_read_proxy_path(BlockScoutWeb.Endpoint, :index, Address.checksum(contract_address.hash))) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_token_balance_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_token_balance_controller_test.exs index cdafe886d54f..8abbc676267c 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_token_balance_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_token_balance_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenBalanceControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs index bfbc172befea..8afa1128093e 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_token_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs index e6ea85081b45..02bf36eb1f37 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_token_transfer_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenTransferControllerTest do use BlockScoutWeb.ConnCase @@ -151,11 +152,12 @@ defmodule BlockScoutWeb.AddressTokenTransferControllerTest do test "returns next_page_path when there are more items", %{conn: conn} do address = insert(:address) token = insert(:token) + start_block_number = 2_000_000 + System.unique_integer([:positive]) page_last_transfer = 1..50 |> Enum.map(fn index -> - block = insert(:block, number: 1000 - index) + block = insert(:block, number: start_block_number - index) transaction = :transaction @@ -174,7 +176,7 @@ defmodule BlockScoutWeb.AddressTokenTransferControllerTest do |> List.last() Enum.each(51..60, fn index -> - block = insert(:block, number: 1000 - index) + block = insert(:block, number: start_block_number - index) transaction = :transaction @@ -207,8 +209,8 @@ defmodule BlockScoutWeb.AddressTokenTransferControllerTest do :index, Address.checksum(address.hash), Address.checksum(token.contract_address_hash), - block_number: page_last_transfer.block_number, index: page_last_transfer.index, + block_number: page_last_transfer.block_number, items_count: "50" ) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs index 57e6c0f3ab29..47a51001525b 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTransactionControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false @@ -137,8 +138,8 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do created_contract_address: address, to_address: nil, transaction: transaction, - block_hash: block.hash, - block_index: 0 + transaction_index: transaction.index, + block_number: transaction.block_number ) conn = get(conn, address_transaction_path(conn, :index, Address.checksum(address)), %{"type" => "JSON"}) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs index c4cb8310ffd9..8e13c89e3771 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_withdrawal_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressWithdrawalControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_write_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_write_contract_controller_test.exs index 2b13b6a136d4..53c91709fcb3 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_write_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_write_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressWriteContractControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false @@ -38,14 +39,15 @@ defmodule BlockScoutWeb.AddressWriteContractControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:smart_contract, address_hash: contract_address.hash, contract_code_md5: "123") - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() conn = get(conn, address_write_contract_path(BlockScoutWeb.Endpoint, :index, Address.checksum(contract_address.hash))) @@ -64,9 +66,9 @@ defmodule BlockScoutWeb.AddressWriteContractControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) conn = diff --git a/apps/block_scout_web/test/block_scout_web/controllers/address_write_proxy_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/address_write_proxy_controller_test.exs index 1b0f7755ecc3..4e83840bd056 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/address_write_proxy_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/address_write_proxy_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressWriteProxyControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false @@ -36,14 +37,15 @@ defmodule BlockScoutWeb.AddressWriteProxyControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:smart_contract, address_hash: contract_address.hash, contract_code_md5: "123") - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() conn = get(conn, address_write_proxy_path(BlockScoutWeb.Endpoint, :index, Address.checksum(contract_address.hash))) @@ -62,9 +64,9 @@ defmodule BlockScoutWeb.AddressWriteProxyControllerTest do :internal_transaction_create, index: 0, transaction: transaction, + transaction_index: transaction.index, created_contract_address: contract_address, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) conn = diff --git a/apps/block_scout_web/test/block_scout_web/controllers/admin/dashboard_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/admin/dashboard_controller_test.exs index 03cc0a388bda..3dbb4108dbf6 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/admin/dashboard_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/admin/dashboard_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.DashboardControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/admin/session_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/admin/session_controller_test.exs index ce6e4e5a821e..472834e9f428 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/admin/session_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/admin/session_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.SessionControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/admin/setup_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/admin/setup_controller_test.exs index 55c4753983b6..dbe6b779c9be 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/admin/setup_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/admin/setup_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Admin.SetupControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs new file mode 100644 index 000000000000..4b904b5c411b --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/block_controller_test.exs @@ -0,0 +1,250 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.BlockControllerTest do + use BlockScoutWeb.ConnCase + + alias BlockScoutWeb.Chain + alias Explorer.Chain.Cache.BlockNumber + + describe "GET /api/legacy/block/get-block-number-by-time" do + test "missing timestamp param", %{conn: conn} do + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{"closest" => "after"}) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "Query parameter 'timestamp' is required" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "missing closest param", %{conn: conn} do + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{"timestamp" => "1617019505"}) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "Query parameter 'closest' is required" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "invalid timestamp param", %{conn: conn} do + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{ + "timestamp" => "invalid", + "closest" => "before" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "Invalid `timestamp` param" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "invalid closest param", %{conn: conn} do + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{ + "timestamp" => "1617019505", + "closest" => "invalid" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "Invalid `closest` param" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "not found — no matching block", %{conn: conn} do + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{ + "timestamp" => "1617019505", + "closest" => "before" + }) + |> json_response(200) + + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "success with closest=before", %{conn: conn} do + timestamp_string = "1617020209" + {:ok, timestamp} = Chain.param_to_block_timestamp(timestamp_string) + block = insert(:block, timestamp: timestamp) + + {timestamp_int, _} = Integer.parse(timestamp_string) + timestamp_in_the_future = to_string(timestamp_int + 1) + + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{ + "timestamp" => timestamp_in_the_future, + "closest" => "before" + }) + |> json_response(200) + + assert response["status"] == "1" + assert response["message"] == "OK" + assert response["result"] == %{"blockNumber" => "#{block.number}"} + end + + test "success with closest=after", %{conn: conn} do + timestamp_string = "1617020209" + {:ok, timestamp} = Chain.param_to_block_timestamp(timestamp_string) + block = insert(:block, timestamp: timestamp) + + {timestamp_int, _} = Integer.parse(timestamp_string) + timestamp_in_the_past = to_string(timestamp_int - 1) + + response = + conn + |> get("/api/legacy/block/get-block-number-by-time", %{ + "timestamp" => timestamp_in_the_past, + "closest" => "after" + }) + |> json_response(200) + + assert response["status"] == "1" + assert response["message"] == "OK" + assert response["result"] == %{"blockNumber" => "#{block.number}"} + end + + # Parity invariant: response body must be byte-identical to the v1 endpoint. + test "parity with v1 /api?module=block&action=getblocknobytime — success", %{conn: conn} do + timestamp_string = "1617020209" + {:ok, timestamp} = Chain.param_to_block_timestamp(timestamp_string) + insert(:block, timestamp: timestamp) + + {timestamp_int, _} = Integer.parse(timestamp_string) + timestamp_in_the_future = to_string(timestamp_int + 1) + + params = %{"timestamp" => timestamp_in_the_future, "closest" => "before"} + + v1_response = + conn + |> get("/api", Map.merge(params, %{"module" => "block", "action" => "getblocknobytime"})) + |> json_response(200) + + v2_response = + conn + |> get("/api/legacy/block/get-block-number-by-time", params) + |> json_response(200) + + assert v1_response == v2_response + end + + test "parity with v1 /api?module=block&action=getblocknobytime — error (missing params)", %{conn: conn} do + v1_response = + conn + |> get("/api", %{"module" => "block", "action" => "getblocknobytime"}) + |> json_response(200) + + v2_response = + conn + |> get("/api/legacy/block/get-block-number-by-time") + |> json_response(200) + + assert v1_response == v2_response + end + end + + describe "GET /api/legacy/block/eth-block-number" do + setup do + Supervisor.terminate_child(Explorer.Supervisor, BlockNumber.child_id()) + Supervisor.restart_child(Explorer.Supervisor, BlockNumber.child_id()) + :ok + end + + test "default id (omitted) — returns integer id 1", %{conn: conn} do + insert(:block) + + response = + conn + |> get("/api/legacy/block/eth-block-number") + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert is_binary(response["result"]) + assert String.starts_with?(response["result"], "0x") + # When id is omitted the v1 controller defaults to integer 1 + assert response["id"] == 1 + end + + test "integer id (?id=7) — echoed back as string (query strings are strings)", %{conn: conn} do + insert(:block) + + response = + conn + |> get("/api/legacy/block/eth-block-number", %{"id" => "7"}) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert is_binary(response["result"]) + # id=7 comes in as the string "7"; sanitize_id emits it quoted → "7" + assert response["id"] == "7" + end + + test "string id (?id=hello) — echoed back as string", %{conn: conn} do + insert(:block) + + response = + conn + |> get("/api/legacy/block/eth-block-number", %{"id" => "hello"}) + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == "hello" + end + + test "empty database — result is \"0x0\"", %{conn: conn} do + # No blocks inserted. BlockNumber.get_max/0 delegates to + # Block.fetch_max_block_number/0 which returns Repo.one(query) || 0, so the + # result is 0, not nil. encode_quantity(0) → "0x0". + response = + conn + |> get("/api/legacy/block/eth-block-number") + |> json_response(200) + + assert response["jsonrpc"] == "2.0" + assert response["result"] == "0x0" + end + + # Parity invariant: response body must be byte-identical to the v1 endpoint. + test "parity with v1 /api?module=block&action=eth_block_number — default id", %{conn: conn} do + insert(:block) + + v1_response = + conn + |> get("/api", %{"module" => "block", "action" => "eth_block_number"}) + |> json_response(200) + + v2_response = + conn + |> get("/api/legacy/block/eth-block-number") + |> json_response(200) + + assert v1_response == v2_response + end + + test "parity with v1 /api?module=block&action=eth_block_number — empty database", %{conn: conn} do + v1_response = + conn + |> get("/api", %{"module" => "block", "action" => "eth_block_number"}) + |> json_response(200) + + v2_response = + conn + |> get("/api/legacy/block/eth-block-number") + |> json_response(200) + + assert v1_response == v2_response + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs new file mode 100644 index 000000000000..42a0f79c2770 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/legacy/logs_controller_test.exs @@ -0,0 +1,263 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.Legacy.LogsControllerTest do + use BlockScoutWeb.ConnCase + + alias Explorer.Chain.Transaction + + describe "GET /api/legacy/logs/get-logs" do + test "missing fromBlock, toBlock, address, and topic{x}", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs") + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "Required query parameters missing" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "missing fromBlock", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "toBlock" => "10", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "fromBlock" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "missing toBlock", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "fromBlock" => "5", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "toBlock" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "missing address and topic{x}", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs", %{"fromBlock" => "5", "toBlock" => "10"}) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "address and/or topic{x}" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "invalid fromBlock format", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "fromBlock" => "abc", + "toBlock" => "10", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] =~ "Invalid fromBlock format" + assert Map.has_key?(response, "result") + refute response["result"] + end + + test "no logs found returns empty result array", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "fromBlock" => "5", + "toBlock" => "10", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] == "No logs found" + assert response["result"] == [] + end + + test "fromBlock=latest and toBlock=latest", %{conn: conn} do + contract_address = insert(:contract_address) + + transaction = + %Transaction{block: block} = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + insert(:log, + address: contract_address, + transaction: transaction, + block: block, + block_number: block.number + ) + + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "fromBlock" => "latest", + "toBlock" => "latest", + "address" => "#{contract_address.hash}" + }) + |> json_response(200) + + assert response["status"] == "1" + assert response["message"] == "OK" + assert is_list(response["result"]) + assert length(response["result"]) == 1 + end + + test "success with logs returned", %{conn: conn} do + contract_address = insert(:contract_address) + + transaction = + %Transaction{block: block} = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + log = + insert(:log, + address: contract_address, + transaction: transaction, + block: block, + block_number: transaction.block_number + ) + + params = %{ + "fromBlock" => "#{block.number}", + "toBlock" => "#{block.number}", + "address" => "#{contract_address.hash}" + } + + response = + conn + |> get("/api/legacy/logs/get-logs", params) + |> json_response(200) + + assert response["status"] == "1" + assert response["message"] == "OK" + assert [found_log] = response["result"] + assert found_log["address"] == "#{contract_address.hash}" + assert found_log["transactionHash"] == "#{transaction.hash}" + assert found_log["blockNumber"] == integer_to_hex(log.block_number) + end + + test "two topics set, required topicA_B_opr missing", %{conn: conn} do + conditions = %{ + ["topic0", "topic1"] => "topic0_1_opr", + ["topic0", "topic2"] => "topic0_2_opr", + ["topic0", "topic3"] => "topic0_3_opr", + ["topic1", "topic2"] => "topic1_2_opr", + ["topic1", "topic3"] => "topic1_3_opr", + ["topic2", "topic3"] => "topic2_3_opr" + } + + for {[key1, key2], expectation} <- conditions do + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "fromBlock" => "5", + "toBlock" => "10", + key1 => "some topic", + key2 => "some other topic" + }) + |> json_response(200) + + assert response["status"] == "0" + assert response["message"] == "Required query parameters missing: #{expectation}" + assert Map.has_key?(response, "result") + refute response["result"] + end + end + + test "four topics set, all six topic*_opr missing", %{conn: conn} do + response = + conn + |> get("/api/legacy/logs/get-logs", %{ + "fromBlock" => "5", + "toBlock" => "10", + "topic0" => "some topic", + "topic1" => "some other topic", + "topic2" => "some extra topic", + "topic3" => "some different topic" + }) + |> json_response(200) + + assert response["status"] == "0" + + assert response["message"] =~ + "Required query parameters missing: " <> + "topic0_1_opr, topic0_2_opr, topic0_3_opr, topic1_2_opr, topic1_3_opr, topic2_3_opr" + + assert Map.has_key?(response, "result") + refute response["result"] + end + + # Parity invariant: response body must be byte-identical to the v1 endpoint. + test "parity with v1 /api?module=logs&action=getLogs — success", %{conn: conn} do + contract_address = insert(:contract_address) + + transaction = + %Transaction{block: block} = + :transaction + |> insert(to_address: contract_address) + |> with_block() + + insert(:log, + address: contract_address, + transaction: transaction, + block: block, + block_number: transaction.block_number + ) + + params = %{ + "fromBlock" => "#{block.number}", + "toBlock" => "#{block.number}", + "address" => "#{contract_address.hash}" + } + + v1_response = + conn + |> get("/api", Map.merge(params, %{"module" => "logs", "action" => "getLogs"})) + |> json_response(200) + + v2_response = + conn + |> get("/api/legacy/logs/get-logs", params) + |> json_response(200) + + assert v1_response == v2_response + end + + test "parity with v1 /api?module=logs&action=getLogs — error (missing params)", %{conn: conn} do + v1_response = + conn + |> get("/api", %{"module" => "logs", "action" => "getLogs"}) + |> json_response(200) + + v2_response = + conn + |> get("/api/legacy/logs/get-logs") + |> json_response(200) + + assert v1_response == v2_response + end + end + + defp integer_to_hex(integer), do: "0x" <> String.downcase(Integer.to_string(integer, 16)) +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs index f04f026fa84d..fc85d15d00fe 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/address_controller_test.exs @@ -1,15 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.AddressControllerTest do use BlockScoutWeb.ConnCase, async: false import Mox alias BlockScoutWeb.API.RPC.AddressController - alias Explorer.Chain + alias Explorer.{Chain, Repo, TestHelper} alias Explorer.Chain.Cache.BackgroundMigrations - alias Explorer.Chain.{Events.Subscriber, Transaction, Wei} + alias Explorer.Chain.{Events.Subscriber, InternalTransaction, Transaction, Wei} alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime} + alias Explorer.Utility.AddressIdToAddressHash alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand - alias Explorer.Repo setup :set_mox_global setup :verify_on_exit! @@ -44,6 +45,8 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do setup do Subscriber.to(:addresses, :on_demand) Subscriber.to(:address_coin_balances, :on_demand) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.BlockNumber.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.BlockNumber.child_id()) %{params: %{"module" => "account", "action" => "listaccounts"}} end @@ -134,78 +137,41 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do test "with a stale balance", %{conn: conn, params: params} do now = Timex.now() + latest_block_number = 302 mining_address = insert(:address, fetched_coin_balance: 0, - fetched_coin_balance_block_number: 103, + fetched_coin_balance_block_number: latest_block_number, inserted_at: Timex.shift(now, minutes: -10) ) mining_address_hash = to_string(mining_address.hash) - # we space these very far apart so that we know it will consider the 0th block stale (it calculates how far - # back we'd need to go to get 24 hours in the past) - Enum.each(0..101, fn i -> - insert(:block, number: i, timestamp: Timex.shift(now, hours: -(103 - i) * 25), miner: mining_address) + + Enum.each(0..301, fn i -> + insert(:block, + number: i, + timestamp: Timex.shift(now, minutes: -25 * 60 - (latest_block_number - i)), + miner: mining_address + ) end) - insert(:block, number: 102, timestamp: Timex.shift(now, hours: -25), miner: mining_address) + insert(:block, number: latest_block_number, timestamp: Timex.shift(now, hours: -25), miner: mining_address) AverageBlockTime.refresh() address = insert(:address, fetched_coin_balance: 100, - fetched_coin_balance_block_number: 101, + # more than 1 hour ago, so should be stale + fetched_coin_balance_block_number: 240, inserted_at: Timex.shift(now, minutes: -5) ) address_hash = to_string(address.hash) - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ - %{ - id: id, - method: "eth_getBalance", - params: [^address_hash, "0x66"] - } - ], - _options -> - {:ok, [%{id: id, jsonrpc: "2.0", result: "0x02"}]} - end) - - res = eth_block_number_fake_response("0x66") - - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ - %{ - id: 0, - method: "eth_getBlockByNumber", - params: ["0x66", true] - } - ], - _ -> - {:ok, [res]} - end) - - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ - %{ - id: id, - method: "eth_getBalance", - params: [^mining_address_hash, "0x66"] - } - ], - _options -> - {:ok, [%{id: id, jsonrpc: "2.0", result: "0x02"}]} - end) + latest_block_number_hex = "0x" <> Integer.to_string(latest_block_number, 16) - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ - %{ - id: 0, - method: "eth_getBlockByNumber", - params: ["0x66", true] - } - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_balance_expectation(address_hash, latest_block_number_hex, "0x02") response = conn @@ -238,7 +204,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert received_address.hash == address.hash assert received_address.fetched_coin_balance == expected_wei - assert received_address.fetched_coin_balance_block_number == 102 + assert received_address.fetched_coin_balance_block_number == latest_block_number end end @@ -670,7 +636,8 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "contractAddress" => "#{transaction.created_contract_address_hash}", "cumulativeGasUsed" => "#{transaction.cumulative_gas_used}", "gasUsed" => "#{transaction.gas_used}", - "confirmations" => "0" + "confirmations" => "0", + "methodId" => "0x" } ] @@ -1243,6 +1210,34 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(txlist_schema(), response) end + test "ignores too big endblock", %{conn: conn} do + blocks = [_, _, _, _] = insert_list(4, :block) + address = insert(:address) + + for block <- blocks do + 2 + |> insert_list(:transaction, from_address: address) + |> with_block(block) + end + + params = %{ + "module" => "account", + "action" => "txlist", + "address" => "#{address.hash}", + "endblock" => "99999999999" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(response["result"]) == 8 + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlist_schema(), response) + end + test "with start_timestamp and end_timestamp params", %{conn: conn} do now = Timex.now() timestamp1 = Timex.shift(now, hours: -6) @@ -1834,11 +1829,11 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do :internal_transaction |> insert( transaction: transaction, + transaction_index: transaction.index, index: 0, + value: 1, from_address: address, to_address: address_2, - block_hash: transaction.block_hash, - block_index: 0, block_number: block.number ) @@ -1846,11 +1841,11 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do :internal_transaction |> insert( transaction: transaction, + transaction_index: transaction.index, index: 1, + value: 2, from_address: address, to_address: address_2, - block_hash: transaction.block_hash, - block_index: 1, block_number: block.number ) @@ -1858,8 +1853,8 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do %{ "blockNumber" => "#{transaction.block_number}", "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", - "from" => "#{internal_transaction.from_address_hash}", - "to" => "#{internal_transaction.to_address_hash}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", "value" => "#{internal_transaction.value.value}", "contractAddress" => "", "input" => "#{internal_transaction.input}", @@ -1884,84 +1879,127 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end - end - describe "txlistinternal with txhash" do - test "with an invalid txhash", %{conn: conn} do - params = %{ - "module" => "account", - "action" => "txlistinternal", - "txhash" => "badhash" - } + test "returns status = 2 for internal transactions not yet processed", %{conn: conn, params: params} do + address = insert(:address) + address_2 = insert(:address) - assert response = - conn - |> get("/api", params) - |> json_response(200) + block = insert(:block) + insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) - assert response["message"] =~ "Invalid txhash format" - assert response["status"] == "0" - assert Map.has_key?(response, "result") - refute response["result"] - assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) - end + transaction = + :transaction + |> insert(from_address: address, to_address: address_2) + |> with_block(block) - test "with a txhash that doesn't exist", %{conn: conn} do - params = %{ - "module" => "account", - "action" => "txlistinternal", - "txhash" => "0x40eb908387324f2b575b4879cd9d7188f69c8fc9d87c901b9e2daaea4b442170" - } + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + from_address: address, + to_address: address_2, + block_number: block.number + ) + + internal_transaction = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 2, + from_address: address, + to_address: address_2, + block_number: block.number + ) + + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", + "value" => "#{internal_transaction.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction.input}", + "type" => "#{internal_transaction.type}", + "callType" => "#{internal_transaction.call_type}", + "gas" => "#{internal_transaction.gas}", + "gasUsed" => "#{internal_transaction.gas_used}", + "index" => "#{internal_transaction.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction.error}" + } + ] assert response = conn - |> get("/api", params) + |> get("/api/v1", params) |> json_response(200) - assert response["result"] == [] - assert response["status"] == "0" - assert response["message"] == "No internal transactions found" + assert response["result"] == expected_result + assert response["status"] == "2" + assert response["message"] == "Some internal transactions within this block range have not yet been processed" assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end - test "response includes all the expected fields", %{conn: conn} do + test "returns only non zero value internal transactions by default", %{conn: conn, params: params} do address = insert(:address) - contract_address = insert(:contract_address) + address_2 = insert(:address) block = insert(:block) transaction = :transaction - |> insert(from_address: address, to_address: nil) - |> with_contract_creation(contract_address) + |> insert(from_address: address, to_address: address_2) |> with_block(block) + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + from_address: address, + to_address: address_2, + block_number: block.number + ) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 2, + value: 0, + from_address: address, + to_address: address_2, + block_number: block.number + ) + internal_transaction = - :internal_transaction_create + :internal_transaction |> insert( transaction: transaction, - index: 0, + transaction_index: transaction.index, + index: 1, + value: 2, from_address: address, - block_hash: transaction.block_hash, - block_index: 0 + to_address: address_2, + block_number: block.number ) - |> with_contract_creation(contract_address) - - params = %{ - "module" => "account", - "action" => "txlistinternal", - "txhash" => "#{transaction.hash}" - } expected_result = [ %{ "blockNumber" => "#{transaction.block_number}", "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", - "from" => "#{internal_transaction.from_address_hash}", - "to" => "#{internal_transaction.to_address_hash}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", "value" => "#{internal_transaction.value.value}", - "contractAddress" => "#{contract_address.hash}", - "input" => "", + "contractAddress" => "", + "input" => "#{internal_transaction.input}", "type" => "#{internal_transaction.type}", "callType" => "#{internal_transaction.call_type}", "gas" => "#{internal_transaction.gas}", @@ -1975,7 +2013,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert response = conn - |> get("/api", params) + |> get("/api/v1", params) |> json_response(200) assert response["result"] == expected_result @@ -1984,81 +2022,110 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end - test "isError is true if internal transaction has an error", %{conn: conn} do + test "with zero value internal transactions when `include_zero_value=true` param is provided", %{ + conn: conn, + params: params + } do + address = insert(:address) + address_2 = insert(:address) + + block = insert(:block) + transaction = :transaction - |> insert() - |> with_block() + |> insert(from_address: address, to_address: address_2) + |> with_block(block) - internal_transaction_details = [ + :internal_transaction + |> insert( transaction: transaction, + transaction_index: transaction.index, index: 0, - type: :reward, - error: "some error", - block_hash: transaction.block_hash, - block_index: 0 - ] - - insert(:internal_transaction_create, internal_transaction_details) - - params = %{ - "module" => "account", - "action" => "txlistinternal", - "txhash" => "#{transaction.hash}" - } - - assert %{"result" => [found_internal_transaction]} = - response = - conn - |> get("/api", params) - |> json_response(200) - - assert found_internal_transaction["isError"] == "1" - assert response["status"] == "1" - assert response["message"] == "OK" - assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) - end + value: 1, + from_address: address, + to_address: address_2, + block_number: block.number + ) - test "with transaction with multiple internal transactions", %{conn: conn} do - transaction = - :transaction - |> insert() - |> with_block() + internal_transaction_a = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 2, + from_address: address, + to_address: address_2, + block_number: block.number + ) - for index <- 0..2 do - insert(:internal_transaction_create, + internal_transaction_b = + :internal_transaction + |> insert( transaction: transaction, - index: index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index, + index: 2, + value: 0, + from_address: address, + to_address: address_2, + block_number: block.number ) - end - params = %{ - "module" => "account", - "action" => "txlistinternal", - "txhash" => "#{transaction.hash}" - } + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_b.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_b.to_address_id)}", + "value" => "#{internal_transaction_b.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction_b.input}", + "type" => "#{internal_transaction_b.type}", + "callType" => "#{internal_transaction_b.call_type}", + "gas" => "#{internal_transaction_b.gas}", + "gasUsed" => "#{internal_transaction_b.gas_used}", + "index" => "#{internal_transaction_b.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction_b.error}" + }, + %{ + "blockNumber" => "#{internal_transaction_a.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_a.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_a.to_address_id)}", + "value" => "#{internal_transaction_a.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction_a.input}", + "type" => "#{internal_transaction_a.type}", + "callType" => "#{internal_transaction_a.call_type}", + "gas" => "#{internal_transaction_a.gas}", + "gasUsed" => "#{internal_transaction_a.gas_used}", + "index" => "#{internal_transaction_a.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction_a.error}" + } + ] - assert %{"result" => found_internal_transactions} = - response = + assert response = conn - |> get("/api", params) + |> get("/api/v1", Map.merge(params, %{"include_zero_value" => "true"})) |> json_response(200) - assert length(found_internal_transactions) == 3 + assert response["result"] == expected_result assert response["status"] == "1" assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end end - describe "txlistinternal with address" do - test "with an invalid address", %{conn: conn} do + describe "txlistinternal with txhash" do + test "with an invalid txhash", %{conn: conn} do params = %{ "module" => "account", "action" => "txlistinternal", - "address" => "badhash" + "txhash" => "badhash" } assert response = @@ -2066,18 +2133,18 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do |> get("/api", params) |> json_response(200) - assert response["message"] =~ "Invalid address format" + assert response["message"] =~ "Invalid txhash format" assert response["status"] == "0" assert Map.has_key?(response, "result") refute response["result"] assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end - test "with a address that doesn't exist", %{conn: conn} do + test "with a txhash that doesn't exist", %{conn: conn} do params = %{ "module" => "account", "action" => "txlistinternal", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + "txhash" => "0x40eb908387324f2b575b4879cd9d7188f69c8fc9d87c901b9e2daaea4b442170" } assert response = @@ -2107,26 +2174,27 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do :internal_transaction_create |> insert( transaction: transaction, + transaction_index: transaction.index, index: 0, + value: 1, from_address: address, - block_number: block.number, - block_hash: transaction.block_hash, - block_index: 0 + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, + block_number: transaction.block_number ) - |> with_contract_creation(contract_address) params = %{ "module" => "account", "action" => "txlistinternal", - "address" => "#{address.hash}" + "txhash" => "#{transaction.hash}" } expected_result = [ %{ "blockNumber" => "#{transaction.block_number}", "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", - "from" => "#{internal_transaction.from_address_hash}", - "to" => "#{internal_transaction.to_address_hash}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", "value" => "#{internal_transaction.value.value}", "contractAddress" => "#{contract_address.hash}", "input" => "", @@ -2134,17 +2202,17 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "callType" => "#{internal_transaction.call_type}", "gas" => "#{internal_transaction.gas}", "gasUsed" => "#{internal_transaction.gas_used}", - "isError" => "0", "index" => "#{internal_transaction.index}", "transactionHash" => "#{transaction.hash}", + "isError" => "0", "errCode" => "#{internal_transaction.error}" } ] - response = - conn - |> get("/api", params) - |> json_response(200) + assert response = + conn + |> get("/api", params) + |> json_response(200) assert response["result"] == expected_result assert response["status"] == "1" @@ -2152,23 +2220,65 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) end - test "isError is true if internal transaction has an error", %{conn: conn} do + test "status = 2 if the block is pending", %{conn: conn} do address = insert(:address) + contract_address = insert(:contract_address) + + block = insert(:block) + insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) + + transaction = + :transaction + |> insert(from_address: address, to_address: nil) + |> with_contract_creation(contract_address) + |> with_block(block) + + _internal_transaction = + :internal_transaction_create + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + from_address: address, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, + block_number: transaction.block_number + ) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.hash}" + } + expected_result = [] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "2" + assert response["message"] == "Internal transactions for this transaction have not been processed yet" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "isError is true if internal transaction has an error", %{conn: conn} do transaction = :transaction |> insert() |> with_block() internal_transaction_details = [ - from_address: address, transaction: transaction, + transaction_index: transaction.index, index: 0, + value: 1, type: :reward, error: "some error", - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ] insert(:internal_transaction_create, internal_transaction_details) @@ -2176,7 +2286,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do params = %{ "module" => "account", "action" => "txlistinternal", - "address" => "#{address.hash}" + "txhash" => "#{transaction.hash}" } assert %{"result" => [found_internal_transaction]} = @@ -2185,77 +2295,1330 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do |> get("/api", params) |> json_response(200) - assert found_internal_transaction["isError"] == "1" - assert response["status"] == "1" - assert response["message"] == "OK" - assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + assert found_internal_transaction["isError"] == "1" + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "with transaction with multiple internal transactions", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..2 do + insert(:internal_transaction_create, + transaction: transaction, + transaction_index: transaction.index, + index: index, + block_number: transaction.block_number, + value: 1 + ) + end + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.hash}" + } + + assert %{"result" => found_internal_transactions} = + response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(found_internal_transactions) == 3 + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "returns only non zero value internal transactions by default", %{conn: conn} do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + block_number: block.number + ) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 2, + value: 0, + block_number: block.number + ) + + internal_transaction = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 2, + block_number: block.number + ) + + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", + "value" => "#{internal_transaction.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction.input}", + "type" => "#{internal_transaction.type}", + "callType" => "#{internal_transaction.call_type}", + "gas" => "#{internal_transaction.gas}", + "gasUsed" => "#{internal_transaction.gas_used}", + "index" => "#{internal_transaction.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction.error}" + } + ] + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.hash}" + } + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "with zero value internal transactions when `include_zero_value=true` param is provided", %{conn: conn} do + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + block_number: block.number + ) + + internal_transaction_a = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 2, + block_number: block.number + ) + + internal_transaction_b = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 2, + value: 0, + block_number: block.number + ) + + expected_result = [ + %{ + "blockNumber" => "#{internal_transaction_b.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_b.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_b.to_address_id)}", + "value" => "#{internal_transaction_b.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction_b.input}", + "type" => "#{internal_transaction_b.type}", + "callType" => "#{internal_transaction_b.call_type}", + "gas" => "#{internal_transaction_b.gas}", + "gasUsed" => "#{internal_transaction_b.gas_used}", + "index" => "#{internal_transaction_b.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction_b.error}" + }, + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_a.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_a.to_address_id)}", + "value" => "#{internal_transaction_a.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction_a.input}", + "type" => "#{internal_transaction_a.type}", + "callType" => "#{internal_transaction_a.call_type}", + "gas" => "#{internal_transaction_a.gas}", + "gasUsed" => "#{internal_transaction_a.gas_used}", + "index" => "#{internal_transaction_a.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction_a.error}" + } + ] + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.hash}", + "include_zero_value" => "true" + } + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + if Application.compile_env(:explorer, :chain_type) not in [:rsk, :filecoin] do + test "via on-demand fetcher", %{conn: conn} do + original_config = Application.get_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions) + + Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, + enabled: true, + storage_period: 0 + ) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, original_config) + end) + + transaction = :transaction |> insert() |> with_block() + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn + [%{id: id, params: _}], _ -> + {:ok, + [ + %{ + id: id, + result: %{ + "type" => "create", + "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", + "to" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", + "value" => "0x0", + "gas" => "0x106f5", + "gasUsed" => "0x106f5", + "input" => + "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033", + "output" => + "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033" + } + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, tracer: "call_tracer", debug_trace_timeout: "5s") + + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "callType" => "", + "contractAddress" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", + "errCode" => "", + "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", + "gas" => "67317", + "gasUsed" => "67317", + "index" => "0", + "input" => "", + "isError" => "0", + "timeStamp" => "#{DateTime.to_unix(transaction.block.timestamp)}", + "to" => "", + "transactionHash" => "#{transaction.hash}", + "type" => "create", + "value" => "0" + } + ] + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "txhash" => "#{transaction.hash}", + "include_zero_value" => "true" + } + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + end + end + + describe "txlistinternal with address" do + test "with an invalid address", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "badhash" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "with a address that doesn't exist", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "0" + assert response["message"] == "No internal transactions found" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "doesn't raise when startblock exceeds postgres integer range", %{conn: conn} do + address = insert(:address) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}", + "startblock" => "2147483648", + "endblock" => "7483099" + } + + conn + |> get("/api", params) + |> json_response(200) + end + + test "response includes all the expected fields", %{conn: conn} do + address = insert(:address) + contract_address = insert(:contract_address) + + block = insert(:block) + + transaction = + :transaction + |> insert(from_address: address, to_address: nil) + |> with_contract_creation(contract_address) + |> with_block(block) + + internal_transaction = + :internal_transaction_create + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + from_address: address, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, + block_number: block.number + ) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}" + } + + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", + "value" => "#{internal_transaction.value.value}", + "contractAddress" => "#{contract_address.hash}", + "input" => "", + "type" => "#{internal_transaction.type}", + "callType" => "#{internal_transaction.call_type}", + "gas" => "#{internal_transaction.gas}", + "gasUsed" => "#{internal_transaction.gas_used}", + "isError" => "0", + "index" => "#{internal_transaction.index}", + "transactionHash" => "#{transaction.hash}", + "errCode" => "#{internal_transaction.error}" + } + ] + + response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "isError is true if internal transaction has an error", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + internal_transaction_details = [ + from_address: address, + transaction: transaction, + transaction_index: transaction.index, + index: 0, + type: :reward, + error: "some error", + block_number: transaction.block_number + ] + + insert(:internal_transaction_create, internal_transaction_details) + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}" + } + + assert %{"result" => [found_internal_transaction]} = + response = + conn + |> get("/api", params) + |> json_response(200) + + assert found_internal_transaction["isError"] == "1" + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "with transaction with multiple internal transactions", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..2 do + internal_transaction_details = %{ + from_address: address, + transaction: transaction, + transaction_index: transaction.index, + index: index, + block_number: transaction.block_number, + value: 1 + } + + insert(:internal_transaction_create, internal_transaction_details) + end + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}" + } + + assert %{"result" => found_internal_transactions} = + response = + conn + |> get("/api", params) + |> json_response(200) + + assert length(found_internal_transactions) == 3 + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "returns only non zero value internal transactions by default", %{conn: conn} do + address = insert(:address) + + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + from_address: address, + block_number: block.number + ) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 2, + value: 0, + from_address: address, + block_number: block.number + ) + + internal_transaction = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 2, + from_address: address, + block_number: block.number + ) + + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)}", + "value" => "#{internal_transaction.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction.input}", + "type" => "#{internal_transaction.type}", + "callType" => "#{internal_transaction.call_type}", + "gas" => "#{internal_transaction.gas}", + "gasUsed" => "#{internal_transaction.gas_used}", + "index" => "#{internal_transaction.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction.error}" + } + ] + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}" + } + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + + test "with zero value internal transactions when `include_zero_value=true` param is provided", %{conn: conn} do + address = insert(:address) + + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 0, + value: 1, + from_address: address, + block_number: block.number + ) + + internal_transaction_a = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 1, + value: 2, + from_address: address, + block_number: block.number + ) + + internal_transaction_b = + :internal_transaction + |> insert( + transaction: transaction, + transaction_index: transaction.index, + index: 2, + value: 0, + from_address: address, + block_number: block.number + ) + + expected_result = [ + %{ + "blockNumber" => "#{transaction.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_b.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_b.to_address_id)}", + "value" => "#{internal_transaction_b.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction_b.input}", + "type" => "#{internal_transaction_b.type}", + "callType" => "#{internal_transaction_b.call_type}", + "gas" => "#{internal_transaction_b.gas}", + "gasUsed" => "#{internal_transaction_b.gas_used}", + "index" => "#{internal_transaction_b.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction_b.error}" + }, + %{ + "blockNumber" => "#{internal_transaction_a.block_number}", + "timeStamp" => "#{DateTime.to_unix(block.timestamp)}", + "from" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_a.from_address_id)}", + "to" => "#{AddressIdToAddressHash.id_to_hash(internal_transaction_a.to_address_id)}", + "value" => "#{internal_transaction_a.value.value}", + "contractAddress" => "", + "input" => "#{internal_transaction_a.input}", + "type" => "#{internal_transaction_a.type}", + "callType" => "#{internal_transaction_a.call_type}", + "gas" => "#{internal_transaction_a.gas}", + "gasUsed" => "#{internal_transaction_a.gas_used}", + "index" => "#{internal_transaction_a.index}", + "transactionHash" => "#{transaction.hash}", + "isError" => "0", + "errCode" => "#{internal_transaction_a.error}" + } + ] + + params = %{ + "module" => "account", + "action" => "txlistinternal", + "address" => "#{address.hash}", + "include_zero_value" => "true" + } + + assert response = + conn + |> get("/api/v1", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + end + end + + describe "tokentx" do + test "with missing address hash", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokentx" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "address is required" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "with an invalid address hash", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => "badhash" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "with an address that doesn't exist", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "0" + assert response["message"] == "No token transfers found" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "returns only ERC-20 transfers", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + erc721_token = insert(:token, %{type: "ERC-721"}) + + erc721_token_transfer = + insert(:token_transfer, %{ + token_contract_address: erc721_token.contract_address, + token_ids: [666], + token_type: "ERC-721", + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + }) + + erc1155_token = insert(:token, %{type: "ERC-1155"}) + + erc1155_token_transfer = + insert(:token_transfer, %{ + token_contract_address: erc1155_token.contract_address, + token_ids: [666], + amounts: [1], + token_type: "ERC-1155", + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + }) + + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => to_string(erc721_token_transfer.from_address.hash) + } + + assert response = + %{"message" => "No token transfers found", "result" => [], "status" => "0"} = + conn + |> get("/api", params) + |> json_response(200) + + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => to_string(erc1155_token_transfer.from_address.hash) + } + + assert response = + %{"message" => "No token transfers found", "result" => [], "status" => "0"} = + conn + |> get("/api", params) + |> json_response(200) + + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "returns ERC-7984 transfers with confidential amounts", %{conn: conn} do + transaction = + %{block: block} = + :transaction + |> insert() + |> with_block() + + erc7984_token = insert(:token, %{type: "ERC-7984"}) + + token_transfer = + insert(:token_transfer, + block: transaction.block, + transaction: transaction, + block_number: block.number, + token_contract_address: erc7984_token.contract_address, + token_type: "ERC-7984", + amount: nil + ) + + params = %{ + "module" => "account", + "action" => "token7984tx", + "address" => to_string(token_transfer.from_address.hash) + } + + expected_result = [ + %{ + "blockNumber" => to_string(transaction.block_number), + "timeStamp" => to_string(DateTime.to_unix(block.timestamp)), + "hash" => to_string(token_transfer.transaction_hash), + "nonce" => to_string(transaction.nonce), + "blockHash" => to_string(block.hash), + "from" => to_string(token_transfer.from_address_hash), + "contractAddress" => to_string(token_transfer.token_contract_address_hash), + "to" => to_string(token_transfer.to_address_hash), + "value" => nil, + "tokenName" => erc7984_token.name, + "tokenSymbol" => erc7984_token.symbol, + "tokenDecimal" => to_string(erc7984_token.decimals), + "transactionIndex" => to_string(transaction.index), + "gas" => to_string(transaction.gas), + "gasPrice" => to_string(transaction.gas_price.value), + "gasUsed" => to_string(transaction.gas_used), + "cumulativeGasUsed" => to_string(transaction.cumulative_gas_used), + "input" => to_string(transaction.input), + "confirmations" => "0", + "functionName" => "", + "methodId" => "" + } + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(token7984tx_schema(), response) + end + + test "returns all the required fields", %{conn: conn} do + transaction = + %{block: block} = + :transaction + |> insert() + |> with_block() + + token_transfer = + insert(:token_transfer, block: transaction.block, transaction: transaction, block_number: block.number) + + {:ok, token} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) + + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => to_string(token_transfer.from_address.hash) + } + + expected_result = [ + %{ + "blockNumber" => to_string(transaction.block_number), + "timeStamp" => to_string(DateTime.to_unix(block.timestamp)), + "hash" => to_string(token_transfer.transaction_hash), + "nonce" => to_string(transaction.nonce), + "blockHash" => to_string(block.hash), + "from" => to_string(token_transfer.from_address_hash), + "contractAddress" => to_string(token_transfer.token_contract_address_hash), + "to" => to_string(token_transfer.to_address_hash), + "value" => to_string(token_transfer.amount), + "tokenName" => token.name, + "tokenSymbol" => token.symbol, + "tokenDecimal" => to_string(token.decimals), + "transactionIndex" => to_string(transaction.index), + "gas" => to_string(transaction.gas), + "gasPrice" => to_string(transaction.gas_price.value), + "gasUsed" => to_string(transaction.gas_used), + "cumulativeGasUsed" => to_string(transaction.cumulative_gas_used), + "input" => to_string(transaction.input), + "confirmations" => "0", + "functionName" => "", + "methodId" => "" + } + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "with an invalid contract address", %{conn: conn} do + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + "contractaddress" => "invalid" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid contract address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "filters results by contract address", %{conn: conn} do + address = insert(:address) + + contract_address = insert(:contract_address) + + insert(:token, contract_address: contract_address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + from_address: address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + insert(:token_transfer, + from_address: address, + token_contract_address: contract_address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + params = %{ + "module" => "account", + "action" => "tokentx", + "address" => to_string(address.hash), + "contractaddress" => to_string(contract_address.hash) + } + + assert response = + %{"result" => [result]} = + conn + |> get("/api", params) + |> json_response(200) + + assert result["contractAddress"] == to_string(contract_address.hash) + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + end + + describe "tokennfttx" do + setup do + %{params: %{"module" => "account", "action" => "tokennfttx"}} + end + + test "API endpoint works after `transactions` table denormalization finished", %{conn: conn, params: params} do + BackgroundMigrations.set_tt_denormalization_finished(true) + + address = insert(:address) + + transaction = + :transaction + |> insert(from_address: address) + |> with_block() + + token = insert(:token, name: "NFT", type: "ERC-721") + + insert(:token_transfer, + transaction: transaction, + from_address: address, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_type: token.type, + token_ids: [100_500] + ) + + new_params = + params + |> Map.put("address", Explorer.Chain.Hash.to_string(address.hash)) + + response = + conn + |> get("/api", new_params) + + assert response.status == 200 + BackgroundMigrations.set_tt_denormalization_finished(false) + end + + test "with missing address and contract address hash", %{conn: conn, params: params} do + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] == "Query parameter address or contractaddress is required" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "with an invalid address hash", %{conn: conn, params: params} do + params = Map.merge(params, %{"address" => "badhash"}) + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "with an address that doesn't exist", %{conn: conn, params: params} do + params = Map.merge(params, %{"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"}) + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == [] + assert response["status"] == "0" + assert response["message"] == "No token transfers found" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "has correct value for ERC-721", %{conn: conn, params: params} do + transaction = + :transaction + |> insert() + |> with_block() + + token_address = insert(:contract_address) + insert(:token, %{contract_address: token_address, type: "ERC-721"}) + + token_transfer = + insert(:token_transfer, %{ + token_contract_address: token_address, + token_ids: [666], + token_type: "ERC-721", + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + }) + + {:ok, _} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) + + params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) + + assert response = + %{"result" => [result]} = + conn + |> get("/api", params) + |> json_response(200) + + assert result["tokenID"] == to_string(List.first(token_transfer.token_ids)) + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "returns all the required fields", %{conn: conn, params: params} do + transaction = + %{block: block} = + :transaction + |> insert() + |> with_block() + + token_address = insert(:contract_address) + token = insert(:token, contract_address: token_address, type: "ERC-721") + + token_transfer = + insert(:token_transfer, + block: transaction.block, + transaction: transaction, + block_number: block.number, + token_ids: [1010], + token_type: token.type, + token_contract_address: token_address + ) + + params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) + + expected_result = [ + %{ + "blockNumber" => to_string(transaction.block_number), + "timeStamp" => to_string(DateTime.to_unix(block.timestamp)), + "hash" => to_string(token_transfer.transaction_hash), + "nonce" => to_string(transaction.nonce), + "blockHash" => to_string(block.hash), + "from" => to_string(token_transfer.from_address_hash), + "contractAddress" => to_string(token_transfer.token_contract_address_hash), + "to" => to_string(token_transfer.to_address_hash), + "tokenName" => token.name, + "tokenSymbol" => token.symbol, + "tokenDecimal" => to_string(token.decimals), + "transactionIndex" => to_string(transaction.index), + "gas" => to_string(transaction.gas), + "gasPrice" => to_string(transaction.gas_price.value), + "gasUsed" => to_string(transaction.gas_used), + "cumulativeGasUsed" => to_string(transaction.cumulative_gas_used), + "input" => to_string(transaction.input), + "confirmations" => "0", + "tokenID" => "1010", + "functionName" => "", + "methodId" => "" + } + ] + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "with an invalid contract address", %{conn: conn, params: params} do + params = + Map.merge(params, %{"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", "contractaddress" => "invalid"}) + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid contract address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "filters results by contract address", %{conn: conn, params: params} do + address = insert(:address) + + contract_address = insert(:contract_address) + + insert(:token, contract_address: contract_address, type: "ERC-721") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + from_address: address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + insert(:token_transfer, + from_address: address, + token_contract_address: contract_address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_ids: [123] + ) + + params = + Map.merge(params, %{"address" => to_string(address.hash), "contractaddress" => to_string(contract_address.hash)}) + + assert response = + %{"result" => [result]} = + conn + |> get("/api", params) + |> json_response(200) + + assert result["contractAddress"] == to_string(contract_address.hash) + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "Check pagination and ordering (page, offset, sort parameters)", %{conn: conn, params: params} do + address = insert(:address) + + erc_721_token = insert(:token, type: "ERC-721") + + erc_721_tt = + for x <- 0..50 do + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_contract_address: erc_721_token.contract_address, + token_ids: [x] + ) + end + + # sort: asc + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 1, "sort" => "asc"}) + + assert %{"result" => token_transfers_1} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 2, "sort" => "asc"}) + + assert %{"result" => token_transfers_2} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 3, "sort" => "asc"}) + + assert %{"result" => token_transfers_3} = + conn + |> get("/api", params) + |> json_response(200) + + assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_721_tt, 0).transaction_hash) + assert Enum.at(token_transfers_1, 24)["hash"] == to_string(Enum.at(erc_721_tt, 24).transaction_hash) + assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_721_tt, 25).transaction_hash) + assert Enum.at(token_transfers_2, 24)["hash"] == to_string(Enum.at(erc_721_tt, 49).transaction_hash) + assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_721_tt, 50).transaction_hash) + assert Enum.count(token_transfers_3) == 1 + + # sort: desc + erc_721_tt_reversed = Enum.reverse(erc_721_tt) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 1, "sort" => "desc"}) + + assert %{"result" => token_transfers_1} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 2, "sort" => "desc"}) + + assert %{"result" => token_transfers_2} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 3, "sort" => "desc"}) + + assert %{"result" => token_transfers_3} = + conn + |> get("/api", params) + |> json_response(200) + + assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 0).transaction_hash) + assert Enum.at(token_transfers_1, 24)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 24).transaction_hash) + assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 25).transaction_hash) + assert Enum.at(token_transfers_2, 24)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 49).transaction_hash) + assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 50).transaction_hash) + assert Enum.count(token_transfers_3) == 1 + end + end + + describe "token1155tx" do + setup do + %{params: %{"module" => "account", "action" => "token1155tx"}} end - test "with transaction with multiple internal transactions", %{conn: conn} do + test "API endpoint works after `transactions` table denormalization finished", %{conn: conn, params: params} do + old_tt_denormalization_finished = BackgroundMigrations.get_tt_denormalization_finished() + BackgroundMigrations.set_tt_denormalization_finished(true) + + on_exit(fn -> + BackgroundMigrations.set_tt_denormalization_finished(old_tt_denormalization_finished) + end) + address = insert(:address) transaction = :transaction - |> insert() + |> insert(from_address: address) |> with_block() - for index <- 0..2 do - internal_transaction_details = %{ - from_address: address, - transaction: transaction, - index: index, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index - } + token = insert(:token, name: "NFT", type: "ERC-1155") - insert(:internal_transaction_create, internal_transaction_details) - end + insert(:token_transfer, + transaction: transaction, + from_address: address, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_type: token.type, + token_ids: [10, 20, 30], + amounts: [100, 200, 300] + ) - params = %{ - "module" => "account", - "action" => "txlistinternal", - "address" => "#{address.hash}" - } + new_params = + params + |> Map.put("address", Explorer.Chain.Hash.to_string(address.hash)) - assert %{"result" => found_internal_transactions} = - response = - conn - |> get("/api", params) - |> json_response(200) + response = + conn + |> get("/api", new_params) - assert length(found_internal_transactions) == 3 - assert response["status"] == "1" - assert response["message"] == "OK" - assert :ok = ExJsonSchema.Validator.validate(txlistinternal_schema(), response) + assert response.status == 200 end - end - - describe "tokentx" do - test "with missing address hash", %{conn: conn} do - params = %{ - "module" => "account", - "action" => "tokentx" - } + test "with missing address and contract address hash", %{conn: conn, params: params} do assert response = conn |> get("/api", params) |> json_response(200) - assert response["message"] =~ "address is required" + assert response["message"] == "Query parameter address or contractaddress is required" assert response["status"] == "0" assert Map.has_key?(response, "result") refute response["result"] assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "with an invalid address hash", %{conn: conn} do - params = %{ - "module" => "account", - "action" => "tokentx", - "address" => "badhash" - } + test "with an invalid address hash", %{conn: conn, params: params} do + params = Map.merge(params, %{"address" => "badhash"}) assert response = conn @@ -2269,12 +3632,8 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "with an address that doesn't exist", %{conn: conn} do - params = %{ - "module" => "account", - "action" => "tokentx", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b" - } + test "with an address that doesn't exist", %{conn: conn, params: params} do + params = Map.merge(params, %{"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b"}) assert response = conn @@ -2287,19 +3646,21 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "has correct value for ERC-721", %{conn: conn} do + test "has correct value for single ERC-1155", %{conn: conn, params: params} do transaction = :transaction |> insert() |> with_block() token_address = insert(:contract_address) - insert(:token, %{contract_address: token_address, type: "ERC-721"}) + insert(:token, %{contract_address: token_address, type: "ERC-1155"}) token_transfer = insert(:token_transfer, %{ token_contract_address: token_address, token_ids: [666], + token_type: "ERC-1155", + amount: 1, transaction: transaction, block: transaction.block, block_number: transaction.block_number @@ -2307,41 +3668,94 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do {:ok, _} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) - params = %{ - "module" => "account", - "action" => "tokentx", - "address" => to_string(token_transfer.from_address.hash) - } + params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) assert response = - %{"result" => [result]} = + %{"result" => results} = conn |> get("/api", params) |> json_response(200) + assert length(results) == 1 + + [result] = results + assert result["tokenID"] == to_string(List.first(token_transfer.token_ids)) + assert result["tokenValue"] == to_string(token_transfer.amount) assert response["status"] == "1" assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "returns all the required fields", %{conn: conn} do + test "has correct value for multiple ERC-1155", %{conn: conn, params: params} do transaction = - %{block: block} = :transaction |> insert() |> with_block() + token_address = insert(:contract_address) + insert(:token, %{contract_address: token_address, type: "ERC-1155"}) + token_transfer = - insert(:token_transfer, block: transaction.block, transaction: transaction, block_number: block.number) + insert(:token_transfer, %{ + token_contract_address: token_address, + token_ids: [669, 999, 1337], + amounts: [1, 2, 10], + token_type: "ERC-1155", + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + }) - {:ok, token} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) + {:ok, _} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) - params = %{ - "module" => "account", - "action" => "tokentx", - "address" => to_string(token_transfer.from_address.hash) - } + params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) + + assert response = + %{"result" => results} = + conn + |> get("/api", params) + |> json_response(200) + + assert length(results) == 3 + + [result1, result2, result3] = results + assert result1["tokenID"] == to_string(Enum.at(token_transfer.token_ids, 2)) + assert result1["tokenValue"] == to_string(Enum.at(token_transfer.amounts, 2)) + + assert result2["tokenID"] == to_string(Enum.at(token_transfer.token_ids, 1)) + assert result2["tokenValue"] == to_string(Enum.at(token_transfer.amounts, 1)) + + assert result3["tokenID"] == to_string(Enum.at(token_transfer.token_ids, 0)) + assert result3["tokenValue"] == to_string(Enum.at(token_transfer.amounts, 0)) + + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "returns all the required fields", %{conn: conn, params: params} do + transaction = + %{block: block} = + :transaction + |> insert() + |> with_block() + + token_address = insert(:contract_address) + token = insert(:token, contract_address: token_address, type: "ERC-1155") + + token_transfer = + insert(:token_transfer, + block: transaction.block, + token_type: token.type, + transaction: transaction, + block_number: block.number, + token_ids: [1010], + token_contract_address: token_address, + amount: 5 + ) + + params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) expected_result = [ %{ @@ -2353,7 +3767,6 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "from" => to_string(token_transfer.from_address_hash), "contractAddress" => to_string(token_transfer.token_contract_address_hash), "to" => to_string(token_transfer.to_address_hash), - "value" => to_string(token_transfer.amount), "tokenName" => token.name, "tokenSymbol" => token.symbol, "tokenDecimal" => to_string(token.decimals), @@ -2362,9 +3775,12 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "gasPrice" => to_string(transaction.gas_price.value), "gasUsed" => to_string(transaction.gas_used), "cumulativeGasUsed" => to_string(transaction.cumulative_gas_used), - "logIndex" => to_string(token_transfer.log_index), "input" => to_string(transaction.input), - "confirmations" => "0" + "confirmations" => "0", + "tokenID" => "1010", + "tokenValue" => "5", + "functionName" => "", + "methodId" => "" } ] @@ -2379,13 +3795,9 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "with an invalid contract address", %{conn: conn} do - params = %{ - "module" => "account", - "action" => "tokentx", - "address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", - "contractaddress" => "invalid" - } + test "with an invalid contract address", %{conn: conn, params: params} do + params = + Map.merge(params, %{"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", "contractaddress" => "invalid"}) assert response = conn @@ -2399,12 +3811,12 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "filters results by contract address", %{conn: conn} do + test "filters results by contract address", %{conn: conn, params: params} do address = insert(:address) contract_address = insert(:contract_address) - insert(:token, contract_address: contract_address) + insert(:token, contract_address: contract_address, type: "ERC-1155") transaction = :transaction @@ -2423,15 +3835,13 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do token_contract_address: contract_address, transaction: transaction, block: transaction.block, - block_number: transaction.block_number + block_number: transaction.block_number, + token_ids: [123], + amount: 1 ) - params = %{ - "module" => "account", - "action" => "tokentx", - "address" => to_string(address.hash), - "contractaddress" => to_string(contract_address.hash) - } + params = + Map.merge(params, %{"address" => to_string(address.hash), "contractaddress" => to_string(contract_address.hash)}) assert response = %{"result" => [result]} = @@ -2444,15 +3854,206 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end + + test "Check pagination and ordering (page, offset, sort parameters)", %{conn: conn, params: params} do + address = insert(:address) + + erc_1155_token = insert(:token, type: "ERC-1155") + + erc_1155_tt = + for x <- 0..50 do + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_type: erc_1155_token.type, + token_contract_address: erc_1155_token.contract_address, + token_ids: [x, x + 100], + amounts: [1, 2] + ) + end + + # sort: asc + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 1, "sort" => "asc"}) + + assert %{"result" => token_transfers_1} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 2, "sort" => "asc"}) + + assert %{"result" => token_transfers_2} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 3, "sort" => "asc"}) + + assert %{"result" => token_transfers_3} = + conn + |> get("/api", params) + |> json_response(200) + + assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_1155_tt, 0).transaction_hash) + assert Enum.at(token_transfers_1, 0)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 0).token_ids, 0)) + assert Enum.at(token_transfers_1, 0)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 0).amounts, 0)) + assert Enum.at(token_transfers_1, 1)["hash"] == to_string(Enum.at(erc_1155_tt, 0).transaction_hash) + assert Enum.at(token_transfers_1, 1)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 0).token_ids, 1)) + assert Enum.at(token_transfers_1, 1)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 0).amounts, 1)) + + assert Enum.at(token_transfers_1, 48)["hash"] == to_string(Enum.at(erc_1155_tt, 24).transaction_hash) + assert Enum.at(token_transfers_1, 48)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 24).token_ids, 0)) + assert Enum.at(token_transfers_1, 48)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 24).amounts, 0)) + assert Enum.at(token_transfers_1, 49)["hash"] == to_string(Enum.at(erc_1155_tt, 24).transaction_hash) + assert Enum.at(token_transfers_1, 49)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 24).token_ids, 1)) + assert Enum.at(token_transfers_1, 49)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 24).amounts, 1)) + + assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_1155_tt, 25).transaction_hash) + assert Enum.at(token_transfers_2, 0)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 25).token_ids, 0)) + assert Enum.at(token_transfers_2, 0)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 25).amounts, 0)) + assert Enum.at(token_transfers_2, 1)["hash"] == to_string(Enum.at(erc_1155_tt, 25).transaction_hash) + assert Enum.at(token_transfers_2, 1)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 25).token_ids, 1)) + assert Enum.at(token_transfers_2, 1)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 25).amounts, 1)) + + assert Enum.at(token_transfers_2, 48)["hash"] == to_string(Enum.at(erc_1155_tt, 49).transaction_hash) + assert Enum.at(token_transfers_2, 48)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 49).token_ids, 0)) + assert Enum.at(token_transfers_2, 48)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 49).amounts, 0)) + assert Enum.at(token_transfers_2, 49)["hash"] == to_string(Enum.at(erc_1155_tt, 49).transaction_hash) + assert Enum.at(token_transfers_2, 49)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 49).token_ids, 1)) + assert Enum.at(token_transfers_2, 49)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 49).amounts, 1)) + + assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_1155_tt, 50).transaction_hash) + assert Enum.at(token_transfers_3, 0)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 50).token_ids, 0)) + assert Enum.at(token_transfers_3, 0)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 50).amounts, 0)) + assert Enum.at(token_transfers_3, 1)["hash"] == to_string(Enum.at(erc_1155_tt, 50).transaction_hash) + assert Enum.at(token_transfers_3, 1)["tokenID"] == to_string(Enum.at(Enum.at(erc_1155_tt, 50).token_ids, 1)) + assert Enum.at(token_transfers_3, 1)["tokenValue"] == to_string(Enum.at(Enum.at(erc_1155_tt, 50).amounts, 1)) + + assert Enum.count(token_transfers_3) == 2 + + # sort: desc + erc_1155_tt_reversed = Enum.reverse(erc_1155_tt) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 1, "sort" => "desc"}) + + assert %{"result" => token_transfers_1} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 2, "sort" => "desc"}) + + assert %{"result" => token_transfers_2} = + conn + |> get("/api", params) + |> json_response(200) + + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 3, "sort" => "desc"}) + + assert %{"result" => token_transfers_3} = + conn + |> get("/api", params) + |> json_response(200) + + assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 0).transaction_hash) + + assert Enum.at(token_transfers_1, 0)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 0).token_ids, 1)) + + assert Enum.at(token_transfers_1, 0)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 0).amounts, 1)) + + assert Enum.at(token_transfers_1, 1)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 0).transaction_hash) + + assert Enum.at(token_transfers_1, 1)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 0).token_ids, 0)) + + assert Enum.at(token_transfers_1, 1)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 0).amounts, 0)) + + assert Enum.at(token_transfers_1, 48)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 24).transaction_hash) + + assert Enum.at(token_transfers_1, 48)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 24).token_ids, 1)) + + assert Enum.at(token_transfers_1, 48)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 24).amounts, 1)) + + assert Enum.at(token_transfers_1, 49)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 24).transaction_hash) + + assert Enum.at(token_transfers_1, 49)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 24).token_ids, 0)) + + assert Enum.at(token_transfers_1, 49)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 24).amounts, 0)) + + assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 25).transaction_hash) + + assert Enum.at(token_transfers_2, 0)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 25).token_ids, 1)) + + assert Enum.at(token_transfers_2, 0)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 25).amounts, 1)) + + assert Enum.at(token_transfers_2, 1)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 25).transaction_hash) + + assert Enum.at(token_transfers_2, 1)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 25).token_ids, 0)) + + assert Enum.at(token_transfers_2, 1)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 25).amounts, 0)) + + assert Enum.at(token_transfers_2, 48)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 49).transaction_hash) + + assert Enum.at(token_transfers_2, 48)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 49).token_ids, 1)) + + assert Enum.at(token_transfers_2, 48)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 49).amounts, 1)) + + assert Enum.at(token_transfers_2, 49)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 49).transaction_hash) + + assert Enum.at(token_transfers_2, 49)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 49).token_ids, 0)) + + assert Enum.at(token_transfers_2, 49)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 49).amounts, 0)) + + assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 50).transaction_hash) + + assert Enum.at(token_transfers_3, 0)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 50).token_ids, 1)) + + assert Enum.at(token_transfers_3, 0)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 50).amounts, 1)) + + assert Enum.at(token_transfers_3, 1)["hash"] == to_string(Enum.at(erc_1155_tt_reversed, 50).transaction_hash) + + assert Enum.at(token_transfers_3, 1)["tokenID"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 50).token_ids, 0)) + + assert Enum.at(token_transfers_3, 1)["tokenValue"] == + to_string(Enum.at(Enum.at(erc_1155_tt_reversed, 50).amounts, 0)) + + assert Enum.count(token_transfers_3) == 2 + end end - describe "tokennfttx" do + describe "token404tx" do setup do - %{params: %{"module" => "account", "action" => "tokennfttx"}} + %{params: %{"module" => "account", "action" => "token404tx"}} end - test "API endpoint works after `transactions` table denormalization finished", %{conn: conn, params: params} do - BackgroundMigrations.set_tt_denormalization_finished(true) + test "works before denormalization finished and after", %{conn: conn, params: params} do + old_tt_denormalization_finished = BackgroundMigrations.get_tt_denormalization_finished() + + on_exit(fn -> + BackgroundMigrations.set_tt_denormalization_finished(old_tt_denormalization_finished) + end) address = insert(:address) @@ -2461,27 +4062,49 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do |> insert(from_address: address) |> with_block() - token = insert(:token, name: "NFT", type: "ERC-721") + token = insert(:token, name: "NFT", type: "ERC-404") insert(:token_transfer, transaction: transaction, + token_type: token.type, from_address: address, block_number: transaction.block_number, token_contract_address: token.contract_address, token_type: token.type, - token_ids: [100_500] + token_ids: [10], + amounts: [] + ) + + insert(:token_transfer, + transaction: transaction, + token_type: token.type, + from_address: address, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_type: token.type, + token_ids: [], + amounts: [20] ) new_params = params |> Map.put("address", Explorer.Chain.Hash.to_string(address.hash)) + BackgroundMigrations.set_tt_denormalization_finished(false) + + response = + conn + |> get("/api", new_params) + + assert response.status == 200 + + BackgroundMigrations.set_tt_denormalization_finished(true) + response = conn |> get("/api", new_params) assert response.status == 200 - BackgroundMigrations.set_tt_denormalization_finished(false) end test "with missing address and contract address hash", %{conn: conn, params: params} do @@ -2526,27 +4149,57 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "has correct value for ERC-721", %{conn: conn, params: params} do + test "with an invalid contract address", %{conn: conn, params: params} do + params = + Map.merge(params, %{"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", "contractaddress" => "invalid"}) + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] =~ "Invalid contract address format" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) + end + + test "filters results by contract address", %{conn: conn, params: params} do + address = insert(:address) + + contract_address = insert(:contract_address) + + insert(:token, contract_address: contract_address, type: "ERC-404") + transaction = :transaction |> insert() |> with_block() - token_address = insert(:contract_address) - insert(:token, %{contract_address: token_address, type: "ERC-721"}) - - token_transfer = - insert(:token_transfer, %{ - token_contract_address: token_address, - token_ids: [666], - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number - }) + insert(:token_transfer, + from_address: address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_type: "ERC-404", + token_ids: [1], + amounts: [] + ) - {:ok, _} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) + insert(:token_transfer, + from_address: address, + token_contract_address: contract_address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_ids: [], + amounts: [1], + token_type: "ERC-404" + ) - params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) + params = + Map.merge(params, %{"address" => to_string(address.hash), "contractaddress" => to_string(contract_address.hash)}) assert response = %{"result" => [result]} = @@ -2554,40 +4207,46 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do |> get("/api", params) |> json_response(200) - assert result["tokenID"] == to_string(List.first(token_transfer.token_ids)) + assert result["contractAddress"] == to_string(contract_address.hash) assert response["status"] == "1" assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end test "returns all the required fields", %{conn: conn, params: params} do + address = insert(:address) + + contract_address = insert(:contract_address) + + token = insert(:token, contract_address: contract_address, type: "ERC-404") + transaction = - %{block: block} = :transaction |> insert() |> with_block() - token_address = insert(:contract_address) - token = insert(:token, contract_address: token_address, type: "ERC-721") - token_transfer = insert(:token_transfer, - block: transaction.block, + from_address: address, + token_contract_address: contract_address, transaction: transaction, - block_number: block.number, - token_ids: [1010], - token_contract_address: token_address + block: transaction.block, + block_number: transaction.block_number, + token_ids: [], + amounts: [1], + token_type: token.type ) - params = Map.merge(params, %{"address" => to_string(token_transfer.from_address.hash)}) + params = + Map.merge(params, %{"address" => to_string(address.hash), "contractaddress" => to_string(contract_address.hash)}) expected_result = [ %{ "blockNumber" => to_string(transaction.block_number), - "timeStamp" => to_string(DateTime.to_unix(block.timestamp)), + "timeStamp" => to_string(DateTime.to_unix(transaction.block.timestamp)), "hash" => to_string(token_transfer.transaction_hash), "nonce" => to_string(transaction.nonce), - "blockHash" => to_string(block.hash), + "blockHash" => to_string(transaction.block.hash), "from" => to_string(token_transfer.from_address_hash), "contractAddress" => to_string(token_transfer.token_contract_address_hash), "to" => to_string(token_transfer.to_address_hash), @@ -2599,10 +4258,12 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "gasPrice" => to_string(transaction.gas_price.value), "gasUsed" => to_string(transaction.gas_used), "cumulativeGasUsed" => to_string(transaction.cumulative_gas_used), - "logIndex" => to_string(token_transfer.log_index), - "input" => "deprecated", + "input" => to_string(transaction.input), "confirmations" => "0", - "tokenID" => "1010" + "tokenID" => "", + "value" => "1", + "functionName" => "", + "methodId" => "" } ] @@ -2617,143 +4278,192 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) end - test "with an invalid contract address", %{conn: conn, params: params} do - params = - Map.merge(params, %{"address" => "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", "contractaddress" => "invalid"}) - - assert response = - conn - |> get("/api", params) - |> json_response(200) - - assert response["message"] =~ "Invalid contract address format" - assert response["status"] == "0" - assert Map.has_key?(response, "result") - refute response["result"] - assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) - end - - test "filters results by contract address", %{conn: conn, params: params} do - address = insert(:address) - - contract_address = insert(:contract_address) - - insert(:token, contract_address: contract_address, type: "ERC-721") - - transaction = - :transaction - |> insert() - |> with_block() - - insert(:token_transfer, - from_address: address, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number - ) - - insert(:token_transfer, - from_address: address, - token_contract_address: contract_address, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - token_ids: [123] - ) - - params = - Map.merge(params, %{"address" => to_string(address.hash), "contractaddress" => to_string(contract_address.hash)}) - - assert response = - %{"result" => [result]} = - conn - |> get("/api", params) - |> json_response(200) - - assert result["contractAddress"] == to_string(contract_address.hash) - assert response["status"] == "1" - assert response["message"] == "OK" - assert :ok = ExJsonSchema.Validator.validate(tokentx_schema(), response) - end - - test "Check pagination and ordering (page, offset, sort parameters)", %{conn: conn, params: params} do + test "check pagination and ordering (page, offset, sort parameters)", %{conn: conn, params: params} do address = insert(:address) - erc_721_token = insert(:token, type: "ERC-721") + erc_404_token = insert(:token, type: "ERC-404") - erc_721_tt = + erc_404_tt = for x <- 0..50 do transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - from_address: address, - token_contract_address: erc_721_token.contract_address, - token_ids: [x] - ) + [ + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_type: erc_404_token.type, + token_contract_address: erc_404_token.contract_address, + token_ids: [x], + amounts: [] + ), + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_type: erc_404_token.type, + token_contract_address: erc_404_token.contract_address, + token_ids: [], + amounts: [x + 100] + ) + ] end + |> List.flatten() # sort: asc - params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 1, "sort" => "asc"}) + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 1, "sort" => "asc"}) assert %{"result" => token_transfers_1} = conn |> get("/api", params) |> json_response(200) - params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 2, "sort" => "asc"}) + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 2, "sort" => "asc"}) assert %{"result" => token_transfers_2} = conn |> get("/api", params) |> json_response(200) - params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 3, "sort" => "asc"}) + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 3, "sort" => "asc"}) assert %{"result" => token_transfers_3} = conn |> get("/api", params) |> json_response(200) - assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_721_tt, 0).transaction_hash) - assert Enum.at(token_transfers_1, 24)["hash"] == to_string(Enum.at(erc_721_tt, 24).transaction_hash) - assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_721_tt, 25).transaction_hash) - assert Enum.at(token_transfers_2, 24)["hash"] == to_string(Enum.at(erc_721_tt, 49).transaction_hash) - assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_721_tt, 50).transaction_hash) - assert Enum.count(token_transfers_3) == 1 + assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_404_tt, 0).transaction_hash) + assert Enum.at(token_transfers_1, 0)["tokenID"] == to_string(Enum.at(Enum.at(erc_404_tt, 0).token_ids, 0)) + assert Enum.at(token_transfers_1, 0)["value"] == "" + assert Enum.at(token_transfers_1, 1)["hash"] == to_string(Enum.at(erc_404_tt, 1).transaction_hash) + assert Enum.at(token_transfers_1, 1)["tokenID"] == "" + assert Enum.at(token_transfers_1, 1)["value"] == to_string(Enum.at(Enum.at(erc_404_tt, 1).amounts, 0)) + + assert Enum.at(token_transfers_1, 48)["hash"] == to_string(Enum.at(erc_404_tt, 48).transaction_hash) + assert Enum.at(token_transfers_1, 48)["tokenID"] == to_string(Enum.at(Enum.at(erc_404_tt, 48).token_ids, 0)) + assert Enum.at(token_transfers_1, 48)["value"] == "" + assert Enum.at(token_transfers_1, 49)["hash"] == to_string(Enum.at(erc_404_tt, 49).transaction_hash) + assert Enum.at(token_transfers_1, 49)["tokenID"] == "" + assert Enum.at(token_transfers_1, 49)["value"] == to_string(Enum.at(Enum.at(erc_404_tt, 49).amounts, 0)) + + assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_404_tt, 50).transaction_hash) + assert Enum.at(token_transfers_2, 0)["tokenID"] == to_string(Enum.at(Enum.at(erc_404_tt, 50).token_ids, 0)) + assert Enum.at(token_transfers_2, 0)["value"] == "" + assert Enum.at(token_transfers_2, 1)["hash"] == to_string(Enum.at(erc_404_tt, 51).transaction_hash) + assert Enum.at(token_transfers_2, 1)["tokenID"] == "" + assert Enum.at(token_transfers_2, 1)["value"] == to_string(Enum.at(Enum.at(erc_404_tt, 51).amounts, 0)) + + assert Enum.at(token_transfers_2, 48)["hash"] == to_string(Enum.at(erc_404_tt, 98).transaction_hash) + assert Enum.at(token_transfers_2, 48)["tokenID"] == to_string(Enum.at(Enum.at(erc_404_tt, 98).token_ids, 0)) + assert Enum.at(token_transfers_2, 48)["value"] == "" + assert Enum.at(token_transfers_2, 49)["hash"] == to_string(Enum.at(erc_404_tt, 99).transaction_hash) + assert Enum.at(token_transfers_2, 49)["tokenID"] == "" + assert Enum.at(token_transfers_2, 49)["value"] == to_string(Enum.at(Enum.at(erc_404_tt, 99).amounts, 0)) + + assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_404_tt, 100).transaction_hash) + assert Enum.at(token_transfers_3, 0)["tokenID"] == to_string(Enum.at(Enum.at(erc_404_tt, 100).token_ids, 0)) + assert Enum.at(token_transfers_3, 0)["value"] == "" + assert Enum.at(token_transfers_3, 1)["hash"] == to_string(Enum.at(erc_404_tt, 101).transaction_hash) + assert Enum.at(token_transfers_3, 1)["tokenID"] == "" + assert Enum.at(token_transfers_3, 1)["value"] == to_string(Enum.at(Enum.at(erc_404_tt, 101).amounts, 0)) + + assert Enum.count(token_transfers_3) == 2 # sort: desc - erc_721_tt_reversed = Enum.reverse(erc_721_tt) + erc_404_tt_reversed = Enum.reverse(erc_404_tt) - params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 1, "sort" => "desc"}) + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 1, "sort" => "desc"}) assert %{"result" => token_transfers_1} = conn |> get("/api", params) |> json_response(200) - params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 2, "sort" => "desc"}) + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 2, "sort" => "desc"}) assert %{"result" => token_transfers_2} = conn |> get("/api", params) |> json_response(200) - params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 25, "page" => 3, "sort" => "desc"}) + params = Map.merge(params, %{"address" => to_string(address.hash), "offset" => 50, "page" => 3, "sort" => "desc"}) assert %{"result" => token_transfers_3} = conn |> get("/api", params) |> json_response(200) - assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 0).transaction_hash) - assert Enum.at(token_transfers_1, 24)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 24).transaction_hash) - assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 25).transaction_hash) - assert Enum.at(token_transfers_2, 24)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 49).transaction_hash) - assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_721_tt_reversed, 50).transaction_hash) - assert Enum.count(token_transfers_3) == 1 + assert Enum.at(token_transfers_1, 0)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 0).transaction_hash) + + assert Enum.at(token_transfers_1, 0)["tokenID"] == "" + + assert Enum.at(token_transfers_1, 0)["value"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 0).amounts, 0)) + + assert Enum.at(token_transfers_1, 1)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 1).transaction_hash) + + assert Enum.at(token_transfers_1, 1)["tokenID"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 1).token_ids, 0)) + + assert Enum.at(token_transfers_1, 1)["value"] == "" + + assert Enum.at(token_transfers_1, 48)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 48).transaction_hash) + + assert Enum.at(token_transfers_1, 48)["tokenID"] == "" + + assert Enum.at(token_transfers_1, 48)["value"] == to_string(Enum.at(Enum.at(erc_404_tt_reversed, 48).amounts, 0)) + + assert Enum.at(token_transfers_1, 49)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 49).transaction_hash) + + assert Enum.at(token_transfers_1, 49)["tokenID"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 49).token_ids, 0)) + + assert Enum.at(token_transfers_1, 49)["value"] == "" + + assert Enum.at(token_transfers_2, 0)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 50).transaction_hash) + + assert Enum.at(token_transfers_2, 0)["tokenID"] == "" + + assert Enum.at(token_transfers_2, 0)["value"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 50).amounts, 0)) + + assert Enum.at(token_transfers_2, 1)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 51).transaction_hash) + + assert Enum.at(token_transfers_2, 1)["tokenID"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 51).token_ids, 0)) + + assert Enum.at(token_transfers_2, 1)["value"] == "" + + assert Enum.at(token_transfers_2, 48)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 98).transaction_hash) + + assert Enum.at(token_transfers_2, 48)["tokenID"] == "" + + assert Enum.at(token_transfers_2, 48)["value"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 98).amounts, 0)) + + assert Enum.at(token_transfers_2, 49)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 99).transaction_hash) + + assert Enum.at(token_transfers_2, 49)["tokenID"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 99).token_ids, 0)) + + assert Enum.at(token_transfers_2, 49)["value"] == "" + + assert Enum.at(token_transfers_3, 0)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 100).transaction_hash) + + assert Enum.at(token_transfers_3, 0)["tokenID"] == "" + + assert Enum.at(token_transfers_3, 0)["value"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 100).amounts, 0)) + + assert Enum.at(token_transfers_3, 1)["hash"] == to_string(Enum.at(erc_404_tt_reversed, 101).transaction_hash) + + assert Enum.at(token_transfers_3, 1)["tokenID"] == + to_string(Enum.at(Enum.at(erc_404_tt_reversed, 101).token_ids, 0)) + + assert Enum.at(token_transfers_3, 1)["value"] == "" + assert(Enum.count(token_transfers_3) == 2) end end @@ -3231,7 +4941,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "end_timestamp" => "1539186474" } - optional_params = AddressController.optional_params(params) + {:ok, optional_params} = AddressController.optional_params(params) # 1539186474 equals "2018-10-10 15:47:54Z" {:ok, expected_timestamp, _} = DateTime.from_iso8601("2018-10-10 15:47:54Z") @@ -3249,41 +4959,41 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do test "'sort' values can be 'asc' or 'desc'" do params1 = %{"sort" => "asc"} - optional_params = AddressController.optional_params(params1) + {:ok, optional_params} = AddressController.optional_params(params1) assert optional_params.order_by_direction == :asc params2 = %{"sort" => "desc"} - optional_params = AddressController.optional_params(params2) + {:ok, optional_params} = AddressController.optional_params(params2) assert optional_params.order_by_direction == :desc params3 = %{"sort" => "invalid"} - assert AddressController.optional_params(params3) == %{} + assert AddressController.optional_params(params3) == {:ok, %{}} end test "'filter_by' value can be 'to' or 'from'" do params1 = %{"filter_by" => "to"} - optional_params1 = AddressController.optional_params(params1) + {:ok, optional_params1} = AddressController.optional_params(params1) assert optional_params1.filter_by == "to" params2 = %{"filter_by" => "from"} - optional_params2 = AddressController.optional_params(params2) + {:ok, optional_params2} = AddressController.optional_params(params2) assert optional_params2.filter_by == "from" params3 = %{"filter_by" => "invalid"} - assert AddressController.optional_params(params3) == %{} + assert AddressController.optional_params(params3) == {:ok, %{}} end test "only includes optional params when they're given" do - assert AddressController.optional_params(%{}) == %{} + assert AddressController.optional_params(%{}) == {:ok, %{}} end test "ignores invalid optional params, keeps valid ones" do @@ -3297,7 +5007,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "end_timestamp" => "invalid" } - assert AddressController.optional_params(params1) == %{} + assert AddressController.optional_params(params1) == {:ok, %{}} params2 = %{ "startblock" => "4", @@ -3309,7 +5019,7 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do "end_timestamp" => "invalid" } - optional_params = AddressController.optional_params(params2) + {:ok, optional_params} = AddressController.optional_params(params2) assert optional_params.startblock == 4 assert optional_params.endblock == 10 @@ -3318,19 +5028,19 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do test "ignores 'page' if less than 1" do params = %{"page" => "0"} - assert AddressController.optional_params(params) == %{} + assert AddressController.optional_params(params) == {:ok, %{}} end test "ignores 'offset' if less than 1" do params = %{"offset" => "0"} - assert AddressController.optional_params(params) == %{} + assert AddressController.optional_params(params) == {:ok, %{}} end test "ignores 'offset' if more than 10,000" do params = %{"offset" => "10001"} - assert AddressController.optional_params(params) == %{} + assert AddressController.optional_params(params) == {:ok, %{}} end end @@ -3470,6 +5180,38 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do }) end + defp token7984tx_schema do + resolve_schema(%{ + "type" => ["array", "null"], + "items" => %{ + "type" => "object", + "properties" => %{ + "blockNumber" => %{"type" => "string"}, + "timeStamp" => %{"type" => "string"}, + "hash" => %{"type" => "string"}, + "nonce" => %{"type" => "string"}, + "blockHash" => %{"type" => "string"}, + "from" => %{"type" => "string"}, + "contractAddress" => %{"type" => "string"}, + "to" => %{"type" => "string"}, + "logIndex" => %{"type" => "string"}, + "value" => %{"type" => "null"}, + "tokenName" => %{"type" => "string"}, + "tokenID" => %{"type" => "string"}, + "tokenSymbol" => %{"type" => "string"}, + "tokenDecimal" => %{"type" => "string"}, + "transactionIndex" => %{"type" => "string"}, + "gas" => %{"type" => "string"}, + "gasPrice" => %{"type" => "string"}, + "gasUsed" => %{"type" => "string"}, + "cumulativeGasUsed" => %{"type" => "string"}, + "input" => %{"type" => "string"}, + "confirmations" => %{"type" => "string"} + } + } + }) + end + defp tokenbalance_schema, do: resolve_schema(%{"type" => ["string", "null"]}) defp tokenlist_schema do @@ -3514,40 +5256,4 @@ defmodule BlockScoutWeb.API.RPC.AddressControllerTest do |> put_in(["properties", "result"], result) |> ExJsonSchema.Schema.resolve() end - - defp eth_block_number_fake_response(block_quantity) do - %{ - id: 0, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] - } - } - end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/block_controller_test.exs index 9ee058f30e72..324a2e763b2d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/block_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.BlockControllerTest do use BlockScoutWeb.ConnCase @@ -169,6 +170,9 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do start_supervised!(AverageBlockTime) Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.BlockNumber.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.BlockNumber.child_id()) + on_exit(fn -> Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) end) @@ -183,7 +187,11 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do first_timestamp = Timex.now() for i <- 1..current_block_number do - insert(:block, number: i, timestamp: Timex.shift(first_timestamp, seconds: i * average_block_time)) + insert(:block, + number: i, + timestamp: Timex.shift(first_timestamp, seconds: i * average_block_time), + consensus: true + ) end AverageBlockTime.refresh() @@ -225,7 +233,7 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do assert response["status"] == "0" assert Map.has_key?(response, "result") refute response["result"] - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end @@ -239,7 +247,7 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do assert response["status"] == "0" assert Map.has_key?(response, "result") refute response["result"] - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end @@ -258,7 +266,26 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do assert response["status"] == "0" assert Map.has_key?(response, "result") refute response["result"] - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() + assert :ok = ExJsonSchema.Validator.validate(schema, response) + end + + test "with an excessively large timestamp param", %{conn: conn} do + response = + conn + |> get("/api", %{ + "module" => "block", + "action" => "getblocknobytime", + "timestamp" => "1000000000000000000000000", + "closest" => "before" + }) + |> json_response(200) + + assert response["message"] =~ "Invalid `timestamp` param" + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end @@ -277,7 +304,7 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do assert response["status"] == "0" assert Map.has_key?(response, "result") refute response["result"] - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end @@ -292,10 +319,6 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do (timestamp_int + 1) |> to_string() - expected_result = %{ - "blockNumber" => "#{block.number}" - } - assert response = conn |> get("/api", %{ @@ -306,10 +329,12 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do }) |> json_response(200) - assert response["result"] == expected_result + # TODO: migrate to the following format in the next release + # assert response["result"] == "#{block.number}" + assert response["result"] == %{"blockNumber" => "#{block.number}"} assert response["status"] == "1" assert response["message"] == "OK" - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end @@ -324,10 +349,6 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do (timestamp_int - 1) |> to_string() - expected_result = %{ - "blockNumber" => "#{block.number}" - } - assert response = conn |> get("/api", %{ @@ -338,10 +359,12 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do }) |> json_response(200) - assert response["result"] == expected_result + # TODO: migrate to the following format in the next release + # assert response["result"] == "#{block.number}" + assert response["result"] == %{"blockNumber" => "#{block.number}"} assert response["status"] == "1" assert response["message"] == "OK" - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end @@ -356,10 +379,6 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do (timestamp_int - 2 * 60) |> to_string() - expected_result = %{ - "blockNumber" => "#{block.number}" - } - assert response = conn |> get("/api", %{ @@ -370,10 +389,12 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do }) |> json_response(200) - assert response["result"] == expected_result + # TODO: migrate to the following format in the next release + # assert response["result"] == "#{block.number}" + assert response["result"] == %{"blockNumber" => "#{block.number}"} assert response["status"] == "1" assert response["message"] == "OK" - schema = resolve_getblockreward_schema() + schema = resolve_getblocknobytime_schema() assert :ok = ExJsonSchema.Validator.validate(schema, response) end end @@ -427,4 +448,26 @@ defmodule BlockScoutWeb.API.RPC.BlockControllerTest do } }) end + + defp resolve_getblocknobytime_schema do + ExJsonSchema.Schema.resolve(%{ + "type" => "object", + "properties" => %{ + "message" => %{"type" => "string"}, + "status" => %{"type" => "string"}, + # TODO: migrate to the following format in the next release + # + # "result" => %{ + # "type" => ["string", "null"], + # "description" => "Block number as a string or null if not found" + # } + "result" => %{ + "type" => ["object", "null"], + "properties" => %{ + "blockNumber" => %{"type" => "string"} + } + } + } + }) + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs index 04e2eb956c02..0d53977bba07 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.ContractControllerTest do use BlockScoutWeb.ConnCase @@ -6,7 +7,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do alias Explorer.{Repo, TestHelper} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation - alias Explorer.Chain.{Address, SmartContract} + alias Explorer.Chain.{Address, InternalTransaction, SmartContract} setup :verify_on_exit! @@ -76,6 +77,14 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do } end + setup do + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + end + describe "listcontracts" do setup do %{params: %{"module" => "contract", "action" => "listcontracts"}} @@ -463,7 +472,8 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do } ] - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() assert response = conn @@ -728,7 +738,8 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do } ] - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() assert response = conn @@ -769,8 +780,6 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do created_contract_address: created_contract_address, created_contract_code: smart_contract_bytecode, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -837,7 +846,8 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do } ] - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() assert response = conn @@ -856,173 +866,174 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do end end - describe "verify" do - test "verify known on sourcify repo contract", %{conn: conn} do - response = verify(conn) - - assert response["message"] == "OK" - assert response["status"] == "1" - - assert response["result"]["ABI"] == - "[{\"inputs\":[],\"name\":\"retrieve\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_number\",\"type\":\"uint256\"}],\"name\":\"store\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]" - - assert response["result"]["CompilerVersion"] == "v0.7.6+commit.7338295f" - assert response["result"]["ContractName"] == "Storage" - assert response["result"]["EVMVersion"] == "istanbul" - assert response["result"]["OptimizationUsed"] == "false" - end - - test "verify already verified contract", %{conn: conn} do - _response = verify(conn) - - params = %{ - "module" => "contract", - "action" => "verify_via_sourcify", - "addressHash" => "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1" - } - - response = - conn - |> get("/api", params) - |> json_response(200) - - assert response["message"] == "Smart-contract already verified." - assert response["status"] == "0" - assert response["result"] == nil - end - - defp verify(conn) do - smart_contract_bytecode = - "0x6080604052348015600f57600080fd5b506004361060325760003560e01c80632e64cec11460375780636057361d146053575b600080fd5b603d607e565b6040518082815260200191505060405180910390f35b607c60048036036020811015606757600080fd5b81019080803590602001909291905050506087565b005b60008054905090565b806000819055505056fea26469706673582212205afbc4864a2486ec80f10e5eceeaac30e88c9b3dfcd1bfadd6cdf6e6cb6e1fd364736f6c63430007060033" - - _created_contract_address = - insert( - :address, - hash: "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1", - contract_code: smart_contract_bytecode - ) - - params = %{ - "module" => "contract", - "action" => "verify_via_sourcify", - "addressHash" => "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1" - } - - TestHelper.get_all_proxies_implementation_zero_addresses() - - conn - |> get("/api", params) - |> json_response(200) - end + # describe "verify" do + # test "verify known on sourcify repo contract", %{conn: conn} do + # response = verify(conn) + # + # assert response["message"] == "OK" + # assert response["status"] == "1" + # + # assert response["result"]["ABI"] == + # "[{\"inputs\":[],\"name\":\"retrieve\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_number\",\"type\":\"uint256\"}],\"name\":\"store\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]" + # + # assert response["result"]["CompilerVersion"] == "v0.7.6+commit.7338295f" + # assert response["result"]["ContractName"] == "Storage" + # assert response["result"]["EVMVersion"] == "istanbul" + # assert response["result"]["OptimizationUsed"] == "false" + # end + # + # test "verify already verified contract", %{conn: conn} do + # _response = verify(conn) + # + # params = %{ + # "module" => "contract", + # "action" => "verify_via_sourcify", + # "addressHash" => "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1" + # } + # + # response = + # conn + # |> get("/api", params) + # |> json_response(200) + # + # assert response["message"] == "Smart-contract already verified." + # assert response["status"] == "0" + # assert response["result"] == nil + # end + # + # defp verify(conn) do + # smart_contract_bytecode = + # "0x6080604052348015600f57600080fd5b506004361060325760003560e01c80632e64cec11460375780636057361d146053575b600080fd5b603d607e565b6040518082815260200191505060405180910390f35b607c60048036036020811015606757600080fd5b81019080803590602001909291905050506087565b005b60008054905090565b806000819055505056fea26469706673582212205afbc4864a2486ec80f10e5eceeaac30e88c9b3dfcd1bfadd6cdf6e6cb6e1fd364736f6c63430007060033" + # + # _created_contract_address = + # insert( + # :address, + # hash: "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1", + # contract_code: smart_contract_bytecode + # ) + # + # params = %{ + # "module" => "contract", + # "action" => "verify_via_sourcify", + # "addressHash" => "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1" + # } + # + # EthereumJSONRPC.Mox + # |> TestHelper.mock_generic_proxy_requests() + # + # conn + # |> get("/api", params) + # |> json_response(200) + # end + # + # flaky test + # test "with an address that doesn't exist", %{conn: conn} do + # contract_code_info = Factory.contract_code_info() + # + # contract_address = insert(:contract_address, contract_code: contract_code_info.bytecode) + # insert(:transaction, created_contract_address_hash: contract_address.hash, input: contract_code_info.tx_input) + # + # params = %{ + # "module" => "contract", + # "action" => "verify", + # "addressHash" => to_string(contract_address.hash), + # "name" => contract_code_info.name, + # "compilerVersion" => contract_code_info.version, + # "optimization" => contract_code_info.optimized, + # "contractSourceCode" => contract_code_info.source_code + # } + # + # response = + # conn + # |> get("/api", params) + # |> json_response(200) + # + # verified_contract = SmartContract.address_hash_to_smart_contract(contract_address.hash) + # + # expected_result = %{ + # "Address" => to_string(contract_address.hash), + # "SourceCode" => + # "/**\n* Submitted for verification at blockscout.com on #{verified_contract.inserted_at}\n*/\n" <> + # contract_code_info.source_code, + # "ABI" => Jason.encode!(contract_code_info.abi), + # "ContractName" => contract_code_info.name, + # "CompilerVersion" => contract_code_info.version, + # "OptimizationUsed" => "false", + # "EVMVersion" => nil + # } + # + # assert response["status"] == "1" + # assert response["result"] == expected_result + # assert response["message"] == "OK" + # assert :ok = ExJsonSchema.Validator.validate(verify_schema(), response) + # end - # flaky test - # test "with an address that doesn't exist", %{conn: conn} do - # contract_code_info = Factory.contract_code_info() - - # contract_address = insert(:contract_address, contract_code: contract_code_info.bytecode) - # insert(:transaction, created_contract_address_hash: contract_address.hash, input: contract_code_info.tx_input) - - # params = %{ - # "module" => "contract", - # "action" => "verify", - # "addressHash" => to_string(contract_address.hash), - # "name" => contract_code_info.name, - # "compilerVersion" => contract_code_info.version, - # "optimization" => contract_code_info.optimized, - # "contractSourceCode" => contract_code_info.source_code - # } - - # response = - # conn - # |> get("/api", params) - # |> json_response(200) - - # verified_contract = SmartContract.address_hash_to_smart_contract(contract_address.hash) - - # expected_result = %{ - # "Address" => to_string(contract_address.hash), - # "SourceCode" => - # "/**\n* Submitted for verification at blockscout.com on #{verified_contract.inserted_at}\n*/\n" <> - # contract_code_info.source_code, - # "ABI" => Jason.encode!(contract_code_info.abi), - # "ContractName" => contract_code_info.name, - # "CompilerVersion" => contract_code_info.version, - # "OptimizationUsed" => "false", - # "EVMVersion" => nil - # } - - # assert response["status"] == "1" - # assert response["result"] == expected_result - # assert response["message"] == "OK" - # assert :ok = ExJsonSchema.Validator.validate(verify_schema(), response) - # end - - # flaky test - # test "with external libraries", %{conn: conn} do - # contract_data = - # "#{File.cwd!()}/test/support/fixture/smart_contract/contract_with_lib.json" - # |> File.read!() - # |> Jason.decode!() - # |> List.first() - - # %{ - # "compiler_version" => compiler_version, - # "external_libraries" => external_libraries, - # "name" => name, - # "optimize" => optimize, - # "contract" => contract_source_code, - # "expected_bytecode" => expected_bytecode, - # "tx_input" => tx_input - # } = contract_data - - # contract_address = insert(:contract_address, contract_code: "0x" <> expected_bytecode) - # insert(:transaction, created_contract_address_hash: contract_address.hash, input: "0x" <> tx_input) - - # params = %{ - # "module" => "contract", - # "action" => "verify", - # "addressHash" => to_string(contract_address.hash), - # "name" => name, - # "compilerVersion" => compiler_version, - # "optimization" => optimize, - # "contractSourceCode" => contract_source_code - # } - - # params_with_external_libraries = - # external_libraries - # |> Enum.with_index() - # |> Enum.reduce(params, fn {{name, address}, index}, acc -> - # name_key = "library#{index + 1}Name" - # address_key = "library#{index + 1}Address" - - # acc - # |> Map.put(name_key, name) - # |> Map.put(address_key, address) - # end) - - # response = - # conn - # |> get("/api", params_with_external_libraries) - # |> json_response(200) - - # assert response["status"] == "1" - # assert response["message"] == "OK" - - # result = response["result"] - - # verified_contract = SmartContract.address_hash_to_smart_contract(contract_address.hash) - - # assert result["Address"] == to_string(contract_address.hash) - - # assert result["SourceCode"] == - # "/**\n* Submitted for verification at blockscout.com on #{verified_contract.inserted_at}\n*/\n" <> - # contract_source_code - - # assert result["ContractName"] == name - # assert result["OptimizationUsed"] == "true" - # assert :ok = ExJsonSchema.Validator.validate(verify_schema(), response) - # end - end + # flaky test + # test "with external libraries", %{conn: conn} do + # contract_data = + # "#{File.cwd!()}/test/support/fixture/smart_contract/contract_with_lib.json" + # |> File.read!() + # |> Jason.decode!() + # |> List.first() + # + # %{ + # "compiler_version" => compiler_version, + # "external_libraries" => external_libraries, + # "name" => name, + # "optimize" => optimize, + # "contract" => contract_source_code, + # "expected_bytecode" => expected_bytecode, + # "tx_input" => tx_input + # } = contract_data + # + # contract_address = insert(:contract_address, contract_code: "0x" <> expected_bytecode) + # insert(:transaction, created_contract_address_hash: contract_address.hash, input: "0x" <> tx_input) + # + # params = %{ + # "module" => "contract", + # "action" => "verify", + # "addressHash" => to_string(contract_address.hash), + # "name" => name, + # "compilerVersion" => compiler_version, + # "optimization" => optimize, + # "contractSourceCode" => contract_source_code + # } + # + # params_with_external_libraries = + # external_libraries + # |> Enum.with_index() + # |> Enum.reduce(params, fn {{name, address}, index}, acc -> + # name_key = "library#{index + 1}Name" + # address_key = "library#{index + 1}Address" + # + # acc + # |> Map.put(name_key, name) + # |> Map.put(address_key, address) + # end) + # + # response = + # conn + # |> get("/api", params_with_external_libraries) + # |> json_response(200) + # + # assert response["status"] == "1" + # assert response["message"] == "OK" + # + # result = response["result"] + # + # verified_contract = SmartContract.address_hash_to_smart_contract(contract_address.hash) + # + # assert result["Address"] == to_string(contract_address.hash) + # + # assert result["SourceCode"] == + # "/**\n* Submitted for verification at blockscout.com on #{verified_contract.inserted_at}\n*/\n" <> + # contract_source_code + # + # assert result["ContractName"] == name + # assert result["OptimizationUsed"] == "true" + # assert :ok = ExJsonSchema.Validator.validate(verify_schema(), response) + # end + # end describe "getcontractcreation" do setup do @@ -1054,11 +1065,17 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do |> json_response(200) end - test "get not empty list", %{conn: conn, params: params} do + test "get contract creation info from a transaction", %{conn: conn, params: params} do address_1 = build(:address) address = insert(:contract_address) + {:ok, block_timestamp, _} = DateTime.from_iso8601("2021-05-05T21:42:11.000000Z") + unix_timestamp = DateTime.to_unix(block_timestamp, :second) - transaction = insert(:transaction, created_contract_address: address) + transaction = + insert(:transaction, + created_contract_address: address, + block_timestamp: block_timestamp + ) %{ "status" => "1", @@ -1067,7 +1084,11 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do %{ "contractAddress" => contract_address, "contractCreator" => contract_creator, - "txHash" => transaction_hash + "txHash" => transaction_hash, + "blockNumber" => block_number, + "timestamp" => timestamp, + "contractFactory" => "", + "creationBytecode" => creation_bytecode } ] } = @@ -1078,6 +1099,142 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do assert contract_address == to_string(address.hash) assert contract_creator == to_string(transaction.from_address_hash) assert transaction_hash == to_string(transaction.hash) + assert block_number == to_string(transaction.block_number) + assert timestamp == to_string(unix_timestamp) + assert creation_bytecode == to_string(transaction.input) + end + + test "returns contract creation info for addresses separated by comma and whitespace", %{conn: conn, params: params} do + {:ok, block_timestamp, _} = DateTime.from_iso8601("2021-05-05T21:42:11.000000Z") + + address = insert(:contract_address) + address_1 = insert(:contract_address) + + transaction = + insert(:transaction, + created_contract_address: address, + block_timestamp: block_timestamp + ) + + transaction_1 = + insert(:transaction, + created_contract_address: address_1, + block_timestamp: block_timestamp + ) + + %{ + "status" => "1", + "message" => "OK", + "result" => result + } = + conn + |> get( + "/api", + Map.put(params, "contractaddresses", "#{to_string(address)}, #{to_string(address_1)}") + ) + |> json_response(200) + + assert length(result) == 2 + + assert Enum.map(result, & &1["contractAddress"]) == [ + to_string(address.hash), + to_string(address_1.hash) + ] + + assert Enum.map(result, & &1["txHash"]) == [ + to_string(transaction.hash), + to_string(transaction_1.hash) + ] + end + + test "get contract creation info via internal transaction", %{conn: conn, params: params} do + {:ok, block_timestamp, _} = DateTime.from_iso8601("2021-05-05T21:42:11.000000Z") + unix_timestamp = DateTime.to_unix(block_timestamp, :second) + + block = insert(:block, timestamp: block_timestamp) + + transaction = + :transaction + |> insert() + |> with_block(block) + + internal_transaction = + insert(:internal_transaction_create, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + |> InternalTransaction.preload_addresses() + + address = internal_transaction.created_contract_address + + %{ + "status" => "1", + "message" => "OK", + "result" => [ + %{ + "contractAddress" => contract_address, + "contractCreator" => contract_creator, + "txHash" => transaction_hash, + "blockNumber" => block_number, + "timestamp" => timestamp, + "contractFactory" => contract_factory, + "creationBytecode" => creation_bytecode + } + ] + } = + conn + |> get("/api", Map.put(params, "contractaddresses", to_string(address))) + |> json_response(200) + + assert contract_address == to_string(internal_transaction.created_contract_address_hash) + assert contract_creator == to_string(internal_transaction.transaction.from_address_hash) + assert transaction_hash == to_string(internal_transaction.transaction.hash) + assert block_number == to_string(internal_transaction.transaction.block_number) + assert timestamp == to_string(unix_timestamp) + assert contract_factory == to_string(internal_transaction.from_address_hash) + assert creation_bytecode == to_string(internal_transaction.init) + end + + test "get contract creation info via internal transaction with index 0 and parent transaction - contractFactory should be empty", + %{ + conn: conn, + params: params + } do + {:ok, block_timestamp, _} = DateTime.from_iso8601("2021-05-05T21:42:11.000000Z") + block = insert(:block, timestamp: block_timestamp) + contract_address = insert(:contract_address) + + # Create a transaction that creates the contract + transaction = + :transaction + |> insert(created_contract_address: contract_address) + |> with_block(block) + + # Also create an internal transaction with index 0 for the same contract + insert(:internal_transaction_create, + transaction: transaction, + # index 0 should result in empty contractFactory + index: 0, + created_contract_address: contract_address, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + assert %{ + "result" => [ + %{ + "contractFactory" => "", + "contractCreator" => contract_creator + } + ] + } = + conn + |> get("/api", Map.put(params, "contractaddresses", to_string(contract_address))) + |> json_response(200) + + assert contract_creator == to_string(transaction.from_address_hash) end end @@ -1206,7 +1363,7 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do } ] test "verify", %{conn: conn, params: params} do - proxy_contract_address = insert(:contract_address) + proxy_contract_address = insert(:contract_address, contract_code: "0xDEADBEEF5c60da1bDEADBEEF") insert(:smart_contract, address_hash: proxy_contract_address.hash, abi: @proxy_abi, contract_code_md5: "123") @@ -1218,25 +1375,8 @@ defmodule BlockScoutWeb.API.RPC.ContractControllerTest do contract_code_md5: "123" ) - implementation_contract_address_hash_string = - Base.encode16(implementation_contract_address.hash.bytes, case: :lower) - - TestHelper.get_all_proxies_implementation_zero_addresses() - - expect( - EthereumJSONRPC.Mox, - :json_rpc, - fn [%{id: id, method: _, params: [%{data: _, to: _}, _]}], _options -> - {:ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: "0x000000000000000000000000" <> implementation_contract_address_hash_string - } - ]} - end - ) + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(basic_implementation: implementation_contract_address.hash) %{ "message" => "OK", diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs index f8191bac9e95..42e62fd6a5ac 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/eth_controller_test.exs @@ -1,9 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.EthControllerTest do use BlockScoutWeb.ConnCase, async: false alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime} alias Explorer.Repo - alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand @first_topic_hex_string_1 "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65" @first_topic_hex_string_2 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs index df82a98a6a1c..d532c7cba146 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/logs_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.LogsControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/rpc_translator_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/rpc_translator_test.exs index bf410d62a14a..c3e646992db9 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/rpc_translator_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/rpc_translator_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.RPCTranslatorTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/stats_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/stats_controller_test.exs index c6c0822ea13e..e23a2732de8b 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/stats_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/stats_controller_test.exs @@ -1,11 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.StatsControllerTest do use BlockScoutWeb.ConnCase import Mox + alias Explorer.Chain.Cache.Counters.{AddressesCoinBalanceSum, AddressesCoinBalanceSumMinusBurnt, LastFetchedCounter} + alias Explorer.Chain.Transaction.History.TransactionStats alias Explorer.Market.Fetcher.Coin alias Explorer.Market.Token alias Explorer.Market.Source.TestSource + alias Explorer.Repo describe "tokensupply" do test "with missing contract address", %{conn: conn} do @@ -159,48 +163,177 @@ defmodule BlockScoutWeb.API.RPC.StatsControllerTest do |> get("/api", params) |> json_response(200) - assert response["result"] == "252460800000000000000000000" + assert Decimal.compare(Decimal.new(response["result"]), Decimal.new(0)) in [:eq, :gt] assert response["status"] == "1" assert response["message"] == "OK" assert :ok = ExJsonSchema.Validator.validate(ethsupplyexchange_schema(), response) end end - # todo: Temporarily disable this test because of unstable work in CI - # describe "ethsupply" do - # test "returns total supply from DB", %{conn: conn} do - # params = %{ - # "module" => "stats", - # "action" => "ethsupply" - # } - - # assert response = - # conn - # |> get("/api", params) - # |> json_response(200) - - # assert response["result"] == "0" - # assert response["status"] == "1" - # assert response["message"] == "OK" - # assert :ok = ExJsonSchema.Validator.validate(ethsupply_schema(), response) - # end - # end - - # describe "coinsupply" do - # test "returns total supply minus a burnt number from DB in coins denomination", %{conn: conn} do - # params = %{ - # "module" => "stats", - # "action" => "coinsupply" - # } - - # assert response = - # conn - # |> get("/api", params) - # |> json_response(200) - - # assert response == 0.0 - # end - # end + describe "ethsupply" do + setup do + original_sum = AddressesCoinBalanceSum.get_sum() + + on_exit(fn -> + AddressesCoinBalanceSum.set_sum(original_sum) + end) + + :ok + end + + test "returns cached total supply from counter", %{conn: conn} do + AddressesCoinBalanceSum.set_sum(Decimal.new("3400000000000000000")) + + params = %{ + "module" => "stats", + "action" => "ethsupply" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == "3400000000000000000" + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(ethsupply_schema(), response) + end + end + + describe "coinsupply" do + setup do + original_sum_minus_burnt = AddressesCoinBalanceSumMinusBurnt.get_sum_minus_burnt() + + on_exit(fn -> + AddressesCoinBalanceSumMinusBurnt.set_sum_minus_burnt(original_sum_minus_burnt) + end) + + :ok + end + + test "returns value from cached sum minus burnt when it is positive", %{conn: conn} do + AddressesCoinBalanceSumMinusBurnt.set_sum_minus_burnt(Decimal.new("2000000000000000000")) + + params = %{ + "module" => "stats", + "action" => "coinsupply" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == "2" + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(coinsupply_schema(), response) + end + + test "falls back to last fetched counter when cached sum minus burnt is non-positive", %{conn: conn} do + original_last_fetched_counter = LastFetchedCounter.get("sum_coin_total_supply_minus_burnt", nullable: true) + + try do + AddressesCoinBalanceSumMinusBurnt.set_sum_minus_burnt(Decimal.new(0)) + + LastFetchedCounter.upsert(%{ + counter_type: "sum_coin_total_supply_minus_burnt", + value: Decimal.new("1500000000000000000") + }) + + params = %{ + "module" => "stats", + "action" => "coinsupply" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == "1.5" + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(coinsupply_schema(), response) + after + if is_nil(original_last_fetched_counter) do + LastFetchedCounter.delete("sum_coin_total_supply_minus_burnt") + else + LastFetchedCounter.upsert(%{ + counter_type: "sum_coin_total_supply_minus_burnt", + value: original_last_fetched_counter + }) + end + end + end + end + + describe "ethprice" do + setup :set_mox_global + + setup do + coin_fetcher_configuration = Application.get_env(:explorer, Coin) + market_source_configuration = Application.get_env(:explorer, Explorer.Market.Source) + + Application.put_env(:explorer, Explorer.Market.Source, native_coin_source: TestSource) + Application.put_env(:explorer, Coin, Keyword.merge(coin_fetcher_configuration, table_name: :rates, enabled: true)) + + Coin.init([]) + + on_exit(fn -> + Application.put_env(:explorer, Coin, coin_fetcher_configuration) + Application.put_env(:explorer, Explorer.Market.Source, market_source_configuration) + end) + + :ok + end + + test "returns the configured native coin price information with eth keys", %{conn: conn} do + symbol = Application.get_env(:explorer, :coin) + + eth = %Token{ + available_supply: Decimal.new("1000000.0"), + total_supply: Decimal.new("1000000.0"), + btc_value: Decimal.new("1.000"), + last_updated: DateTime.utc_now(), + market_cap: Decimal.new("1000000.0"), + tvl: Decimal.new("2000000.0"), + name: "test", + symbol: symbol, + fiat_value: Decimal.new("1.0"), + volume_24h: Decimal.new("1000.0"), + image_url: nil, + circulating_supply: nil + } + + Coin.handle_info({nil, {{:ok, eth}, false}}, %{}) + + params = %{ + "module" => "stats", + "action" => "ethprice" + } + + expected_timestamp = eth.last_updated |> DateTime.to_unix() |> to_string() + + expected_result = %{ + "ethbtc" => to_string(eth.btc_value), + "ethbtc_timestamp" => expected_timestamp, + "ethusd" => to_string(eth.fiat_value), + "ethusd_timestamp" => expected_timestamp + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == expected_result + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(ethprice_schema(), response) + end + end describe "coinprice" do setup :set_mox_global @@ -237,7 +370,8 @@ defmodule BlockScoutWeb.API.RPC.StatsControllerTest do symbol: symbol, fiat_value: Decimal.new("1.0"), volume_24h: Decimal.new("1000.0"), - image_url: nil + image_url: nil, + circulating_supply: nil } Coin.handle_info({nil, {{:ok, eth}, false}}, %{}) @@ -268,17 +402,86 @@ defmodule BlockScoutWeb.API.RPC.StatsControllerTest do end end + describe "totalfees" do + test "returns error when date input is missing", %{conn: conn} do + params = %{ + "module" => "stats", + "action" => "totalfees" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] == "Required date input is missing." + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(totalfees_schema(), response) + end + + test "returns error for invalid date format", %{conn: conn} do + params = %{ + "module" => "stats", + "action" => "totalfees", + "date" => "2026/01/01" + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["message"] == "An incorrect input date provided. It should be in ISO 8601 format (yyyy-mm-dd)." + assert response["status"] == "0" + assert Map.has_key?(response, "result") + refute response["result"] + assert :ok = ExJsonSchema.Validator.validate(totalfees_schema(), response) + end + + test "returns total fees for valid date", %{conn: conn} do + target_date = ~D[2026-01-10] + + Repo.insert_all(TransactionStats, [ + %{date: target_date, number_of_transactions: 3, gas_used: Decimal.new("1250.0"), total_fee: Decimal.new("42")} + ]) + + params = %{ + "module" => "stats", + "action" => "totalfees", + "date" => Date.to_iso8601(target_date) + } + + assert response = + conn + |> get("/api", params) + |> json_response(200) + + assert response["result"] == "42" + assert response["status"] == "1" + assert response["message"] == "OK" + assert :ok = ExJsonSchema.Validator.validate(totalfees_schema(), response) + end + end + defp tokensupply_schema do resolve_schema(%{ "type" => ["string", "null"] }) end - # defp ethsupply_schema do - # resolve_schema(%{ - # "type" => ["string", "null"] - # }) - # end + defp ethsupply_schema do + resolve_schema(%{ + "type" => ["string", "null"] + }) + end + + defp coinsupply_schema do + resolve_schema(%{ + "type" => ["string", "null"] + }) + end defp ethsupplyexchange_schema do resolve_schema(%{ @@ -298,6 +501,24 @@ defmodule BlockScoutWeb.API.RPC.StatsControllerTest do }) end + defp ethprice_schema do + resolve_schema(%{ + "type" => "object", + "properties" => %{ + "ethbtc" => %{"type" => "string"}, + "ethbtc_timestamp" => %{"type" => "string"}, + "ethusd" => %{"type" => "string"}, + "ethusd_timestamp" => %{"type" => "string"} + } + }) + end + + defp totalfees_schema do + resolve_schema(%{ + "type" => ["string", "null"] + }) + end + defp resolve_schema(result) do %{ "type" => "object", diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/token_controller_test.exs index 2257fe1e96b2..2eaa2f444892 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/token_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.TokenControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs index 0ecd2fd52a2d..6a6793216f15 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/rpc/transaction_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.RPC.TransactionControllerTest do use BlockScoutWeb.ConnCase @@ -265,11 +266,11 @@ defmodule BlockScoutWeb.API.RPC.TransactionControllerTest do internal_transaction_details = [ transaction: transaction, + transaction_index: transaction.index, index: 0, type: :reward, error: error, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ] insert(:internal_transaction, internal_transaction_details) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs index d38f51303c64..9ba9c7d5fd73 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/health_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V1.HealthControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/supply_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/supply_controller_test.exs index b5218388b4de..0b4edee4d4ba 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/supply_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/supply_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V1.SupplyControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs index 095a7e4d6518..a5a5b6fde679 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v1/verified_smart_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V1.VerifiedControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs index 2a1ff83f662d..ef8279df7c98 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/address_controller_test.exs @@ -1,7 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.AddressControllerTest do use BlockScoutWeb.ConnCase use EthereumJSONRPC.Case, async: false use BlockScoutWeb.ChannelCase + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] alias ABI.{TypeDecoder, TypeEncoder} alias Explorer.{Chain, Repo, TestHelper} @@ -23,15 +25,17 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do alias Explorer.Account.{Identity, WatchlistAddress} alias Explorer.Chain.Address.CurrentTokenBalance - alias Explorer.Chain.SmartContract.Proxy.ResolvedDelegateProxy + alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Indexer.Fetcher.OnDemand.ContractCode, as: ContractCodeOnDemand alias Plug.Conn + import Ecto.Query, only: [from: 2] import Explorer.Chain, only: [hash_to_lower_case_string: 1] import Mox @first_topic_hex_string_1 "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65" @instances_amount_in_collection 9 + @resolved_delegate_proxy "0x608060408181523060009081526001602090815282822054908290529181207FBF40FAC1000000000000000000000000000000000000000000000000000000009093529173FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF9091169063BF40FAC19061006D9060846101E2565B602060405180830381865AFA15801561008A573D6000803E3D6000FD5B505050506040513D601F19601F820116820180604052508101906100AE91906102C5565B905073FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8116610157576040517F08C379A000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527F5265736F6C76656444656C656761746550726F78793A2074617267657420616460448201527F6472657373206D75737420626520696E697469616C697A656400000000000000606482015260840160405180910390FD5B6000808273FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF16600036604051610182929190610302565B600060405180830381855AF49150503D80600081146101BD576040519150601F19603F3D011682016040523D82523D6000602084013E6101C2565B606091505B5090925090508115156001036101DA57805160208201F35B805160208201FD5B600060208083526000845481600182811C91508083168061020457607F831692505B858310810361023A577F4E487B710000000000000000000000000000000000000000000000000000000085526022600452602485FD5B878601838152602001818015610257576001811461028B576102B6565B7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF008616825284151560051B820196506102B6565B60008B81526020902060005B868110156102B057815484820152908501908901610297565B83019750505B50949998505050505050505050565B6000602082840312156102D757600080FD5B815173FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF811681146102FB57600080FD5B9392505050565B818382376000910190815291905056FEA164736F6C634300080F000A" setup :set_mox_global @@ -49,11 +53,6 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do :ok end - defp topic(topic_hex_string) do - {:ok, topic} = Explorer.Chain.Hash.Full.cast(topic_hex_string) - topic - end - describe "/addresses/{address_hash}" do test "get 200 on non existing address", %{conn: conn} do address = build(:address) @@ -80,7 +79,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "watchlist_address_id" => nil, "has_beacon_chain_withdrawals" => false, "ens_domain_name" => nil, - "metadata" => nil + "metadata" => nil, + "creation_status" => nil } stub(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> @@ -88,13 +88,24 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end) request = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}") - check_response(correct_response, json_response(request, 200)) + json_response = json_response(request, 200) + check_response(correct_response, json_response) end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get address & get the same response for checksummed and downcased parameter", %{conn: conn} do @@ -122,7 +133,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "watchlist_address_id" => nil, "has_beacon_chain_withdrawals" => false, "ens_domain_name" => nil, - "metadata" => nil + "metadata" => nil, + "creation_status" => nil } stub(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> @@ -130,10 +142,12 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end) request = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}") - check_response(correct_response, json_response(request, 200)) + json_response = json_response(request, 200) + check_response(correct_response, json_response) request = get(conn, "/api/v2/addresses/#{String.downcase(to_string(address.hash))}") - check_response(correct_response, json_response(request, 200)) + json_response = json_response(request, 200) + check_response(correct_response, json_response) end test "returns successful creation transaction for a contract when both failed and successful transactions exist", @@ -162,6 +176,28 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do response = json_response(request, 200) assert response["is_contract"] assert response["creation_transaction_hash"] == to_string(succeeded_transaction.hash) + assert response["creation_status"] == "success" + end + + test "returns failed creation transaction for a contract", + %{conn: conn} do + contract_address = insert(:address, contract_code: "0x") + + failed_transaction = + insert(:transaction, + created_contract_address_hash: contract_address.hash + ) + |> with_block(status: :error) + + stub(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:ok, []} + end) + + request = get(conn, "/api/v2/addresses/#{Address.checksum(contract_address.hash)}") + response = json_response(request, 200) + assert response["is_contract"] + assert response["creation_transaction_hash"] == to_string(failed_transaction.hash) + assert response["creation_status"] == "failed" end defp check_response(pattern_response, response) do @@ -188,6 +224,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert pattern_response["has_beacon_chain_withdrawals"] == response["has_beacon_chain_withdrawals"] assert pattern_response["ens_domain_name"] == response["ens_domain_name"] assert pattern_response["metadata"] == response["metadata"] + assert pattern_response["creation_status"] == response["creation_status"] end test "get EIP-1167 proxy contract info", %{conn: conn} do @@ -269,6 +306,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do request = get(conn, "/api/v2/addresses/#{Address.checksum(proxy_address.hash)}") + json_response = json_response(request, 200) + assert %{ "hash" => ^address_hash, "is_contract" => true, @@ -278,15 +317,15 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "watchlist_names" => [], "creator_address_hash" => ^from, "creation_transaction_hash" => ^transaction_hash, + "creation_status" => "success", "proxy_type" => "eip1167", "implementations" => [ %{ "address_hash" => ^checksummed_implementation_contract_address_hash, - "address" => ^checksummed_implementation_contract_address_hash, "name" => ^name } ] - } = json_response(request, 200) + } = json_response end test "get EIP-1967 proxy contract info", %{conn: conn} do @@ -314,10 +353,14 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do implementation_address = insert(:address) implementation_address_hash_string = to_string(Address.checksum(implementation_address.hash)) - TestHelper.get_eip1967_implementation_non_zero_address(implementation_address_hash_string) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) request = get(conn, "/api/v2/addresses/#{Address.checksum(smart_contract.address_hash)}") + json_response = json_response(request, 200) + assert %{ "hash" => ^address_hash, "is_contract" => true, @@ -328,34 +371,20 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "watchlist_names" => [], "creator_address_hash" => ^from, "creation_transaction_hash" => ^transaction_hash, + "creation_status" => "success", "proxy_type" => "eip1967", "implementations" => [ %{ "address_hash" => ^implementation_address_hash_string, - "address" => ^implementation_address_hash_string, "name" => nil } ] - } = json_response(request, 200) + } = json_response end test "get Resolved Delegate Proxy contract info", %{conn: conn} do - address_manager_address = insert(:address) - - "0x" <> address_manager_address_hash_string_without_0x = to_string(address_manager_address.hash) - - owner_address = insert(:address) - - "0x" <> owner_address_hash_string_without_0x = to_string(owner_address.hash) - - proxy_smart_contract = - insert(:smart_contract, - abi: ResolvedDelegateProxy.resolved_delegate_proxy_abi(), - constructor_arguments: - "0x000000000000000000000000" <> - address_manager_address_hash_string_without_0x <> - "0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001a4f564d5f4c3143726f7373446f6d61696e4d657373656e676572000000000000" - ) + proxy_address = insert(:address, contract_code: @resolved_delegate_proxy, verified: true) + proxy_smart_contract = insert(:smart_contract, address_hash: proxy_address.hash) transaction = insert(:transaction, @@ -376,20 +405,18 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do from = Address.checksum(transaction.from_address_hash) transaction_hash = to_string(transaction.hash) checksummed_proxy_address_hash = Address.checksum(proxy_smart_contract.address_hash) - "0x" <> proxy_address_hash_string_without_0x = to_string(proxy_smart_contract.address_hash) implementation_address = insert(:address) + implementation_address_hash_string = implementation_address.hash |> Address.checksum() |> to_string() - "0x" <> implementation_address_hash_string_without_0x = to_string(implementation_address.hash) - implementation_address_hash_string = to_string(Address.checksum(implementation_address.hash)) - - TestHelper.get_resolved_delegate_proxy_implementation_non_zero_address( - owner_address_hash_string_without_0x, - implementation_address_hash_string_without_0x, - proxy_address_hash_string_without_0x + EthereumJSONRPC.Mox + |> TestHelper.mock_resolved_delegate_proxy_requests( + proxy_smart_contract.address_hash, + implementation_address.hash ) request = get(conn, "/api/v2/addresses/#{checksummed_proxy_address_hash}") + json_response = json_response(request, 200) assert %{ "hash" => ^checksummed_proxy_address_hash, @@ -401,15 +428,15 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "watchlist_names" => [], "creator_address_hash" => ^from, "creation_transaction_hash" => ^transaction_hash, + "creation_status" => "success", "proxy_type" => "resolved_delegate_proxy", "implementations" => [ %{ "address_hash" => ^implementation_address_hash_string, - "address" => ^implementation_address_hash_string, "name" => nil } ] - } = json_response(request, 200) + } = json_response end test "get watchlist id", %{conn: conn} do @@ -491,18 +518,30 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do request = get(conn, "/api/v2/addresses/#{address.hash}/counters") + json_response = json_response(request, 200) + assert %{ "transactions_count" => "0", "token_transfers_count" => "0", "gas_usage_count" => "0", "validations_count" => "0" - } = json_response(request, 200) + } = json_response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/counters") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get counters with 0s", %{conn: conn} do @@ -510,12 +549,14 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do request = get(conn, "/api/v2/addresses/#{address.hash}/counters") + json_response = json_response(request, 200) + assert %{ "transactions_count" => "0", "token_transfers_count" => "0", "gas_usage_count" => "0", "validations_count" => "0" - } = json_response(request, 200) + } = json_response end test "get counters", %{conn: conn} do @@ -541,12 +582,12 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do insert(:block, miner: address) - Counters.transaction_count(address) + Counters.transactions_count(address) Counters.token_transfers_count(address) Counters.gas_usage_count(address) request = get(conn, "/api/v2/addresses/#{address.hash}/counters") - + json_response = json_response(request, 200) gas_used = to_string(transaction_from.gas_used) assert %{ @@ -554,7 +595,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "token_transfers_count" => "2", "gas_usage_count" => ^gas_used, "validations_count" => "1" - } = json_response(request, 200) + } = json_response end end @@ -563,14 +604,25 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/transactions") + json_response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = json_response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/transactions") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get relevant transaction", %{conn: conn} do @@ -585,6 +637,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(transaction, Enum.at(response["items"], 0)) end @@ -602,6 +655,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 2 assert response["next_page_params"] == nil + compare_item(pending_transaction, Enum.at(response["items"], 0)) compare_item(transaction, Enum.at(response["items"], 1)) end @@ -617,6 +671,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(transaction, Enum.at(response["items"], 0)) end @@ -631,6 +686,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(transaction, Enum.at(response["items"], 0)) end @@ -754,30 +810,36 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(Enum.at(transactions_from, 49), Enum.at(response_2nd_page["items"], 1)) compare_item(Enum.at(transactions_from, 1), Enum.at(response_2nd_page["items"], 49)) - request = get(conn, "/api/v2/addresses/#{address.hash}/transactions", response_2nd_page["next_page_params"]) - assert response = json_response(request, 200) + request_3rd_page = + get(conn, "/api/v2/addresses/#{address.hash}/transactions", response_2nd_page["next_page_params"]) + + assert response_3rd_page = json_response(request_3rd_page, 200) - check_paginated_response(response_2nd_page, response, transactions_from ++ [Enum.at(transactions_to, 0)]) + check_paginated_response(response_2nd_page, response_3rd_page, transactions_from ++ [Enum.at(transactions_to, 0)]) end - test "ignores wrong ordering params", %{conn: conn} do + test "422 on wrong ordering params", %{conn: conn} do address = insert(:address) - transactions = insert_list(51, :transaction, from_address: address) |> with_block() + insert_list(51, :transaction, from_address: address) |> with_block() request = get(conn, "/api/v2/addresses/#{address.hash}/transactions", %{"sort" => "foo", "order" => "bar"}) - assert response = json_response(request, 200) - - request_2nd_page = - get( - conn, - "/api/v2/addresses/#{address.hash}/transactions", - %{"sort" => "foo", "order" => "bar"} |> Map.merge(response["next_page_params"]) - ) - - assert response_2nd_page = json_response(request_2nd_page, 200) + assert json_response = json_response(request, 422) - check_paginated_response(response, response_2nd_page, transactions) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid value for enum", + "source" => %{"pointer" => "/sort"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid value for enum", + "source" => %{"pointer" => "/order"}, + "title" => "Invalid value" + } + ] + } = json_response end test "backward compatible with legacy paging params", %{conn: conn} do @@ -914,7 +976,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do transactions = (transactions_from ++ transactions_to) - |> Enum.sort(&(Decimal.compare(Wei.to(&1.value, :wei), Wei.to(&2.value, :wei)) in [:eq, :lt])) + |> sort_transactions_by_value(:asc) request = get(conn, "/api/v2/addresses/#{address.hash}/transactions", %{"sort" => "value", "order" => "asc"}) assert response = json_response(request, 200) @@ -948,7 +1010,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do transactions = (transactions_from ++ transactions_to) - |> Enum.sort(&(Decimal.compare(Wei.to(&1.value, :wei), Wei.to(&2.value, :wei)) in [:eq, :gt])) + |> sort_transactions_by_value(:desc) request = get(conn, "/api/v2/addresses/#{address.hash}/transactions", %{"sort" => "value", "order" => "desc"}) assert response = json_response(request, 200) @@ -1049,21 +1111,229 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do check_paginated_response(response, response_2nd_page, transactions |> Enum.reverse()) end + + test "regression test for decoding issue", %{conn: conn} do + from_address = insert(:address) + to_address = build(:address) + + insert(:transaction, from_address: from_address, to_address_hash: to_address.hash, to_address: to_address) + + Explorer.Repo.delete(to_address) + + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + + old_env_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + old_env_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Bypass.expect_once(bypass, "POST", "api/v1/#{chain_id}/addresses:batch_resolve_names", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "names" => %{ + to_string(to_address) => "test.eth" + } + }) + ) + end) + + Bypass.expect_once(bypass, "GET", "api/v1/metadata", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "addresses" => %{ + to_string(to_address) => %{ + "tags" => [ + %{ + "name" => "Proposer Fee Recipient", + "ordinal" => 0, + "slug" => "proposer-fee-recipient", + "tagType" => "generic", + "meta" => "{\"styles\":\"danger_high\"}" + } + ] + } + } + }) + ) + end) + + request = get(conn, "/api/v2/addresses/#{from_address.hash}/transactions") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + + transaction = Enum.at(response["items"], 0) + assert transaction["to"]["ens_domain_name"] == "test.eth" + + assert transaction["to"]["metadata"] == %{ + "tags" => [ + %{ + "slug" => "proposer-fee-recipient", + "name" => "Proposer Fee Recipient", + "ordinal" => 0, + "tagType" => "generic", + "meta" => %{"styles" => "danger_high"} + } + ] + } + + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_env_bens) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_env_metadata) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end end describe "/addresses/{address_hash}/token-transfers" do + test "get token transfers with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address + ) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/addresses/#{address.hash}/token-transfers") + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get("/api/v2/addresses/#{address.hash}/token-transfers") |> json_response(200) + end + + test "get token transfers with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address + ) + + insert(:scam_badge_to_address, address_hash: token_transfer.token_contract_address_hash) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/addresses/#{address.hash}/token-transfers") + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/addresses/#{address.hash}/token-transfers") + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token transfers with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address + ) + + request = conn |> get("/api/v2/addresses/#{address.hash}/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get token transfers with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + token_transfer = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address + ) + + insert(:scam_badge_to_address, address_hash: token_transfer.token_contract_address_hash) + + request = conn |> get("/api/v2/addresses/#{address.hash}/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + test "get 200 on non existing address", %{conn: conn} do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers") + json_response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = json_response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/token-transfers") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get 200 on non existing address of token", %{conn: conn} do @@ -1072,8 +1342,9 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do token = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", %{"token" => to_string(token.hash)}) + json_response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = json_response end test "get 422 on invalid token address hash", %{conn: conn} do @@ -1081,7 +1352,17 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", %{"token" => "0x"}) - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/token"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get relevant token transfer", %{conn: conn} do @@ -1108,6 +1389,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(token_transfer, Enum.at(response["items"], 0)) end @@ -1199,6 +1481,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(token_transfer, Enum.at(response["items"], 0)) end @@ -1264,6 +1547,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(token_transfer, Enum.at(response["items"], 0)) end @@ -1292,6 +1576,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(token_transfer, Enum.at(response["items"], 0)) end @@ -1439,10 +1724,12 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(Enum.at(tt_from, 49), Enum.at(response_2nd_page["items"], 1)) compare_item(Enum.at(tt_from, 1), Enum.at(response_2nd_page["items"], 49)) - request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", response_2nd_page["next_page_params"]) - assert response = json_response(request, 200) + request_3rd_page = + get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", response_2nd_page["next_page_params"]) - check_paginated_response(response_2nd_page, response, tt_from ++ [Enum.at(tt_to, 0)]) + assert response_3rd_page = json_response(request_3rd_page, 200) + + check_paginated_response(response_2nd_page, response_3rd_page, tt_from ++ [Enum.at(tt_to, 0)]) end test "check token type filters", %{conn: conn} do @@ -1537,8 +1824,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do check_paginated_response(response, response_2nd_page, erc_1155_tt) # -- ------ -- - # two filters simultaneously - filter = %{"type" => "ERC-1155,ERC-20"} + # two filters simultaneously (includes ERC-7984 but no ERC-7984 transfers created in this test) + filter = %{"type" => "ERC-1155,ERC-20,ERC-7984"} request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", filter) assert response = json_response(request, 200) @@ -1620,12 +1907,14 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) request_2nd_page = - get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", Map.merge(response["next_page_params"], filter)) + get( + conn, + "/api/v2/addresses/#{address.hash}/token-transfers", + Map.merge(response["next_page_params"], filter) + ) assert response_2nd_page = json_response(request_2nd_page, 200) - check_paginated_response(response, response_2nd_page, erc_721_tt) - filter = %{"type" => "ERC-721,ERC-20", "filter" => "to"} request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", filter) assert response = json_response(request, 200) @@ -1757,7 +2046,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do filter = %{"type" => "ERC-1155", "filter" => "from"} request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers", filter) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert response = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "check that pagination works fine with 1155 batches #2 some batches on the first page and one on the second", @@ -1878,6 +2168,29 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do check_paginated_response(response, response_2nd_page, token_transfers_1 ++ token_transfers_2) end + + if @chain_identity == {:optimism, :celo} do + test "get token balance when a token transfer has no transaction", %{conn: conn} do + address = insert(:address) + block = insert(:block) + + token_transfer = + insert(:token_transfer, + from_address: address, + transaction_hash: nil, + block: block, + transaction: nil + ) + + request = get(conn, "/api/v2/addresses/#{address.hash}/token-transfers") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + + compare_item(token_transfer, Enum.at(response["items"], 0), true) + end + end end describe "/addresses/{address_hash}/internal-transactions" do @@ -1885,17 +2198,28 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/internal-transactions") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) - end + json_response = json_response(request, 422) - test "get internal transaction and filter working", %{conn: conn} do + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response + end + + test "get internal transaction and filter working", %{conn: conn} do address = insert(:address) transaction = @@ -1909,10 +2233,9 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: 1, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1, from_address: address ) + |> InternalTransaction.preload_addresses() internal_transaction_to = insert(:internal_transaction, @@ -1920,10 +2243,9 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: 2, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2, to_address: address ) + |> InternalTransaction.preload_addresses() request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") @@ -1939,6 +2261,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(internal_transaction_from, Enum.at(response["items"], 0)) request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions", %{"filter" => "to"}) @@ -1949,6 +2272,33 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(internal_transaction_to, Enum.at(response["items"], 0)) end + test "returns gas_limit as 0 for selfdestruct without gas", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert(from_address: address) + |> with_block() + + internal_transaction = + insert(:internal_transaction_selfdestruct, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + from_address: address, + to_address: insert(:address), + gas: nil, + gas_used: nil + ) + + request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") + + assert %{"items" => [item], "next_page_params" => nil} = json_response(request, 200) + assert "0" == to_string(item["gas_limit"]) + assert item["index"] == internal_transaction.index + end + test "internal transactions can paginate", %{conn: conn} do address = insert(:address) @@ -1964,11 +2314,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: i, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: i, from_address: address ) end + |> InternalTransaction.preload_addresses() request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions") assert response = json_response(request, 200) @@ -1987,11 +2336,10 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: i, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: i, to_address: address ) end + |> InternalTransaction.preload_addresses() filter = %{"filter" => "to"} request = get(conn, "/api/v2/addresses/#{address.hash}/internal-transactions", filter) @@ -2030,14 +2378,24 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/blocks-validated") - - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert response = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/blocks-validated") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get relevant block validated", %{conn: conn} do @@ -2070,18 +2428,103 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end describe "/addresses/{address_hash}/token-balances" do + test "get token balances with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + insert(:address_current_token_balance_with_token_id, address: address) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/addresses/#{address.hash}/token-balances") + + response = json_response(request, 200) + + assert List.first(response)["token"]["reputation"] == "ok" + + assert response == conn |> get("/api/v2/addresses/#{address.hash}/token-balances") |> json_response(200) + end + + test "get token balances with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + ctb = insert(:address_current_token_balance_with_token_id, address: address) + + insert(:scam_badge_to_address, address_hash: ctb.token_contract_address_hash) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/addresses/#{address.hash}/token-balances") + + response = json_response(request, 200) + + assert List.first(response)["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/addresses/#{address.hash}/token-balances") + response = json_response(request, 200) + + assert response == [] + end + + test "get smart-contract with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + insert(:address_current_token_balance_with_token_id, address: address) + + request = conn |> get("/api/v2/addresses/#{address.hash}/token-balances") + response = json_response(request, 200) + + assert List.first(response)["token"]["reputation"] == "ok" + end + + test "get smart-contract with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + ctb = insert(:address_current_token_balance_with_token_id, address: address) + + insert(:scam_badge_to_address, address_hash: ctb.token_contract_address_hash) + + request = conn |> get("/api/v2/addresses/#{address.hash}/token-balances") + response = json_response(request, 200) + + assert List.first(response)["token"]["reputation"] == "ok" + end + test "get empty list on non existing address", %{conn: conn} do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/token-balances") - - assert json_response(request, 200) == [] + assert response = json_response(request, 200) + assert response == [] end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/token-balances") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get token balance", %{conn: conn} do @@ -2108,14 +2551,25 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/coin-balance-history") + response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/coin-balance-history") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get coin balance history", %{conn: conn} do @@ -2133,6 +2587,55 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do compare_item(acb, Enum.at(response["items"], 0)) end + test "get coin balance with transaction", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert(to_address_hash: address.hash, to_address: address, value: 123) + |> with_block() + + acb = insert(:address_coin_balance, address: address, block_number: transaction.block_number) + + request = get(conn, "/api/v2/addresses/#{address.hash}/coin-balance-history") + + assert %{"items" => [acb_json], "next_page_params" => nil} = json_response(request, 200) + assert acb_json["transaction_hash"] == to_string(transaction.hash) + + compare_item(acb, acb_json) + end + + test "get coin balance with internal transaction", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + address = insert(:address) + + insert(:internal_transaction, + type: "call", + call_type: "call", + transaction: transaction, + transaction_index: transaction.index, + block: transaction.block, + to_address: address, + value: 123, + block_number: transaction.block_number, + index: 1 + ) + + insert(:address_coin_balance) + acb = insert(:address_coin_balance, address: address, block_number: transaction.block_number) + + request = get(conn, "/api/v2/addresses/#{address.hash}/coin-balance-history") + + assert %{"items" => [acb_json], "next_page_params" => nil} = json_response(request, 200) + assert acb_json["transaction_hash"] == to_string(transaction.hash) + + compare_item(acb, acb_json) + end + test "coin balance history can paginate", %{conn: conn} do address = insert(:address) @@ -2159,16 +2662,28 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do days_count = Application.get_env(:block_scout_web, BlockScoutWeb.Chain.Address.CoinBalance)[:coin_balance_history_days] + response = json_response(request, 200) + assert %{ "days" => ^days_count, "items" => [] - } = json_response(request, 200) + } = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/coin-balance-history-by-day") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get coin balance history by day", %{conn: conn} do @@ -2189,7 +2704,6 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "days" => 10, "items" => [ %{"date" => _, "value" => "2000"}, - %{"date" => _, "value" => "1000"}, %{"date" => _, "value" => "1000"} ] } = response @@ -2201,14 +2715,24 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/logs") - - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + response = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/logs") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get log", %{conn: conn} do @@ -2233,6 +2757,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(log, Enum.at(response["items"], 0)) end @@ -2284,6 +2809,8 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do bypass = Bypass.open() + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) chain_id = 1 Application.put_env(:block_scout_web, :chain_id, chain_id) @@ -2320,7 +2847,17 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do 200, Jason.encode!(%{ "addresses" => %{ - to_string(address) => %{"tags" => [%{"slug" => "tag", "meta" => "{\"styles\":\"danger_high\"}"}]} + to_string(address) => %{ + "tags" => [ + %{ + "name" => "Proposer Fee Recipient", + "ordinal" => 0, + "slug" => "proposer-fee-recipient", + "tagType" => "generic", + "meta" => "{\"styles\":\"danger_high\"}" + } + ] + } } }) ) @@ -2331,17 +2868,130 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(log, Enum.at(response["items"], 0)) + log = Enum.at(response["items"], 0) assert log["address"]["ens_domain_name"] == "test.eth" - assert log["address"]["metadata"] == %{"tags" => [%{"slug" => "tag", "meta" => %{"styles" => "danger_high"}}]} + + assert log["address"]["metadata"] == %{ + "tags" => [ + %{ + "slug" => "proposer-fee-recipient", + "name" => "Proposer Fee Recipient", + "ordinal" => 0, + "tagType" => "generic", + "meta" => %{"styles" => "danger_high"} + } + ] + } Application.put_env(:block_scout_web, :chain_id, old_chain_id) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_env_bens) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_env_metadata) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) Bypass.down(bypass) end + # https://github.com/blockscout/blockscout/issues/13763 + test "BENS multiprotocol: batch resolve uses protocol-based URL without chain_id", %{conn: conn} do + address = insert(:address, hash: "0x036cec1a199234fC02f72d29e596a09440825f1C") + + transaction = + :transaction + |> insert() + |> with_block() + + log = + insert(:log, + transaction: transaction, + index: 1, + block: transaction.block, + block_number: transaction.block_number, + address: address + ) + + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + chain_id = 1 + Application.put_env(:block_scout_web, :chain_id, chain_id) + + old_env_bens = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true, + protocols: ["ens"] + ) + + old_env_metadata = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + on_exit(fn -> + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_env_bens) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_env_metadata) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end) + + Bypass.expect_once(bypass, "POST", "api/v1/addresses:batch_resolve", fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + assert decoded["protocols"] == "ens" + + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "names" => %{ + to_string(address) => "test.eth" + } + }) + ) + end) + + Bypass.expect_once(bypass, "GET", "api/v1/metadata", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "addresses" => %{ + to_string(address) => %{ + "tags" => [ + %{ + "name" => "Proposer Fee Recipient", + "ordinal" => 0, + "slug" => "proposer-fee-recipient", + "tagType" => "generic", + "meta" => "{\"styles\":\"danger_high\"}" + } + ] + } + } + }) + ) + end) + + request = get(conn, "/api/v2/addresses/#{address.hash}/logs") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + + compare_item(log, Enum.at(response["items"], 0)) + + log = Enum.at(response["items"], 0) + assert log["address"]["ens_domain_name"] == "test.eth" + end + test "logs can be filtered by topic", %{conn: conn} do address = insert(:address) @@ -2371,53 +3021,364 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do block: transaction.block, block_number: transaction.block_number, address: address, - first_topic: topic(@first_topic_hex_string_1) + first_topic: TestHelper.topic(@first_topic_hex_string_1) ) request = get(conn, "/api/v2/addresses/#{address.hash}/logs?topic=#{@first_topic_hex_string_1}") assert response = json_response(request, 200) assert Enum.count(response["items"]) == 1 assert response["next_page_params"] == nil + compare_item(log, Enum.at(response["items"], 0)) end - end - describe "/addresses/{address_hash}/tokens" do - test "get empty list on non existing address", %{conn: conn} do - address = build(:address) + test "log could be decoded via verified implementation", %{conn: conn} do + address = insert(:contract_address) - request = get(conn, "/api/v2/addresses/#{address.hash}/tokens") + contract_address = insert(:contract_address) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) - end + smart_contract = + insert(:smart_contract, + address_hash: contract_address.hash, + abi: [ + %{ + "name" => "OptionSettled", + "type" => "event", + "inputs" => [ + %{"name" => "accountId", "type" => "uint256", "indexed" => true, "internalType" => "uint256"}, + %{"name" => "option", "type" => "address", "indexed" => false, "internalType" => "address"}, + %{"name" => "subId", "type" => "uint256", "indexed" => false, "internalType" => "uint256"}, + %{"name" => "amount", "type" => "int256", "indexed" => false, "internalType" => "int256"}, + %{"name" => "value", "type" => "int256", "indexed" => false, "internalType" => "int256"} + ], + "anonymous" => false + } + ] + ) - test "get 422 on invalid address", %{conn: conn} do - request = get(conn, "/api/v2/addresses/0x/tokens") + topic1_bytes = ExKeccak.hash_256("OptionSettled(uint256,address,uint256,int256,int256)") + topic1 = "0x" <> Base.encode16(topic1_bytes, case: :lower) + topic2 = "0x0000000000000000000000000000000000000000000000000000000000005d19" - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) - end + log_data = + "0x000000000000000000000000aeb81cbe6b19ceeb0dbe0d230cffe35bb40a13a700000000000000000000000000000000000000000000045d964b80006597b700fffffffffffffffffffffffffffffffffffffffffffffffffe55aca2c2f40000ffffffffffffffffffffffffffffffffffffffffffffffe3a8289da3d7a13ef2" - test "get tokens", %{conn: conn} do - address = insert(:address) + transaction = :transaction |> insert() |> with_block() - ctbs_erc_20 = - for _ <- 0..50 do - insert(:address_current_token_balance_with_token_id_and_fixed_token_type, - address: address, - token_type: "ERC-20", - token_id: nil - ) - |> Repo.preload([:token]) - end - |> Enum.sort_by(fn x -> Decimal.to_float(Decimal.mult(x.value, x.token.fiat_value)) end, :asc) + log = + insert(:log, + transaction: transaction, + first_topic: TestHelper.topic(topic1), + second_topic: TestHelper.topic(topic2), + third_topic: nil, + fourth_topic: nil, + data: log_data, + address: address + ) - ctbs_erc_721 = - for _ <- 0..50 do - insert(:address_current_token_balance_with_token_id_and_fixed_token_type, - address: address, - token_type: "ERC-721", - token_id: nil - ) + insert(:proxy_implementation, + proxy_address_hash: address.hash, + proxy_type: "eip1167", + address_hashes: [smart_contract.address_hash], + names: ["Test"] + ) + + request = get(conn, "/api/v2/addresses/#{address.hash}/logs") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + log_from_api = Enum.at(response["items"], 0) + compare_item(log, log_from_api) + assert not is_nil(log_from_api["decoded"]) + + assert log_from_api["decoded"] == %{ + "method_call" => + "OptionSettled(uint256 indexed accountId, address option, uint256 subId, int256 amount, int256 value)", + "method_id" => "d20a68b2", + "parameters" => [ + %{ + "indexed" => true, + "name" => "accountId", + "type" => "uint256", + "value" => "23833" + }, + %{ + "indexed" => false, + "name" => "option", + "type" => "address", + "value" => Address.checksum("0xAeB81cbe6b19CeEB0dBE0d230CFFE35Bb40a13a7") + }, + %{ + "indexed" => false, + "name" => "subId", + "type" => "uint256", + "value" => "20615843020801704441600" + }, + %{ + "indexed" => false, + "name" => "amount", + "type" => "int256", + "value" => "-120000000000000000" + }, + %{ + "indexed" => false, + "name" => "value", + "type" => "int256", + "value" => "-522838470013113778446" + } + ] + } + end + + test "test corner case, when preload functions face absent smart contract", %{conn: conn} do + address = insert(:contract_address) + + contract_address = insert(:contract_address) + + topic1_bytes = ExKeccak.hash_256("OptionSettled(uint256,address,uint256,int256,int256)") + topic1 = "0x" <> Base.encode16(topic1_bytes, case: :lower) + topic2 = "0x0000000000000000000000000000000000000000000000000000000000005d19" + + log_data = + "0x000000000000000000000000aeb81cbe6b19ceeb0dbe0d230cffe35bb40a13a700000000000000000000000000000000000000000000045d964b80006597b700fffffffffffffffffffffffffffffffffffffffffffffffffe55aca2c2f40000ffffffffffffffffffffffffffffffffffffffffffffffe3a8289da3d7a13ef2" + + transaction = :transaction |> insert() |> with_block() + + log = + insert(:log, + transaction: transaction, + first_topic: TestHelper.topic(topic1), + second_topic: TestHelper.topic(topic2), + third_topic: nil, + fourth_topic: nil, + data: log_data, + address: address + ) + + insert(:proxy_implementation, + proxy_address_hash: address.hash, + proxy_type: "eip1167", + address_hashes: [contract_address.hash], + names: ["Test"] + ) + + request = get(conn, "/api/v2/addresses/#{address.hash}/logs") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + log_from_api = Enum.at(response["items"], 0) + + compare_item(log, log_from_api) + end + + test "ignore logs without topics when trying to decode with sig provider", %{conn: conn} do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + old_env_sig_provider = Application.get_env(:explorer, Explorer.SmartContract.SigProviderInterface) + + Application.put_env(:explorer, Explorer.SmartContract.SigProviderInterface, + enabled: true, + service_url: "http://localhost:#{bypass.port}" + ) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.SmartContract.SigProviderInterface, old_env_sig_provider) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + address = insert(:contract_address) + + log_data = + "0x000000000000000000000000aeb81cbe6b19ceeb0dbe0d230cffe35bb40a13a700000000000000000000000000000000000000000000045d964b80006597b700fffffffffffffffffffffffffffffffffffffffffffffffffe55aca2c2f40000ffffffffffffffffffffffffffffffffffffffffffffffe3a8289da3d7a13ef2" + + transaction = :transaction |> insert() |> with_block() + + insert(:log, + transaction: transaction, + first_topic: nil, + second_topic: nil, + third_topic: nil, + fourth_topic: nil, + data: log_data, + address: address + ) + + insert(:log, + transaction: transaction, + first_topic: TestHelper.topic("0x0000000000000000000000000000000000000000000000000000000000005d19"), + second_topic: nil, + third_topic: nil, + fourth_topic: nil, + data: log_data, + address: address + ) + + # cspell:disable + Bypass.expect_once(bypass, "POST", "/api/v1/abi/events%3Abatch-get", fn conn -> + # cspell:enable + {:ok, body, conn} = Plug.Conn.read_body(conn) + body = Jason.decode!(body) + assert Enum.count(body["requests"]) == 1 + + Conn.resp(conn, 200, Jason.encode!([])) + end) + + request = get(conn, "/api/v2/addresses/#{address.hash}/logs") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 2 + + Bypass.down(bypass) + end + end + + describe "/addresses/{address_hash}/tokens" do + test "get token balances with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: Enum.random(1..100_000) + ) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/addresses/#{address.hash}/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get("/api/v2/addresses/#{address.hash}/tokens") |> json_response(200) + end + + test "get token balances with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + ctbs_erc_1155 = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: Enum.random(1..100_000) + ) + + insert(:scam_badge_to_address, address_hash: ctbs_erc_1155.token_contract_address_hash) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/addresses/#{address.hash}/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/addresses/#{address.hash}/tokens") + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token balances with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: Enum.random(1..100_000) + ) + + request = conn |> get("/api/v2/addresses/#{address.hash}/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get token balances with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + ctbs_erc_1155 = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: Enum.random(1..100_000) + ) + + insert(:scam_badge_to_address, address_hash: ctbs_erc_1155.token_contract_address_hash) + + request = conn |> get("/api/v2/addresses/#{address.hash}/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get empty list on non existing address", %{conn: conn} do + address = build(:address) + + request = get(conn, "/api/v2/addresses/#{address.hash}/tokens") + + response = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response + end + + test "get 422 on invalid address", %{conn: conn} do + request = get(conn, "/api/v2/addresses/0x/tokens") + + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response + end + + test "get tokens", %{conn: conn} do + initial_value = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + + on_exit(fn -> + :persistent_term.put(:market_token_fetcher_enabled, initial_value) + end) + + address = insert(:address) + + ctbs_erc_20 = + for _ <- 0..50 do + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-20", + token_id: nil + ) + |> Repo.preload([:token]) + end + |> Enum.sort_by(fn x -> Decimal.to_float(Decimal.mult(x.value, x.token.fiat_value)) end, :asc) + + ctbs_erc_721 = + for _ <- 0..50 do + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-721", + token_id: nil + ) |> Repo.preload([:token]) end |> Enum.sort_by(fn x -> Decimal.to_integer(x.value) end, :asc) @@ -2465,6 +3426,20 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert response_2nd_page = json_response(request_2nd_page, 200) check_paginated_response(response, response_2nd_page, ctbs_erc_1155) + + # Test multiple token types (the fix for the original issue) + filter = %{"type" => "ERC-721,ERC-1155"} + request = get(conn, "/api/v2/addresses/#{address.hash}/tokens", filter) + assert response = json_response(request, 200) + + # Verify we get tokens from both types + response_token_types = + response["items"] + |> Enum.map(fn item -> item["token"]["type"] end) + |> Enum.uniq() + |> Enum.sort() + + assert response_token_types == ["ERC-1155", "ERC-721"] end end @@ -2475,14 +3450,17 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do old_env = Application.get_env(:indexer, Indexer.Fetcher.OnDemand.TokenBalance) configuration = Application.get_env(:indexer, Indexer.Fetcher.OnDemand.TokenBalance.Supervisor) Application.put_env(:indexer, Indexer.Fetcher.OnDemand.TokenBalance.Supervisor, disabled?: false) - Indexer.Fetcher.OnDemand.TokenBalance.Supervisor.Case.start_supervised!() Application.put_env( :indexer, Indexer.Fetcher.OnDemand.TokenBalance, - Keyword.put(old_env, :fallback_threshold_in_blocks, 0) + old_env + |> Keyword.put(:fallback_threshold_in_blocks, 0) + |> Keyword.put(:address_queue_flush_interval, 50) ) + Indexer.Fetcher.OnDemand.TokenBalance.Supervisor.Case.start_supervised!(max_batch_size: 100) + on_exit(fn -> Application.put_env(:indexer, Indexer.Fetcher.OnDemand.TokenBalance.Supervisor, configuration) Application.put_env(:indexer, Indexer.Fetcher.OnDemand.TokenBalance, old_env) @@ -2737,14 +3715,14 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do :timer.seconds(1) assert Decimal.to_integer(ctb_erc_20["value"]) == - other_balances[ctb_erc_20["token"]["address"] |> String.downcase()] + other_balances[ctb_erc_20["token"]["address_hash"] |> String.downcase()] assert Decimal.to_integer(ctb_erc_721["value"]) == - other_balances[ctb_erc_721["token"]["address"] |> String.downcase()] + other_balances[ctb_erc_721["token"]["address_hash"] |> String.downcase()] assert Decimal.to_integer(ctb_erc_1155["value"]) == balances_erc_1155[ - {ctb_erc_1155["token"]["address"] |> String.downcase(), to_string(ctb_erc_1155["token_id"])} + {ctb_erc_1155["token"]["address_hash"] |> String.downcase(), to_string(ctb_erc_1155["token_id"])} ] end end @@ -2754,14 +3732,25 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/withdrawals") + response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/withdrawals") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get withdrawals", %{conn: conn} do @@ -2815,17 +3804,21 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = insert(:address, transactions_count: 2, fetched_coin_balance: 1) request = get(conn, "/api/v2/addresses") - - assert %{"items" => [address_json], "next_page_params" => nil} = json_response(request, 200) + response = json_response(request, 200) + assert %{"items" => [address_json], "next_page_params" => nil} = response compare_item(address, address_json) end - test "check smart contract preload", %{conn: conn} do - smart_contract = insert(:smart_contract, address_hash: insert(:contract_address, fetched_coin_balance: 1).hash) + test "check smart contract properties", %{conn: conn} do + smart_contract = + insert(:smart_contract, + address_hash: insert(:contract_address, fetched_coin_balance: 1, verified: true).hash + ) request = get(conn, "/api/v2/addresses") - assert %{"items" => [address]} = json_response(request, 200) + response = json_response(request, 200) + assert %{"items" => [address]} = response assert String.downcase(address["hash"]) == to_string(smart_contract.address_hash) assert address["is_contract"] == true @@ -2914,6 +3907,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") + response = json_response(request, 200) assert %{ "validations_count" => 0, @@ -2924,19 +3918,30 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "withdrawals_count" => 0, "internal_transactions_count" => 0, "celo_election_rewards_count" => 0 - } = json_response(request, 200) + } = response end test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/addresses/0x/tabs-counters") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get counters with 0s", %{conn: conn} do address = insert(:address) request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") + response = json_response(request, 200) assert %{ "validations_count" => 0, @@ -2946,7 +3951,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "logs_count" => 0, "withdrawals_count" => 0, "internal_transactions_count" => 0 - } = json_response(request, 200) + } = response end test "get counters and check that cache works", %{conn: conn} do @@ -2983,9 +3988,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: x, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: x, - from_address: address + to_address: address ) end @@ -3009,6 +4012,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") + response = json_response(request, 200) assert %{ "validations_count" => 1, @@ -3018,7 +4022,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "logs_count" => 51, "withdrawals_count" => 51, "internal_transactions_count" => 2 - } = json_response(request, 200) + } = response for x <- 3..4 do insert(:internal_transaction, @@ -3026,13 +4030,12 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: x, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: x, from_address: address ) end request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") + response = json_response(request, 200) assert %{ "validations_count" => 1, @@ -3042,7 +4045,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "logs_count" => 51, "withdrawals_count" => 51, "internal_transactions_count" => 2 - } = json_response(request, 200) + } = response end test "check counters cache ttl", %{conn: conn} do @@ -3079,8 +4082,6 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: x, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: x, from_address: address ) end @@ -3105,6 +4106,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") + response = json_response(request, 200) assert %{ "validations_count" => 1, @@ -3114,7 +4116,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "logs_count" => 51, "withdrawals_count" => 51, "internal_transactions_count" => 2 - } = json_response(request, 200) + } = response old_env = Application.get_env(:explorer, Explorer.Chain.Cache.Counters.AddressTabsElementsCount) Application.put_env(:explorer, Explorer.Chain.Cache.Counters.AddressTabsElementsCount, ttl: 200) @@ -3126,8 +4128,6 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do index: x, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: x, from_address: address ) end @@ -3136,6 +4136,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do insert(:transaction, to_address: address) |> with_block() request = get(conn, "/api/v2/addresses/#{address.hash}/tabs-counters") + response = json_response(request, 200) assert %{ "validations_count" => 1, @@ -3145,7 +4146,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "logs_count" => 51, "withdrawals_count" => 51, "internal_transactions_count" => 4 - } = json_response(request, 200) + } = response Application.put_env(:explorer, Explorer.Chain.Cache.Counters.AddressTabsElementsCount, old_env) end @@ -3160,69 +4161,407 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do address = build(:address) request = get(conn, endpoint.(address.hash)) + response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn, endpoint: endpoint} do request = get(conn, endpoint.("0x")) - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end - test "get paginated ERC-721 nft", %{conn: conn, endpoint: endpoint} do + test "get token with ok reputation", %{conn: conn, endpoint: endpoint} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + address = insert(:address) - insert_list(51, :token_instance) + # --- ERC-721 --- + erc_721_token = insert(:token, type: "ERC-721") - token_instances = - for _ <- 0..50 do - erc_721_token = insert(:token, type: "ERC-721") + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) - insert(:token_instance, - owner_address_hash: address.hash, - token_contract_address_hash: erc_721_token.contract_address_hash - ) - |> Repo.preload([:token]) - end - # works because one token_id per token, despite ordering in DB: [asc: ti.token_contract_address_hash, desc: ti.token_id] - |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + # --- ERC-1155 --- - request = get(conn, endpoint.(address.hash)) - assert response = json_response(request, 200) + token = insert(:token, type: "ERC-1155") - request_2nd_page = get(conn, endpoint.(address.hash), response["next_page_params"]) - assert response_2nd_page = json_response(request_2nd_page, 200) + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) - check_paginated_response(response, response_2nd_page, token_instances) - end + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) - test "get paginated ERC-1155 nft", %{conn: conn, endpoint: endpoint} do - address = insert(:address) + # --- ERC-404 --- + token = insert(:token, type: "ERC-404") - insert_list(51, :address_current_token_balance_with_token_id) + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) - token_instances = - for _ <- 0..50 do - token = insert(:token, type: "ERC-1155") + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) - ti = - insert(:token_instance, - token_contract_address_hash: token.contract_address_hash - ) - |> Repo.preload([:token]) + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get(endpoint.(address.hash), %{"type" => "ERC-721"}) - current_token_balance = - insert(:address_current_token_balance_with_token_id_and_fixed_token_type, - address: address, - token_type: "ERC-1155", - token_id: ti.token_id, - token_contract_address_hash: token.contract_address_hash - ) + response = json_response(request, 200) - %Instance{ti | current_token_balance: current_token_balance} - end - |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get(endpoint.(address.hash), %{"type" => "ERC-721"}) |> json_response(200) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get(endpoint.(address.hash), %{"type" => "ERC-1155"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get(endpoint.(address.hash), %{"type" => "ERC-1155"}) |> json_response(200) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get(endpoint.(address.hash), %{"type" => "ERC-404"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get(endpoint.(address.hash), %{"type" => "ERC-404"}) |> json_response(200) + end + + test "get token with scam reputation", %{conn: conn, endpoint: endpoint} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + address = insert(:address) + # --- ERC-721 --- + erc_721_token = insert(:token, type: "ERC-721") + + insert(:scam_badge_to_address, address_hash: erc_721_token.contract_address_hash) + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + + # --- ERC-1155 --- + token = insert(:token, type: "ERC-1155") + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + # --- ERC-404 --- + token = insert(:token, type: "ERC-404") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get(endpoint.(address.hash), %{"type" => "ERC-721"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-721"}) + response = json_response(request, 200) + + assert response["items"] == [] + + # --- ERC-1155 --- + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get(endpoint.(address.hash), %{"type" => "ERC-1155"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-1155"}) + response = json_response(request, 200) + + assert response["items"] == [] + + # --- ERC-404 --- + request = + conn |> put_req_cookie("show_scam_tokens", "true") |> get(endpoint.(address.hash), %{"type" => "ERC-404"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-404"}) + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token with ok reputation with hide_scam_addresses=false", %{conn: conn, endpoint: endpoint} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + # --- ERC-721 --- + erc_721_token = insert(:token, type: "ERC-721") + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + + # --- ERC-1155 --- + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + # --- ERC-404 --- + token = insert(:token, type: "ERC-404") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-721"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-1155"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-404"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get token with scam reputation with hide_scam_addresses=false", %{conn: conn, endpoint: endpoint} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + # --- ERC-721 --- + erc_721_token = insert(:token, type: "ERC-721") + + insert(:scam_badge_to_address, address_hash: erc_721_token.contract_address_hash) + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + + # --- ERC-1155 --- + token = insert(:token, type: "ERC-1155") + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + # --- ERC-404 --- + token = insert(:token, type: "ERC-404") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-721"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-1155"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get(endpoint.(address.hash), %{"type" => "ERC-404"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get paginated ERC-721 nft", %{conn: conn, endpoint: endpoint} do + address = insert(:address) + + insert_list(51, :token_instance) + + token_instances = + for _ <- 0..50 do + erc_721_token = insert(:token, type: "ERC-721") + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + |> Repo.preload([:token]) + end + # works because one token_id per token, despite ordering in DB: [asc: ti.token_contract_address_hash, desc: ti.token_id] + |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + + request = get(conn, endpoint.(address.hash)) + assert response = json_response(request, 200) + + request_2nd_page = get(conn, endpoint.(address.hash), response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, token_instances) + end + + test "next_page_params does not leak original type filter", %{conn: conn, endpoint: endpoint} do + address = insert(:address) + + # Create a mix of ERC-1155 and ERC-404 items exceeding one page + insert_list(60, :address_current_token_balance_with_token_id) + + for _ <- 1..60 do + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, token_contract_address_hash: token.contract_address_hash) |> Repo.preload([:token]) + + ctb = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + %Instance{ti | current_token_balance: ctb} + end + + response = + conn + |> get(endpoint.(address.hash), %{type: "ERC-404,ERC-1155"}) + |> json_response(200) + + assert not is_nil(response["next_page_params"]) + refute Map.has_key?(response["next_page_params"], "type") + assert Map.has_key?(response["next_page_params"], "token_type") + end + + test "get paginated ERC-1155 nft", %{conn: conn, endpoint: endpoint} do + address = insert(:address) + + insert_list(51, :address_current_token_balance_with_token_id) + + token_instances = + for _ <- 0..50 do + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + %Instance{ti | current_token_balance: current_token_balance} + end + |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) request = get(conn, endpoint.(address.hash)) assert response = json_response(request, 200) @@ -3260,148 +4599,723 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) - request = get(conn, endpoint.(address.hash)) - assert response = json_response(request, 200) + request = get(conn, endpoint.(address.hash)) + assert response = json_response(request, 200) + + request_2nd_page = get(conn, endpoint.(address.hash), response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, token_instances) + end + + test "test filters", %{conn: conn, endpoint: endpoint} do + address = insert(:address) + + insert_list(51, :token_instance) + + token_instances_721 = + for _ <- 0..50 do + erc_721_token = insert(:token, type: "ERC-721") + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + |> Repo.preload([:token]) + end + |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + + insert_list(51, :address_current_token_balance_with_token_id) + + token_instances_1155 = + for _ <- 0..50 do + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + %Instance{ti | current_token_balance: current_token_balance} + end + |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + + filter = %{"type" => "ERC-721"} + request = get(conn, endpoint.(address.hash), filter) + assert response = json_response(request, 200) + + request_2nd_page = get(conn, endpoint.(address.hash), Map.merge(response["next_page_params"], filter)) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, token_instances_721) + + filter = %{"type" => "ERC-1155"} + request = get(conn, endpoint.(address.hash), filter) + assert response = json_response(request, 200) + + request_2nd_page = get(conn, endpoint.(address.hash), Map.merge(response["next_page_params"], filter)) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, token_instances_1155) + end + + test "return all token instances", %{conn: conn, endpoint: endpoint} do + address = insert(:address) + + insert_list(51, :token_instance) + + token_instances_721 = + for _ <- 0..50 do + erc_721_token = insert(:token, type: "ERC-721") + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + |> Repo.preload([:token]) + end + |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + + insert_list(51, :address_current_token_balance_with_token_id) + + token_instances_1155 = + for _ <- 0..50 do + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + + %Instance{ti | current_token_balance: current_token_balance} + end + |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + + request = get(conn, endpoint.(address.hash)) + assert response = json_response(request, 200) + + request_2nd_page = get(conn, endpoint.(address.hash), response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + request_3rd_page = get(conn, endpoint.(address.hash), response_2nd_page["next_page_params"]) + assert response_3rd_page = json_response(request_3rd_page, 200) + + assert response["next_page_params"] != nil + assert response_2nd_page["next_page_params"] != nil + assert response_3rd_page["next_page_params"] == nil + + assert Enum.count(response["items"]) == 50 + assert Enum.count(response_2nd_page["items"]) == 50 + assert Enum.count(response_3rd_page["items"]) == 2 + + compare_item(Enum.at(token_instances_721, 50), Enum.at(response["items"], 0)) + compare_item(Enum.at(token_instances_721, 1), Enum.at(response["items"], 49)) + + compare_item(Enum.at(token_instances_721, 0), Enum.at(response_2nd_page["items"], 0)) + compare_item(Enum.at(token_instances_1155, 50), Enum.at(response_2nd_page["items"], 1)) + compare_item(Enum.at(token_instances_1155, 2), Enum.at(response_2nd_page["items"], 49)) + + compare_item(Enum.at(token_instances_1155, 1), Enum.at(response_3rd_page["items"], 0)) + compare_item(Enum.at(token_instances_1155, 0), Enum.at(response_3rd_page["items"], 1)) + end + + test "paginates across types after intermediate type exhaustion (should include next type)", %{ + conn: conn, + endpoint: endpoint + } do + address = insert(:address) + + # Insert 30 ERC-721 (owned directly) + for _ <- 1..30 do + erc_721_token = insert(:token, type: "ERC-721") + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + end + + # Insert 25 ERC-1155 (with balances) + for _ <- 1..25 do + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + end + + # Insert 10 ERC-404 (with balances) + for _ <- 1..10 do + token = insert(:token, type: "ERC-404") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + end + + # Page 1 + page1_resp = conn |> get(endpoint.(address.hash)) |> json_response(200) + assert Enum.count(page1_resp["items"]) == 50 + assert page1_resp["next_page_params"] != nil + + # Expect mixture of ERC-721 and ERC-1155 only on first page (by current logic order) + assert %{ + "ERC-721" => 30, + "ERC-1155" => 20 + } = Enum.frequencies_by(page1_resp["items"], & &1["token_type"]) + + page2_resp = conn |> get(endpoint.(address.hash), page1_resp["next_page_params"]) |> json_response(200) + + assert Enum.count(page2_resp["items"]) == 15 + assert page2_resp["next_page_params"] == nil + + assert %{ + "ERC-1155" => 5, + "ERC-404" => 10 + } = Enum.frequencies_by(page2_resp["items"], & &1["token_type"]) + end + + test "multi-type filter includes only requested types", %{conn: conn, endpoint: endpoint} do + address = insert(:address) + + # ERC-721 tokens (should be excluded by filter) + for _ <- 1..5 do + erc_721_token = insert(:token, type: "ERC-721") + + insert(:token_instance, + owner_address_hash: address.hash, + token_contract_address_hash: erc_721_token.contract_address_hash + ) + end + + # ERC-1155 tokens (should be included) + for _ <- 1..5 do + token = insert(:token, type: "ERC-1155") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + end + + # ERC-404 tokens (should be included) + for _ <- 1..5 do + token = insert(:token, type: "ERC-404") + + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash + ) + |> Repo.preload([:token]) + + insert(:address_current_token_balance_with_token_id_and_fixed_token_type, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash + ) + end + + filter = %{"type" => "ERC-404,ERC-1155"} + request = get(conn, endpoint.(address.hash), filter) + response = json_response(request, 200) + + assert Enum.count(response["items"]) == 10 + assert Enum.all?(response["items"], fn item -> item["token_type"] in ["ERC-404", "ERC-1155"] end) + refute Enum.any?(response["items"], fn item -> item["token_type"] == "ERC-721" end) + end + end + + describe "/addresses/{address_hash}/nft/collections" do + setup do + {:ok, endpoint: &"/api/v2/addresses/#{&1}/nft/collections"} + end + + test "get nft collections with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + token = insert(:token, type: "ERC-721") + amount = Enum.random(16..50) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-721", + token_id: nil, + token_contract_address_hash: token.contract_address_hash, + value: amount + ) + |> Repo.preload([:token]) + + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) + + %Instance{ti | current_token_balance: current_token_balance} + end + + token = insert(:token, type: "ERC-1155") + amount = Enum.random(16..50) + + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) + + %Instance{ti | current_token_balance: current_token_balance} + end + + token = insert(:token, type: "ERC-404") + amount = Enum.random(16..50) + + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) + + %Instance{ti | current_token_balance: current_token_balance} + end + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-721"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == + conn + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-721"}) + |> json_response(200) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-1155"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == + conn + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-1155"}) + |> json_response(200) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-404"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == + conn + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-404"}) + |> json_response(200) + end + + test "get nft collections with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + address = insert(:address) + + token = insert(:token, type: "ERC-721") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + amount = Enum.random(16..50) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-721", + token_id: nil, + token_contract_address_hash: token.contract_address_hash, + value: amount + ) + |> Repo.preload([:token]) + + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) + + %Instance{ti | current_token_balance: current_token_balance} + end + + token = insert(:token, type: "ERC-1155") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + amount = Enum.random(16..50) + + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) + + %Instance{ti | current_token_balance: current_token_balance} + end + + token = insert(:token, type: "ERC-404") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + amount = Enum.random(16..50) + + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) + + %Instance{ti | current_token_balance: current_token_balance} + end + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-721"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-721"}) + response = json_response(request, 200) + + assert response["items"] == [] + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-1155"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-1155"}) + response = json_response(request, 200) + + assert response["items"] == [] + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-404"}) + + response = json_response(request, 200) - request_2nd_page = get(conn, endpoint.(address.hash), response["next_page_params"]) - assert response_2nd_page = json_response(request_2nd_page, 200) + assert List.first(response["items"])["token"]["reputation"] == "scam" - check_paginated_response(response, response_2nd_page, token_instances) + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-404"}) + response = json_response(request, 200) + + assert response["items"] == [] end - test "test filters", %{conn: conn, endpoint: endpoint} do + test "get nft collections with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) address = insert(:address) - insert_list(51, :token_instance) + token = insert(:token, type: "ERC-721") - token_instances_721 = - for _ <- 0..50 do - erc_721_token = insert(:token, type: "ERC-721") + amount = Enum.random(16..50) + + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-721", + token_id: nil, + token_contract_address_hash: token.contract_address_hash, + value: amount + ) + |> Repo.preload([:token]) + for _ <- 0..(amount - 1) do + ti = insert(:token_instance, - owner_address_hash: address.hash, - token_contract_address_hash: erc_721_token.contract_address_hash + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash ) |> Repo.preload([:token]) - end - |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) - insert_list(51, :address_current_token_balance_with_token_id) + %Instance{ti | current_token_balance: current_token_balance} + end - token_instances_1155 = - for _ <- 0..50 do - token = insert(:token, type: "ERC-1155") + token = insert(:token, type: "ERC-1155") + amount = Enum.random(16..50) - ti = - insert(:token_instance, - token_contract_address_hash: token.contract_address_hash - ) - |> Repo.preload([:token]) + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) - current_token_balance = - insert(:address_current_token_balance_with_token_id_and_fixed_token_type, - address: address, - token_type: "ERC-1155", - token_id: ti.token_id, - token_contract_address_hash: token.contract_address_hash - ) + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) - %Instance{ti | current_token_balance: current_token_balance} - end - |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + %Instance{ti | current_token_balance: current_token_balance} + end - filter = %{"type" => "ERC-721"} - request = get(conn, endpoint.(address.hash), filter) - assert response = json_response(request, 200) + token = insert(:token, type: "ERC-404") + amount = Enum.random(16..50) - request_2nd_page = get(conn, endpoint.(address.hash), Map.merge(response["next_page_params"], filter)) - assert response_2nd_page = json_response(request_2nd_page, 200) + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) - check_paginated_response(response, response_2nd_page, token_instances_721) + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) - filter = %{"type" => "ERC-1155"} - request = get(conn, endpoint.(address.hash), filter) - assert response = json_response(request, 200) + %Instance{ti | current_token_balance: current_token_balance} + end - request_2nd_page = get(conn, endpoint.(address.hash), Map.merge(response["next_page_params"], filter)) - assert response_2nd_page = json_response(request_2nd_page, 200) + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-721"}) + response = json_response(request, 200) - check_paginated_response(response, response_2nd_page, token_instances_1155) + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-1155"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-404"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" end - test "return all token instances", %{conn: conn, endpoint: endpoint} do + test "get nft collections with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + address = insert(:address) - insert_list(51, :token_instance) + token = insert(:token, type: "ERC-721") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) - token_instances_721 = - for _ <- 0..50 do - erc_721_token = insert(:token, type: "ERC-721") + amount = Enum.random(16..50) + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-721", + token_id: nil, + token_contract_address_hash: token.contract_address_hash, + value: amount + ) + |> Repo.preload([:token]) + + for _ <- 0..(amount - 1) do + ti = insert(:token_instance, - owner_address_hash: address.hash, - token_contract_address_hash: erc_721_token.contract_address_hash + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash ) |> Repo.preload([:token]) - end - |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) - insert_list(51, :address_current_token_balance_with_token_id) + %Instance{ti | current_token_balance: current_token_balance} + end - token_instances_1155 = - for _ <- 0..50 do - token = insert(:token, type: "ERC-1155") + token = insert(:token, type: "ERC-1155") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) - ti = - insert(:token_instance, - token_contract_address_hash: token.contract_address_hash - ) - |> Repo.preload([:token]) + amount = Enum.random(16..50) - current_token_balance = - insert(:address_current_token_balance_with_token_id_and_fixed_token_type, - address: address, - token_type: "ERC-1155", - token_id: ti.token_id, - token_contract_address_hash: token.contract_address_hash - ) + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) - %Instance{ti | current_token_balance: current_token_balance} - end - |> Enum.sort_by(&{&1.token_contract_address_hash, &1.token_id}, :desc) + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-1155", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) - request = get(conn, endpoint.(address.hash)) - assert response = json_response(request, 200) + %Instance{ti | current_token_balance: current_token_balance} + end - request_2nd_page = get(conn, endpoint.(address.hash), response["next_page_params"]) - assert response_2nd_page = json_response(request_2nd_page, 200) + token = insert(:token, type: "ERC-404") + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) - request_3rd_page = get(conn, endpoint.(address.hash), response_2nd_page["next_page_params"]) - assert response_3rd_page = json_response(request_3rd_page, 200) + amount = Enum.random(16..50) - assert response["next_page_params"] != nil - assert response_2nd_page["next_page_params"] != nil - assert response_3rd_page["next_page_params"] == nil + for _ <- 0..(amount - 1) do + ti = + insert(:token_instance, + token_contract_address_hash: token.contract_address_hash, + owner_address_hash: address.hash + ) + |> Repo.preload([:token]) - assert Enum.count(response["items"]) == 50 - assert Enum.count(response_2nd_page["items"]) == 50 - assert Enum.count(response_3rd_page["items"]) == 2 + current_token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-404", + token_id: ti.token_id, + token_contract_address_hash: token.contract_address_hash, + value: Enum.random(1..100_000) + ) + |> Repo.preload([:token]) - compare_item(Enum.at(token_instances_721, 50), Enum.at(response["items"], 0)) - compare_item(Enum.at(token_instances_721, 1), Enum.at(response["items"], 49)) + %Instance{ti | current_token_balance: current_token_balance} + end - compare_item(Enum.at(token_instances_721, 0), Enum.at(response_2nd_page["items"], 0)) - compare_item(Enum.at(token_instances_1155, 50), Enum.at(response_2nd_page["items"], 1)) - compare_item(Enum.at(token_instances_1155, 2), Enum.at(response_2nd_page["items"], 49)) + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-721"}) + response = json_response(request, 200) - compare_item(Enum.at(token_instances_1155, 1), Enum.at(response_3rd_page["items"], 0)) - compare_item(Enum.at(token_instances_1155, 0), Enum.at(response_3rd_page["items"], 1)) - end - end + assert List.first(response["items"])["token"]["reputation"] == "ok" - describe "/addresses/{address_hash}/nft/collections" do - setup do - {:ok, endpoint: &"/api/v2/addresses/#{&1}/nft/collections"} + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-1155"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + request = conn |> get("/api/v2/addresses/#{address.hash}/nft/collections", %{type: "ERC-404"}) + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" end test "get 200 on non existing address", %{conn: conn, endpoint: endpoint} do @@ -3409,13 +5323,24 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do request = get(conn, endpoint.(address.hash)) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + response = json_response(request, 200) + assert %{"items" => [], "next_page_params" => nil} = response end test "get 422 on invalid address", %{conn: conn, endpoint: endpoint} do request = get(conn, endpoint.("0x")) - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + json_response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response end test "get paginated erc-721 collection", %{conn: conn, endpoint: endpoint} do @@ -3699,9 +5624,206 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end end + if @chain_identity == {:optimism, :celo} do + describe "/addresses/{address_hash}/election-rewards" do + setup do + celo_token = insert(:token) + usd_token = insert(:token) + + original_core_contracts_config = + Application.get_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts) + + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, + contracts: %{ + "addresses" => %{ + "Accounts" => [], + "Election" => [], + "EpochRewards" => [], + "FeeHandler" => [], + "GasPriceMinimum" => [], + "GoldToken" => [ + %{"address" => to_string(celo_token.contract_address_hash), "updated_at_block_number" => 0} + ], + "Governance" => [], + "LockedGold" => [], + "Reserve" => [], + "StableToken" => [ + %{"address" => to_string(usd_token.contract_address_hash), "updated_at_block_number" => 0} + ], + "Validators" => [] + } + } + ) + + original_celo_config = Application.get_env(:explorer, :celo) + + on_exit(fn -> + Application.put_env( + :explorer, + Explorer.Chain.Cache.CeloCoreContracts, + original_core_contracts_config + ) + + Application.put_env(:explorer, :celo, original_celo_config) + end) + + {:ok, %{celo_token: celo_token, usd_token: usd_token}} + end + + test "get empty list on non-existing address", %{conn: conn} do + address = build(:address) + + request = get(conn, "/api/v2/addresses/#{address.hash}/celo/election-rewards") + # The endpoint requires the address to exist in the database, returns 404 if not found + json_response(request, 404) + end + + test "get 422 on invalid address", %{conn: conn} do + request = get(conn, "/api/v2/addresses/0x/celo/election-rewards") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end + + test "paginates election rewards across two pages", %{conn: conn} do + address = insert(:address) + end_processing_block = insert(:block) + + epoch = + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279, + end_processing_block_hash: end_processing_block.hash + ) + + # Insert 51 rewards with distinct amounts 1..51 for the same address, epoch, and type. + # Default sort is desc:epoch_number, asc:type, desc:amount, so within a single epoch+type + # rewards are ordered by descending amount: 51, 50, ..., 1. + rewards = + 1..51 + |> Enum.map(fn i -> + insert(:celo_election_reward, + account_address_hash: address.hash, + epoch_number: epoch.number, + type: :voter, + amount: i + ) + end) + |> Enum.sort_by(& &1.amount.value, :desc) + + request = get(conn, "/api/v2/addresses/#{address.hash}/celo/election-rewards") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + + assert Enum.at(response["items"], 0)["epoch_number"] == epoch.number + assert Enum.at(response["items"], 0)["type"] == "voter" + + # First page: amounts 51 down to 2 + assert Enum.at(response["items"], 0)["amount"] == + to_string(Enum.at(rewards, 0).amount.value) + + assert Enum.at(response["items"], 49)["amount"] == + to_string(Enum.at(rewards, 49).amount.value) + + request_2nd_page = + get(conn, "/api/v2/addresses/#{address.hash}/celo/election-rewards", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + + # Second page: the one reward with the lowest amount + assert Enum.at(response_2nd_page["items"], 0)["amount"] == + to_string(Enum.at(rewards, 50).amount.value) + end + + test "rewards for different addresses do not appear in each other's results", %{conn: conn} do + address_a = insert(:address) + address_b = insert(:address) + end_processing_block = insert(:block) + + epoch = + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279, + end_processing_block_hash: end_processing_block.hash + ) + + insert(:celo_election_reward, account_address_hash: address_a.hash, epoch_number: epoch.number, type: :voter) + insert(:celo_election_reward, account_address_hash: address_b.hash, epoch_number: epoch.number, type: :voter) + + request_a = get(conn, "/api/v2/addresses/#{address_a.hash}/celo/election-rewards") + assert %{"items" => [item_a], "next_page_params" => nil} = json_response(request_a, 200) + assert item_a["account"]["hash"] == Address.checksum(address_a.hash) + + request_b = get(conn, "/api/v2/addresses/#{address_b.hash}/celo/election-rewards") + assert %{"items" => [item_b], "next_page_params" => nil} = json_response(request_b, 200) + assert item_b["account"]["hash"] == Address.checksum(address_b.hash) + end + end + end + + if @chain_type == :ethereum do + describe "/addresses/{address_hash}/beacon/deposits" do + test "get empty list on non-existing address", %{conn: conn} do + address = build(:address) + + request = get(conn, "/api/v2/addresses/#{address.hash}/beacon/deposits") + response = json_response(request, 200) + + assert %{"items" => [], "next_page_params" => nil} = response + end + + test "get 422 on invalid address", %{conn: conn} do + request = get(conn, "/api/v2/addresses/invalid/beacon/deposits") + response = json_response(request, 422) + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = response + end + + test "get deposits", %{conn: conn} do + address = insert(:address) + + deposits = insert_list(51, :beacon_deposit, from_address: address) + + request = get(conn, "/api/v2/addresses/#{address.hash}/beacon/deposits") + assert response = json_response(request, 200) + + request_2nd_page = + get(conn, "/api/v2/addresses/#{address.hash}/beacon/deposits", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, deposits) + end + end + end + defp compare_item(%Address{} = address, json) do assert Address.checksum(address.hash) == json["hash"] - assert to_string(address.transactions_count) == json["transaction_count"] + assert to_string(address.transactions_count) == json["transactions_count"] end defp compare_item(%Transaction{} = transaction, json) do @@ -3712,22 +5834,11 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert Address.checksum(transaction.to_address_hash) == json["to"]["hash"] end - defp compare_item(%TokenTransfer{} = token_transfer, json) do - assert Address.checksum(token_transfer.from_address_hash) == json["from"]["hash"] - assert Address.checksum(token_transfer.to_address_hash) == json["to"]["hash"] - assert to_string(token_transfer.transaction_hash) == json["transaction_hash"] - assert json["timestamp"] != nil - assert json["method"] != nil - assert to_string(token_transfer.block_hash) == json["block_hash"] - assert token_transfer.log_index == json["log_index"] - assert check_total(Repo.preload(token_transfer, [{:token, :contract_address}]).token, json["total"], token_transfer) - end - defp compare_item(%InternalTransaction{} = internal_transaction, json) do assert internal_transaction.block_number == json["block_number"] assert to_string(internal_transaction.gas) == json["gas_limit"] assert internal_transaction.index == json["index"] - assert to_string(internal_transaction.transaction_hash) == json["transaction_hash"] + assert to_string(internal_transaction.transaction.hash) == json["transaction_hash"] assert Address.checksum(internal_transaction.from_address_hash) == json["from"]["hash"] assert Address.checksum(internal_transaction.to_address_hash) == json["to"]["hash"] end @@ -3747,17 +5858,38 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert to_string(cb.value.value) == json["value"] assert cb.block_number == json["block_number"] - assert Jason.encode!(Repo.get_by(Block, number: cb.block_number).timestamp) =~ - String.replace(json["block_timestamp"], "Z", "") + # The API uses linear interpolation over the fetched set (page_size + 1 items), + # so the returned timestamp may be off by up to 1 second from the actual block + # timestamp. Allow ±1 second tolerance to account for this artifact. + expected_timestamps = + Repo.all( + from(block in Block, + where: block.number == ^cb.block_number, + select: block.timestamp + ) + ) + |> Enum.flat_map(fn ts -> + truncated = DateTime.truncate(ts, :second) + + [ + DateTime.add(truncated, -1, :second), + truncated, + DateTime.add(truncated, 1, :second) + ] + end) + |> Enum.uniq() + + {:ok, response_timestamp, 0} = DateTime.from_iso8601(json["block_timestamp"]) + assert DateTime.truncate(response_timestamp, :second) in expected_timestamps end defp compare_item(%Token{} = token, json) do - assert Address.checksum(token.contract_address_hash) == json["address"] + assert Address.checksum(token.contract_address_hash) == json["address_hash"] assert to_string(token.symbol) == json["symbol"] assert to_string(token.name) == json["name"] assert to_string(token.type) == json["type"] assert to_string(token.decimals) == json["decimals"] - assert (token.holder_count && to_string(token.holder_count)) == json["holders"] + assert (token.holder_count && to_string(token.holder_count)) == json["holders_count"] assert Map.has_key?(json, "exchange_rate") end @@ -3768,6 +5900,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do assert to_string(log.transaction_hash) == json["transaction_hash"] assert json["block_number"] == log.block_number assert json["block_hash"] == to_string(log.block_hash) + assert json["block_timestamp"] != nil end defp compare_item(%Withdrawal{} = withdrawal, json) do @@ -3791,7 +5924,7 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do "id" => ^id, "metadata" => ^metadata, "owner" => nil, - "token" => %{"address" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, + "token" => %{"address_hash" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, "external_app_url" => ^app_url, "animation_url" => ^animation_url, "image_url" => ^image_url, @@ -3815,11 +5948,50 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end) assert %{ - "token" => %{"address" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, + "token" => %{"address_hash" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, "amount" => ^amount } = json end + defp compare_item(%BeaconDeposit{} = deposit, json) do + index = deposit.index + transaction_hash = to_string(deposit.transaction_hash) + block_hash = to_string(deposit.block_hash) + block_number = deposit.block_number + pubkey = to_string(deposit.pubkey) + withdrawal_credentials = to_string(deposit.withdrawal_credentials) + signature = to_string(deposit.signature) + from_address_hash = Address.checksum(deposit.from_address_hash) + + if deposit.withdrawal_address_hash do + withdrawal_address_hash = Address.checksum(deposit.withdrawal_address_hash) + + assert %{ + "index" => ^index, + "transaction_hash" => ^transaction_hash, + "block_hash" => ^block_hash, + "block_number" => ^block_number, + "pubkey" => ^pubkey, + "withdrawal_credentials" => ^withdrawal_credentials, + "withdrawal_address" => %{"hash" => ^withdrawal_address_hash}, + "signature" => ^signature, + "from_address" => %{"hash" => ^from_address_hash} + } = json + else + assert %{ + "index" => ^index, + "transaction_hash" => ^transaction_hash, + "block_hash" => ^block_hash, + "block_number" => ^block_number, + "pubkey" => ^pubkey, + "withdrawal_credentials" => ^withdrawal_credentials, + "withdrawal_address" => nil, + "signature" => ^signature, + "from_address" => %{"hash" => ^from_address_hash} + } = json + end + end + defp compare_item({token, amount, token_instances}, json) do token_type = token.type token_address_hash = Address.checksum(token.contract_address_hash) @@ -3836,11 +6008,26 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do end) assert %{ - "token" => %{"address" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, + "token" => %{"address_hash" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, "amount" => ^amount } = json end + defp compare_item(%TokenTransfer{} = token_transfer, json, allow_nil_method? \\ false) do + assert Address.checksum(token_transfer.from_address_hash) == json["from"]["hash"] + assert Address.checksum(token_transfer.to_address_hash) == json["to"]["hash"] + assert to_string(token_transfer.transaction_hash) == to_string(json["transaction_hash"]) + assert json["timestamp"] != nil + + if not allow_nil_method? do + assert json["method"] != nil + end + + assert to_string(token_transfer.block_hash) == json["block_hash"] + assert token_transfer.log_index == json["log_index"] + assert check_total(Repo.preload(token_transfer, [{:token, :contract_address}]).token, json["total"], token_transfer) + end + defp compare_token_instance_in_collection(%Instance{token: %Token{} = token} = instance, json) do token_type = token.type value = to_string(value(token.type, instance)) @@ -3867,6 +6054,35 @@ defmodule BlockScoutWeb.API.V2.AddressControllerTest do defp value("ERC-721", _), do: 1 defp value(_, nft), do: nft.current_token_balance.value + defp sort_transactions_by_value(transactions, order) do + Enum.sort(transactions, fn a, b -> + case Decimal.compare(Wei.to(a.value, :wei), Wei.to(b.value, :wei)) do + :lt -> order == :asc + :gt -> order == :desc + :eq -> compare_transactions_default_order(a, b) + end + end) + end + + defp compare_transactions_default_order(a, b) do + case { + compare_values(a.block_number, b.block_number), + compare_values(a.index, b.index), + DateTime.compare(a.inserted_at, b.inserted_at), + compare_values(to_string(a.hash), to_string(b.hash)) + } do + {:lt, _, _, _} -> false + {:eq, :lt, _, _} -> false + {:eq, :eq, :lt, _} -> false + {:eq, :eq, :eq, :gt} -> false + _ -> true + end + end + + defp compare_values(a, b) when a < b, do: :lt + defp compare_values(a, b) when a > b, do: :gt + defp compare_values(_, _), do: :eq + defp check_paginated_response(first_page_resp, second_page_resp, list) do assert Enum.count(first_page_resp["items"]) == 50 assert first_page_resp["next_page_params"] != nil diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs index 17c301b44dec..8e08e654111d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/advanced_filter_controller_test.exs @@ -1,13 +1,97 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do use BlockScoutWeb.ConnCase - import Mox - alias Explorer.Chain.SmartContract alias Explorer.Chain.{AdvancedFilter, Data, Hash} alias Explorer.{Factory, TestHelper} describe "/advanced_filters" do + test "get token-transfers with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = insert(:transaction) |> with_block() + + insert(:token_transfer, transaction: transaction) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/advanced-filters", %{"transaction_types" => "ERC-20,ERC-404,ERC-721,ERC-1155,ERC-7984"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == + conn + |> get("/api/v2/advanced-filters", %{"transaction_types" => "ERC-20,ERC-404,ERC-721,ERC-1155,ERC-7984"}) + |> json_response(200) + end + + test "get smart-contract with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = insert(:transaction) |> with_block() + + tt = insert(:token_transfer, transaction: transaction) + insert(:scam_badge_to_address, address_hash: tt.token_contract_address_hash) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/advanced-filters", %{"transaction_types" => "ERC-20,ERC-404,ERC-721,ERC-1155,ERC-7984"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = + conn |> get("/api/v2/advanced-filters", %{"transaction_types" => "ERC-20,ERC-404,ERC-721,ERC-1155,ERC-7984"}) + + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token-transfers with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = insert(:transaction) |> with_block() + + insert(:token_transfer, transaction: transaction) + + request = + conn |> get("/api/v2/advanced-filters", %{"transaction_types" => "ERC-20,ERC-404,ERC-721,ERC-1155,ERC-7984"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get token-transfers with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = insert(:transaction) |> with_block() + tt = insert(:token_transfer, transaction: transaction) + insert(:scam_badge_to_address, address_hash: tt.token_contract_address_hash) + + request = + conn |> get("/api/v2/advanced-filters", %{"transaction_types" => "ERC-20,ERC-404,ERC-721,ERC-1155,ERC-7984"}) + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + test "empty list", %{conn: conn} do request = get(conn, "/api/v2/advanced-filters") assert response = json_response(request, 200) @@ -15,6 +99,23 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert response["next_page_params"] == nil end + # Regression: keyset cursor params accept the literal string "null" in addition + # to an empty string, signalling that the previous-page item was not of the + # corresponding kind (internal transaction / token transfer / batch item). + # Locks in the second branch of `General.IntegerStringOrEmptyOrNullLiteral`. + test "accepts the literal \"null\" in keyset cursor params", %{conn: conn} do + cursor_params = %{ + "block_number" => "0", + "transaction_index" => "0", + "internal_transaction_index" => "null", + "token_transfer_index" => "null", + "token_transfer_batch_index" => "null" + } + + request = get(conn, "/api/v2/advanced-filters", cursor_params) + assert json_response(request, 200) + end + test "get and paginate advanced filter (transactions split between pages)", %{conn: conn} do first_transaction = :transaction |> insert() |> with_block() insert_list(3, :token_transfer, transaction: first_transaction) @@ -22,9 +123,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do for i <- 1..3 do insert(:internal_transaction, transaction: first_transaction, - block_hash: first_transaction.block_hash, - index: i, - block_index: i + block_number: first_transaction.block_number, + transaction_index: first_transaction.index, + index: i ) end @@ -45,9 +146,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do for i <- 1..3 do insert(:internal_transaction, transaction: first_transaction, - block_hash: first_transaction.block_hash, - index: i, - block_index: i + block_number: first_transaction.block_number, + transaction_index: first_transaction.index, + index: i ) end @@ -69,9 +170,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do for i <- 1..3 do insert(:internal_transaction, transaction: first_transaction, - block_hash: first_transaction.block_hash, - index: i, - block_index: i + block_number: first_transaction.block_number, + transaction_index: first_transaction.index, + index: i ) end @@ -100,9 +201,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do for i <- 1..3 do insert(:internal_transaction, transaction: first_transaction, - block_hash: first_transaction.block_hash, - index: i, - block_index: i + block_number: first_transaction.block_number, + transaction_index: first_transaction.index, + index: i ) end @@ -111,9 +212,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do for i <- 1..50 do insert(:internal_transaction, transaction: second_transaction, - block_hash: second_transaction.block_hash, - index: i, - block_index: i + block_number: second_transaction.block_number, + transaction_index: second_transaction.index, + index: i ) end @@ -130,7 +231,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction = insert(:transaction) |> with_block() - for token_type <- ~w(ERC-20 ERC-404 ERC-721 ERC-1155), + for token_type <- ~w(ERC-20 ERC-404 ERC-721 ERC-1155 ERC-7984), token = insert(:token, type: token_type), _ <- 0..4 do insert(:token_transfer, @@ -147,14 +248,14 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do for i <- 1..30 do insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i, - block_index: i + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i ) end for transaction_type_filter_string <- - ~w(COIN_TRANSFER COIN_TRANSFER,ERC-404 ERC-721,ERC-1155 ERC-20,COIN_TRANSFER,ERC-1155) do + ~w(COIN_TRANSFER COIN_TRANSFER,ERC-404 ERC-721,ERC-1155 ERC-20,COIN_TRANSFER,ERC-1155 ERC-7984) do transaction_type_filter = transaction_type_filter_string |> String.split(",") request = get(conn, "/api/v2/advanced-filters", %{"transaction_types" => transaction_type_filter_string}) assert response = json_response(request, 200) @@ -184,13 +285,197 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do end end + test "filter by COIN_TRANSFER transaction_type", %{conn: conn} do + for i <- 1..50 do + value = if i < 20, do: 0, else: 1 + transaction = insert(:transaction, value: value) |> with_block() + + insert(:internal_transaction, + transaction: transaction, + value: value, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + ) + end + + request = get(conn, "/api/v2/advanced-filters", %{"transaction_types" => "coin_transfer"}) + assert response = json_response(request, 200) + + assert Enum.all?(response["items"], fn item -> + String.upcase(item["type"]) == "COIN_TRANSFER" and item["value"] > 0 + end) + + request_2nd_page = + get( + conn, + "/api/v2/advanced-filters", + Map.merge(%{"transaction_types" => "coin_transfer"}, response["next_page_params"]) + ) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 12 + + assert Enum.all?(response_2nd_page["items"], fn item -> + String.upcase(item["type"]) == "COIN_TRANSFER" and item["value"] > 0 + end) + + check_paginated_response( + AdvancedFilter.list(transaction_types: ["COIN_TRANSFER"]), + response["items"], + response_2nd_page["items"] + ) + end + + test "filter by CONTRACT_INTERACTION transaction_type", %{conn: conn} do + contract_address = + insert(:address, contract_code: Factory.contract_code_info().bytecode) + + for i <- 1..50 do + if i < 20 do + transaction = insert(:transaction) |> with_block() + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + ) + else + transaction = + insert(:transaction, to_address_hash: contract_address.hash, to_address: contract_address) |> with_block() + + insert(:internal_transaction, + transaction: transaction, + to_address_hash: contract_address.hash, + to_address: contract_address, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + ) + end + end + + request = get(conn, "/api/v2/advanced-filters", %{"transaction_types" => "contract_interaction"}) + assert response = json_response(request, 200) + + assert Enum.all?(response["items"], fn item -> + item["to"]["hash"] == to_string(contract_address) + end) + + request_2nd_page = + get( + conn, + "/api/v2/advanced-filters", + Map.merge(%{"transaction_types" => "contract_interaction"}, response["next_page_params"]) + ) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 12 + + assert Enum.all?(response_2nd_page["items"], fn item -> + item["to"]["hash"] == to_string(contract_address) + end) + + check_paginated_response( + AdvancedFilter.list(transaction_types: ["CONTRACT_INTERACTION"]), + response["items"], + response_2nd_page["items"] + ) + end + + test "filter by CONTRACT_CREATION transaction_type", %{conn: conn} do + for i <- 1..62 do + address = insert(:address, contract_code: Factory.contract_code_info().bytecode) + + if i < 20 do + transaction = insert(:transaction) |> with_block() + + insert(:internal_transaction_create, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + created_contract_address: address, + to_address_hash: nil, + to_address: nil, + index: i + ) + else + transaction = + insert(:transaction, + created_contract_address: address, + created_contract_address_hash: address.hash, + to_address_hash: nil, + to_address: nil + ) + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + ) + end + end + + request = get(conn, "/api/v2/advanced-filters", %{"transaction_types" => "contract_creation"}) + assert response = json_response(request, 200) + + assert Enum.all?(response["items"], fn item -> + is_nil(item["to"]) and not is_nil(item["created_contract"]) + end) + + request_2nd_page = + get( + conn, + "/api/v2/advanced-filters", + Map.merge(%{"transaction_types" => "contract_creation"}, response["next_page_params"]) + ) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 12 + + assert Enum.all?(response_2nd_page["items"], fn item -> + is_nil(item["to"]) and not is_nil(item["created_contract"]) + end) + + check_paginated_response( + AdvancedFilter.list(transaction_types: ["CONTRACT_CREATION"]), + response["items"], + response_2nd_page["items"] + ) + end + test "filter by methods", %{conn: conn} do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() transaction = :transaction |> insert() |> with_block() smart_contract = build(:smart_contract) + abi = + %{ + "constant" => false, + "inputs" => [%{"name" => "x", "type" => "uint64"}, %{"name" => "y", "type" => "address"}], + "name" => "getAccess", + "outputs" => [], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + + [parsed_method] = ABI.parse_specification([abi]) + + insert(:contract_method, + abi: abi, + identifier: parsed_method.method_id + ) + contract_address = insert(:address, hash: address_hash(), @@ -201,7 +486,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do method_id1_string = "0xa9059cbb" method_id2_string = "0xa0712d68" - method_id3_string = "0x095ea7b3" + method_id3_string = "0x3078f114" method_id4_string = "0x40993b26" {:ok, method1} = Data.cast(method_id1_string <> "ab0ba0") @@ -214,9 +499,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: contract_address.hash, to_address: contract_address, - block_hash: transaction.block_hash, + block_number: transaction.block_number, + transaction_index: transaction.index, index: i, - block_index: i, input: method1 ) end @@ -226,9 +511,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: contract_address.hash, to_address: contract_address, - block_hash: transaction.block_hash, + block_number: transaction.block_number, + transaction_index: transaction.index, index: i, - block_index: i, input: method2 ) end @@ -243,18 +528,28 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do method3_transaction = :transaction - |> insert(to_address_hash: contract_address.hash, to_address: contract_address, input: method3) + |> insert( + to_address_hash: contract_address.hash, + to_address: contract_address, + input: method3, + has_token_transfers: true + ) |> with_block() method4_transaction = :transaction - |> insert(to_address_hash: contract_address.hash, to_address: contract_address, input: method4) + |> insert( + to_address_hash: contract_address.hash, + to_address: contract_address, + input: method4, + has_token_transfers: true + ) |> with_block() 5 |> insert_list(:token_transfer, transaction: method3_transaction) 5 |> insert_list(:token_transfer, transaction: method4_transaction) - request = get(conn, "/api/v2/advanced-filters", %{"methods" => "0xa0712d68,0x095ea7b3"}) + request = get(conn, "/api/v2/advanced-filters", %{"methods" => "0xa0712d68,0x3078f114"}) assert response = json_response(request, 200) assert Enum.all?(response["items"], fn item -> @@ -264,6 +559,79 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert Enum.count(response["items"]) == 21 end + test "filter by methods_names alone", %{conn: conn} do + contract_address = insert(:contract_address) + transfer_selector = "0xa9059cbb" + mint_selector = "0xa0712d68" + {:ok, transfer_input} = Data.cast(transfer_selector <> "ab0ba0") + {:ok, mint_input} = Data.cast(mint_selector <> "ab0ba0") + + 3 + |> insert_list(:transaction, + to_address: contract_address, + to_address_hash: contract_address.hash, + input: transfer_input + ) + |> with_block() + + 5 + |> insert_list(:transaction, + to_address: contract_address, + to_address_hash: contract_address.hash, + input: mint_input + ) + |> with_block() + + request = get(conn, "/api/v2/advanced-filters", %{"methods_names" => "transfer"}) + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 3 + + assert Enum.all?(response["items"], fn item -> + String.slice(item["method"], 0..9) == transfer_selector + end) + end + + test "filter by methods_names merges with methods param", %{conn: conn} do + contract_address = insert(:contract_address) + transfer_selector = "0xa9059cbb" + mint_selector = "0xa0712d68" + {:ok, transfer_input} = Data.cast(transfer_selector <> "ab0ba0") + {:ok, mint_input} = Data.cast(mint_selector <> "ab0ba0") + + 3 + |> insert_list(:transaction, + to_address: contract_address, + to_address_hash: contract_address.hash, + input: transfer_input + ) + |> with_block() + + 5 + |> insert_list(:transaction, + to_address: contract_address, + to_address_hash: contract_address.hash, + input: mint_input + ) + |> with_block() + + 2 |> insert_list(:transaction) |> with_block() + + request = + get(conn, "/api/v2/advanced-filters", %{ + "methods_names" => "transfer", + "methods" => mint_selector + }) + + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 8 + + assert Enum.all?(response["items"], fn item -> + String.slice(item["method"], 0..9) in [transfer_selector, mint_selector] + end) + end + test "filter by age", %{conn: conn} do [_, transaction_a, _, transaction_b, _] = for i <- 0..4 do @@ -271,9 +639,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do insert(:internal_transaction, transaction: tx, + transaction_index: tx.index, index: i + 1, - block_index: i + 1, - block_hash: tx.block_hash, + block_number: tx.block_number, block: tx.block ) @@ -312,9 +680,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, from_address_hash: address.hash, from_address: address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -327,9 +695,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do else insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -356,9 +724,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, from_address_hash: address.hash, from_address: address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -371,9 +739,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do else insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -406,9 +774,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, from_address_hash: address_to_include.hash, from_address: address_to_include, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -421,9 +789,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do else insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -454,9 +822,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: address.hash, to_address: address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -469,9 +837,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do else insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -498,9 +866,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: address.hash, to_address: address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -513,9 +881,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do else insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -548,9 +916,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: address_to_include.hash, to_address: address_to_include, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -563,9 +931,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do else insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -598,9 +966,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, from_address_hash: from_address.hash, from_address: from_address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -618,9 +986,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: to_address.hash, to_address: to_address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -647,9 +1015,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do to_address: to_address, from_address_hash: from_address.hash, from_address: from_address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -665,9 +1033,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do true -> insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -686,6 +1054,94 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert Enum.count(response["items"]) == 6 end + test "filter by from and to address (intersect corner case)", %{conn: conn} do + from_address = insert(:address) + to_address = insert(:address) + + transaction = + :transaction + |> insert( + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash + ) + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: 51, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash + ) + + insert(:token_transfer, + transaction: transaction, + block_number: transaction.block_number, + log_index: 51, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash + ) + + for i <- 0..50 do + transaction = + :transaction |> insert(from_address: from_address, from_address_hash: from_address.hash) |> with_block() + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1, + from_address: from_address, + from_address_hash: from_address.hash + ) + + insert(:token_transfer, + transaction: transaction, + block_number: transaction.block_number, + log_index: i, + from_address: from_address, + from_address_hash: from_address.hash + ) + + transaction = :transaction |> insert(to_address: to_address, to_address_hash: to_address.hash) |> with_block() + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1, + to_address: to_address, + to_address_hash: to_address.hash + ) + + insert(:token_transfer, + transaction: transaction, + block_number: transaction.block_number, + log_index: i, + to_address: to_address, + to_address_hash: to_address.hash + ) + end + + request = + get(conn, "/api/v2/advanced-filters", %{ + "from_address_hashes_to_include" => to_string(from_address.hash), + "to_address_hashes_to_include" => to_string(to_address.hash), + "address_relation" => "AnD" + }) + + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 3 + end + test "filter by from or to address", %{conn: conn} do from_address = insert(:address) to_address = insert(:address) @@ -701,9 +1157,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, from_address_hash: from_address.hash, from_address: from_address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -721,9 +1177,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do transaction: transaction, to_address_hash: to_address.hash, to_address: to_address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -750,9 +1206,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do to_address: to_address, from_address_hash: from_address.hash, from_address: from_address, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, @@ -768,9 +1224,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do true -> insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, - index: i + 1, - block_index: i + 1 + block_number: transaction.block_number, + transaction_index: transaction.index, + index: i + 1 ) insert(:token_transfer, transaction: transaction, block_number: transaction.block_number, log_index: i) @@ -794,9 +1250,9 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do insert(:internal_transaction, transaction: transaction, - block_hash: transaction.block_hash, + block_number: transaction.block_number, + transaction_index: transaction.index, index: 1, - block_index: 1, value: i * 10 ** 18 ) @@ -947,7 +1403,8 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do to_address: transaction_to_address, to_address_hash: transaction_to_address.hash, value: Enum.random(0..1_000_000), - input: method + input: method, + has_token_transfers: true ) |> with_block() @@ -972,7 +1429,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do to_timestamp = List.last(transactions).block.timestamp params = %{ - "tx_types" => "coin_transfer,ERC-20", + "transaction_types" => "COIN_TRANSFER,ERC-20", "methods" => method_id_string, "age_from" => from_timestamp |> DateTime.to_iso8601(), "age_to" => to_timestamp |> DateTime.to_iso8601(), @@ -994,7 +1451,7 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do check_paginated_response( AdvancedFilter.list( - tx_types: ["COIN_TRANSFER", "ERC-20"], + transaction_types: ["COIN_TRANSFER", "ERC-20"], methods: ["0xa9059cbb"], age: [from: from_timestamp, to: to_timestamp], from_address_hashes: [ @@ -1023,6 +1480,21 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do end end + describe "/advanced_filters/methods" do + test "returns default list of methods", %{conn: conn} do + request = get(conn, "/api/v2/advanced-filters/methods") + assert response = json_response(request, 200) + assert is_list(response) + assert length(response) > 0 + + Enum.each(response, fn method -> + assert %{"method_id" => method_id, "name" => name} = method + assert method_id =~ ~r/^0x[0-9a-f]{8}$/ + assert is_binary(name) and name != "" + end) + end + end + describe "/advanced_filters/methods?q=" do test "returns empty list if method does not exist", %{conn: conn} do request = get(conn, "/api/v2/advanced-filters/methods", %{"q" => "foo"}) @@ -1030,6 +1502,12 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert response == [] end + test "returns empty list for empty q", %{conn: conn} do + request = get(conn, "/api/v2/advanced-filters/methods", %{"q" => ""}) + assert response = json_response(request, 200) + assert response == [] + end + test "finds method by name", %{conn: conn} do insert(:contract_method) request = get(conn, "/api/v2/advanced-filters/methods", %{"q" => "set"}) @@ -1043,6 +1521,36 @@ defmodule BlockScoutWeb.API.V2.AdvancedFilterControllerTest do assert response = json_response(request, 200) assert response == [%{"method_id" => "0x60fe47b1", "name" => "set"}] end + + test "finds method with method id starting with 0x", %{conn: conn} do + abi = + %{ + "constant" => false, + "inputs" => [%{"name" => "x", "type" => "uint64"}, %{"name" => "y", "type" => "address"}], + "name" => "getAccess", + "outputs" => [], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + + [parsed_method] = ABI.parse_specification([abi]) + + insert(:contract_method, + abi: abi, + identifier: parsed_method.method_id + ) + + request = get(conn, "/api/v2/advanced-filters/methods", %{"q" => "0x3078f114"}) + assert response = json_response(request, 200) + assert response == [%{"method_id" => "0x3078f114", "name" => "getAccess"}] + end + + test "returns method id without name if q is valid method id", %{conn: conn} do + request = get(conn, "/api/v2/advanced-filters/methods", %{"q" => "0x60fe47b1"}) + assert response = json_response(request, 200) + assert response == [%{"method_id" => "0x60fe47b1", "name" => ""}] + end end defp check_paginated_response(all_advanced_filters, first_page, second_page) do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/arbitrum_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/arbitrum_controller_test.exs new file mode 100644 index 000000000000..2ce1cc99cd64 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/arbitrum_controller_test.exs @@ -0,0 +1,1150 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.ArbitrumControllerTest do + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + if @chain_type == :arbitrum do + use BlockScoutWeb.ConnCase + + alias Explorer.Chain.Arbitrum.{BatchToDaBlob, DaMultiPurposeRecord, L1Batch} + + import Explorer.Chain.Arbitrum.DaMultiPurposeRecord.Helper, only: [calculate_celestia_data_key: 2] + + describe "/main-page/arbitrum/batches/committed" do + test "returns committed batches", %{conn: conn} do + batches = insert_list(3, :arbitrum_l1_batch) + + request = get(conn, "/api/v2/main-page/arbitrum/batches/committed") + assert response = json_response(request, 200) + + assert length(response["items"]) == 3 + + # Response is ordered by batch number descending + sorted_batches = Enum.sort_by(batches, & &1.number, :desc) + + for {batch, item} <- Enum.zip(sorted_batches, response["items"]) do + compare_batch(batch, item) + end + end + + test "returns empty list when no committed batches exist", %{conn: conn} do + request = get(conn, "/api/v2/main-page/arbitrum/batches/committed") + assert response = json_response(request, 200) + assert response["items"] == [] + end + + test "returns at most 10 committed batches", %{conn: conn} do + insert_list(12, :arbitrum_l1_batch) + + request = get(conn, "/api/v2/main-page/arbitrum/batches/committed") + assert response = json_response(request, 200) + + assert length(response["items"]) == 10 + end + end + + describe "/main-page/arbitrum/messages/to-rollup" do + test "returns recent L1-to-L2 messages", %{conn: conn} do + messages = insert_list(3, :arbitrum_message, direction: :to_l2, status: :relayed) + + request = get(conn, "/api/v2/main-page/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + + assert length(response["items"]) == 3 + + sorted_messages = Enum.sort_by(messages, & &1.message_id, :desc) + + for {msg, item} <- Enum.zip(sorted_messages, response["items"]) do + assert to_string(msg.originating_transaction_hash) == item["origination_transaction_hash"] + assert to_string(msg.completion_transaction_hash) == item["completion_transaction_hash"] + assert msg.originating_transaction_block_number == item["origination_transaction_block_number"] + end + end + + test "returns empty list when no messages exist", %{conn: conn} do + request = get(conn, "/api/v2/main-page/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + assert response["items"] == [] + end + + test "returns at most 6 messages", %{conn: conn} do + insert_list(8, :arbitrum_message, direction: :to_l2, status: :relayed) + + request = get(conn, "/api/v2/main-page/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + + assert length(response["items"]) == 6 + end + end + + describe "/arbitrum/messages/:direction" do + test "returns to-rollup messages", %{conn: conn} do + messages = insert_list(3, :arbitrum_message, direction: :to_l2, status: :relayed) + + request = get(conn, "/api/v2/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + + assert length(response["items"]) == 3 + assert response["next_page_params"] == nil + + sorted_messages = Enum.sort_by(messages, & &1.message_id, :desc) + + for {msg, item} <- Enum.zip(sorted_messages, response["items"]) do + assert msg.message_id == item["id"] + assert to_string(msg.originator_address) == item["origination_address_hash"] + assert to_string(msg.originating_transaction_hash) == item["origination_transaction_hash"] + assert msg.originating_transaction_block_number == item["origination_transaction_block_number"] + assert to_string(msg.completion_transaction_hash) == item["completion_transaction_hash"] + assert to_string(msg.status) == item["status"] + end + end + + test "returns from-rollup messages", %{conn: conn} do + messages = insert_list(3, :arbitrum_message, direction: :from_l2, status: :initiated) + + request = get(conn, "/api/v2/arbitrum/messages/from-rollup") + assert response = json_response(request, 200) + + assert length(response["items"]) == 3 + assert response["next_page_params"] == nil + + sorted_messages = Enum.sort_by(messages, & &1.message_id, :desc) + + for {msg, item} <- Enum.zip(sorted_messages, response["items"]) do + assert msg.message_id == item["id"] + assert to_string(msg.status) == item["status"] + end + end + + test "returns empty list when no messages exist", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "does not include messages from opposite direction", %{conn: conn} do + insert_list(3, :arbitrum_message, direction: :from_l2, status: :initiated) + + request = get(conn, "/api/v2/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + assert response["items"] == [] + end + + test "paginates messages", %{conn: conn} do + insert_list(51, :arbitrum_message, direction: :to_l2, status: :relayed) + + request = get(conn, "/api/v2/arbitrum/messages/to-rollup") + assert response = json_response(request, 200) + assert length(response["items"]) == 50 + assert response["next_page_params"] != nil + + request_2nd_page = get(conn, "/api/v2/arbitrum/messages/to-rollup", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + assert length(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + end + + test "returns 422 for invalid direction", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + end + + describe "/arbitrum/messages/:direction/count" do + test "returns count for to-rollup messages", %{conn: conn} do + insert_list(3, :arbitrum_message, direction: :to_l2, status: :relayed) + + request = get(conn, "/api/v2/arbitrum/messages/to-rollup/count") + assert json_response(request, 200) == 3 + end + + test "returns count for from-rollup messages", %{conn: conn} do + insert_list(5, :arbitrum_message, direction: :from_l2, status: :initiated) + + request = get(conn, "/api/v2/arbitrum/messages/from-rollup/count") + assert json_response(request, 200) == 5 + end + + test "returns 0 when no messages exist", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/to-rollup/count") + assert json_response(request, 200) == 0 + end + + test "returns 422 for invalid direction", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/invalid/count") + assert %{"errors" => _} = json_response(request, 422) + end + end + + describe "/main-page/arbitrum/batches/latest-number" do + test "returns latest batch number", %{conn: conn} do + insert(:arbitrum_l1_batch, number: 5) + insert(:arbitrum_l1_batch, number: 10) + + request = get(conn, "/api/v2/main-page/arbitrum/batches/latest-number") + assert json_response(request, 200) == 10 + end + + test "returns 0 when no batches exist", %{conn: conn} do + request = get(conn, "/api/v2/main-page/arbitrum/batches/latest-number") + assert json_response(request, 200) == 0 + end + end + + describe "/arbitrum/batches" do + test "returns empty list when no batches exist", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns batches", %{conn: conn} do + batches = insert_list(3, :arbitrum_l1_batch) + + request = get(conn, "/api/v2/arbitrum/batches") + assert response = json_response(request, 200) + + assert length(response["items"]) == 3 + + sorted_batches = Enum.sort_by(batches, & &1.number, :desc) + + for {batch, item} <- Enum.zip(sorted_batches, response["items"]) do + compare_batch(batch, item) + end + end + + test "filters by batch_numbers", %{conn: conn} do + batches = insert_list(5, :arbitrum_l1_batch) + selected = Enum.take(batches, 2) + selected_numbers = Enum.map(selected, & &1.number) + + query = %{"batch_numbers" => selected_numbers} + request = get(conn, "/api/v2/arbitrum/batches", query) + assert response = json_response(request, 200) + + assert length(response["items"]) == 2 + + returned_numbers = Enum.map(response["items"], & &1["number"]) + assert Enum.sort(returned_numbers) == Enum.sort(selected_numbers) + end + + test "paginates batches", %{conn: conn} do + batches = insert_list(51, :arbitrum_l1_batch) + + request = get(conn, "/api/v2/arbitrum/batches") + assert response = json_response(request, 200) + + assert length(response["items"]) == 50 + assert response["next_page_params"] != nil + + request = get(conn, "/api/v2/arbitrum/batches", response["next_page_params"]) + assert response = json_response(request, 200) + + assert length(response["items"]) == 1 + assert response["next_page_params"] == nil + + assert length(Enum.uniq_by(batches, & &1.number)) == 51 + end + end + + describe "/arbitrum/batches/count" do + test "returns 0 when no batches exist", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches/count") + assert json_response(request, 200) == 0 + end + + # The endpoint uses get_table_rows_total_count/2 which relies on PostgreSQL's + # reltuples estimate rather than an exact COUNT. In the test database, reltuples + # is stale after inserts (ANALYZE hasn't run), so the returned count may be 0 + # instead of the actual row count. We assert the response type rather than an + # exact value; schema validation via json_response/2 is the main check here. + test "returns batches count", %{conn: conn} do + insert_list(3, :arbitrum_l1_batch) + + request = get(conn, "/api/v2/arbitrum/batches/count") + assert response = json_response(request, 200) + assert is_integer(response) and response >= 0 + end + end + + describe "/arbitrum/batches/:batch_number" do + test "returns batch by number", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + + request = get(conn, "/api/v2/arbitrum/batches/#{batch.number}") + assert response = json_response(request, 200) + + batch = Explorer.Repo.preload(batch, :commitment_transaction) + + assert response["number"] == batch.number + assert response["transactions_count"] == batch.transactions_count + assert response["start_block_number"] == batch.start_block + assert response["end_block_number"] == batch.end_block + assert response["before_acc_hash"] == to_string(batch.before_acc) + assert response["after_acc_hash"] == to_string(batch.after_acc) + + commitment_json = response["commitment_transaction"] + assert to_string(batch.commitment_transaction.hash) == commitment_json["hash"] + assert batch.commitment_transaction.block_number == commitment_json["block_number"] + assert commitment_json["status"] == "finalized" + + assert response["data_availability"] != nil + assert response["data_availability"]["batch_data_container"] == nil + end + + test "returns batch with unfinalized commitment transaction status", %{conn: conn} do + lifecycle_tx = insert(:arbitrum_lifecycle_transaction, status: :unfinalized) + batch = insert(:arbitrum_l1_batch, commitment_id: lifecycle_tx.id) + + request = get(conn, "/api/v2/arbitrum/batches/#{batch.number}") + assert response = json_response(request, 200) + + assert response["commitment_transaction"]["status"] == "unfinalized" + end + + test "returns batch with celestia data availability info", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_celestia) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("ab", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "height" => 123_456, + "transaction_commitment" => "0x" <> String.duplicate("cd", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + request = get(conn, "/api/v2/arbitrum/batches/#{batch.number}") + assert response = json_response(request, 200) + + da = response["data_availability"] + assert da["batch_data_container"] == "in_celestia" + assert da["height"] == 123_456 + assert da["transaction_commitment"] == "0x" <> String.duplicate("cd", 32) + end + + test "returns batch with in_blob4844 data availability", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_blob4844) + + request = get(conn, "/api/v2/arbitrum/batches/#{batch.number}") + assert response = json_response(request, 200) + + da = response["data_availability"] + assert da["batch_data_container"] == "in_blob4844" + end + + test "returns batch with anytrust data availability info", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_anytrust) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("a1", 32)) + {:ok, keyset_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("b2", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "keyset_hash" => "0x" <> String.duplicate("b2", 32), + "data_hash" => "0x" <> String.duplicate("c3", 32), + "timeout" => "2024-10-01T12:00:00Z", + "signers_mask" => 3, + "bls_signature" => "0x" <> String.duplicate("d4", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: keyset_key, + data_type: 1, + data: %{ + "threshold" => 1, + "pubkeys" => [ + %{"trusted" => true, "key" => "0x" <> String.duplicate("e5", 32)}, + %{ + "trusted" => false, + "key" => "0x" <> String.duplicate("f6", 32), + "proof" => "0x" <> String.duplicate("07", 32) + } + ] + } + }) + + request = get(conn, "/api/v2/arbitrum/batches/#{batch.number}") + assert response = json_response(request, 200) + + da = response["data_availability"] + assert da["batch_data_container"] == "in_anytrust" + assert da["data_hash"] == "0x" <> String.duplicate("c3", 32) + assert da["timeout"] == "2024-10-01T12:00:00Z" + assert da["bls_signature"] == "0x" <> String.duplicate("d4", 32) + assert length(da["signers"]) == 2 + + [signer1, signer2] = da["signers"] + assert signer1["trusted"] == true + assert signer1["key"] == "0x" <> String.duplicate("e5", 32) + assert signer2["trusted"] == false + assert signer2["proof"] != nil + end + + test "returns batch with eigenda data availability info", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_eigenda) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("e1", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "blob_header" => "0x" <> String.duplicate("f2", 32), + "blob_verification_proof" => "0x" <> String.duplicate("03", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + request = get(conn, "/api/v2/arbitrum/batches/#{batch.number}") + assert response = json_response(request, 200) + + da = response["data_availability"] + assert da["batch_data_container"] == "in_eigenda" + assert da["blob_header"] == "0x" <> String.duplicate("f2", 32) + assert da["blob_verification_proof"] == "0x" <> String.duplicate("03", 32) + end + + test "returns 404 for non-existing batch", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches/0") + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 422 for invalid batch number", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + end + + describe "/arbitrum/messages/claim/:message_id" do + test "returns 400 for already relayed withdrawal", %{conn: conn} do + message_id = 42 + + transaction = insert(:transaction) |> with_block() + + insert(:arbitrum_message, + direction: :from_l2, + message_id: message_id, + originating_transaction_hash: transaction.hash, + status: :relayed + ) + + {:ok, fourth_topic} = + Explorer.Chain.Hash.Full.cast("0x" <> String.pad_leading(Integer.to_string(message_id, 16), 64, "0")) + + {:ok, second_topic} = + Explorer.Chain.Hash.Full.cast("0x" <> String.pad_leading("dead", 64, "0")) + + # ABI-encode unindexed params: [caller, arb_block_number, eth_block_number, timestamp, callvalue, data] + log_data_bin = + ABI.TypeEncoder.encode_raw( + [<<0::160>>, 1, 2, 3, 0, <<>>], + [:address, {:uint, 256}, {:uint, 256}, {:uint, 256}, {:uint, 256}, :bytes], + :standard + ) + + {:ok, data} = Explorer.Chain.Data.cast("0x" <> Base.encode16(log_data_bin, case: :lower)) + + insert(:log, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + first_topic: "0x3e7aafa77dbf186b7fd488006beff893744caa3c4f6f299e8a709fa2087374fc", + second_topic: second_topic, + fourth_topic: fourth_topic, + data: data + ) + + request = get(conn, "/api/v2/arbitrum/messages/claim/#{message_id}") + assert %{"message" => "withdrawal was executed already"} = json_response(request, 400) + end + + test "returns 404 for non-existing message", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/claim/0") + assert %{"message" => _} = json_response(request, 404) + end + + test "returns 422 for invalid message id", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/claim/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + end + + describe "/arbitrum/messages/withdrawals/:transaction_hash" do + test "returns empty list for a transaction with no withdrawals", %{conn: conn} do + transaction = insert(:transaction) + + request = get(conn, "/api/v2/arbitrum/messages/withdrawals/#{transaction.hash}") + assert response = json_response(request, 200) + assert response["items"] == [] + end + + test "returns 422 for invalid transaction hash", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/messages/withdrawals/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + + # Native ETH withdrawal whose DB-side message is already :relayed. + # The renderer skips the L1 RPC status check entirely (see + # `ClaimRollupMessage.log_to_withdrawal/2`), and `obtain_token_withdrawal_data/1` + # returns nil because the L2ToL1Tx event data does not start with the + # `finalizeInboundTransfer` selector. So no L1 RPC mocking is required. + test "returns native ETH withdrawal with status :relayed (no token)", %{conn: conn} do + transaction = insert(:transaction) |> with_block() + + message_id = 100 + completion_transaction_hash = transaction_hash() + + insert(:arbitrum_message, + direction: :from_l2, + message_id: message_id, + originating_transaction_hash: transaction.hash, + completion_transaction_hash: completion_transaction_hash, + status: :relayed + ) + + callvalue = 1_000_000_000_000_000 + + insert_l2_to_l1_log!(transaction, + message_id: message_id, + callvalue: callvalue, + data: <<>> + ) + + request = get(conn, "/api/v2/arbitrum/messages/withdrawals/#{transaction.hash}") + assert response = json_response(request, 200) + + assert [item] = response["items"] + assert item["id"] == message_id + assert item["status"] == "relayed" + assert item["callvalue"] == Integer.to_string(callvalue) + assert item["token"] == nil + assert item["completion_transaction_hash"] == to_string(completion_transaction_hash) + end + + # ERC20 token withdrawal whose DB-side message is already :relayed. + # Status check is skipped, but `obtain_token_withdrawal_data/1` decodes the + # `finalizeInboundTransfer(...)` calldata and calls `ERC20.fetch_token_properties/3` + # against the L1 RPC for `name`/`symbol`/`decimals`. We mock those via Mox. + test "returns ERC20 withdrawal with token sub-object and status :relayed", %{conn: conn} do + setup_arbitrum_l1_rpc_mocks!() + + transaction = insert(:transaction) |> with_block() + + message_id = 160_586 + completion_transaction_hash = transaction_hash() + + insert(:arbitrum_message, + direction: :from_l2, + message_id: message_id, + originating_transaction_hash: transaction.hash, + completion_transaction_hash: completion_transaction_hash, + status: :relayed + ) + + # UNI on Ethereum mainnet — chosen from real Arbitrum withdrawal data + # (tx 0x692ebe...557021, position 160586) discovered via the Blockscout + # MCP server. Token metadata returned by the mock matches L1 reality. + l1_token_address = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" + l1_recipient_address = "0xb8018422bce25d82e70cb98fda96a4f502d89427" + amount = 0x6C6B935B8BBD400000 + + insert_l2_to_l1_log!(transaction, + message_id: message_id, + data: build_finalize_inbound_transfer_calldata(l1_token_address, l1_recipient_address, amount) + ) + + expect_erc20_metadata!(l1_token_address, name: "Uniswap", symbol: "UNI", decimals: 18) + + request = get(conn, "/api/v2/arbitrum/messages/withdrawals/#{transaction.hash}") + assert response = json_response(request, 200) + + assert [item] = response["items"] + assert item["status"] == "relayed" + + token = item["token"] + assert token != nil + assert String.downcase(token["address_hash"]) == l1_token_address + assert String.downcase(token["destination_address_hash"]) == l1_recipient_address + assert token["amount"] == Integer.to_string(amount) + assert token["name"] == "Uniswap" + assert token["symbol"] == "UNI" + assert token["decimals"] == 18 + end + + # Native ETH withdrawal whose DB-side message is :initiated. + # Forces `get_actual_message_status/1` to run: it calls Outbox.outbox()/ + # sequencerInbox() (batched) plus Outbox.isSpent(message_id) on the L1 RPC, + # then compares message_id to the highest-confirmed-block's `send_count` + # (sourced from the DB). `send_count > message_id` resolves to :confirmed. + test "returns withdrawal with status :confirmed when isSpent=false and send_count > message_id", + %{conn: conn} do + setup_arbitrum_l1_rpc_mocks!() + + transaction = insert(:transaction) |> with_block() + message_id = 50 + + insert(:arbitrum_message, + direction: :from_l2, + message_id: message_id, + originating_transaction_hash: transaction.hash, + status: :initiated + ) + + insert_l2_to_l1_log!(transaction, message_id: message_id, callvalue: 1_000) + + # send_count > message_id ⇒ :confirmed + seed_highest_confirmed_block!(message_id + 50) + + outbox_address = "0x" <> String.duplicate("ab", 20) + expect_inbox_outbox_query!(outbox_address) + expect_outbox_is_spent!(outbox_address, message_id, false) + + request = get(conn, "/api/v2/arbitrum/messages/withdrawals/#{transaction.hash}") + assert response = json_response(request, 200) + + assert [item] = response["items"] + assert item["id"] == message_id + assert item["status"] == "confirmed" + end + + # Same machinery as the :confirmed test, but `send_count <= message_id` + # so `get_actual_message_status/1` resolves to :sent. + test "returns withdrawal with status :sent when isSpent=false and send_count <= message_id", + %{conn: conn} do + setup_arbitrum_l1_rpc_mocks!() + + transaction = insert(:transaction) |> with_block() + message_id = 100 + + insert(:arbitrum_message, + direction: :from_l2, + message_id: message_id, + originating_transaction_hash: transaction.hash, + status: :initiated + ) + + insert_l2_to_l1_log!(transaction, message_id: message_id, callvalue: 1_000) + + # send_count <= message_id ⇒ :sent + seed_highest_confirmed_block!(message_id) + + outbox_address = "0x" <> String.duplicate("ab", 20) + expect_inbox_outbox_query!(outbox_address) + expect_outbox_is_spent!(outbox_address, message_id, false) + + request = get(conn, "/api/v2/arbitrum/messages/withdrawals/#{transaction.hash}") + assert response = json_response(request, 200) + + assert [item] = response["items"] + assert item["id"] == message_id + assert item["status"] == "sent" + end + + # The `:unknown` status branch of `get_actual_message_status/1` is not covered. + # It requires the Outbox `isSpent` check to return false AND `get_size_for_proof/0` + # to return nil — which only happens when both the DB lookup (no confirmed block + # linked to an L1 batch) AND the RPC fallback (multi-step L1/L2 calls to resolve + # a send-count) fail. Exercising that fallback requires mocking several additional + # Arbitrum L1/L2 RPC endpoints beyond the ones set up here. + end + + describe "/arbitrum/batches/da/anytrust/:data_hash" do + test "returns batch by anytrust data hash", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_anytrust) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("a1", 32)) + {:ok, keyset_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("b2", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "keyset_hash" => "0x" <> String.duplicate("b2", 32), + "data_hash" => "0x" <> String.duplicate("c3", 32), + "timeout" => "2024-10-01T12:00:00Z", + "signers_mask" => 3, + "bls_signature" => "0x" <> String.duplicate("d4", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: keyset_key, + data_type: 1, + data: %{ + "threshold" => 1, + "pubkeys" => [ + %{"trusted" => true, "key" => "0x" <> String.duplicate("e5", 32)}, + %{ + "trusted" => false, + "key" => "0x" <> String.duplicate("f6", 32), + "proof" => "0x" <> String.duplicate("07", 32) + } + ] + } + }) + + request = get(conn, "/api/v2/arbitrum/batches/da/anytrust/#{data_key}") + assert response = json_response(request, 200) + + assert response["number"] == batch.number + + da = response["data_availability"] + assert da["batch_data_container"] == "in_anytrust" + assert da["data_hash"] == "0x" <> String.duplicate("c3", 32) + assert da["bls_signature"] == "0x" <> String.duplicate("d4", 32) + assert length(da["signers"]) == 2 + end + + test "returns paginated batch list with type=all", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_anytrust) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("a1", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "keyset_hash" => "0x" <> String.duplicate("b2", 32), + "data_hash" => "0x" <> String.duplicate("c3", 32), + "timeout" => "2024-10-01T12:00:00Z", + "signers_mask" => 1, + "bls_signature" => "0x" <> String.duplicate("d4", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + request = get(conn, "/api/v2/arbitrum/batches/da/anytrust/#{data_key}", %{"type" => "all"}) + assert response = json_response(request, 200) + + assert length(response["items"]) == 1 + assert response["next_page_params"] == nil + end + + test "returns 404 for non-existing data hash", %{conn: conn} do + data_hash = "0x" <> String.duplicate("00", 32) + + request = get(conn, "/api/v2/arbitrum/batches/da/anytrust/#{data_hash}") + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 404 for non-existing data hash with type=all", %{conn: conn} do + data_hash = "0x" <> String.duplicate("00", 32) + + request = get(conn, "/api/v2/arbitrum/batches/da/anytrust/#{data_hash}", %{"type" => "all"}) + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 422 for invalid data hash", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches/da/anytrust/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + end + + describe "/arbitrum/batches/da/eigenda/:data_hash" do + test "returns batch by eigenda data hash", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_eigenda) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("e1", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "blob_header" => "0x" <> String.duplicate("f2", 32), + "blob_verification_proof" => "0x" <> String.duplicate("03", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + request = get(conn, "/api/v2/arbitrum/batches/da/eigenda/#{data_key}") + assert response = json_response(request, 200) + + assert response["number"] == batch.number + + da = response["data_availability"] + assert da["batch_data_container"] == "in_eigenda" + assert da["blob_header"] == "0x" <> String.duplicate("f2", 32) + assert da["blob_verification_proof"] == "0x" <> String.duplicate("03", 32) + end + + test "returns paginated batch list with type=all", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_eigenda) + + {:ok, data_key} = Explorer.Chain.Hash.Full.cast("0x" <> String.duplicate("e1", 32)) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "blob_header" => "0x" <> String.duplicate("f2", 32), + "blob_verification_proof" => "0x" <> String.duplicate("03", 32) + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + request = get(conn, "/api/v2/arbitrum/batches/da/eigenda/#{data_key}", %{"type" => "all"}) + assert response = json_response(request, 200) + + assert length(response["items"]) == 1 + assert response["next_page_params"] == nil + end + + test "returns 404 for non-existing data hash", %{conn: conn} do + data_hash = "0x" <> String.duplicate("00", 32) + + request = get(conn, "/api/v2/arbitrum/batches/da/eigenda/#{data_hash}") + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 404 for non-existing data hash with type=all", %{conn: conn} do + data_hash = "0x" <> String.duplicate("00", 32) + + request = get(conn, "/api/v2/arbitrum/batches/da/eigenda/#{data_hash}", %{"type" => "all"}) + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 422 for invalid data hash", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches/da/eigenda/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + end + + describe "/arbitrum/batches/da/celestia/:height/:transaction_commitment" do + test "returns batch by celestia blob reference", %{conn: conn} do + batch = insert(:arbitrum_l1_batch, batch_container: :in_celestia) + + height = 123_456 + commitment_hex = "0x" <> String.duplicate("cd", 32) + {:ok, commitment_hash} = Explorer.Chain.Hash.Full.cast(commitment_hex) + raw_key = calculate_celestia_data_key(height, commitment_hash) + hex_key = "0x" <> Base.encode16(raw_key, case: :lower) + {:ok, data_key} = Explorer.Chain.Hash.Full.cast(hex_key) + + Explorer.Repo.insert!(%DaMultiPurposeRecord{ + data_key: data_key, + data_type: 0, + batch_number: batch.number, + data: %{ + "height" => height, + "transaction_commitment" => commitment_hex + } + }) + + Explorer.Repo.insert!(%BatchToDaBlob{ + batch_number: batch.number, + data_blob_id: data_key + }) + + request = get(conn, "/api/v2/arbitrum/batches/da/celestia/#{height}/#{commitment_hex}") + assert response = json_response(request, 200) + + assert response["number"] == batch.number + + da = response["data_availability"] + assert da["batch_data_container"] == "in_celestia" + assert da["height"] == height + assert da["transaction_commitment"] == commitment_hex + end + + test "returns 404 for non-existing celestia blob reference", %{conn: conn} do + commitment_hex = "0x" <> String.duplicate("00", 32) + + request = get(conn, "/api/v2/arbitrum/batches/da/celestia/999999/#{commitment_hex}") + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 422 for invalid transaction commitment", %{conn: conn} do + request = get(conn, "/api/v2/arbitrum/batches/da/celestia/123/invalid") + assert %{"errors" => _} = json_response(request, 422) + end + + test "returns 422 for invalid height", %{conn: conn} do + commitment_hex = "0x" <> String.duplicate("00", 32) + + request = get(conn, "/api/v2/arbitrum/batches/da/celestia/invalid/#{commitment_hex}") + assert %{"errors" => _} = json_response(request, 422) + end + end + + # Sets up `:meck` to make `Indexer.Helper.json_rpc_named_arguments/1` return + # the Mox transport (via `EthereumJSONRPC.Mox`) regardless of the configured URL, + # and seeds the Arbitrum fetcher config so `get_json_rpc(:l1)` and + # `get_l1_rollup_address/0` return usable values. Also installs `Mox.set_mox_global` + # so the stubs are visible from the controller's request process. + # + # Registers `on_exit` callbacks to restore the original config and unload meck. + # Use this in any test that needs to mock Arbitrum L1 RPC calls (Outbox, ERC20, ...). + defp setup_arbitrum_l1_rpc_mocks!(rollup_address \\ "0x" <> String.duplicate("aa", 20)) do + Mox.set_mox_global() + Mox.verify_on_exit!() + + prev_config = Application.get_env(:indexer, Indexer.Fetcher.Arbitrum, []) + + Application.put_env( + :indexer, + Indexer.Fetcher.Arbitrum, + Keyword.merge(prev_config, + l1_rpc: "http://placeholder.invalid", + l1_rollup_address: rollup_address + ) + ) + + :meck.new(Indexer.Helper, [:passthrough]) + + :meck.expect(Indexer.Helper, :json_rpc_named_arguments, fn _rpc_url -> + [ + transport: EthereumJSONRPC.Mox, + transport_options: [], + variant: EthereumJSONRPC.Geth + ] + end) + + ExUnit.Callbacks.on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.Arbitrum, prev_config) + + try do + :meck.unload(Indexer.Helper) + catch + _, _ -> :ok + end + end) + + rollup_address + end + + # Inserts an L2ToL1Tx event log on the given transaction. Pads the destination + # address into the indexed `second_topic` and the message id into the indexed + # `fourth_topic`, then ABI-encodes the unindexed params + # `[caller, arbBlockNum, ethBlockNum, timestamp, callvalue, data]` for the data field. + defp insert_l2_to_l1_log!(transaction, opts) do + message_id = Keyword.fetch!(opts, :message_id) + destination = Keyword.get(opts, :destination, "0x" <> String.duplicate("de", 20)) + callvalue = Keyword.get(opts, :callvalue, 0) + data_bytes = Keyword.get(opts, :data, <<>>) + caller = Keyword.get(opts, :caller, <<0::160>>) + arb_block_number = Keyword.get(opts, :arb_block_number, 1) + eth_block_number = Keyword.get(opts, :eth_block_number, 2) + timestamp = Keyword.get(opts, :timestamp, 3) + + {:ok, fourth_topic} = + Explorer.Chain.Hash.Full.cast("0x" <> String.pad_leading(Integer.to_string(message_id, 16), 64, "0")) + + destination_hex = String.replace(destination, "0x", "") + + {:ok, second_topic} = + Explorer.Chain.Hash.Full.cast("0x" <> String.pad_leading(destination_hex, 64, "0")) + + log_data_bin = + ABI.TypeEncoder.encode_raw( + [caller, arb_block_number, eth_block_number, timestamp, callvalue, data_bytes], + [:address, {:uint, 256}, {:uint, 256}, {:uint, 256}, {:uint, 256}, :bytes], + :standard + ) + + {:ok, data} = Explorer.Chain.Data.cast("0x" <> Base.encode16(log_data_bin, case: :lower)) + + insert(:log, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + first_topic: "0x3e7aafa77dbf186b7fd488006beff893744caa3c4f6f299e8a709fa2087374fc", + second_topic: second_topic, + fourth_topic: fourth_topic, + data: data + ) + end + + # Builds the bytes for `finalizeInboundTransfer(address,address,address,uint256,bytes)` + # — selector `0x2e567b36`. Used as the `data` field of an L2ToL1Tx log to trigger + # the token-withdrawal code path in `obtain_token_withdrawal_data/1`. + defp build_finalize_inbound_transfer_calldata(l1_token, l1_recipient, amount) do + {:ok, %{bytes: token_bytes}} = Explorer.Chain.Hash.Address.cast(l1_token) + {:ok, %{bytes: recipient_bytes}} = Explorer.Chain.Hash.Address.cast(l1_recipient) + # Second `from` arg is unused by `obtain_token_withdrawal_data/1` (it ignores + # the second decoded value), so a zero-padded placeholder is sufficient. + from_bytes = <<0::160>> + + args = + ABI.TypeEncoder.encode_raw( + [token_bytes, from_bytes, recipient_bytes, amount, <<>>], + [:address, :address, :address, {:uint, 256}, :bytes], + :standard + ) + + <<0x2E, 0x56, 0x7B, 0x36>> <> args + end + + # Inserts a confirmed Arbitrum batch + block linkage so that + # `SettlementReader.highest_confirmed_block/0` returns a block whose `:send_count` + # is the supplied value. Used to drive `get_size_for_proof_from_database/0`. + defp seed_highest_confirmed_block!(send_count) do + lifecycle_tx = insert(:arbitrum_lifecycle_transaction) + block = insert(:block, send_count: send_count, consensus: true) + batch = insert(:arbitrum_l1_batch) + + insert(:arbitrum_batch_block, + batch_number: batch.number, + block_number: block.number, + confirmation_id: lifecycle_tx.id + ) + + block + end + + # Mocks the batched eth_call that `get_contracts_for_rollup(:inbox_outbox, ...)` + # issues against the rollup contract: `sequencerInbox()` (selector 0xee35f327) + # and `outbox()` (selector 0xce11e6ab). Returns `outbox_address` for the latter + # and an arbitrary placeholder for the former. + defp expect_inbox_outbox_query!(outbox_address) do + outbox_hex = String.replace(outbox_address, "0x", "") + outbox_response = "0x" <> String.pad_leading(outbox_hex, 64, "0") + sequencer_inbox_response = "0x" <> String.pad_leading("ff", 64, "0") + + Mox.expect(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + responses = + Enum.map(requests, fn %{id: id, method: "eth_call", params: [%{data: data}, _block]} -> + result = + cond do + String.starts_with?(data, "0xce11e6ab") -> outbox_response + String.starts_with?(data, "0xee35f327") -> sequencer_inbox_response + true -> raise "Unexpected eth_call to rollup contract: #{data}" + end + + %{id: id, jsonrpc: "2.0", result: result} + end) + + {:ok, responses} + end) + end + + # Mocks the batched eth_call that `ArbitrumRpc.withdrawal_spent?/3` issues against + # the Outbox contract: `isSpent(uint256)` — selector 0x5a129efe. + defp expect_outbox_is_spent!(outbox_address, message_id, value) do + expected_data = + "0x5a129efe" <> String.pad_leading(Integer.to_string(message_id, 16), 64, "0") + + result_byte = if value, do: "01", else: "00" + result = "0x" <> String.pad_leading(result_byte, 64, "0") + + Mox.expect(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + responses = + Enum.map(requests, fn %{id: id, method: "eth_call", params: [%{data: data, to: to}, _block]} -> + assert String.downcase(to) == String.downcase(outbox_address), + "isSpent must target the outbox address" + + assert String.downcase(data) == expected_data, "isSpent data mismatch" + + %{id: id, jsonrpc: "2.0", result: result} + end) + + {:ok, responses} + end) + end + + # Mocks the batched eth_call that `ERC20.fetch_token_properties/3` issues: + # `name()` (0x06fdde03), `symbol()` (0x95d89b41), `decimals()` (0x313ce567). + defp expect_erc20_metadata!(token_address, opts) do + name = Keyword.fetch!(opts, :name) + symbol = Keyword.fetch!(opts, :symbol) + decimals = Keyword.fetch!(opts, :decimals) + + decimals_response = "0x" <> String.pad_leading(Integer.to_string(decimals, 16), 64, "0") + name_response = abi_encoded_string(name) + symbol_response = abi_encoded_string(symbol) + + Mox.expect(EthereumJSONRPC.Mox, :json_rpc, fn requests, _opts -> + token_addr_lower = String.downcase(token_address) + + responses = + Enum.map(requests, fn %{id: id, method: "eth_call", params: [%{data: data, to: to}, _block]} -> + assert String.downcase(to) == token_addr_lower, + "ERC20 metadata call must target the token contract" + + result = + cond do + String.starts_with?(data, "0x313ce567") -> decimals_response + String.starts_with?(data, "0x06fdde03") -> name_response + String.starts_with?(data, "0x95d89b41") -> symbol_response + true -> raise "Unexpected ERC20 call: #{data}" + end + + %{id: id, jsonrpc: "2.0", result: result} + end) + + {:ok, responses} + end) + end + + # ABI-encodes a single string value as the eth_call return payload (offset+length+padded data). + defp abi_encoded_string(str) do + encoded = + ABI.TypeEncoder.encode([str], %ABI.FunctionSelector{ + function: nil, + types: [:string] + }) + + "0x" <> Base.encode16(encoded, case: :lower) + end + + defp compare_batch(%L1Batch{} = batch, json) do + batch = Explorer.Repo.preload(batch, :commitment_transaction) + + assert batch.number == json["number"] + assert batch.transactions_count == json["transactions_count"] + assert batch.end_block - batch.start_block + 1 == json["blocks_count"] + + commitment_tx = batch.commitment_transaction + commitment_json = json["commitment_transaction"] + + assert to_string(commitment_tx.hash) == commitment_json["hash"] + assert commitment_tx.block_number == commitment_json["block_number"] + assert DateTime.to_iso8601(commitment_tx.timestamp) == commitment_json["timestamp"] + assert to_string(commitment_tx.status) == commitment_json["status"] + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs index cdbd5461871e..12db427aa1b6 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/block_controller_test.exs @@ -1,7 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.BlockControllerTest do use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] alias Explorer.Chain.{Address, Block, InternalTransaction, Transaction, Withdrawal} + alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit setup do Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) @@ -206,15 +209,75 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do check_paginated_response(response, response_2nd_page, uncles) end + + test "return 422 on invalid type", %{conn: conn} do + request = get(conn, "/api/v2/blocks", %{"type" => "bogus"}) + + assert %{ + "errors" => [ + %{ + "source" => %{"pointer" => "/type"}, + "title" => "Invalid value" + } + | _ + ] + } = json_response(request, 422) + end end describe "/blocks/{block_hash_or_number}" do test "return 422 on invalid parameter", %{conn: conn} do request_1 = get(conn, "/api/v2/blocks/0x123123") - assert %{"message" => "Invalid hash"} = json_response(request_1, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_1, 422) request_2 = get(conn, "/api/v2/blocks/123qwe") - assert %{"message" => "Invalid number"} = json_response(request_2, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_2, 422) + end + + test "return 404 when block number exceeds allowed range", %{conn: conn} do + request = get(conn, "/api/v2/blocks/3000000000") + + assert %{"message" => "Not found"} = json_response(request, 404) end test "return 404 on non existing block", %{conn: conn} do @@ -229,6 +292,7 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do test "get 'Block lost consensus' message", %{conn: conn} do block = insert(:block, consensus: false) + insert(:block, consensus: true) hash = to_string(block.hash) request_1 = get(conn, "/api/v2/blocks/#{block.number}") @@ -247,15 +311,312 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do assert response_2 == response_1 compare_item(block, response_2) end + + test "includes is_pending_update field in response", %{conn: conn} do + block_refetch_needed = insert(:block, refetch_needed: true) + block_no_refetch = insert(:block, refetch_needed: false) + + request_1 = get(conn, "/api/v2/blocks/#{block_refetch_needed.hash}") + assert response_1 = json_response(request_1, 200) + assert response_1["is_pending_update"] == true + + request_2 = get(conn, "/api/v2/blocks/#{block_no_refetch.hash}") + assert response_2 = json_response(request_2, 200) + assert response_2["is_pending_update"] == false + end + + test "includes is_pending_update field in block lists", %{conn: conn} do + block_refetch_needed = insert(:block, refetch_needed: true) + block_no_refetch = insert(:block, refetch_needed: false) + + request = get(conn, "/api/v2/blocks") + assert response = json_response(request, 200) + + # Find the blocks in the response + refetch_block_response = + Enum.find(response["items"], fn item -> item["hash"] == to_string(block_refetch_needed.hash) end) + + no_refetch_block_response = + Enum.find(response["items"], fn item -> item["hash"] == to_string(block_no_refetch.hash) end) + + assert refetch_block_response["is_pending_update"] == true + assert no_refetch_block_response["is_pending_update"] == false + end + + if @chain_identity == {:optimism, :celo} do + test "get block with Celo base fee information when chain type is celo", %{conn: conn} do + # Store original configuration + original_celo_config = Application.get_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts) + + # Set up Celo core contracts configuration for base fee + fee_handler_address = insert(:address) + governance_address = insert(:address) + celo_token_address = insert(:address) + + celo_config = [ + contracts: %{ + "addresses" => %{ + "FeeHandler" => [ + %{ + "address" => to_string(fee_handler_address.hash), + "updated_at_block_number" => 0 + } + ], + "Governance" => [ + %{ + "address" => to_string(governance_address.hash), + "updated_at_block_number" => 0 + } + ], + "GoldToken" => [ + %{ + "address" => to_string(celo_token_address.hash), + "updated_at_block_number" => 0 + } + ] + }, + "events" => %{ + "FeeHandler" => %{ + "FeeBeneficiarySet" => [ + %{ + "address_hash" => to_string(insert(:address).hash), + "updated_at_block_number" => 0 + } + ], + "BurnFractionSet" => [ + %{ + "value" => "500000000000000000000000", + "updated_at_block_number" => 0 + } + ] + } + } + } + ] + + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, celo_config) + + # Create a CELO token for the response + insert(:token, + contract_address_hash: celo_token_address.hash, + contract_address: celo_token_address, + symbol: "CELO", + name: "Celo", + type: "ERC-20" + ) + + # Create a block with base fee and transactions + block = + insert(:block, + # 1 gwei + base_fee_per_gas: 1_000_000_000 + ) + + # Create transactions for the block to calculate burnt fees + for index <- 0..2 do + insert(:transaction, + block_hash: block.hash, + block_number: block.number, + # 2 gwei + gas_price: 2_000_000_000, + gas_used: 21_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + cumulative_gas_used: 21_000, + index: index + ) + end + + # Make the request + request = get(conn, "/api/v2/blocks/#{block.hash}") + assert response = json_response(request, 200) + + # Verify basic block information + compare_item(block, response) + + # Verify Celo-specific information is present + assert Map.has_key?(response, "celo") + celo_info = response["celo"] + + # Verify epoch information + assert Map.has_key?(celo_info, "epoch_number") + assert Map.has_key?(celo_info, "is_epoch_block") + assert celo_info["is_epoch_block"] == false + + # Verify base fee information is present + assert Map.has_key?(celo_info, "base_fee") + assert base_fee_info = celo_info["base_fee"] + + # Verify base fee structure + assert Map.has_key?(base_fee_info, "recipient") + assert Map.has_key?(base_fee_info, "amount") + assert Map.has_key?(base_fee_info, "token") + assert Map.has_key?(base_fee_info, "breakdown") + + # Verify token information + token_info = base_fee_info["token"] + assert token_info["symbol"] == "CELO" + assert token_info["name"] == "Celo" + + # Verify recipient information + recipient = base_fee_info["recipient"] + assert Map.has_key?(recipient, "hash") + + # Verify breakdown structure + breakdown = base_fee_info["breakdown"] + assert is_list(breakdown) + + # Restore original configuration + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, original_celo_config) + end) + end + + test "get block with Celo governance base fee when fee handler is not available", %{conn: conn} do + # Store original configuration + original_celo_config = Application.get_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts) + + # Set up Celo core contracts configuration with only governance (no fee handler) + governance_address = insert(:address) + celo_token_address = insert(:address) + + celo_config = [ + contracts: %{ + "addresses" => %{ + "Governance" => [ + %{ + "address" => to_string(governance_address.hash), + "updated_at_block_number" => 0 + } + ], + "GoldToken" => [ + %{ + "address" => to_string(celo_token_address.hash), + "updated_at_block_number" => 0 + } + ] + } + } + ] + + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, celo_config) + + # Create a CELO token for the response + insert(:token, + contract_address_hash: celo_token_address.hash, + contract_address: celo_token_address, + symbol: "CELO", + name: "Celo", + type: "ERC-20" + ) + + # Create a block with base fee and transactions + block = + insert(:block, + # 1 gwei + base_fee_per_gas: 1_000_000_000 + ) + + # Create transactions for the block to calculate burnt fees + for index <- 0..2 do + insert(:transaction, + block_hash: block.hash, + block_number: block.number, + # 2 gwei + gas_price: 2_000_000_000, + gas_used: 21_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + cumulative_gas_used: 21_000, + index: index + ) + end + + # Make the request + request = get(conn, "/api/v2/blocks/#{block.hash}") + assert response = json_response(request, 200) + + # Verify basic block information + compare_item(block, response) + + # Verify Celo-specific information is present + assert Map.has_key?(response, "celo") + celo_info = response["celo"] + + # Verify epoch information + assert Map.has_key?(celo_info, "epoch_number") + assert Map.has_key?(celo_info, "is_epoch_block") + + # Verify base fee information is present (may be nil if governance fallback doesn't work) + assert Map.has_key?(celo_info, "base_fee") + assert base_fee_info = celo_info["base_fee"] + + # Verify base fee structure for governance case + assert Map.has_key?(base_fee_info, "recipient") + assert Map.has_key?(base_fee_info, "amount") + assert Map.has_key?(base_fee_info, "token") + assert Map.has_key?(base_fee_info, "breakdown") + + # For governance case, breakdown should be empty + breakdown = base_fee_info["breakdown"] + assert is_list(breakdown) + assert Enum.empty?(breakdown) + + # Restore original configuration + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, original_celo_config) + end) + end + end end describe "/blocks/{block_hash_or_number}/transactions" do test "return 422 on invalid parameter", %{conn: conn} do request_1 = get(conn, "/api/v2/blocks/0x123123/transactions") - assert %{"message" => "Invalid hash"} = json_response(request_1, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_1, 422) request_2 = get(conn, "/api/v2/blocks/123qwe/transactions") - assert %{"message" => "Invalid number"} = json_response(request_2, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_2, 422) end test "return 404 on non existing block", %{conn: conn} do @@ -340,10 +701,50 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do describe "/blocks/{block_hash_or_number}/withdrawals" do test "return 422 on invalid parameter", %{conn: conn} do request_1 = get(conn, "/api/v2/blocks/0x123123/withdrawals") - assert %{"message" => "Invalid hash"} = json_response(request_1, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_1, 422) request_2 = get(conn, "/api/v2/blocks/123qwe/withdrawals") - assert %{"message" => "Invalid number"} = json_response(request_2, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_2, 422) end test "return 404 on non existing block", %{conn: conn} do @@ -411,10 +812,50 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do describe "/blocks/{block_hash_or_number}/internal-transactions" do test "returns 422 on invalid parameter", %{conn: conn} do request_1 = get(conn, "/api/v2/blocks/0x123123/internal-transactions") - assert %{"message" => "Invalid hash"} = json_response(request_1, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_1, 422) request_2 = get(conn, "/api/v2/blocks/123qwe/internal-transactions") - assert %{"message" => "Invalid number"} = json_response(request_2, 422) + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_2, 422) end test "returns 404 on non existing block", %{conn: conn} do @@ -452,9 +893,7 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do transaction: transaction, index: 0, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0 + transaction_index: transaction.index ) internal_transactions = @@ -463,17 +902,16 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do transaction = :transaction |> insert() - |> with_block(block) + |> with_block(block, index: index) insert(:internal_transaction, transaction: transaction, index: index, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index ) end) + |> InternalTransaction.preload_addresses() request = get(conn, "/api/v2/blocks/#{block.hash}/internal-transactions") assert response = json_response(request, 200) @@ -486,6 +924,81 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do end end + if @chain_type == :ethereum do + describe "blocks/{block_hash_or_number}/beacon/deposits" do + test "get 404 on non-existing block", %{conn: conn} do + block = build(:block) + + request = get(conn, "/api/v2/blocks/#{block.hash}/beacon/deposits") + json_response(request, 404) + end + + test "get 422 on invalid block", %{conn: conn} do + request_1 = get(conn, "/api/v2/blocks/0x123123/beacon/deposits") + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_1, 422) + + request_2 = get(conn, "/api/v2/blocks/123qwe/beacon/deposits") + + assert %{ + "errors" => [ + %{ + "detail" => + "Failed to cast value using any of: Schema(title: \"FullHash\", type: :string), Schema(type: :integer)", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid integer. Got: string", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/block_hash_or_number_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request_2, 422) + end + + test "get deposits", %{conn: conn} do + block = insert(:block) + + deposits = insert_list(51, :beacon_deposit, block: block) + + insert(:beacon_deposit) + + request = get(conn, "/api/v2/blocks/#{block.hash}/beacon/deposits") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/blocks/#{block.hash}/beacon/deposits", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, deposits) + end + end + end + defp compare_item(%Block{} = block, json) do assert to_string(block.hash) == json["hash"] assert block.number == json["height"] @@ -507,11 +1020,50 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do assert internal_transaction.block_number == json["block_number"] assert to_string(internal_transaction.gas) == json["gas_limit"] assert internal_transaction.index == json["index"] - assert to_string(internal_transaction.transaction_hash) == json["transaction_hash"] + assert to_string(internal_transaction.transaction.hash) == json["transaction_hash"] assert Address.checksum(internal_transaction.from_address_hash) == json["from"]["hash"] assert Address.checksum(internal_transaction.to_address_hash) == json["to"]["hash"] end + defp compare_item(%BeaconDeposit{} = deposit, json) do + index = deposit.index + transaction_hash = to_string(deposit.transaction_hash) + block_hash = to_string(deposit.block_hash) + block_number = deposit.block_number + pubkey = to_string(deposit.pubkey) + withdrawal_credentials = to_string(deposit.withdrawal_credentials) + signature = to_string(deposit.signature) + from_address_hash = Address.checksum(deposit.from_address_hash) + + if deposit.withdrawal_address_hash do + withdrawal_address_hash = Address.checksum(deposit.withdrawal_address_hash) + + assert %{ + "index" => ^index, + "transaction_hash" => ^transaction_hash, + "block_hash" => ^block_hash, + "block_number" => ^block_number, + "pubkey" => ^pubkey, + "withdrawal_credentials" => ^withdrawal_credentials, + "withdrawal_address" => %{"hash" => ^withdrawal_address_hash}, + "signature" => ^signature, + "from_address" => %{"hash" => ^from_address_hash} + } = json + else + assert %{ + "index" => ^index, + "transaction_hash" => ^transaction_hash, + "block_hash" => ^block_hash, + "block_number" => ^block_number, + "pubkey" => ^pubkey, + "withdrawal_credentials" => ^withdrawal_credentials, + "withdrawal_address" => nil, + "signature" => ^signature, + "from_address" => %{"hash" => ^from_address_hash} + } = json + end + end + defp check_paginated_response(first_page_resp, second_page_resp, list) do assert Enum.count(first_page_resp["items"]) == 50 assert first_page_resp["next_page_params"] != nil @@ -522,4 +1074,53 @@ defmodule BlockScoutWeb.API.V2.BlockControllerTest do assert second_page_resp["next_page_params"] == nil compare_item(Enum.at(list, 0), Enum.at(second_page_resp["items"], 0)) end + + if @chain_type == :arbitrum do + describe "/blocks/arbitrum-batch/:batch_number_param" do + test "returns empty list when batch has no blocks", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + + request = get(conn, "/api/v2/blocks/arbitrum-batch/#{batch.number}") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns blocks in the batch", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + block = insert(:block, consensus: true) + + insert(:arbitrum_batch_block, batch_number: batch.number, block_number: block.number) + + request = get(conn, "/api/v2/blocks/arbitrum-batch/#{batch.number}") + assert response = json_response(request, 200) + assert length(response["items"]) == 1 + assert hd(response["items"])["height"] == block.number + end + + test "can paginate blocks in Arbitrum batch", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + blocks = insert_list(51, :block, consensus: true) + + Enum.each(blocks, fn block -> + insert(:arbitrum_batch_block, batch_number: batch.number, block_number: block.number) + end) + + request = get(conn, "/api/v2/blocks/arbitrum-batch/#{batch.number}") + assert response = json_response(request, 200) + + request_2nd_page = + get(conn, "/api/v2/blocks/arbitrum-batch/#{batch.number}", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, blocks) + end + + test "returns 422 for non-integer batch_number_param", %{conn: conn} do + request = get(conn, "/api/v2/blocks/arbitrum-batch/invalid") + assert %{"errors" => [_]} = json_response(request, 422) + end + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs new file mode 100644 index 000000000000..9deb089cc840 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/celo_controller_test.exs @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.CeloControllerTest do + use BlockScoutWeb.ConnCase + + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + if @chain_identity == {:optimism, :celo} do + alias Explorer.Chain.Celo.ElectionReward + + setup do + celo_token = insert(:token) + usd_token = insert(:token) + + original_core_contracts_config = + Application.get_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts) + + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, + contracts: %{ + "addresses" => %{ + "Accounts" => [], + "Election" => [], + "EpochRewards" => [], + "FeeHandler" => [], + "GasPriceMinimum" => [], + "GoldToken" => [ + %{"address" => to_string(celo_token.contract_address_hash), "updated_at_block_number" => 0} + ], + "Governance" => [], + "LockedGold" => [], + "Reserve" => [], + "StableToken" => [ + %{"address" => to_string(usd_token.contract_address_hash), "updated_at_block_number" => 0} + ], + "Validators" => [] + } + } + ) + + original_celo_config = Application.get_env(:explorer, :celo) + + on_exit(fn -> + Application.put_env( + :explorer, + Explorer.Chain.Cache.CeloCoreContracts, + original_core_contracts_config + ) + + Application.put_env(:explorer, :celo, original_celo_config) + end) + + {:ok, %{celo_token: celo_token, usd_token: usd_token}} + end + + describe "/api/v2/celo/epochs" do + test "returns empty list", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns epochs", %{conn: conn} do + epoch = + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279 + ) + + request = get(conn, "/api/v2/celo/epochs") + assert response = json_response(request, 200) + assert [item] = response["items"] + assert item["number"] == epoch.number + assert item["start_block_number"] == epoch.start_block_number + assert item["end_block_number"] == epoch.end_block_number + assert item["is_finalized"] == true + end + end + + describe "/api/v2/celo/epochs/:number" do + test "returns 404 for non-existing epoch", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs/100") + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "returns 422 for invalid epoch number", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs/invalid") + assert %{"errors" => [_]} = json_response(request, 422) + end + + test "returns unfetched epoch with null aggregated rewards", %{conn: conn} do + insert(:celo_epoch, + number: 1, + fetched?: false, + start_block_number: 0, + end_block_number: 17_279 + ) + + request = get(conn, "/api/v2/celo/epochs/1") + assert response = json_response(request, 200) + assert response["number"] == 1 + assert response["is_finalized"] == false + assert response["aggregated_election_rewards"] == nil + end + + test "returns fetched epoch with aggregated rewards", %{conn: conn} do + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279 + ) + + for type <- ElectionReward.types() do + insert(:celo_aggregated_election_reward, + epoch_number: 1, + type: type, + sum: 1000, + count: 5 + ) + end + + request = get(conn, "/api/v2/celo/epochs/1") + assert response = json_response(request, 200) + assert response["number"] == 1 + assert response["is_finalized"] == true + + rewards = response["aggregated_election_rewards"] + assert is_map(rewards) + + for type <- ElectionReward.types() do + assert %{"total" => _, "count" => 5, "token" => _} = rewards[to_string(type)] + end + end + + test "returns L2 epoch with null delegated_payment in aggregated rewards", %{conn: conn} do + # Epoch 2 starts at block 17280. Setting l2_migration_block to 17280 + # makes epoch 2 an L2 epoch (epoch_number >= migration epoch number). + Application.put_env(:explorer, :celo, l2_migration_block: 17_280) + + insert(:celo_epoch, + number: 2, + fetched?: true, + start_block_number: 17_280, + end_block_number: 34_559 + ) + + for type <- ElectionReward.types() do + insert(:celo_aggregated_election_reward, + epoch_number: 2, + type: type, + sum: 1000, + count: 5 + ) + end + + request = get(conn, "/api/v2/celo/epochs/2") + assert response = json_response(request, 200) + assert response["type"] == "L2" + + rewards = response["aggregated_election_rewards"] + assert rewards["delegated_payment"] == nil + + for type <- ElectionReward.types() -- [:delegated_payment] do + assert %{"total" => _, "count" => 5, "token" => _} = rewards[to_string(type)] + end + end + end + + describe "/api/v2/celo/epochs/:number/election-rewards/:type" do + test "returns empty list", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/voter") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "paginates election rewards across two pages", %{conn: conn} do + epoch = + insert(:celo_epoch, + number: 1, + fetched?: true, + start_block_number: 0, + end_block_number: 17_279 + ) + + account_address = insert(:address) + + 1..51 + |> Enum.each(fn amount -> + associated_account_address = insert(:address) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :voter, + amount: amount, + account_address_hash: account_address.hash, + associated_account_address_hash: associated_account_address.hash + ) + end) + + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/voter") + assert response = json_response(request, 200) + + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + + assert Enum.at(response["items"], 0)["amount"] == "51" + assert Enum.at(response["items"], 49)["amount"] == "2" + + request_2nd_page = + get(conn, "/api/v2/celo/epochs/1/election-rewards/voter", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + assert Enum.at(response_2nd_page["items"], 0)["amount"] == "1" + end + + test "returns 422 for invalid epoch number", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs/invalid/election-rewards/voter") + assert %{"errors" => [_]} = json_response(request, 422) + end + + test "accepts both hyphenated and underscored delegated_payment type in URL", %{conn: conn} do + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/delegated-payment") + assert %{"items" => []} = json_response(request, 200) + + request = get(conn, "/api/v2/celo/epochs/1/election-rewards/delegated_payment") + assert %{"items" => []} = json_response(request, 200) + end + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/config_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/config_controller_test.exs index eb55d1225b1b..29b214a80fe6 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/config_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/config_controller_test.exs @@ -1,6 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ConfigControllerTest do use BlockScoutWeb.ConnCase + @chain_type Application.compile_env(:explorer, :chain_type) + + describe "/config/backend" do + test "returns chain_type when configured", %{conn: conn} do + request = get(conn, "/api/v2/config/backend") + response = json_response(request, 200) + + assert %{"chain_type" => chain_type} = response + assert is_binary(chain_type) + end + + test "returns the configured chain type value", %{conn: conn} do + request = get(conn, "/api/v2/config/backend") + response = json_response(request, 200) + + assert %{"chain_type" => chain_type} = response + + # Compare string representations + expected_chain_type = if is_atom(@chain_type), do: Atom.to_string(@chain_type), else: @chain_type + assert chain_type == expected_chain_type + end + + test "returns openapi spec folder name", %{conn: conn} do + request = get(conn, "/api/v2/config/backend") + response = json_response(request, 200) + + assert %{"openapi_spec_folder_name" => openapi_spec_folder_name} = response + assert is_binary(openapi_spec_folder_name) + end + + case Application.compile_env(:explorer, :chain_identity) do + {:optimism, :celo} -> + test "translates optimism-celo chain identity to celo openapi folder", %{conn: conn} do + request = get(conn, "/api/v2/config/backend") + response = json_response(request, 200) + + assert %{"openapi_spec_folder_name" => "optimism-celo"} = response + end + + _ -> + :ok + end + end + describe "/config/backend-version" do test "get json rps url if set", %{conn: conn} do version = "v6.3.0-beta" @@ -19,4 +64,100 @@ defmodule BlockScoutWeb.API.V2.ConfigControllerTest do assert %{"backend_version" => nil} = json_response(request, 200) end end + + describe "/config/public-metrics" do + test "returns configured update period hours", %{conn: conn} do + # save existing configuration and set test value + prev = Application.get_env(:explorer, Explorer.Chain.Metrics.PublicMetrics) + Application.put_env(:explorer, Explorer.Chain.Metrics.PublicMetrics, update_period_hours: 7) + + on_exit(fn -> Application.put_env(:explorer, Explorer.Chain.Metrics.PublicMetrics, prev) end) + + request = get(conn, "/api/v2/config/public-metrics") + assert %{"update_period_hours" => 7} = json_response(request, 200) + end + end + + describe "/config/smart-contracts/languages" do + @base_languages ["solidity", "vyper", "yul", "geas"] + + case Application.compile_env(:explorer, :chain_type) do + :arbitrum -> + test "gets smart-contract languages", %{conn: conn} do + request = get(conn, "/api/v2/config/smart-contracts/languages") + response = json_response(request, 200) + + assert response == %{"languages" => @base_languages ++ ["stylus_rust"]} + end + + :zilliqa -> + test "gets smart-contract languages", %{conn: conn} do + request = get(conn, "/api/v2/config/smart-contracts/languages") + response = json_response(request, 200) + + assert response == %{"languages" => @base_languages ++ ["scilla"]} + end + + _ -> + test "gets smart-contract languages", %{conn: conn} do + request = get(conn, "/api/v2/config/smart-contracts/languages") + response = json_response(request, 200) + + assert response == %{"languages" => @base_languages} + end + end + end + + describe "/config/db-background-migrations" do + test "returns empty list when there are no uncompleted migrations", %{conn: conn} do + request = get(conn, "/api/v2/config/db-background-migrations") + assert %{"migrations" => migrations} = json_response(request, 200) + assert is_list(migrations) + end + + test "returns list of uncompleted migrations", %{conn: conn} do + insert(:migration_status, migration_name: "test_migration", status: "started") + + request = get(conn, "/api/v2/config/db-background-migrations") + assert %{"migrations" => migrations} = json_response(request, 200) + + assert Enum.any?(migrations, fn m -> m["migration_name"] == "test_migration" and m["status"] == "started" end) + end + + test "does not return completed migrations", %{conn: conn} do + insert(:migration_status, migration_name: "completed_migration", status: "completed") + insert(:migration_status, migration_name: "started_migration", status: "started") + + request = get(conn, "/api/v2/config/db-background-migrations") + assert %{"migrations" => migrations} = json_response(request, 200) + + assert Enum.all?(migrations, fn m -> m["status"] != "completed" end) + assert Enum.any?(migrations, fn m -> m["migration_name"] == "started_migration" end) + end + + test "returns migration with meta data", %{conn: conn} do + insert(:migration_status, + migration_name: "test_migration", + status: "started", + meta: %{"max_block_number" => 8_151_758} + ) + + request = get(conn, "/api/v2/config/db-background-migrations") + assert %{"migrations" => migrations} = json_response(request, 200) + + migration = Enum.find(migrations, fn m -> m["migration_name"] == "test_migration" end) + assert migration["meta"]["max_block_number"] == 8_151_758 + end + + test "returns migration timestamps", %{conn: conn} do + insert(:migration_status, migration_name: "test_migration", status: "started") + + request = get(conn, "/api/v2/config/db-background-migrations") + assert %{"migrations" => migrations} = json_response(request, 200) + + migration = Enum.find(migrations, fn m -> m["migration_name"] == "test_migration" end) + assert Map.has_key?(migration, "inserted_at") + assert Map.has_key?(migration, "updated_at") + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs index 9c9168ca8039..f1bda4457449 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/csv_export_controller_test.exs @@ -1,8 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do use BlockScoutWeb.ConnCase, async: true use ExUnit.Case, async: false - alias Explorer.Chain.Address + use Oban.Testing, repo: Explorer.Repo + + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + alias Explorer.Chain.Address import Mox setup :verify_on_exit! @@ -12,7 +16,8 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do csv_setup() end - test "do not export token transfers to csv without recaptcha recaptcha_response provided", %{conn: conn} do + test "do not export token transfers to csv after rate limit is reached (1 per hour) without recaptcha recaptcha_response provided", + %{conn: conn} do address = insert(:address) transaction = @@ -29,25 +34,41 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do to_period = now |> DateTime.to_iso8601() conn = - get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ - "address_id" => Address.checksum(address.hash), + conn + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ "from_period" => from_period, "to_period" => to_period }) - assert conn.status == 403 + assert conn.status == 200 + + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 429 end - test "do not export token transfers to csv without recaptcha passed", %{ + test "do not export token transfers to csv after rate limit is reached without recaptcha passed", %{ conn: conn, v2_secret_key: recaptcha_secret_key } do expected_body = "secret=#{recaptcha_secret_key}&response=123" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(%{"success" => false})}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{body: ^expected_body}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(%{"success" => false}) + }} + end + ) address = insert(:address) @@ -66,16 +87,27 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ - "address_id" => Address.checksum(address.hash), "from_period" => from_period, - "to_period" => to_period, - "recaptcha_response" => "123" + "to_period" => to_period }) - assert conn.status == 403 + assert conn.status == 200 + + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("recaptcha-v2-response", "123") + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 429 end - test "exports token transfers to csv without recaptcha if recaptcha is disabled", %{conn: conn} do + test "exports token transfers to csv after rate limit is reached without recaptcha if recaptcha is disabled", %{ + conn: conn + } do init_config = Application.get_env(:block_scout_web, :recaptcha) Application.put_env(:block_scout_web, :recaptcha, is_disabled: true) @@ -96,11 +128,22 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ - "address_id" => Address.checksum(address.hash), "from_period" => from_period, "to_period" => to_period }) + assert conn.status == 200 + assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 + + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 Application.put_env(:block_scout_web, :recaptcha, init_config) @@ -109,18 +152,20 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do test "exports token transfers to csv", %{conn: conn, v2_secret_key: recaptcha_secret_key} do expected_body = "secret=#{recaptcha_secret_key}&response=123" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{body: ^expected_body}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "success" => true, + "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] + }) + }} + end + ) address = insert(:address) @@ -139,12 +184,23 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ - "address_id" => Address.checksum(address.hash), "from_period" => from_period, - "to_period" => to_period, - "recaptcha_response" => "123" + "to_period" => to_period }) + assert conn.status == 200 + assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 + + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("recaptcha-v2-response", "123") + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 end end @@ -154,21 +210,50 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do csv_setup() end + test "exports transactions to csv when recaptcha is disabled", %{conn: conn} do + init_config = Application.get_env(:block_scout_web, :recaptcha) + Application.put_env(:block_scout_web, :recaptcha, is_disabled: true) + + address = insert(:address) + + :transaction + |> insert(from_address: address) + |> with_block() + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/transactions/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 + assert conn.resp_body =~ "TxHash" + assert conn.resp_body |> String.split("\n") |> Enum.count() >= 2 + + Application.put_env(:block_scout_web, :recaptcha, init_config) + end + test "download csv file with transactions", %{conn: conn, v2_secret_key: recaptcha_secret_key} do expected_body = "secret=#{recaptcha_secret_key}&response=123" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{body: ^expected_body}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "success" => true, + "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] + }) + }} + end + ) address = insert(:address) @@ -182,17 +267,28 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do {:ok, now} = DateTime.now("Etc/UTC") - from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() - to_period = now |> DateTime.to_iso8601() + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() |> to_string() + to_period = now |> DateTime.to_iso8601() |> to_string() conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/transactions/csv", %{ - "address_id" => Address.checksum(address.hash), "from_period" => from_period, - "to_period" => to_period, - "recaptcha_response" => "123" + "to_period" => to_period }) + assert conn.status == 200 + assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 + + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("recaptcha-v2-response", "123") + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/transactions/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 assert conn.resp_body |> String.split("\n") |> Enum.count() == 4 end end @@ -202,21 +298,59 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do csv_setup() end + test "exports internal transactions to csv when recaptcha is disabled", %{conn: conn} do + init_config = Application.get_env(:block_scout_web, :recaptcha) + Application.put_env(:block_scout_web, :recaptcha, is_disabled: true) + + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + index: 0, + transaction: transaction, + from_address: address, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :day) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/internal-transactions/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 + assert conn.resp_body =~ "TxHash" + assert conn.resp_body |> String.split("\n") |> Enum.count() >= 2 + + Application.put_env(:block_scout_web, :recaptcha, init_config) + end + test "download csv file with internal transactions", %{conn: conn, v2_secret_key: recaptcha_secret_key} do expected_body = "secret=#{recaptcha_secret_key}&response=123" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{body: ^expected_body}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "success" => true, + "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] + }) + }} + end + ) address = insert(:address) @@ -240,8 +374,6 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do transaction: transaction_1, from_address: address, block_number: transaction_1.block_number, - block_hash: transaction_1.block_hash, - block_index: 0, transaction_index: transaction_1.index ) @@ -250,18 +382,15 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do transaction: transaction_2, to_address: address, block_number: transaction_2.block_number, - block_hash: transaction_2.block_hash, - block_index: 1, transaction_index: transaction_2.index ) - insert(:internal_transaction, + insert(:internal_transaction_create, index: 2, transaction: transaction_3, created_contract_address: address, + to_address: nil, block_number: transaction_3.block_number, - block_hash: transaction_3.block_hash, - block_index: 2, transaction_index: transaction_3.index ) @@ -272,36 +401,493 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/internal-transactions/csv", %{ - "address_id" => Address.checksum(address.hash), "from_period" => from_period, - "to_period" => to_period, - "recaptcha_response" => "123" + "to_period" => to_period }) + assert conn.status == 200 + assert conn.resp_body |> String.split("\n") |> Enum.count() == 5 + + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("recaptcha-v2-response", "123") + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/internal-transactions/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 assert conn.resp_body |> String.split("\n") |> Enum.count() == 5 end end + describe "GET /api/v2/csv-exports/:uuid" do + setup do + bypass = Bypass.open() + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + original_tesla = Application.get_env(:tesla, :adapter) + + config = + (original_config || []) + |> Keyword.put(:max_pending_tasks_per_ip, 5) + |> Keyword.put(:gokapi_url, "http://localhost:#{bypass.port}") + |> Keyword.put(:gokapi_api_key, "test-api-key") + |> Keyword.put(:gokapi_upload_expiry_days, 1) + |> Keyword.put(:gokapi_upload_allowed_downloads, 1) + + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, original_tesla) + + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + {:ok, bypass: bypass} + end + + test "returns 200 with pending status for pending request", %{conn: conn} do + ip_hash = :crypto.hash(:sha256, "127.0.0.1") + + request = + %Explorer.Chain.CsvExport.Request{ + remote_ip_hash: ip_hash, + file_id: nil, + status: :pending + } + |> Explorer.Repo.insert!() + + conn = get(conn, "/api/v2/csv-exports/#{request.id}") + + assert %{"status" => "pending", "file_id" => nil} = json_response(conn, 200) + end + + test "returns 200 with completed status and file_id for completed request", %{ + conn: conn, + bypass: bypass + } do + ip_hash = :crypto.hash(:sha256, "127.0.0.1") + file_id = "test-file-123" + expires_at_to_expect = DateTime.utc_now() |> DateTime.truncate(:second) + + request = + %Explorer.Chain.CsvExport.Request{ + remote_ip_hash: ip_hash, + file_id: file_id, + status: :completed, + expires_at: expires_at_to_expect + } + |> Explorer.Repo.insert!() + + Bypass.expect(bypass, fn conn -> + assert conn.request_path =~ "/api/files/list/#{file_id}" + Plug.Conn.resp(conn, 200, "") + end) + + conn = get(conn, "/api/v2/csv-exports/#{request.id}") + + assert %{"status" => "completed", "file_id" => ^file_id, "expires_at" => expires_at} = json_response(conn, 200) + + assert expires_at == expires_at_to_expect |> DateTime.to_iso8601() + end + + test "returns 200 with failed status for failed request", %{conn: conn} do + ip_hash = :crypto.hash(:sha256, "127.0.0.1") + + request = + %Explorer.Chain.CsvExport.Request{ + remote_ip_hash: ip_hash, + file_id: nil, + status: :failed + } + |> Explorer.Repo.insert!() + + conn = get(conn, "/api/v2/csv-exports/#{request.id}") + + assert %{"status" => "failed", "file_id" => nil, "expires_at" => nil} = json_response(conn, 200) + end + + test "returns 404 for non-existent UUID", %{conn: conn} do + fake_uuid = "11111111-1111-1111-1111-111111111111" + + conn = get(conn, "/api/v2/csv-exports/#{fake_uuid}") + + assert %{"message" => "Not found"} = json_response(conn, 404) + end + + test "returns 422 for malformed UUID", %{conn: conn} do + conn = get(conn, "/api/v2/csv-exports/not-a-valid-uuid") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected :uuid", + "source" => %{"pointer" => "/uuid_param"}, + "title" => "Invalid value" + } + ] + } = json_response(conn, 422) + end + + test "returns 404 when file is removed on gokapi", %{ + conn: conn, + bypass: bypass + } do + ip_hash = :crypto.hash(:sha256, "127.0.0.1") + file_id = "test-file-123" + + request = + %Explorer.Chain.CsvExport.Request{ + remote_ip_hash: ip_hash, + file_id: file_id, + status: :completed + } + |> Explorer.Repo.insert!() + + Bypass.expect(bypass, fn conn -> + assert conn.request_path =~ "/api/files/list/#{file_id}" + Plug.Conn.resp(conn, 404, "") + end) + + conn = get(conn, "/api/v2/csv-exports/#{request.id}") + + assert %{"message" => "Not found"} = json_response(conn, 404) + end + end + + describe "async mode export endpoints" do + setup do + csv_setup() + bypass = Bypass.open() + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + original_tesla = Application.get_env(:tesla, :adapter) + + config = + (original_config || []) + |> Keyword.put(:async?, true) + |> Keyword.put(:max_pending_tasks_per_ip, 3) + |> Keyword.put(:gokapi_url, "http://localhost:#{bypass.port}") + |> Keyword.put(:gokapi_api_key, "test-api-key") + |> Keyword.put(:gokapi_upload_expiry_days, 1) + |> Keyword.put(:gokapi_upload_allowed_downloads, 1) + + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, original_tesla) + + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + {:ok, bypass: bypass} + end + + test "returns 202 with request_id for token-transfers export when async enabled", %{conn: conn} do + address = insert(:address) + + :transaction + |> insert(from_address: address) + |> with_block() + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert %{"request_id" => request_id} = json_response(conn, 202) + assert is_binary(request_id) + end + + test "returns 202 with request_id for transactions export when async enabled", %{conn: conn} do + address = insert(:address) + + :transaction + |> insert(from_address: address) + |> with_block() + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/transactions/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert %{"request_id" => request_id} = json_response(conn, 202) + assert is_binary(request_id) + end + + test "returns 202 with request_id for internal-transactions export when async enabled", %{ + conn: conn + } do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + index: 0, + transaction: transaction, + from_address: address, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :day) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/internal-transactions/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert %{"request_id" => request_id} = json_response(conn, 202) + assert is_binary(request_id) + end + + test "returns 202 with request_id for logs export when async enabled", %{conn: conn} do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:log, + address: address, + index: 0, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/logs/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 202 + body = Jason.decode!(conn.resp_body) + assert Map.has_key?(body, "request_id") + assert is_binary(body["request_id"]) + end + + test "returns 202 with request_id for token holders export when async enabled", %{conn: conn} do + token = insert(:token, type: "ERC-20", decimals: 18) + + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 100_000_000_000_000_000_000 + ) + + conn = get(conn, "/api/v2/tokens/#{Address.checksum(token.contract_address_hash)}/holders/csv") + + assert %{"request_id" => request_id} = json_response(conn, 202) + assert is_binary(request_id) + end + + if @chain_identity == {:optimism, :celo} do + test "returns 202 with request_id for celo election-rewards export when async enabled", %{ + conn: conn + } do + address = insert(:address) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :day) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/celo/election-rewards/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert %{"request_id" => request_id} = json_response(conn, 202) + assert is_binary(request_id) + end + end + + test "returns 409 when pending request limit is reached", %{conn: conn} do + address = insert(:address) + + :transaction + |> insert(from_address: address) + |> with_block() + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + args = %{ + address_hash: to_string(address.hash), + from_period: from_period, + to_period: to_period, + filter_type: nil, + filter_value: nil, + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.TokenTransfers" + } + + 1..3 + |> Enum.each(fn _ -> + {:ok, _} = Explorer.Chain.CsvExport.Request.create("127.0.0.1", args) + end) + + conn = + conn + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/token-transfers/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert json_response(conn, 409) == %{"error" => "You can only have 3 pending requests at a time"} + end + end + + describe "GET /api/v2/tokens/:hash/holders/csv" do + setup do + result = csv_setup() + + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + config = (original_config || []) |> Keyword.put(:async?, false) + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + + on_exit(fn -> + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + result + end + + test "exports token holders to csv in sync mode", %{conn: conn} do + token = insert(:token, type: "ERC-20", decimals: 18) + + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 100_000_000_000_000_000_000 + ) + + conn = get(conn, "/api/v2/tokens/#{Address.checksum(token.contract_address_hash)}/holders/csv") + + assert conn.status == 200 + assert conn.resp_body =~ "HolderAddress" + assert conn.resp_body =~ "Balance" + end + + test "returns 404 for non-existent token", %{conn: conn} do + fake_hash = "0x0000000000000000000000000000000000000001" + + conn = get(conn, "/api/v2/tokens/#{fake_hash}/holders/csv") + + assert %{"message" => "Not found"} = json_response(conn, 404) + end + + test "returns 422 for invalid token hash", %{conn: conn} do + conn = get(conn, "/api/v2/tokens/not-a-valid-hash/holders/csv") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(conn, 422) + end + end + describe "GET logs_csv/2" do setup do csv_setup() end + test "exports logs to csv when recaptcha is disabled", %{conn: conn} do + init_config = Application.get_env(:block_scout_web, :recaptcha) + Application.put_env(:block_scout_web, :recaptcha, is_disabled: true) + + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:log, + address: address, + index: 0, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :minute) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/logs/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 + assert conn.resp_body =~ "TxHash" + assert conn.resp_body |> String.split("\n") |> Enum.count() >= 2 + + Application.put_env(:block_scout_web, :recaptcha, init_config) + end + test "download csv file with logs", %{conn: conn, v2_secret_key: recaptcha_secret_key} do expected_body = "secret=#{recaptcha_secret_key}&response=123" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{body: ^expected_body}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "success" => true, + "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] + }) + }} + end + ) address = insert(:address) @@ -351,31 +937,27 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/logs/csv", %{ - "address_id" => Address.checksum(address.hash), "from_period" => from_period, - "to_period" => to_period, - "recaptcha_response" => "123" + "to_period" => to_period }) + assert conn.status == 200 assert conn.resp_body |> String.split("\n") |> Enum.count() == 5 - end - test "handles null filter", %{conn: conn, v2_secret_key: recaptcha_secret_key} do - expected_body = "secret=#{recaptcha_secret_key}&response=123" + conn = + Phoenix.ConnTest.build_conn() + |> put_req_header("recaptcha-v2-response", "123") + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses/#{Address.checksum(address.hash)}/logs/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + assert conn.status == 200 + assert conn.resp_body |> String.split("\n") |> Enum.count() == 5 + end + test "handles null filter", %{conn: conn} do address = insert(:address) transaction = @@ -398,21 +980,52 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do conn = get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/logs/csv", %{ - "address_id" => Address.checksum(address.hash), "filter_type" => "null", "filter_value" => "null", "from_period" => from_period, - "to_period" => to_period, - "recaptcha_response" => "123" + "to_period" => to_period }) + assert conn.status == 200 assert conn.resp_body |> String.split("\n") |> Enum.count() == 3 end end + if @chain_identity == {:optimism, :celo} do + describe "GET celo/election-rewards/csv" do + setup do + csv_setup() + end + + test "exports Celo election rewards to csv when recaptcha is disabled", %{conn: conn} do + init_config = Application.get_env(:block_scout_web, :recaptcha) + Application.put_env(:block_scout_web, :recaptcha, is_disabled: true) + + address = insert(:address) + + {:ok, now} = DateTime.now("Etc/UTC") + from_period = DateTime.add(now, -1, :day) |> DateTime.to_iso8601() + to_period = now |> DateTime.to_iso8601() + + conn = + get(conn, "/api/v2/addresses/#{Address.checksum(address.hash)}/celo/election-rewards/csv", %{ + "from_period" => from_period, + "to_period" => to_period + }) + + assert conn.status == 200 + assert conn.resp_body =~ "EpochNumber" + assert conn.resp_body =~ "BlockNumber" + + Application.put_env(:block_scout_web, :recaptcha, init_config) + end + end + end + defp csv_setup() do + original_config = :persistent_term.get(:rate_limit_config) old_recaptcha_env = Application.get_env(:block_scout_web, :recaptcha) - old_http_adapter = Application.get_env(:block_scout_web, :http_adapter) + original_api_rate_limit = Application.get_env(:block_scout_web, :api_rate_limit) v2_secret_key = "v2_secret_key" v3_secret_key = "v3_secret_key" @@ -423,11 +1036,64 @@ defmodule BlockScoutWeb.Api.V2.CsvExportControllerTest do is_disabled: false ) - Application.put_env(:block_scout_web, :http_adapter, Explorer.Mox.HTTPoison) + Application.put_env(:block_scout_web, :api_rate_limit, Keyword.put(original_api_rate_limit, :disabled, false)) + + config = %{ + static_match: %{}, + wildcard_match: %{}, + parametrized_match: %{ + ["api", "v2", "addresses", ":param", "election-rewards", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/addresses/:param/election-rewards/csv_", + isolate_rate_limit?: true + }, + ["api", "v2", "addresses", ":param", "celo", "election-rewards", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/addresses/:param/celo/election-rewards/csv_", + isolate_rate_limit?: true + }, + ["api", "v2", "addresses", ":param", "internal-transactions", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/addresses/:param/internal-transactions/csv_", + isolate_rate_limit?: true + }, + ["api", "v2", "addresses", ":param", "logs", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/addresses/:param/logs/csv_", + isolate_rate_limit?: true + }, + ["api", "v2", "addresses", ":param", "token-transfers", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/addresses/:param/token-transfers/csv_", + isolate_rate_limit?: true + }, + ["api", "v2", "addresses", ":param", "transactions", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/addresses/:param/transactions/csv_", + isolate_rate_limit?: true + }, + ["api", "v2", "tokens", ":param", "holders", "csv"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bucket_key_prefix: "api/v2/tokens/:param/holders/csv_", + isolate_rate_limit?: true + } + } + } + + :persistent_term.put(:rate_limit_config, config) on_exit(fn -> + :persistent_term.put(:rate_limit_config, original_config) Application.put_env(:block_scout_web, :recaptcha, old_recaptcha_env) - Application.put_env(:block_scout_web, :http_adapter, old_http_adapter) + :ets.delete_all_objects(BlockScoutWeb.RateLimit.Hammer.ETS) + Application.put_env(:block_scout_web, :api_rate_limit, original_api_rate_limit) end) {:ok, %{v2_secret_key: v2_secret_key, v3_secret_key: v3_secret_key}} diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/ethereum/deposit_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/ethereum/deposit_controller_test.exs new file mode 100644 index 000000000000..640f976259f0 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/ethereum/deposit_controller_test.exs @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Api.V2.Ethereum.DepositControllerTest do + use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + alias Explorer.Repo + + if @chain_type == :ethereum do + describe "/beacon/deposits" do + test "get empty list when no deposits exist", %{conn: conn} do + request = get(conn, "/api/v2/beacon/deposits") + assert response = json_response(request, 200) + assert %{"items" => []} = response + end + + test "get deposits", %{conn: conn} do + deposits = insert_list(51, :beacon_deposit) + + request = get(conn, "/api/v2/beacon/deposits") + assert response = json_response(request, 200) + assert %{"items" => deposits_json, "next_page_params" => next_page_params} = response + request_2nd_page = get(conn, "/api/v2/beacon/deposits", next_page_params) + assert response_2nd_page = json_response(request_2nd_page, 200) + assert %{"items" => deposits_json_2nd_page} = response_2nd_page + + assert deposits_json + |> Kernel.++(deposits_json_2nd_page) + |> Enum.map(&{&1["index"], &1["transaction_hash"], &1["block_hash"]}) == + deposits + |> Enum.reverse() + |> Enum.map(&{&1.index, to_string(&1.transaction_hash), to_string(&1.block_hash)}) + end + end + + describe "/beacon/deposits/count" do + test "returns 0 when no deposits exist", %{conn: conn} do + request = get(conn, "/api/v2/beacon/deposits/count") + assert response = json_response(request, 200) + assert %{"deposits_count" => 0} = response + end + + test "returns deposit count", %{conn: conn} do + Repo.delete_all(Explorer.Chain.Beacon.Deposit) + ExMachina.Sequence.reset("beacon_deposit_index") + + insert_list(3, :beacon_deposit) + + deposits_count = Repo.aggregate(Explorer.Chain.Beacon.Deposit, :count, :index) + + request = get(conn, "/api/v2/beacon/deposits/count") + assert response = json_response(request, 200) + assert %{"deposits_count" => ^deposits_count} = response + end + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs index 1c4d6d72e5b7..ec56b20105d4 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/import_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ImportControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs index cee0215b1919..6075d5afe952 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/internal_transaction_controller_test.exs @@ -1,94 +1,193 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.InternalTransactionControllerTest do use BlockScoutWeb.ConnCase alias Explorer.Chain.{Address, InternalTransaction} - - # todo: enable when /internal-transactions API endpoint will be enabled - # describe "/internal-transactions" do - # test "empty list", %{conn: conn} do - # request = get(conn, "/api/v2/internal-transactions") - - # assert response = json_response(request, 200) - # assert response["items"] == [] - # assert response["next_page_params"] == nil - # end - - # test "non empty list", %{conn: conn} do - # tx = - # :transaction - # |> insert() - # |> with_block() - - # insert(:internal_transaction, - # transaction: tx, - # block_hash: tx.block_hash, - # index: 0, - # block_index: 0 - # ) - - # request = get(conn, "/api/v2/internal-transactions") - - # assert response = json_response(request, 200) - # assert Enum.count(response["items"]) == 1 - # assert response["next_page_params"] == nil - # end - - # test "internal transactions with next_page_params", %{conn: conn} do - # transaction = insert(:transaction) |> with_block() - - # internal_transaction = - # insert(:internal_transaction, - # transaction: transaction, - # transaction_index: 0, - # block_number: transaction.block_number, - # block_hash: transaction.block_hash, - # index: 0, - # block_index: 0 - # ) - - # transaction_2 = insert(:transaction) |> with_block() - - # internal_transactions = - # for i <- 0..49 do - # insert(:internal_transaction, - # transaction: transaction_2, - # transaction_index: 0, - # block_number: transaction_2.block_number, - # block_hash: transaction_2.block_hash, - # index: i, - # block_index: i - # ) - # end - - # internal_transactions = [internal_transaction | internal_transactions] - - # request = get(conn, "/api/v2/internal-transactions") - # assert response = json_response(request, 200) - - # request_2nd_page = get(conn, "/api/v2/internal-transactions", response["next_page_params"]) - # assert response_2nd_page = json_response(request_2nd_page, 200) - - # check_paginated_response(response, response_2nd_page, internal_transactions) - # end - # end - - # defp compare_item(%InternalTransaction{} = internal_transaction, json) do - # assert Address.checksum(internal_transaction.from_address_hash) == json["from"]["hash"] - # assert Address.checksum(internal_transaction.to_address_hash) == json["to"]["hash"] - # assert to_string(internal_transaction.transaction_hash) == json["transaction_hash"] - # assert internal_transaction.block_number == json["block_number"] - # assert internal_transaction.block_index == json["block_index"] - # end - - # defp check_paginated_response(first_page_resp, second_page_resp, internal_transactions) do - # assert Enum.count(first_page_resp["items"]) == 50 - # assert first_page_resp["next_page_params"] != nil - # compare_item(Enum.at(internal_transactions, 50), Enum.at(first_page_resp["items"], 0)) - - # compare_item(Enum.at(internal_transactions, 1), Enum.at(first_page_resp["items"], 49)) - - # assert Enum.count(second_page_resp["items"]) == 1 - # assert second_page_resp["next_page_params"] == nil - # compare_item(Enum.at(internal_transactions, 0), Enum.at(second_page_resp["items"], 0)) - # end + alias Explorer.Chain.Cache.BackgroundMigrations + + describe "/internal-transactions" do + setup do + BackgroundMigrations.set_heavy_indexes_create_internal_transactions_block_number_desc_transaction_index_desc_index_desc_index_finished( + true + ) + + :ok + end + + test "empty list", %{conn: conn} do + request = get(conn, "/api/v2/internal-transactions") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "non empty list", %{conn: conn} do + tx = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: tx, + transaction_index: 0, + block_number: tx.block_number, + index: 1 + ) + + request = get(conn, "/api/v2/internal-transactions") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + end + + test "internal transactions with next_page_params", %{conn: conn} do + transaction = insert(:transaction) |> with_block() + + internal_transaction = + insert(:internal_transaction, + transaction: transaction, + transaction_index: 0, + block_number: transaction.block_number, + index: 1 + ) + + transaction_2 = insert(:transaction) |> with_block() + + internal_transactions = + for i <- 1..50 do + insert(:internal_transaction, + transaction: transaction_2, + transaction_index: 0, + block_number: transaction_2.block_number, + index: i + ) + end + + internal_transactions = InternalTransaction.preload_addresses([internal_transaction | internal_transactions]) + + request = get(conn, "/api/v2/internal-transactions") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/internal-transactions", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, internal_transactions) + end + + test "excludes zero index internal transaction when querying by transaction_hash", %{conn: conn} do + tx = + :transaction + |> insert() + |> with_block() + + # Insert internal transaction with index 0 (origin sender transaction) - should be excluded + insert(:internal_transaction, + transaction: tx, + transaction_index: 0, + block_number: tx.block_number, + index: 0, + type: :call + ) + + # Insert internal transaction with index 1 - should be included + _it_1 = + insert(:internal_transaction, + transaction: tx, + transaction_index: 0, + block_number: tx.block_number, + index: 1, + type: :call + ) + + # Insert internal transaction with index 2 - should be included + _it_2 = + insert(:internal_transaction, + transaction: tx, + transaction_index: 0, + block_number: tx.block_number, + index: 2, + type: :call + ) + + request = get(conn, "/api/v2/internal-transactions", %{"transaction_hash" => to_string(tx.hash)}) + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 2 + assert response["next_page_params"] == nil + + # Verify that only index 1 and 2 are returned, not index 0 + returned_indices = Enum.map(response["items"], & &1["index"]) + assert 1 in returned_indices + assert 2 in returned_indices + refute 0 in returned_indices + end + + test "pagination works correctly with zero index filtering", %{conn: conn} do + tx = + :transaction + |> insert() + |> with_block() + + # Insert internal transaction with index 0 - should be excluded + insert(:internal_transaction, + transaction: tx, + transaction_index: 0, + block_number: tx.block_number, + index: 0, + type: :call + ) + + # Insert 51 internal transactions with index 1-51 + for i <- 1..51 do + insert(:internal_transaction, + transaction: tx, + transaction_index: 0, + block_number: tx.block_number, + index: i, + type: :call + ) + end + + request = get(conn, "/api/v2/internal-transactions", %{"transaction_hash" => to_string(tx.hash)}) + assert response = json_response(request, 200) + + # Should return 50 items (excluding index 0) + assert Enum.count(response["items"]) == 50 + assert response["next_page_params"] != nil + + # First item should be index 1, not 0 + assert List.first(response["items"])["index"] == 1 + + # Get second page + request_2nd_page = get(conn, "/api/v2/internal-transactions", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + # Second page should have 1 item + assert Enum.count(response_2nd_page["items"]) == 1 + assert response_2nd_page["next_page_params"] == nil + end + end + + defp compare_item(%InternalTransaction{} = internal_transaction, json) do + assert Address.checksum(internal_transaction.from_address_hash) == json["from"]["hash"] + assert Address.checksum(internal_transaction.to_address_hash) == json["to"]["hash"] + assert to_string(internal_transaction.transaction.hash) == json["transaction_hash"] + assert internal_transaction.block_number == json["block_number"] + assert internal_transaction.transaction_index == json["transaction_index"] + assert internal_transaction.index == json["index"] + end + + defp check_paginated_response(first_page_resp, second_page_resp, internal_transactions) do + assert Enum.count(first_page_resp["items"]) == 50 + assert first_page_resp["next_page_params"] != nil + compare_item(Enum.at(internal_transactions, 50), Enum.at(first_page_resp["items"], 0)) + + compare_item(Enum.at(internal_transactions, 1), Enum.at(first_page_resp["items"], 49)) + + assert Enum.count(second_page_resp["items"]) == 1 + assert second_page_resp["next_page_params"] == nil + compare_item(Enum.at(internal_transactions, 0), Enum.at(second_page_resp["items"], 0)) + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/main_page_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/main_page_controller_test.exs index 3445aa5699ae..d4e596cced82 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/main_page_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/main_page_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.MainPageControllerTest do use BlockScoutWeb.ConnCase @@ -10,8 +11,8 @@ defmodule BlockScoutWeb.API.V2.MainPageControllerTest do setup do Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) - Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.TransactionsApiV2.child_id()) - Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.TransactionsApiV2.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Transactions.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Transactions.child_id()) :ok end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs new file mode 100644 index 000000000000..896242b3646b --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/optimism_controller_test.exs @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.OptimismControllerTest do + use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + import Mox + + alias Explorer.Chain.{Address, Data} + alias Explorer.Chain.Optimism.Deposit + alias Explorer.TestHelper + + setup :set_mox_global + + describe "/optimism/deposits" do + if @chain_type == :optimism do + test "deposits with next_page_params", %{conn: conn} do + deposits = insert_list(51, :op_deposit) + + request = get(conn, "/api/v2/optimism/deposits") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/optimism/deposits", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, deposits) + end + end + end + + describe "/optimism/interop/messages" do + if @chain_type == :optimism do + test "handles message with 0x prefixed payload", %{conn: conn} do + insert(:op_interop_message, + payload: %Data{ + bytes: <<48, 120, 120, 73, 33, 116, 36, 121, 34, 115, 113, 39, 119, 112, 117, 118, 105, 106, 108, 93>> + } + ) + + insert(:op_interop_message, payload: "0x30787849217424792273712777707576696a6c5d") + + TestHelper.get_chain_id_mock() + + conn = get(conn, "/api/v2/optimism/interop/messages") + + assert %{ + "items" => [ + %{ + "payload" => "0x30787849217424792273712777707576696a6c5d" + }, + %{ + "payload" => "0x30787849217424792273712777707576696a6c5d" + } + ], + "next_page_params" => nil + } = json_response(conn, 200) + end + end + end + + defp check_paginated_response(first_page_resp, second_page_resp, items) do + assert Enum.count(first_page_resp["items"]) == 50 + assert first_page_resp["next_page_params"] != nil + compare_item(Enum.at(items, 50), Enum.at(first_page_resp["items"], 0)) + compare_item(Enum.at(items, 1), Enum.at(first_page_resp["items"], 49)) + + assert Enum.count(second_page_resp["items"]) == 1 + assert second_page_resp["next_page_params"] == nil + compare_item(Enum.at(items, 0), Enum.at(second_page_resp["items"], 0)) + end + + defp compare_item(%Deposit{} = deposit, json) do + assert deposit.l1_block_number == json["l1_block_number"] + assert DateTime.to_iso8601(deposit.l1_block_timestamp) == json["l1_block_timestamp"] + assert to_string(deposit.l1_transaction_hash) == json["l1_transaction_hash"] + assert Address.checksum(deposit.l1_transaction_origin) == Address.checksum(json["l1_transaction_origin"]) + assert to_string(deposit.l2_transaction.hash) == json["l2_transaction_hash"] + assert to_string(deposit.l2_transaction.gas) == json["l2_transaction_gas_limit"] + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs new file mode 100644 index 000000000000..1dd356d4ec42 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/proxy/account_abstraction_controller_test.exs @@ -0,0 +1,251 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.Proxy.AccountAbstractionControllerTest do + use BlockScoutWeb.ConnCase + use EthereumJSONRPC.Case, async: false + + # Helper function to create complete user operation data + defp create_user_op_data(operation_hash, block_hash, transaction_hash) do + %{ + "hash" => operation_hash, + "sender" => "0xc9f2b9AF320D92A7c9CD67BBbF0f3055F81B6d31", + "nonce" => "0x000000000000000000000000000000000000000000000001000000000000042e", + "call_data" => + "0xb61d27f600000000000000000000000047c4442562280196b54c640acd3af9f45c981f0c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000064541c9e4e00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020c5561301aaa52dbc9fdcbf9fdae2ea1a929207270c909f2b6248a3bc80b042b200000000000000000000000000000000000000000000000000000000", + "call_gas_limit" => "633880", + "verification_gas_limit" => "34721", + "pre_verification_gas" => "48192", + "max_fee_per_gas" => "220000000", + "max_priority_fee_per_gas" => "2829647646", + "signature" => + "0xff002c1dc16510d46e607fc4f05f0bb3fe73b1e8102b6b982fcfd2d0d1eed241d69206ffa9d4914417a679fbfe89b36259c7b96d7f84239e5037d9e8cc5456afab131c", + "raw" => %{ + "nonce" => "18446744073709552686", + "pre_verification_gas" => "48192", + "paymaster_and_data" => + "0x2cc0c7981d846b9f2a16276556f6e8cb52bfb6330000000000000000000000000000788f00000000000000000000000000000000000000000000000068c81c5dc97f5c2402d4193ba68f29746742a77fad9571c3bb52056a877a98e369feab6c13f091314a3db58e01de16f79e459269577a54b17a7764f2728180b00d3a37281b", + "signature" => + "0xff002c1dc16510d46e607fc4f05f0bb3fe73b1e8102b6b982fcfd2d0d1eed241d69206ffa9d4914417a679fbfe89b36259c7b96d7f84239e5037d9e8cc5456afab131c", + "gas_fees" => "0x0000000000000000000000000d1cef00000000000000000000000000a8a8ff1e", + "account_gas_limits" => "0x000000000000000000000000000087a10000000000000000000000000009ac18", + "init_code" => "0x", + "call_data" => + "0xb61d27f600000000000000000000000047c4442562280196b54c640acd3af9f45c981f0c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000064541c9e4e00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020c5561301aaa52dbc9fdcbf9fdae2ea1a929207270c909f2b6248a3bc80b042b200000000000000000000000000000000000000000000000000000000", + "sender" => "0xc9f2b9AF320D92A7c9CD67BBbF0f3055F81B6d31" + }, + "aggregator" => nil, + "aggregator_signature" => nil, + "entry_point" => "0x0000000071727De22E5E9d8BAf0edAc6f37da032", + "entry_point_version" => "v0.7", + "transaction_hash" => transaction_hash || "0xe0f96d979a2610a89a642744124e7caa087fe4771092286b763ea0d963a73fca", + "block_number" => "9208931", + "block_hash" => block_hash || "0xfe288e8d52d3148bda81194b9767e82d2238303a8808a1331b865cbb35f8bb35", + "bundler" => "0x92613ef2DF071255D6ccd554651e7e445e939A32", + "bundle_index" => 0, + "index" => 0, + "factory" => nil, + "paymaster" => "0x2cc0c7981D846b9F2a16276556f6e8cb52BfB633", + "status" => true, + "revert_reason" => nil, + "gas" => "747656", + "gas_price" => "1067209413", + "gas_used" => "416893", + "sponsor_type" => "paymaster_sponsor", + "user_logs_start_index" => 0, + "user_logs_count" => 100, + "fee" => "444912133813809", + "consensus" => true, + "timestamp" => "2025-09-15T13:52:12.000000Z", + "execute_target" => "0x47C4442562280196b54c640acD3AF9F45c981F0C", + "execute_call_data" => + "0x541c9e4e00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020c5561301aaa52dbc9fdcbf9fdae2ea1a929207270c909f2b6248a3bc80b042b2" + } + end + + describe "/proxy/account-abstraction/operations/{operation_hash}/summary?just_request_body=true" do + setup do + # Setup for TransactionInterpretation service + original_ti_config = + Application.get_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation) + + # Setup for AccountAbstraction service + original_aa_config = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.AccountAbstraction) + + original_tesla_adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + aa_bypass = Bypass.open() + ti_bypass = Bypass.open() + + on_exit(fn -> + Application.put_env( + :block_scout_web, + BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + original_ti_config + ) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.AccountAbstraction, original_aa_config) + Application.put_env(:tesla, :adapter, original_tesla_adapter) + + Bypass.down(aa_bypass) + Bypass.down(ti_bypass) + end) + + # Return bypass instances for use in tests + %{ + aa_bypass: aa_bypass, + ti_bypass: ti_bypass + } + end + + test "return 422 on invalid operation hash", %{conn: conn} do + request = get(conn, "/api/v2/proxy/account-abstraction/operations/0x/summary?just_request_body=true") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/operation_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end + + test "return 404 on non existing operation", %{conn: conn, aa_bypass: aa_bypass, ti_bypass: ti_bypass} do + operation_hash = "0x" <> String.duplicate("0", 64) + + # Setup AccountAbstraction service + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.AccountAbstraction, + enabled: true, + service_url: "http://localhost:#{aa_bypass.port}" + ) + + # Setup TransactionInterpretation service + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: true, + service_url: "http://localhost:#{ti_bypass.port}" + ) + + Bypass.expect_once( + aa_bypass, + "GET", + "/api/v1/userOps/#{operation_hash}", + fn conn -> + Plug.Conn.resp(conn, 404, Jason.encode!(%{"error" => "Not found"})) + end + ) + + request = + get(conn, "/api/v2/proxy/account-abstraction/operations/#{operation_hash}/summary?just_request_body=true") + + assert %{"error" => "Not found"} = json_response(request, 404) + end + + test "return request body for existing operation", %{conn: conn, aa_bypass: aa_bypass, ti_bypass: ti_bypass} do + operation_hash = "0xcfb9123a6591d9f80ded2aec7e9842c2258c8e2a0d3c88f3a38a060aa10e9869" + + # Setup services + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.AccountAbstraction, + enabled: true, + service_url: "http://localhost:#{aa_bypass.port}" + ) + + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: true, + service_url: "http://localhost:#{ti_bypass.port}" + ) + + transaction = + :transaction + |> insert() + |> with_block() + + logs = + insert_list(51, :token_transfer_log, + transaction: transaction, + block: transaction.block + ) + + for log <- logs do + insert(:token_transfer_with_predefined_params, + log: log, + block: transaction.block + ) + end + + user_op = create_user_op_data(operation_hash, transaction.block_hash, transaction.hash) + + Bypass.expect_once( + aa_bypass, + "GET", + "/api/v1/userOps/#{operation_hash}", + fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(user_op)) + end + ) + + request = + get(conn, "/api/v2/proxy/account-abstraction/operations/#{operation_hash}/summary?just_request_body=true") + + assert response = json_response(request, 200) + + # Verify the structure of the request body + assert Map.has_key?(response, "data") + assert Map.has_key?(response, "logs_data") + assert Map.has_key?(response, "chain_id") + + # Verify data structure + data = response["data"] + assert Map.has_key?(data, "to") + assert Map.has_key?(data, "from") + assert Map.has_key?(data, "hash") + assert Map.has_key?(data, "type") + assert Map.has_key?(data, "value") + assert Map.has_key?(data, "method") + assert Map.has_key?(data, "status") + assert Map.has_key?(data, "transaction_types") + assert Map.has_key?(data, "raw_input") + assert Map.has_key?(data, "decoded_input") + assert Map.has_key?(data, "token_transfers") + + # Verify logs_data structure + logs_data = response["logs_data"] + assert Map.has_key?(logs_data, "items") + assert is_list(logs_data["items"]) + + # Verify chain_id is present and is an integer + assert is_integer(response["chain_id"]) + + # Verify operation data matches + assert operation_hash == data["hash"] + assert 0 == data["type"] + assert "0" == data["value"] + assert "ok" == data["status"] + + assert Enum.count(data["token_transfers"]) == 50 + end + + test "return 403 when transaction interpretation service is disabled", %{ + conn: conn, + aa_bypass: aa_bypass + } do + operation_hash = "0xcfb9123a6591d9f80ded2aec7e9842c2258c8e2a0d3c88f3a38a060aa10e9869" + + # Setup AccountAbstraction service as enabled + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.AccountAbstraction, + enabled: true, + service_url: "http://localhost:#{aa_bypass.port}" + ) + + # Setup TransactionInterpretation service as disabled + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: false + ) + + request = + get(conn, "/api/v2/proxy/account-abstraction/operations/#{operation_hash}/summary?just_request_body=true") + + assert %{"message" => "Transaction Interpretation Service is disabled"} = json_response(request, 403) + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/scroll_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/scroll_controller_test.exs new file mode 100644 index 000000000000..b126a30d825b --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/scroll_controller_test.exs @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.ScrollControllerTest do + use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + import Mox + + alias Explorer.Chain.Scroll.{Batch, Bridge} + + setup :set_mox_global + + describe "/scroll/deposits, /scroll/withdrawals, /scroll/batches" do + if @chain_type == :scroll do + test "deposits with next_page_params", %{conn: conn} do + deposits = insert_list(51, :scroll_bridge, type: :deposit) + + request = get(conn, "/api/v2/scroll/deposits") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/scroll/deposits", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, deposits) + end + + test "withdrawals with next_page_params", %{conn: conn} do + withdrawals = insert_list(51, :scroll_bridge, type: :withdrawal) + + request = get(conn, "/api/v2/scroll/withdrawals") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/scroll/withdrawals", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, withdrawals) + end + + test "batches with next_page_params", %{conn: conn} do + bundle = insert(:scroll_batch_bundle) + batches = insert_list(bundle.final_batch_number + 1, :scroll_batch, bundle_id: bundle.id) + + request = get(conn, "/api/v2/scroll/batches") + assert response = json_response(request, 200) + + request_2nd_page = get(conn, "/api/v2/scroll/batches", response["next_page_params"]) + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, batches) + end + end + end + + defp check_paginated_response(first_page_resp, second_page_resp, items) do + assert Enum.count(first_page_resp["items"]) == 50 + assert first_page_resp["next_page_params"] != nil + compare_item(Enum.at(items, 50), Enum.at(first_page_resp["items"], 0)) + compare_item(Enum.at(items, 1), Enum.at(first_page_resp["items"], 49)) + + assert Enum.count(second_page_resp["items"]) == 1 + assert second_page_resp["next_page_params"] == nil + compare_item(Enum.at(items, 0), Enum.at(second_page_resp["items"], 0)) + end + + defp compare_item(%Batch{} = item, json) do + assert item.number == json["number"] + assert to_string(item.commit_transaction_hash) == json["commitment_transaction"]["hash"] + assert item.commit_block_number == json["commitment_transaction"]["block_number"] + assert DateTime.to_iso8601(item.commit_timestamp) == json["commitment_transaction"]["timestamp"] + assert item.container == String.to_atom(json["data_availability"]["batch_data_container"]) + end + + defp compare_item(%Bridge{} = item, json) do + assert item.index == json["id"] + assert DateTime.to_iso8601(item.block_timestamp) == json["origination_timestamp"] + assert item.block_number == json["origination_transaction_block_number"] + assert to_string(item.amount) == json["value"] + + if item.type == :deposit do + assert to_string(item.l1_transaction_hash) == json["origination_transaction_hash"] + assert to_string(item.l2_transaction_hash) == json["completion_transaction_hash"] + else + assert to_string(item.l2_transaction_hash) == json["origination_transaction_hash"] + assert to_string(item.l1_transaction_hash) == json["completion_transaction_hash"] + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs index 63e9d8fd96f6..9d6053bef926 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/search_controller_test.exs @@ -1,11 +1,92 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.SearchControllerTest do use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] alias Explorer.Chain.{Address, Block} alias Explorer.Tags.AddressTag alias Plug.Conn.Query describe "/search" do + setup do + initial_value = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + + on_exit(fn -> + :persistent_term.put(:market_token_fetcher_enabled, initial_value) + end) + end + + test "get token-transfers with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/search?q=#{token.name}") + + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + + assert response == conn |> get("/api/v2/search?q=#{token.name}") |> json_response(200) + end + + test "get smart-contract with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/search?q=#{token.name}") + + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "scam" + + request = conn |> get("/api/v2/search?q=#{token.name}") + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token-transfers with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + + request = conn |> get("/api/v2/search?q=#{token.name}") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + end + + test "get token-transfers with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = conn |> get("/api/v2/search?q=#{token.name}") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + end + test "search block", %{conn: conn} do block = insert(:block) @@ -102,7 +183,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert item["type"] == "address" assert item["name"] == name.name - assert item["address"] == Address.checksum(address.hash) + assert item["address_hash"] == Address.checksum(address.hash) assert item["url"] =~ Address.checksum(address.hash) assert item["is_smart_contract_verified"] == address.verified end @@ -120,7 +201,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert item["type"] == "contract" assert item["name"] == contract.name - assert item["address"] == Address.checksum(contract.address_hash) + assert item["address_hash"] == Address.checksum(contract.address_hash) assert item["url"] =~ Address.checksum(contract.address_hash) assert item["is_smart_contract_verified"] == true end @@ -205,7 +286,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(labels_from_api) |> Enum.all?(fn {label, item} -> label.tag.display_name == item["name"] && item["type"] == "label" && - item["address"] == Address.checksum(label.address_hash) + item["address_hash"] == Address.checksum(label.address_hash) end) tokens_from_api = Enum.slice(response_2["items"], 1, 49) ++ Enum.slice(response_3["items"], 0, 2) @@ -214,7 +295,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(tokens_from_api) |> Enum.all?(fn {token, item} -> token.name == item["name"] && item["type"] == "token" && - item["address"] == Address.checksum(token.contract_address_hash) + item["address_hash"] == Address.checksum(token.contract_address_hash) end) contracts_from_api = Enum.slice(response_3["items"], 2, 48) ++ response_4["items"] @@ -223,7 +304,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(contracts_from_api) |> Enum.all?(fn {contract, item} -> contract.name == item["name"] && item["type"] == "contract" && - item["address"] == Address.checksum(contract.address_hash) + item["address_hash"] == Address.checksum(contract.address_hash) end) end @@ -278,7 +359,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(labels_from_api) |> Enum.all?(fn {label, item} -> label.tag.display_name == item["name"] && item["type"] == "label" && - item["address"] == Address.checksum(label.address_hash) + item["address_hash"] == Address.checksum(label.address_hash) end) tokens_from_api = Enum.slice(response_2["items"], 1, 49) ++ Enum.slice(response_3["items"], 0, 2) @@ -287,7 +368,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(tokens_from_api) |> Enum.all?(fn {token, item} -> token.name == item["name"] && item["type"] == "token" && - item["address"] == Address.checksum(token.contract_address_hash) + item["address_hash"] == Address.checksum(token.contract_address_hash) end) contracts_from_api = Enum.slice(response_3["items"], 2, 48) ++ response_4["items"] @@ -296,7 +377,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(contracts_from_api) |> Enum.all?(fn {contract, item} -> contract.name == item["name"] && item["type"] == "contract" && - item["address"] == Address.checksum(contract.address_hash) + item["address_hash"] == Address.checksum(contract.address_hash) end) end @@ -314,7 +395,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert item["type"] == "token" assert item["name"] == token.name assert item["symbol"] == token.symbol - assert item["address"] == Address.checksum(token.contract_address_hash) + assert item["address_hash"] == Address.checksum(token.contract_address_hash) assert item["token_url"] =~ Address.checksum(token.contract_address_hash) assert item["address_url"] =~ Address.checksum(token.contract_address_hash) assert item["token_type"] == token.type @@ -339,7 +420,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert item["type"] == "token" assert item["name"] == token.name assert item["symbol"] == token.symbol - assert item["address"] == Address.checksum(token.contract_address_hash) + assert item["address_hash"] == Address.checksum(token.contract_address_hash) assert item["token_url"] =~ Address.checksum(token.contract_address_hash) assert item["address_url"] =~ Address.checksum(token.contract_address_hash) assert item["token_type"] == token.type @@ -410,7 +491,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do item = Enum.at(response["items"], 0) assert item["type"] == "label" - assert item["address"] == Address.checksum(tag.address.hash) + assert item["address_hash"] == Address.checksum(tag.address.hash) assert item["name"] == tag.tag.display_name assert item["url"] =~ Address.checksum(tag.address.hash) assert item["is_smart_contract_verified"] == tag.address.verified @@ -437,7 +518,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do end test "search for a big positive integer", %{conn: conn} do - big_integer = :math.pow(2, 64) |> round |> :erlang.integer_to_binary() + big_integer = :math.pow(2, 64) |> round() |> :erlang.integer_to_binary() request = get(conn, "/api/v2/search?q=#{big_integer}") assert response = json_response(request, 200) @@ -446,7 +527,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do end test "search for a big negative integer", %{conn: conn} do - big_integer = (:math.pow(2, 64) - 1) |> round |> :erlang.integer_to_binary() + big_integer = (:math.pow(2, 64) - 1) |> round() |> :erlang.integer_to_binary() request = get(conn, "/api/v2/search?q=#{big_integer}") assert response = json_response(request, 200) @@ -459,6 +540,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do metadata_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata) bens_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS) old_chain_id = Application.get_env(:block_scout_web, :chain_id) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) chain_id = 1 Application.put_env(:block_scout_web, :chain_id, chain_id) old_hide_scam_addresses = Application.get_env(:block_scout_web, :hide_scam_addresses) @@ -470,6 +552,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, bens_envs) Application.put_env(:block_scout_web, :chain_id, old_chain_id) Application.put_env(:block_scout_web, :hide_scam_addresses, old_hide_scam_addresses) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, @@ -654,7 +737,9 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do ], "icon_url": "https://i.imgur.com/GOfUwCb.jpeg", "docs_url": "https://docs.ens.domains/" - } + }, + "protocol_dapp_url": "https://app.ens.domains/", + "protocol_dapp_logo": "https://i.imgur.com/ens-logo.png" } ], "next_page_params": null @@ -721,7 +806,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(labels_from_api) |> Enum.all?(fn {label, item} -> label.tag.display_name == item["name"] && item["type"] == "label" && - item["address"] == Address.checksum(label.address_hash) + item["address_hash"] == Address.checksum(label.address_hash) end) tokens_from_api = Enum.slice(response_2["items"], 2, 48) ++ Enum.slice(response_3["items"], 0, 3) @@ -730,7 +815,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(tokens_from_api) |> Enum.all?(fn {token, item} -> token.name == item["name"] && item["type"] == "token" && - item["address"] == Address.checksum(token.contract_address_hash) + item["address_hash"] == Address.checksum(token.contract_address_hash) end) contracts_from_api = Enum.slice(response_4["items"], 5, 45) ++ response_5["items"] @@ -739,7 +824,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(contracts_from_api) |> Enum.all?(fn {contract, item} -> contract.name == item["name"] && item["type"] == "contract" && - item["address"] == Address.checksum(contract.address_hash) + item["address_hash"] == Address.checksum(contract.address_hash) end) metadata_tags_from_api = Enum.slice(response_3["items"], 3, 47) ++ Enum.slice(response_4["items"], 0, 5) @@ -758,12 +843,14 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.all?(fn {{address_hash, tag}, api_item} -> tag["name"] == api_item["metadata"]["name"] && tag["slug"] == api_item["metadata"]["slug"] && api_item["type"] == "metadata_tag" && - api_item["address"] == address_hash + api_item["address_hash"] == address_hash end) ens = Enum.at(response["items"], 0) - assert ens["address"] == to_string(ens_address) + assert ens["address_hash"] == to_string(ens_address) assert ens["ens_info"]["name"] == name + assert ens["ens_info"]["protocol_dapp_url"] == "https://app.ens.domains/" + assert ens["ens_info"]["protocol_dapp_logo"] == "https://i.imgur.com/ens-logo.png" end test "check pagination #4 (ens and metadata tags (complex case) added)", %{conn: conn} do @@ -773,12 +860,14 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do old_chain_id = Application.get_env(:block_scout_web, :chain_id) chain_id = 1 Application.put_env(:block_scout_web, :chain_id, chain_id) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Bypass.down(bypass) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, metadata_envs) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, bens_envs) Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, @@ -1006,7 +1095,9 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do ], "icon_url": "https://i.imgur.com/GOfUwCb.jpeg", "docs_url": "https://docs.ens.domains/" - } + }, + "protocol_dapp_url": "https://app.ens.domains/", + "protocol_dapp_logo": "https://i.imgur.com/ens-logo.png" } ], "next_page_params": null @@ -1073,7 +1164,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(labels_from_api) |> Enum.all?(fn {label, item} -> label.tag.display_name == item["name"] && item["type"] == "label" && - item["address"] == Address.checksum(label.address_hash) + item["address_hash"] == Address.checksum(label.address_hash) end) tokens_from_api = Enum.slice(response_2["items"], 2, 48) ++ Enum.slice(response_3["items"], 0, 3) @@ -1082,7 +1173,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(tokens_from_api) |> Enum.all?(fn {token, item} -> token.name == item["name"] && item["type"] == "token" && - item["address"] == Address.checksum(token.contract_address_hash) + item["address_hash"] == Address.checksum(token.contract_address_hash) end) contracts_from_api = Enum.slice(response_4["items"], 5, 45) ++ response_5["items"] @@ -1091,7 +1182,7 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.zip(contracts_from_api) |> Enum.all?(fn {contract, item} -> contract.name == item["name"] && item["type"] == "contract" && - item["address"] == Address.checksum(contract.address_hash) + item["address_hash"] == Address.checksum(contract.address_hash) end) metadata_tags_from_api = Enum.slice(response_3["items"], 3, 47) ++ Enum.slice(response_4["items"], 0, 5) @@ -1110,12 +1201,653 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do |> Enum.all?(fn {{address_hash, tag}, api_item} -> tag["name"] == api_item["metadata"]["name"] && tag["slug"] == api_item["metadata"]["slug"] && api_item["type"] == "metadata_tag" && - api_item["address"] == address_hash + api_item["address_hash"] == address_hash end) ens = Enum.at(response["items"], 0) - assert ens["address"] == to_string(ens_address) + assert ens["address_hash"] == to_string(ens_address) assert ens["ens_info"]["name"] == name + assert ens["ens_info"]["protocol_dapp_url"] == "https://app.ens.domains/" + assert ens["ens_info"]["protocol_dapp_logo"] == "https://i.imgur.com/ens-logo.png" + end + + if @chain_type == :default do + test "finds a TAC operation", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + operation_id = "0xd06b6d3dbefcd1e4a5bb5806d0fdad87ae963bcc7d48d9a39ed361167958c09b" + + tac_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:38.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect_once( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search?q=#{operation_id}") + + assert %{ + "items" => [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:38.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } + ], + "next_page_params" => nil + } == json_response(request, 200) + end + + test "handles no results from TAC microservice", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + operation_id = "0xd06b6d3dbefcd1e4a5bb5806d0fdad87ae963bcc7d48d9a39ed361167958c09b" + + tac_response = """ + { + "items": [], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search?q=#{operation_id}") + + assert %{ + "items" => [], + "next_page_params" => nil + } == json_response(request, 200) + end + + test "finds a TAC operation with transaction", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + transaction = insert(:transaction) |> with_block() + + operation_id = "#{transaction.hash}" + + tac_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:38.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search?q=#{operation_id}") + assert response = json_response(request, 200) + + # tl to check order + assert %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:38.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } in tl(response["items"]) + + assert %{ + "priority" => 0, + "transaction_hash" => "#{transaction.hash}", + "type" => "transaction", + "timestamp" => "#{transaction.block_timestamp}" |> String.replace(" ", "T"), + "url" => "/tx/#{transaction.hash}" + } in response["items"] + end + + test "finds a TAC operation with block", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + transaction = insert(:transaction) |> with_block() + + operation_id = "#{transaction.block_hash}" + + tac_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:38.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search?q=#{operation_id}") + assert response = json_response(request, 200) + + # tl to check order + assert %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:38.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } in tl(response["items"]) + + assert %{ + "block_hash" => "#{transaction.block_hash}", + "block_number" => transaction.block_number, + "block_type" => "block", + "priority" => 3, + "type" => "block", + "timestamp" => "#{transaction.block_timestamp}" |> String.replace(" ", "T"), + "url" => "/block/#{transaction.block_hash}" + } in response["items"] + end + + test "finds TAC operations by sender and paginates", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + address_hash = Address.checksum(insert(:address).hash) + operation_id = "0x07d74803dd6fd1a684b50494c09e366f3a6be20cd09928ebdf80d178ce41b5a2" + + tac_first_response = """ + { + "items": [ + #{for i <- 10..59, do: """ + { + "operation_id": "#{operation_id}", + "sender": "#{address_hash}", + "timestamp": "2025-05-14T19:16:#{i}.000Z", + "type": "TON_TAC_TON" + }#{if i == 59, do: "", else: ","} + """} + ], + "next_page_params": { + "page_token": 1747250219, + "page_size": 50 + } + } + """ + + tac_second_response = """ + { + "items": [ + #{for i <- 10..59, do: """ + { + "operation_id": "#{operation_id}", + "sender": "#{address_hash}", + "timestamp": "#{if i == 0, do: "2025-05-14T19:16:59.000Z", else: "2025-05-14T19:17:#{i}.000Z"}", + "type": "TON_TAC_TON" + }#{if i == 59, do: "", else: ","} + """} + ], + "next_page_params": { + "page_token": 1747250279, + "page_size": 50 + } + } + """ + + tac_third_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": "#{address_hash}", + "timestamp": "2025-05-14T19:18:01.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + case conn.params["page_token"] do + nil -> Plug.Conn.resp(conn, 200, tac_first_response) + "1747250218" -> Plug.Conn.resp(conn, 200, tac_second_response) + "1747250279" -> Plug.Conn.resp(conn, 200, tac_third_response) + _ -> raise "Unexpected page_token" + end + end + ) + + request = get(conn, "/api/v2/search?q=#{address_hash}") + assert response = json_response(request, 200) + + second_page_request = + get(conn, "/api/v2/search", response["next_page_params"] |> Query.encode() |> Query.decode()) + + second_page_response = json_response(second_page_request, 200) + + third_page_request = + get(conn, "/api/v2/search", second_page_response["next_page_params"] |> Query.encode() |> Query.decode()) + + assert %{ + "items" => [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => address_hash, + "timestamp" => "2025-05-14T19:18:01.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } + ], + "next_page_params" => nil + } == json_response(third_page_request, 200) + end + + test "finds TAC operations by TON sender", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + tac_response = """ + { + "items": [ + { + "operation_id": "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "type": "ROLLBACK", + "timestamp": "2025-06-05T12:21:11.000Z", + "sender": { + "address": "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain": "TON" + } + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + case conn.params["q"] do + expected_q + when expected_q in [ + "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnXTt", + "UQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnSko", + "kQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnc9n", + "0QBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnZKi", + "0:67560e31eae4c26bc8e5ae1f185f25a99c9277a31c6b741436f99c3cc9aa319d" + ] -> + Plug.Conn.resp(conn, 200, tac_response) + + q -> + raise "Unexpected 'q' parameter #{inspect(q)}" + end + end + ) + + request = + get(conn, "/api/v2/search?q=#{URI.encode_www_form("EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response["items"] + + request = + get(conn, "/api/v2/search?q=#{URI.encode_www_form("EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnXTt")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response["items"] + + request = + get(conn, "/api/v2/search?q=#{URI.encode_www_form("UQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnSko")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response["items"] + + request = + get(conn, "/api/v2/search?q=#{URI.encode_www_form("kQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnc9n")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response["items"] + + request = + get(conn, "/api/v2/search?q=#{URI.encode_www_form("0QBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnZKi")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response["items"] + + request = + get( + conn, + "/api/v2/search?q=#{URI.encode_www_form("0:67560e31eae4c26bc8e5ae1f185f25a99c9277a31c6b741436f99c3cc9aa319d")}" + ) + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response["items"] + end + + test "finds TAC operations with transaction and paginates", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + operation_id = "0x07d74803dd6fd1a684b50494c09e366f3a6be20cd09928ebdf80d178ce41b5a2" + + insert(:transaction, hash: operation_id) + + tac_first_response = """ + { + "items": [ + #{for i <- 0..49, do: """ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:#{i}.000Z", + "type": "TON_TAC_TON" + }#{if i == 49, do: "", else: ","} + """} + ], + "next_page_params": { + "page_token": 1747250209, + "page_size": 50 + } + } + """ + + tac_second_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:50.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + case conn.params["page_token"] do + nil -> Plug.Conn.resp(conn, 200, tac_first_response) + "1747250208" -> Plug.Conn.resp(conn, 200, tac_second_response) + _ -> raise "Unexpected page_token" + end + end + ) + + request = get(conn, "/api/v2/search?q=#{operation_id}") + assert response = json_response(request, 200) + + next_page_request = + get(conn, "/api/v2/search", response["next_page_params"] |> Query.encode() |> Query.decode()) + + assert %{ + "items" => [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:50.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } + ], + "next_page_params" => nil + } == json_response(next_page_request, 200) + end end end @@ -1218,6 +1950,76 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do end describe "/search/quick" do + test "get token with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/search/quick?q=#{token.name}") + + response = json_response(request, 200) + + assert List.first(response)["reputation"] == "ok" + + assert response == conn |> get("/api/v2/search/quick?q=#{token.name}") |> json_response(200) + end + + test "get token with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/search/quick?q=#{token.name}") + + response = json_response(request, 200) + + assert List.first(response)["reputation"] == "scam" + + request = conn |> get("/api/v2/search/quick?q=#{token.name}") + response = json_response(request, 200) + + assert response == [] + end + + test "get token with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + + request = conn |> get("/api/v2/search/quick?q=#{token.name}") + response = json_response(request, 200) + + assert List.first(response)["reputation"] == "ok" + end + + test "get token with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:unique_token) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = conn |> get("/api/v2/search/quick?q=#{token.name}") + response = json_response(request, 200) + + assert List.first(response)["reputation"] == "ok" + end + test "check that all categories are in response list", %{conn: conn} do name = "156000" @@ -1234,16 +2036,16 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert response = json_response(request, 200) assert Enum.count(response) == 50 - assert response |> Enum.filter(fn x -> x["type"] == "label" end) |> Enum.map(fn x -> x["address"] end) == + assert response |> Enum.filter(fn x -> x["type"] == "label" end) |> Enum.map(fn x -> x["address_hash"] end) == tags |> Enum.reverse() |> Enum.take(16) |> Enum.map(fn tag -> Address.checksum(tag.address.hash) end) - assert response |> Enum.filter(fn x -> x["type"] == "contract" end) |> Enum.map(fn x -> x["address"] end) == + assert response |> Enum.filter(fn x -> x["type"] == "contract" end) |> Enum.map(fn x -> x["address_hash"] end) == contracts |> Enum.reverse() |> Enum.take(16) |> Enum.map(fn contract -> Address.checksum(contract.address_hash) end) - assert response |> Enum.filter(fn x -> x["type"] == "token" end) |> Enum.map(fn x -> x["address"] end) == + assert response |> Enum.filter(fn x -> x["type"] == "token" end) |> Enum.map(fn x -> x["address_hash"] end) == tokens |> Enum.reverse() |> Enum.sort_by(fn x -> x.is_verified_via_admin_panel end, :desc) @@ -1266,12 +2068,14 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do old_chain_id = Application.get_env(:block_scout_web, :chain_id) chain_id = 1 Application.put_env(:block_scout_web, :chain_id, chain_id) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Bypass.down(bypass) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, metadata_envs) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, bens_envs) Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, @@ -1357,7 +2161,9 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do ], "icon_url": "https://i.imgur.com/GOfUwCb.jpeg", "docs_url": "https://docs.ens.domains/" - } + }, + "protocol_dapp_url": "https://app.ens.domains/", + "protocol_dapp_logo": "https://i.imgur.com/ens-logo.png" } ], "next_page_params": null @@ -1379,25 +2185,26 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert response = json_response(request, 200) assert Enum.count(response) == 50 - assert response |> Enum.filter(fn x -> x["type"] == "label" end) |> Enum.map(fn x -> x["address"] end) == + assert response |> Enum.filter(fn x -> x["type"] == "label" end) |> Enum.map(fn x -> x["address_hash"] end) == tags |> Enum.reverse() |> Enum.take(12) |> Enum.map(fn tag -> Address.checksum(tag.address.hash) end) - assert response |> Enum.filter(fn x -> x["type"] == "contract" end) |> Enum.map(fn x -> x["address"] end) == + assert response |> Enum.filter(fn x -> x["type"] == "contract" end) |> Enum.map(fn x -> x["address_hash"] end) == contracts |> Enum.reverse() |> Enum.take(12) |> Enum.map(fn contract -> Address.checksum(contract.address_hash) end) - assert response |> Enum.filter(fn x -> x["type"] == "token" end) |> Enum.map(fn x -> x["address"] end) == + assert response |> Enum.filter(fn x -> x["type"] == "token" end) |> Enum.map(fn x -> x["address_hash"] end) == tokens |> Enum.reverse() |> Enum.sort_by(fn x -> x.is_verified_via_admin_panel end, :desc) |> Enum.take(13) |> Enum.map(fn token -> Address.checksum(token.contract_address_hash) end) - assert response |> Enum.filter(fn x -> x["type"] == "ens_domain" end) |> Enum.map(fn x -> x["address"] end) == [ - to_string(ens_address) - ] + assert response |> Enum.filter(fn x -> x["type"] == "ens_domain" end) |> Enum.map(fn x -> x["address_hash"] end) == + [ + to_string(ens_address) + ] metadata_tags = response |> Enum.filter(fn x -> x["type"] == "metadata_tag" end) @@ -1406,10 +2213,565 @@ defmodule BlockScoutWeb.API.V2.SearchControllerTest do assert metadata_tags |> Enum.with_index() |> Enum.all?(fn {x, index} -> - x["address"] == to_string(address_1) && x["metadata"]["name"] == "#{name} #{index}" + x["address_hash"] == to_string(address_1) && x["metadata"]["name"] == "#{name} #{index}" end) end + if @chain_type == :default do + test "finds a TAC operation", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + operation_id = "0xd06b6d3dbefcd1e4a5bb5806d0fdad87ae963bcc7d48d9a39ed361167958c09b" + + tac_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:38.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search/quick?q=#{operation_id}") + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:38.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } + ] == json_response(request, 200) + end + + test "finds a TAC operation with transaction", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + transaction = insert(:transaction) |> with_block() + + operation_id = "#{transaction.hash}" + + tac_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:38.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search/quick?q=#{operation_id}") + assert response = json_response(request, 200) + + # tl to check order + assert %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:38.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } in tl(response) + + assert %{ + "priority" => 0, + "transaction_hash" => "#{transaction.hash}", + "type" => "transaction", + "timestamp" => "#{transaction.block_timestamp}" |> String.replace(" ", "T"), + "url" => "/tx/#{transaction.hash}" + } in response + end + + test "finds a TAC operation with block", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + transaction = insert(:transaction) |> with_block() + + operation_id = "#{transaction.block_hash}" + + tac_response = """ + { + "items": [ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:38.000Z", + "type": "TON_TAC_TON" + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search/quick?q=#{operation_id}") + assert response = json_response(request, 200) + + # tl to check order + assert %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:38.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } in tl(response) + + assert %{ + "block_hash" => "#{transaction.block_hash}", + "block_number" => transaction.block_number, + "block_type" => "block", + "priority" => 3, + "type" => "block", + "timestamp" => "#{transaction.block_timestamp}" |> String.replace(" ", "T"), + "url" => "/block/#{transaction.block_hash}" + } in response + end + + test "finds a lot of TAC operations with address", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + address_hash = Address.checksum(insert(:address).hash) + operation_id = "0x07d74803dd6fd1a684b50494c09e366f3a6be20cd09928ebdf80d178ce41b5a2" + + tac_response = + """ + { + "items": [ + #{for i <- 0..49, do: """ + { + "operation_id": "#{operation_id}", + "sender": "#{address_hash}", + "timestamp": "2025-05-14T19:16:#{i}.000Z", + "type": "TON_TAC_TON" + }#{if i == 49, do: "", else: ","} + """} + ], + "next_page_params": { + "page_token": "2025-05-14T19:16:49.000Z", + "page_size": 50 + } + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == address_hash + + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search/quick?q=#{address_hash}") + assert response = json_response(request, 200) + + correct_response = [ + %{ + "address_hash" => address_hash, + "certified" => false, + "ens_info" => nil, + "is_smart_contract_verified" => false, + "name" => nil, + "priority" => 0, + "type" => "address", + "url" => "/address/#{address_hash}", + "reputation" => "ok", + "is_smart_contract_address" => false + } + | for( + i <- 0..48, + do: %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => address_hash, + "timestamp" => "2025-05-14T19:16:#{i}.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } + ) + ] + + assert correct_response == response + end + + test "finds a TAC operation with TON address", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + tac_response = """ + { + "items": [ + { + "operation_id": "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "type": "ROLLBACK", + "timestamp": "2025-06-05T12:21:11.000Z", + "sender": { + "address": "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain": "TON" + } + } + ], + "next_page_params": null + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + case conn.params["q"] do + expected_q + when expected_q in [ + "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnXTt", + "UQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnSko", + "kQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnc9n", + "0QBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnZKi", + "0:67560e31eae4c26bc8e5ae1f185f25a99c9277a31c6b741436f99c3cc9aa319d" + ] -> + Plug.Conn.resp(conn, 200, tac_response) + + q -> + raise "Unexpected 'q' parameter #{inspect(q)}" + end + end + ) + + request = + get(conn, "/api/v2/search/quick?q=#{URI.encode_www_form("EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response + + request = + get(conn, "/api/v2/search/quick?q=#{URI.encode_www_form("EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnXTt")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response + + request = + get(conn, "/api/v2/search/quick?q=#{URI.encode_www_form("UQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnSko")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response + + request = + get(conn, "/api/v2/search/quick?q=#{URI.encode_www_form("kQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnc9n")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response + + request = + get(conn, "/api/v2/search/quick?q=#{URI.encode_www_form("0QBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2-Zw8yaoxnZKi")}") + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response + + request = + get( + conn, + "/api/v2/search/quick?q=#{URI.encode_www_form("0:67560e31eae4c26bc8e5ae1f185f25a99c9277a31c6b741436f99c3cc9aa319d")}" + ) + + assert response = json_response(request, 200) + + assert [ + %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => "0xcdbc69a2d42c796bb8d6c2db76f366baa93f0ce5badcf8ed766f686b0f734612", + "sender" => %{ + "address" => "EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt", + "blockchain" => "TON" + }, + "timestamp" => "2025-06-05T12:21:11.000Z", + "type" => "ROLLBACK" + }, + "type" => "tac_operation" + } + ] == response + end + + test "finds a lot if TAC operations with transaction", %{conn: conn} do + bypass = Bypass.open() + tac_envs = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, tac_envs) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + transaction = + insert(:transaction, hash: "0x07d74803dd6fd1a684b50494c09e366f3a6be20cd09928ebdf80d178ce41b5a2") + |> with_block() + + operation_id = "#{transaction.hash}" + + tac_response = """ + { + "items": [ + #{for i <- 0..49, do: """ + { + "operation_id": "#{operation_id}", + "sender": null, + "timestamp": "2025-05-14T19:16:#{i}.000Z", + "type": "TON_TAC_TON" + }#{if i == 49, do: "", else: ","} + """} + ], + "next_page_params": { + "page_token": "2025-05-14T19:16:49.000Z", + "page_size": 50 + } + } + """ + + Bypass.expect( + bypass, + "GET", + "/api/v1/tac/operations", + fn conn -> + assert conn.params["q"] == operation_id + + Plug.Conn.resp(conn, 200, tac_response) + end + ) + + request = get(conn, "/api/v2/search/quick?q=#{operation_id}") + assert response = json_response(request, 200) + + correct_response = [ + %{ + "priority" => 0, + "transaction_hash" => "#{transaction.hash}", + "type" => "transaction", + "timestamp" => "#{transaction.block_timestamp}" |> String.replace(" ", "T"), + "url" => "/tx/#{transaction.hash}" + } + | for( + i <- 0..48, + do: %{ + "priority" => 0, + "tac_operation" => %{ + "operation_id" => operation_id, + "sender" => nil, + "timestamp" => "2025-05-14T19:16:#{i}.000Z", + "type" => "TON_TAC_TON" + }, + "type" => "tac_operation" + } + ) + ] + + assert correct_response == response + end + end + test "returns empty list and don't crash", %{conn: conn} do request = get(conn, "/api/v2/search/quick?q=qwertyuioiuytrewertyuioiuytrertyuio") assert [] = json_response(request, 200) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs index 74d1fb63230f..729d40df3e0e 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/smart_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do use BlockScoutWeb.ConnCase, async: false use BlockScoutWeb.ChannelCase, async: false @@ -34,6 +35,14 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do end describe "/smart-contracts/{address_hash}" do + setup do + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + end + test "get 404 on non existing SC", %{conn: conn} do address = build(:address) @@ -45,13 +54,22 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/smart-contracts/0x") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get unverified smart-contract info", %{conn: conn} do address = insert(:contract_address) - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") response = json_response(request, 200) @@ -60,10 +78,10 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do %{ "proxy_type" => nil, "implementations" => [], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => nil, - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } insert(:transaction, @@ -73,8 +91,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do ) |> with_block(status: :ok) - TestHelper.get_eip1967_implementation_error_response() - request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") response = json_response(request, 200) @@ -82,11 +98,40 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do %{ "proxy_type" => nil, "implementations" => [], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil + } + end + + test "get unverified smart-contract with failed creation status", %{conn: conn} do + address = insert(:address, contract_code: "0x") + + creation_bytecode = + "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029" + + insert(:transaction, + created_contract_address_hash: address.hash, + input: creation_bytecode + ) + |> with_block(status: :error) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + + request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") + response = json_response(request, 200) + + assert response == + %{ + "proxy_type" => nil, + "implementations" => [], + "deployed_bytecode" => "0x", + "creation_bytecode" => creation_bytecode, + "creation_status" => "failed", + "conflicting_implementations" => nil } end @@ -111,7 +156,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert implementation.proxy_type == :eip1967 request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(proxy_address.hash)}") - response = json_response(request, 200) + json_response(request, 200) end test "get smart-contract", %{conn: conn} do @@ -133,8 +178,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do |> with_block(status: :ok) implementation_address = insert(:address) - implementation_address_hash_string = to_string(implementation_address.hash) - formatted_implementation_address_hash_string = to_string(Address.checksum(implementation_address.hash)) correct_response = %{ "verified_twin_address_hash" => nil, @@ -162,7 +205,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "external_libraries" => [%{"name" => "ABC", "address_hash" => Address.checksum(lib_address)}], "constructor_args" => target_contract.constructor_arguments, "decoded_constructor_args" => nil, - "is_self_destructed" => false, "deployed_bytecode" => "0x6080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", "creation_bytecode" => @@ -170,22 +212,23 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "abi" => target_contract.abi, "proxy_type" => "eip1967", "implementations" => [ - %{ - "address_hash" => formatted_implementation_address_hash_string, - "address" => formatted_implementation_address_hash_string, + prepare_implementation(%{ + "address_hash" => implementation_address.hash |> Address.checksum() |> to_string(), "name" => nil - } + }) ], "is_verified_via_eth_bytecode_db" => target_contract.verified_via_eth_bytecode_db, "is_verified_via_verifier_alliance" => target_contract.verified_via_verifier_alliance, - "language" => target_contract |> SmartContract.language() |> to_string(), + "language" => target_contract.language |> to_string(), "license_type" => "none", "certified" => false, "is_blueprint" => false, - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } - TestHelper.get_eip1967_implementation_non_zero_address(implementation_address_hash_string) + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(target_contract.address_hash)}") response = json_response(request, 200) @@ -193,7 +236,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do result_props = correct_response |> Map.keys() for prop <- result_props do - assert prepare_implementation(correct_response[prop]) == response[prop] + assert correct_response[prop] == response[prop] end end @@ -275,7 +318,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do %{"name" => "_implementationAddress", "type" => "address"} ] ], - "is_self_destructed" => false, "deployed_bytecode" => "0x6080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", "creation_bytecode" => @@ -285,13 +327,15 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [], "is_verified_via_eth_bytecode_db" => target_contract.verified_via_eth_bytecode_db, "is_verified_via_verifier_alliance" => target_contract.verified_via_verifier_alliance, - "language" => target_contract |> SmartContract.language() |> to_string(), + "language" => target_contract.language |> to_string(), "license_type" => "gnu_agpl_v3", "certified" => false, - "is_blueprint" => false + "is_blueprint" => false, + "conflicting_implementations" => nil } - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(target_contract.address_hash)}") response = json_response(request, 200) @@ -380,7 +424,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "external_libraries" => [%{"name" => "ABC", "address_hash" => Address.checksum(lib_address)}], "constructor_args" => nil, "decoded_constructor_args" => nil, - "is_self_destructed" => false, "deployed_bytecode" => "0x6080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", "creation_bytecode" => @@ -390,13 +433,15 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [], "is_verified_via_eth_bytecode_db" => target_contract.verified_via_eth_bytecode_db, "is_verified_via_verifier_alliance" => target_contract.verified_via_verifier_alliance, - "language" => target_contract |> SmartContract.language() |> to_string(), + "language" => target_contract.language |> to_string(), "license_type" => "none", "certified" => false, - "is_blueprint" => false + "is_blueprint" => false, + "conflicting_implementations" => nil } - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") response = json_response(request, 200) @@ -478,17 +523,16 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do |> with_block(status: :ok) correct_response = %{ - "is_self_destructed" => false, "deployed_bytecode" => proxy_deployed_bytecode, "creation_bytecode" => proxy_transaction_input, "proxy_type" => "eip1167", "implementations" => [ - %{ + prepare_implementation(%{ "address_hash" => Address.checksum(implementation_contract.address_hash), - "address" => Address.checksum(implementation_contract.address_hash), "name" => implementation_contract.name - } - ] + }) + ], + "conflicting_implementations" => nil } request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(proxy_address.hash)}") @@ -497,7 +541,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do result_props = correct_response |> Map.keys() for prop <- result_props do - assert prepare_implementation(correct_response[prop]) == response[prop] + assert correct_response[prop] == response[prop] end end @@ -540,7 +584,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "external_libraries" => target_contract.external_libraries, "constructor_args" => target_contract.constructor_arguments, "decoded_constructor_args" => nil, - "is_self_destructed" => false, "deployed_bytecode" => "0x6080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", "creation_bytecode" => @@ -550,14 +593,16 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [], "is_verified_via_eth_bytecode_db" => target_contract.verified_via_eth_bytecode_db, "is_verified_via_verifier_alliance" => target_contract.verified_via_verifier_alliance, - "language" => target_contract |> SmartContract.language() |> to_string(), + "language" => target_contract.language |> to_string(), "license_type" => "none", "certified" => false, "is_blueprint" => true, - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(target_contract.address_hash)}") response = json_response(request, 200) @@ -571,6 +616,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do end test "doesn't get smart-contract implementation for 'Clones with immutable arguments' pattern", %{conn: conn} do + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + implementation_contract = insert(:smart_contract, external_libraries: [], @@ -633,62 +680,138 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do ) |> with_block(status: :ok) - formatted_implementation_address_hash_string = to_string(Address.checksum(implementation_contract.address_hash)) - correct_response = %{ "proxy_type" => "clone_with_immutable_arguments", "implementations" => [ - %{ - "address_hash" => formatted_implementation_address_hash_string, - "address" => formatted_implementation_address_hash_string, + prepare_implementation(%{ + "address_hash" => implementation_contract.address_hash |> Address.checksum() |> to_string(), "name" => implementation_contract.name - } + }) ], - "is_self_destructed" => false, "deployed_bytecode" => proxy_deployed_bytecode, - "creation_bytecode" => proxy_transaction_input + "creation_bytecode" => proxy_transaction_input, + "conflicting_implementations" => nil } request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(proxy_address.hash)}") response = json_response(request, 200) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + result_props = correct_response |> Map.keys() for prop <- result_props do - assert prepare_implementation(correct_response[prop]) == response[prop] + assert correct_response[prop] == response[prop] + end + end + + test "returns smart-contract with conflicting implementations", %{conn: conn} do + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + proxy_smart_contract = insert(:smart_contract) + implementation_smart_contract1 = insert(:smart_contract, name: "implementation1") + implementation_smart_contract2 = insert(:smart_contract, name: "implementation2") + implementation_smart_contract3 = insert(:smart_contract, name: "implementation3") + + correct_response = %{ + "proxy_type" => "eip1967", + "implementations" => [ + prepare_implementation(%{ + "address_hash" => implementation_smart_contract1.address_hash |> Address.checksum() |> to_string(), + "name" => implementation_smart_contract1.name + }) + ], + "conflicting_implementations" => [ + %{ + "proxy_type" => "eip1967", + "implementations" => [ + prepare_implementation(%{ + "address_hash" => implementation_smart_contract1.address_hash |> Address.checksum() |> to_string(), + "name" => implementation_smart_contract1.name + }) + ] + }, + %{ + "proxy_type" => "eip1822", + "implementations" => [ + prepare_implementation(%{ + "address_hash" => implementation_smart_contract3.address_hash |> Address.checksum() |> to_string() + }) + ] + }, + %{ + "proxy_type" => "eip1967_oz", + "implementations" => [ + prepare_implementation(%{ + "address_hash" => implementation_smart_contract2.address_hash |> Address.checksum() |> to_string() + }) + ] + } + ] + } + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests( + eip1967: implementation_smart_contract1.address_hash, + eip1967_oz: implementation_smart_contract2.address_hash, + eip1822: implementation_smart_contract3.address_hash + ) + + request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(proxy_smart_contract.address_hash)}") + response = json_response(request, 200) + + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + + result_props = correct_response |> Map.keys() + + for prop <- result_props do + assert correct_response[prop] == response[prop] end end if Application.compile_env(:explorer, :chain_type) !== :zksync do describe "/smart-contracts/{address_hash} <> eth_bytecode_db" do setup do - old_interval_env = Application.get_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand) + old_fetcher_env = Application.get_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand) - :ok + old_verifier_env = + Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, []) - on_exit(fn -> - Application.put_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand, old_interval_env) - end) - end - - test "automatically verify contract", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) old_chain_id = Application.get_env(:block_scout_web, :chain_id) - Application.put_env(:block_scout_web, :chain_id, 5) - + {:ok, _pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) bypass = Bypass.open() - eth_bytecode_response = File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_response.json") - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:block_scout_web, :chain_id, 5) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true + Application.put_env( + :explorer, + Explorer.SmartContract.RustVerifierInterfaceBehaviour, + Keyword.merge( + old_verifier_env, + service_url: "http://localhost:#{bypass.port}", + enabled: true, + type: "eth_bytecode_db", + eth_bytecode_db?: true + ) ) + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand, old_fetcher_env) + Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_verifier_env) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end) + + {:ok, bypass: bypass} + end + + test "automatically verify contract", %{conn: conn, bypass: bypass} do + eth_bytecode_response = File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_response.json") + address = insert(:contract_address) insert(:transaction, @@ -709,7 +832,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do Conn.resp(conn, 200, eth_bytecode_response) end) - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -733,48 +857,28 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do %{ "proxy_type" => nil, "implementations" => [], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } - TestHelper.get_all_proxies_implementation_zero_addresses() - request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") assert response = json_response(request, 200) assert %{"is_verified" => true} = response assert %{"is_verified_via_eth_bytecode_db" => true} = response assert %{"is_partially_verified" => true} = response assert %{"is_fully_verified" => false} = response - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end - test "automatically verify contract using search-all (ethBytecodeDbSources) endpoint", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - - Application.put_env(:block_scout_web, :chain_id, 5) - - bypass = Bypass.open() - + test "automatically verify contract using search-all (ethBytecodeDbSources) endpoint", %{ + conn: conn, + bypass: bypass + } do eth_bytecode_response = File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_all_local_sources_response.json") - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) - address = insert(:contract_address) insert(:transaction, @@ -795,7 +899,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do Conn.resp(conn, 200, eth_bytecode_response) end) - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -819,15 +924,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do %{ "proxy_type" => nil, "implementations" => [], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } - TestHelper.get_all_proxies_implementation_zero_addresses() - request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") assert response = json_response(request, 200) assert %{"is_verified" => true} = response @@ -881,33 +984,12 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert response["additional_sources"] |> Enum.sort_by(fn x -> x["file_path"] end) == additional_sources |> Enum.sort_by(fn x -> x["file_path"] end) - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end - test "automatically verify contract using search-all (sourcifySources) endpoint", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - - Application.put_env(:block_scout_web, :chain_id, 5) - - bypass = Bypass.open() - + test "automatically verify contract using search-all (sourcifySources) endpoint", %{conn: conn, bypass: bypass} do eth_bytecode_response = File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_all_sourcify_sources_response.json") - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) - address = insert(:contract_address) insert(:transaction, @@ -928,7 +1010,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do Conn.resp(conn, 200, eth_bytecode_response) end) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -950,13 +1033,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert response == %{ - "proxy_type" => "unknown", + "proxy_type" => nil, "implementations" => [], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -967,35 +1050,17 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert %{"is_partially_verified" => true} = response assert %{"is_fully_verified" => false} = response assert response["file_path"] == "Test.sol" - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end - test "automatically verify contract using search-all (sourcifySources with libraries) endpoint", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - - Application.put_env(:block_scout_web, :chain_id, 5) - - bypass = Bypass.open() - + test "automatically verify contract using search-all (sourcifySources with libraries) endpoint", %{ + conn: conn, + bypass: bypass + } do eth_bytecode_response = File.read!( "./test/support/fixture/smart_contract/eth_bytecode_db_search_all_sourcify_sources_with_libs_response.json" ) - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) - address = insert(:contract_address) insert(:transaction, @@ -1016,7 +1081,8 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do Conn.resp(conn, 200, eth_bytecode_response) end) - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1040,15 +1106,13 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do %{ "proxy_type" => nil, "implementations" => [], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } - TestHelper.get_all_proxies_implementation_zero_addresses() - request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") assert response = json_response(request, 200) @@ -1081,32 +1145,75 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert response["additional_sources"] |> Enum.sort_by(fn x -> x["file_path"] end) == additional_sources |> Enum.sort_by(fn x -> x["file_path"] end) - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end - test "automatically verify contract using search-all (allianceSources) endpoint", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) + test "top-level libraries field takes precedence over compilerSettings.libraries", %{ + conn: conn, + bypass: bypass + } do + eth_bytecode_response = + File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_response_with_libs_priority.json") - Application.put_env(:block_scout_web, :chain_id, 5) + address = insert(:contract_address) - bypass = Bypass.open() + insert(:transaction, + created_contract_address_hash: address.hash, + input: + "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029" + ) + |> with_block(status: :ok) - eth_bytecode_response = - File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_all_alliance_sources_response.json") + topic = "addresses:#{address.hash}" - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) + {:ok, _reply, _socket} = + BlockScoutWeb.V2.UserSocket + |> socket("no_id", %{}) + |> subscribe_and_join(topic) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) + Bypass.expect_once(bypass, "POST", "/api/v2/bytecodes/sources_search_all", fn conn -> + Conn.resp(conn, 200, eth_bytecode_response) + end) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + + request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") + + assert_receive %Phoenix.Socket.Message{ + payload: %{}, + event: "eth_bytecode_db_lookup_started", + topic: ^topic + }, + :timer.seconds(1) + + assert_receive %Phoenix.Socket.Message{ + payload: %{}, + event: "smart_contract_was_verified", + topic: ^topic + }, + :timer.seconds(1) + + _response = json_response(request, 200) + + request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") + assert response = json_response(request, 200) + assert %{"is_verified" => true} = response + assert %{"is_fully_verified" => true} = response + + # Verify that top-level libraries field is used instead of compilerSettings.libraries + # Top-level has: "contracts/Library.sol:NewLibrary" => "0x1234567890123456789012345678901234567890" + # compilerSettings has: "Test.sol" => {"OldLibrary" => "0x0000000000000000000000000000000000000001"} + assert response["external_libraries"] == [ + %{ + "address_hash" => Address.checksum("0x1234567890123456789012345678901234567890"), + "name" => "contracts/Library.sol:NewLibrary" + } + ] + end + + test "automatically verify contract using search-all (allianceSources) endpoint", %{conn: conn, bypass: bypass} do + eth_bytecode_response = + File.read!("./test/support/fixture/smart_contract/eth_bytecode_db_search_all_alliance_sources_response.json") address = insert(:contract_address) @@ -1129,9 +1236,10 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do end) implementation_address = insert(:address) - implementation_address_hash_string = to_string(implementation_address.hash) - formatted_implementation_address_hash_string = to_string(Address.checksum(implementation_address.hash)) - TestHelper.get_eip1967_implementation_non_zero_address(implementation_address_hash_string) + formatted_implementation_address_hash_string = implementation_address.hash |> Address.checksum() |> to_string() + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1157,15 +1265,14 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [ prepare_implementation(%{ "address_hash" => formatted_implementation_address_hash_string, - "address" => formatted_implementation_address_hash_string, "name" => nil }) ], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1176,7 +1283,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [ %{ "address_hash" => ^formatted_implementation_address_hash_string, - "address" => ^formatted_implementation_address_hash_string, "name" => nil } ] @@ -1217,35 +1323,17 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert response["additional_sources"] |> Enum.sort_by(fn x -> x["file_path"] end) == additional_sources |> Enum.sort_by(fn x -> x["file_path"] end) - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end - test "automatically verify contract using search-all (prefer sourcify FULL match) endpoint", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - - Application.put_env(:block_scout_web, :chain_id, 5) - - bypass = Bypass.open() - + test "automatically verify contract using search-all (prefer sourcify FULL match) endpoint", %{ + conn: conn, + bypass: bypass + } do eth_bytecode_response = File.read!( "./test/support/fixture/smart_contract/eth_bytecode_db_search_all_alliance_sources_partial_response.json" ) - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) - address = insert(:contract_address) insert(:transaction, @@ -1267,9 +1355,10 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do end) implementation_address = insert(:address) - implementation_address_hash_string = to_string(implementation_address.hash) - formatted_implementation_address_hash_string = to_string(Address.checksum(implementation_address.hash)) - TestHelper.get_eip1967_implementation_non_zero_address(implementation_address_hash_string) + formatted_implementation_address_hash_string = implementation_address.hash |> Address.checksum() |> to_string() + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1295,15 +1384,14 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [ prepare_implementation(%{ "address_hash" => formatted_implementation_address_hash_string, - "address" => formatted_implementation_address_hash_string, "name" => nil }) ], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1314,7 +1402,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [ %{ "address_hash" => ^formatted_implementation_address_hash_string, - "address" => ^formatted_implementation_address_hash_string, "name" => nil } ] @@ -1355,35 +1442,17 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert response["additional_sources"] |> Enum.sort_by(fn x -> x["file_path"] end) == additional_sources |> Enum.sort_by(fn x -> x["file_path"] end) - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end - test "automatically verify contract using search-all (take eth bytecode db FULL match) endpoint", %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - - Application.put_env(:block_scout_web, :chain_id, 5) - - bypass = Bypass.open() - + test "automatically verify contract using search-all (take eth bytecode db FULL match) endpoint", %{ + conn: conn, + bypass: bypass + } do eth_bytecode_response = File.read!( "./test/support/fixture/smart_contract/eth_bytecode_db_search_all_alliance_sources_partial_response_eth_bdb_full.json" ) - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) - address = insert(:contract_address) insert(:transaction, @@ -1405,9 +1474,10 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do end) implementation_address = insert(:address) - implementation_address_hash_string = to_string(implementation_address.hash) - formatted_implementation_address_hash_string = to_string(Address.checksum(implementation_address.hash)) - TestHelper.get_eip1967_implementation_non_zero_address(implementation_address_hash_string) + formatted_implementation_address_hash_string = implementation_address.hash |> Address.checksum() |> to_string() + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1433,15 +1503,14 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [ prepare_implementation(%{ "address_hash" => formatted_implementation_address_hash_string, - "address" => formatted_implementation_address_hash_string, "name" => nil }) ], - "is_self_destructed" => false, "deployed_bytecode" => to_string(address.contract_code), "creation_bytecode" => "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582061b7676067d537e410bb704932a9984739a959416170ea17bda192ac1218d2790029", - "status" => "success" + "creation_status" => "success", + "conflicting_implementations" => nil } request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1452,7 +1521,6 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do "implementations" => [ %{ "address_hash" => ^formatted_implementation_address_hash_string, - "address" => ^formatted_implementation_address_hash_string, "name" => nil } ] @@ -1493,21 +1561,11 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert response["additional_sources"] |> Enum.sort_by(fn x -> x["file_path"] end) == additional_sources |> Enum.sort_by(fn x -> x["file_path"] end) - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end test "check fetch interval for LookUpSmartContractSourcesOnDemand and use sources:search endpoint since chain_id is unset", - %{conn: conn} do - {:ok, pid} = Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand.start_link([]) - old_chain_id = Application.get_env(:block_scout_web, :chain_id) - + %{conn: conn, bypass: bypass} do Application.put_env(:block_scout_web, :chain_id, nil) - - bypass = Bypass.open() address = insert(:contract_address) topic = "addresses:#{address.hash}" @@ -1523,24 +1581,14 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do ) |> with_block(status: :ok) - old_env = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: "http://localhost:#{bypass.port}", - enabled: true, - type: "eth_bytecode_db", - eth_bytecode_db?: true - ) - - old_interval_env = Application.get_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand) - Application.put_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand, fetch_interval: 0) Bypass.expect_once(bypass, "POST", "/api/v2/bytecodes/sources_search", fn conn -> Conn.resp(conn, 200, "{\"sources\": []}") end) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() _request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(address.hash)}") @@ -1628,70 +1676,133 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do topic: ^topic }, :timer.seconds(1) - - Application.put_env(:block_scout_web, :chain_id, old_chain_id) - Application.put_env(:explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand, old_interval_env) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) - Bypass.down(bypass) - GenServer.stop(pid) end end end - for {state_name, migrations_finished?} <- [ - {"completed migrations", true}, - {"migrations in progress", false} - ] do - describe "/smart-contracts" <> " (with #{state_name})" do - setup do - :meck.expect( - Explorer.Chain.SmartContract, - :background_migrations_finished?, - fn -> - unquote(migrations_finished?) - end - ) + describe "/smart-contracts" do + test "get smart-contract with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) - :ok - end + insert(:smart_contract) - test "get [] on empty db", %{conn: conn} do - request = get(conn, "/api/v2/smart-contracts") + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/smart-contracts") + response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) - end + assert List.first(response["items"])["reputation"] == "ok" + + assert response == conn |> get("/api/v2/smart-contracts") |> json_response(200) + end + + test "get smart-contract with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + target_contract = insert(:smart_contract) + insert(:scam_badge_to_address, address_hash: target_contract.address_hash) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/smart-contracts") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "scam" + + request = conn |> get("/api/v2/smart-contracts") + response = json_response(request, 200) + + assert response["items"] == [] + end - test "get correct smart contract", %{conn: conn} do - smart_contract = insert(:smart_contract) - request = get(conn, "/api/v2/smart-contracts") + test "get smart-contract with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + insert(:smart_contract) + + request = conn |> get("/api/v2/smart-contracts") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + end + + test "get smart-contract with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + target_contract = insert(:smart_contract) + insert(:scam_badge_to_address, address_hash: target_contract.address_hash) + + request = conn |> get("/api/v2/smart-contracts") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + end + + test "get [] on empty db", %{conn: conn} do + request = get(conn, "/api/v2/smart-contracts") + + assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + end + + test "get correct smart contract", %{conn: conn} do + smart_contract = insert(:smart_contract) + request = get(conn, "/api/v2/smart-contracts") + + assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) + compare_item(smart_contract, sc) + assert sc["address"]["is_verified"] == true + assert sc["address"]["is_contract"] == true + end + + test "get filtered smart contracts by language", %{conn: conn} do + smart_contracts = [ + {"solidity", insert(:smart_contract, language: :solidity)}, + {"vyper", insert(:smart_contract, language: :vyper)}, + {"yul", insert(:smart_contract, abi: nil, language: :yul)} + ] + + for {filter, smart_contract} <- smart_contracts do + request = get(conn, "/api/v2/smart-contracts", %{"filter" => filter}) assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) compare_item(smart_contract, sc) assert sc["address"]["is_verified"] == true assert sc["address"]["is_contract"] == true end + end - test "get filtered smart contracts when flag is set and language is not set", %{conn: conn} do - smart_contracts = [ - {"solidity", insert(:smart_contract, is_vyper_contract: false, language: nil)}, - {"vyper", insert(:smart_contract, is_vyper_contract: true, language: nil)}, - {"yul", insert(:smart_contract, abi: nil, is_vyper_contract: false, language: nil)} - ] + test "get filtered vyper smart contracts", %{conn: conn} do + smart_contract = insert(:smart_contract, language: :vyper) + insert(:smart_contract, language: :solidity) + request = get(conn, "/api/v2/smart-contracts", %{"filter" => "vyper"}) - for {filter, smart_contract} <- smart_contracts do - request = get(conn, "/api/v2/smart-contracts", %{"filter" => filter}) + assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) + compare_item(smart_contract, sc) + assert sc["address"]["is_verified"] == true + assert sc["address"]["is_contract"] == true + end - assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) - compare_item(smart_contract, sc) - assert sc["address"]["is_verified"] == true - assert sc["address"]["is_contract"] == true - end - end + test "get filtered yul smart contracts", %{conn: conn} do + smart_contract = insert(:smart_contract, abi: nil, language: :yul) + insert(:smart_contract, language: :vyper) + insert(:smart_contract, language: :solidity) + request = get(conn, "/api/v2/smart-contracts", %{"filter" => "yul"}) - test "get filtered smart contracts when flag is set and language is set", %{conn: conn} do - smart_contract = insert(:smart_contract, is_vyper_contract: true, language: :vyper) - insert(:smart_contract, is_vyper_contract: false, language: :solidity) - request = get(conn, "/api/v2/smart-contracts", %{"filter" => "vyper"}) + assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) + compare_item(smart_contract, sc) + assert sc["address"]["is_verified"] == true + assert sc["address"]["is_contract"] == true + end + + if Application.compile_env(:explorer, :chain_type) == :zilliqa do + test "get filtered scilla smart contracts when language is set", %{conn: conn} do + smart_contract = insert(:smart_contract, language: :scilla, abi: nil) + insert(:smart_contract) + request = get(conn, "/api/v2/smart-contracts", %{"filter" => "scilla"}) assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) compare_item(smart_contract, sc) @@ -1699,156 +1810,137 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert sc["address"]["is_contract"] == true end - test "get filtered smart contracts when flag is not set and language is set", %{conn: conn} do - smart_contract = insert(:smart_contract, is_vyper_contract: nil, abi: nil, language: :yul) - insert(:smart_contract, is_vyper_contract: nil, language: :vyper) - insert(:smart_contract, is_vyper_contract: nil, language: :solidity) + test "scilla contracts are not returned when yul filter is applied", %{conn: conn} do + insert(:smart_contract, language: :scilla, abi: nil) request = get(conn, "/api/v2/smart-contracts", %{"filter" => "yul"}) - assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) - compare_item(smart_contract, sc) - assert sc["address"]["is_verified"] == true - assert sc["address"]["is_contract"] == true + assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) end + end - if Application.compile_env(:explorer, :chain_type) == :zilliqa do - test "get filtered scilla smart contracts when language is set", %{conn: conn} do - smart_contract = insert(:smart_contract, language: :scilla, abi: nil) + test "check pagination", %{conn: conn} do + smart_contracts = + for _ <- 0..50 do insert(:smart_contract) - request = get(conn, "/api/v2/smart-contracts", %{"filter" => "scilla"}) - - assert %{"items" => [sc], "next_page_params" => nil} = json_response(request, 200) - compare_item(smart_contract, sc) - assert sc["address"]["is_verified"] == true - assert sc["address"]["is_contract"] == true end + |> Enum.sort_by(& &1.id) - test "scilla contracts are not returned when yul filter is applied", %{conn: conn} do - insert(:smart_contract, language: :scilla, abi: nil) - request = get(conn, "/api/v2/smart-contracts", %{"filter" => "yul"}) + request = get(conn, "/api/v2/smart-contracts") + assert response = json_response(request, 200) - assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) - end - end - - test "check pagination", %{conn: conn} do - smart_contracts = - for _ <- 0..50 do - insert(:smart_contract) - end - |> Enum.sort_by(& &1.address_hash.bytes, :desc) + request_2nd_page = get(conn, "/api/v2/smart-contracts", response["next_page_params"]) - request = get(conn, "/api/v2/smart-contracts") - assert response = json_response(request, 200) + assert response_2nd_page = json_response(request_2nd_page, 200) - request_2nd_page = get(conn, "/api/v2/smart-contracts", response["next_page_params"]) - - assert response_2nd_page = json_response(request_2nd_page, 200) + check_paginated_response(response, response_2nd_page, smart_contracts) + end - check_paginated_response(response, response_2nd_page, smart_contracts) + test "Returns error with wrong ordering params", %{conn: conn} do + for _ <- 0..50 do + insert(:smart_contract) end + |> Enum.sort_by(& &1.id) - test "ignores wrong ordering params", %{conn: conn} do - smart_contracts = - for _ <- 0..50 do - insert(:smart_contract) - end - |> Enum.sort_by(& &1.address_hash.bytes, :desc) + ordering_params = %{"sort" => "foo", "order" => "bar"} - ordering_params = %{"sort" => "foo", "order" => "bar"} - - request = get(conn, "/api/v2/smart-contracts", ordering_params) - assert response = json_response(request, 200) + request = get(conn, "/api/v2/smart-contracts", ordering_params) - request_2nd_page = - get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) - - assert response_2nd_page = json_response(request_2nd_page, 200) - - check_paginated_response(response, response_2nd_page, smart_contracts) - end + assert %{ + "errors" => [ + %{ + "detail" => "Invalid value for enum", + "source" => %{"pointer" => "/sort"}, + "title" => "Invalid value" + }, + %{ + "detail" => "Invalid value for enum", + "source" => %{"pointer" => "/order"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end - test "can order by balance ascending", %{conn: conn} do - smart_contracts = - for i <- 0..50 do - address = insert(:address, fetched_coin_balance: i, verified: true) - insert(:smart_contract, address_hash: address.hash, address: address) - end - |> Enum.reverse() + test "can order by balance ascending", %{conn: conn} do + smart_contracts = + for i <- 0..50 do + address = insert(:address, fetched_coin_balance: i, verified: true) + insert(:smart_contract, address_hash: address.hash, address: address) + end + |> Enum.reverse() - ordering_params = %{"sort" => "balance", "order" => "asc"} + ordering_params = %{"sort" => "balance", "order" => "asc"} - request = get(conn, "/api/v2/smart-contracts", ordering_params) - assert response = json_response(request, 200) + request = get(conn, "/api/v2/smart-contracts", ordering_params) + assert response = json_response(request, 200) - request_2nd_page = - get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) + request_2nd_page = + get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) - assert response_2nd_page = json_response(request_2nd_page, 200) + assert response_2nd_page = json_response(request_2nd_page, 200) - check_paginated_response(response, response_2nd_page, smart_contracts) - end + check_paginated_response(response, response_2nd_page, smart_contracts) + end - test "can order by balance descending", %{conn: conn} do - smart_contracts = - for i <- 0..50 do - address = insert(:address, fetched_coin_balance: i, verified: true) - insert(:smart_contract, address_hash: address.hash, address: address) - end + test "can order by balance descending", %{conn: conn} do + smart_contracts = + for i <- 0..50 do + address = insert(:address, fetched_coin_balance: i, verified: true) + insert(:smart_contract, address_hash: address.hash, address: address) + end - ordering_params = %{"sort" => "balance", "order" => "desc"} + ordering_params = %{"sort" => "balance", "order" => "desc"} - request = get(conn, "/api/v2/smart-contracts", ordering_params) - assert response = json_response(request, 200) + request = get(conn, "/api/v2/smart-contracts", ordering_params) + assert response = json_response(request, 200) - request_2nd_page = - get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) + request_2nd_page = + get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) - assert response_2nd_page = json_response(request_2nd_page, 200) + assert response_2nd_page = json_response(request_2nd_page, 200) - check_paginated_response(response, response_2nd_page, smart_contracts) - end + check_paginated_response(response, response_2nd_page, smart_contracts) + end - test "can order by transaction count ascending", %{conn: conn} do - smart_contracts = - for i <- 0..50 do - address = insert(:address, transactions_count: i, verified: true) - insert(:smart_contract, address_hash: address.hash, address: address) - end - |> Enum.reverse() + test "can order by transaction count ascending", %{conn: conn} do + smart_contracts = + for i <- 0..50 do + address = insert(:address, transactions_count: i, verified: true) + insert(:smart_contract, address_hash: address.hash, address: address) + end + |> Enum.reverse() - ordering_params = %{"sort" => "transactions_count", "order" => "asc"} + ordering_params = %{"sort" => "transactions_count", "order" => "asc"} - request = get(conn, "/api/v2/smart-contracts", ordering_params) - assert response = json_response(request, 200) + request = get(conn, "/api/v2/smart-contracts", ordering_params) + assert response = json_response(request, 200) - request_2nd_page = - get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) + request_2nd_page = + get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) - assert response_2nd_page = json_response(request_2nd_page, 200) + assert response_2nd_page = json_response(request_2nd_page, 200) - check_paginated_response(response, response_2nd_page, smart_contracts) - end + check_paginated_response(response, response_2nd_page, smart_contracts) + end - test "can order by transaction count descending", %{conn: conn} do - smart_contracts = - for i <- 0..50 do - address = insert(:address, transactions_count: i, verified: true) - insert(:smart_contract, address_hash: address.hash, address: address) - end + test "can order by transaction count descending", %{conn: conn} do + smart_contracts = + for i <- 0..50 do + address = insert(:address, transactions_count: i, verified: true) + insert(:smart_contract, address_hash: address.hash, address: address) + end - ordering_params = %{"sort" => "transactions_count", "order" => "desc"} + ordering_params = %{"sort" => "transactions_count", "order" => "desc"} - request = get(conn, "/api/v2/smart-contracts", ordering_params) - assert response = json_response(request, 200) + request = get(conn, "/api/v2/smart-contracts", ordering_params) + assert response = json_response(request, 200) - request_2nd_page = - get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) + request_2nd_page = + get(conn, "/api/v2/smart-contracts", ordering_params |> Map.merge(response["next_page_params"])) - assert response_2nd_page = json_response(request_2nd_page, 200) + assert response_2nd_page = json_response(request_2nd_page, 200) - check_paginated_response(response, response_2nd_page, smart_contracts) - end + check_paginated_response(response, response_2nd_page, smart_contracts) end end @@ -1870,7 +1962,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do assert smart_contract.optimization == json["optimization_enabled"] - assert json["language"] == smart_contract |> SmartContract.language() |> to_string() + assert json["language"] == smart_contract.language |> to_string() assert json["verified_at"] assert !is_nil(smart_contract.constructor_arguments) == json["has_constructor_args"] assert Address.checksum(smart_contract.address_hash) == json["address"]["hash"] @@ -1887,11 +1979,7 @@ defmodule BlockScoutWeb.API.V2.SmartContractControllerTest do compare_item(Enum.at(list, 0), Enum.at(second_page_resp["items"], 0)) end - defp prepare_implementation(items) when is_list(items) do - Enum.map(items, &prepare_implementation/1) - end - - defp prepare_implementation(%{"address" => _, "name" => _} = implementation) do + defp prepare_implementation(%{"address_hash" => _} = implementation) do case Application.get_env(:explorer, :chain_type) do :filecoin -> Map.put(implementation, "filecoin_robust_address", nil) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs index 683605e0d003..e8925384dd02 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/stats_controller_test.exs @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.StatsControllerTest do use BlockScoutWeb.ConnCase + alias Explorer.Chain.Address alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime} describe "/stats" do @@ -64,4 +66,318 @@ defmodule BlockScoutWeb.API.V2.StatsControllerTest do assert response["chart_data"] == [] end end + + describe "/stats/hot-smart-contracts" do + import Explorer.Factory + alias Explorer.Repo + alias Explorer.Stats.{HotSmartContracts, HotSmartContractsCache} + + setup do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + hot_smart_contracts_cache_config = Application.get_env(:explorer, HotSmartContractsCache) + clear_hot_smart_contracts_cache() + + on_exit(fn -> + Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) + Application.put_env(:explorer, HotSmartContractsCache, hot_smart_contracts_cache_config) + clear_hot_smart_contracts_cache() + end) + + :ok + end + + defp insert_hot_smart_contracts_daily(count, date \\ Date.utc_today(), scam \\ 0) do + addresses = Enum.map(1..count, fn _ -> insert(:address) end) + + Enum.each(addresses, fn addr -> + %HotSmartContracts{ + date: date, + contract_address_hash: addr.hash, + transactions_count: 1, + total_gas_used: Decimal.new(21_000) + } + |> HotSmartContracts.changeset(%{}) + |> Repo.insert!() + end) + + addresses + |> Enum.take(-scam) + |> Enum.each(fn addr -> insert(:scam_badge_to_address, address_hash: addr.hash) end) + + addresses + end + + defp insert_transactions_in_last_seconds(count, seconds_ago, scam \\ 0) do + now = DateTime.utc_now() + from_ts = DateTime.add(now, -seconds_ago, :second) + + # Ensure strict boundary blocks exist + some_block_in_beginning = insert(:block, timestamp: DateTime.add(from_ts, -1, :second)) + _from_block = insert(:block, timestamp: DateTime.add(from_ts, 1, :second)) + to_block = insert(:block, timestamp: DateTime.add(now, -1, :second)) + addresses = Enum.map(1..count, fn _ -> insert(:contract_address) end) + + Enum.each(addresses, fn addr -> + insert(:transaction, to_address: addr) + |> with_block(to_block, gas_used: 21_000) + end) + + insert(:transaction, to_address: insert(:contract_address)) + |> with_block(some_block_in_beginning, gas_used: 21_000) + + addresses + |> Enum.take(-scam) + |> Enum.each(fn addr -> insert(:scam_badge_to_address, address_hash: addr.hash) end) + + addresses + end + + defp clear_hot_smart_contracts_cache do + Supervisor.terminate_child(Explorer.Supervisor, {ConCache, HotSmartContractsCache.cache_name()}) + Supervisor.restart_child(Explorer.Supervisor, {ConCache, HotSmartContractsCache.cache_name()}) + end + + test "empty stats works for short scale", %{conn: conn} do + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 0 + end + + test "empty stats works for long scale", %{conn: conn} do + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "7d"}) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 0 + end + + # Daily scales pagination + test "1d pagination", %{conn: conn} do + insert_hot_smart_contracts_daily(55) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "1d"}) + assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200) + + assert length(items) == 50 + assert is_map(next_page_params) + assert Map.has_key?(next_page_params, "contract_address_hash") + assert Map.has_key?(next_page_params, "transactions_count") + assert Map.has_key?(next_page_params, "total_gas_used") + + request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "1d"})) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 5 + end + + test "7d pagination", %{conn: conn} do + insert_hot_smart_contracts_daily(55) + + insert_hot_smart_contracts_daily(55, Date.add(Date.utc_today(), -8)) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "7d"}) + assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200) + + assert length(items) == 50 + assert is_map(next_page_params) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "7d"})) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 5 + end + + test "30d pagination", %{conn: conn} do + insert_hot_smart_contracts_daily(55) + + insert_hot_smart_contracts_daily(55, Date.add(Date.utc_today(), -31)) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "30d"}) + assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200) + + assert length(items) == 50 + assert is_map(next_page_params) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "30d"})) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 5 + end + + # Daily scales scam toggle + test "1d scam toggle respects cookie and hide config", %{conn: conn} do + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + + [normal | [scam | _]] = insert_hot_smart_contracts_daily(2, Date.utc_today(), 1) + + # Without cookie -> hide scam + request1 = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "1d"}) + resp1 = json_response(request1, 200) + hashes1 = Enum.map(resp1["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes1 + refute Address.checksum(scam.hash) in hashes1 + + # With cookie -> show scam + request2 = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/stats/hot-smart-contracts", %{scale: "1d"}) + + resp2 = json_response(request2, 200) + hashes2 = Enum.map(resp2["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes2 + assert Address.checksum(scam.hash) in hashes2 + end + + # Seconds scales pagination (seed once covers all three) + test "5m pagination", %{conn: conn} do + insert_transactions_in_last_seconds(55, 300) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200) + assert length(items) == 50 + assert is_map(next_page_params) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "5m"})) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 5 + end + + test "1h pagination", %{conn: conn} do + insert_transactions_in_last_seconds(55, 3600) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "1h"}) + assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200) + assert length(items) == 50 + assert is_map(next_page_params) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "1h"})) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 5 + end + + test "3h pagination", %{conn: conn} do + addresses = insert_transactions_in_last_seconds(55, 10800) + + Enum.map(addresses, fn addr -> to_string(addr.hash) end) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "3h"}) + assert %{"items" => items, "next_page_params" => next_page_params} = json_response(request, 200) + assert length(items) == 50 + assert is_map(next_page_params) + + request = get(conn, "/api/v2/stats/hot-smart-contracts", Map.merge(next_page_params, %{scale: "3h"})) + assert %{"items" => items, "next_page_params" => nil} = json_response(request, 200) + assert length(items) == 5 + end + + # Seconds scales scam toggle + test "5m scam toggle respects cookie and hide config", %{conn: conn} do + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + + [normal | [scam | _]] = insert_transactions_in_last_seconds(2, 300, 1) + + request1 = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + resp1 = json_response(request1, 200) + hashes1 = Enum.map(resp1["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes1 + refute Address.checksum(scam.hash) in hashes1 + + request2 = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + + resp2 = json_response(request2, 200) + hashes2 = Enum.map(resp2["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes2 + assert Address.checksum(scam.hash) in hashes2 + end + + test "1h scam toggle respects cookie and hide config", %{conn: conn} do + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + + [normal | [scam | _]] = insert_transactions_in_last_seconds(2, 300, 1) + + request1 = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "1h"}) + resp1 = json_response(request1, 200) + hashes1 = Enum.map(resp1["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes1 + refute Address.checksum(scam.hash) in hashes1 + + request2 = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/stats/hot-smart-contracts", %{scale: "1h"}) + + resp2 = json_response(request2, 200) + hashes2 = Enum.map(resp2["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes2 + assert Address.checksum(scam.hash) in hashes2 + end + + test "3h scam toggle respects cookie and hide config", %{conn: conn} do + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + + [normal | [scam | _]] = insert_transactions_in_last_seconds(2, 300, 1) + + request1 = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "3h"}) + resp1 = json_response(request1, 200) + hashes1 = Enum.map(resp1["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes1 + refute Address.checksum(scam.hash) in hashes1 + + request2 = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/stats/hot-smart-contracts", %{scale: "3h"}) + + resp2 = json_response(request2, 200) + hashes2 = Enum.map(resp2["items"], fn %{"contract_address" => %{"hash" => h}} -> h end) + assert Address.checksum(normal.hash) in hashes2 + assert Address.checksum(scam.hash) in hashes2 + end + + test "seconds scales are cached independently by scale", %{conn: conn} do + insert_transactions_in_last_seconds(1, 300) + + first_5m_request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => first_5m_items} = json_response(first_5m_request, 200) + assert length(first_5m_items) == 1 + + insert_transactions_in_last_seconds(1, 300) + + second_5m_request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => second_5m_items} = json_response(second_5m_request, 200) + assert length(second_5m_items) == 1 + + first_1h_request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "1h"}) + assert %{"items" => first_1h_items} = json_response(first_1h_request, 200) + + # each insert_transactions_in_last_seconds(1, 300) inserts 1 more transaction which is older than 5 minutes, but not older than 1 hour + assert length(first_1h_items) == 4 + end + + test "seconds scale cache expires according to ttl", %{conn: conn} do + Application.put_env(:explorer, HotSmartContractsCache, %{ + "5m" => 100, + "1h" => :timer.minutes(6), + "3h" => :timer.minutes(18) + }) + + insert_transactions_in_last_seconds(1, 300) + + first_request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => first_items} = json_response(first_request, 200) + assert length(first_items) == 1 + + insert_transactions_in_last_seconds(1, 300) + + cached_request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => cached_items} = json_response(cached_request, 200) + assert length(cached_items) == 1 + + Process.sleep(2000) + + expired_cache_request = get(conn, "/api/v2/stats/hot-smart-contracts", %{scale: "5m"}) + assert %{"items" => expired_cache_items} = json_response(expired_cache_request, 200) + # hot contracts are extracted by block range, so even block older than 5 minutes is included + assert length(expired_cache_items) == 3 + end + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs index 1757af144822..ecb3a77c381a 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_controller_test.exs @@ -1,9 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TokenControllerTest do use EthereumJSONRPC.Case, async: false use BlockScoutWeb.ConnCase use BlockScoutWeb.ChannelCase, async: false + use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]] import Mox + import Ecto.Query, only: [from: 2] alias Explorer.{Repo, TestHelper} @@ -26,7 +29,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get token", %{conn: conn} do @@ -52,7 +63,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/counters") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get counters", %{conn: conn} do @@ -116,7 +135,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/transfers") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get empty list", %{conn: conn} do @@ -375,7 +402,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/holders") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get empty list", %{conn: conn} do @@ -435,6 +470,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do end describe "/tokens" do + setup do + initial_value = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + + on_exit(fn -> + :persistent_term.put(:market_token_fetcher_enabled, initial_value) + end) + end + defp check_tokens_pagination(tokens, conn, additional_params \\ %{}) do request = get(conn, "/api/v2/tokens", additional_params) assert response = json_response(request, 200) @@ -497,7 +541,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do tokens_ordered_by_holders = Enum.sort(tokens, &(&1.holder_count <= &2.holder_count)) request_ordered_by_holders = - get(conn, "/api/v2/tokens", additional_params |> Map.merge(%{"sort" => "holder_count", "order" => "desc"})) + get(conn, "/api/v2/tokens", additional_params |> Map.merge(%{"sort" => "holders_count", "order" => "desc"})) assert response_ordered_by_holders = json_response(request_ordered_by_holders, 200) @@ -506,7 +550,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do conn, "/api/v2/tokens", additional_params - |> Map.merge(%{"sort" => "holder_count", "order" => "desc"}) + |> Map.merge(%{"sort" => "holders_count", "order" => "desc"}) |> Map.merge(response_ordered_by_holders["next_page_params"]) ) @@ -521,7 +565,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do tokens_ordered_by_holders_asc = Enum.sort(tokens, &(&1.holder_count >= &2.holder_count)) request_ordered_by_holders_asc = - get(conn, "/api/v2/tokens", additional_params |> Map.merge(%{"sort" => "holder_count", "order" => "asc"})) + get(conn, "/api/v2/tokens", additional_params |> Map.merge(%{"sort" => "holders_count", "order" => "asc"})) assert response_ordered_by_holders_asc = json_response(request_ordered_by_holders_asc, 200) @@ -530,7 +574,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do conn, "/api/v2/tokens", additional_params - |> Map.merge(%{"sort" => "holder_count", "order" => "asc"}) + |> Map.merge(%{"sort" => "holders_count", "order" => "asc"}) |> Map.merge(response_ordered_by_holders_asc["next_page_params"]) ) @@ -613,21 +657,71 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) end - test "ignores wrong ordering params", %{conn: conn} do - tokens = - for i <- 0..50 do - insert(:token, fiat_value: i) - end + test "accepts limit", %{conn: conn} do + request = get(conn, "/api/v2/tokens?limit=5") - request = get(conn, "/api/v2/tokens", %{"sort" => "foo", "order" => "bar"}) + assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + end - assert response = json_response(request, 200) + test "get token with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) - request_2nd_page = - get(conn, "/api/v2/tokens", %{"sort" => "foo", "order" => "bar"} |> Map.merge(response["next_page_params"])) + insert(:token) - assert response_2nd_page = json_response(request_2nd_page, 200) - check_paginated_response(response, response_2nd_page, tokens) + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + + assert response == conn |> get("/api/v2/tokens") |> json_response(200) + end + + test "get token with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + target_token = insert(:token) + insert(:scam_badge_to_address, address_hash: target_token.contract_address_hash) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "scam" + + request = conn |> get("/api/v2/tokens") + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + insert(:token) + + request = conn |> get("/api/v2/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" + end + + test "get token with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + target_token = insert(:token) + insert(:scam_badge_to_address, address_hash: target_token.contract_address_hash) + + request = conn |> get("/api/v2/tokens") + response = json_response(request, 200) + + assert List.first(response["items"])["reputation"] == "ok" end test "tokens are filtered by single type", %{conn: conn} do @@ -651,10 +745,16 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do insert(:token, type: "ERC-404") end + erc_7984_tokens = + for _i <- 0..50 do + insert(:token, type: "ERC-7984") + end + check_tokens_pagination(erc_20_tokens, conn, %{"type" => "ERC-20"}) check_tokens_pagination(erc_721_tokens |> Enum.reverse(), conn, %{"type" => "ERC-721"}) check_tokens_pagination(erc_1155_tokens |> Enum.reverse(), conn, %{"type" => "ERC-1155"}) check_tokens_pagination(erc_404_tokens |> Enum.reverse(), conn, %{"type" => "ERC-404"}) + check_tokens_pagination(erc_7984_tokens |> Enum.reverse(), conn, %{"type" => "ERC-7984"}) end test "tokens are filtered by multiple type", %{conn: conn} do @@ -678,6 +778,11 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do insert(:token, type: "ERC-404") end + erc_7984_tokens = + for _i <- 0..24 do + insert(:token, type: "ERC-7984") + end + check_tokens_pagination( erc_721_tokens |> Kernel.++(erc_1155_tokens) |> Enum.reverse(), conn, @@ -701,6 +806,14 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do "type" => "[erc-20,ERC-404]" } ) + + check_tokens_pagination( + erc_7984_tokens |> Enum.reverse() |> Kernel.++(erc_20_tokens), + conn, + %{ + "type" => "[erc-20,ERC-7984]" + } + ) end test "sorting by fiat_value", %{conn: conn} do @@ -887,7 +1000,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/instances") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get empty list", %{conn: conn} do @@ -1015,7 +1136,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/instances/12") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get token instance by token id", %{conn: conn} do @@ -1030,7 +1159,12 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do |> insert() |> with_block() - instance = insert(:token_instance, token_id: 0, token_contract_address_hash: token.contract_address_hash) + instance = + insert(:token_instance, + token_id: 0, + token_contract_address_hash: token.contract_address_hash, + skip_metadata_url: true + ) _transfer = insert(:token_transfer, @@ -1066,6 +1200,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do insert(:token_instance, token_id: 0, token_contract_address_hash: token.contract_address_hash, + skip_metadata_url: true, metadata: %{ "image_url" => "ipfs://QmTQBtvkCQKnxbUejwYHrs2G74JR2qFwxPUqRb3BQ6BM3S/gm%20gm%20feelin%20blue%204k.png" } @@ -1102,6 +1237,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do insert(:token_instance, token_id: 0, token_contract_address_hash: token.contract_address_hash, + skip_metadata_url: true, metadata: %{ "image_url" => "ipfs://QmTQBtvkCQKnxbUejwYHrs2G74JR2qFwxPUqRb3BQ6BM3S/123.png" } @@ -1183,7 +1319,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/instances/12/transfers") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get token transfers by instance", %{conn: conn} do @@ -1427,7 +1571,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/instances/12/holders") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get 422 on invalid id", %{conn: conn} do @@ -1435,7 +1587,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do request = get(conn, "/api/v2/tokens/#{token.contract_address_hash}/instances/123ab/holders") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^-?([1-9][0-9]*|0)$/", + "source" => %{"pointer" => "/token_id_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "get token transfers by instance", %{conn: conn} do @@ -1487,7 +1647,15 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do test "get 422 on invalid address", %{conn: conn} do request = get(conn, "/api/v2/tokens/0x/instances/12/transfers-count") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{40})$/", + "source" => %{"pointer" => "/address_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "receive 0 count", %{conn: conn} do @@ -1535,8 +1703,9 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do setup :verify_on_exit! setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + original_config = :persistent_term.get(:rate_limit_config) old_recaptcha_env = Application.get_env(:block_scout_web, :recaptcha) - old_http_adapter = Application.get_env(:block_scout_web, :http_adapter) + original_api_rate_limit = Application.get_env(:block_scout_web, :api_rate_limit) v2_secret_key = "v2_secret_key" v3_secret_key = "v3_secret_key" @@ -1547,8 +1716,6 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do is_disabled: false ) - Application.put_env(:block_scout_web, :http_adapter, Explorer.Mox.HTTPoison) - mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) @@ -1561,31 +1728,38 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} Subscriber.to(:fetched_token_instance_metadata, :on_demand) + Subscriber.to(:not_fetched_token_instance_metadata, :on_demand) + + Application.put_env(:block_scout_web, :api_rate_limit, Keyword.put(original_api_rate_limit, :disabled, false)) + + config = %{ + static_match: %{}, + wildcard_match: %{}, + parametrized_match: %{ + ["api", "v2", "tokens", ":param", "instances", ":param", "refetch-metadata"] => %{ + ip: %{period: 3_600_000, limit: 1}, + recaptcha_to_bypass_429: true, + bypass_token_scope: "token_instance_refetch_metadata", + bucket_key_prefix: "api/v2/tokens/:param/instances/:param/refetch-metadata_", + isolate_rate_limit?: true + } + } + } + + :persistent_term.put(:rate_limit_config, config) on_exit(fn -> + :persistent_term.put(:rate_limit_config, original_config) Application.put_env(:block_scout_web, :recaptcha, old_recaptcha_env) - Application.put_env(:block_scout_web, :http_adapter, old_http_adapter) + Application.put_env(:block_scout_web, :api_rate_limit, original_api_rate_limit) + :ets.delete_all_objects(BlockScoutWeb.RateLimit.Hammer.ETS) end) {:ok, %{v2_secret_key: v2_secret_key, v3_secret_key: v3_secret_key}} end - test "token instance metadata on-demand re-fetcher is called", %{conn: conn, v2_secret_key: v2_secret_key} do - expected_body = "secret=#{v2_secret_key}&response=123" - - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) - + test "token instance metadata on-demand re-fetcher is called; recaptcha is required after rate limit is exceeded", + %{conn: conn, v2_secret_key: v2_secret_key} do token = insert(:token, type: "ERC-721") token_id = 1 @@ -1601,12 +1775,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + token_instance_success_metadata_expectation(url, metadata) topic = "token_instances:#{token_contract_address_hash_string}" @@ -1616,9 +1785,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do |> subscribe_and_join(topic) request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "recaptcha_response" => "123" - }) + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) assert %{"message" => "OK"} = json_response(request, 200) @@ -1629,8 +1796,10 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do [^token_contract_address_hash_string, ^token_id, ^metadata]} ) + token_id_string = to_string(token_id) + assert_receive %Phoenix.Socket.Message{ - payload: %{token_id: ^token_id, fetched_metadata: ^metadata}, + payload: %{token_id: ^token_id_string, fetched_metadata: ^metadata}, event: "fetched_token_instance_metadata", topic: ^topic }, @@ -1642,60 +1811,70 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert(token_instance_from_db) assert token_instance_from_db.metadata == metadata - Application.put_env(:explorer, :http_adapter, HTTPoison) + expected_body = "secret=#{v2_secret_key}&response=123" + + token_instance_success_metadata_expectation_with_req_body(expected_body) + + TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + + token_instance_success_metadata_expectation(url, metadata) + + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert json_response(request, 429) + + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("recaptcha-v2-response", "123") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert %{"message" => "OK"} = json_response(request, 200) + + :timer.sleep(100) + + assert_receive( + {:chain_event, :fetched_token_instance_metadata, :on_demand, + [^token_contract_address_hash_string, ^token_id, ^metadata]} + ) + + token_id_string = to_string(token_id) + + assert_receive %Phoenix.Socket.Message{ + payload: %{token_id: ^token_id_string, fetched_metadata: ^metadata}, + event: "fetched_token_instance_metadata", + topic: ^topic + }, + :timer.seconds(1) + + token_instance_from_db = + Repo.get_by(Instance, token_id: token_id, token_contract_address_hash: token.contract_address_hash) + + assert(token_instance_from_db) + assert token_instance_from_db.metadata == metadata end test "don't fetch token instance metadata for non-existent token instance", %{ conn: conn, - v2_secret_key: v2_secret_key + v2_secret_key: _v2_secret_key } do - expected_body = "secret=#{v2_secret_key}&response=123" - - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) - token = insert(:token, type: "ERC-721") token_id = 0 insert(:token_instance, token_id: token_id, token_contract_address_hash: token.contract_address_hash) request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/1/refetch-metadata", %{ - "recaptcha_response" => "123" - }) + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/1/refetch-metadata", %{}) assert %{"message" => "Not found"} = json_response(request, 404) end test "fetch token instance metadata for existing token instance with no metadata", %{ - conn: conn, - v2_secret_key: v2_secret_key + conn: conn } do - expected_body = "secret=#{v2_secret_key}&response=123" - - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) - token = insert(:token, type: "ERC-721") token_id = 1 @@ -1711,12 +1890,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + token_instance_success_metadata_expectation(url, metadata) topic = "token_instances:#{token_contract_address_hash_string}" @@ -1726,9 +1900,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do |> subscribe_and_join(topic) request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "recaptcha_response" => "123" - }) + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) assert %{"message" => "OK"} = json_response(request, 200) @@ -1739,8 +1911,10 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do [^token_contract_address_hash_string, ^token_id, ^metadata]} ) + token_id_string = to_string(token_id) + assert_receive %Phoenix.Socket.Message{ - payload: %{token_id: ^token_id, fetched_metadata: ^metadata}, + payload: %{token_id: ^token_id_string, fetched_metadata: ^metadata}, event: "fetched_token_instance_metadata", topic: ^topic }, @@ -1751,8 +1925,71 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert(token_instance_from_db) assert token_instance_from_db.metadata == metadata + end + + test "emit not_fetched_token_instance_metadata event when fetching token instance metadata fails", %{ + conn: conn + } do + token = insert(:token, type: "ERC-721") + token_id = 1 + + insert(:token_instance, + token_id: token_id, + token_contract_address_hash: token.contract_address_hash, + metadata: nil + ) + + url = "http://metadata.endpoint.com" + token_contract_address_hash_string = to_string(token.contract_address_hash) + + TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, + %Tesla.Env{ + status: 500, + body: "Error" + }} + end + ) + + topic = "token_instances:#{token_contract_address_hash_string}" - Application.put_env(:explorer, :http_adapter, HTTPoison) + {:ok, _reply, _socket} = + BlockScoutWeb.V2.UserSocket + |> socket("no_id", %{}) + |> subscribe_and_join(topic) + + request = + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ + "recaptcha_response" => "123" + }) + + assert %{"message" => "OK"} = json_response(request, 200) + + :timer.sleep(100) + + assert_receive( + {:chain_event, :not_fetched_token_instance_metadata, :on_demand, + [^token_contract_address_hash_string, ^token_id, "error"]} + ) + + token_id_string = to_string(token_id) + + assert_receive %Phoenix.Socket.Message{ + payload: %{token_id: ^token_id_string, reason: "error"}, + event: "not_fetched_token_instance_metadata", + topic: ^topic + }, + :timer.seconds(1) + + token_instance_from_db = + Repo.get_by(Instance, token_id: token_id, token_contract_address_hash: token.contract_address_hash) + + assert(token_instance_from_db) + assert is_nil(token_instance_from_db.metadata) end test "fetch token instance metadata using scoped bypass api key", %{conn: conn} do @@ -1770,11 +2007,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do ) ) - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - on_exit(fn -> Application.put_env(:block_scout_web, :recaptcha, old_recaptcha_env) - Application.put_env(:explorer, :http_adapter, HTTPoison) end) token = insert(:token, type: "ERC-721") @@ -1792,10 +2026,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + token_instance_success_metadata_expectation(url, metadata) topic = "token_instances:#{token_contract_address_hash_string}" @@ -1805,9 +2036,7 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do |> subscribe_and_join(topic) request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "scoped_recaptcha_bypass_token" => scoped_bypass_token - }) + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) assert %{"message" => "OK"} = json_response(request, 200) @@ -1818,8 +2047,51 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do [^token_contract_address_hash_string, ^token_id, ^metadata]} ) + token_id_string = to_string(token_id) + assert_receive %Phoenix.Socket.Message{ - payload: %{token_id: ^token_id, fetched_metadata: ^metadata}, + payload: %{token_id: ^token_id_string, fetched_metadata: ^metadata}, + event: "fetched_token_instance_metadata", + topic: ^topic + }, + :timer.seconds(1) + + token_instance_from_db = + Repo.get_by(Instance, token_id: token_id, token_contract_address_hash: token.contract_address_hash) + + assert(token_instance_from_db) + assert token_instance_from_db.metadata == metadata + + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert json_response(request, 429) + + TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + + token_instance_success_metadata_expectation(url, metadata) + + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("scoped-recaptcha-bypass-token", scoped_bypass_token) + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert %{"message" => "OK"} = json_response(request, 200) + + :timer.sleep(100) + + assert_receive( + {:chain_event, :fetched_token_instance_metadata, :on_demand, + [^token_contract_address_hash_string, ^token_id, ^metadata]} + ) + + token_id_string = to_string(token_id) + + assert_receive %Phoenix.Socket.Message{ + payload: %{token_id: ^token_id_string, fetched_metadata: ^metadata}, event: "fetched_token_instance_metadata", topic: ^topic }, @@ -1850,11 +2122,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do ) ) - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - on_exit(fn -> Application.put_env(:block_scout_web, :recaptcha, old_recaptcha_env) - Application.put_env(:explorer, :http_adapter, HTTPoison) end) token = insert(:token, type: "ERC-721") @@ -1866,40 +2135,39 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do metadata: %{} ) - metadata = %{"name" => "Super Token"} url = "http://metadata.endpoint.com" token_contract_address_hash_string = to_string(token.contract_address_hash) TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + token_instance_success_metadata_expectation(url, %{}) - # First request with wrong scoped token - should fail request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "scoped_recaptcha_bypass_token" => "wrong_scoped_token" - }) + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert %{"message" => "OK"} = json_response(request, 200) + :timer.sleep(100) + + # First request after hitting rate limit with wrong scoped token - should fail + request = + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("scoped-recaptcha-bypass-token", "wrong_scoped_token") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) + + assert %{"message" => "Too many requests. Increase limits now at https://dev.blockscout.com"} = + json_response(request, 429) - assert %{"message" => "Invalid reCAPTCHA response"} = json_response(request, 403) + :timer.sleep(100) # Set up normal reCAPTCHA validation for the second request expected_body = "secret=#{v2_secret_key}&response=correct_recaptcha_token" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + token_instance_success_metadata_expectation_with_req_body(expected_body) - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + + metadata = %{"name" => "Super Token"} + token_instance_success_metadata_expectation(url, metadata) topic = "token_instances:#{token_contract_address_hash_string}" @@ -1910,9 +2178,10 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do # Second request with correct reCAPTCHA token - should work request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "recaptcha_response" => "correct_recaptcha_token" - }) + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("recaptcha-v2-response", "correct_recaptcha_token") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) assert %{"message" => "OK"} = json_response(request, 200) @@ -1948,11 +2217,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do ) ) - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - on_exit(fn -> Application.put_env(:block_scout_web, :recaptcha, old_recaptcha_env) - Application.put_env(:explorer, :http_adapter, HTTPoison) end) token = insert(:token, type: "ERC-721") @@ -1964,60 +2230,54 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do metadata: %{} ) - metadata = %{"name" => "Super Token"} url = "http://metadata.endpoint.com" token_contract_address_hash_string = to_string(token.contract_address_hash) + TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) + token_instance_success_metadata_expectation(url, %{}) - # First request with a scoped token that isn't configured - should fail request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "scoped_recaptcha_bypass_token" => "some_token_that_does_not_exist" - }) + patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) - assert %{"message" => "Invalid reCAPTCHA response"} = json_response(request, 403) + assert %{"message" => "OK"} = json_response(request, 200) + :timer.sleep(100) + + # First request after hitting rate limit with a scoped token that isn't configured - should fail request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "scoped_recaptcha_bypass_token" => "" - }) + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("scoped-recaptcha-bypass-token", "some_token_that_does_not_exist") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) - assert %{"message" => "Invalid reCAPTCHA response"} = json_response(request, 403) + assert %{"message" => "Too many requests. Increase limits now at https://dev.blockscout.com"} = + json_response(request, 429) request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "scoped_recaptcha_bypass_token" => nil - }) + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("scoped-recaptcha-bypass-token", "") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) - assert %{"message" => "Invalid reCAPTCHA response"} = json_response(request, 403) + assert %{"message" => "Too many requests. Increase limits now at https://dev.blockscout.com"} = + json_response(request, 429) + + :timer.sleep(100) # Set up normal reCAPTCHA validation for the second request expected_body = "secret=#{v2_secret_key}&response=correct_recaptcha_token" - Explorer.Mox.HTTPoison - |> expect(:post, fn _url, ^expected_body, _headers, _options -> - {:ok, - %HTTPoison.Response{ - status_code: 200, - body: - Jason.encode!(%{ - "success" => true, - "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] - }) - }} - end) + token_instance_success_metadata_expectation_with_req_body(expected_body) TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + metadata = %{"name" => "Super Token"} + token_instance_success_metadata_expectation(url, metadata) # Second request with correct reCAPTCHA token - should work request = - patch(conn, "/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{ - "recaptcha_response" => "correct_recaptcha_token" - }) + Phoenix.ConnTest.build_conn() + |> put_req_header("user-agent", "test-agent") + |> put_req_header("recaptcha-v2-response", "correct_recaptcha_token") + |> patch("/api/v2/tokens/#{token.contract_address.hash}/instances/#{token_id}/refetch-metadata", %{}) assert %{"message" => "OK"} = json_response(request, 200) @@ -2069,11 +2329,8 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do request = patch( - conn, - "/api/v2/tokens/#{token.contract_address.hash}/instances/refetch-metadata", - %{ - "api_key" => "abc" - } + conn |> put_req_header("x-api-key", "abc"), + "/api/v2/tokens/#{token.contract_address.hash}/instances/refetch-metadata" ) assert %{"message" => "OK"} = json_response(request, 200) @@ -2111,14 +2368,14 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do end def compare_item(%Token{} = token, json) do - assert Address.checksum(token.contract_address.hash) == json["address"] + assert Address.checksum(token.contract_address.hash) == json["address_hash"] assert token.symbol == json["symbol"] assert token.name == json["name"] assert to_string(token.decimals) == json["decimals"] assert token.type == json["type"] - assert (is_nil(token.holder_count) and is_nil(json["holders"])) or - (to_string(token.holder_count) == json["holders"] and !is_nil(token.holder_count)) + assert (is_nil(token.holder_count) and is_nil(json["holders_count"])) or + (to_string(token.holder_count) == json["holders_count"] and !is_nil(token.holder_count)) assert to_string(token.total_supply) == json["total_supply"] assert Map.has_key?(json, "exchange_rate") @@ -2155,11 +2412,9 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do is_unique = value == "1" assert %{ - "token_type" => ^token_type, - "value" => ^value, "id" => ^id, "metadata" => ^metadata, - "token" => %{"address" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, + "token" => %{"address_hash" => ^token_address_hash, "name" => ^token_name, "type" => ^token_type}, "external_app_url" => ^app_url, "animation_url" => ^animation_url, "image_url" => ^image_url, @@ -2221,4 +2476,254 @@ defmodule BlockScoutWeb.API.V2.TokenControllerTest do assert second_page_resp["next_page_params"] == nil compare_holders_item(Enum.at(list, 0), Enum.at(second_page_resp["items"], 0)) end + + if @bridged_tokens_enabled do + describe "/tokens/bridged" do + test "returns empty list when no bridged tokens", %{conn: conn} do + request = get(conn, "/api/v2/tokens/bridged") + + assert %{"items" => [], "next_page_params" => nil} = json_response(request, 200) + end + + test "returns bridged tokens list", %{conn: conn} do + # Create a token + token = insert(:token, %{total_supply: 1000}) + + # Update token to set bridged flag directly in the database + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token.contract_address_hash), + set: [bridged: true] + ) + + # Create a bridged token record + {:ok, _bridged_token} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token.contract_address_hash, + foreign_chain_id: 1, + foreign_token_contract_address_hash: build(:address).hash, + type: "omni", + exchange_rate: Decimal.new("1.5") + }) + + request = get(conn, "/api/v2/tokens/bridged") + + assert response = json_response(request, 200) + assert %{"items" => items, "next_page_params" => _} = response + assert length(items) == 1 + + item = List.first(items) + assert item["address_hash"] == Address.checksum(token.contract_address_hash) + assert item["name"] == token.name + assert item["symbol"] == token.symbol + assert item["bridge_type"] == "omni" + assert item["origin_chain_id"] == "1" + assert is_binary(item["foreign_address"]) + end + + test "filters bridged tokens by search query", %{conn: conn} do + # Create first token + token1 = insert(:token, %{total_supply: 1000, name: "TestToken", symbol: "TEST"}) + + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token1.contract_address_hash), + set: [bridged: true] + ) + + {:ok, _bridged_token1} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token1.contract_address_hash, + foreign_chain_id: 1, + foreign_token_contract_address_hash: build(:address).hash, + type: "omni" + }) + + # Create second token with different name + token2 = insert(:token, %{total_supply: 2000, name: "OtherToken", symbol: "OTHER"}) + + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token2.contract_address_hash), + set: [bridged: true] + ) + + {:ok, _bridged_token2} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token2.contract_address_hash, + foreign_chain_id: 1, + foreign_token_contract_address_hash: build(:address).hash, + type: "amb" + }) + + # Search for "Test" + request = get(conn, "/api/v2/tokens/bridged", %{"q" => "Test"}) + + assert response = json_response(request, 200) + assert %{"items" => items} = response + assert length(items) == 1 + + item = List.first(items) + assert item["name"] == "TestToken" + assert item["symbol"] == "TEST" + end + + test "filters bridged tokens by chain ids", %{conn: conn} do + # Create tokens on different chains + token1 = insert(:token, %{total_supply: 1000}) + + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token1.contract_address_hash), + set: [bridged: true] + ) + + {:ok, _bridged_token1} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token1.contract_address_hash, + foreign_chain_id: 1, + foreign_token_contract_address_hash: build(:address).hash, + type: "omni" + }) + + token2 = insert(:token, %{total_supply: 2000}) + + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token2.contract_address_hash), + set: [bridged: true] + ) + + {:ok, _bridged_token2} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token2.contract_address_hash, + foreign_chain_id: 56, + foreign_token_contract_address_hash: build(:address).hash, + type: "amb" + }) + + # Filter by chain id 1 + request = get(conn, "/api/v2/tokens/bridged", %{"chain_ids" => "1"}) + + assert response = json_response(request, 200) + assert %{"items" => items} = response + assert length(items) == 1 + + item = List.first(items) + assert item["origin_chain_id"] == "1" + end + + test "supports pagination", %{conn: conn} do + # Create 51 bridged tokens to trigger pagination (default page size is 50) + _tokens = + for i <- 1..51 do + token = insert(:token, %{total_supply: 1000 * i, name: "Token#{i}", symbol: "T#{i}"}) + + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token.contract_address_hash), + set: [bridged: true] + ) + + {:ok, _bridged_token} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token.contract_address_hash, + foreign_chain_id: 1, + foreign_token_contract_address_hash: build(:address).hash, + type: "omni" + }) + + token + end + + # Test first page (should have 50 items and next_page_params) + request = get(conn, "/api/v2/tokens/bridged") + + assert response = json_response(request, 200) + assert %{"items" => items, "next_page_params" => next_page_params} = response + assert length(items) == 50 + assert next_page_params != nil + + # Test second page (should have 1 item and no next_page_params) + request = get(conn, "/api/v2/tokens/bridged", next_page_params) + + assert response = json_response(request, 200) + assert %{"items" => items, "next_page_params" => next_page_params} = response + assert length(items) == 1 + assert next_page_params == nil + end + + test "regression: sort and order params are applied for bridged tokens", %{conn: conn} do + parameter_names = + BlockScoutWeb.API.V2.TokenController.open_api_operation(:bridged_tokens_list).parameters + |> Enum.map(fn + %OpenApiSpex.Parameter{name: name} -> to_string(name) + %OpenApiSpex.Reference{} -> nil + end) + |> Enum.reject(&is_nil/1) + + assert "sort" in parameter_names + assert "order" in parameter_names + + high_fiat_token = insert(:token, %{total_supply: 20_000, name: "BridgeTokenHigh", fiat_value: 200}) + low_fiat_token = insert(:token, %{total_supply: 10_000, name: "BridgeTokenLow", fiat_value: 100}) + + for token <- [high_fiat_token, low_fiat_token] do + Explorer.Repo.update_all( + from(t in Explorer.Chain.Token, where: t.contract_address_hash == ^token.contract_address_hash), + set: [bridged: true] + ) + + {:ok, _bridged_token} = + Explorer.Repo.insert(%Explorer.Chain.BridgedToken{ + home_token_contract_address_hash: token.contract_address_hash, + foreign_chain_id: 1, + foreign_token_contract_address_hash: build(:address).hash, + type: "omni" + }) + end + + base_params = %{ + "q" => "BridgeToken", + "chain_ids" => "1", + "sort" => "fiat_value" + } + + request_asc = get(conn, "/api/v2/tokens/bridged", Map.put(base_params, "order", "asc")) + request_desc = get(conn, "/api/v2/tokens/bridged", Map.put(base_params, "order", "desc")) + + assert %{"items" => [asc_first | _] = asc_items} = json_response(request_asc, 200) + assert %{"items" => [desc_first | _] = desc_items} = json_response(request_desc, 200) + assert length(asc_items) == 2 + assert length(desc_items) == 2 + + assert asc_first["address_hash"] == Address.checksum(low_fiat_token.contract_address_hash) + assert desc_first["address_hash"] == Address.checksum(high_fiat_token.contract_address_hash) + end + end + end + + defp token_instance_success_metadata_expectation(url, metadata) do + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(metadata) + }} + end + ) + end + + defp token_instance_success_metadata_expectation_with_req_body(expected_body) do + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{body: ^expected_body}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "success" => true, + "hostname" => Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] + }) + }} + end + ) + end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_transfer_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_transfer_controller_test.exs index fe293c2c73b3..2096cb84fb08 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_transfer_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/token_transfer_controller_test.exs @@ -1,9 +1,91 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TokenTransferControllerTest do use BlockScoutWeb.ConnCase alias Explorer.Chain.{Address, TokenTransfer} describe "/token-transfers" do + test "get token-transfers with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, transaction: transaction) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get("/api/v2/token-transfers") |> json_response(200) + end + + test "get smart-contract with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = + :transaction + |> insert() + |> with_block() + + tt = insert(:token_transfer, transaction: transaction) + insert(:scam_badge_to_address, address_hash: tt.token_contract_address_hash) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/token-transfers") + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token-transfers with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, transaction: transaction) + + request = conn |> get("/api/v2/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get token-transfers with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + transaction = + :transaction + |> insert() + |> with_block() + + tt = insert(:token_transfer, transaction: transaction) + insert(:scam_badge_to_address, address_hash: tt.token_contract_address_hash) + + request = conn |> get("/api/v2/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + test "empty list", %{conn: conn} do request = get(conn, "/api/v2/token-transfers") @@ -48,6 +130,39 @@ defmodule BlockScoutWeb.API.V2.TokenTransferControllerTest do assert response["next_page_params"] == nil end + test "filters by ERC-7984 type", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + erc7984_token = insert(:token, type: "ERC-7984") + + insert(:token_transfer, + transaction: transaction, + token: erc7984_token, + token_contract_address: erc7984_token.contract_address, + token_type: "ERC-7984", + amount: nil + ) + + erc20_token = insert(:token, type: "ERC-20") + + insert(:token_transfer, + transaction: transaction, + token: erc20_token, + token_contract_address: erc20_token.contract_address, + token_type: "ERC-20" + ) + + request = get(conn, "/api/v2/token-transfers?type=ERC-7984") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + assert response["next_page_params"] == nil + assert List.first(response["items"])["token_type"] == "ERC-7984" + end + test "returns all transfers if filter is incorrect", %{conn: conn} do transaction = :transaction diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs index f3179efd3a15..89e5baeaed8d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs @@ -1,19 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TransactionControllerTest do use BlockScoutWeb.ConnCase + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + import Explorer.Chain, only: [hash_to_lower_case_string: 1] import Mox - require Logger alias Explorer.Account.{Identity, WatchlistAddress} - alias Explorer.Chain.{Address, InternalTransaction, Log, Token, TokenTransfer, Transaction, Wei} - alias Explorer.Repo - import Ecto.Query, only: [from: 2] - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + alias Explorer.Chain.{Address, FheOperation, InternalTransaction, Log, Token, TokenTransfer, Transaction, Wei} + alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit + alias Explorer.{Repo, TestHelper} + + require Logger setup do - Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.TransactionsApiV2.child_id()) - Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.TransactionsApiV2.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Transactions.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Transactions.child_id()) :ok end @@ -202,6 +207,129 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do end describe "/transactions/{transaction_hash}" do + test "get token-transfers with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert List.first(response["token_transfers"])["token"]["reputation"] == "ok" + + assert response == conn |> get("/api/v2/transactions/#{transaction.hash}") |> json_response(200) + end + + test "get smart-contract with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = conn |> put_req_cookie("show_scam_tokens", "true") |> get("/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert List.first(response["token_transfers"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert response["token_transfers"] == [] + end + + test "get token-transfers with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + request = conn |> get("/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert List.first(response["token_transfers"])["token"]["reputation"] == "ok" + end + + test "get token-transfers with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = conn |> get("/api/v2/transactions/#{transaction.hash}") + response = json_response(request, 200) + + assert List.first(response["token_transfers"])["token"]["reputation"] == "ok" + end + test "return 404 on non existing transaction", %{conn: conn} do transaction = build(:transaction) request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}") @@ -212,7 +340,15 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do test "return 422 on invalid transaction hash", %{conn: conn} do request = get(conn, "/api/v2/transactions/0x") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "return existing transaction", %{conn: conn} do @@ -227,6 +363,57 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do compare_item(transaction, response) end + test "includes is_pending_update field in response", %{conn: conn} do + block_refetch_needed = insert(:block, refetch_needed: true) + block_no_refetch = insert(:block, refetch_needed: false) + + transaction_refetch_needed = + :transaction + |> insert() + |> with_block(block_refetch_needed) + + transaction_no_refetch = + :transaction + |> insert() + |> with_block(block_no_refetch) + + request_1 = get(conn, "/api/v2/transactions/" <> to_string(transaction_refetch_needed.hash)) + assert response_1 = json_response(request_1, 200) + assert response_1["is_pending_update"] == true + + request_2 = get(conn, "/api/v2/transactions/" <> to_string(transaction_no_refetch.hash)) + assert response_2 = json_response(request_2, 200) + assert response_2["is_pending_update"] == false + end + + test "includes is_pending_update field in transaction lists", %{conn: conn} do + block_refetch_needed = insert(:block, refetch_needed: true) + block_no_refetch = insert(:block, refetch_needed: false) + + transaction_refetch_needed = + :transaction + |> insert() + |> with_block(block_refetch_needed) + + transaction_no_refetch = + :transaction + |> insert() + |> with_block(block_no_refetch) + + request = get(conn, "/api/v2/transactions") + assert response = json_response(request, 200) + + # Find the transactions in the response + refetch_tx_response = + Enum.find(response["items"], fn item -> item["hash"] == to_string(transaction_refetch_needed.hash) end) + + no_refetch_tx_response = + Enum.find(response["items"], fn item -> item["hash"] == to_string(transaction_no_refetch.hash) end) + + assert refetch_tx_response["is_pending_update"] == true + assert no_refetch_tx_response["is_pending_update"] == false + end + test "batch 1155 flattened", %{conn: conn} do token = insert(:token, type: "ERC-1155") @@ -282,6 +469,96 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert is_map(Enum.at(response["token_transfers"], 0)["total"]) assert compare_item(%TokenTransfer{tt | amount: 2}, Enum.at(response["token_transfers"], 0)) end + + test "return transaction with input starting with 0x", %{conn: conn} do + contract = + insert(:smart_contract, + contract_code_md5: "123", + abi: [ + %{ + "constant" => false, + "inputs" => [%{"name" => "", "type" => "bytes"}], + "name" => "set", + "outputs" => [], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + ] + ) + |> Repo.preload(:address) + + input_data = + "set(bytes)" + |> ABI.encode([ + <<48, 120, 253, 69, 39, 88, 49, 136, 89, 142, 21, 123, 116, 129, 248, 32, 77, 29, 224, 121, 49, 137, 216, 8, + 212, 195, 239, 11, 174, 75, 56, 126>> + ]) + |> Base.encode16(case: :lower) + + transaction = + :transaction + |> insert(to_address: contract.address, input: "0x" <> input_data) + |> Repo.preload(to_address: :smart_contract) + + request = get(conn, "/api/v2/transactions/" <> to_string(transaction.hash)) + + assert json_response(request, 200) + end + + if @chain_type == :suave do + test "renders peeker starting with 0x", %{conn: conn} do + bid_contract = insert(:address) + + old_env = Application.get_env(:explorer, Transaction, []) + + Application.put_env( + :explorer, + Transaction, + Keyword.merge(old_env, suave_bid_contracts: to_string(bid_contract.hash)) + ) + + on_exit(fn -> + Application.put_env(:explorer, Transaction, old_env) + end) + + transaction = + insert(:transaction, + to_address_hash: bid_contract.hash, + to_address: bid_contract, + execution_node_hash: bid_contract.hash + ) + + insert(:log, + transaction_hash: transaction.hash, + transaction: transaction, + address: bid_contract, + first_topic: "0x83481d5b04dea534715acad673a8177a46fc93882760f36bdc16ccac439d504e", + data: + "0x11111111111111111111111111111111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000003078505152535455565758595a5b5c5d5e5f6061" + ) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}") + + assert %{"allowed_peekers" => ["0x3078505152535455565758595a5b5C5D5E5f6061"]} = json_response(request, 200) + end + end + + if @chain_type == :optimism do + test "returns transaction with interop message", %{conn: conn} do + transaction = insert(:transaction) + + insert(:op_interop_message, + init_transaction_hash: transaction.hash, + payload: "0x30787849009c24f10a91a327a9f2ed94ebc49ee9" + ) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}") + + assert %{"op_interop_messages" => [%{"payload" => "0x30787849009c24f10a91a327a9f2ed94ebc49ee9"}]} = + json_response(request, 200) + end + end end describe "/transactions/{transaction_hash}/internal-transactions" do @@ -295,7 +572,15 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do test "return 422 on invalid transaction hash", %{conn: conn} do request = get(conn, "/api/v2/transactions/0x/internal-transactions") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "return empty list", %{conn: conn} do @@ -321,9 +606,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do transaction: transaction, index: 0, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0 + transaction_index: transaction.index ) internal_transaction = @@ -331,10 +614,9 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do transaction: transaction, index: 1, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1 + transaction_index: transaction.index ) + |> InternalTransaction.preload_addresses() transaction_1 = :transaction @@ -347,9 +629,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do transaction: transaction_1, index: index, block_number: transaction_1.block_number, - transaction_index: transaction_1.index, - block_hash: transaction_1.block_hash, - block_index: index + transaction_index: transaction_1.index ) end) @@ -371,23 +651,20 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do transaction: transaction, index: 0, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0 + transaction_index: transaction.index ) internal_transactions = - 51..1 + 51..1//-1 |> Enum.map(fn index -> insert(:internal_transaction, transaction: transaction, index: index, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index ) end) + |> InternalTransaction.preload_addresses() request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/internal-transactions") assert response = json_response(request, 200) @@ -416,7 +693,15 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do test "return 422 on invalid transaction hash", %{conn: conn} do request = get(conn, "/api/v2/transactions/0x/logs") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "return empty list", %{conn: conn} do @@ -476,7 +761,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do |> with_block() logs = - 50..0 + 50..0//-1 |> Enum.map(fn index -> insert(:log, transaction: transaction, @@ -499,6 +784,137 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do end describe "/transactions/{transaction_hash}/token-transfers" do + test "get token-transfers with ok reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/transactions/#{transaction.hash}/token-transfers") + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + + assert response == conn |> get("/api/v2/transactions/#{transaction.hash}/token-transfers") |> json_response(200) + end + + test "get smart-contract with scam reputation", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = + conn + |> put_req_cookie("show_scam_tokens", "true") + |> get("/api/v2/transactions/#{transaction.hash}/token-transfers") + + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "scam" + + request = conn |> get("/api/v2/transactions/#{transaction.hash}/token-transfers") + response = json_response(request, 200) + + assert response["items"] == [] + end + + test "get token-transfers with ok reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + request = conn |> get("/api/v2/transactions/#{transaction.hash}/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + + test "get token-transfers with scam reputation with hide_scam_addresses=false", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, false) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + token = insert(:token, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_ids: Enum.map(0..50, fn x -> x end), + token_type: "ERC-1155", + amounts: Enum.map(0..50, fn x -> x end) + ) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + + request = conn |> get("/api/v2/transactions/#{transaction.hash}/token-transfers") + response = json_response(request, 200) + + assert List.first(response["items"])["token"]["reputation"] == "ok" + end + test "return 404 on non existing transaction", %{conn: conn} do transaction = build(:transaction) request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/token-transfers") @@ -509,7 +925,15 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do test "return 422 on invalid transaction hash", %{conn: conn} do request = get(conn, "/api/v2/transactions/0x/token-transfers") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) end test "return empty list", %{conn: conn} do @@ -684,7 +1108,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do # -- ------ -- # two filters simultaneously - filter = %{"type" => "ERC-1155,ERC-20"} + filter = %{"type" => "ERC-1155,ERC-20,ERC-7984"} request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/token-transfers", filter) assert response = json_response(request, 200) @@ -697,6 +1121,8 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert response_2nd_page = json_response(request_2nd_page, 200) + # Note: filter now includes ERC-7984, but we didn't create any ERC-7984 transfers in this test + # So the pagination behavior remains the same as before assert Enum.count(response["items"]) == 50 assert response["next_page_params"] != nil compare_item(Enum.at(erc_1155_tt, 50), Enum.at(response["items"], 0)) @@ -941,7 +1367,185 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do end end - describe "/transactions/{transaction_hash}/state-changes" do + describe "/transactions/{transaction_hash}/fhe-operations" do + test "return 404 on non existing transaction", %{conn: conn} do + transaction = build(:transaction) + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/fhe-operations") + + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "return 422 on invalid transaction hash", %{conn: conn} do + request = get(conn, "/api/v2/transactions/0x/fhe-operations") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end + + test "return empty list when no FHE operations", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/fhe-operations") + + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["total_hcu"] == 0 + assert response["max_depth_hcu"] == 0 + assert response["operation_count"] == 0 + end + + test "return FHE operations for transaction", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + caller = insert(:address) + + fhe_operation_1 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + caller: caller.hash, + hcu_cost: 100, + hcu_depth: 1 + ) + + fhe_operation_2 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 2, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + caller: caller.hash, + hcu_cost: 200, + hcu_depth: 2 + ) + + # Create another transaction with FHE operations to ensure filtering works + transaction_2 = + :transaction + |> insert() + |> with_block() + + insert(:fhe_operation, + transaction_hash: transaction_2.hash, + log_index: 1, + block_hash: transaction_2.block.hash, + block_number: transaction_2.block_number + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/fhe-operations") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 2 + assert response["total_hcu"] == 300 + assert response["max_depth_hcu"] == 2 + assert response["operation_count"] == 2 + + # Check first operation + operation_1 = Enum.at(response["items"], 0) + assert operation_1["log_index"] == fhe_operation_1.log_index + assert operation_1["operation"] == fhe_operation_1.operation + assert operation_1["type"] == fhe_operation_1.operation_type + assert operation_1["fhe_type"] == fhe_operation_1.fhe_type + assert operation_1["is_scalar"] == fhe_operation_1.is_scalar + assert operation_1["hcu_cost"] == fhe_operation_1.hcu_cost + assert operation_1["hcu_depth"] == fhe_operation_1.hcu_depth + assert operation_1["block_number"] == fhe_operation_1.block_number + assert operation_1["caller"] != nil + assert operation_1["caller"]["hash"] == Address.checksum(caller.hash) + assert operation_1["result"] == "0x" <> Base.encode16(fhe_operation_1.result_handle, case: :lower) + assert operation_1["inputs"] == fhe_operation_1.input_handles + + # Check second operation + operation_2 = Enum.at(response["items"], 1) + assert operation_2["log_index"] == fhe_operation_2.log_index + assert operation_2["hcu_cost"] == fhe_operation_2.hcu_cost + assert operation_2["hcu_depth"] == fhe_operation_2.hcu_depth + end + + test "return FHE operations without caller", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + fhe_operation = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + caller: nil + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/fhe-operations") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 1 + + operation = Enum.at(response["items"], 0) + assert operation["caller"] == nil + assert operation["log_index"] == fhe_operation.log_index + end + + test "return FHE operations ordered by log_index", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + # Insert operations in non-sequential order + fhe_operation_3 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 3, + block_hash: transaction.block.hash, + block_number: transaction.block_number + ) + + fhe_operation_1 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: transaction.block.hash, + block_number: transaction.block_number + ) + + fhe_operation_2 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 2, + block_hash: transaction.block.hash, + block_number: transaction.block_number + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/fhe-operations") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 3 + + # Verify ordering + assert Enum.at(response["items"], 0)["log_index"] == fhe_operation_1.log_index + assert Enum.at(response["items"], 1)["log_index"] == fhe_operation_2.log_index + assert Enum.at(response["items"], 2)["log_index"] == fhe_operation_3.log_index + end + end + + describe "/transactions/{transaction_hash}/state-changes" do test "return 404 on non existing transaction", %{conn: conn} do transaction = build(:transaction) request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") @@ -952,7 +1556,27 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do test "return 422 on invalid transaction hash", %{conn: conn} do request = get(conn, "/api/v2/transactions/0x/state-changes") - assert %{"message" => "Invalid parameter(s)"} = json_response(request, 422) + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end + + test "accepts pagination params", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + request = + get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes?items_count=50&state_changes=null") + + assert %{} = json_response(request, 200) end test "return existing transaction", %{conn: conn} do @@ -1003,8 +1627,6 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do index: 0, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0, value: %Wei{value: Decimal.new(7)}, from_address_hash: internal_transaction_from.hash, from_address: internal_transaction_from, @@ -1068,8 +1690,6 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do index: 0, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0, value: %Wei{value: Decimal.new(7)}, from_address_hash: internal_transaction_from.hash, from_address: internal_transaction_from, @@ -1084,8 +1704,6 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do index: 1, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 1, value: %Wei{value: Decimal.new(7)}, from_address_hash: internal_transaction_from_delegatecall.hash, from_address: internal_transaction_from_delegatecall, @@ -1099,8 +1717,6 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do index: 2, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2, value: %Wei{value: Decimal.new(7)}, from_address_hash: internal_transaction_from.hash, from_address: internal_transaction_from, @@ -1148,141 +1764,466 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert response = json_response(request, 200) assert Enum.count(response["items"]) == 5 end - end - - if Application.compile_env(:explorer, :chain_type) == :celo do - describe "celo gas token" do - test "when gas is paid with token and token is present in db", %{conn: conn} do - token = insert(:token) - transaction = - :transaction - |> insert(gas_token_contract_address: token.contract_address) - |> with_block() + test "return state changes with token transfers and verify token is correctly loaded", %{conn: conn} do + block_before = insert(:block) - request = get(conn, "/api/v2/transactions") + transaction = + :transaction + |> insert() + |> with_block(status: :ok) - token_address_hash = Address.checksum(token.contract_address_hash) - token_type = token.type - token_name = token.name - token_symbol = token.symbol + token = insert(:token, type: "ERC-20", symbol: "TEST", name: "Test Token") + from_address = insert(:address) + to_address = insert(:address) - assert %{ - "items" => [ - %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^token_address_hash, - "name" => ^token_name, - "symbol" => ^token_symbol, - "type" => ^token_type - } - } - } - ] - } = json_response(request, 200) + # Create token transfer + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_contract_address_hash: token.contract_address_hash, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash, + amount: Decimal.new(100), + token: token, + token_ids: nil + ) - request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}") + # Set up coin balances for transaction participants + insert(:address_coin_balance, + address: transaction.from_address, + address_hash: transaction.from_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) - assert %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^token_address_hash, - "name" => ^token_name, - "symbol" => ^token_symbol, - "type" => ^token_type - } - } - } = json_response(request, 200) + insert(:address_coin_balance, + address: transaction.to_address, + address_hash: transaction.to_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) - request = get(conn, "/api/v2/addresses/#{to_string(transaction.from_address_hash)}/transactions") + insert(:address_coin_balance, + address: transaction.block.miner, + address_hash: transaction.block.miner_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) - assert %{ - "items" => [ - %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^token_address_hash, - "name" => ^token_name, - "symbol" => ^token_symbol, - "type" => ^token_type - } - } - } - ] - } = json_response(request, 200) + # Set up token balances + insert(:address_current_token_balance, + address: from_address, + address_hash: from_address.hash, + token_contract_address_hash: token.contract_address_hash, + block_number: block_before.number, + value: Decimal.new(1000) + ) - request = get(conn, "/api/v2/main-page/transactions") + insert(:address_current_token_balance, + address: to_address, + address_hash: to_address.hash, + token_contract_address_hash: token.contract_address_hash, + block_number: block_before.number, + value: Decimal.new(0) + ) - assert [ - %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^token_address_hash, - "name" => ^token_name, - "symbol" => ^token_symbol, - "type" => ^token_type - } - } - } - ] = json_response(request, 200) - end + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") - test "when gas is paid with token and token is not present in db", %{conn: conn} do - unknown_token_address = insert(:address) + assert response = json_response(request, 200) + assert is_list(response["items"]) + + # Find the token state changes (should have at least from and to addresses) + token_state_changes = Enum.filter(response["items"], fn item -> item["type"] == "token" end) + assert length(token_state_changes) >= 2 + + # Verify token information is properly loaded in at least one state change + token_state_change = Enum.find(token_state_changes, fn item -> not is_nil(item["token"]) end) + assert token_state_change + + token_data = token_state_change["token"] + assert token_data["symbol"] == "TEST" + assert token_data["name"] == "Test Token" + assert token_data["type"] == "ERC-20" + assert token_data["address_hash"] == to_string(token.contract_address) + assert token_data["reputation"] == "ok" + end - transaction = - :transaction - |> insert(gas_token_contract_address: unknown_token_address) - |> with_block() + test "return state changes with scam token reputation properly set", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) - unknown_token_address_hash = Address.checksum(unknown_token_address.hash) + block_before = insert(:block) - request = get(conn, "/api/v2/transactions") + transaction = + :transaction + |> insert() + |> with_block(status: :ok) - assert %{ - "items" => [ - %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^unknown_token_address_hash - } - } - } - ] - } = json_response(request, 200) + # Create a token + token = insert(:token, type: "ERC-20", symbol: "SCAM", name: "Scam Token") + from_address = insert(:address) + to_address = insert(:address) - request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}") + # Create scam badge for the token address to mark it as scam + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) - assert %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^unknown_token_address_hash - } - } - } = json_response(request, 200) + # Create token transfer + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: token.contract_address, + token_contract_address_hash: token.contract_address_hash, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash, + amount: Decimal.new(100), + token: token, + token_ids: nil + ) - request = get(conn, "/api/v2/addresses/#{to_string(transaction.from_address_hash)}/transactions") + # Set up coin balances for transaction participants + insert(:address_coin_balance, + address: transaction.from_address, + address_hash: transaction.from_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) - assert %{ - "items" => [ - %{ - "celo" => %{ - "gas_token" => %{ - "address" => ^unknown_token_address_hash - } - } - } - ] - } = json_response(request, 200) + insert(:address_coin_balance, + address: transaction.to_address, + address_hash: transaction.to_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) - request = get(conn, "/api/v2/main-page/transactions") + insert(:address_coin_balance, + address: transaction.block.miner, + address_hash: transaction.block.miner_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + # Set up token balances + insert(:address_current_token_balance, + address: from_address, + address_hash: from_address.hash, + token_contract_address_hash: token.contract_address_hash, + block_number: block_before.number, + value: Decimal.new(1000) + ) + + insert(:address_current_token_balance, + address: to_address, + address_hash: to_address.hash, + token_contract_address_hash: token.contract_address_hash, + block_number: block_before.number, + value: Decimal.new(0) + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") + + assert response = json_response(request, 200) + assert is_list(response["items"]) + + # Find the token state changes + token_state_changes = Enum.filter(response["items"], fn item -> item["type"] == "token" end) + assert length(token_state_changes) >= 2 + + # Verify that the token has scam reputation + token_state_change = Enum.find(token_state_changes, fn item -> not is_nil(item["token"]) end) + assert token_state_change + + token_data = token_state_change["token"] + assert token_data["reputation"] == "scam" + assert token_data["symbol"] == "SCAM" + assert token_data["name"] == "Scam Token" + assert token_data["address_hash"] == to_string(token.contract_address) + end + + test "return state changes with null value internal transaction", %{conn: conn} do + block_before = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + internal_transaction = + insert(:internal_transaction, + call_type: :call, + transaction_hash: transaction.hash, + transaction: transaction, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + value: %Wei{value: Decimal.new(1000)} + ) + |> InternalTransaction.preload_addresses() + + insert(:internal_transaction, + call_type: :call, + transaction_hash: transaction.hash, + transaction: transaction, + index: 2, + block_number: transaction.block_number, + transaction_index: transaction.index, + value: nil, + from_address_hash: internal_transaction.from_address_hash, + from_address: internal_transaction.from_address, + to_address_hash: internal_transaction.to_address_hash, + to_address: internal_transaction.to_address + ) + + insert(:address_coin_balance, + address: transaction.from_address, + address_hash: transaction.from_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + insert(:address_coin_balance, + address: transaction.to_address, + address_hash: transaction.to_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + insert(:address_coin_balance, + address: transaction.block.miner, + address_hash: transaction.block.miner_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + insert(:address_coin_balance, + address: internal_transaction.from_address, + address_hash: internal_transaction.from_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + insert(:address_coin_balance, + address: internal_transaction.to_address, + address_hash: internal_transaction.to_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 5 + end + + test "return state changes with ERC-7984 token transfer", %{conn: conn} do + block_before = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + confidential_token = insert(:token, type: "ERC-7984", symbol: "CT", name: "Confidential Token") + erc20_token = insert(:token, type: "ERC-20", symbol: "ERC20", name: "ERC20 Token") + + erc20_token_transfer = + insert(:token_transfer, + token_type: "ERC-20", + transaction: transaction, + transaction_hash: transaction.hash, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: erc20_token.contract_address, + amount: Decimal.new(100), + token_ids: nil + ) + + from_address = erc20_token_transfer.from_address + to_address = erc20_token_transfer.to_address + + # Create ERC-7984 token transfer - should be skipped in state changes + insert(:token_transfer, + token_type: "ERC-7984", + transaction: transaction, + transaction_hash: transaction.hash, + block: transaction.block, + block_number: transaction.block_number, + token_contract_address: confidential_token.contract_address, + from_address: from_address, + to_address: to_address, + amount: nil, + token_ids: nil + ) + + insert(:address_coin_balance, + address: transaction.from_address, + address_hash: transaction.from_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + insert(:address_coin_balance, + address: transaction.to_address, + address_hash: transaction.to_address_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + insert(:address_coin_balance, + address: transaction.block.miner, + address_hash: transaction.block.miner_hash, + block_number: block_before.number, + value: %Wei{value: Decimal.new(1000)} + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/state-changes") + + assert response = json_response(request, 200) + assert Enum.count(response["items"]) == 5 + end + end + + if @chain_identity == {:optimism, :celo} do + describe "celo gas token" do + test "when gas is paid with token and token is present in db", %{conn: conn} do + token = insert(:token) + + transaction = + :transaction + |> insert(gas_token_contract_address: token.contract_address) + |> with_block() + + request = get(conn, "/api/v2/transactions") + + token_address_hash = Address.checksum(token.contract_address_hash) + token_type = token.type + token_name = token.name + token_symbol = token.symbol + + assert %{ + "items" => [ + %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^token_address_hash, + "name" => ^token_name, + "symbol" => ^token_symbol, + "type" => ^token_type + } + } + } + ] + } = json_response(request, 200) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}") + + assert %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^token_address_hash, + "name" => ^token_name, + "symbol" => ^token_symbol, + "type" => ^token_type + } + } + } = json_response(request, 200) + + request = get(conn, "/api/v2/addresses/#{to_string(transaction.from_address_hash)}/transactions") + + assert %{ + "items" => [ + %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^token_address_hash, + "name" => ^token_name, + "symbol" => ^token_symbol, + "type" => ^token_type + } + } + } + ] + } = json_response(request, 200) + + request = get(conn, "/api/v2/main-page/transactions") + + assert [ + %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^token_address_hash, + "name" => ^token_name, + "symbol" => ^token_symbol, + "type" => ^token_type + } + } + } + ] = json_response(request, 200) + end + + test "when gas is paid with token and token is not present in db", %{conn: conn} do + unknown_token_address = insert(:address) + + transaction = + :transaction + |> insert(gas_token_contract_address: unknown_token_address) + |> with_block() + + unknown_token_address_hash = Address.checksum(unknown_token_address.hash) + + request = get(conn, "/api/v2/transactions") + + assert %{ + "items" => [ + %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^unknown_token_address_hash + } + } + } + ] + } = json_response(request, 200) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}") + + assert %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^unknown_token_address_hash + } + } + } = json_response(request, 200) + + request = get(conn, "/api/v2/addresses/#{to_string(transaction.from_address_hash)}/transactions") + + assert %{ + "items" => [ + %{ + "celo" => %{ + "gas_token" => %{ + "address_hash" => ^unknown_token_address_hash + } + } + } + ] + } = json_response(request, 200) + + request = get(conn, "/api/v2/main-page/transactions") assert [ %{ "celo" => %{ "gas_token" => %{ - "address" => ^unknown_token_address_hash + "address_hash" => ^unknown_token_address_hash } } } @@ -1337,17 +2278,22 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do |> with_block(status: :ok) raw_trace = %{ + "action" => %{ + "callType" => "call", + "from" => "0xa931c862e662134b85e4dc4baf5c70cc9ba74db4", + "to" => "0x1469b17ebf82fedf56f04109e5207bdc4554288c", + "gas" => "0x8600", + "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", + "value" => "0x174876e800" + }, + "result" => %{ + "gasUsed" => "0x7d37", + "output" => "0x" + }, + "subtraces" => 0, "traceAddress" => [], - "type" => "call", - "callType" => "call", - "from" => "0xa931c862e662134b85e4dc4baf5c70cc9ba74db4", - "to" => "0x1469b17ebf82fedf56f04109e5207bdc4554288c", - "gas" => "0x8600", - "gasUsed" => "0x7d37", - "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", - "output" => "0x", - "value" => "0x174876e800", - "transactionHash" => to_string(transaction.hash) + "transactionHash" => to_string(transaction.hash), + "type" => "call" } expect(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> {:ok, [raw_trace]} end) @@ -1376,11 +2322,6 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do if Application.compile_env(:explorer, :chain_type) == :stability do @first_topic_hex_string_1 "0x99e7b0ba56da2819c37c047f0511fd2bf6c9b4e27b4a979a19d6da0f74be8155" - defp topic(topic_hex_string) do - {:ok, topic} = Explorer.Chain.Hash.Full.cast(topic_hex_string) - topic - end - describe "stability fees" do test "check stability fees", %{conn: conn} do transaction = insert(:transaction) |> with_block() @@ -1391,7 +2332,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do index: 1, block: transaction.block, block_number: transaction.block_number, - first_topic: topic(@first_topic_hex_string_1), + first_topic: TestHelper.topic(@first_topic_hex_string_1), data: "0x000000000000000000000000dc2b93f3291030f3f7a6d9363ac37757f7ad5c4300000000000000000000000000000000000000000000000000002824369a100000000000000000000000000046b555cb3962bf9533c437cbd04a2f702dfdb999000000000000000000000000000000000000000000000000000014121b4d0800000000000000000000000000faf7a981360c2fab3a5ab7b3d6d8d0cf97a91eb9000000000000000000000000000000000000000000000000000014121b4d0800" ) @@ -1403,7 +2344,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do "items" => [ %{ "stability_fee" => %{ - "token" => %{"address" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, + "token" => %{"address_hash" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, "validator_address" => %{"hash" => "0x46B555CB3962bF9533c437cBD04A2f702dfdB999"}, "dapp_address" => %{"hash" => "0xFAf7a981360c2FAb3a5Ab7b3D6d8D0Cf97a91Eb9"}, "total_fee" => "44136000000000", @@ -1418,7 +2359,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert %{ "stability_fee" => %{ - "token" => %{"address" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, + "token" => %{"address_hash" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, "validator_address" => %{"hash" => "0x46B555CB3962bF9533c437cBD04A2f702dfdB999"}, "dapp_address" => %{"hash" => "0xFAf7a981360c2FAb3a5Ab7b3D6d8D0Cf97a91Eb9"}, "total_fee" => "44136000000000", @@ -1433,7 +2374,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do "items" => [ %{ "stability_fee" => %{ - "token" => %{"address" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, + "token" => %{"address_hash" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, "validator_address" => %{"hash" => "0x46B555CB3962bF9533c437cBD04A2f702dfdB999"}, "dapp_address" => %{"hash" => "0xFAf7a981360c2FAb3a5Ab7b3D6d8D0Cf97a91Eb9"}, "total_fee" => "44136000000000", @@ -1454,7 +2395,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do index: 1, block: transaction.block, block_number: transaction.block_number, - first_topic: topic(@first_topic_hex_string_1), + first_topic: TestHelper.topic(@first_topic_hex_string_1), data: "0x000000000000000000000000dc2b93f3291030f3f7a6d9363ac37757f7ad5c4300000000000000000000000000000000000000000000000000002824369a100000000000000000000000000046b555cb3962bf9533c437cbd04a2f702dfdb999000000000000000000000000000000000000000000000000000014121b4d0800000000000000000000000000faf7a981360c2fab3a5ab7b3d6d8d0cf97a91eb9000000000000000000000000000000000000000000000000000014121b4d0800" ) @@ -1465,7 +2406,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do "items" => [ %{ "stability_fee" => %{ - "token" => %{"address" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, + "token" => %{"address_hash" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, "validator_address" => %{"hash" => "0x46B555CB3962bF9533c437cBD04A2f702dfdB999"}, "dapp_address" => %{"hash" => "0xFAf7a981360c2FAb3a5Ab7b3D6d8D0Cf97a91Eb9"}, "total_fee" => "44136000000000", @@ -1480,7 +2421,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert %{ "stability_fee" => %{ - "token" => %{"address" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, + "token" => %{"address_hash" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, "validator_address" => %{"hash" => "0x46B555CB3962bF9533c437cBD04A2f702dfdB999"}, "dapp_address" => %{"hash" => "0xFAf7a981360c2FAb3a5Ab7b3D6d8D0Cf97a91Eb9"}, "total_fee" => "44136000000000", @@ -1495,7 +2436,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do "items" => [ %{ "stability_fee" => %{ - "token" => %{"address" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, + "token" => %{"address_hash" => "0xDc2B93f3291030F3F7a6D9363ac37757f7AD5C43"}, "validator_address" => %{"hash" => "0x46B555CB3962bF9533c437cBD04A2f702dfdB999"}, "dapp_address" => %{"hash" => "0xFAf7a981360c2FAb3a5Ab7b3D6d8D0Cf97a91Eb9"}, "total_fee" => "44136000000000", @@ -1509,19 +2450,48 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do end end - defp compare_item(%Transaction{} = transaction, json) do - assert to_string(transaction.hash) == json["hash"] - assert transaction.block_number == json["block_number"] - assert to_string(transaction.value.value) == json["value"] - assert Address.checksum(transaction.from_address_hash) == json["from"]["hash"] - assert Address.checksum(transaction.to_address_hash) == json["to"]["hash"] - end + if @chain_type == :ethereum do + describe "transactions/{transaction_hash}/beacon/deposits" do + test "get 404 on non-existing transaction", %{conn: conn} do + transaction = build(:transaction) - defp compare_item(%InternalTransaction{} = internal_transaction, json) do - assert internal_transaction.block_number == json["block_number"] - assert to_string(internal_transaction.gas) == json["gas_limit"] - assert internal_transaction.index == json["index"] - assert to_string(internal_transaction.transaction_hash) == json["transaction_hash"] + request = get(conn, "/api/v2/transactions/#{transaction.hash}/beacon/deposits") + response = json_response(request, 404) + end + + test "get deposits", %{conn: conn} do + transaction = insert(:transaction) + + deposits = insert_list(51, :beacon_deposit, transaction: transaction) + + insert(:beacon_deposit) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/beacon/deposits") + assert response = json_response(request, 200) + + request_2nd_page = + get(conn, "/api/v2/transactions/#{transaction.hash}/beacon/deposits", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, deposits) + end + end + end + + defp compare_item(%Transaction{} = transaction, json) do + assert to_string(transaction.hash) == json["hash"] + assert transaction.block_number == json["block_number"] + assert to_string(transaction.value.value) == json["value"] + assert Address.checksum(transaction.from_address_hash) == json["from"]["hash"] + assert Address.checksum(transaction.to_address_hash) == json["to"]["hash"] + end + + defp compare_item(%InternalTransaction{} = internal_transaction, json) do + assert internal_transaction.block_number == json["block_number"] + assert to_string(internal_transaction.gas) == json["gas_limit"] + assert internal_transaction.index == json["index"] + assert to_string(internal_transaction.transaction.hash) == json["transaction_hash"] assert Address.checksum(internal_transaction.from_address_hash) == json["from"]["hash"] assert Address.checksum(internal_transaction.to_address_hash) == json["to"]["hash"] end @@ -1532,6 +2502,7 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert Address.checksum(log.address_hash) == json["address"]["hash"] assert to_string(log.transaction_hash) == json["transaction_hash"] assert json["block_number"] == log.block_number + assert json["block_timestamp"] != nil end defp compare_item(%TokenTransfer{} = token_transfer, json) do @@ -1545,6 +2516,45 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert check_total(Repo.preload(token_transfer, [{:token, :contract_address}]).token, json["total"], token_transfer) end + defp compare_item(%BeaconDeposit{} = deposit, json) do + index = deposit.index + transaction_hash = to_string(deposit.transaction_hash) + block_hash = to_string(deposit.block_hash) + block_number = deposit.block_number + pubkey = to_string(deposit.pubkey) + withdrawal_credentials = to_string(deposit.withdrawal_credentials) + signature = to_string(deposit.signature) + from_address_hash = Address.checksum(deposit.from_address_hash) + + if deposit.withdrawal_address_hash do + withdrawal_address_hash = Address.checksum(deposit.withdrawal_address_hash) + + assert %{ + "index" => ^index, + "transaction_hash" => ^transaction_hash, + "block_hash" => ^block_hash, + "block_number" => ^block_number, + "pubkey" => ^pubkey, + "withdrawal_credentials" => ^withdrawal_credentials, + "withdrawal_address" => %{"hash" => ^withdrawal_address_hash}, + "signature" => ^signature, + "from_address" => %{"hash" => ^from_address_hash} + } = json + else + assert %{ + "index" => ^index, + "transaction_hash" => ^transaction_hash, + "block_hash" => ^block_hash, + "block_number" => ^block_number, + "pubkey" => ^pubkey, + "withdrawal_credentials" => ^withdrawal_credentials, + "withdrawal_address" => nil, + "signature" => ^signature, + "from_address" => %{"hash" => ^from_address_hash} + } = json + end + end + defp compare_item(%Transaction{} = transaction, json, wl_names) do assert to_string(transaction.hash) == json["hash"] assert transaction.block_number == json["block_number"] @@ -1613,7 +2623,682 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do defp check_total(_, _, _), do: true + describe "/transactions/{transaction_hash}/summary?just_request_body=true" do + setup do + original_config = + Application.get_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation) + + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: true, + service_url: "http://localhost:4000" + ) + + original_chain_id = Application.get_env(:block_scout_web, :chain_id) + Application.put_env(:block_scout_web, :chain_id, 1) + + on_exit(fn -> + Application.put_env( + :block_scout_web, + BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + original_config + ) + + Application.put_env(:block_scout_web, :chain_id, original_chain_id) + end) + end + + test "return 404 on non existing transaction", %{conn: conn} do + transaction = build(:transaction) + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert %{"message" => "Not found"} = json_response(request, 404) + end + + test "return 422 on invalid transaction hash", %{conn: conn} do + request = get(conn, "/api/v2/transactions/0x/summary?just_request_body=true") + + assert %{ + "errors" => [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + } = json_response(request, 422) + end + + test "return 403 when transaction interpretation service is disabled", %{conn: conn} do + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: false + ) + + transaction = + :transaction + |> insert() + |> with_block() + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert %{"message" => "Transaction Interpretation Service is disabled"} = json_response(request, 403) + end + + test "return request body for existing transaction", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + + # Verify the structure of the request body + assert Map.has_key?(response, "data") + assert Map.has_key?(response, "logs_data") + assert Map.has_key?(response, "chain_id") + + # Verify data structure + data = response["data"] + assert Map.has_key?(data, "to") + assert Map.has_key?(data, "from") + assert Map.has_key?(data, "hash") + assert Map.has_key?(data, "type") + assert Map.has_key?(data, "value") + assert Map.has_key?(data, "method") + assert Map.has_key?(data, "status") + assert Map.has_key?(data, "transaction_types") + assert Map.has_key?(data, "raw_input") + assert Map.has_key?(data, "decoded_input") + assert Map.has_key?(data, "token_transfers") + assert Map.has_key?(data, "internal_transactions") + + # Verify logs_data structure + logs_data = response["logs_data"] + assert Map.has_key?(logs_data, "items") + assert is_list(logs_data["items"]) + + # Verify chain_id is present and is an integer + assert is_integer(response["chain_id"]) + + # Verify transaction data matches + assert to_string(transaction.hash) == data["hash"] + assert transaction.type == data["type"] + assert to_string(transaction.value.value) == data["value"] + assert to_string(transaction.status) == data["status"] + end + + test "return request body with token transfers", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert is_list(response["data"]["token_transfers"]) + assert Enum.count(response["data"]["token_transfers"]) >= 1 + end + + test "return request body with internal transactions", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction_hash: transaction.hash, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :reward + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert is_list(response["data"]["internal_transactions"]) + assert Enum.count(response["data"]["internal_transactions"]) >= 1 + end + + test "return request body with logs", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + insert(:log, + transaction: transaction, + index: 1, + block: transaction.block, + block_number: transaction.block_number + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert is_list(response["logs_data"]["items"]) + assert Enum.count(response["logs_data"]["items"]) >= 1 + end + + test "return request body with smart contract transaction", %{conn: conn} do + contract = + insert(:smart_contract, + contract_code_md5: "123", + abi: [ + %{ + "constant" => false, + "inputs" => [%{"name" => "", "type" => "bytes"}], + "name" => "set", + "outputs" => [], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + ] + ) + |> Repo.preload(:address) + + input_data = + "set(bytes)" + |> ABI.encode([ + <<48, 120, 253, 69, 39, 88, 49, 136, 89, 142, 21, 123, 116, 129, 248, 32, 77, 29, 224, 121, 49, 137, 216, 8, + 212, 195, 239, 11, 174, 75, 56, 126>> + ]) + |> Base.encode16(case: :lower) + + transaction = + :transaction + |> insert(to_address: contract.address, input: "0x" <> input_data) + |> with_block() + |> Repo.preload(to_address: :smart_contract) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + + # Verify that the transaction has input data + assert response["data"]["raw_input"] == "0x" <> input_data + + assert response["data"]["decoded_input"] == %{ + "method_call" => "set(bytes arg0)", + "method_id" => "0399321e", + "parameters" => [ + %{ + "name" => "arg0", + "type" => "bytes", + "value" => "0x3078fd4527583188598e157b7481f8204d1de0793189d808d4c3ef0bae4b387e" + } + ] + } + end + + test "return request body with proper address information", %{conn: conn} do + from_address = insert(:address) + to_address = insert(:address) + + transaction = + :transaction + |> insert(from_address: from_address, to_address: to_address) + |> with_block() + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + + # Verify from address + from_data = response["data"]["from"] + assert Map.has_key?(from_data, "hash") + assert from_data["hash"] == Address.checksum(from_address.hash) + + # Verify to address + to_data = response["data"]["to"] + assert Map.has_key?(to_data, "hash") + assert to_data["hash"] == Address.checksum(to_address.hash) + end + + test "limits token transfers to 50 items", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + # Insert more than 50 token transfers + insert_list(60, :token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert Enum.count(response["data"]["token_transfers"]) <= 50 + end + + test "limits internal transactions to 50 items", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + # Insert more than 50 internal transactions + for index <- 1..60 do + insert(:internal_transaction, + transaction: transaction, + index: index, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + end + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert Enum.count(response["data"]["internal_transactions"]) <= 50 + end + + test "limits logs to 50 items", %{conn: conn} do + transaction = + :transaction + |> insert() + |> with_block() + + # Insert more than 50 logs + for index <- 1..60 do + insert(:log, + transaction: transaction, + index: index, + block: transaction.block, + block_number: transaction.block_number + ) + end + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert Enum.count(response["logs_data"]["items"]) <= 50 + end + + test "log could be decoded via verified implementation", %{conn: conn} do + address = insert(:contract_address) + + contract_address = insert(:contract_address) + + smart_contract = + insert(:smart_contract, + address_hash: contract_address.hash, + abi: [ + %{ + "name" => "OptionSettled", + "type" => "event", + "inputs" => [ + %{"name" => "accountId", "type" => "uint256", "indexed" => true, "internalType" => "uint256"}, + %{"name" => "option", "type" => "address", "indexed" => false, "internalType" => "address"}, + %{"name" => "subId", "type" => "uint256", "indexed" => false, "internalType" => "uint256"}, + %{"name" => "amount", "type" => "int256", "indexed" => false, "internalType" => "int256"}, + %{"name" => "value", "type" => "int256", "indexed" => false, "internalType" => "int256"} + ], + "anonymous" => false + } + ] + ) + + topic1_bytes = ExKeccak.hash_256("OptionSettled(uint256,address,uint256,int256,int256)") + topic1 = "0x" <> Base.encode16(topic1_bytes, case: :lower) + topic2 = "0x0000000000000000000000000000000000000000000000000000000000005d19" + + log_data = + "0x000000000000000000000000aeb81cbe6b19ceeb0dbe0d230cffe35bb40a13a700000000000000000000000000000000000000000000045d964b80006597b700fffffffffffffffffffffffffffffffffffffffffffffffffe55aca2c2f40000ffffffffffffffffffffffffffffffffffffffffffffffe3a8289da3d7a13ef2" + + transaction = :transaction |> insert() |> with_block() + + insert(:log, + transaction: transaction, + first_topic: TestHelper.topic(topic1), + second_topic: TestHelper.topic(topic2), + third_topic: nil, + fourth_topic: nil, + data: log_data, + address: address, + block: transaction.block, + block_number: transaction.block_number + ) + + insert(:proxy_implementation, + proxy_address_hash: address.hash, + proxy_type: "eip1167", + address_hashes: [smart_contract.address_hash], + names: ["Test"] + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert is_list(response["logs_data"]["items"]) + assert Enum.count(response["logs_data"]["items"]) == 1 + + log_from_api = Enum.at(response["logs_data"]["items"], 0) + assert not is_nil(log_from_api["decoded"]) + + assert log_from_api["decoded"] == %{ + "method_call" => + "OptionSettled(uint256 indexed accountId, address option, uint256 subId, int256 amount, int256 value)", + "method_id" => "d20a68b2", + "parameters" => [ + %{ + "indexed" => true, + "name" => "accountId", + "type" => "uint256", + "value" => "23833" + }, + %{ + "indexed" => false, + "name" => "option", + "type" => "address", + "value" => Address.checksum("0xAeB81cbe6b19CeEB0dBE0d230CFFE35Bb40a13a7") + }, + %{ + "indexed" => false, + "name" => "subId", + "type" => "uint256", + "value" => "20615843020801704441600" + }, + %{ + "indexed" => false, + "name" => "amount", + "type" => "int256", + "value" => "-120000000000000000" + }, + %{ + "indexed" => false, + "name" => "value", + "type" => "int256", + "value" => "-522838470013113778446" + } + ] + } + end + + test "test corner case, when preload functions face absent smart contract", %{conn: conn} do + address = insert(:contract_address) + + contract_address = insert(:contract_address) + + topic1_bytes = ExKeccak.hash_256("OptionSettled(uint256,address,uint256,int256,int256)") + topic1 = "0x" <> Base.encode16(topic1_bytes, case: :lower) + topic2 = "0x0000000000000000000000000000000000000000000000000000000000005d19" + + log_data = + "0x000000000000000000000000aeb81cbe6b19ceeb0dbe0d230cffe35bb40a13a700000000000000000000000000000000000000000000045d964b80006597b700fffffffffffffffffffffffffffffffffffffffffffffffffe55aca2c2f40000ffffffffffffffffffffffffffffffffffffffffffffffe3a8289da3d7a13ef2" + + transaction = :transaction |> insert() |> with_block() + + insert(:log, + transaction: transaction, + first_topic: TestHelper.topic(topic1), + second_topic: TestHelper.topic(topic2), + third_topic: nil, + fourth_topic: nil, + data: log_data, + address: address, + block: transaction.block, + block_number: transaction.block_number + ) + + insert(:proxy_implementation, + proxy_address_hash: address.hash, + proxy_type: "eip1167", + address_hashes: [contract_address.hash], + names: ["Test"] + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary?just_request_body=true") + + assert response = json_response(request, 200) + assert is_list(response["logs_data"]["items"]) + assert Enum.count(response["logs_data"]["items"]) == 1 + + log_from_api = Enum.at(response["logs_data"]["items"], 0) + # In this case, the log should not be decoded since the smart contract is absent + assert is_nil(log_from_api["decoded"]) + end + end + + describe "/transactions/{transaction_hash}/summary" do + setup do + original_config = + Application.get_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation) + + original_tesla_adapter = Application.get_env(:tesla, :adapter) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env( + :block_scout_web, + BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + original_config + ) + + Application.put_env(:tesla, :adapter, original_tesla_adapter) + end) + end + + test "success preload template variables", %{conn: conn} do + bypass = Bypass.open() + + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: true, + service_url: "http://localhost:#{bypass.port}" + ) + + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + token = insert(:token) + address = insert(:address) + + tx_interpretation_response = """ + { + "success": true, + "data": { + "summaries": [ + { + "summary_template": "{action_type} {outgoing_amount} {native} for {incoming_amount} {incoming_token} on {market}", + "summary_template_variables": { + "action_type": { + "type": "string", + "value": "Swap" + }, + "outgoing_amount": { + "type": "currency", + "value": "0.3" + }, + "incoming_amount": { + "type": "currency", + "value": "9693.997876398680187001" + }, + "incoming_token": { + "type": "token", + "value": { + "address_hash": "#{token.contract_address_hash}" + } + }, + "rnd_address": { + "type": "address", + "value": { + "hash": "#{address.hash}" + } + }, + "market": { + "type": "dexTag", + "value": { + "name": "Uniswap V3", + "icon": "https://blockscout-content.s3.amazonaws.com/uniswap.png", + "url": "https://uniswap.org/?utm_source=Blockscout" + } + } + } + } + ] + } + } + """ + + Bypass.expect_once( + bypass, + "GET", + "/cache/#{to_string(transaction.hash)}", + fn conn -> + Plug.Conn.resp(conn, 404, "Not Found") + end + ) + + Bypass.expect_once( + bypass, + "POST", + "/transactions/summary", + fn conn -> + Plug.Conn.resp(conn, 200, tx_interpretation_response) + end + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary") + assert response = json_response(request, 200) + + token_json = Enum.at(response["data"]["summaries"], 0)["summary_template_variables"]["incoming_token"]["value"] + assert token_json["address_hash"] == to_string(token.contract_address_hash) + assert token_json["symbol"] == token.symbol + assert token_json["reputation"] == "ok" + + address_json = Enum.at(response["data"]["summaries"], 0)["summary_template_variables"]["rnd_address"]["value"] + assert Map.has_key?(address_json, "ens_domain_name") + assert address_json["hash"] == to_string(address.hash) + + Bypass.down(bypass) + end + + test "success preload template variables when scam token", %{conn: conn} do + init_value = Application.get_env(:block_scout_web, :hide_scam_addresses) + Application.put_env(:block_scout_web, :hide_scam_addresses, true) + on_exit(fn -> Application.put_env(:block_scout_web, :hide_scam_addresses, init_value) end) + + bypass = Bypass.open() + + Application.put_env(:block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, + enabled: true, + service_url: "http://localhost:#{bypass.port}" + ) + + transaction = + :transaction + |> insert() + |> with_block(status: :ok) + + token = insert(:token) + address = insert(:address) + + insert(:scam_badge_to_address, address_hash: token.contract_address_hash) + insert(:scam_badge_to_address, address_hash: address.hash) + + tx_interpretation_response = """ + { + "success": true, + "data": { + "summaries": [ + { + "summary_template": "{action_type} {outgoing_amount} {native} for {incoming_amount} {incoming_token} on {market}", + "summary_template_variables": { + "action_type": { + "type": "string", + "value": "Swap" + }, + "outgoing_amount": { + "type": "currency", + "value": "0.3" + }, + "incoming_amount": { + "type": "currency", + "value": "9693.997876398680187001" + }, + "incoming_token": { + "type": "token", + "value": { + "address_hash": "#{token.contract_address_hash}" + } + }, + "rnd_address": { + "type": "address", + "value": { + "hash": "#{address.hash}" + } + }, + "market": { + "type": "dexTag", + "value": { + "name": "Uniswap V3", + "icon": "https://blockscout-content.s3.amazonaws.com/uniswap.png", + "url": "https://uniswap.org/?utm_source=Blockscout" + } + } + } + } + ] + } + } + """ + + Bypass.expect_once( + bypass, + "GET", + "/cache/#{to_string(transaction.hash)}", + fn conn -> + Plug.Conn.resp(conn, 404, "Not Found") + end + ) + + Bypass.expect_once( + bypass, + "POST", + "/transactions/summary", + fn conn -> + Plug.Conn.resp(conn, 200, tx_interpretation_response) + end + ) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/summary") + assert response = json_response(request, 200) + + token_json = Enum.at(response["data"]["summaries"], 0)["summary_template_variables"]["incoming_token"]["value"] + assert token_json["address_hash"] == to_string(token.contract_address_hash) + assert token_json["symbol"] == token.symbol + assert token_json["reputation"] == "scam" + + address_json = Enum.at(response["data"]["summaries"], 0)["summary_template_variables"]["rnd_address"]["value"] + assert Map.has_key?(address_json, "ens_domain_name") + assert address_json["hash"] == to_string(address.hash) + assert address_json["reputation"] == "scam" + assert address_json["is_scam"] == true + + Bypass.down(bypass) + end + end + if @chain_type == :neon do + import Ecto.Query, only: [from: 2] + describe "neon linked transactions service" do test "fetches data from the node and caches in the db", %{conn: conn} do transaction = insert(:transaction) @@ -1688,7 +3373,63 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do test "returns 422 for invalid transaction hash", %{conn: conn} do request = get(conn, "/api/v2/transactions/invalid_hash/external-transactions") assert response = json_response(request, 422) - assert response["message"] == "Invalid parameter(s)" + + assert response["errors"] == [ + %{ + "detail" => "Invalid format. Expected ~r/^0x([A-Fa-f0-9]{64})$/", + "source" => %{"pointer" => "/transaction_hash_param"}, + "title" => "Invalid value" + } + ] + end + end + end + + if @chain_type == :arbitrum do + describe "/transactions/arbitrum-batch/:batch_number_param" do + test "returns empty list when batch has no transactions", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + + request = get(conn, "/api/v2/transactions/arbitrum-batch/#{batch.number}") + assert response = json_response(request, 200) + assert response["items"] == [] + assert response["next_page_params"] == nil + end + + test "returns transactions in the batch", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + transaction = :transaction |> insert() |> with_block() + + insert(:arbitrum_batch_transaction, batch_number: batch.number, transaction_hash: transaction.hash) + + request = get(conn, "/api/v2/transactions/arbitrum-batch/#{batch.number}") + assert response = json_response(request, 200) + assert length(response["items"]) == 1 + assert hd(response["items"])["hash"] == to_string(transaction.hash) + end + + test "can paginate transactions in Arbitrum batch", %{conn: conn} do + batch = insert(:arbitrum_l1_batch) + transactions = 51 |> insert_list(:transaction) |> with_block() + + Enum.each(transactions, fn tx -> + insert(:arbitrum_batch_transaction, batch_number: batch.number, transaction_hash: tx.hash) + end) + + request = get(conn, "/api/v2/transactions/arbitrum-batch/#{batch.number}") + assert response = json_response(request, 200) + + request_2nd_page = + get(conn, "/api/v2/transactions/arbitrum-batch/#{batch.number}", response["next_page_params"]) + + assert response_2nd_page = json_response(request_2nd_page, 200) + + check_paginated_response(response, response_2nd_page, transactions) + end + + test "returns 422 for non-integer batch_number_param", %{conn: conn} do + request = get(conn, "/api/v2/transactions/arbitrum-batch/invalid") + assert %{"errors" => [_]} = json_response(request, 422) end end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/utils_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/utils_controller_test.exs index d9c23fdc813b..7315a6b3dfd9 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/utils_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/utils_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.UtilsControllerTest do use BlockScoutWeb.ConnCase @@ -9,7 +10,8 @@ defmodule BlockScoutWeb.API.V2.UtilsControllerTest do :transaction_to_verified_contract |> insert() - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() assert conn |> get("/api/v2/utils/decode-calldata", %{ @@ -25,7 +27,8 @@ defmodule BlockScoutWeb.API.V2.UtilsControllerTest do } } - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() assert conn |> post("/api/v2/utils/decode-calldata", %{ diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs index a0915ce45e14..38bc05fbc202 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/validator_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do use BlockScoutWeb.ConnCase use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -57,14 +58,15 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do end defp compare_item({%ValidatorStability{} = validator, count}, json) do - assert json["blocks_validated_count"] == count + 1 assert compare_item(validator, json) + assert json["blocks_validated_count"] == count end describe "/validators/stability" do test "get paginated list of the validators", %{conn: conn} do validators = - insert_list(51, :validator_stability) + 51 + |> insert_list(:validator_stability) |> Enum.sort_by( fn validator -> {Keyword.fetch!(ValidatorStability.state_enum(), validator.state), validator.address_hash.bytes} @@ -84,13 +86,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do test "sort by blocks_validated asc", %{conn: conn} do validators = for _ <- 0..50 do - validator = insert(:validator_stability) blocks_count = Enum.random(0..50) - - _ = - for _ <- 0..blocks_count do - insert(:block, miner_hash: validator.address_hash, miner: nil) - end + validator = insert(:validator_stability, blocks_validated: blocks_count) {validator, blocks_count} end @@ -111,13 +108,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do test "sort by blocks_validated desc", %{conn: conn} do validators = for _ <- 0..50 do - validator = insert(:validator_stability) blocks_count = Enum.random(0..50) - - _ = - for _ <- 0..blocks_count do - insert(:block, miner_hash: validator.address_hash, miner: nil) - end + validator = insert(:validator_stability, blocks_validated: blocks_count) {validator, blocks_count} end @@ -188,10 +180,10 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do request = get(conn, "/api/v2/validators/stability/counters") assert %{ - "active_validators_counter" => "3", + "active_validators_count" => "3", "active_validators_percentage" => ^percentage, - "new_validators_counter_24h" => "6", - "validators_counter" => "9" + "new_validators_count_24h" => "6", + "validators_count" => "9" } = json_response(request, 200) end end @@ -199,29 +191,39 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do if @chain_type == :zilliqa do alias Explorer.Chain.Zilliqa.Staker - alias Explorer.Chain.Zilliqa.Hash.BLSPublicKey - alias Explorer.Chain.Cache.BlockNumber @page_limit 50 - # A helper to verify the JSON structure for a single validator. + # A helper to verify the JSON structure for a validator. # Adjust the expectations based on what your prepare functions return. - defp check_validator_json(%Staker{} = validator, json) do - assert json["peer_id"] == validator.peer_id - assert json["added_at_block_number"] == validator.added_at_block_number - assert json["stake_updated_at_block_number"] == validator.stake_updated_at_block_number - assert json["control_address"]["hash"] |> String.downcase() == validator.control_address_hash |> to_string() - assert json["reward_address"]["hash"] |> String.downcase() == validator.reward_address_hash |> to_string() - assert json["signing_address"]["hash"] |> String.downcase() == validator.signing_address_hash |> to_string() + defp check_validator_json(%Staker{} = validator, json, detailed? \\ true) do + assert json["balance"] == validator.balance |> to_string() + assert json["bls_public_key"] |> String.downcase() == validator.bls_public_key |> String.downcase() + assert json["index"] == validator.index + + if detailed? do + assert json["peer_id"] == validator.peer_id + assert json["added_at_block_number"] == validator.added_at_block_number + assert json["stake_updated_at_block_number"] == validator.stake_updated_at_block_number + assert json["control_address"]["hash"] |> String.downcase() == validator.control_address_hash |> to_string() + assert json["reward_address"]["hash"] |> String.downcase() == validator.reward_address_hash |> to_string() + assert json["signing_address"]["hash"] |> String.downcase() == validator.signing_address_hash |> to_string() + end + end + + defp check_paginated_response(first_page_resp, second_page_resp, items) do + assert first_page_resp["next_page_params"] != nil + check_validator_json(Enum.at(items, 0), Enum.at(first_page_resp["items"], 0), false) + check_validator_json(Enum.at(items, @page_limit - 1), Enum.at(first_page_resp["items"], @page_limit - 1), false) + check_validator_json(Enum.at(items, @page_limit), Enum.at(second_page_resp["items"], 0), false) end describe "GET /api/v2/validators/zilliqa" do test "returns a paginated list of validators", %{conn: conn} do total_validators = @page_limit + 1 + # Insert enough validators to force pagination. - for _ <- 1..total_validators do - insert(:zilliqa_staker) - end + validators = insert_list(total_validators, :zilliqa_staker) # First page request. request = get(conn, "/api/v2/validators/zilliqa") @@ -231,7 +233,7 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do assert is_list(first_page["items"]) assert Map.has_key?(first_page, "next_page_params") - # # Check that the first page contains the page limit number of items. + # Check that the first page contains the page limit number of items. assert length(first_page["items"]) == @page_limit # Second page request using next_page_params. @@ -242,6 +244,8 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do # and no further page. assert length(second_page["items"]) == total_validators - @page_limit assert second_page["next_page_params"] == nil + + check_paginated_response(first_page, second_page, validators) end end @@ -254,7 +258,7 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do index = staker.index balance = to_string(staker.balance) - request = get(conn, "/api/v2/validators/zilliqa", %{"filter" => "active"}) + request = get(conn, "/api/v2/validators/zilliqa") assert %{ "items" => [ @@ -284,6 +288,16 @@ defmodule BlockScoutWeb.API.V2.ValidatorControllerTest do test "returns an error for an invalid BLS public key", %{conn: conn} do invalid_bls_key = "invalid_key" + conn = get(conn, "/api/v2/validators/zilliqa/#{invalid_bls_key}") + response = json_response(conn, 422) + + # The controller returns a 422 with a JSON message for an invalid BLS public key format. + assert String.contains?(inspect(response), "Invalid format") + end + + test "returns an error for an invalid 0x-prefixed BLS public key", %{conn: conn} do + invalid_bls_key = "0x00" + conn = get(conn, "/api/v2/validators/zilliqa/#{invalid_bls_key}") response = json_response(conn, 400) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/verification_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/verification_controller_test.exs index 2a83fcfbc87e..a1580c0376a3 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/verification_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/verification_controller_test.exs @@ -1,21 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.VerificationControllerTest do use BlockScoutWeb.ConnCase use BlockScoutWeb.ChannelCase, async: false + # Provide JSON-RPC named arguments and Mox helpers for Ethereum JSON-RPC + use EthereumJSONRPC.Case, async: false alias BlockScoutWeb.V2.UserSocket alias Explorer.Chain.Address alias Explorer.TestHelper alias Tesla.Multipart alias Plug.Conn + import Mox + alias Indexer.Fetcher.OnDemand.ContractCode, as: ContractCodeOnDemand + alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand @moduletag timeout: :infinity setup do configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) end @@ -35,6 +43,82 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do end if Application.compile_env(:explorer, :chain_type) !== :zksync do + describe "bytecode lookup on verification requests" do + # Set up Mox and start on-demand bytecode fetcher with mocked transport + setup :set_mox_global + setup :verify_on_exit! + + setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) + + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + start_supervised!({ContractCodeOnDemand, [mocked_json_rpc_named_arguments, [name: ContractCodeOnDemand]]}) + start_supervised!({ContractCreatorOnDemand, name: ContractCreatorOnDemand}) + + %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} + end + + test "proceeds when bytecode is fetched from RPC", %{conn: conn} do + address = insert(:address) + string_address_hash = to_string(address.hash) + + contract_code = "0x6080" + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: id, + jsonrpc: "2.0", + method: "eth_getCode", + params: [^string_address_hash, "latest"] + } + ], + _ -> + {:ok, [%{id: id, result: contract_code}]} + end) + + params = %{"compiler_version" => "", "source_code" => ""} + + request = + post( + conn, + "/api/v2/smart-contracts/#{address.hash}/verification/via/flattened-code", + params + ) + + assert %{"message" => "Smart-contract verification started"} = json_response(request, 200) + end + + test "returns 404 when RPC returns empty code (EOA or not a contract)", %{conn: conn} do + address = insert(:address) + string_address_hash = to_string(address.hash) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: id, + jsonrpc: "2.0", + method: "eth_getCode", + params: [^string_address_hash, "latest"] + } + ], + _ -> + {:ok, [%{id: id, result: "0x"}]} + end) + + params = %{"compiler_version" => "", "source_code" => ""} + + request = + post( + conn, + "/api/v2/smart-contracts/#{address.hash}/verification/via/flattened-code", + params + ) + + assert %{"message" => "Address is not a smart-contract"} = json_response(request, 404) + end + end + describe "/api/v2/smart-contracts/{address_hash}/verification/via/flattened-code" do test "get 200 for verified contract", %{conn: conn} do contract = insert(:smart_contract) @@ -48,7 +132,7 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do test "success verification", %{conn: conn} do before = Application.get_env(:explorer, :solc_bin_api_url) - Application.put_env(:explorer, :solc_bin_api_url, "https://solc-bin.ethereum.org") + Application.put_env(:explorer, :solc_bin_api_url, "https://binaries.soliditylang.org") path = File.cwd!() <> "/../explorer/test/support/fixture/smart_contract/solidity_0.5.9_smart_contract.sol" contract = File.read!(path) @@ -103,7 +187,7 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do test "get error on empty contract name", %{conn: conn} do before = Application.get_env(:explorer, :solc_bin_api_url) - Application.put_env(:explorer, :solc_bin_api_url, "https://solc-bin.ethereum.org") + Application.put_env(:explorer, :solc_bin_api_url, "https://binaries.soliditylang.org") contract_address = insert(:contract_address, contract_code: "0x01") @@ -144,64 +228,64 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do end end - describe "/api/v2/smart-contracts/{address_hash}/verification/via/sourcify" do - test "get 200 for verified contract", %{conn: conn} do - contract = insert(:smart_contract) - - params = %{"files" => ""} - request = post(conn, "/api/v2/smart-contracts/#{contract.address_hash}/verification/via/sourcify", params) - - assert %{"message" => "Already verified"} = json_response(request, 200) - end - - test "verify contract from sourcify repo", %{conn: conn} do - address = "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1" - - _contract = insert(:address, hash: address, contract_code: "0x01") - - topic = "addresses:#{String.downcase(address)}" - - {:ok, _reply, _socket} = - UserSocket - |> socket("no_id", %{}) - |> subscribe_and_join(topic) - - multipart = - Multipart.new() - |> Multipart.add_file_content("content", "name.json", - name: "files[0]", - headers: [{"content-type", "application/json"}] - ) - - body = - multipart - |> Multipart.body() - |> Enum.to_list() - |> to_str() - - [{name, value}] = Multipart.headers(multipart) - - request = - post( - conn - |> Plug.Conn.put_req_header( - name, - value - ), - "/api/v2/smart-contracts/#{address}/verification/via/sourcify", - body - ) - - assert %{"message" => "Smart-contract verification started"} = json_response(request, 200) - - assert_receive %Phoenix.Socket.Message{ - payload: %{status: "success"}, - event: "verification_result", - topic: ^topic - }, - :timer.seconds(120) - end - end + # describe "/api/v2/smart-contracts/{address_hash}/verification/via/sourcify" do + # test "get 200 for verified contract", %{conn: conn} do + # contract = insert(:smart_contract) + # + # params = %{"files" => ""} + # request = post(conn, "/api/v2/smart-contracts/#{contract.address_hash}/verification/via/sourcify", params) + # + # assert %{"message" => "Already verified"} = json_response(request, 200) + # end + # + # test "verify contract from sourcify repo", %{conn: conn} do + # address = "0xf26594F585De4EB0Ae9De865d9053FEe02ac6eF1" + # + # _contract = insert(:address, hash: address, contract_code: "0x01") + # + # topic = "addresses:#{String.downcase(address)}" + # + # {:ok, _reply, _socket} = + # UserSocket + # |> socket("no_id", %{}) + # |> subscribe_and_join(topic) + # + # multipart = + # Multipart.new() + # |> Multipart.add_file_content("content", "name.json", + # name: "files[0]", + # headers: [{"content-type", "application/json"}] + # ) + # + # body = + # multipart + # |> Multipart.body() + # |> Enum.to_list() + # |> to_str() + # + # [{name, value}] = Multipart.headers(multipart) + # + # request = + # post( + # conn + # |> Plug.Conn.put_req_header( + # name, + # value + # ), + # "/api/v2/smart-contracts/#{address}/verification/via/sourcify", + # body + # ) + # + # assert %{"message" => "Smart-contract verification started"} = json_response(request, 200) + # + # assert_receive %Phoenix.Socket.Message{ + # payload: %{status: "success"}, + # event: "verification_result", + # topic: ^topic + # }, + # :timer.seconds(120) + # end + # end describe "/api/v2/smart-contracts/{address_hash}/verification/via/multi-part" do test "get 404", %{conn: conn} do @@ -227,7 +311,7 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do test "success verification", %{conn: conn} do before = Application.get_env(:explorer, :solc_bin_api_url) - Application.put_env(:explorer, :solc_bin_api_url, "https://solc-bin.ethereum.org") + Application.put_env(:explorer, :solc_bin_api_url, "https://binaries.soliditylang.org") path = File.cwd!() <> "/../explorer/test/support/fixture/smart_contract/vyper.vy" contract = File.read!(path) @@ -291,6 +375,8 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do eth_bytecode_db?: true ) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + Bypass.expect_once(bypass, "POST", "/api/v2//verifier/vyper/sources%3Averify-multi-part", fn conn -> Conn.resp(conn, 200, sc_verifier_response) end) @@ -336,7 +422,8 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do :timer.seconds(300) # Assert that the `is_blueprint=true` is stored in the database after verification - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() request = get(conn, "/api/v2/smart-contracts/#{Address.checksum(contract_address.hash)}") response = json_response(request, 200) @@ -344,6 +431,7 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do assert response["is_blueprint"] == true Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, old_env) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) Bypass.down(bypass) end end @@ -375,7 +463,7 @@ defmodule BlockScoutWeb.API.V2.VerificationControllerTest do test "success verification", %{conn: conn} do before = Application.get_env(:explorer, :solc_bin_api_url) - Application.put_env(:explorer, :solc_bin_api_url, "https://solc-bin.ethereum.org") + Application.put_env(:explorer, :solc_bin_api_url, "https://binaries.soliditylang.org") path = File.cwd!() <> "/../explorer/test/support/fixture/smart_contract/standard_input.json" json = File.read!(path) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs index 88320ec50b5b..6d6f2fc843f1 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/withdrawal_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.WithdrawalControllerTest do use BlockScoutWeb.ConnCase @@ -43,8 +44,8 @@ defmodule BlockScoutWeb.API.V2.WithdrawalControllerTest do request = get(conn, "/api/v2/withdrawals/counters") assert %{ - "withdrawal_count" => _, - "withdrawal_sum" => _ + "withdrawals_count" => _, + "withdrawals_sum" => _ } = json_response(request, 200) end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api_docs_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api_docs_controller_test.exs index 3b2cb99ff032..a90f8c940ccb 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api_docs_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api_docs_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.APIDocsControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs index 0ebc98bfc98a..5dc44aafd5e6 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockControllerTest do use BlockScoutWeb.ConnCase alias Explorer.Chain.Block @@ -98,7 +99,7 @@ defmodule BlockScoutWeb.BlockControllerTest do conn = get(conn, blocks_path(conn, :index), %{"type" => "JSON"}) - expected_path = blocks_path(conn, :index, block_number: number, block_type: "Block", items_count: "50") + expected_path = blocks_path(conn, :index, block_number: number, items_count: "50", block_type: "Block") assert Map.get(json_response(conn, 200), "next_page_path") == expected_path end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs index 494dc0d90923..08c30b068dae 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_transaction_controller_test.exs @@ -1,8 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockTransactionControllerTest do use BlockScoutWeb.ConnCase import BlockScoutWeb.Routers.WebRouter.Helpers, only: [block_transaction_path: 3] + alias BlockScoutWeb.TestHelper + describe "GET index/2" do test "with invalid block number", %{conn: conn} do conn = get(conn, block_transaction_path(conn, :index, "unknown")) @@ -57,7 +60,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do conn = get(conn, block_transaction_path(conn, :index, block.number)) - assert_block_above_tip(conn) + TestHelper.assert_block_above_tip(conn) end test "non-consensus block number above consensus block number is treated as consensus number above tip", %{ @@ -71,7 +74,7 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do conn = get(conn, block_transaction_path(conn, :index, block.number)) - assert_block_above_tip(conn) + TestHelper.assert_block_above_tip(conn) end test "returns transactions for consensus block hash", %{conn: conn} do @@ -193,12 +196,4 @@ defmodule BlockScoutWeb.BlockTransactionControllerTest do assert html_response(conn, 200) =~ miner_name end end - - defp assert_block_above_tip(conn) do - assert conn - |> html_response(404) - |> Floki.find(~S|.error-descr|) - |> Floki.text() - |> String.trim() == "Easy Cowboy! This block does not exist yet!" - end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs index 831d088286ef..63b6f87553f2 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/block_withdrawal_controller_test.exs @@ -1,8 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockWithdrawalControllerTest do use BlockScoutWeb.ConnCase import BlockScoutWeb.Routers.WebRouter.Helpers, only: [block_withdrawal_path: 3] + alias BlockScoutWeb.TestHelper + describe "GET index/2" do test "with invalid block number", %{conn: conn} do conn = get(conn, block_withdrawal_path(conn, :index, "unknown")) @@ -23,7 +26,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do conn = get(conn, block_withdrawal_path(conn, :index, block.number + 1)) - assert_block_above_tip(conn) + TestHelper.assert_block_above_tip(conn) end test "returns withdrawals for the block", %{conn: conn} do @@ -50,7 +53,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do conn = get(conn, block_withdrawal_path(conn, :index, block.number)) - assert_block_above_tip(conn) + TestHelper.assert_block_above_tip(conn) end test "non-consensus block number above consensus block number is treated as consensus number above tip", %{ @@ -64,7 +67,7 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do conn = get(conn, block_withdrawal_path(conn, :index, block.number)) - assert_block_above_tip(conn) + TestHelper.assert_block_above_tip(conn) end test "does not return transactions for invalid block hash", %{conn: conn} do @@ -128,12 +131,4 @@ defmodule BlockScoutWeb.BlockWithdrawalControllerTest do assert html_response(conn, 200) =~ miner_name end end - - defp assert_block_above_tip(conn) do - assert conn - |> html_response(404) - |> Floki.find(~S|.error-descr|) - |> Floki.text() - |> String.trim() == "Easy Cowboy! This block does not exist yet!" - end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/chain/market_history_chart_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/chain/market_history_chart_controller_test.exs index e2958800fe0c..648ec2cff105 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/chain/market_history_chart_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/chain/market_history_chart_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Chain.MarketHistoryChartControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs index 7de397620ff7..0d608cd070bb 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/chain/transaction_history_chart_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Chain.TransactionHistoryChartControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/chain_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/chain_controller_test.exs index 5cf77df9f45b..32b0fe0c6b08 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/chain_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/chain_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ChainControllerTest do use BlockScoutWeb.ConnCase, # ETS table is shared in `Explorer.Chain.Cache.Counters.AddressesCount` diff --git a/apps/block_scout_web/test/block_scout_web/controllers/metrics_contoller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/metrics_contoller_test.exs index e9ea7e7e55b4..b4083daa0637 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/metrics_contoller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/metrics_contoller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.MetricsControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/page_not_found_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/page_not_found_controller_test.exs index 85403586eeeb..427683e363ca 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/page_not_found_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/page_not_found_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.PageNotFoundControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs index c8e57ddb6453..3dcfdd48de6b 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/pending_transaction_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.PendingTransactionControllerTest do use BlockScoutWeb.ConnCase alias Explorer.Chain.{Hash, Transaction} diff --git a/apps/block_scout_web/test/block_scout_web/controllers/recent_transactions_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/recent_transactions_controller_test.exs index e0d2e56f5bb5..bb02d701c494 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/recent_transactions_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/recent_transactions_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.RecentTransactionsControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/smart_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/smart_contract_controller_test.exs index 12bc66f2d54f..6f30ae0358a2 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/smart_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/smart_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SmartContractControllerTest do use BlockScoutWeb.ConnCase @@ -86,7 +87,8 @@ defmodule BlockScoutWeb.SmartContractControllerTest do contract_code_md5: "123" ) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() path = smart_contract_path(BlockScoutWeb.Endpoint, :index, @@ -106,6 +108,7 @@ defmodule BlockScoutWeb.SmartContractControllerTest do test "lists [] proxy read only functions if no verified eip-1967 implementation" do token_contract_address = insert(:contract_address) + implementation_address = insert(:address) insert(:smart_contract, address_hash: token_contract_address.hash, @@ -123,7 +126,8 @@ defmodule BlockScoutWeb.SmartContractControllerTest do contract_code_md5: "123" ) - blockchain_get_implementation_mock() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) path = smart_contract_path(BlockScoutWeb.Endpoint, :index, @@ -141,30 +145,43 @@ defmodule BlockScoutWeb.SmartContractControllerTest do assert conn.assigns.read_only_functions == [] end - test "lists [] proxy read only functions if no verified eip-1967 implementation and eth_getStorageAt returns not normalized address hash" do - token_contract_address = insert(:contract_address) + test "uses first implementation from address_hashes for proxy contract" do + proxy_address = insert(:contract_address) + implementation_address = insert(:contract_address) insert(:smart_contract, - address_hash: token_contract_address.hash, + address_hash: proxy_address.hash, + contract_code_md5: "123" + ) + + insert(:smart_contract, + address_hash: implementation_address.hash, abi: [ %{ "type" => "function", - "stateMutability" => "nonpayable", + "stateMutability" => "view", "payable" => false, - "outputs" => [%{"type" => "address", "name" => "", "internalType" => "address"}], - "name" => "implementation", + "outputs" => [%{"type" => "uint256", "name" => ""}], + "name" => "get", "inputs" => [], - "constant" => false + "constant" => true } ], - contract_code_md5: "123" + contract_code_md5: "456" + ) + + insert(:proxy_implementation, + proxy_address_hash: proxy_address.hash, + proxy_type: "eip1967", + address_hashes: [implementation_address.hash], + names: ["implementation"] ) - blockchain_get_implementation_mock_2() + blockchain_get_function_mock() path = smart_contract_path(BlockScoutWeb.Endpoint, :index, - hash: token_contract_address.hash, + hash: proxy_address.hash, type: :proxy, action: :read ) @@ -175,7 +192,8 @@ defmodule BlockScoutWeb.SmartContractControllerTest do |> get(path) assert conn.status == 200 - assert conn.assigns.read_only_functions == [] + assert conn.assigns.implementation_address == implementation_address.hash + refute conn.assigns.read_only_functions == [] end end @@ -276,17 +294,4 @@ defmodule BlockScoutWeb.SmartContractControllerTest do end ) end - - defp blockchain_get_implementation_mock do - EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, "0xcebb2CCCFe291F0c442841cBE9C1D06EED61Ca02") - end - - defp blockchain_get_implementation_mock_2 do - EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request( - false, - "0x000000000000000000000000cebb2CCCFe291F0c442841cBE9C1D06EED61Ca02" - ) - end end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs index 45c23d6f09c7..d5352d3c756a 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/holder_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.HolderControllerTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance/transfer_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance/transfer_controller_test.exs index d2a3f0e29f94..bc4e66732477 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance/transfer_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance/transfer_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.TransferControllerTest do use BlockScoutWeb.ConnCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance_controller_test.exs index 65acc5bb9e2f..b0d8955bfc78 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/instance_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.InstanceControllerTest do use BlockScoutWeb.ConnCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs index e15f85569214..275a49aa2f31 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/inventory_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.InventoryControllerTest do use BlockScoutWeb.ConnCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/read_contract_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/read_contract_controller_test.exs index fbebeaee0478..7a25385c4e58 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/read_contract_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/read_contract_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.ContractControllerTest do use BlockScoutWeb.ConnCase, async: false @@ -57,7 +58,8 @@ defmodule BlockScoutWeb.Tokens.ContractControllerTest do token: token ) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() conn = get(conn, token_read_contract_path(BlockScoutWeb.Endpoint, :index, token.contract_address_hash)) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs index ab5d93fb6378..dd309d7f77ed 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/tokens/token_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.TokenControllerTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_controller_test.exs deleted file mode 100644 index 99d09fcb44a8..000000000000 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_controller_test.exs +++ /dev/null @@ -1,137 +0,0 @@ -defmodule BlockScoutWeb.TransactionControllerTest do - use BlockScoutWeb.ConnCase - - import BlockScoutWeb.Routers.WebRouter.Helpers, - only: [transaction_path: 3] - - alias Explorer.Chain.Transaction - - describe "GET index/2" do - test "returns a collated transactions", %{conn: conn} do - :transaction - |> insert() - |> with_block() - - conn = get(conn, transaction_path(conn, :index, %{"type" => "JSON"})) - - transactions_html = - conn - |> json_response(200) - |> Map.get("items") - - assert length(transactions_html) == 1 - end - - test "returns a count of transactions", %{conn: conn} do - :transaction - |> insert() - |> with_block() - - conn = get(conn, "/txs") - - assert is_integer(conn.assigns.transaction_estimated_count) - end - - test "excludes pending transactions", %{conn: conn} do - %Transaction{hash: transaction_hash} = - :transaction - |> insert() - |> with_block() - - %Transaction{hash: pending_transaction_hash} = insert(:transaction) - - conn = get(conn, transaction_path(conn, :index, %{"type" => "JSON"})) - - transactions_html = - conn - |> json_response(200) - |> Map.get("items") - - assert Enum.any?(transactions_html, &String.contains?(&1, to_string(transaction_hash))) - refute Enum.any?(transactions_html, &String.contains?(&1, to_string(pending_transaction_hash))) - end - - test "returns next page of results based on last seen transaction", %{conn: conn} do - second_page_hashes = - 50 - |> insert_list(:transaction) - |> with_block() - |> Enum.map(&to_string(&1.hash)) - - %Transaction{block_number: block_number, index: index} = - :transaction - |> insert() - |> with_block() - - conn = - get( - conn, - transaction_path(conn, :index, %{ - "type" => "JSON", - "block_number" => Integer.to_string(block_number), - "index" => Integer.to_string(index) - }) - ) - - transactions_html = - conn - |> json_response(200) - |> Map.get("items") - - assert length(second_page_hashes) == length(transactions_html) - end - - test "next_page_params exist if not on last page", %{conn: conn} do - address = insert(:address) - block = insert(:block) - - 60 - |> insert_list(:transaction, from_address: address) - |> with_block(block) - - conn = get(conn, transaction_path(conn, :index, %{"type" => "JSON"})) - - assert conn |> json_response(200) |> Map.get("next_page_params") - end - - test "next_page_params are empty if on last page", %{conn: conn} do - address = insert(:address) - - :transaction - |> insert(from_address: address) - |> with_block() - - conn = get(conn, transaction_path(conn, :index, %{"type" => "JSON"})) - - refute conn |> json_response(200) |> Map.get("next_page_path") - end - - test "works when there are no transactions", %{conn: conn} do - conn = get(conn, "/txs") - - assert html_response(conn, 200) - end - end - - describe "GET show/3" do - test "responds with 404 with the transaction missing", %{conn: conn} do - hash = transaction_hash() - conn = get(conn, transaction_path(BlockScoutWeb.Endpoint, :show, hash)) - - assert html_response(conn, 404) - end - - test "responds with 422 when the hash is invalid", %{conn: conn} do - conn = get(conn, transaction_path(BlockScoutWeb.Endpoint, :show, "wrong")) - - assert html_response(conn, 422) - end - - test "no redirect from transaction page", %{conn: conn} do - transaction = insert(:transaction) - conn = get(conn, transaction_path(BlockScoutWeb.Endpoint, :show, transaction)) - - assert html_response(conn, 200) - end - end -end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs index 2d59ad7de257..3f9d78742a6d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_internal_transaction_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do use BlockScoutWeb.ConnCase @@ -44,18 +45,14 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do transaction: transaction, index: 0, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0 + transaction_index: transaction.index ) insert(:internal_transaction, transaction: transaction, index: 1, transaction_index: transaction.index, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) path = transaction_internal_transaction_path(BlockScoutWeb.Endpoint, :index, transaction.hash) @@ -96,10 +93,9 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do index: 0, block_number: transaction.block_number, transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0 + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address ) - |> with_contract_creation(contract_address) conn = get( @@ -107,7 +103,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do transaction_internal_transaction_path( BlockScoutWeb.Endpoint, :index, - internal_transaction.transaction_hash + internal_transaction.transaction.hash ) ) @@ -125,9 +121,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do transaction: transaction, index: 0, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 0 + transaction_index: transaction.index ) second_page_indexes = @@ -137,9 +131,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do transaction: transaction, index: index, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index ) end) |> Enum.map(& &1.index) @@ -172,9 +164,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do transaction: transaction, index: index, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index ) end) @@ -203,9 +193,7 @@ defmodule BlockScoutWeb.TransactionInternalTransactionControllerTest do transaction: transaction, index: index, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: index + transaction_index: transaction.index ) end) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs index da7d6324c24e..6ab0a63e3ab6 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_log_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionLogControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs index 3e91da15a317..844f9afd98fd 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_state_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionStateControllerTest do use BlockScoutWeb.ConnCase @@ -9,7 +10,6 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do alias Explorer.Chain.Wei alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup alias Explorer.Chain.Cache.Counters.{AddressesCount, AverageBlockTime} - alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand setup :set_mox_global @@ -251,7 +251,7 @@ defmodule BlockScoutWeb.TransactionStateControllerTest do full_text = Enum.join(items) assert(length(items) == 3) - assert(String.contains?(full_text, format_wei_value(%Wei{value: Decimal.new(0)}, :ether))) + assert(String.contains?(full_text, format_wei_value(Wei.zero(), :ether))) 1 |> :timer.seconds() |> :timer.sleep() conn = get(conn, transaction_state_path(conn, :index, transaction), %{type: "JSON"}) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs index fcff1a3c260a..0a4d75d2f370 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/transaction_token_transfer_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionTokenTransferControllerTest do use BlockScoutWeb.ConnCase @@ -159,7 +160,8 @@ defmodule BlockScoutWeb.TransactionTokenTransferControllerTest do end test "preloads to_address smart contract verified", %{conn: conn} do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() transaction = insert(:transaction_to_verified_contract) diff --git a/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs index e0ca5bd1292b..5c1ecdc1c0b4 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/verified_contracts_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.VerifiedContractsControllerTest do use BlockScoutWeb.ConnCase @@ -55,21 +56,18 @@ defmodule BlockScoutWeb.VerifiedContractsControllerTest do end test "next_page_path exist if not on last page", %{conn: conn} do - %SmartContract{address_hash: address_hash} = + %SmartContract{id: id} = 60 |> insert_list(:smart_contract) - |> Enum.reverse() + |> Enum.sort_by(& &1.id, :asc) |> Enum.fetch!(10) conn = get(conn, verified_contracts_path(conn, :index), %{"type" => "JSON"}) expected_path = verified_contracts_path(conn, :index, - coin_balance: nil, - hash: address_hash, - items_count: "50", - transaction_count: nil, - transactions_count: nil + id: id, + items_count: "50" ) assert Map.get(json_response(conn, 200), "next_page_path") == expected_path @@ -84,10 +82,10 @@ defmodule BlockScoutWeb.VerifiedContractsControllerTest do end test "returns solidity contracts", %{conn: conn} do - insert(:smart_contract, is_vyper_contract: true, language: nil) + insert(:smart_contract, language: :vyper) %SmartContract{address_hash: solidity_hash} = - insert(:smart_contract, is_vyper_contract: false, language: nil) + insert(:smart_contract, language: :solidity) path = verified_contracts_path(conn, :index, %{ @@ -104,9 +102,9 @@ defmodule BlockScoutWeb.VerifiedContractsControllerTest do test "returns vyper contract", %{conn: conn} do %SmartContract{address_hash: vyper_hash} = - insert(:smart_contract, is_vyper_contract: true, language: nil) + insert(:smart_contract, language: :vyper) - insert(:smart_contract, is_vyper_contract: false, language: nil) + insert(:smart_contract, language: :solidity) path = verified_contracts_path(conn, :index, %{ @@ -123,9 +121,9 @@ defmodule BlockScoutWeb.VerifiedContractsControllerTest do test "returns yul contract", %{conn: conn} do %SmartContract{address_hash: yul_hash} = - insert(:smart_contract, abi: nil, language: nil) + insert(:smart_contract, abi: nil, language: :yul) - insert(:smart_contract, language: nil) + insert(:smart_contract) path = verified_contracts_path(conn, :index, %{ diff --git a/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs index c152318a1cc5..47dd685ab6c8 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/withdrawal_controller_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.WithdrawalControllerTest do use BlockScoutWeb.ConnCase diff --git a/apps/explorer/test/explorer/chain/csv_export/address/internal_transactions_test.exs b/apps/block_scout_web/test/block_scout_web/csv_export/address/internal_transactions_test.exs similarity index 83% rename from apps/explorer/test/explorer/chain/csv_export/address/internal_transactions_test.exs rename to apps/block_scout_web/test/block_scout_web/csv_export/address/internal_transactions_test.exs index 93cb100fcb15..788a381b691b 100644 --- a/apps/explorer/test/explorer/chain/csv_export/address/internal_transactions_test.exs +++ b/apps/block_scout_web/test/block_scout_web/csv_export/address/internal_transactions_test.exs @@ -1,8 +1,10 @@ -defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.CsvExport.Address.InternalTransactionsTest do use Explorer.DataCase - alias Explorer.Chain.CsvExport.Address.InternalTransactions, as: AddressInternalTransactionsCsvExporter - alias Explorer.Chain.{Address, Wei} + alias BlockScoutWeb.CsvExport.Address.InternalTransactions, as: AddressInternalTransactionsCsvExporter + alias Explorer.Chain.{Address, InternalTransaction, Wei} + alias Explorer.Utility.AddressIdToAddressHash describe "export/3" do test "exports address internal transactions to csv" do @@ -19,8 +21,6 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do transaction: transaction, from_address: address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index ) @@ -31,7 +31,7 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do res = address.hash - |> AddressInternalTransactionsCsvExporter.export(from_period, to_period, []) + |> AddressInternalTransactionsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) @@ -44,10 +44,6 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do _, [[], block_number], _, - [[], block_hash], - _, - [[], block_index], - _, [[], transaction_index], _, [[], timestamp], @@ -81,8 +77,6 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do transaction_hash: transaction_hash, index: index, block_number: block_number, - block_index: block_index, - block_hash: block_hash, transaction_index: transaction_index, timestamp: timestamp, from_address_hash: from_address_hash, @@ -100,16 +94,21 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do } end) - assert result.transaction_hash == to_string(internal_transaction.transaction_hash) + assert result.transaction_hash == to_string(internal_transaction.transaction.hash) assert result.index == to_string(internal_transaction.index) assert result.block_number == to_string(internal_transaction.block_number) - assert result.block_index == to_string(internal_transaction.block_index) - assert result.block_hash == to_string(internal_transaction.block_hash) assert result.transaction_index == to_string(internal_transaction.transaction_index) assert result.timestamp - assert result.from_address_hash == Address.checksum(internal_transaction.from_address_hash) - assert result.to_address_hash == Address.checksum(internal_transaction.to_address_hash) - assert result.created_contract_address_hash == to_string(internal_transaction.created_contract_address_hash) + + assert result.from_address_hash == + Address.checksum(AddressIdToAddressHash.id_to_hash(internal_transaction.from_address_id)) + + assert result.to_address_hash == + Address.checksum(AddressIdToAddressHash.id_to_hash(internal_transaction.to_address_id)) + + assert result.created_contract_address_hash == + to_string(AddressIdToAddressHash.id_to_hash(internal_transaction.created_contract_address_hash)) + assert result.type == to_string(internal_transaction.type) assert result.call_type == to_string(internal_transaction.call_type) assert result.gas == to_string(internal_transaction.gas) @@ -142,12 +141,9 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do transaction: transaction, from_address: address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index ) end) - |> Enum.count() 1..200 |> Enum.map(fn index -> @@ -161,12 +157,9 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do transaction: transaction, to_address: address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index ) end) - |> Enum.count() 1..200 |> Enum.map(fn index -> @@ -175,17 +168,15 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do |> insert() |> with_block() - insert(:internal_transaction, + insert(:internal_transaction_create, index: index, transaction: transaction, created_contract_address: address, + to_address: nil, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index ) end) - |> Enum.count() {:ok, now} = DateTime.now("Etc/UTC") @@ -194,7 +185,7 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do result = address.hash - |> AddressInternalTransactionsCsvExporter.export(from_period, to_period, []) + |> AddressInternalTransactionsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) @@ -209,19 +200,21 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do |> insert() |> with_block() + transaction_error = insert(:transaction_error, message: "reverted") + internal_transaction = insert(:internal_transaction, index: 1, transaction: transaction, from_address: address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index, error: "reverted", + error_id: transaction_error.id, gas_used: nil, output: nil ) + |> InternalTransaction.preload_addresses() {:ok, now} = DateTime.now("Etc/UTC") @@ -230,7 +223,7 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do res = address.hash - |> AddressInternalTransactionsCsvExporter.export(from_period, to_period, []) + |> AddressInternalTransactionsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) @@ -243,10 +236,6 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do _, [[], block_number], _, - [[], block_hash], - _, - [[], block_index], - _, [[], transaction_index], _, [[], timestamp], @@ -280,8 +269,6 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do transaction_hash: transaction_hash, index: index, block_number: block_number, - block_index: block_index, - block_hash: block_hash, transaction_index: transaction_index, timestamp: timestamp, from_address_hash: from_address_hash, @@ -299,11 +286,9 @@ defmodule Explorer.Chain.CsvExport.Address.InternalTransactionsTest do } end) - assert result.transaction_hash == to_string(internal_transaction.transaction_hash) + assert result.transaction_hash == to_string(internal_transaction.transaction.hash) assert result.index == to_string(internal_transaction.index) assert result.block_number == to_string(internal_transaction.block_number) - assert result.block_index == to_string(internal_transaction.block_index) - assert result.block_hash == to_string(internal_transaction.block_hash) assert result.transaction_index == to_string(internal_transaction.transaction_index) assert result.timestamp assert result.from_address_hash == Address.checksum(internal_transaction.from_address_hash) diff --git a/apps/block_scout_web/test/block_scout_web/features/address_contract_verification_test.exs b/apps/block_scout_web/test/block_scout_web/features/address_contract_verification_test.exs index bfad4cf065f8..4ebc182c60fa 100644 --- a/apps/block_scout_web/test/block_scout_web/features/address_contract_verification_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/address_contract_verification_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractVerificationTest do use BlockScoutWeb.FeatureCase, async: false @@ -13,8 +14,11 @@ defmodule BlockScoutWeb.AddressContractVerificationTest do Application.put_env(:explorer, :solc_bin_api_url, "http://localhost:#{bypass.port}") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + on_exit(fn -> Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) {:ok, bypass: bypass} @@ -35,7 +39,9 @@ defmodule BlockScoutWeb.AddressContractVerificationTest do created_contract_address: address, created_contract_code: bytecode, index: 0, - transaction: transaction + transaction: transaction, + transaction_index: transaction.index, + block_number: transaction.block_number ) session diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/address_contract_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/address_contract_page.ex index 2930e3525484..90d7b06f3b80 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/address_contract_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/address_contract_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/address_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/address_page.ex index 99b66dde1f98..628407fc1ea3 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/address_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/address_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressPage do @moduledoc false @@ -83,10 +84,10 @@ defmodule BlockScoutWeb.AddressPage do css("[data-test='address_detail_hash']", text: to_string(address)) end - def internal_transaction(%InternalTransaction{transaction_hash: transaction_hash, index: index}) do + def internal_transaction(%InternalTransaction{index: index} = internal_transaction) do css( "[data-test='internal_transaction']" <> - "[data-internal-transaction-transaction-hash='#{transaction_hash}']" <> + "[data-internal-transaction-transaction-hash='#{internal_transaction.transaction.hash}']" <> "[data-internal-transaction-index='#{index}']" ) end @@ -96,23 +97,23 @@ defmodule BlockScoutWeb.AddressPage do end def internal_transaction_address_link( - %InternalTransaction{transaction_hash: transaction_hash, index: index, from_address_hash: address_hash}, + %InternalTransaction{index: index, from_address_hash: address_hash} = internal_transaction, :from ) do checksum = Address.checksum(address_hash) css( - "[data-internal-transaction-transaction-hash='#{transaction_hash}'][data-internal-transaction-index='#{index}']" <> + "[data-internal-transaction-transaction-hash='#{internal_transaction.transaction.hash}'][data-internal-transaction-index='#{index}']" <> " [data-test='address_hash_link']" <> " [data-address-hash='#{checksum}']" ) end def internal_transaction_address_link( - %InternalTransaction{transaction_hash: transaction_hash, index: index, to_address_hash: address_hash}, + %InternalTransaction{index: index, to_address_hash: address_hash} = internal_transaction, :to ) do css( - "[data-internal-transaction-transaction-hash='#{transaction_hash}'][data-internal-transaction-index='#{index}']" <> + "[data-internal-transaction-transaction-hash='#{internal_transaction.transaction.hash}'][data-internal-transaction-index='#{index}']" <> " [data-test='address_hash_link']" <> " [data-address-hash='#{address_hash}']" ) end diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/app_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/app_page.ex index 98b934a16968..2141188a0abc 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/app_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/app_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AppPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/block_list_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/block_list_page.ex index f59cf4243ac7..e20067197c80 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/block_list_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/block_list_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockListPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/block_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/block_page.ex index 18289e016196..4ee5d5b1d854 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/block_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/block_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/chain_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/chain_page.ex index 7781210ba943..5f770bde84e9 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/chain_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/chain_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ChainPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/contract_verify_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/contract_verify_page.ex index f57a72304888..c703c5cd19e0 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/contract_verify_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/contract_verify_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ContractVerifyPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/token_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/token_page.ex index 23eb4186d71d..ff7fc1e56540 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/token_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/token_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TokenPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/transaction_list_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/transaction_list_page.ex index 9b3c34fa9204..2515d3a98292 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/transaction_list_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/transaction_list_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionListPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/transaction_logs_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/transaction_logs_page.ex index cd0dc88da8c7..700953cb40df 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/transaction_logs_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/transaction_logs_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionLogsPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/pages/transaction_page.ex b/apps/block_scout_web/test/block_scout_web/features/pages/transaction_page.ex index 19fe4997d6f8..92b2226b743e 100644 --- a/apps/block_scout_web/test/block_scout_web/features/pages/transaction_page.ex +++ b/apps/block_scout_web/test/block_scout_web/features/pages/transaction_page.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionPage do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_addresses_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_addresses_test.exs index 9dac716e910c..c7cfd7d806be 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_addresses_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_addresses_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ViewingAddressesTest do use BlockScoutWeb.FeatureCase, # Because ETS tables is shared for `Explorer.Counters.*` @@ -91,12 +92,12 @@ defmodule BlockScoutWeb.ViewingAddressesTest do internal_transaction = insert( :internal_transaction_create, + transaction_index: transaction.index, index: 1, transaction: transaction, from_address: address, created_contract_address: contract, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) address_hash = AddressView.trimmed_hash(address.hash) @@ -115,25 +116,25 @@ defmodule BlockScoutWeb.ViewingAddressesTest do insert( :internal_transaction, + transaction_index: transaction.index, index: 1, transaction: transaction, from_address: lincoln, to_address: contract, created_contract_address: contract, type: :call, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) internal_transaction = insert( :internal_transaction_create, + transaction_index: transaction.index, index: 2, transaction: transaction, from_address: contract, created_contract_address: another_contract, - block_hash: transaction.block_hash, - block_index: 2 + block_number: transaction.block_number ) contract_hash = AddressView.trimmed_hash(contract.hash) @@ -218,21 +219,17 @@ defmodule BlockScoutWeb.ViewingAddressesTest do insert(:internal_transaction, transaction: transaction, to_address: address, + transaction_index: transaction.index, index: 1, - block_number: 7000, - transaction_index: 1, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) insert(:internal_transaction, transaction: transaction, from_address: address, + transaction_index: transaction.index, index: 2, - block_number: 8000, - transaction_index: 2, - block_hash: transaction.block_hash, - block_index: 2 + block_number: transaction.block_number ) {:ok, %{internal_transaction_lincoln_to_address: internal_transaction_lincoln_to_address}} @@ -267,9 +264,7 @@ defmodule BlockScoutWeb.ViewingAddressesTest do index: 2, from_address: addresses.lincoln, block_number: transaction.block_number, - transaction_index: transaction.index, - block_hash: transaction.block_hash, - block_index: 2 + transaction_index: transaction.index ) Notifier.handle_event({:chain_event, :internal_transactions, :realtime, [internal_transaction]}) @@ -300,9 +295,7 @@ defmodule BlockScoutWeb.ViewingAddressesTest do index: 2, from_address: addresses.lincoln, block_number: from_lincoln.block_number, - transaction_index: from_lincoln.index, - block_hash: from_lincoln.block_hash, - block_index: 2 + transaction_index: from_lincoln.index ) session @@ -333,9 +326,7 @@ defmodule BlockScoutWeb.ViewingAddressesTest do index: 2, from_address: addresses.lincoln, block_number: from_lincoln.block_number, - transaction_index: from_lincoln.index, - block_hash: from_lincoln.block_hash, - block_index: 2 + transaction_index: from_lincoln.index ) session diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_app_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_app_test.exs index fabb2a59a66c..a14f27b69c3d 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_app_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_app_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ViewingAppTest do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_blocks_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_blocks_test.exs index 914ef68edb16..74fa8514c75e 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_blocks_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_blocks_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ViewingBlocksTest do use BlockScoutWeb.FeatureCase, async: false @@ -61,11 +62,12 @@ defmodule BlockScoutWeb.ViewingBlocksTest do :internal_transaction_create |> insert( transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 1 + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, + block_number: transaction.block_number ) - |> with_contract_creation(contract_address) session |> BlockPage.visit_page(block) diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_chain_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_chain_test.exs index 0b12f012b9e8..46d7e8eefc29 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_chain_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_chain_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ViewingChainTest do @moduledoc false diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs index d8962668480b..b08de54093d7 100644 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs +++ b/apps/block_scout_web/test/block_scout_web/features/viewing_tokens_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ViewingTokensTest do use BlockScoutWeb.FeatureCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/features/viewing_transactions_test.exs b/apps/block_scout_web/test/block_scout_web/features/viewing_transactions_test.exs deleted file mode 100644 index 9931bd43a5d9..000000000000 --- a/apps/block_scout_web/test/block_scout_web/features/viewing_transactions_test.exs +++ /dev/null @@ -1,163 +0,0 @@ -defmodule BlockScoutWeb.ViewingTransactionsTest do - @moduledoc false - - import Mox - - use BlockScoutWeb.FeatureCase, async: false - - alias BlockScoutWeb.{AddressPage, TransactionListPage, TransactionLogsPage, TransactionPage} - alias Explorer.Chain.Wei - - setup :set_mox_global - - setup do - block = - insert(:block, %{ - timestamp: Timex.now() |> Timex.shift(hours: -2), - gas_used: 123_987 - }) - - 3 - |> insert_list(:transaction) - |> with_block() - - pending = insert(:transaction, block_hash: nil, gas: 5891, index: nil) - pending_contract = insert(:transaction, to_address: nil, block_hash: nil, gas: 5891, index: nil) - - lincoln = insert(:address) - taft = insert(:address) - - # From Lincoln to Taft. - transaction_from_lincoln = - :transaction - |> insert(from_address: lincoln, to_address: taft) - |> with_block(block) - - transaction = - :transaction - |> insert( - value: Wei.from(Decimal.new(5656), :ether), - gas: Decimal.new(1_230_000_000_000_123_123), - gas_price: Decimal.new(7_890_000_000_898_912_300_045), - input: "0x000012", - nonce: 99045, - inserted_at: Timex.parse!("1970-01-01T00:00:18-00:00", "{ISO:Extended}"), - updated_at: Timex.parse!("1980-01-01T00:00:18-00:00", "{ISO:Extended}"), - from_address: taft, - to_address: lincoln - ) - |> with_block(block, gas_used: Decimal.new(1_230_000_000_000_123_000), status: :ok) - - insert(:log, address: lincoln, index: 0, transaction: transaction, block: block, block_number: block.number) - - internal = - insert(:internal_transaction, - index: 0, - transaction: transaction, - block_hash: transaction.block_hash, - block_index: 0 - ) - - {:ok, - %{ - pending: pending, - pending_contract: pending_contract, - internal: internal, - lincoln: lincoln, - taft: taft, - transaction: transaction, - transaction_from_lincoln: transaction_from_lincoln - }} - end - - describe "viewing transaction lists" do - test "viewing the default transactions tab", %{ - session: session, - transaction: transaction, - pending: pending - } do - session - |> TransactionListPage.visit_page() - |> assert_has(TransactionListPage.transaction(transaction)) - |> assert_has(TransactionListPage.transaction_status(transaction)) - |> refute_has(TransactionListPage.transaction(pending)) - end - - test "viewing the pending transactions list", %{ - pending: pending, - pending_contract: pending_contract, - session: session - } do - session - |> TransactionListPage.visit_pending_transactions_page() - |> assert_has(TransactionListPage.transaction(pending)) - |> assert_has(TransactionListPage.transaction(pending_contract)) - |> assert_has(TransactionListPage.transaction_status(pending_contract)) - end - - test "contract creation is shown for to_address on list page", %{session: session} do - contract_address = insert(:contract_address) - - transaction = - :transaction - |> insert(to_address: nil) - |> with_block() - |> with_contract_creation(contract_address) - - :internal_transaction_create - |> insert(transaction: transaction, index: 0, block_hash: transaction.block_hash, block_index: 0) - |> with_contract_creation(contract_address) - - session - |> TransactionListPage.visit_page() - |> assert_has(TransactionListPage.contract_creation(transaction)) - end - end - - describe "viewing a pending transaction page" do - test "can see a pending transaction's details", %{session: session, pending: pending} do - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn %{id: _id, method: "net_version", params: []}, _options -> - {:ok, "100"} - end) - - session - |> TransactionPage.visit_page(pending) - |> assert_has(TransactionPage.detail_hash(pending)) - |> assert_has(TransactionPage.is_pending()) - end - end - - describe "viewing a transaction page" do - test "can navigate to transaction show from list page", %{session: session, transaction: transaction} do - session - |> TransactionListPage.visit_page() - |> TransactionListPage.click_transaction(transaction) - |> assert_has(TransactionPage.detail_hash(transaction)) - end - - test "can see a transaction's details", %{session: session, transaction: transaction} do - session - |> TransactionPage.visit_page(transaction) - |> assert_has(TransactionPage.detail_hash(transaction)) - end - - test "can view a transaction's logs", %{session: session, transaction: transaction} do - session - |> TransactionPage.visit_page(transaction) - |> TransactionPage.click_logs() - |> assert_has(TransactionLogsPage.logs(count: 1)) - end - - test "can visit an address from the transaction logs page", %{ - lincoln: lincoln, - session: session, - transaction: transaction - } do - session - |> TransactionLogsPage.visit_page(transaction) - |> TransactionLogsPage.click_address(lincoln) - |> assert_has(AddressPage.detail_hash(lincoln)) - end - end -end diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs index 3b0c3fec22a0..ba66558b1148 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/address_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Query.AddressTest do use BlockScoutWeb.ConnCase @@ -97,6 +98,39 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.AddressTest do } end + test "smart_contract language field returns all supported languages", %{conn: conn} do + for language <- [:solidity, :vyper, :yul, :geas] do + address = insert(:address, fetched_coin_balance: 100) + insert(:smart_contract, address_hash: address.hash, contract_code_md5: "123", language: language) + + query = """ + query ($hash: AddressHash!) { + address(hash: $hash) { + smart_contract { + language + } + } + } + """ + + variables = %{"hash" => to_string(address.hash)} + + conn = get(conn, "/api/v1/graphql", query: query, variables: variables) + + expected = language |> to_string() |> String.upcase() + + assert %{ + "data" => %{ + "address" => %{ + "smart_contract" => %{ + "language" => ^expected + } + } + } + } = json_response(conn, 200) + end + end + test "errors for non-existent address hash", %{conn: conn} do address = build(:address) diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs index 32bbc5fdd205..11248e4f327a 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/addresses_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Query.AddressesTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/block_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/block_test.exs index 8f2f6badbbda..c5e731a26139 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/block_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/block_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Query.BlockTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/introspection_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/introspection_test.exs index 792d4e7d8344..c8d4798fe471 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/introspection_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/introspection_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Schema.Query.IntrospectionTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs index 05c38b5fdcd8..d88f9ff68063 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/node_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do use BlockScoutWeb.ConnCase @@ -63,9 +64,9 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do internal_transaction = insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) query = """ @@ -107,9 +108,9 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.NodeTest do internal_transaction = build(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) query = """ diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs index 4c0d1499b3e6..a8d1609e63ec 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Query.TokenTransfersTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs index f13049bde761..0492a49ab4db 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/query/transaction_test.exs @@ -1,6 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do use BlockScoutWeb.ConnCase + alias Explorer.Chain.InternalTransaction + describe "transaction field" do test "with valid argument 'hash', returns all expected fields", %{conn: conn} do block = insert(:block) @@ -132,17 +135,19 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do internal_transaction_attributes = %{ transaction: transaction, + transaction_index: transaction.index, index: 0, from_address: address, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, call_type: :call, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number } internal_transaction = :internal_transaction_create |> insert(internal_transaction_attributes) - |> with_contract_creation(contract_address) + |> InternalTransaction.preload_addresses() query = """ query ($hash: FullHash!, $first: Int!) { @@ -198,7 +203,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do "init" => to_string(internal_transaction.init), "input" => nil, "output" => nil, - "trace_address" => Jason.encode!(internal_transaction.trace_address), + "trace_address" => internal_transaction.trace_address, "type" => internal_transaction.type |> to_string() |> String.upcase(), "value" => to_string(internal_transaction.value.value), "block_number" => internal_transaction.block_number, @@ -207,7 +212,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do to_string(internal_transaction.created_contract_address_hash), "from_address_hash" => to_string(internal_transaction.from_address_hash), "to_address_hash" => nil, - "transaction_hash" => to_string(internal_transaction.transaction_hash) + "transaction_hash" => to_string(internal_transaction.transaction.hash) } } ] @@ -265,23 +270,23 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 2, - block_hash: transaction.block_hash, - block_index: 2 + block_number: transaction.block_number ) insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 1, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) query = """ @@ -393,23 +398,23 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 2, - block_hash: transaction.block_hash, - block_index: 2 + block_number: transaction.block_number ) insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 1, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) query = """ @@ -430,7 +435,7 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do variables = %{ "hash" => to_string(transaction.hash), "last" => 1, - "count" => 3 + "count" => 2 } [internal_transaction] = @@ -448,9 +453,9 @@ defmodule BlockScoutWeb.GraphQL.Schema.Query.TransactionTest do for index <- 0..5 do insert(:internal_transaction_create, transaction: transaction, + transaction_index: transaction.index, index: index, - block_hash: transaction.block_hash, - block_index: index + block_number: transaction.block_number ) end diff --git a/apps/block_scout_web/test/block_scout_web/graphql/schema/subscription/token_transfers_test.exs b/apps/block_scout_web/test/block_scout_web/graphql/schema/subscription/token_transfers_test.exs index 4ba16ec96ecd..3be1f3ba36c8 100644 --- a/apps/block_scout_web/test/block_scout_web/graphql/schema/subscription/token_transfers_test.exs +++ b/apps/block_scout_web/test/block_scout_web/graphql/schema/subscription/token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.GraphQL.Schema.Subscription.TokenTransfersTest do use BlockScoutWeb.SubscriptionCase import Mox diff --git a/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs b/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs new file mode 100644 index 000000000000..3d39eda1dc0c --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/notifier_subscriber_filter_test.exs @@ -0,0 +1,291 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.NotifierSubscriberFilterTest do + use BlockScoutWeb.ChannelCase, + async: false + + alias BlockScoutWeb.Notifier + alias Explorer.Chain.Wei + alias Explorer.Chain.Cache.Counters.AddressesCount + + defp create_token_transfer(opts \\ []) do + from_address = opts[:from_address] || insert(:address) + to_address = opts[:to_address] || insert(:address) + + token = insert(:token, type: "ERC-20") + + transaction = + :transaction + |> insert(from_address: from_address, to_address: token.contract_address) + |> with_block() + + insert(:token_transfer, + from_address: from_address, + to_address: to_address, + token_contract_address: token.contract_address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 0, + token_type: "ERC-20", + block_consensus: true + ) + end + + describe "addresses event: subscriber filtering" do + test "broadcasts balance only to subscribed address in a batch" do + {:ok, balance} = Wei.cast(1) + + subscribed = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + unsubscribed = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + + subscribed_topic = "addresses:#{subscribed.hash}" + unsubscribed_topic = "addresses:#{unsubscribed.hash}" + @endpoint.subscribe(subscribed_topic) + @endpoint.subscribe(unsubscribed_topic) + + start_supervised!(AddressesCount) + AddressesCount.consolidate() + + Phoenix.PubSub.unsubscribe(BlockScoutWeb.PubSub, unsubscribed_topic) + + Notifier.handle_event({:chain_event, :addresses, :realtime, [subscribed, unsubscribed]}) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^subscribed_topic, event: "balance"}, :timer.seconds(5) + refute_receive %Phoenix.Socket.Broadcast{topic: ^unsubscribed_topic, event: "balance"}, 100 + end + + test "handles addresses event without error when no address has subscribers" do + {:ok, balance} = Wei.cast(1) + address = insert(:address, fetched_coin_balance: balance, fetched_coin_balance_block_number: 1) + + start_supervised!(AddressesCount) + AddressesCount.consolidate() + + Notifier.handle_event({:chain_event, :addresses, :realtime, [address]}) + end + end + + describe "address_coin_balances event: subscriber filtering" do + test "handles event without error when no address has subscribers" do + address = insert(:address) + block = insert(:block) + + Notifier.handle_event( + {:chain_event, :address_coin_balances, :realtime, + [%{address_hash: address.hash, block_number: block.number, value: 1}]} + ) + end + + test "skips unsubscribed addresses in a batch" do + subscribed_address = insert(:address) + unsubscribed_address = insert(:address) + + coin_balance = + insert(:address_coin_balance, + address: subscribed_address, + address_hash: subscribed_address.hash, + delta: 500 + ) + + subscribed_topic = "addresses:#{subscribed_address.hash}" + @endpoint.subscribe(subscribed_topic) + + Notifier.handle_event( + {:chain_event, :address_coin_balances, :realtime, + [ + %{address_hash: subscribed_address.hash, block_number: coin_balance.block_number, value: 1}, + %{address_hash: unsubscribed_address.hash, block_number: coin_balance.block_number, value: 1} + ]} + ) + + assert_receive %Phoenix.Socket.Broadcast{topic: ^subscribed_topic, event: "coin_balance"}, :timer.seconds(5) + end + end + + describe "address_token_balances event: subscriber filtering" do + test "handles event without error when no address has subscribers" do + address = insert(:address) + block = insert(:block) + + Notifier.handle_event( + {:chain_event, :address_token_balances, :realtime, [%{address_hash: address.hash, block_number: block.number}]} + ) + end + end + + describe "internal_transactions event: subscriber filtering" do + test "handles event without error when no address has subscribers" do + transaction = + :transaction + |> insert() + |> with_block() + + internal_transaction = + insert(:internal_transaction, + transaction: transaction, + transaction_index: transaction.index, + index: 0, + block_number: transaction.block_number + ) + + Notifier.handle_event({:chain_event, :internal_transactions, :realtime, [internal_transaction]}) + end + + test "processes only internal transactions with subscribed addresses" do + subscribed_address = insert(:address) + + transaction = + :transaction + |> insert(from_address: subscribed_address) + |> with_block() + + subscribed_it = + insert(:internal_transaction, + transaction: transaction, + from_address: subscribed_address, + transaction_index: transaction.index, + index: 0, + block_number: transaction.block_number + ) + + unsubscribed_transaction = + :transaction + |> insert() + |> with_block() + + unsubscribed_it = + insert(:internal_transaction, + transaction: unsubscribed_transaction, + transaction_index: unsubscribed_transaction.index, + index: 0, + block_number: unsubscribed_transaction.block_number + ) + + topic = "addresses_old:#{subscribed_address.hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event({:chain_event, :internal_transactions, :realtime, [subscribed_it, unsubscribed_it]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "internal_transaction", + payload: %{internal_transaction: _} + }, + :timer.seconds(5) + + refute_receive %Phoenix.Socket.Broadcast{event: "internal_transaction"}, 100 + end + end + + describe "token_transfers event: subscriber filtering" do + test "handles event without error when no channel has subscribers" do + token_transfer = create_token_transfer() + + Notifier.handle_event({:chain_event, :token_transfers, :realtime, [token_transfer]}) + end + + test "processes transfers when subscribed to from_address channel" do + token_transfer = create_token_transfer() + topic = "addresses:#{token_transfer.from_address_hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event({:chain_event, :token_transfers, :realtime, [token_transfer]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "token_transfer", + payload: %{token_transfers: _} + }, + :timer.seconds(5) + end + + test "processes transfers when subscribed to to_address channel" do + token_transfer = create_token_transfer() + topic = "addresses:#{token_transfer.to_address_hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event({:chain_event, :token_transfers, :realtime, [token_transfer]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "token_transfer", + payload: %{token_transfers: _} + }, + :timer.seconds(5) + end + + test "processes transfers when subscribed to token channel" do + token_transfer = create_token_transfer() + topic = "tokens:#{token_transfer.token_contract_address_hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event({:chain_event, :token_transfers, :realtime, [token_transfer]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "token_transfer", + payload: %{token_transfer: 1} + }, + :timer.seconds(5) + end + + test "processes only relevant transfers in a mixed batch" do + subscribed_address = insert(:address) + subscribed_transfer = create_token_transfer(from_address: subscribed_address) + unsubscribed_transfer = create_token_transfer() + + subscribed_topic = "addresses:#{subscribed_address.hash}" + @endpoint.subscribe(subscribed_topic) + + Notifier.handle_event({:chain_event, :token_transfers, :realtime, [subscribed_transfer, unsubscribed_transfer]}) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^subscribed_topic, + event: "token_transfer", + payload: %{token_transfers: _} + }, + :timer.seconds(5) + + unsubscribed_topic = "addresses:#{unsubscribed_transfer.from_address_hash}" + refute_receive %Phoenix.Socket.Broadcast{topic: ^unsubscribed_topic}, 100 + end + end + + describe "address_current_token_balances event: subscriber filtering" do + test "handles event without error when address has no subscribers" do + address = insert(:address) + token_balance = insert(:address_current_token_balance, address: address) + + Notifier.handle_event( + {:chain_event, :address_current_token_balances, :realtime, + %{address_current_token_balances: [token_balance], address_hash: address.hash}} + ) + end + + test "processes balances when address has subscribers" do + address = insert(:address) + + token_balance = + insert(:address_current_token_balance, + address: address, + token_type: "ERC-20", + value: 1_000_000_000_000_000_000 + ) + + topic = "addresses:#{address.hash}" + @endpoint.subscribe(topic) + + Notifier.handle_event( + {:chain_event, :address_current_token_balances, :realtime, + %{address_current_token_balances: [token_balance], address_hash: address.hash}} + ) + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: "updated_token_balances_erc_20", + payload: %{token_balances: _, overflow: _} + }, + :timer.seconds(5) + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/plug/admin/check_owner_registered_test.exs b/apps/block_scout_web/test/block_scout_web/plug/admin/check_owner_registered_test.exs index 2eb7b98aea4d..cdf6f870de2b 100644 --- a/apps/block_scout_web/test/block_scout_web/plug/admin/check_owner_registered_test.exs +++ b/apps/block_scout_web/test/block_scout_web/plug/admin/check_owner_registered_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.Admin.CheckOwnerRegisteredTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/plug/admin/require_admin_role_test.exs b/apps/block_scout_web/test/block_scout_web/plug/admin/require_admin_role_test.exs index 45bcdc2beac6..8907aeed4aa8 100644 --- a/apps/block_scout_web/test/block_scout_web/plug/admin/require_admin_role_test.exs +++ b/apps/block_scout_web/test/block_scout_web/plug/admin/require_admin_role_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.Admin.RequireAdminRoleTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/plug/fetch_user_from_session_test.exs b/apps/block_scout_web/test/block_scout_web/plug/fetch_user_from_session_test.exs index ed00ce6de9d4..9336d2f48676 100644 --- a/apps/block_scout_web/test/block_scout_web/plug/fetch_user_from_session_test.exs +++ b/apps/block_scout_web/test/block_scout_web/plug/fetch_user_from_session_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Plug.FetchUserFromSessionTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs b/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs new file mode 100644 index 000000000000..096d80ea2ffb --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/plug/rate_limit_test.exs @@ -0,0 +1,484 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Plug.RateLimitTest do + use BlockScoutWeb.ConnCase, async: false + + setup do + # Store original config + original_config = :persistent_term.get(:rate_limit_config) + + original_recaptcha_config = Application.get_env(:block_scout_web, :recaptcha) + original_rate_limit_config = Application.get_env(:block_scout_web, :api_rate_limit) + original_graphql_config = Application.get_env(:block_scout_web, Api.GraphQL) + + Application.put_env(:block_scout_web, :api_rate_limit, Keyword.put(original_rate_limit_config, :disabled, false)) + + Application.put_env( + :block_scout_web, + Api.GraphQL, + Keyword.put(original_graphql_config, :rate_limit_disabled?, false) + ) + + on_exit(fn -> + :persistent_term.put(:rate_limit_config, original_config) + + Application.put_env(:block_scout_web, :recaptcha, original_recaptcha_config) + Application.put_env(:block_scout_web, :api_rate_limit, original_rate_limit_config) + Application.put_env(:block_scout_web, Api.GraphQL, original_graphql_config) + + :ets.delete_all_objects(BlockScoutWeb.RateLimit.Hammer.ETS) + end) + end + + describe "rate limiting" do + test "sets rate limit headers for allowed and denied requests", %{conn: conn} do + config = %{ + static_match: %{ + "api/v2/blocks" => %{ + ip: %{ + period: 60_000, + limit: 1 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # First request - allowed + first_request = conn |> get("/api/v2/blocks") + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(first_request, "bypass-429-option") == ["no_bypass"] + + # Second request - should be denied with 429 + second_request = conn |> get("/api/v2/blocks") + assert second_request.status == 429 + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(second_request, "bypass-429-option") == ["no_bypass"] + end + + test "handles recaptcha bypass option", %{conn: conn} do + config = %{ + static_match: %{ + "api/v2/addresses" => %{ + ip: %{ + period: 60_000, + limit: 1 + }, + recaptcha_to_bypass_429: true + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # First request with user agent + first_request = + conn + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses") + + assert first_request.status == 200 + + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "bypass-429-option") == ["recaptcha"] + + # Second request - should be denied with 429 + second_request = + conn + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/addresses") + + assert second_request.status == 429 + + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "bypass-429-option") == ["recaptcha"] + end + + test "handles temporary token bypass option", %{conn: conn} do + config = %{ + static_match: %{ + "api/v2/transactions" => %{ + ip: %{ + period: 60_000, + limit: 1 + }, + temporary_token: true + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # First request with user agent + first_request = + conn + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/transactions") + + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "bypass-429-option") == ["temporary_token"] + + # Second request - should be denied with 429 + second_request = + conn + |> put_req_header("user-agent", "test-agent") + |> get("/api/v2/transactions") + + assert second_request.status == 429 + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "bypass-429-option") == ["temporary_token"] + end + + test "handles GraphQL requests", %{conn: conn} do + Application.put_env(:block_scout_web, Api.GraphQL, + rate_limit_disabled?: false, + limit_by_ip: 1, + time_interval_limit_by_ip: 60_000, + global_limit: 500, + time_interval_limit: 60_000 + ) + + # First request + first_request = conn |> post("/api/v1/graphql", %{query: "{}"}) + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + + # This request should be denied due to IP limit + last_request = conn |> post("/api/v1/graphql", %{query: "{}"}) + assert last_request.status == 429 + assert get_resp_header(last_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(last_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(last_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "handles GraphQL requests with global limit", %{conn: conn} do + Application.put_env(:block_scout_web, Api.GraphQL, + rate_limit_disabled?: false, + limit_by_ip: 100, + time_interval_limit_by_ip: 60_000, + global_limit: 1, + time_interval_limit: 60_000 + ) + + # First request + first_request = conn |> post("/api/v1/graphql", %{query: "{}"}) + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["100"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["99"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + + # This request should be denied due to IP limit + last_request = conn |> post("/api/v1/graphql", %{query: "{}"}) + assert last_request.status == 429 + assert get_resp_header(last_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(last_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(last_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "handles parametrized paths", %{conn: conn} do + token = insert(:token) + + config = %{ + static_match: %{}, + wildcard_match: %{}, + parametrized_match: %{ + ["api", "v2", "tokens", ":param"] => %{ + ip: %{ + period: 60_000, + limit: 1 + } + } + } + } + + :persistent_term.put(:rate_limit_config, config) + + # First request to parametrized path + first_request = conn |> get("/api/v2/tokens/#{token.contract_address_hash}") + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + + # Second request - should be denied with 429 + second_request = conn |> get("/api/v2/tokens/123") + assert second_request.status == 429 + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "handles wildcard paths", %{conn: conn} do + config = %{ + static_match: %{}, + wildcard_match: %{ + {["api", "v2"], 2} => %{ + ip: %{ + period: 60_000, + limit: 1 + } + } + }, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # First request to a path matching wildcard + first_request = conn |> get("/api/v2/main-page/transactions") + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + + # Second request - should be denied with 429 + second_request = conn |> get("/api/v2/blocks") + assert second_request.status == 429 + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "falls back to default config", %{conn: conn} do + config = %{ + static_match: %{ + "default" => %{ + ip: %{ + period: 60_000, + limit: 1 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # First request to a path with no specific config + first_request = conn |> get("/api/v2/transactions") + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + + # Second request - should be denied with 429 + second_request = conn |> get("/api/v2/blocks") + assert second_request.status == 429 + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "rate limit is disabled when configured", %{conn: conn} do + config = %{ + static_match: %{ + "default" => %{ + ip: %{ + period: 60_000, + limit: 0 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(Application.get_env(:block_scout_web, :api_rate_limit), :disabled, true) + ) + + request = conn |> get("/api/v2/transactions") + assert request.status == 200 + assert get_resp_header(request, "x-ratelimit-limit") == ["-1"] + assert get_resp_header(request, "x-ratelimit-remaining") == ["-1"] + assert get_resp_header(request, "x-ratelimit-reset") == ["-1"] + end + + test "bypasses rate limit with valid API key", %{conn: conn} do + config = %{ + static_match: %{ + "default" => %{ + ip: %{ + period: 60_000, + limit: 0 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(Application.get_env(:block_scout_web, :api_rate_limit), :no_rate_limit_api_key_value, "123") + ) + + request = conn |> get("/api/v2/transactions", %{"apikey" => "123"}) + assert request.status == 200 + assert get_resp_header(request, "x-ratelimit-limit") == ["-1"] + assert get_resp_header(request, "x-ratelimit-remaining") == ["-1"] + assert get_resp_header(request, "x-ratelimit-reset") == ["-1"] + + request = conn |> put_req_header("x-api-key", "123") |> get("/api/v2/transactions") + assert request.status == 200 + assert get_resp_header(request, "x-ratelimit-limit") == ["-1"] + assert get_resp_header(request, "x-ratelimit-remaining") == ["-1"] + assert get_resp_header(request, "x-ratelimit-reset") == ["-1"] + end + + test "enforces rate limit with invalid API key", %{conn: conn} do + config = %{ + static_match: %{ + "default" => %{ + ip: %{ + period: 60_000, + limit: 0 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(Application.get_env(:block_scout_web, :api_rate_limit), :no_rate_limit_api_key_value, nil) + ) + + request = conn |> get("/api/v2/transactions", %{"apikey" => nil}) + assert request.status == 429 + assert get_resp_header(request, "x-ratelimit-limit") == ["0"] + assert get_resp_header(request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "enforces rate limit with empty API key", %{conn: conn} do + config = %{ + static_match: %{ + "default" => %{ + ip: %{ + period: 60_000, + limit: 0 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(Application.get_env(:block_scout_web, :api_rate_limit), :no_rate_limit_api_key_value, "") + ) + + request = conn |> get("/api/v2/transactions", %{"apikey" => " "}) + assert request.status == 429 + assert get_resp_header(request, "x-ratelimit-limit") == ["0"] + assert get_resp_header(request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + end + + test "rate limit requests with isolated rate limit", %{conn: conn} do + config = %{ + static_match: %{ + "api/v2/blocks" => %{ + ip: %{ + period: 60_000, + limit: 1 + } + }, + "api/v2/transactions" => %{ + ip: %{ + period: 60_000, + limit: 1 + }, + isolate_rate_limit?: true, + bucket_key_prefix: "api/v2/transactions_" + }, + "api/v2/addresses" => %{ + ip: %{ + period: 60_000, + limit: 1 + } + } + }, + wildcard_match: %{}, + parametrized_match: %{} + } + + :persistent_term.put(:rate_limit_config, config) + + # First request - allowed + first_request = conn |> get("/api/v2/blocks") + assert first_request.status == 200 + assert get_resp_header(first_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(first_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(first_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(first_request, "bypass-429-option") == ["no_bypass"] + + # Second request - should be denied with 429 + second_request = conn |> get("/api/v2/blocks") + assert second_request.status == 429 + assert get_resp_header(second_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(second_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(second_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(second_request, "bypass-429-option") == ["no_bypass"] + + # Third request - should be allowed + third_request = conn |> get("/api/v2/transactions") + assert third_request.status == 200 + assert get_resp_header(third_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(third_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(third_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(third_request, "bypass-429-option") == ["no_bypass"] + + # Fourth request - should be allowed + fourth_request = conn |> get("/api/v2/transactions") + assert fourth_request.status == 429 + assert get_resp_header(fourth_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(fourth_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(fourth_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(fourth_request, "bypass-429-option") == ["no_bypass"] + + # Fifth request - should be denied with 429 + fifth_request = conn |> get("/api/v2/addresses") + assert fifth_request.status == 429 + assert get_resp_header(fifth_request, "x-ratelimit-limit") == ["1"] + assert get_resp_header(fifth_request, "x-ratelimit-remaining") == ["0"] + assert get_resp_header(fifth_request, "x-ratelimit-reset") |> hd() |> String.to_integer() > 0 + assert get_resp_header(fifth_request, "bypass-429-option") == ["no_bypass"] + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/rate_limit_test.exs b/apps/block_scout_web/test/block_scout_web/rate_limit_test.exs new file mode 100644 index 000000000000..edebaa73485d --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/rate_limit_test.exs @@ -0,0 +1,520 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.RateLimitTest do + use BlockScoutWeb.ConnCase, async: false + alias BlockScoutWeb.RateLimit + + describe "check_rate_limit_graphql/3" do + setup do + original_config = Application.get_env(:block_scout_web, Api.GraphQL) + Application.put_env(:block_scout_web, Api.GraphQL, Keyword.put(original_config, :rate_limit_disabled?, false)) + + on_exit(fn -> + Application.put_env(:block_scout_web, Api.GraphQL, original_config) + :ets.delete_all_objects(BlockScoutWeb.RateLimit.Hammer.ETS) + end) + end + + test "returns {:allow, -1} when rate limit is disabled" do + Application.put_env(:block_scout_web, Api.GraphQL, rate_limit_disabled?: true) + conn = build_conn() + + assert RateLimit.check_rate_limit_graphql(conn, 1) == {:allow, -1} + end + + test "returns {:allow, -1} when using no_rate_limit_api_key" do + no_rate_limit_api_key = "no_limit_key" + Application.put_env(:block_scout_web, Api.GraphQL, no_rate_limit_api_key: no_rate_limit_api_key) + conn = build_conn() |> Map.put(:query_params, %{"apikey" => no_rate_limit_api_key}) + + assert RateLimit.check_rate_limit_graphql(conn, 1) == {:allow, -1} + end + + test "applies rate limit for IP when no API key is provided" do + Application.put_env(:block_scout_web, Api.GraphQL, + rate_limit_disabled?: false, + limit_by_ip: 100, + time_interval_limit_by_ip: 60_000, + time_interval_limit: 60_000, + global_limit: 1000 + ) + + conn = build_conn() + + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == 1 + assert limit == 100 + assert period == 60_000 + end + + test "global limit is applied when no API key is provided" do + static_api_key = "static_key" + + Application.put_env(:block_scout_web, Api.GraphQL, + rate_limit_disabled?: false, + limit_by_ip: 100, + time_interval_limit_by_ip: 50_000, + time_interval_limit: 60_000, + global_limit: 1, + limit_by_key: 100, + static_api_key: static_api_key + ) + + conn = build_conn() + + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == 1 + assert limit == 100 + assert period == 50_000 + + assert {:deny, time_to_reset, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert time_to_reset > 0 and time_to_reset < 60_000 + assert limit == 1 + assert period == 60_000 + + conn = build_conn() |> Map.put(:query_params, %{"apikey" => static_api_key}) + + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == 1 + assert limit == 100 + assert period == 60_000 + end + + test "bypass 429 rate limit with static API key" do + static_api_key = "static_key" + + Application.put_env(:block_scout_web, Api.GraphQL, + rate_limit_disabled?: false, + limit_by_ip: 1, + time_interval_limit_by_ip: 60_000, + time_interval_limit: 1_000, + global_limit: 1000, + static_api_key: static_api_key, + limit_by_key: 500 + ) + + conn = build_conn() + + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == 1 + assert limit == 1 + assert period == 60_000 + + assert {:deny, time_to_reset, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert time_to_reset > 0 and time_to_reset < 60_000 + assert limit == 1 + assert period == 60_000 + + conn = build_conn() |> Map.put(:query_params, %{"apikey" => static_api_key}) + + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == 1 + assert limit == 500 + assert period == 1_000 + end + + test "applies rate limit for static API key" do + static_api_key = "static_key" + time_interval_limit = 500 + + Application.put_env(:block_scout_web, Api.GraphQL, + rate_limit_disabled?: false, + static_api_key: static_api_key, + time_interval_limit: time_interval_limit, + limit_by_key: 500 + ) + + conn = build_conn() |> Map.put(:query_params, %{"apikey" => static_api_key}) + + # Make 500 requests to hit the limit + Enum.each(1..500, fn i -> + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == i + assert limit == 500 + assert period == time_interval_limit + end) + + # Next request should be denied + assert {:deny, time_to_reset, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert limit == 500 + assert period == time_interval_limit + assert time_to_reset > 0 and time_to_reset < time_interval_limit + + Process.sleep(time_to_reset) + + # Make another request to check if it's allowed again + assert {:allow, count, limit, period} = RateLimit.check_rate_limit_graphql(conn, 1) + assert count == 1 + assert limit == 500 + assert period == time_interval_limit + end + end + + describe "rate_limit_with_config/2" do + setup do + original_config = Application.get_env(:block_scout_web, :api_rate_limit) + Application.put_env(:block_scout_web, :api_rate_limit, Keyword.put(original_config, :disabled, false)) + original_recaptcha_config = Application.get_env(:block_scout_web, :recaptcha) + + on_exit(fn -> + Application.put_env(:block_scout_web, :api_rate_limit, original_config) + Application.put_env(:block_scout_web, :recaptcha, original_recaptcha_config) + :ets.delete_all_objects(BlockScoutWeb.RateLimit.Hammer.ETS) + end) + end + + test "returns {:allow, -1} when rate limit is disabled globally" do + Application.put_env(:block_scout_web, :api_rate_limit, disabled: true) + conn = build_conn() + + assert RateLimit.rate_limit_with_config(conn, %{}) == {:allow, -1} + end + + test "returns {:allow, -1} when endpoint is ignored" do + conn = build_conn() + + assert RateLimit.rate_limit_with_config(conn, %{ignore: true}) == {:allow, -1} + end + + test "applies rate limit by IP" do + config = %{ + ip: %{ + period: 60_000, + limit: 100 + } + } + + conn = build_conn() + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 100 + assert period == 60_000 + end + + test "applies rate limit by static API key" do + static_api_key = "static_key" + original_config = Application.get_env(:block_scout_web, :api_rate_limit) + + Application.put_env( + :block_scout_web, + :api_rate_limit, + Keyword.put(original_config, :static_api_key_value, static_api_key) + ) + + config = %{ + temporary_token: %{ + period: 60_000, + limit: 100 + }, + static_api_key: %{ + period: 60_000, + limit: 500 + } + } + + conn = build_conn() |> Map.put(:query_params, %{"apikey" => static_api_key}) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count >= 0 + assert limit == 500 + assert period == 60_000 + end + + test "applies rate limit by whitelisted IP" do + ip = "192.168.1.1" + original_config = Application.get_env(:block_scout_web, :api_rate_limit) + Application.put_env(:block_scout_web, :api_rate_limit, Keyword.put(original_config, :whitelisted_ips, ip)) + + config = %{ + temporary_token: %{ + period: 60_000, + limit: 100 + }, + static_api_key: %{ + period: 60_000, + limit: 200 + }, + account_api_key: %{ + period: 60_000, + limit: 300 + }, + whitelisted_ip: %{ + period: 60_000, + limit: 400 + }, + ip: %{ + period: 60_000, + limit: 500 + } + } + + conn = build_conn() |> Map.put(:remote_ip, {192, 168, 1, 1}) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 400 + assert period == 60_000 + end + + test "applies rate limit by temporary token" do + config = %{ + temporary_token: %{ + period: 60_000, + limit: 100 + }, + static_api_key: %{ + period: 60_000, + limit: 200 + }, + account_api_key: %{ + period: 60_000, + limit: 300 + }, + whitelisted_ip: %{ + period: 60_000, + limit: 400 + }, + ip: %{ + period: 60_000, + limit: 1 + } + } + + Application.put_env( + :block_scout_web, + :recaptcha, + Keyword.put(Application.get_env(:block_scout_web, :recaptcha), :bypass_token, "test_token") + ) + + conn = + build_conn() + |> Map.put(:remote_ip, {192, 168, 1, 1}) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 1 + assert period == 60_000 + + assert {:deny, time_to_reset, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert time_to_reset > 0 and time_to_reset < 60_000 + assert limit == 1 + assert period == 60_000 + + # First make request to get temporary token + conn = + build_conn() + |> Map.put(:remote_ip, {192, 168, 1, 1}) + |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + |> post("/api/v2/key", %{"recaptcha_bypass_token" => "test_token"}) + + # Extract token from response + token = conn.resp_cookies["api_v2_temp_token"].value + + # Now make request with the token + conn = + conn + |> Map.put(:remote_ip, {192, 168, 1, 1}) + |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + |> Map.put(:req_cookies, %{"api_v2_temp_token" => token}) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 100 + assert period == 60_000 + + assert {:allow, count, limit, period} = + RateLimit.rate_limit_with_config(conn |> Map.put(:remote_ip, {192, 168, 1, 2}), config) + + assert count == 1 + assert limit == 1 + assert period == 60_000 + end + + test "applies rate limit by temporary token in header" do + config = %{ + temporary_token: %{ + period: 60_000, + limit: 100 + }, + static_api_key: %{ + period: 60_000, + limit: 200 + }, + account_api_key: %{ + period: 60_000, + limit: 300 + }, + whitelisted_ip: %{ + period: 60_000, + limit: 400 + }, + ip: %{ + period: 60_000, + limit: 1 + } + } + + Application.put_env( + :block_scout_web, + :recaptcha, + Keyword.put(Application.get_env(:block_scout_web, :recaptcha), :bypass_token, "test_token") + ) + + conn = + build_conn() + |> Map.put(:remote_ip, {192, 168, 1, 1}) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 1 + assert period == 60_000 + + assert {:deny, time_to_reset, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert time_to_reset > 0 and time_to_reset < 60_000 + assert limit == 1 + assert period == 60_000 + + # First make request to get temporary token + conn = + build_conn() + |> Map.put(:remote_ip, {192, 168, 1, 1}) + |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + |> post("/api/v2/key", %{"recaptcha_bypass_token" => "test_token", "in_header" => "true"}) + + # Extract token from response + [token] = Plug.Conn.get_resp_header(conn, "api-v2-temp-token") + assert conn.resp_cookies["api_v2_temp_token"] == nil + + # Now make request with the token + conn = + conn + |> Map.put(:remote_ip, {192, 168, 1, 1}) + |> Map.put(:req_headers, [{"user-agent", "test-agent"}, {"api-v2-temp-token", token}]) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 100 + assert period == 60_000 + + # Token from header does not work in cookies + conn = + conn + |> Map.put(:remote_ip, {192, 168, 1, 1}) + |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + |> Map.put(:req_cookies, %{"api_v2_temp_token" => token}) + + assert {:deny, time_to_reset, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert time_to_reset > 0 and time_to_reset < 60_000 + assert limit == 1 + assert period == 60_000 + + assert {:allow, count, limit, period} = + RateLimit.rate_limit_with_config(conn |> Map.put(:remote_ip, {192, 168, 1, 2}), config) + + assert count == 1 + assert limit == 1 + assert period == 60_000 + end + + test "handles recaptcha bypass" do + config = %{ + temporary_token: %{ + period: 60_000, + limit: 100 + }, + static_api_key: %{ + period: 60_000, + limit: 200 + }, + account_api_key: %{ + period: 60_000, + limit: 300 + }, + whitelisted_ip: %{ + period: 60_000, + limit: 400 + }, + ip: %{ + period: 70_000, + limit: 1 + }, + recaptcha_to_bypass_429: true + } + + # First request to hit the limit + conn = build_conn() |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + assert {:allow, 1, 1, _} = RateLimit.rate_limit_with_config(conn, config) + + # Second request that should be denied + conn = build_conn() |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + assert {:deny, _, _, _} = RateLimit.rate_limit_with_config(conn, config) + + Application.put_env( + :block_scout_web, + :recaptcha, + Keyword.put(Application.get_env(:block_scout_web, :recaptcha), :bypass_token, "test_token") + ) + + # Request with valid recaptcha + conn = + build_conn() + |> Map.put(:req_headers, [ + {"user-agent", "test-agent"}, + {"recaptcha-bypass-token", "test_token"} + ]) + + assert {:allow, count, limit, period} = RateLimit.rate_limit_with_config(conn, config) + assert count == 1 + assert limit == 1 + assert period == 70_000 + end + end + + describe "get_user_agent/1" do + test "returns user agent from headers" do + conn = build_conn() |> Map.put(:req_headers, [{"user-agent", "test-agent"}]) + assert RateLimit.get_user_agent(conn) == "test-agent" + end + + test "returns nil when user agent is not present" do + conn = build_conn() + assert RateLimit.get_user_agent(conn) == nil + end + end + + describe "rate_limit/4" do + setup do + original_config = Application.get_env(:block_scout_web, :api_rate_limit) + Application.put_env(:block_scout_web, :api_rate_limit, Keyword.put(original_config, :disabled, false)) + + on_exit(fn -> + Application.put_env(:block_scout_web, :api_rate_limit, original_config) + :ets.delete_all_objects(BlockScoutWeb.RateLimit.Hammer.ETS) + end) + end + + test "returns {:allow, count, limit, period} when under limit" do + key = "test_key" + period = 60_000 + limit = 100 + multiplier = 33 + + assert {:allow, count, ^limit, ^period} = RateLimit.rate_limit(key, period, limit, multiplier) + assert count == 33 + end + + test "returns {:deny, time_to_reset, limit, period} when over limit" do + key = "test_key" + period = 60_000 + limit = 1 + multiplier = 1 + + # First request + RateLimit.rate_limit(key, period, limit, multiplier) + + # Second request that should be denied + assert {:deny, time_to_reset, ^limit, ^period} = RateLimit.rate_limit(key, period, limit, multiplier) + assert time_to_reset > 0 + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/routers/chain_type_scope_test.exs b/apps/block_scout_web/test/block_scout_web/routers/chain_type_scope_test.exs index cc0f6ac215a3..426962e6c952 100644 --- a/apps/block_scout_web/test/block_scout_web/routers/chain_type_scope_test.exs +++ b/apps/block_scout_web/test/block_scout_web/routers/chain_type_scope_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Routers.ChainTypeScopeTest do use BlockScoutWeb.ConnCase use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -17,7 +18,7 @@ defmodule BlockScoutWeb.Routers.ChainTypeScopeTest do test "stability validators counters are accessible when chain type is stability", %{conn: conn} do Application.put_env(:explorer, :chain_type, :stability) - assert response = + assert _response = conn |> get("/api/v2/validators/stability/counters") |> json_response(200) @@ -34,17 +35,20 @@ defmodule BlockScoutWeb.Routers.ChainTypeScopeTest do test "blackfort validators counters are accessible when chain type is blackfort and stability is not", %{conn: conn} do + chain_type = Application.get_env(:explorer, :chain_type) Application.put_env(:explorer, :chain_type, :blackfort) - assert response = - conn - |> get("/api/v2/validators/blackfort/counters") - |> json_response(200) + on_exit(fn -> + Application.put_env(:explorer, :chain_type, chain_type) + end) - assert response = - conn - |> get("/api/v2/validators/stability/counters") - |> json_response(404) + assert conn + |> get("/api/v2/validators/blackfort/counters") + |> json_response(200) + + assert conn + |> get("/api/v2/validators/stability/counters") + |> json_response(404) end end end diff --git a/apps/block_scout_web/test/block_scout_web/social_media_test.exs b/apps/block_scout_web/test/block_scout_web/social_media_test.exs index 19243245b91d..5f552597b9b1 100644 --- a/apps/block_scout_web/test/block_scout_web/social_media_test.exs +++ b/apps/block_scout_web/test/block_scout_web/social_media_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SocialMediaTest do use Explorer.DataCase diff --git a/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs b/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs new file mode 100644 index 000000000000..f4cf42cff1f4 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/specs/public_legacy_tag_test.exs @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Specs.PublicLegacyTagTest do + use ExUnit.Case, async: true + + @legacy_paths [ + "/legacy/logs/get-logs", + "/legacy/block/get-block-number-by-time", + "/legacy/block/eth-block-number" + ] + + setup_all do + {:ok, spec: BlockScoutWeb.Specs.Public.spec()} + end + + for path <- @legacy_paths do + describe "path #{path}" do + test "exists in spec.paths", %{spec: spec} do + path = unquote(path) + assert Map.has_key?(spec.paths, path), "Expected path #{path} to be present in spec.paths" + end + + test "GET operation carries tags: [\"legacy\"]", %{spec: spec} do + path = unquote(path) + path_item = Map.fetch!(spec.paths, path) + operation = path_item.get + + assert operation != nil, "Expected a GET operation for #{path}" + assert operation.tags == ["legacy"], "Expected tags [\"legacy\"] on #{path}, got: #{inspect(operation.tags)}" + end + + test "GET operation has a declared 200 response", %{spec: spec} do + path = unquote(path) + path_item = Map.fetch!(spec.paths, path) + operation = path_item.get + + assert operation != nil, "Expected a GET operation for #{path}" + + response = Map.get(operation.responses, "200") || Map.get(operation.responses, 200) + + assert response != nil, + "Expected a 200 response on #{path}, got keys: #{inspect(Map.keys(operation.responses))}" + end + + test "GET 200 response has an application/json schema", %{spec: spec} do + path = unquote(path) + path_item = Map.fetch!(spec.paths, path) + operation = path_item.get + + assert operation != nil, "Expected a GET operation for #{path}" + + response = Map.get(operation.responses, "200") || Map.get(operation.responses, 200) + assert response != nil, "Expected a 200 response on #{path}" + + schema = get_in(response, [Access.key!(:content), "application/json", Access.key!(:schema)]) + assert schema != nil, "Expected an application/json schema in the 200 response of #{path}" + end + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs b/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs new file mode 100644 index 000000000000..296a0066a3fe --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/utility/rate_limit_config_helper_test.exs @@ -0,0 +1,307 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Utility.RateLimitConfigHelperTest do + use BlockScoutWeb.ConnCase, async: false + alias BlockScoutWeb.Utility.RateLimitConfigHelper + + describe "store_rate_limit_config/0" do + setup do + original_config_from_persistent_term = :persistent_term.get(:rate_limit_config) + + # Store original config URL + original_config = Application.get_env(:block_scout_web, :api_rate_limit) + + on_exit(fn -> + Application.put_env(:block_scout_web, :api_rate_limit, original_config) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + :persistent_term.put(:rate_limit_config, original_config_from_persistent_term) + end) + end + + test "successfully fetches and parses config from URL" do + config = %{ + "api/v2/*" => %{ + "static_api_key" => true, + "account_api_key" => true + } + } + + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + + assert :persistent_term.get(:rate_limit_config) == %{ + wildcard_match: %{ + {["api", "v2"], 2} => %{ + static_api_key: true, + account_api_key: true, + bucket_key_prefix: "" + } + }, + parametrized_match: %{}, + static_match: %{} + } + end + + test "falls back to local config when URL fetch fails" do + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 500, "Internal Server Error") + end) + + RateLimitConfigHelper.store_rate_limit_config() + # Verify that we got some config (from local file) + config = :persistent_term.get(:rate_limit_config) + assert is_map(config[:static_match]["default"]) + + assert config[:static_match]["api/account/v2/authenticate_via_wallet"][:bucket_key_prefix] == + "api/account/v2/authenticate_via_wallet_" + + assert config[:static_match]["api/account/v2/authenticate_via_wallet"][:isolate_rate_limit?] == true + end + + test "correctly categorizes different path types when fetching config" do + config = %{ + "api/v2/*" => %{"limit" => 100}, + "api/v2/tokens/:param" => %{"limit" => 50}, + "api/v2/static" => %{"limit" => 25} + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + result = :persistent_term.get(:rate_limit_config) + + assert result.wildcard_match == %{ + {["api", "v2"], 2} => %{limit: 100, bucket_key_prefix: ""} + } + + assert result.parametrized_match == %{ + ["api", "v2", "tokens", ":param"] => %{limit: 50, bucket_key_prefix: ""} + } + + assert result.static_match == %{ + "api/v2/static" => %{limit: 25, bucket_key_prefix: ""} + } + end + + test "falls back to local config when fetching config with invalid wildcard placement" do + config = %{ + "api/*/v2" => %{ + "ip" => %{ + "period" => "1h", + "limit" => 100 + } + } + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + assert is_map(:persistent_term.get(:rate_limit_config)[:static_match]["default"]) + end + + test "converts time strings to milliseconds when fetching config" do + config = %{ + "api/v2/endpoint" => %{ + "ip" => %{ + "period" => "5m", + "limit" => 100 + } + } + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + result = :persistent_term.get(:rate_limit_config) + + # 5 minutes in milliseconds + assert result.static_match["api/v2/endpoint"][:ip][:period] == 300_000 + # 100 requests per 5 minutes + assert result.static_match["api/v2/endpoint"][:ip][:limit] == 100 + end + + test "falls back to local config when fetching config with invalid time format" do + config = %{ + "api/v2/endpoint" => %{ + "ip" => %{ + "period" => "invalid" + } + } + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + assert is_map(:persistent_term.get(:rate_limit_config)[:static_match]["default"]) + end + + test "correctly processes all reserved keywords in configuration" do + config = %{ + "api/v2/endpoint" => %{ + "account_api_key" => true, + "bypass_token_scope" => "test_scope", + "cost" => 5, + "ip" => %{ + "period" => "1h", + "limit" => 100 + }, + "ignore" => true, + "recaptcha_to_bypass_429" => true, + "static_api_key" => true, + "temporary_token" => true, + "whitelisted_ip" => true, + "isolate_rate_limit?" => true + } + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + result = :persistent_term.get(:rate_limit_config) + + # Check that all keys are properly converted to atoms + processed_config = result.static_match["api/v2/endpoint"] + + assert processed_config[:account_api_key] == true + assert processed_config[:bypass_token_scope] == "test_scope" + assert processed_config[:cost] == 5 + # 1h in milliseconds + assert processed_config[:ip][:period] == 3_600_000 + assert processed_config[:ip][:limit] == 100 + assert processed_config[:ignore] == true + assert processed_config[:recaptcha_to_bypass_429] == true + assert processed_config[:static_api_key] == true + assert processed_config[:temporary_token] == true + assert processed_config[:whitelisted_ip] == true + assert processed_config[:isolate_rate_limit?] == true + assert processed_config[:bucket_key_prefix] == "api/v2/endpoint_" + end + + test "correctly processes nested structures with reserved keywords" do + config = %{ + "api/v2/tokens/:param" => %{ + "ip" => %{ + "period" => "5m", + "limit" => 50 + }, + "static_api_key" => %{ + "period" => "1h", + "limit" => 500 + }, + "whitelisted_ip" => %{ + "period" => "10m", + "limit" => 1000 + } + } + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + result = :persistent_term.get(:rate_limit_config) + + # Check processing of parametrized path with nested config + processed_config = result.parametrized_match[["api", "v2", "tokens", ":param"]] + + # Check nested IP config + # 5m in milliseconds + assert processed_config[:ip][:period] == 300_000 + assert processed_config[:ip][:limit] == 50 + + # Check nested static_api_key config + # 1h in milliseconds + assert processed_config[:static_api_key][:period] == 3_600_000 + assert processed_config[:static_api_key][:limit] == 500 + + # Check nested whitelisted_ip config + # 10m in milliseconds + assert processed_config[:whitelisted_ip][:period] == 600_000 + assert processed_config[:whitelisted_ip][:limit] == 1000 + end + + test "handles unsupported keywords gracefully" do + config = %{ + "api/v2/endpoint" => %{ + "unknown_keyword" => true, + "another_unknown" => "value", + "ip" => %{ + "period" => "1h", + "limit" => 100, + "unsupported_nested" => "test" + } + } + } + + bypass = Bypass.open() + Application.put_env(:block_scout_web, :api_rate_limit, config_url: "http://localhost:#{bypass.port}/config") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "GET", "/config", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(config)) + end) + + RateLimitConfigHelper.store_rate_limit_config() + result = :persistent_term.get(:rate_limit_config) + + processed_config = result.static_match["api/v2/endpoint"] + + # It should process the supported keywords while ignoring unsupported ones + # 1h in milliseconds + assert processed_config[:ip][:period] == 3_600_000 + assert processed_config[:ip][:limit] == 100 + + # Verify unsupported keywords are not included or are handled gracefully + assert processed_config["unknown_keyword"] == true + assert processed_config["another_unknown"] == "value" + assert processed_config[:ip]["unsupported_nested"] == "test" + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/abi_encoded_value_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/abi_encoded_value_view_test.exs index 9d29afe2449e..135ceae28c1b 100644 --- a/apps/block_scout_web/test/block_scout_web/views/abi_encoded_value_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/abi_encoded_value_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ABIEncodedValueViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/access_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/access_helper_test.exs deleted file mode 100644 index c18cb955e1d2..000000000000 --- a/apps/block_scout_web/test/block_scout_web/views/access_helper_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -defmodule BlockScoutWeb.AccessHelperTest do - alias BlockScoutWeb.AccessHelper - use BlockScoutWeb.ConnCase - import Mox - - setup :verify_on_exit! - - setup do - configuration = Application.get_env(:block_scout_web, :api_rate_limit) - - on_exit(fn -> - Application.put_env(:block_scout_web, :api_rate_limit, configuration) - end) - - :ok - end - - describe "check_rate_limit/1" do - test "rate_limit_disabled", %{conn: conn} do - Application.put_env(:block_scout_web, :api_rate_limit, - global_limit: 0, - limit_by_key: 0, - limit_by_whitelisted_ip: 0, - time_interval_limit: 1_000, - disabled: true - ) - - assert AccessHelper.check_rate_limit(conn) == :ok - end - - test "no_rate_limit_api_key", %{conn: conn} do - Application.put_env(:block_scout_web, :api_rate_limit, - global_limit: 0, - limit_by_key: 0, - limit_by_whitelisted_ip: 0, - time_interval_limit: 1_000, - no_rate_limit_api_key: "123" - ) - - conn = %{conn | query_params: %{"apikey" => "123"}} - assert AccessHelper.check_rate_limit(conn) == :ok - end - - test "rate limit, if no_rate_limit_api_key is nil", %{conn: conn} do - Application.put_env(:block_scout_web, :api_rate_limit, - global_limit: 0, - limit_by_key: 0, - limit_by_whitelisted_ip: 0, - time_interval_limit: 1_000, - no_rate_limit_api_key: nil - ) - - conn = %{conn | query_params: %{"apikey" => nil}} - assert AccessHelper.check_rate_limit(conn) == :rate_limit_reached - end - - test "rate limit, if no_rate_limit_api_key is empty", %{conn: conn} do - Application.put_env(:block_scout_web, :api_rate_limit, - global_limit: 0, - limit_by_key: 0, - limit_by_whitelisted_ip: 0, - time_interval_limit: 1_000, - no_rate_limit_api_key: "" - ) - - conn = %{conn | query_params: %{"apikey" => " "}} - assert AccessHelper.check_rate_limit(conn) == :rate_limit_reached - end - end -end diff --git a/apps/block_scout_web/test/block_scout_web/views/address_coin_balance_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/address_coin_balance_view_test.exs index bc07e1c4feeb..809df3f89088 100644 --- a/apps/block_scout_web/test/block_scout_web/views/address_coin_balance_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/address_coin_balance_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressCoinBalanceViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/address_contract_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/address_contract_view_test.exs index e483bfc7ea65..c39d540da3c3 100644 --- a/apps/block_scout_web/test/block_scout_web/views/address_contract_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/address_contract_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressContractViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs index 778c1eb0364b..49ed78c4b374 100644 --- a/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/address_token_balance_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTokenBalanceViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/address_transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/address_transaction_view_test.exs index 666f06bc86ac..f63ce78858c3 100644 --- a/apps/block_scout_web/test/block_scout_web/views/address_transaction_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/address_transaction_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressTransactionViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/address_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/address_view_test.exs index db7ddbe10bf3..703e281896ec 100644 --- a/apps/block_scout_web/test/block_scout_web/views/address_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/address_view_test.exs @@ -1,7 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.AddressViewTest do use BlockScoutWeb.ConnCase, async: true - alias Explorer.Chain.{Address, Data, Hash, Transaction} + alias Explorer.Chain.{Address, Hash, Transaction} alias BlockScoutWeb.{AddressView, Endpoint} describe "address_partial_selector/4" do @@ -17,10 +18,10 @@ defmodule BlockScoutWeb.AddressViewTest do insert(:internal_transaction, index: 1, transaction: transaction, + transaction_index: transaction.index, to_address: nil, - created_contract_address_hash: nil, - block_hash: transaction.block_hash, - block_index: 1 + created_contract_address: nil, + block_number: transaction.block_number ) assert "Contract Address Pending" == AddressView.address_partial_selector(internal_transaction, :to, nil) @@ -341,7 +342,7 @@ defmodule BlockScoutWeb.AddressViewTest do describe "address_page_title/1" do test "uses the Smart Contract name when the contract is verified" do smart_contract = build(:smart_contract, name: "POA") - address = build(:address, smart_contract: smart_contract) + address = build(:address, smart_contract: smart_contract, verified: true) assert AddressView.address_page_title(address) == "POA (#{address})" end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/address_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/address_view_test.exs new file mode 100644 index 000000000000..58da6ddc8a44 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/address_view_test.exs @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.AddressViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.AddressView + alias Explorer.Chain.Wei + + describe "prepare_coin_balance_history_entry/1" do + test "returns map with expected keys and values" do + block_timestamp = ~U[2023-01-01 00:00:00Z] + delta = Decimal.new(100) + value = Decimal.new(200) + + coin_balance = %{ + transaction_hash: nil, + block_number: 10, + delta: delta, + value: value, + block_timestamp: block_timestamp + } + + result = AddressView.prepare_coin_balance_history_entry(coin_balance) + + assert result["transaction_hash"] == nil + assert result["block_number"] == 10 + assert Decimal.equal?(result["delta"], delta) + assert Decimal.equal?(result["value"], value) + assert result["block_timestamp"] == block_timestamp + end + end + + describe "prepare_coin_balance_history_by_day_entry/1" do + test "returns date and value" do + value = Decimal.new(500) + coin_balance_by_day = %{date: ~D[2023-01-01], value: value} + + result = AddressView.prepare_coin_balance_history_by_day_entry(coin_balance_by_day) + + assert result["date"] == ~D[2023-01-01] + assert Decimal.equal?(result["value"], value) + end + end + + describe "prepare_address_for_list/1" do + test "includes coin balance and transaction count" do + address = + build(:address, + fetched_coin_balance: %Wei{value: Decimal.new(100)}, + transactions_count: 5 + ) + + result = AddressView.prepare_address_for_list(address) + + assert Decimal.equal?(result[:coin_balance], Decimal.new(100)) + assert result[:transactions_count] == "5" + assert Map.has_key?(result, "hash") + end + + test "sets coin balance to nil when fetched coin balance is missing" do + address = build(:address, fetched_coin_balance: nil, transactions_count: 3) + + result = AddressView.prepare_address_for_list(address) + + assert result[:coin_balance] == nil + assert result[:transactions_count] == "3" + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/api_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/api_view_test.exs new file mode 100644 index 000000000000..decc754b29b5 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/api_view_test.exs @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.ApiViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.ApiView + + describe "render/2" do + test "renders message.json" do + assert %{"message" => "ok"} = ApiView.render("message.json", %{message: "ok"}) + end + + test "renders smart_contract_audit_report_changeset_errors.json" do + changeset = + {%{}, %{audit_report_url: :string}} + |> Ecto.Changeset.cast(%{}, []) + |> Ecto.Changeset.add_error(:audit_report_url, "can't be blank") + + result = + ApiView.render("smart_contract_audit_report_changeset_errors.json", %{changeset: changeset}) + + assert result["message"] == "Error on inserting audit report" + assert is_map(result["errors"]) + assert map_size(result["errors"]) > 0 + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/block_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/block_view_test.exs new file mode 100644 index 000000000000..e1f4e9a6c19b --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/block_view_test.exs @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.BlockViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.BlockView + alias Explorer.Repo + + describe "burnt_fees_percentage/2" do + test "returns nil when transaction fees are zero" do + assert BlockView.burnt_fees_percentage(Decimal.new(50), Decimal.new(0)) == nil + end + + test "returns nil when transaction fees are nil" do + assert BlockView.burnt_fees_percentage(Decimal.new(50), nil) == nil + end + + test "returns nil when burnt fees are nil" do + assert BlockView.burnt_fees_percentage(nil, Decimal.new(100)) == nil + end + + test "returns percentage for valid values" do + assert BlockView.burnt_fees_percentage(Decimal.new(50), Decimal.new(100)) == 50.0 + end + end + + describe "render/2" do + test "renders block_countdown.json" do + result = + BlockView.render("block_countdown.json", %{ + current_block: 100, + countdown_block: 200, + remaining_blocks: 100, + estimated_time_in_sec: 1200 + }) + + assert result.current_block_number == 100 + assert result.countdown_block_number == 200 + assert result.remaining_blocks_count == 100 + assert result.estimated_time_in_seconds == "1200" + end + end + + describe "prepare_block/3" do + test "returns expected block fields" do + block = + insert(:block) + |> Repo.preload([:miner, :uncle_relations, :rewards, :withdrawals, :internal_transactions, :transactions]) + + result = BlockView.prepare_block(block, nil) + + assert result["height"] == block.number + assert result["hash"] == block.hash + assert result["transactions_count"] == 0 + assert result["uncles_hashes"] == [] + assert result["rewards"] == [] + assert result["withdrawals_count"] == 0 + assert is_map(result["miner"]) + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/config_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/config_view_test.exs new file mode 100644 index 000000000000..552a16e20998 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/config_view_test.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.ConfigViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.ConfigView + + test "renders backend_version.json" do + result = ConfigView.render("backend_version.json", %{version: "1.2.3"}) + + assert result == %{"backend_version" => "1.2.3"} + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/internal_transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/internal_transaction_view_test.exs new file mode 100644 index 000000000000..9ef030e0bc0a --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/internal_transaction_view_test.exs @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.InternalTransactionViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.InternalTransactionView + alias Explorer.Chain.{InternalTransaction, Wei} + + describe "prepare_internal_transaction/2" do + test "returns expected fields for a successful call" do + from_address = build(:address) + to_address = build(:address) + + internal_transaction = %InternalTransaction{ + error: nil, + type: :call, + call_type: :call, + transaction_hash: nil, + transaction_index: 0, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash, + created_contract_address: nil, + created_contract_address_hash: nil, + value: Wei.zero(), + block_number: 10, + block: nil, + index: 0, + gas: Decimal.new(21_000) + } + + result = InternalTransactionView.prepare_internal_transaction(internal_transaction, nil) + expected_type = InternalTransaction.call_type(internal_transaction) || internal_transaction.type + + assert result["success"] == true + assert result["error"] == nil + assert result["type"] == expected_type + assert result["block_number"] == 10 + assert result["index"] == 0 + assert Decimal.equal?(result["gas_limit"], Decimal.new(21_000)) + assert is_map(result["from"]) + assert is_map(result["to"]) + end + + test "returns success false when error is present" do + from_address = build(:address) + to_address = build(:address) + + internal_transaction = %InternalTransaction{ + error: "reverted", + type: :call, + call_type: :call, + transaction_hash: nil, + transaction_index: 1, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash, + created_contract_address: nil, + created_contract_address_hash: nil, + value: Wei.zero(), + block_number: 11, + block: nil, + index: 1, + gas: Decimal.new(22_000) + } + + result = InternalTransactionView.prepare_internal_transaction(internal_transaction, nil) + + assert result["success"] == false + assert result["error"] == "reverted" + end + + test "uses provided block timestamp when block is passed" do + from_address = build(:address) + to_address = build(:address) + block = build(:block) + + internal_transaction = %InternalTransaction{ + error: nil, + type: :call, + call_type: :call, + transaction_hash: nil, + transaction_index: 2, + from_address: from_address, + from_address_hash: from_address.hash, + to_address: to_address, + to_address_hash: to_address.hash, + created_contract_address: nil, + created_contract_address_hash: nil, + value: Wei.zero(), + block_number: 12, + block: nil, + index: 2, + gas: Decimal.new(23_000) + } + + result = InternalTransactionView.prepare_internal_transaction(internal_transaction, block) + + assert result["timestamp"] == block.timestamp + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/search_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/search_view_test.exs new file mode 100644 index 000000000000..24ab2c8e43d3 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/search_view_test.exs @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.SearchViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.SearchView + alias Explorer.Chain.Address + + describe "render search_results.json" do + test "renders search results list and encoded next_page_params" do + search_result = %{ + type: "token", + name: "Token", + symbol: "TKN", + address_hash: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + icon_url: "https://example.com/token.png", + token_type: "ERC-20", + verified: true, + exchange_rate: Decimal.new("1.5"), + total_supply: Decimal.new("1000"), + circulating_market_cap: Decimal.new("1500"), + is_verified_via_admin_panel: false, + certified: nil, + priority: 1, + reputation: "ok", + is_smart_contract_address: true + } + + result = + SearchView.render("search_results.json", %{ + search_results: [search_result], + next_page_params: %{"q" => "token", "type" => ""} + }) + + assert [item] = result["items"] + assert item["type"] == "token" + assert item["name"] == "Token" + assert item["exchange_rate"] == "1.5" + assert item["certified"] == false + assert item["token_url"] =~ "/token/0xdAC17F958D2ee523a2206206994597C13D831ec7" + assert item["address_url"] =~ "/address/0xdAC17F958D2ee523a2206206994597C13D831ec7" + + assert result["next_page_params"] == %{"q" => "token", "type" => nil} + end + + test "renders redirect payload for successful lookup" do + address = build(:address) + + result = SearchView.render("search_results.json", %{result: {:ok, address}}) + + assert result["redirect"] == true + assert result["type"] == "address" + assert result["parameter"] == Address.checksum(address.hash) + end + + test "renders not found payload" do + assert SearchView.render("search_results.json", %{result: {:error, :not_found}}) == + %{"redirect" => false, "type" => nil, "parameter" => nil} + end + end + + describe "prepare_search_result/1" do + test "renders transaction search result" do + transaction = build(:transaction) + + result = + SearchView.prepare_search_result(%{ + type: "transaction", + transaction_hash: transaction.hash, + timestamp: ~U[2024-01-01 00:00:00Z], + priority: 2 + }) + + assert result["type"] == "transaction" + assert result["transaction_hash"] == to_string(transaction.hash) + assert result["url"] =~ "/tx/" + assert result["priority"] == 2 + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/stats_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/stats_view_test.exs new file mode 100644 index 000000000000..0acab2964a6f --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/stats_view_test.exs @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.StatsViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.StatsView + alias Explorer.Chain.Wei + + test "renders hot_smart_contracts.json" do + contract_address = + build(:address, + fetched_coin_balance: %Wei{value: Decimal.new(500)} + ) + + hot_contract = %{ + contract_address: contract_address, + contract_address_hash: contract_address.hash, + transactions_count: 12, + total_gas_used: 34 + } + + result = + StatsView.render("hot_smart_contracts.json", %{ + hot_smart_contracts: [hot_contract], + next_page_params: %{"items_count" => 50} + }) + + assert result.next_page_params == %{"items_count" => 50} + assert [item] = result.items + assert item.transactions_count == "12" + assert item.total_gas_used == "34" + assert Decimal.equal?(item.balance, Decimal.new(500)) + assert is_map(item.contract_address) + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/token_transfer_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/token_transfer_view_test.exs new file mode 100644 index 000000000000..12171776864b --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/token_transfer_view_test.exs @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.TokenTransferViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.TokenTransferView + alias Explorer.Chain.TokenTransfer + + describe "prepare_token_transfer_total/1" do + test "returns value and decimals for ERC-20 transfer" do + token = build(:token, type: "ERC-20", decimals: 18) + + token_transfer = %TokenTransfer{ + token: token, + token_type: "ERC-20", + amount: Decimal.new(1000), + amounts: nil, + token_ids: nil, + token_instance: nil + } + + result = TokenTransferView.prepare_token_transfer_total(token_transfer) + + assert Decimal.equal?(result["value"], Decimal.new(1000)) + assert result["decimals"] == 18 + end + + test "returns token_id for ERC-721 transfer" do + token = build(:token, type: "ERC-721") + + token_transfer = %TokenTransfer{ + token: token, + token_type: "ERC-721", + amount: nil, + amounts: nil, + token_ids: [42], + token_instance: nil + } + + result = TokenTransferView.prepare_token_transfer_total(token_transfer) + + assert result["token_id"] == 42 + assert Map.has_key?(result, "token_instance") + end + + test "returns token_id, value and decimals for ERC-1155 transfer" do + token = build(:token, type: "ERC-1155", decimals: 0) + + token_transfer = %TokenTransfer{ + token: token, + token_type: "ERC-1155", + amount: Decimal.new(5), + amounts: nil, + token_ids: [99], + token_instance: nil + } + + result = TokenTransferView.prepare_token_transfer_total(token_transfer) + + assert result["token_id"] == 99 + assert Decimal.equal?(result["value"], Decimal.new(5)) + assert result["decimals"] == 0 + assert Map.has_key?(result, "token_instance") + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs new file mode 100644 index 000000000000..635f94516377 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/token_view_test.exs @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.TokenViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.TokenView + + describe "exchange_rate/1" do + test "returns string when fiat_value exists" do + assert TokenView.exchange_rate(%{fiat_value: Decimal.new("1.5")}) == "1.5" + end + + test "returns nil when fiat_value is nil" do + assert TokenView.exchange_rate(%{fiat_value: nil}) == nil + end + end + + describe "render token.json" do + test "renders token fields" do + token = insert(:token) + result = TokenView.render("token.json", %{token: token}) + + assert result["symbol"] == token.symbol + assert result["name"] == token.name + assert result["type"] == token.type + assert result["decimals"] == token.decimals + assert Map.has_key?(result, "address_hash") + assert Map.has_key?(result, "holders_count") + end + + test "returns nil for nil token" do + assert TokenView.render("token.json", %{token: nil}) == nil + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs index a5ff0b18f6b9..0fbb48af2fb7 100644 --- a/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/transaction_view_test.exs @@ -1,7 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.API.V2.TransactionViewTest do use BlockScoutWeb.ConnCase, async: true alias BlockScoutWeb.API.V2.TransactionView + alias Explorer.Repo + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation describe "decode_logs/2" do test "doesn't use decoding candidate event with different 2nd, 3d or 4th topic" do @@ -54,7 +57,11 @@ defmodule BlockScoutWeb.API.V2.TransactionViewTest do data: log2_data ) - logs = [log1, log2] + logs = + [log1, log2] + |> Repo.preload( + address: [:names, :smart_contract, Implementation.proxy_implementations_smart_contracts_association()] + ) assert [ {:ok, "d20a68b2", @@ -129,7 +136,11 @@ defmodule BlockScoutWeb.API.V2.TransactionViewTest do data: log2_data ) - logs = [log1, log2] + logs = + [log1, log2] + |> Repo.preload( + address: [:names, :smart_contract, Implementation.proxy_implementations_smart_contracts_association()] + ) assert [ {:ok, "d20a68b2", diff --git a/apps/block_scout_web/test/block_scout_web/views/api/v2/withdrawal_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api/v2/withdrawal_view_test.exs new file mode 100644 index 000000000000..510ffbc35ac4 --- /dev/null +++ b/apps/block_scout_web/test/block_scout_web/views/api/v2/withdrawal_view_test.exs @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.API.V2.WithdrawalViewTest do + use BlockScoutWeb.ConnCase, async: true + + alias BlockScoutWeb.API.V2.WithdrawalView + alias Explorer.Chain.Withdrawal + + describe "prepare_withdrawal/1" do + test "returns full map when block and address are loaded" do + withdrawal = build(:withdrawal) + + result = WithdrawalView.prepare_withdrawal(withdrawal) + + assert result["index"] == withdrawal.index + assert result["validator_index"] == withdrawal.validator_index + assert result["block_number"] == withdrawal.block.number + assert is_map(result["receiver"]) + assert result["amount"] == withdrawal.amount + assert result["timestamp"] == withdrawal.block.timestamp + end + + test "returns map without block fields when block is not loaded" do + address = build(:address) + + withdrawal = %Withdrawal{ + index: 1, + validator_index: 2, + amount: 100, + block: %Ecto.Association.NotLoaded{}, + address: address, + address_hash: address.hash + } + + result = WithdrawalView.prepare_withdrawal(withdrawal) + + assert result["index"] == 1 + assert result["validator_index"] == 2 + assert is_map(result["receiver"]) + refute Map.has_key?(result, "block_number") + refute Map.has_key?(result, "timestamp") + end + + test "returns map without receiver when address is not loaded" do + block = build(:block) + address = build(:address) + + withdrawal = %Withdrawal{ + index: 3, + validator_index: 4, + amount: 200, + block: block, + block_hash: block.hash, + address: %Ecto.Association.NotLoaded{}, + address_hash: address.hash + } + + result = WithdrawalView.prepare_withdrawal(withdrawal) + + assert result["index"] == 3 + assert result["validator_index"] == 4 + assert result["block_number"] == block.number + assert result["timestamp"] == block.timestamp + refute Map.has_key?(result, "receiver") + end + end +end diff --git a/apps/block_scout_web/test/block_scout_web/views/api_docs_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/api_docs_view_test.exs index 1a56d7ef6c31..382134ef0a3f 100644 --- a/apps/block_scout_web/test/block_scout_web/views/api_docs_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/api_docs_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ApiDocsViewTest do use BlockScoutWeb.ConnCase, async: false diff --git a/apps/block_scout_web/test/block_scout_web/views/block_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/block_view_test.exs index eea5baf28420..2d3235f2fbfb 100644 --- a/apps/block_scout_web/test/block_scout_web/views/block_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/block_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.BlockViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/currency_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/currency_helper_test.exs index 432d34eb02a1..1f62e5952b82 100644 --- a/apps/block_scout_web/test/block_scout_web/views/currency_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/currency_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.CurrencyHelperTest do use ExUnit.Case diff --git a/apps/block_scout_web/test/block_scout_web/views/error_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/error_helper_test.exs index 7aac3cffee7d..e59988f8891c 100644 --- a/apps/block_scout_web/test/block_scout_web/views/error_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/error_helper_test.exs @@ -1,6 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ErrorHelperTest do use BlockScoutWeb.ConnCase, async: true - import Phoenix.HTML.Tag, only: [content_tag: 3] + import Phoenix.HTML + import Phoenix.HTML.Form + use PhoenixHTMLHelpers alias BlockScoutWeb.ErrorHelper diff --git a/apps/block_scout_web/test/block_scout_web/views/error_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/error_view_test.exs index 6babf7c0e26b..b8469baec089 100644 --- a/apps/block_scout_web/test/block_scout_web/views/error_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/error_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ErrorViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/internal_transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/internal_transaction_view_test.exs index 987f89f76e55..818671bb4208 100644 --- a/apps/block_scout_web/test/block_scout_web/views/internal_transaction_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/internal_transaction_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.InternalTransactionViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/layout_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/layout_view_test.exs index b194934a5f09..b650f0c6f95a 100644 --- a/apps/block_scout_web/test/block_scout_web/views/layout_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/layout_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.LayoutViewTest do use BlockScoutWeb.ConnCase diff --git a/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs index 3af434c0a42b..71affd17f4e0 100644 --- a/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/nft_helper_test.exs @@ -1,8 +1,89 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.NFTHelperTest do use BlockScoutWeb.ConnCase, async: true alias BlockScoutWeb.NFTHelper + describe "get_media_src/2" do + test "returns nil when metadata is nil" do + assert NFTHelper.get_media_src(nil, true) == nil + assert NFTHelper.get_media_src(nil, false) == nil + end + + test "returns animation_url when present and high_quality_media? is true" do + metadata = %{"animation_url" => "https://example.com/animation.mp4"} + assert NFTHelper.get_media_src(metadata, true) == "https://example.com/animation.mp4" + end + + test "falls through to image_url when animation_url is present but high_quality_media? is false" do + metadata = %{ + "animation_url" => "https://example.com/animation.mp4", + "image_url" => "https://example.com/image.png" + } + + assert NFTHelper.get_media_src(metadata, false) == "https://example.com/image.png" + end + + test "falls through to image when animation_url present, high_quality_media? false, and no image_url" do + metadata = %{ + "animation_url" => "https://example.com/animation.mp4", + "image" => "https://example.com/static.png" + } + + assert NFTHelper.get_media_src(metadata, false) == "https://example.com/static.png" + end + + test "returns image_url when present (and no animation_url or high_quality off)" do + metadata = %{"image_url" => "https://example.com/image.png"} + assert NFTHelper.get_media_src(metadata, true) == "https://example.com/image.png" + assert NFTHelper.get_media_src(metadata, false) == "https://example.com/image.png" + end + + test "returns image when present and image_url is not" do + metadata = %{"image" => "https://example.com/fallback.png"} + assert NFTHelper.get_media_src(metadata, true) == "https://example.com/fallback.png" + end + + test "returns properties.image description when properties.image is a map" do + metadata = %{ + "properties" => %{ + "image" => %{"description" => "https://example.com/props-image.png"} + } + } + + assert NFTHelper.get_media_src(metadata, true) == "https://example.com/props-image.png" + end + + test "returns properties.image as string when properties.image is not a map" do + metadata = %{ + "properties" => %{"image" => "https://example.com/props-string.png"} + } + + assert NFTHelper.get_media_src(metadata, true) == "https://example.com/props-string.png" + end + + test "returns nil when no image source is present" do + metadata = %{"name" => "NFT", "description" => "No image"} + assert NFTHelper.get_media_src(metadata, true) == nil + assert NFTHelper.get_media_src(metadata, false) == nil + end + + test "returns nil when result is empty string after trim" do + metadata = %{"properties" => %{"image" => " "}} + assert NFTHelper.get_media_src(metadata, true) == nil + end + + test "returns nil when properties exists but image key is missing" do + metadata = %{"properties" => %{"name" => "something"}} + assert NFTHelper.get_media_src(metadata, true) == nil + end + + test "returns nil when properties is a list" do + metadata = %{"properties" => []} + assert NFTHelper.get_media_src(metadata, true) == nil + end + end + describe "compose_resource_url/1" do test "transforms ipfs link like ipfs://${id}" do url = "ipfs://QmYFf7D2UtqnNz8Lu57Gnk3dxgdAiuboPWMEaNNjhr29tS/hidden.png" diff --git a/apps/block_scout_web/test/block_scout_web/views/render_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/render_helper_test.exs index 491d2fbcae22..33753a0726ae 100644 --- a/apps/block_scout_web/test/block_scout_web/views/render_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/render_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.RenderHelperTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/search_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/search_view_test.exs index 8bc64add9f30..f3b1a9d4b1ae 100644 --- a/apps/block_scout_web/test/block_scout_web/views/search_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/search_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SearchViewTest do use ExUnit.Case alias BlockScoutWeb.SearchView diff --git a/apps/block_scout_web/test/block_scout_web/views/smart_contract_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/smart_contract_view_test.exs index cb63435730b2..a1d330dfdb38 100644 --- a/apps/block_scout_web/test/block_scout_web/views/smart_contract_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/smart_contract_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SmartContractViewTest do use ExUnit.Case diff --git a/apps/block_scout_web/test/block_scout_web/views/tab_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/tab_helper_test.exs index 8cfe4e036e57..f4fea0d4a946 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tab_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tab_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TabHelperTest do use ExUnit.Case diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/helper_test.exs index bcf41d9d4660..c297a5d94634 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.HelperTest do use BlockScoutWeb.ConnCase, async: true @@ -32,6 +33,13 @@ defmodule BlockScoutWeb.Tokens.HelperTest do assert Helper.token_transfer_amount(token_transfer) == {:ok, :erc721_instance} end + test "returns '*confidential*' with ERC-7984 token" do + token = build(:token, type: "ERC-7984", decimals: Decimal.new(18)) + token_transfer = build(:token_transfer, token: token, amount: Decimal.new(1_000_000), token_type: "ERC-7984") + + assert Helper.token_transfer_amount(token_transfer) == {:ok, "*confidential*"} + end + test "returns nothing for unknown token's type" do token = build(:token, type: "unknown") token_transfer = build(:token_transfer, token: token, token_type: "unknown") diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/holder_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/holder_view_test.exs index 6b5c6c853dd8..166dc3eafc9c 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/holder_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/holder_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.HolderViewTest do use BlockScoutWeb.ConnCase, async: true @@ -70,5 +71,12 @@ defmodule BlockScoutWeb.Tokens.HolderViewTest do assert HolderView.format_token_balance_value(token_balance.value, nil, token) == 1 end + + test "returns '*confidential*' for ERC-7984 tokens" do + token = build(:token, type: "ERC-7984", decimals: Decimal.new(18)) + token_balance = build(:token_balance, value: 1_000_000) + + assert HolderView.format_token_balance_value(token_balance.value, nil, token) == "*confidential*" + end end end diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs index ec01e6a2070b..54c8de8613ed 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/instance/overview_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.Instance.OverviewViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs index 45f691023677..2d6a81af8db7 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.OverviewViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/read_contract_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/read_contract_view_test.exs index 3f567cb6a0d6..6b1613acd727 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/read_contract_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/read_contract_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.ReadContractViewTest do use BlockScoutWeb.ConnCase, async: true end diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs index cefba8b98cdf..c67de5594593 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/smart_contract_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.SmartContractViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs index 6b320385453a..b668bd75a471 100644 --- a/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/tokens/transfer_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.Tokens.TransferViewTest do use BlockScoutWeb.ConnCase, async: true end diff --git a/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs index ea53a2a970a7..e1b5a1a10e4c 100644 --- a/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/transaction_view_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.TransactionViewTest do use BlockScoutWeb.ConnCase, async: true diff --git a/apps/block_scout_web/test/block_scout_web/views/wei_helper_test.exs b/apps/block_scout_web/test/block_scout_web/views/wei_helper_test.exs index e2b0425add04..56f8ae8f4e7c 100644 --- a/apps/block_scout_web/test/block_scout_web/views/wei_helper_test.exs +++ b/apps/block_scout_web/test/block_scout_web/views/wei_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.WeiHelperTest do use ExUnit.Case diff --git a/apps/block_scout_web/test/phoenix/param/explorer/chain/block_test.exs b/apps/block_scout_web/test/phoenix/param/explorer/chain/block_test.exs index 38ff7d5e5456..46f66139877c 100644 --- a/apps/block_scout_web/test/phoenix/param/explorer/chain/block_test.exs +++ b/apps/block_scout_web/test/phoenix/param/explorer/chain/block_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Phoenix.Param.Explorer.Chain.BlockTest do use ExUnit.Case diff --git a/apps/block_scout_web/test/support/api_schema_assertions.ex b/apps/block_scout_web/test/support/api_schema_assertions.ex new file mode 100644 index 000000000000..0f2a42de5979 --- /dev/null +++ b/apps/block_scout_web/test/support/api_schema_assertions.ex @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.TestApiSchemaAssertions do + @moduledoc """ + Test helper that automatically validates JSON responses against the OpenAPI schema + for every GET request to `/api/*` endpoints. + + It wraps `Phoenix.ConnTest.json_response/2` to perform schema validation using + `OpenApiSpex.TestAssertions.assert_schema/3` based on the current request path, + HTTP method and status code. + """ + + require Logger + alias OpenApiSpex.{Operation, PathItem} + + @spec json_response(Plug.Conn.t(), non_neg_integer()) :: map() | list() + def json_response(%Plug.Conn{} = conn, status_code) when is_integer(status_code) do + json = Phoenix.ConnTest.json_response(conn, status_code) + + maybe_assert_schema(conn, status_code, json) + + json + end + + defp maybe_assert_schema(%Plug.Conn{method: method, request_path: request_path} = _conn, status_code, json) + when is_integer(status_code) and is_binary(request_path) do + method_atom = String.downcase(method) |> String.to_atom() + public_spec = BlockScoutWeb.Specs.Public.spec() + private_spec = BlockScoutWeb.Specs.Private.spec() + + with {:path_item, {:ok, spec, %PathItem{} = path_item}} <- + {:path_item, find_path_item([public_spec, private_spec], request_path)}, + {:operation, %Operation{} = operation} <- {:operation, Map.get(path_item, method_atom)}, + {:schema, {:ok, schema}} <- {:schema, find_response_schema(operation, status_code)} do + Logger.info("Validated response against schema for path: #{request_path} and status code: #{status_code}") + + OpenApiSpex.TestAssertions.assert_raw_schema(json, schema, spec) + else + {:path_item, :error} -> + Logger.warning("No schema found for path: #{request_path}") + :ok + + {:operation, _} -> + Logger.warning("No #{method} operation found for path: #{request_path}") + :ok + + {:schema, :error} -> + Logger.warning("No schema found for path: #{request_path} and status code: #{status_code}") + :ok + end + end + + defp maybe_assert_schema(_conn, _status_code, _json), do: :ok + + defp find_path_item(specs, request_path) do + api_relative = strip_api_prefix(request_path) + + Enum.reduce_while(specs, :error, fn %{paths: paths} = spec, acc -> + case match_template_path(paths, api_relative) do + {:ok, {_, path_item}} -> {:halt, {:ok, spec, path_item}} + _ -> {:cont, acc} + end + end) + end + + defp strip_api_prefix("/api" <> rest), do: rest + defp strip_api_prefix(path), do: path + + defp match_template_path(paths_map, actual_path) do + actual_segments = split_path(actual_path) + + # The OpenAPI spec paths map is unordered, so when multiple templates match + # the same request path, Enum.find_value would pick one non-deterministically. + # This matters because the router has many sibling routes where a literal + # segment coexists with a dynamic parameter at the same position, e.g.: + # + # GET /batches/count → :batches_count + # GET /batches/{batch_number} → :batch + # + # Phoenix resolves these by declaration order (literal first), but here we + # match against the spec map, where both templates satisfy segments_match?. + # To mirror Phoenix's behaviour we prefer the template with the fewest + # dynamic segments — the more specific (literal) path always wins. + paths_map + |> Enum.filter(fn {template_path, %PathItem{}} -> + segments_match?(split_path(template_path), actual_segments) + end) + |> Enum.min_by( + fn {template_path, _} -> + segments = split_path(template_path) + dynamic_count = Enum.count(segments, &dynamic_segment?/1) + {dynamic_count, length(segments), template_path} + end, + fn -> nil end + ) + |> case do + {_, %PathItem{}} = match -> {:ok, match} + nil -> :error + end + end + + defp split_path(path) when is_binary(path) do + path + |> String.split("?", parts: 2) + |> hd() + |> String.split("/", trim: true) + end + + defp segments_match?(template_segments, actual_segments) when length(template_segments) == length(actual_segments) do + Enum.zip(template_segments, actual_segments) + |> Enum.all?(fn {t, a} -> dynamic_segment?(t) or t == a end) + end + + defp segments_match?(_template_segments, _actual_segments), do: false + + defp dynamic_segment?(segment) when is_binary(segment) do + String.starts_with?(segment, "{") and String.ends_with?(segment, "}") + end + + defp find_response_schema(%Operation{responses: responses}, status_code) when is_map(responses) do + key = Integer.to_string(status_code) + + response = Map.get(responses, key) || Map.get(responses, status_code) || Map.get(responses, "default") + + case response do + %OpenApiSpex.Response{content: %{"application/json" => %OpenApiSpex.MediaType{schema: schema}}} -> + {:ok, schema} + + %OpenApiSpex.Reference{} = ref -> + {:ok, ref} + + _ -> + :error + end + end +end diff --git a/apps/block_scout_web/test/support/channel_case.ex b/apps/block_scout_web/test/support/channel_case.ex index c0a7f4939a5b..27f9caeeaa52 100644 --- a/apps/block_scout_web/test/support/channel_case.ex +++ b/apps/block_scout_web/test/support/channel_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ChannelCase do @moduledoc """ This module defines the test case to be used by @@ -31,7 +32,7 @@ defmodule BlockScoutWeb.ChannelCase do setup tags do _ = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo) - unless tags[:async] do + if !tags[:async] do Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, {:shared, self()}) end diff --git a/apps/block_scout_web/test/support/conn_case.ex b/apps/block_scout_web/test/support/conn_case.ex index d122090b4a20..fb77f53c4f23 100644 --- a/apps/block_scout_web/test/support/conn_case.ex +++ b/apps/block_scout_web/test/support/conn_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.ConnCase do @moduledoc """ This module defines the test case to be used by @@ -19,7 +20,8 @@ defmodule BlockScoutWeb.ConnCase do quote do # Import conveniences for testing with connections import Plug.Conn - import Phoenix.ConnTest + import Phoenix.ConnTest, except: [json_response: 2] + import BlockScoutWeb.TestApiSchemaAssertions, only: [json_response: 2] import BlockScoutWeb.Router.Helpers import BlockScoutWeb.Routers.WebRouter.Helpers, except: [static_path: 2] import BlockScoutWeb.Routers.AccountRouter.Helpers, except: [static_path: 2] @@ -40,7 +42,7 @@ defmodule BlockScoutWeb.ConnCase do :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo.Account) - unless tags[:async] do + if !tags[:async] do Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, {:shared, self()}) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Account, {:shared, self()}) end diff --git a/apps/block_scout_web/test/support/feature_case.ex b/apps/block_scout_web/test/support/feature_case.ex index f6b1aedf2412..b11e72069d6c 100644 --- a/apps/block_scout_web/test/support/feature_case.ex +++ b/apps/block_scout_web/test/support/feature_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.FeatureCase do use ExUnit.CaseTemplate use Wallaby.DSL @@ -23,7 +24,7 @@ defmodule BlockScoutWeb.FeatureCase do setup tags do :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo) - unless tags[:async] do + if !tags[:async] do Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, {:shared, self()}) end @@ -31,6 +32,10 @@ defmodule BlockScoutWeb.FeatureCase do Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Transactions.child_id()) Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Accounts.child_id()) Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Accounts.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Uncles.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Uncles.child_id()) metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Explorer.Repo, self()) {:ok, session} = Wallaby.start_session(metadata: metadata) diff --git a/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_all_sourcify_sources_with_libs_response.json b/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_all_sourcify_sources_with_libs_response.json index 494c7897b819..ca356fcb9260 100644 --- a/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_all_sourcify_sources_with_libs_response.json +++ b/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_all_sourcify_sources_with_libs_response.json @@ -39,6 +39,10 @@ "abi": "[{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"__pool_id\",\"type\":\"uint256\"},{\"internalType\":\"address\",\"name\":\"_token\",\"type\":\"address\"},{\"internalType\":\"contract ITransferVerifier\",\"name\":\"_transfer_verifier\",\"type\":\"address\"},{\"internalType\":\"contract ITreeVerifier\",\"name\":\"_tree_verifier\",\"type\":\"address\"},{\"internalType\":\"contract IBatchDepositVerifier\",\"name\":\"_batch_deposit_verifier\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_direct_deposit_queue\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"index\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"bytes32\",\"name\":\"hash\",\"type\":\"bytes32\"},{\"indexed\":false,\"internalType\":\"bytes\",\"name\":\"message\",\"type\":\"bytes\"}],\"name\":\"Message\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"previousOwner\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"OwnershipTransferred\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint8\",\"name\":\"tier\",\"type\":\"uint8\"},{\"components\":[{\"internalType\":\"uint56\",\"name\":\"tvlCap\",\"type\":\"uint56\"},{\"internalType\":\"uint32\",\"name\":\"dailyDepositCap\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"dailyWithdrawalCap\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"dailyUserDepositCap\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"depositCap\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"directDepositCap\",\"type\":\"uint32\"},{\"internalType\":\"uint32\",\"name\":\"dailyUserDirectDepositCap\",\"type\":\"uint32\"}],\"indexed\":false,\"internalType\":\"struct ZkBobAccounting.TierLimits\",\"name\":\"limits\",\"type\":\"tuple\"}],\"name\":\"UpdateLimits\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"manager\",\"type\":\"address\"}],\"name\":\"UpdateOperatorManager\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"user\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint8\",\"name\":\"tier\",\"type\":\"uint8\"}],\"name\":\"UpdateTier\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"address\",\"name\":\"seller\",\"type\":\"address\"}],\"name\":\"UpdateTokenSeller\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"fee\",\"type\":\"uint256\"}],\"name\":\"WithdrawFee\",\"type\":\"event\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"name\":\"accumulatedFee\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"all_messages_hash\",\"outputs\":[{\"internalType\":\"bytes32\",\"name\":\"\",\"type\":\"bytes32\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_root_after\",\"type\":\"uint256\"},{\"internalType\":\"uint256[]\",\"name\":\"_indices\",\"type\":\"uint256[]\"},{\"internalType\":\"uint256\",\"name\":\"_out_commit\",\"type\":\"uint256\"},{\"internalType\":\"uint256[8]\",\"name\":\"_batch_deposit_proof\",\"type\":\"uint256[8]\"},{\"internalType\":\"uint256[8]\",\"name\":\"_tree_proof\",\"type\":\"uint256[8]\"}],\"name\":\"appendDirectDeposits\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"batch_deposit_verifier\",\"outputs\":[{\"internalType\":\"contract IBatchDepositVerifier\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"denominator\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"direct_deposit_queue\",\"outputs\":[{\"internalType\":\"contract IZkBobDirectDepositQueue\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_user\",\"type\":\"address\"}],\"name\":\"getLimitsFor\",\"outputs\":[{\"components\":[{\"internalType\":\"uint256\",\"name\":\"tvlCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"tvl\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyDepositCapUsage\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyWithdrawalCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyWithdrawalCapUsage\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyUserDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyUserDepositCapUsage\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"depositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint8\",\"name\":\"tier\",\"type\":\"uint8\"},{\"internalType\":\"uint256\",\"name\":\"dailyUserDirectDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"dailyUserDirectDepositCapUsage\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"directDepositCap\",\"type\":\"uint256\"}],\"internalType\":\"struct ZkBobAccounting.Limits\",\"name\":\"\",\"type\":\"tuple\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"_root\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_tvlCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyWithdrawalCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyUserDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_depositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyUserDirectDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_directDepositCap\",\"type\":\"uint256\"}],\"name\":\"initialize\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"nullifiers\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"operatorManager\",\"outputs\":[{\"internalType\":\"contract IOperatorManager\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"owner\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"pool_id\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"pool_index\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_sender\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"_amount\",\"type\":\"uint256\"}],\"name\":\"recordDirectDeposit\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"renounceOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint8\",\"name\":\"_tier\",\"type\":\"uint8\"}],\"name\":\"resetDailyLimits\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"name\":\"roots\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint8\",\"name\":\"_tier\",\"type\":\"uint8\"},{\"internalType\":\"uint256\",\"name\":\"_tvlCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyWithdrawalCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyUserDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_depositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_dailyUserDirectDepositCap\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"_directDepositCap\",\"type\":\"uint256\"}],\"name\":\"setLimits\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"contract IOperatorManager\",\"name\":\"_operatorManager\",\"type\":\"address\"}],\"name\":\"setOperatorManager\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_seller\",\"type\":\"address\"}],\"name\":\"setTokenSeller\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint8\",\"name\":\"_tier\",\"type\":\"uint8\"},{\"internalType\":\"address[]\",\"name\":\"_users\",\"type\":\"address[]\"}],\"name\":\"setUsersTier\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"token\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"tokenSeller\",\"outputs\":[{\"internalType\":\"contract ITokenSeller\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"transact\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"newOwner\",\"type\":\"address\"}],\"name\":\"transferOwnership\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"transfer_verifier\",\"outputs\":[{\"internalType\":\"contract ITransferVerifier\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"tree_verifier\",\"outputs\":[{\"internalType\":\"contract ITreeVerifier\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_operator\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"_to\",\"type\":\"address\"}],\"name\":\"withdrawFee\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", "constructorArguments": null, "matchType": "PARTIAL", + "libraries": { + "lib/base58-solidity/contracts/Base58.sol:Base58": "0x22de6b06544ee5cd907813a04bcded149a2f49d2", + "src/libraries/ZkAddress.sol:ZkAddress": "0x019d3788f00a7087234f3844cb1cece1f9982b7a" + }, "compilationArtifacts": null, "creationInputArtifacts": null, "deployedBytecodeArtifacts": null diff --git a/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_response_with_libs_priority.json b/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_response_with_libs_priority.json new file mode 100644 index 000000000000..63fb58709006 --- /dev/null +++ b/apps/block_scout_web/test/support/fixture/smart_contract/eth_bytecode_db_search_response_with_libs_priority.json @@ -0,0 +1,20 @@ +{ + "sources": [ + { + "fileName": "Test.sol", + "contractName": "Test", + "compilerVersion": "v0.8.17+commit.8df45f5f", + "compilerSettings": "{\"libraries\":{\"Test.sol\":{\"OldLibrary\":\"0x0000000000000000000000000000000000000001\"}},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":true,\"runs\":199},\"outputSelection\":{\"*\":{\"\":[\"ast\"],\"*\":[\"abi\",\"evm.bytecode\",\"evm.deployedBytecode\",\"evm.methodIdentifiers\"]}}}", + "sourceType": "SOLIDITY", + "sourceFiles": { + "Test.sol": "// SPDX-License-Identifier: MIT\r\n\r\npragma solidity 0.8.17;\r\n\r\ncontract Test {\r\n function get(uint256 x) external pure returns (uint256) {\r\n return x;\r\n }\r\n}" + }, + "abi": "[{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"x\",\"type\":\"uint256\"}],\"name\":\"get\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"pure\",\"type\":\"function\"}]", + "constructorArguments": null, + "matchType": "FULL", + "libraries": { + "contracts/Library.sol:NewLibrary": "0x1234567890123456789012345678901234567890" + } + } + ] +} diff --git a/apps/block_scout_web/test/support/subscription_case.ex b/apps/block_scout_web/test/support/subscription_case.ex index 3701e6e993a6..91bf803ce982 100644 --- a/apps/block_scout_web/test/support/subscription_case.ex +++ b/apps/block_scout_web/test/support/subscription_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScoutWeb.SubscriptionCase do @moduledoc """ This module defines the test case to be used by GraphQL subscription tests. diff --git a/apps/block_scout_web/test/support/test_helper.ex b/apps/block_scout_web/test/support/test_helper.ex new file mode 100644 index 000000000000..1854bc8cf87f --- /dev/null +++ b/apps/block_scout_web/test/support/test_helper.ex @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.TestHelper do + @moduledoc false + use BlockScoutWeb.ConnCase + + @doc """ + Asserts that the connection response contains the "block above tip" error message. + + ## Parameters + - `conn`: A Phoenix connection struct with a 404 response + + ## Returns + - Assertion passes if the error message matches, raises otherwise + """ + @spec assert_block_above_tip(Plug.Conn.t()) :: true + def assert_block_above_tip(conn) do + html = html_response(conn, 404) + {:ok, document} = Floki.parse_fragment(html) + [error_element | _] = Floki.find(document, ~S|.error-descr|) + assert Floki.text(error_element) |> String.trim() == "Easy Cowboy! This block does not exist yet!" + end +end diff --git a/apps/block_scout_web/test/test_helper.exs b/apps/block_scout_web/test/test_helper.exs index a40cf54b4702..40f35cfbb9b7 100644 --- a/apps/block_scout_web/test/test_helper.exs +++ b/apps/block_scout_web/test/test_helper.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # https://github.com/CircleCI-Public/circleci-demo-elixir-phoenix/blob/a89de33a01df67b6773ac90adc74c34367a4a2d6/test/test_helper.exs#L1-L3 junit_folder = Mix.Project.build_path() <> "/junit/#{Mix.Project.config()[:app]}" File.mkdir_p!(junit_folder) @@ -24,10 +25,11 @@ ExUnit.start() Mox.defmock(Explorer.Market.Source.TestSource, for: Explorer.Market.Source) +Explorer.TestHelper.run_necessary_background_migrations() + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Account, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.PolygonEdge, :manual) -Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.PolygonZkevm, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.RSK, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Shibarium, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Suave, :manual) @@ -37,9 +39,10 @@ Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.BridgedTokens, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Filecoin, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Mud, :manual) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.ShrunkInternalTransactions, :manual) +Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.EventNotifications, :manual) Absinthe.Test.prime(BlockScoutWeb.GraphQL.Schema) Mox.defmock(EthereumJSONRPC.Mox, for: EthereumJSONRPC.Transport) -Mox.defmock(Explorer.Mox.HTTPoison, for: HTTPoison.Base) +Mox.defmock(Explorer.Mock.TeslaAdapter, for: Tesla.Adapter) diff --git a/apps/ethereum_jsonrpc/README.md b/apps/ethereum_jsonrpc/README.md index 7dfe70dd55d3..d84bcfaa1a7f 100644 --- a/apps/ethereum_jsonrpc/README.md +++ b/apps/ethereum_jsonrpc/README.md @@ -17,8 +17,8 @@ config :ethereum_jsonrpc, Note: the tracing node URL is provided separately from `:url`, via `:trace_url`. The trace URL is used for `fetch_internal_transactions`, which is only a supported method on -tracing nodes. The `:http` option is passed directly to the HTTP -library (`HTTPoison`), which forwards the options down to `:hackney`. +tracing nodes. The `:http` option is adapted +to the HTTP library (`HTTPoison` or `Tesla.Mint`). ## Testing diff --git a/apps/ethereum_jsonrpc/config/config.exs b/apps/ethereum_jsonrpc/config/config.exs index 503b7a3828ed..8a6e88f5fd39 100644 --- a/apps/ethereum_jsonrpc/config/config.exs +++ b/apps/ethereum_jsonrpc/config/config.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :ethereum_jsonrpc, EthereumJSONRPC.RequestCoordinator, @@ -22,13 +23,11 @@ config :ethereum_jsonrpc, EthereumJSONRPC.Tracer, trace_key: :blockscout config :logger, :ethereum_jsonrpc, - # keep synced with `config/config.exs` - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, + metadata: ConfigHelper.logger_metadata(), metadata_filter: [application: :ethereum_jsonrpc] +config :tesla, adapter: Tesla.Adapter.Mint + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/apps/ethereum_jsonrpc/config/dev.exs b/apps/ethereum_jsonrpc/config/dev.exs index da39fcf0802c..d4c53cef6120 100644 --- a/apps/ethereum_jsonrpc/config/dev.exs +++ b/apps/ethereum_jsonrpc/config/dev.exs @@ -1,7 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :ethereum_jsonrpc, EthereumJSONRPC.Tracer, env: "dev", disabled?: true -config :logger, :ethereum_jsonrpc, - level: :debug, - path: Path.absname("logs/dev/ethereum_jsonrpc.log") +config :logger, :ethereum_jsonrpc, path: Path.absname("logs/dev/ethereum_jsonrpc.log") diff --git a/apps/ethereum_jsonrpc/config/prod.exs b/apps/ethereum_jsonrpc/config/prod.exs index f11e4e7a728c..271262810cb1 100644 --- a/apps/ethereum_jsonrpc/config/prod.exs +++ b/apps/ethereum_jsonrpc/config/prod.exs @@ -1,8 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :ethereum_jsonrpc, EthereumJSONRPC.Tracer, env: "production", disabled?: true config :logger, :ethereum_jsonrpc, - level: :info, path: Path.absname("logs/prod/ethereum_jsonrpc.log"), rotate: %{max_bytes: 52_428_800, keep: 19} diff --git a/apps/ethereum_jsonrpc/config/runtime/test.exs b/apps/ethereum_jsonrpc/config/runtime/test.exs index 081c952dfc9d..3e984344df1c 100644 --- a/apps/ethereum_jsonrpc/config/runtime/test.exs +++ b/apps/ethereum_jsonrpc/config/runtime/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant diff --git a/apps/ethereum_jsonrpc/config/test.exs b/apps/ethereum_jsonrpc/config/test.exs index 61e5f67398a0..01cff3d7433c 100644 --- a/apps/ethereum_jsonrpc/config/test.exs +++ b/apps/ethereum_jsonrpc/config/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :ethereum_jsonrpc, EthereumJSONRPC.RequestCoordinator, @@ -17,6 +18,6 @@ config :ethereum_jsonrpc, EthereumJSONRPC.RequestCoordinator, config :ethereum_jsonrpc, EthereumJSONRPC.Tracer, disabled?: false -config :logger, :ethereum_jsonrpc, - level: :warn, - path: Path.absname("logs/test/ethereum_jsonrpc.log") +config :tesla, adapter: Explorer.Mock.TeslaAdapter + +config :logger, :ethereum_jsonrpc, path: Path.absname("logs/test/ethereum_jsonrpc.log") diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex index f945c6a566ad..2d79fbad6d56 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC do @moduledoc """ Ethereum JSONRPC client. @@ -9,12 +10,12 @@ defmodule EthereumJSONRPC do config :ethereum_jsonrpc, url: "http://localhost:8545", trace_url: "http://localhost:8545", - http: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]] + http: [recv_timeout: 60_000, timeout: 60_000, pool: :ethereum_jsonrpc] Note: the tracing node URL is provided separately from `:url`, via `:trace_url`. The trace URL and is used for - `fetch_internal_transactions`, which is only a supported method on tracing nodes. The `:http` option is passed - directly to the HTTP library (`HTTPoison`), which forwards the options down to `:hackney`. + `fetch_internal_transactions`, which is only a supported method on tracing nodes. The `:http` option is adapted + to the HTTP library (`HTTPoison` or `Tesla.Mint`). ## Throttling @@ -34,6 +35,7 @@ defmodule EthereumJSONRPC do FetchedBalances, FetchedBeneficiaries, FetchedCodes, + Nonces, Receipts, RequestCoordinator, Subscription, @@ -237,6 +239,48 @@ defmodule EthereumJSONRPC do end end + @doc """ + Fetches transactions count for every `block_number` + """ + @spec fetch_transactions_count([integer()], json_rpc_named_arguments) :: + {:ok, %{transactions_count_map: %{integer() => integer()}, errors: [{:error, map()}]}} + | {:error, reason :: term()} + def fetch_transactions_count(block_numbers, json_rpc_named_arguments) do + id_to_params = EthereumJSONRPC.id_to_params(block_numbers) + + id_to_params + |> Enum.map(fn {id, number} -> + EthereumJSONRPC.request(%{ + id: id, + method: "eth_getBlockTransactionCountByNumber", + params: [EthereumJSONRPC.integer_to_quantity(number)] + }) + end) + |> EthereumJSONRPC.json_rpc(json_rpc_named_arguments) + |> case do + {:ok, responses} -> + %{errors: errors, counts: counts} = + responses + |> EthereumJSONRPC.sanitize_responses(id_to_params) + |> Enum.reduce(%{errors: [], counts: %{}}, fn + %{id: id, result: nil}, %{errors: errors} = acc -> + error = {:error, %{code: 404, message: "Not Found", data: Map.fetch!(id_to_params, id)}} + %{acc | errors: [error | errors]} + + %{id: id, result: count}, %{counts: counts} = acc -> + %{acc | counts: Map.put(counts, Map.fetch!(id_to_params, id), EthereumJSONRPC.quantity_to_integer(count))} + + %{id: id, error: error}, %{errors: errors} = acc -> + %{acc | errors: [{:error, Map.put(error, :data, Map.fetch!(id_to_params, id))} | errors]} + end) + + {:ok, %{transactions_count_map: Map.new(counts), errors: errors}} + + error -> + error + end + end + @doc """ Fetches contract code for multiple addresses at specified block numbers. @@ -275,6 +319,44 @@ defmodule EthereumJSONRPC do end end + @doc """ + Fetches address nonces for multiple addresses at specified block numbers. + + This function takes a list of parameters, each containing an address and a + block number, and retrieves the nonce for each address at the specified + block. + + ## Parameters + - `params_list`: A list of maps, each containing: + - `:block_quantity`: The block number (as a quantity string) at which to fetch the nonce. + - `:address`: The address of the contract to fetch the nonce for. + - `json_rpc_named_arguments`: A keyword list of JSON-RPC configuration options. + + ## Returns + - `{:ok, fetched_nonces}`, where `fetched_nonces` is a `Nonces.t()` struct containing: + - `params_list`: A list of successfully fetched code parameters, each containing: + - `address`: The contract address. + - `block_number`: The block number at which the nonce was fetched. + - `nonce`: The fetched nonce. + - `errors`: A list of errors encountered during the fetch operation. + - `{:error, reason}`: An error occurred during the fetch operation. + """ + @spec fetch_nonces( + [%{required(:block_quantity) => quantity, required(:address) => address()}], + json_rpc_named_arguments + ) :: {:ok, Nonces.t()} | {:error, reason :: term} + def fetch_nonces(params_list, json_rpc_named_arguments) + when is_list(params_list) and is_list(json_rpc_named_arguments) do + id_to_params = id_to_params(params_list) + + with {:ok, responses} <- + id_to_params + |> Nonces.requests() + |> json_rpc(json_rpc_named_arguments) do + {:ok, Nonces.from_responses(responses, id_to_params)} + end + end + @doc """ Fetches block reward contract beneficiaries from variant API. """ @@ -878,4 +960,35 @@ defmodule EthereumJSONRPC do defp address_correct?(_address) do false end + + @doc """ + Determines whether a given error represents a contract execution failure. + + This function checks if an error indicates a contract failure by examining + specific error patterns. It supports both map-based and tuple-based error + formats. The function identifies two types of contract failures: + 1. Errors with the atom `:unable_to_decode` + 2. Binary errors containing the pattern "execution" followed by "revert" + 3. Binary errors containing the pattern "out of gas" + + ## Parameters + - `error`: The error to check. Can be a map with an `:error` key, a tuple + `{:error, reason}`, or any other value. + + ## Returns + - `true` if the error represents a contract failure + - `false` otherwise + """ + @spec contract_failure?(any()) :: boolean() + def contract_failure?(%{error: :unable_to_decode}), do: true + + def contract_failure?(%{error: error}) when is_binary(error), + do: String.match?(error, ~r/execution.*revert/) or String.match?(error, ~r/out of gas/) + + def contract_failure?({:error, :unable_to_decode}), do: true + + def contract_failure?({:error, error}) when is_binary(error), + do: String.match?(error, ~r/execution.*revert/) or String.match?(error, ~r/out of gas/) + + def contract_failure?(_), do: false end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/anvil.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/anvil.ex index 55fc748ab659..546d9416c3de 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/anvil.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/anvil.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Anvil do @moduledoc """ Ethereum JSONRPC methods that are only supported by [Anvil](https://book.getfoundry.sh/anvil/). diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/application.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/application.ex index a03f80e27603..66bbda4175d6 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/application.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/application.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Application do @moduledoc """ Starts `:hackney_pool` `:ethereum_jsonrpc`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum.ex index 303c746b802c..5c71caaffcb0 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Arbitrum do @moduledoc """ Arbitrum specific routines used to fetch and process diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/contracts.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/contracts.ex index 1136a2bde325..68b6c8b43797 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/contracts.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/contracts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Arbitrum.Constants.Contracts do @moduledoc """ Provides constants and ABI definitions for Arbitrum-specific smart contracts. @@ -6,6 +7,81 @@ defmodule EthereumJSONRPC.Arbitrum.Constants.Contracts do interacting with core Arbitrum protocol contracts including: """ + # EigenDA type definitions - extracted for reusability + @eigen_da_bn254_g1_point_type {:tuple, + [ + # X + {:uint, 256}, + # Y + {:uint, 256} + ]} + + @eigen_da_quorum_blob_param_type {:tuple, + [ + # quorumNumber + {:uint, 8}, + # adversaryThresholdPercentage + {:uint, 8}, + # confirmationThresholdPercentage + {:uint, 8}, + # chunkLength + {:uint, 32} + ]} + + @eigen_da_batch_header_type {:tuple, + [ + # blobHeadersRoot + {:bytes, 32}, + # quorumNumbers + :bytes, + # signedStakeForQuorums + :bytes, + # referenceBlockNumber + {:uint, 32} + ]} + + @eigen_da_batch_metadata_type {:tuple, + [ + # BatchHeader + @eigen_da_batch_header_type, + # signatoryRecordHash + {:bytes, 32}, + # confirmationBlockNumber + {:uint, 32} + ]} + + @eigen_da_blob_verification_proof_type {:tuple, + [ + # batchId + {:uint, 32}, + # blobIndex + {:uint, 32}, + # BatchMetadata + @eigen_da_batch_metadata_type, + # inclusionProof + :bytes, + # quorumIndices + :bytes + ]} + + @eigen_da_blob_header_type {:tuple, + [ + # BN254.G1Point commitment + @eigen_da_bn254_g1_point_type, + # dataLength + {:uint, 32}, + # QuorumBlobParam[] quorumBlobParams + {:array, @eigen_da_quorum_blob_param_type} + ]} + + @eigen_da_cert_type {:tuple, + [ + # BlobVerificationProof + @eigen_da_blob_verification_proof_type, + # BlobHeader + @eigen_da_blob_header_type + ]} + @selector_outbox "ce11e6ab" @selector_sequencer_inbox "ee35f327" @selector_bridge "e78cea92" @@ -546,4 +622,74 @@ defmodule EthereumJSONRPC.Arbitrum.Constants.Contracts do ]} ] } + + @doc """ + Returns selector with ABI (object of `ABI.FunctionSelector`) of the function: + + addSequencerL2BatchFromEigenDA( + uint256 sequenceNumber, + EigenDACert calldata cert, + IGasRefunder gasRefunder, + uint256 afterDelayedMessagesRead, + uint256 prevMessageCount, + uint256 newMessageCount + ) + """ + def add_sequencer_l2_batch_from_eigen_da_selector_with_abi, + do: %ABI.FunctionSelector{ + function: "addSequencerL2BatchFromEigenDA", + types: [ + {:uint, 256}, + # EigenDACert structure + @eigen_da_cert_type, + :address, + {:uint, 256}, + {:uint, 256}, + {:uint, 256} + ] + } + + @doc """ + Returns ABI definition for EigenDACert structure for encoding purposes. + + The EigenDACert contains BlobVerificationProof and BlobHeader with full nested structures. + """ + def eigen_da_cert_abi, + do: %ABI.FunctionSelector{ + function: nil, + types: [ + # EigenDACert structure + @eigen_da_cert_type + ] + } + + @doc """ + Returns ABI definition for BlobVerificationProof structure for encoding purposes. + + The BlobVerificationProof contains batchId, blobIndex, BatchMetadata, inclusionProof, and quorumIndices. + """ + def eigen_da_blob_verification_proof_abi, + # https://github.com/Layr-Labs/eigenda/blob/6c1deb51fced68efb1aaa585dd5ddb6a27f88637/contracts/src/core/libraries/v1/EigenDATypesV1.sol#L36-L55 + do: %ABI.FunctionSelector{ + function: nil, + types: [ + # BlobVerificationProof + @eigen_da_blob_verification_proof_type + ] + } + + @doc """ + Returns ABI definition for BlobHeader structure for encoding purposes. + + The BlobHeader contains BN254.G1Point commitment, dataLength, and quorumBlobParams array. + """ + def eigen_da_blob_header_abi, + # https://github.com/Layr-Labs/eigenda/blob/6c1deb51fced68efb1aaa585dd5ddb6a27f88637/contracts/src/core/libraries/v1/EigenDATypesV1.sol#L18-L29 + do: %ABI.FunctionSelector{ + function: nil, + types: [ + # BlobHeader + @eigen_da_blob_header_type + ] + } end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/events.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/events.ex index 1a18f7548691..336d1f59b46d 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/events.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/events.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Arbitrum.Constants.Events do @moduledoc """ Provides constant values for Arbitrum-specific event signatures and their parameter types. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu.ex index 45b8d545e3ac..dfc46419d65a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule EthereumJSONRPC.Besu do @moduledoc """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/trace.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/trace.ex index e06c9d8da3a5..7235d942e427 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/trace.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/trace.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Besu.Trace do @moduledoc """ Trace returned by @@ -44,4 +45,5 @@ defmodule EthereumJSONRPC.Besu.Trace do end defp entry_to_elixir({"transactionIndex", index} = entry) when is_integer(index), do: entry + defp entry_to_elixir({_, _}), do: {:ignore, :ignore} end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/traces.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/traces.ex index 780d3af22cab..3b09c616cf4f 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/traces.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/besu/traces.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Besu.Traces do @moduledoc """ Trace returned by diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex index dd166ef653c1..3e983f9effcb 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Block do @moduledoc """ Block format as returned by [`eth_getBlockByHash`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_getblockbyhash) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_hash.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_hash.ex index 83aa6fa4ad00..df87b76834e1 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_hash.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Block.ByHash do @moduledoc """ Block format as returned by [`eth_getBlockByHash`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_getblockbyhash) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_nephew.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_nephew.ex index eba8c46ec826..c39caef403a1 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_nephew.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_nephew.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Block.ByNephew do @moduledoc """ Block format as returned by [`eth_getUncleByBlockHashAndIndex`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_getunclebyblockhashandindex) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_number.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_number.ex index b551c4f8e9e3..a7ae4120a637 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_number.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_number.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Block.ByNumber do @moduledoc """ Provides functionality to compose JSON-RPC requests for fetching Ethereum blocks by their number. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_tag.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_tag.ex index 7c48d80d19ec..244b526bbc6a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_tag.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/block/by_tag.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Block.ByTag do @moduledoc """ Block format returned by [`eth_getBlockByNumber`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getblockbyhash) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex index c4c4fdaaed06..592194ea113a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Blocks do @moduledoc """ Blocks format as returned by [`eth_getBlockByHash`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_getblockbyhash) @@ -551,4 +552,38 @@ defmodule EthereumJSONRPC.Blocks do def to_elixir(blocks) when is_list(blocks) do Enum.map(blocks, &Block.to_elixir/1) end + + @doc """ + Filters out all data related to provided block numbers + """ + @spec reject_data_by_block_numbers(t(), [non_neg_integer()]) :: t() + def reject_data_by_block_numbers(blocks_data, []), do: blocks_data + + def reject_data_by_block_numbers(%{blocks_params: blocks_params} = blocks_data, block_numbers) do + {filtered_blocks_params_reversed, block_hashes} = + Enum.reduce(blocks_params, {[], []}, fn block_params, {params_acc, hashes_acc} -> + if block_params.number in block_numbers do + {params_acc, [block_params.hash | hashes_acc]} + else + {[block_params | params_acc], hashes_acc} + end + end) + + filtered_blocks_params = Enum.reverse(filtered_blocks_params_reversed) + + keep? = fn + %{block_number: block_number} -> block_number not in block_numbers + %{block_hash: block_hash} -> block_hash not in block_hashes + _ -> true + end + + filtered_data = + blocks_data + |> Map.from_struct() + |> Map.drop([:errors, :blocks_params]) + |> Map.new(fn {data_type, data_list} -> {data_type, Enum.filter(data_list, keep?)} end) + |> Map.put(:blocks_params, filtered_blocks_params) + + Map.merge(blocks_data, filtered_data) + end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex index 5bef1c21d628..d67809e9b109 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/contract.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Contract do @moduledoc """ Smart contract functions executed by `eth_call`. @@ -30,54 +31,111 @@ defmodule EthereumJSONRPC.Contract do call_result() ] def execute_contract_functions(requests, abi, json_rpc_named_arguments, leave_error_as_map \\ false) do - parsed_abi = - abi - |> ABI.parse_specification() - + parsed_abi = ABI.parse_specification(abi) functions = Enum.into(parsed_abi, %{}, &{&1.method_id, &1}) - requests_with_index = Enum.with_index(requests) - indexed_responses = + {valid_requests, local_errors} = requests_with_index - |> Enum.map(fn {%{contract_address: contract_address, method_id: target_method_id, args: args} = request, index} -> - function = - functions - |> define_function(target_method_id) - |> Map.drop([:method_id]) + |> build_rpc_requests(functions) + |> Enum.split_with(&match?({:ok, _}, &1)) - formatted_args = format_args(function, args) + local_error_map = + local_errors + |> Enum.into(%{}, fn {:error, {index, message}} -> {index, {:local_error, message}} end) - function - |> Encoder.encode_function_call(formatted_args) - |> eth_call_request(contract_address, index, Map.get(request, :block_number), Map.get(request, :from)) - end) - |> json_rpc(json_rpc_named_arguments) - |> case do - {:ok, responses} -> responses - {:error, {:bad_gateway, _request_url}} -> raise "Bad gateway" - {:error, {reason, _request_url}} -> raise to_string(reason) - {:error, reason} when is_atom(reason) -> raise Atom.to_string(reason) - {:error, error} -> raise error + indexed_responses = + case valid_requests do + [] -> + {:ok, local_error_map} + + _ -> + valid_requests + |> Enum.map(fn {:ok, request} -> request end) + |> safe_json_rpc(json_rpc_named_arguments) + |> handle_batch_response() + |> merge_local_errors(local_error_map) end - |> Enum.into(%{}, &{&1.id, &1}) - Enum.map(requests_with_index, fn {%{method_id: method_id}, index} -> - indexed_responses[index] - |> case do - nil -> - {:error, "No result"} - - response -> - selectors = define_selectors(parsed_abi, method_id) + process_responses(indexed_responses, requests_with_index, parsed_abi, requests, leave_error_as_map) + end - {^index, result} = Encoder.decode_result(response, selectors, leave_error_as_map) - result + defp build_rpc_requests(requests_with_index, functions) do + Enum.map(requests_with_index, fn {%{contract_address: contract_address, method_id: target_method_id, args: args} = + request, index} -> + function = + functions + |> define_function(target_method_id) + |> Map.drop([:method_id]) + + with {:ok, formatted_args} <- safe_format_args(function, args), + {:ok, encoded} <- safe_encode_function_call(function, formatted_args) do + {:ok, + eth_call_request(encoded, contract_address, index, Map.get(request, :block_number), Map.get(request, :from))} + else + {:error, message} -> {:error, {index, message}} end end) + end + + defp merge_local_errors({:ok, response_map}, local_error_map), do: {:ok, Map.merge(response_map, local_error_map)} + defp merge_local_errors(other, _local_error_map), do: other + + defp handle_batch_response({:ok, responses}), do: {:ok, Enum.into(responses, %{}, &{&1.id, &1})} + defp handle_batch_response({:error, {:bad_gateway, _request_url}}), do: {:error, :batch_error, "Bad gateway"} + defp handle_batch_response({:error, {reason, _request_url}}), do: {:error, :batch_error, to_string(reason)} + defp handle_batch_response({:error, reason}) when is_atom(reason), do: {:error, :batch_error, Atom.to_string(reason)} + defp handle_batch_response({:error, error}), do: {:error, :batch_error, error} + + defp process_responses({:ok, response_map}, requests_with_index, parsed_abi, _requests, leave_error_as_map) do + Enum.map(requests_with_index, fn {%{method_id: method_id}, index} -> + process_single_response(response_map[index], index, method_id, parsed_abi, leave_error_as_map) + end) + end + + defp process_responses( + {:error, :batch_error, error}, + _requests_with_index, + _parsed_abi, + requests, + _leave_error_as_map + ) do + # Only apply error to all requests if the entire batch failed + Enum.map(requests, fn _ -> format_error(error) end) + end + + defp process_single_response(nil, _index, _method_id, _parsed_abi, _leave_error_as_map), do: {:error, "No result"} + + defp process_single_response({:local_error, message}, _index, _method_id, _parsed_abi, _leave_error_as_map), + do: {:error, message} + + defp process_single_response(response, index, method_id, parsed_abi, leave_error_as_map) do + selectors = define_selectors(parsed_abi, method_id) + {^index, result} = Encoder.decode_result(response, selectors, leave_error_as_map) + result rescue error -> - Enum.map(requests, fn _ -> format_error(error) end) + format_error(error) + end + + defp safe_format_args(function, args) do + {:ok, format_args(function, args)} + rescue + e -> {:error, Exception.message(e)} + end + + defp safe_encode_function_call(function, formatted_args) do + {:ok, Encoder.encode_function_call(function, formatted_args)} + rescue + e -> {:error, Exception.message(e)} + end + + defp safe_json_rpc(requests, json_rpc_named_arguments) do + json_rpc(requests, json_rpc_named_arguments) + rescue + error -> {:error, error} + catch + :exit, reason -> {:error, reason} end defp format_args(function, args) do @@ -206,22 +264,14 @@ defmodule EthereumJSONRPC.Contract do request(full_params) end - def eth_get_storage_at_request(contract_address, storage_pointer, block_number, json_rpc_named_arguments) do - block = - case block_number do - nil -> "latest" - block_number -> integer_to_quantity(block_number) - end - - result = - %{id: 0, method: "eth_getStorageAt", params: [contract_address, storage_pointer, block]} - |> request() - |> json_rpc(json_rpc_named_arguments) + def eth_get_storage_at_request(contract_address, storage_pointer, id) do + full_params = %{ + id: id, + method: "eth_getStorageAt", + params: [contract_address, storage_pointer, "latest"] + } - case result do - {:ok, storage_value} -> {:ok, storage_value} - other -> other - end + request(full_params) end defp format_error(nil), do: {:error, ""} diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/decode_error.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/decode_error.ex index 57c62262dd48..ed223efd0414 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/decode_error.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/decode_error.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.DecodeError do @moduledoc """ An error has occurred decoding the response to an `EthereumJSONRPC.json_rpc` request. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/encoder.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/encoder.ex index 052765f9a9b7..082bb8ce557a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/encoder.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/encoder.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Encoder do @moduledoc """ Deals with encoding and decoding data to be sent to, or that is diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex index fdcc8ac3eac7..680ff41471f7 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erc20.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.ERC20 do @moduledoc """ Provides ability to interact with ERC20 token contracts directly. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erigon.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erigon.ex index 57f490162270..fd49df73d238 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erigon.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/erigon.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule EthereumJSONRPC.Erigon do @moduledoc """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balance.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balance.ex index 682614feec22..dab97c91eaae 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balance.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedBalance do @moduledoc """ A single balance fetched from `eth_getBalance`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balances.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balances.ex index d87bdfb00e3c..56f281d6fb05 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balances.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedBalances do @moduledoc """ Balance params and errors from a batch request from `eth_getBalance`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiaries.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiaries.ex index 60e65665d967..efe6ef1f6c2b 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiaries.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiaries.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedBeneficiaries do @moduledoc """ Beneficiaries and errors from batch requests to `trace_block`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiary.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiary.ex index daf41053cd54..d1c06980724c 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiary.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_beneficiary.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedBeneficiary do @moduledoc """ A single balance request params for the beneficiary of a block. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex index 2b7211fe4d44..dfff4725e900 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedCode do @moduledoc """ A single code fetched from `eth_getCode`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex index a15ce4f8ce02..363b7a345894 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedCodes do @moduledoc """ Code params and errors from a batch request from `eth_getCode`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/filecoin.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/filecoin.ex index f9395a1321a4..087be00029cf 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/filecoin.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/filecoin.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Filecoin do @moduledoc """ Ethereum JSONRPC methods that are only supported by Filecoin. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth.ex index 6eb0d061e6cc..f0e6cbd06a8d 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth do @moduledoc """ Ethereum JSONRPC methods that are only supported by [Geth](https://github.com/ethereum/go-ethereum/wiki/geth). @@ -76,9 +77,16 @@ defmodule EthereumJSONRPC.Geth do json_rpc_named_arguments_corrected_timeout ) do case {traces, transactions_params} do - {[%{} = first_trace | _], [%{block_hash: block_hash} | _]} -> + {[%{} = first_trace | _], [%{block_hash: block_hash, block_number: block_number} | _]} -> {:ok, - [%{first_trace: first_trace, block_hash: block_hash, json_rpc_named_arguments: json_rpc_named_arguments}]} + [ + %{ + first_trace: first_trace, + block_hash: block_hash, + block_number: block_number, + json_rpc_named_arguments: json_rpc_named_arguments + } + ]} _ -> {:error, :not_found} @@ -278,7 +286,7 @@ defmodule EthereumJSONRPC.Geth do responses |> Enum.map(fn %{result: %{"structLogs" => nil}} -> - [] + {:ok, []} %{id: id, result: %{"structLogs" => _} = result} -> debug_trace_transaction_response_to_internal_transactions_params( @@ -313,7 +321,11 @@ defmodule EthereumJSONRPC.Geth do FetchedCode.request(%{id: id, block_quantity: integer_to_quantity(block_number), address: address}) {id, %{type: "selfdestruct", from_address_hash: hash_data, block_number: block_number}} -> - FetchedBalance.request(%{id: id, block_quantity: integer_to_quantity(block_number), hash_data: hash_data}) + FetchedBalance.request(%{ + id: id, + block_quantity: integer_to_quantity(block_number), + hash_data: hash_data + }) _ -> nil @@ -402,17 +414,23 @@ defmodule EthereumJSONRPC.Geth do end end - defp parse_call_tracer_calls(calls, acc, trace_address, inner? \\ true) - defp parse_call_tracer_calls([], acc, _trace_address, _inner?), do: acc - defp parse_call_tracer_calls({%{"type" => 0}, _}, acc, _trace_address, _inner?), do: acc + defp parse_call_tracer_calls(calls, acc, trace_address, inner?, parent \\ nil) + defp parse_call_tracer_calls([], acc, _trace_address, _inner?, _parent), do: acc + defp parse_call_tracer_calls({%{"type" => 0}, _}, acc, _trace_address, _inner?, _parent), do: acc - defp parse_call_tracer_calls({%{"type" => type}, _}, [last | acc], _trace_address, _inner?) + defp parse_call_tracer_calls({%{"type" => type}, _}, [last | acc], _trace_address, _inner?, _parent) when type in ["STOP", "stop"] do [Map.put(last, "error", "execution stopped") | acc] end # credo:disable-for-next-line /Complexity/ - defp parse_call_tracer_calls({%{"type" => upcase_type, "from" => from} = call, index}, acc, trace_address, inner?) do + defp parse_call_tracer_calls( + {%{"type" => upcase_type, "from" => from} = call, index}, + acc, + trace_address, + inner?, + parent + ) do case String.downcase(upcase_type) do type when type in ~w(call callcode delegatecall staticcall create create2 selfdestruct revert stop invalid) -> new_trace_address = [index | trace_address] @@ -421,7 +439,7 @@ defmodule EthereumJSONRPC.Geth do "type" => if(type in ~w(call callcode delegatecall staticcall), do: "call", else: type), "callType" => type, "from" => from, - "to" => Map.get(call, "to", "0x"), + "to" => Map.get(call, "to", if(type == "selfdestruct", do: parent["to"])), "createdContractAddressHash" => Map.get(call, "to", "0x"), "value" => Map.get(call, "value", "0x0"), "gas" => Map.get(call, "gas", "0x0"), @@ -437,11 +455,13 @@ defmodule EthereumJSONRPC.Geth do parse_call_tracer_calls( Map.get(call, "calls", []), [formatted_call | acc], - if(inner?, do: new_trace_address, else: []) + if(inner?, do: new_trace_address, else: []), + true, + call ) "" -> - unless allow_empty_traces?(), do: log_unknown_type(call) + if !allow_empty_traces?(), do: log_unknown_type(call) acc _unknown_type -> @@ -450,15 +470,15 @@ defmodule EthereumJSONRPC.Geth do end end - defp parse_call_tracer_calls({%{} = call, _}, acc, _trace_address, _inner?) do - unless allow_empty_traces?(), do: log_unknown_type(call) + defp parse_call_tracer_calls({%{} = call, _}, acc, _trace_address, _inner?, _parent) do + if !allow_empty_traces?(), do: log_unknown_type(call) acc end - defp parse_call_tracer_calls(calls, acc, trace_address, _inner) when is_list(calls) do + defp parse_call_tracer_calls(calls, acc, trace_address, inner?, parent) when is_list(calls) do calls |> Stream.with_index() - |> Enum.reduce(acc, &parse_call_tracer_calls(&1, &2, trace_address)) + |> Enum.reduce(acc, &parse_call_tracer_calls(&1, &2, trace_address, inner?, parent)) end defp log_unknown_type(call) do diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/call.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/call.ex index 2835c61431ea..baebfc889778 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/call.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/call.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.Call do @moduledoc """ A single call returned from [debug_traceTransaction](https://github.com/ethereum/go-ethereum/wiki/Management-APIs#debug_tracetransaction) @@ -78,7 +79,7 @@ defmodule EthereumJSONRPC.Geth.Call do gas_used: 1225, input: "0xa83627de", output: nil, - value: 0 + value: nil } A call can reach the stack limit (1024): @@ -116,7 +117,7 @@ defmodule EthereumJSONRPC.Geth.Call do error: "stack limit reached 1024 (1024)", gas: 1445580, gas_used: 1445580, - value: 0, + value: nil, } A contract creation: @@ -151,7 +152,7 @@ defmodule EthereumJSONRPC.Geth.Call do init: "0x", created_contract_code: "0x", trace_address: [], - value: 0 + value: nil } A contract creation can fail: @@ -221,7 +222,7 @@ defmodule EthereumJSONRPC.Geth.Call do gas_used: 6111, input: "0xeb9d50e46930b3227102b442f93b4aed3dead4ed76f850a76ee7f8b2cbe763428f2790530000000000000000000000000000000000000000000000000926708dfd7272e3", output: "0x", - value: 0 + value: nil } A static call calls another contract, but no state can change. This includes no value transfer, so the value for the @@ -259,7 +260,7 @@ defmodule EthereumJSONRPC.Geth.Call do gas_used: 1040, input: "0x0f370699", output: "0x", - value: 0 + value: nil } A selfdestruct destroys the calling contract and sends any left over balance to the to address. @@ -290,7 +291,7 @@ defmodule EthereumJSONRPC.Geth.Call do to_address_hash: "0xff77830c100623316736b45c4983df970423aaf4", gas: 742088, gas_used: 718517, - value: 0 + value: nil } """ @@ -306,9 +307,8 @@ defmodule EthereumJSONRPC.Geth.Call do defp entry_to_elixir({"error", nil} = entry), do: entry - defp entry_to_elixir({key, value} = entry) - when key in ~w(callType createdContractAddressHash createdContractCode error from init input output to transactionHash type) and - is_binary(value), + defp entry_to_elixir({key, _value} = entry) + when key in ~w(callType createdContractAddressHash createdContractCode error from init input output to transactionHash type), do: entry defp entry_to_elixir({key, value} = entry) when key in ~w(blockNumber index transactionIndex) and is_integer(value), @@ -324,6 +324,8 @@ defmodule EthereumJSONRPC.Geth.Call do entry end + defp entry_to_elixir({_, _}), do: {:ignore, :ignore} + defp elixir_to_internal_transaction_params( %{ "blockNumber" => block_number, @@ -356,7 +358,7 @@ defmodule EthereumJSONRPC.Geth.Call do gas_used: gas_used, input: input, output: params["output"], - value: value + value: if(value == 0, do: nil, else: value) } |> put_if_present(params, [ {"error", :error} @@ -390,7 +392,7 @@ defmodule EthereumJSONRPC.Geth.Call do gas: gas, gas_used: gas_used, init: init, - value: value, + value: if(value == 0, do: nil, else: value), error: error } end @@ -422,7 +424,7 @@ defmodule EthereumJSONRPC.Geth.Call do gas: gas, gas_used: gas_used, init: init, - value: value + value: if(value == 0, do: nil, else: value) } |> put_if_present(params, [ {"error", :error}, @@ -455,7 +457,7 @@ defmodule EthereumJSONRPC.Geth.Call do to_address_hash: to_address_hash, gas: gas, gas_used: gas_used, - value: value + value: if(value == 0, do: nil, else: value) } end @@ -483,7 +485,7 @@ defmodule EthereumJSONRPC.Geth.Call do input: input, gas: gas, gas_used: gas_used, - value: value, + value: if(value == 0, do: nil, else: value), error: "execution stopped" } end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/calls.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/calls.ex index d11c0192da38..eb3ba743ff27 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/calls.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/calls.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.Calls do @moduledoc """ Calls returned from [debug_traceTransaction](https://github.com/ethereum/go-ethereum/wiki/Management-APIs#debug_tracetransaction) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/polygon_tracer.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/polygon_tracer.ex index edc16207c961..eb87a91cf73a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/polygon_tracer.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/polygon_tracer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.PolygonTracer do @moduledoc """ Elixir implementation of a custom tracer (`priv/js/ethereum_jsonrpc/geth/debug_traceTransaction/tracer.js`) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/tracer.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/tracer.ex index cc17cc1cb00d..2ec55b8092d9 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/tracer.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/geth/tracer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.Tracer do @moduledoc """ Elixir implementation of a custom tracer (`priv/js/ethereum_jsonrpc/geth/debug_traceTransaction/tracer.js`) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex index 1d791aa73dfc..f169aab08f99 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.HTTP do @moduledoc """ JSONRPC over HTTP @@ -44,7 +45,19 @@ defmodule EthereumJSONRPC.HTTP do end def json_rpc(batch_request, options) when is_list(batch_request) do - chunked_json_rpc([batch_request], options, []) + batch_size = Application.get_env(:ethereum_jsonrpc, __MODULE__)[:batch_size] + chunked_batch_request = Enum.chunk_every(batch_request, batch_size) + maybe_log_big_batch(chunked_batch_request) + chunked_json_rpc(chunked_batch_request, options, []) + end + + defp maybe_log_big_batch([]), do: :ok + defp maybe_log_big_batch([_]), do: :ok + + defp maybe_log_big_batch([first_chunk | _] = batch) do + Logger.warning( + "Big amount of node requests in batch: #{batch |> Enum.map(&length/1) |> Enum.sum()}, 1st_chunk_1st_request: #{inspect(List.first(first_chunk))}" + ) end defp chunked_json_rpc([], _options, decoded_response_bodies) when is_list(decoded_response_bodies) do @@ -191,11 +204,11 @@ defmodule EthereumJSONRPC.HTTP do """ @spec standardize_response(map()) :: %{ :id => nil | non_neg_integer(), - :jsonrpc => binary(), + optional(:jsonrpc) => binary(), optional(:error) => %{:code => integer(), :message => binary(), optional(:data) => any()}, optional(:result) => any() } - def standardize_response(%{"jsonrpc" => "2.0" = jsonrpc} = unstandardized) do + def standardize_response(unstandardized) do # Avoid extracting `id` directly in the function declaration. Some endpoints # do not adhere to standards and may omit the `id` in responses related to # error scenarios. Consequently, the function call would fail during input @@ -204,7 +217,7 @@ defmodule EthereumJSONRPC.HTTP do # Nethermind return string ids id = sanitize_id(unstandardized["id"]) - standardized = %{jsonrpc: jsonrpc, id: id} + standardized = %{jsonrpc: unstandardized["jsonrpc"], id: id} case {id, unstandardized} do {_id, %{"result" => _, "error" => _}} -> @@ -266,6 +279,15 @@ defmodule EthereumJSONRPC.HTTP do end defp headers do - Application.get_env(:ethereum_jsonrpc, __MODULE__)[:headers] + gzip_enabled? = Application.get_env(:ethereum_jsonrpc, __MODULE__)[:gzip_enabled?] + + additional_headers = + if gzip_enabled? do + [{"Accept-Encoding", "gzip"}] + else + [] + end + + Application.get_env(:ethereum_jsonrpc, __MODULE__)[:headers] ++ additional_headers end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex new file mode 100644 index 000000000000..8c2d256443b7 --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/helper.ex @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.HTTP.Helper do + @moduledoc """ + Helper functions for `EthereumJSONRPC.HTTP` implementations. + """ + + @doc """ + Extracts the JSON-RPC method from a JSON string payload. + + Supports both single objects and batch requests (arrays). + + ## Parameters + - `json_string`: The JSON string to parse + + ## Returns + - The method name as a binary, or `{:error, Jason.DecodeError.t()}` if extraction fails + """ + @spec get_method_from_json_string(binary()) :: binary() | {:error, Jason.DecodeError.t()} + def get_method_from_json_string(json_string) do + with {:ok, decoded_json} <- Jason.decode(json_string) do + if is_map(decoded_json) do + Map.get(decoded_json, "method") + else + decoded_json |> Enum.at(0) |> Map.get("method") + end + end + end + + @spec response_body_has_error?(map() | [map()]) :: boolean() + def response_body_has_error?(decoded_body) when is_map(decoded_body) do + Map.has_key?(decoded_body, "error") + end + + def response_body_has_error?(decoded_body) when is_list(decoded_body) do + Enum.any?(decoded_body, &response_body_has_error?/1) + end + + def response_body_has_error?(_decoded_body), do: false + + @doc """ + Conditionally decompresses gzip-encoded HTTP response bodies. + + Checks application configuration and HTTP headers to determine if decompression + should be attempted. + + ## Parameters + - `body`: The response body to potentially decompress + - `headers`: List of HTTP response headers as {key, value} tuples + + ## Returns + - Decompressed body if gzip-enabled and content is gzipped, otherwise original body + """ + @spec try_unzip(binary(), [{binary(), binary()}]) :: binary() + def try_unzip(body, headers) do + gzip_enabled? = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP)[:gzip_enabled?] + + if gzip_enabled? do + do_unzip(body, headers) + else + body + end + end + + defp do_unzip(body, headers) do + gzipped = + Enum.any?( + headers + |> Enum.map(fn {k, v} -> + {String.downcase(k), String.downcase(v)} + end), + fn kv -> + case kv do + {"content-encoding", "gzip"} -> true + {"content-encoding", "x-gzip"} -> true + _ -> false + end + end + ) + + if gzipped do + :zlib.gunzip(body) + else + body + end + end +end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex index 60ef7d8047fc..25a0c60429c8 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/httpoison.ex @@ -1,36 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.HTTP.HTTPoison do @moduledoc """ Uses `HTTPoison` for `EthereumJSONRPC.HTTP` """ alias EthereumJSONRPC.HTTP + alias EthereumJSONRPC.HTTP.Helper alias EthereumJSONRPC.Prometheus.Instrumenter + alias Utils.HttpClient.HTTPoisonHelper @behaviour HTTP @impl HTTP def json_rpc(url, json, headers, options) when is_binary(url) and is_list(options) do - gzip_enabled? = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP)[:gzip_enabled?] - - headers = - if gzip_enabled? do - [{"Accept-Encoding", "gzip"} | headers] - else - headers - end - - method = get_method_from_json_string(json) + method = Helper.get_method_from_json_string(json) Instrumenter.json_rpc_requests(method) - case HTTPoison.post(url, json, headers, options) do + case HTTPoison.post(url, json, headers, HTTPoisonHelper.request_opts(options)) do {:ok, %HTTPoison.Response{body: body, status_code: status_code, headers: headers}} -> with {:ok, decoded_body} <- Jason.decode(body), - true <- response_body_has_error?(decoded_body) do + true <- Helper.response_body_has_error?(decoded_body) do Instrumenter.json_rpc_errors(method) end - {:ok, %{body: try_unzip(gzip_enabled?, body, headers), status_code: status_code}} + {:ok, %{body: Helper.try_unzip(body, headers), status_code: status_code}} {:error, %HTTPoison.Error{reason: reason}} -> Instrumenter.json_rpc_errors(method) @@ -40,51 +34,4 @@ defmodule EthereumJSONRPC.HTTP.HTTPoison do end def json_rpc(url, _json, _headers, _options) when is_nil(url), do: {:error, "URL is nil"} - - defp get_method_from_json_string(json_string) do - with {:ok, decoded_json} <- Jason.decode(json_string) do - if is_map(decoded_json) do - Map.get(decoded_json, "method") - else - decoded_json |> Enum.at(0) |> Map.get("method") - end - end - end - - defp response_body_has_error?(decoded_body) when is_map(decoded_body) do - Map.has_key?(decoded_body, "error") - end - - defp response_body_has_error?(decoded_body) when is_list(decoded_body) do - Enum.any?(decoded_body, &response_body_has_error?/1) - end - - defp response_body_has_error?(_decoded_body), do: false - - defp try_unzip(true, body, headers) do - gzipped = - Enum.any?( - headers - |> Enum.map(fn {k, v} -> - {String.downcase(k), String.downcase(v)} - end), - fn kv -> - case kv do - {"content-encoding", "gzip"} -> true - {"content-encoding", "x-gzip"} -> true - _ -> false - end - end - ) - - if gzipped do - :zlib.gunzip(body) - else - body - end - end - - defp try_unzip(_gzip_enabled?, body, _headers) do - body - end end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex new file mode 100644 index 000000000000..030c04cef753 --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/http/tesla.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.HTTP.Tesla do + @moduledoc """ + Uses `Tesla.Mint` for `EthereumJSONRPC.HTTP` + """ + + require Logger + + alias EthereumJSONRPC.HTTP + alias EthereumJSONRPC.HTTP.Helper + alias EthereumJSONRPC.Prometheus.Instrumenter + alias Utils.HttpClient.TeslaHelper + + @behaviour HTTP + + @impl HTTP + def json_rpc(url, json, headers, options) when is_binary(url) and is_list(options) do + method = Helper.get_method_from_json_string(json) + + Instrumenter.json_rpc_requests(method) + + case do_post(url, json, headers, options) do + {:ok, %Tesla.Env{body: body, status: status_code, headers: headers}} -> + with {:ok, decoded_body} <- Jason.decode(body), + true <- Helper.response_body_has_error?(decoded_body) do + Instrumenter.json_rpc_errors(method) + end + + {:ok, %{body: Helper.try_unzip(body, headers), status_code: status_code}} + + {:error, error} -> + Instrumenter.json_rpc_errors(method) + + {:error, error} + end + end + + def json_rpc(url, _json, _headers, _options) when is_nil(url), do: {:error, "URL is nil"} + + defp do_post(url, json, headers, options) do + Tesla.post(TeslaHelper.client(options), url, json, headers: headers, opts: TeslaHelper.request_opts(options)) + rescue + error -> + if timeout_middleware_exception?(__STACKTRACE__) do + log_normalized_timeout(:rescue, url, headers, options) + {:error, :timeout} + else + {:error, error} + end + catch + :exit, {:timeout, _} -> + log_normalized_timeout(:exit, url, headers, options) + {:error, :timeout} + end + + defp timeout_middleware_exception?(stacktrace) do + Enum.any?(stacktrace, fn + {Tesla.Middleware.Timeout, :call, 3, _} -> true + {Tesla.Middleware.Timeout, :repass_error, 1, _} -> true + _ -> false + end) + end + + defp log_normalized_timeout(source, url, headers, options) do + Logger.warning( + "Normalized timeout in do_post/4 source=#{source} url=#{inspect(url)} timeout=#{inspect(options[:timeout])} recv_timeout=#{inspect(options[:recv_timeout])} headers_count=#{length(headers)}" + ) + end +end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex index b030a6abf35c..5cd19112669f 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/ipc.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.IPC do use GenServer @moduledoc false diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/log.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/log.ex index 7e7ace5b2a57..780418aab409 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/log.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/log.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Log do @moduledoc """ Log included in return from diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/logs.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/logs.ex index de6b81dbf367..4e957d8727f7 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/logs.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/logs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Logs do @moduledoc """ Collection of logs included in return from diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind.ex index 5f9118670951..8045357c1fa9 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule EthereumJSONRPC.Nethermind do @moduledoc """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace.ex index 17b781224ccd..8dc947ef3d98 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.Trace do @moduledoc """ Trace returned by @@ -44,7 +45,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do trace_address: [], transaction_hash: "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", type: "create", - value: 0, + value: nil, transaction_index: 0 } @@ -78,7 +79,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do trace_address: [], transaction_hash: "0x3c624bb4852fb5e35a8f45644cec7a486211f6ba89034768a2b763194f22f97d", type: "create", - value: 0, + value: nil, transaction_index: 0 } @@ -121,7 +122,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do gas_used: 27770, input: "0x10855269000000000000000000000000862d67cb0773ee3f8ce7ea89b328ffea861ab3ef", output: "0x", - value: 0 + value: nil } Calls can error and be reverted @@ -196,7 +197,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do trace_address: [0], transaction_hash: "0xb012b8c53498c669d87d85ed90f57385848b86d3f44ed14b2784ec685d6fda98", type: "selfdestruct", - value: 0, + value: nil, transaction_index: 0 } @@ -231,7 +232,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do to_address_hash: to_address_hash, gas: gas, input: input, - value: value + value: if(value == 0, do: nil, else: value) } |> put_if_present(elixir, [ {"error", :error} @@ -261,7 +262,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do trace_address: trace_address, transaction_hash: transaction_hash, type: type, - value: value, + value: if(value == 0, do: nil, else: value), transaction_index: transaction_index } |> put_if_present(elixir, [ @@ -296,7 +297,7 @@ defmodule EthereumJSONRPC.Nethermind.Trace do trace_address: trace_address, transaction_hash: transaction_hash, type: "selfdestruct", - value: value, + value: if(value == 0, do: nil, else: value), transaction_index: transaction_index } end @@ -484,4 +485,5 @@ defmodule EthereumJSONRPC.Nethermind.Trace do end defp entry_to_elixir({"transactionIndex", index} = entry) when is_integer(index), do: entry + defp entry_to_elixir({_, _}), do: {:ignore, :ignore} end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/action.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/action.ex index ee5d571653b2..f7d3a6d4e3e7 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/action.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/action.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.Trace.Action do @moduledoc """ The action that was performed in a `t:EthereumJSONRPC.Nethermind.Trace.t/0` @@ -51,4 +52,6 @@ defmodule EthereumJSONRPC.Nethermind.Trace.Action do defp entry_to_elixir({key, quantity}) when key in ~w(balance gas value) do {key, quantity_to_integer(quantity)} end + + defp entry_to_elixir({_, _}), do: {:ignore, :ignore} end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/result.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/result.ex index c3ea279a8c6c..f9700b36b65d 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/result.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/trace/result.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.Trace.Result do @moduledoc """ The result of performing the `t:EthereumJSONRPC.Nethermind.Action.t/0` in a `t:EthereumJSONRPC.Nethermind.Trace.t/0`. @@ -39,4 +40,6 @@ defmodule EthereumJSONRPC.Nethermind.Trace.Result do defp entry_to_elixir({key, quantity}) when key in ~w(gasUsed) do {key, quantity_to_integer(quantity)} end + + defp entry_to_elixir({_, _}), do: {:ignore, :ignore} end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/traces.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/traces.ex index 1386eb5f84da..b3043798ef82 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/traces.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nethermind/traces.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.Traces do @moduledoc """ Trace returned by diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nft.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nft.ex index 62b52ab9fb48..30ae4bedb391 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nft.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nft.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.NFT do @moduledoc """ Module responsible for requesting token_uri and uri methods which needed for NFT metadata fetching diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonce.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonce.ex index 41dfa2de2646..286bf5c6f5d5 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonce.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonce.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nonce do @moduledoc """ A single code fetched from `eth_getTransactionCount`. @@ -7,6 +8,7 @@ defmodule EthereumJSONRPC.Nonce do alias EthereumJSONRPC.FetchedCode @type params :: %{address: EthereumJSONRPC.address(), block_number: non_neg_integer(), nonce: non_neg_integer()} + @type error :: FetchedCode.error() @doc """ Converts a JSON-RPC response of `eth_getTransactionCount` to code params or an annotated error. @@ -53,7 +55,7 @@ defmodule EthereumJSONRPC.Nonce do @spec from_response(%{id: EthereumJSONRPC.request_id(), error: %{code: integer(), message: String.t()}}, %{ non_neg_integer() => %{block_quantity: String.t(), address: String.t()} - }) :: {:error, FetchedCode.error()} + }) :: {:error, error()} def from_response(%{id: _id, error: %{code: code, message: message}} = response, id_to_params) when is_integer(code) and is_binary(message) and is_map(id_to_params) do FetchedCode.from_response(response, id_to_params) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonces.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonces.ex new file mode 100644 index 000000000000..df7d074192e9 --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/nonces.ex @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.Nonces do + @moduledoc """ + Nonce params and errors from a batch request from `eth_getTransactionCount`. + """ + + alias EthereumJSONRPC.Nonce + + defstruct params_list: [], + errors: [] + + @typedoc """ + * `params_list` - all the nonce params from requests that succeeded in the batch. + * `errors` - all the errors from requests that failed in the batch. + """ + @type t :: %__MODULE__{params_list: [Nonce.params()], errors: [Nonce.error()]} + + @doc """ + Generates a list of `eth_getTransactionCount` JSON-RPC requests for the given parameters. + + This function takes a map of request IDs to parameter maps and transforms them + into a list of JSON-RPC request structures for fetching nonces. + + ## Parameters + - `id_to_params`: A map where keys are request IDs and values are maps + containing `block_quantity` and `address` for each request. + + ## Returns + A list of maps, each representing a JSON-RPC request with the following + structure: + - `jsonrpc`: The JSON-RPC version (always "2.0"). + - `id`: The request identifier. + - `method`: The RPC method name (always "eth_getTransactionCount"). + - `params`: A list containing the address and block identifier. + """ + @spec requests(%{id => %{block_quantity: block_quantity, address: address}}) :: [ + %{jsonrpc: String.t(), id: id, method: String.t(), params: [address | block_quantity]} + ] + when id: EthereumJSONRPC.request_id(), + block_quantity: EthereumJSONRPC.quantity(), + address: EthereumJSONRPC.address() + def requests(id_to_params) when is_map(id_to_params) do + Enum.map(id_to_params, fn {id, %{block_quantity: block_quantity, address: address}} -> + Nonce.request(%{id: id, block_quantity: block_quantity, address: address}) + end) + end + + @doc """ + Processes responses from `eth_getTransactionCount` JSON-RPC calls and converts them into a structured format. + + This function takes a list of responses from `eth_getTransactionCount` calls and a map of + request IDs to their corresponding parameters. It sanitizes the responses, + processes each one, and accumulates the results into a `Nonces` struct. + + ## Parameters + - `responses`: A list of response maps from `eth_getTransactionCount` calls. Each map + contains an `:id` key and either a `:result` or `:error` key. + - `id_to_params`: A map where keys are request IDs and values are maps + containing `block_quantity` and `address` for each request. + + ## Returns + A `Nonces` struct containing: + - `params_list`: A list of successfully fetched nonce parameters. + - `errors`: A list of errors encountered during the process. + """ + @spec from_responses( + [%{:id => EthereumJSONRPC.request_id(), optional(:error) => map(), optional(:result) => String.t()}], + %{non_neg_integer() => %{block_quantity: String.t(), address: String.t()}} + ) :: t() + def from_responses(responses, id_to_params) do + responses + |> EthereumJSONRPC.sanitize_responses(id_to_params) + |> Enum.map(&Nonce.from_response(&1, id_to_params)) + |> Enum.reduce( + %__MODULE__{}, + fn + {:ok, params}, %__MODULE__{params_list: params_list} = acc -> + %__MODULE__{acc | params_list: [params | params_list]} + + {:error, reason}, %__MODULE__{errors: errors} = acc -> + %__MODULE__{acc | errors: [reason | errors]} + end + ) + end +end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/pending_transaction.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/pending_transaction.ex index b797585ec5d3..ce2a86218a77 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/pending_transaction.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/pending_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.PendingTransaction do @moduledoc """ Defines pending transactions fetching functions @@ -15,21 +16,7 @@ defmodule EthereumJSONRPC.PendingTransaction do with {:ok, transaction_data} <- %{id: 1, method: "txpool_content", params: []} |> request() |> json_rpc(json_rpc_named_arguments), {:transaction_data_is_map, true} <- {:transaction_data_is_map, is_map(transaction_data)} do - transactions_params = - transaction_data["pending"] - |> Enum.flat_map(fn {_address, nonce_transactions_map} -> - nonce_transactions_map - |> Enum.map(fn {_nonce, transaction} -> - transaction - end) - end) - |> Transactions.to_elixir() - |> Transactions.elixir_to_params() - |> Enum.map(fn params -> - # txpool_content always returns transaction with 0x0000000000000000000000000000000000000000000000000000000000000000 value in block hash and index is null. - # https://github.com/ethereum/go-ethereum/issues/19897 - %{params | block_hash: nil, index: nil} - end) + transactions_params = geth_pending_transactions_to_params(transaction_data["pending"]) {:ok, transactions_params} else @@ -38,6 +25,20 @@ defmodule EthereumJSONRPC.PendingTransaction do end end + defp geth_pending_transactions_to_params(pending_transactions_map) do + pending_transactions_map + |> Enum.flat_map(fn {_address, nonce_transactions_map} -> Map.values(nonce_transactions_map) end) + |> Transactions.to_elixir() + |> Transactions.elixir_to_params() + |> Enum.map(&normalize_geth_pending_params/1) + end + + defp normalize_geth_pending_params(params) do + # txpool_content always returns transaction with 0x0000000000000000000000000000000000000000000000000000000000000000 value in block hash and index is null. + # https://github.com/ethereum/go-ethereum/issues/19897 + %{params | block_hash: nil, index: nil} + end + @doc """ parity-style fetching of pending transactions (from `parity_pendingTransactions`) """ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/prometheus/instrumenter.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/prometheus/instrumenter.ex index 81b536102902..95ad5f64d6f9 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/prometheus/instrumenter.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/prometheus/instrumenter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Prometheus.Instrumenter do @moduledoc """ JSON RPC metrics for `Prometheus`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex index 6a2298478e46..4e5f2215e0e0 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipt.ex @@ -1,9 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Receipt do @moduledoc """ Receipts format as returned by [`eth_getTransactionReceipt`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_gettransactionreceipt). """ use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.RuntimeEnvHelper, op_isthmus_timestamp: [:indexer, [Indexer.Fetcher.Optimism, :isthmus_timestamp_l2]] import EthereumJSONRPC, only: [quantity_to_integer: 1] @@ -24,7 +26,10 @@ defmodule EthereumJSONRPC.Receipt do l1_fee: non_neg_integer(), l1_fee_scalar: non_neg_integer(), l1_gas_price: non_neg_integer(), - l1_gas_used: non_neg_integer() + l1_gas_used: non_neg_integer(), + operator_fee_scalar: non_neg_integer() | nil, + operator_fee_constant: non_neg_integer() | nil, + da_footprint_gas_scalar: non_neg_integer() | nil ] ) @@ -127,7 +132,10 @@ defmodule EthereumJSONRPC.Receipt do l1_fee: 0,\ l1_fee_scalar: 0,\ l1_gas_price: 0,\ - l1_gas_used: 0\ + l1_gas_used: 0,\ + operator_fee_scalar: nil,\ + operator_fee_constant: nil,\ + da_footprint_gas_scalar: nil\ """ :scroll -> """ l1_fee: 0\ @@ -179,7 +187,10 @@ defmodule EthereumJSONRPC.Receipt do l1_fee: 0,\ l1_fee_scalar: 0,\ l1_gas_price: 0,\ - l1_gas_used: 0\ + l1_gas_used: 0,\ + operator_fee_scalar: nil,\ + operator_fee_constant: nil,\ + da_footprint_gas_scalar: nil\ """ :scroll -> """ l1_fee: 0\ @@ -243,12 +254,22 @@ defmodule EthereumJSONRPC.Receipt do :optimism -> defp chain_type_fields(params, elixir) do + {operator_fee_scalar_default, operator_fee_constant_default} = + if is_nil(op_isthmus_timestamp()) do + {nil, nil} + else + {0, 0} + end + params |> Map.merge(%{ l1_fee: Map.get(elixir, "l1Fee", 0), l1_fee_scalar: Map.get(elixir, "l1FeeScalar", 0), l1_gas_price: Map.get(elixir, "l1GasPrice", 0), - l1_gas_used: Map.get(elixir, "l1GasUsed", 0) + l1_gas_used: Map.get(elixir, "l1GasUsed", 0), + operator_fee_scalar: Map.get(elixir, "operatorFeeScalar", operator_fee_scalar_default), + operator_fee_constant: Map.get(elixir, "operatorFeeConstant", operator_fee_constant_default), + da_footprint_gas_scalar: Map.get(elixir, "daFootprintGasScalar") }) end @@ -392,7 +413,8 @@ defmodule EthereumJSONRPC.Receipt do defp entry_to_elixir({key, quantity}) when key in ~w(blockNumber cumulativeGasUsed gasUsed transactionIndex blobGasUsed blobGasPrice l1Fee l1GasPrice l1GasUsed effectiveGasPrice gasUsedForL1 - l1BlobBaseFeeScalar l1BlobBaseFee l1BaseFeeScalar) do + l1BlobBaseFeeScalar l1BlobBaseFee l1BaseFeeScalar operatorFeeScalar + operatorFeeConstant daFootprintGasScalar) do result = if is_nil(quantity) do nil diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex index 1f104c181458..7ff22385111b 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Receipts do @moduledoc """ Receipts format as returned by @@ -111,7 +112,10 @@ defmodule EthereumJSONRPC.Receipts do l1_fee: 0,\ l1_fee_scalar: 0,\ l1_gas_price: 0,\ - l1_gas_used: 0\ + l1_gas_used: 0,\ + operator_fee_scalar: nil,\ + operator_fee_constant: nil,\ + da_footprint_gas_scalar: nil\ """ :scroll -> """ l1_fee: 0\ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_block_number.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_block_number.ex index 0207976aaa68..794fa3c3c769 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_block_number.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_block_number.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Receipts.ByBlockNumber do @moduledoc """ Provides functionality to compose JSON-RPC requests for fetching Ethereum block receipts by the block number. @@ -19,7 +20,10 @@ defmodule EthereumJSONRPC.Receipts.ByBlockNumber do ## Returns - A JSON-RPC request map for `eth_getBlockReceipts` """ - @spec request(%{id: EthereumJSONRPC.request_id(), number: EthereumJSONRPC.block_number() | EthereumJSONRPC.quantity()}) :: + @spec request(%{ + id: EthereumJSONRPC.request_id(), + number: EthereumJSONRPC.block_number() | EthereumJSONRPC.quantity() + }) :: Transport.request() def request(%{id: id, number: number}) when is_integer(number) do block_number = integer_to_quantity(number) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_transaction_hash.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_transaction_hash.ex index 1796607cf12a..b25466aac1f1 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_transaction_hash.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/receipts/by_transaction_hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Receipts.ByTransactionHash do @moduledoc """ Provides functionality to compose JSON-RPC requests for fetching Ethereum transaction receipt by the transaction hash. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex index 77ee7d3906bd..aac43018a75f 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/request_coordinator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RequestCoordinator do @moduledoc """ Coordinates requests with a backoff strategy. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rolling_window.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rolling_window.ex index 65657e7b92c5..a4ba29f4622c 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rolling_window.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rolling_window.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RollingWindow do @moduledoc """ Tracker for counting an event that occurs within a moving time window. @@ -45,7 +46,7 @@ defmodule EthereumJSONRPC.RollingWindow do duration = Keyword.fetch!(opts, :duration) window_count = Keyword.fetch!(opts, :window_count) - unless rem(duration, window_count) == 0 do + if rem(duration, window_count) != 0 do raise ArgumentError, "duration must be evenly divisible by window_count" end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk.ex index baff2def36f8..960947b3d7a6 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RSK do @moduledoc """ Ethereum JSONRPC methods that are/are not supported by [RSK](https://www.rsk.co/). """ - alias EthereumJSONRPC.RSK.Traces alias EthereumJSONRPC.{Geth, TraceBlock} + alias EthereumJSONRPC.RSK.Traces @behaviour EthereumJSONRPC.Variant diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/trace.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/trace.ex index 3a765132493e..790559950b9a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/trace.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/trace.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RSK.Trace do @moduledoc """ Trace returned by diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/traces.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/traces.ex index f74eef343c58..72f24a5936c0 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/traces.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/rsk/traces.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RSK.Traces do @moduledoc """ Traces returned by diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex index 47daee35a735..171456d530f0 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.SignedAuthorization do @moduledoc """ The format of authorization tuples returned for diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/subscription.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/subscription.ex index 428a9c30c852..db7522d91992 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/subscription.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/subscription.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Subscription do @moduledoc """ A subscription to an event diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_block.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_block.ex index 2192962c0ffb..c492cd594700 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_block.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.TraceBlock do @moduledoc """ Functions for processing the data from `trace_block` JSON RPC method. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_replay_block_transactions.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_replay_block_transactions.ex index c3aeb291ed44..1aedbad1a5e4 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_replay_block_transactions.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/trace_replay_block_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.TraceReplayBlockTransactions do @moduledoc """ Methods for processing the data from `trace_replayTransaction` and `trace_replayBlockTransactions` JSON RPC methods @@ -18,12 +19,19 @@ defmodule EthereumJSONRPC.TraceReplayBlockTransactions do {:ok, responses} -> case trace_replay_transaction_responses_to_first_trace_params(responses, id_to_params, traces_module) do {:ok, [first_trace]} -> - %{block_hash: block_hash} = + %{block_hash: block_hash, block_number: block_number} = transactions_params |> Enum.at(0) {:ok, - [%{first_trace: first_trace, block_hash: block_hash, json_rpc_named_arguments: json_rpc_named_arguments}]} + [ + %{ + first_trace: first_trace, + block_hash: block_hash, + block_number: block_number, + json_rpc_named_arguments: json_rpc_named_arguments + } + ]} {:error, error} -> Logger.error(inspect(error)) @@ -185,6 +193,9 @@ defmodule EthereumJSONRPC.TraceReplayBlockTransactions do {:ok, traces}, {:ok, acc_traces_list} -> {:ok, [traces | acc_traces_list]} + :ignore, acc -> + acc + {:ok, _}, {:error, _} = acc_error -> acc_error @@ -210,8 +221,11 @@ defmodule EthereumJSONRPC.TraceReplayBlockTransactions do end end - defp trace_replay_transaction_response_to_first_trace(%{id: id, result: %{"trace" => traces}}, id_to_params) - when is_list(traces) and is_map(id_to_params) do + defp trace_replay_transaction_response_to_first_trace( + %{id: id, result: %{"trace" => [raw_first_trace | _]} = result}, + id_to_params + ) + when is_map(id_to_params) do %{ block_hash: block_hash, block_number: block_number, @@ -220,24 +234,30 @@ defmodule EthereumJSONRPC.TraceReplayBlockTransactions do } = Map.fetch!(id_to_params, id) first_trace = - traces - |> Stream.with_index() - |> Enum.map(fn {trace, index} -> - Map.merge(trace, %{ - "blockHash" => block_hash, - "blockNumber" => block_number, - "index" => index, - "transactionIndex" => transaction_index, - "transactionHash" => transaction_hash - }) - end) - |> Enum.filter(fn trace -> - Map.get(trace, "index") == 0 - end) + Map.merge(raw_first_trace, %{ + "blockHash" => block_hash, + "blockNumber" => block_number, + "index" => 0, + "transactionIndex" => transaction_index, + "transactionHash" => transaction_hash + }) + + first_trace = + if Map.has_key?(result, "output") do + Map.update(first_trace, "result", %{"output" => result["output"]}, fn existing -> + Map.put_new(existing, "output", result["output"]) + end) + else + first_trace + end {:ok, first_trace} end + defp trace_replay_transaction_response_to_first_trace(%{id: _id, result: %{"trace" => []}}, _id_to_params) do + :ignore + end + defp trace_replay_transaction_response_to_first_trace(%{id: id, error: error}, id_to_params) when is_map(id_to_params) do %{ diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/tracer.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/tracer.ex index 159ed37bbb25..a66a4cffa394 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/tracer.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/tracer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Tracer do @moduledoc false diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex index 81c772dc8e2f..568b2a979516 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Transaction do @moduledoc """ Transaction format included in the return of @@ -7,7 +8,13 @@ defmodule EthereumJSONRPC.Transaction do [`eth_getTransactionByBlockHashAndIndex`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_gettransactionbyblockhashandindex), and [`eth_getTransactionByBlockNumberAndIndex`](https://github.com/ethereum/wiki/wiki/JSON-RPC/e8e0771b9f3677693649d945956bc60e886ceb2b#eth_gettransactionbyblocknumberandindex) """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] import EthereumJSONRPC, only: [ @@ -64,15 +71,6 @@ defmodule EthereumJSONRPC.Transaction do ] ) - :celo -> - @chain_type_fields quote( - do: [ - gas_token_contract_address_hash: EthereumJSONRPC.address(), - gas_fee_recipient_address_hash: EthereumJSONRPC.address(), - gateway_fee: non_neg_integer() - ] - ) - :arbitrum -> @chain_type_fields quote( do: [ @@ -84,6 +82,20 @@ defmodule EthereumJSONRPC.Transaction do @chain_type_fields quote(do: []) end + case @chain_identity do + {:optimism, :celo} -> + @chain_identity_fields quote( + do: [ + gas_token_contract_address_hash: EthereumJSONRPC.address(), + gas_fee_recipient_address_hash: EthereumJSONRPC.address(), + gateway_fee: non_neg_integer() + ] + ) + + _ -> + @chain_identity_fields quote(do: []) + end + # todo: Check if it's possible to simplify by avoiding t -> elixir -> params conversions # and directly convert t -> params. @type elixir :: %{ @@ -140,7 +152,10 @@ defmodule EthereumJSONRPC.Transaction do * `"executionNode"` - `t:EthereumJSONRPC.address/0` of execution node (used by Suave). * `"requestRecord"` - map of wrapped transaction data (used by Suave). """ - :celo -> """ + _ -> "" + end} + #{case @chain_identity do + {:optimism, :celo} -> """ * `"feeCurrency"` - `t:EthereumJSONRPC.address/0` of the currency used to pay for gas. * `"gatewayFee"` - `t:EthereumJSONRPC.quantity/0` of the gateway fee. * `"gatewayFeeRecipient"` - `t:EthereumJSONRPC.address/0` of the gateway fee recipient. @@ -160,6 +175,7 @@ defmodule EthereumJSONRPC.Transaction do @type params :: %{ unquote_splicing(@chain_type_fields), + unquote_splicing(@chain_identity_fields), block_hash: EthereumJSONRPC.hash(), block_number: non_neg_integer(), from_address_hash: EthereumJSONRPC.address(), @@ -307,6 +323,7 @@ defmodule EthereumJSONRPC.Transaction do elixir |> do_elixir_to_params() |> chain_type_fields(elixir) + |> chain_identity_fields(elixir) end # Converts a map of the transaction parameters to the map with the corresponding atom parameters. @@ -529,7 +546,7 @@ defmodule EthereumJSONRPC.Transaction do end defp chain_type_fields(params, elixir) do - case Application.get_env(:explorer, :chain_type) do + case chain_type() do :ethereum -> put_if_present(params, elixir, [ {"blobVersionedHashes", :blob_versioned_hashes}, @@ -574,16 +591,23 @@ defmodule EthereumJSONRPC.Transaction do }) end - :celo -> + :arbitrum -> put_if_present(params, elixir, [ - {"feeCurrency", :gas_token_contract_address_hash}, - {"gatewayFee", :gateway_fee}, - {"gatewayFeeRecipient", :gas_fee_recipient_address_hash} + {"requestId", :request_id} ]) - :arbitrum -> + _ -> + params + end + end + + defp chain_identity_fields(params, elixir) do + case chain_identity() do + {:optimism, :celo} -> put_if_present(params, elixir, [ - {"requestId", :request_id} + {"feeCurrency", :gas_token_contract_address_hash}, + {"gatewayFee", :gateway_fee}, + {"gatewayFeeRecipient", :gas_fee_recipient_address_hash} ]) _ -> @@ -742,7 +766,7 @@ defmodule EthereumJSONRPC.Transaction do do: {key, value |> Enum.map(&SignedAuthorization.to_params/1)} # Celo-specific fields - if @chain_type == :celo do + if @chain_identity == {:optimism, :celo} do defp entry_to_elixir({key, value}) when key in ~w(feeCurrency gatewayFeeRecipient), do: {key, value} diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transactions.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transactions.ex index 702786bae824..5d4ed385a59b 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transactions.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Transactions do @moduledoc """ List of transactions format as included in return from diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex index 829f5816d3f8..3f8e38b75d50 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transport.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Transport do @moduledoc """ The transport over which JSONRPC calls occur. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncle.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncle.ex index b9cd6e58d126..23a3357bc9d6 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncle.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncle.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Uncle do @moduledoc """ [Uncle](https://ethereum.org/en/glossary). diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncles.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncles.ex index fe36b7aa33c1..12506be06bcb 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncles.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/uncles.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Uncles do @moduledoc """ List of [uncles](https://ethereum.org/en/glossary). Uncles are blocks that didn't diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/common_helper.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/common_helper.ex index d6bca798daad..c1ab36c2c2a5 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/common_helper.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/common_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Utility.CommonHelper do @moduledoc """ Common helper functions diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex index 36e6632b8210..9cd09d0d47ea 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_checker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Utility.EndpointAvailabilityChecker do @moduledoc """ Monitors and verifies the availability of Ethereum JSON-RPC endpoints. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex index 9b10a5d0ea4b..e434525a9284 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/endpoint_availability_observer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Utility.EndpointAvailabilityObserver do @moduledoc """ Monitors and manages the availability of JSON-RPC endpoints. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/ranges_helper.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/ranges_helper.ex index df9366b06f71..22a4d052e7ae 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/ranges_helper.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/utility/ranges_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule EthereumJSONRPC.Utility.RangesHelper do @moduledoc """ @@ -31,6 +32,19 @@ defmodule EthereumJSONRPC.Utility.RangesHelper do end end + @doc """ + Filters out records from `data` that are outside the configured block ranges + """ + @spec filter_by_block_ranges([integer() | map()]) :: [integer() | map()] + def filter_by_block_ranges(data) do + if block_ranges_present?() do + block_ranges = get_block_ranges() + Enum.filter(data, &number_in_ranges?(extract_block_number(&1), block_ranges)) + else + data + end + end + @doc """ Filters elements with `filter_func` if `TRACE_BLOCK_RANGES` is set """ @@ -51,6 +65,14 @@ defmodule EthereumJSONRPC.Utility.RangesHelper do Application.get_env(:indexer, :trace_block_ranges) != @default_trace_block_ranges end + @doc """ + Checks if block ranges are defined via env variables + """ + @spec block_ranges_present? :: boolean() + def block_ranges_present? do + Application.get_env(:indexer, :block_ranges) != nil + end + @doc """ Retrieves trace ranges from application variable in string format and parses them into Range/integer """ @@ -61,6 +83,16 @@ defmodule EthereumJSONRPC.Utility.RangesHelper do |> parse_block_ranges() end + @doc """ + Retrieves block ranges from application variable in string format and parses them into Range/integer + """ + @spec get_block_ranges :: [Range.t() | integer()] + def get_block_ranges do + :indexer + |> Application.get_env(:block_ranges) + |> parse_block_ranges() + end + @doc """ Parse ranges from string format into Range/integer """ @@ -68,19 +100,78 @@ defmodule EthereumJSONRPC.Utility.RangesHelper do def parse_block_ranges(block_ranges_string) do block_ranges_string |> String.split(",") - |> Enum.map(fn string_range -> + |> Enum.reduce({[], nil}, fn string_range, {ranges, to_latest} -> case String.split(string_range, "..") do [from_string, "latest"] -> - parse_integer(from_string) + {ranges, parse_integer(from_string)} [from_string, to_string] -> - get_from_to(from_string, to_string) + {[get_from_to(from_string, to_string) | ranges], to_latest} _ -> - nil + {ranges, to_latest} end end) - |> sanitize_ranges() + |> then(fn + {ranges, nil} -> + sanitize_ranges(ranges) + + {ranges, to_latest} -> + ranges + |> sanitize_ranges() + |> Enum.reduce([], fn + first..last//1 = range, acc -> + cond do + first >= to_latest -> acc + last >= to_latest -> [first..(to_latest - 1) | acc] + true -> [range | acc] + end + + first..last//-1 = range, acc -> + cond do + last >= to_latest -> acc + first >= to_latest -> [(to_latest - 1)..last | acc] + true -> [range | acc] + end + end) + |> Enum.reverse() + |> Kernel.++([to_latest]) + end) + end + + @doc """ + Parses a block ranges string into a flat list of block numbers. + + This function expands each parsed range into individual block numbers and returns + all resulting numbers as a single list. The input must contain only finite ranges. + If the parsed result includes a standalone integer such as a `latest` marker, + the function raises. + + ## Parameters + + - `block_ranges_string`: A string representing block ranges. + + ## Returns + + - A list of block numbers as integers. + + ## Examples + + iex> parse_block_ranges_to_numbers("1..3,5..6") + [1, 2, 3, 5, 6] + + iex> parse_block_ranges_to_numbers("10..12") + [10, 11, 12] + + """ + @spec parse_block_ranges_to_numbers(binary()) :: [integer()] + def parse_block_ranges_to_numbers(block_ranges_string) do + block_ranges_string + |> parse_block_ranges() + |> Enum.flat_map(fn + %Range{} = range -> Range.to_list(range) + _number -> raise "Invalid ranges string" + end) end @doc """ @@ -136,39 +227,11 @@ defmodule EthereumJSONRPC.Utility.RangesHelper do @doc """ Rejects empty ranges and merges adjacent ranges """ - @spec sanitize_ranges([Range.t() | integer()]) :: [Range.t() | integer()] + @spec sanitize_ranges([Range.t() | nil]) :: [Range.t()] def sanitize_ranges(ranges) do ranges |> Enum.reject(&is_nil/1) - |> Enum.sort_by( - fn - from.._to//_ -> from - el -> el - end, - :asc - ) - |> Enum.chunk_while( - nil, - fn - _from.._to//_ = chunk, nil -> - {:cont, chunk} - - _ch_from..ch_to//_ = chunk, acc_from..acc_to//_ = acc -> - if Range.disjoint?(chunk, acc), - do: {:cont, acc, chunk}, - else: {:cont, acc_from..max(ch_to, acc_to)} - - num, nil -> - {:halt, num} - - num, acc_from.._//_ = acc -> - if Range.disjoint?(num..num, acc), do: {:cont, acc, num}, else: {:halt, acc_from} - - _, num -> - {:halt, num} - end, - fn remainder -> {:cont, remainder, nil} end - ) + |> do_sanitize_ranges() end @doc """ @@ -202,17 +265,63 @@ defmodule EthereumJSONRPC.Utility.RangesHelper do trace_block_ranges = get_trace_block_ranges() fn data, acc -> - if number_in_ranges?(extract_block_number(data), trace_block_ranges), + block_number = extract_block_number(data) + + if number_in_ranges?(block_number, trace_block_ranges), do: reducer.(data, acc), else: acc end else - fn block_number, acc -> - reducer.(block_number, acc) + reducer + end + end + + @doc """ + Defines a stream reducer that filters out data outside configured block ranges. + Applicable for fetchers' `init` function (for modules that implement `BufferedTask`). + """ + @spec stream_reducer_by_block_ranges((any(), any() -> any())) :: (any(), any() -> any()) + def stream_reducer_by_block_ranges(reducer) do + if block_ranges_present?() do + block_ranges = get_block_ranges() + + fn data, acc -> + block_number = extract_block_number(data) + + if number_in_ranges?(block_number, block_ranges), + do: reducer.(data, acc), + else: acc end + else + reducer end end + defp do_sanitize_ranges([]), do: [] + + defp do_sanitize_ranges([_.._//step | _] = ranges) do + ranges + |> Enum.reject(&is_nil/1) + |> Enum.map(fn first..last//_ -> min(first, last)..max(first, last) end) + |> Enum.sort_by(fn first.._last//_ -> first end) + |> Enum.reduce([], fn + range, [] -> + [range] + + first..last//_ = range, [previous_first..previous_last//_ | rest] = acc -> + if first <= previous_last + 1 do + [previous_first..max(previous_last, last) | rest] + else + [range | acc] + end + end) + |> Enum.reverse() + |> Enum.map(fn first..last//_ -> + if step == 1, do: first..last, else: last..first//-1 + end) + end + + defp extract_block_number({_, _, block_number, _, _, _}), do: block_number defp extract_block_number(%{block_number: block_number}), do: block_number defp extract_block_number(block_number), do: block_number diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/variant.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/variant.ex index c4991fe97c92..6f2f198909ba 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/variant.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/variant.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Variant do @moduledoc """ A variant of the Ethereum JSONRPC API. Each Ethereum client supports slightly different versions of the non-standard diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket.ex index a6d7afccb4f0..887d92265797 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket do @moduledoc """ JSONRPC over WebSocket. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/registration.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/registration.ex index af3e720a5e37..416d1b0b0a1c 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/registration.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/registration.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.Registration do @moduledoc """ When a caller registers for responses to asynchronous frame responses. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/retry_worker.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/retry_worker.ex index 4a6329edc036..d289b20f7b01 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/retry_worker.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/retry_worker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.RetryWorker do @moduledoc """ Stores the unavailable websocket endpoint state and periodically checks if it is already available. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/supervisor.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/supervisor.ex index 974b249e18df..c7eeb7979248 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/supervisor.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/supervisor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.Supervisor do @moduledoc """ Supervises the processes related to `EthereumJSONRPC.WebSocket`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex index 4098e5e42a1c..c78f94133a66 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.WebSocketClient do @moduledoc """ `EthereumJSONRPC.WebSocket` that uses `WebSockex` @@ -92,7 +93,7 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClient do def handle_connect(_conn, state) do Logger.metadata(fetcher: :websocket_client) - unless state.fallback? do + if !state.fallback? do RetryWorker.deactivate() WebSocketSupervisor.stop_other_client(self()) end @@ -447,7 +448,7 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClient do request: %{params: [event | params]} }, %{"result" => subscription_id}, - %{ + %__MODULE__{ subscription_id_to_subscription_reference: subscription_id_to_subscription_reference, subscription_reference_to_subscription: subscription_reference_to_subscription, subscription_reference_to_subscription_id: subscription_reference_to_subscription_id, @@ -522,7 +523,7 @@ defmodule EthereumJSONRPC.WebSocket.WebSocketClient do request: %{method: "eth_unsubscribe", params: [subscription_id]} }, response, - %{ + %__MODULE__{ subscription_id_to_subscription_reference: subscription_id_to_subscription_reference, subscription_reference_to_subscription: subscription_reference_to_subscription, subscription_reference_to_subscription_id: subscription_reference_to_subscription_id diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client/options.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client/options.ex index 6d283ddfce30..bb9414512bf6 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client/options.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/web_socket/web_socket_client/options.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.WebSocketClient.Options do @moduledoc """ `t:EthereumJSONRPC.WebSocket.options/0` for `EthereumJSONRPC.WebSocket.WebSocketClient` `t:EthereumJSONRPC.Subscription.t/0` `transport_options`. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex index 3763fc5d66c0..5a9ea78d0334 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawal.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Withdrawal do @moduledoc """ Withdrawal format included in the return of @@ -98,4 +99,5 @@ defmodule EthereumJSONRPC.Withdrawal do defp entry_to_elixir({key, value}) when key in ~w(index validatorIndex amount), do: {key, quantity_to_integer(value)} defp entry_to_elixir({key, value}) when key in ~w(address), do: {key, value} + defp entry_to_elixir({_, _}), do: {:ignore, :ignore} end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex index 863857eee573..847ada27103a 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/withdrawals.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Withdrawals do @moduledoc """ List of withdrawals format included in the return of diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa.ex index 36815e47067d..88341620d89c 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Zilliqa do @moduledoc """ Zilliqa type definitions. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/aggregate_quorum_certificate.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/aggregate_quorum_certificate.ex index d114358938a7..be9bd91a2ae0 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/aggregate_quorum_certificate.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/aggregate_quorum_certificate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Zilliqa.AggregateQuorumCertificate do @moduledoc """ Represents an aggregate quorum certificate associated with the block. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex index dc630270e34b..8eb381d03845 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/helper.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Zilliqa.Helper do @moduledoc """ Helper functions for processing consensus data. """ - alias EthereumJSONRPC.Zilliqa alias EthereumJSONRPC.{Block, Blocks} + alias EthereumJSONRPC.Zilliqa alias EthereumJSONRPC.Zilliqa.{ AggregateQuorumCertificate, diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/nested_quorum_certificates.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/nested_quorum_certificates.ex index 5320d483fab1..bf429649f2f7 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/nested_quorum_certificates.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/nested_quorum_certificates.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Zilliqa.NestedQuorumCertificates do @moduledoc """ Represents a list of quorum certificates that were proposed by different diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/quorum_certificate.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/quorum_certificate.ex index ea2716fe9fd8..20ce7fd5ac0b 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/quorum_certificate.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zilliqa/quorum_certificate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Zilliqa.QuorumCertificate do @moduledoc """ Represents a quorum certificate associated with the block. diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zksync/constants/contracts.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zksync/constants/contracts.ex new file mode 100644 index 000000000000..e51d9ace5927 --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/zksync/constants/contracts.ex @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.ZkSync.Constants.Contracts do + @moduledoc """ + Provides constants and ABI definitions for zkSync-specific smart contracts. + """ + + # /// @notice Rollup batch stored data + # /// @param batchNumber Rollup batch number + # /// @param batchHash Hash of L2 batch + # /// @param indexRepeatedStorageChanges The serial number of the shortcut index that's used as a unique identifier for storage keys that were used twice or more + # /// @param numberOfLayer1Txs Number of priority operations to be processed + # /// @param priorityOperationsHash Hash of all priority operations from this batch + # /// @param l2LogsTreeRoot Root hash of tree that contains L2 -> L1 messages from this batch + # /// @param timestamp Rollup batch timestamp, have the same format as Ethereum batch constant + # /// @param commitment Verified input for the ZKsync circuit + # struct StoredBatchInfo { + # uint64 batchNumber; + # bytes32 batchHash; + # uint64 indexRepeatedStorageChanges; + # uint256 numberOfLayer1Txs; + # bytes32 priorityOperationsHash; + # bytes32 l2LogsTreeRoot; + # uint256 timestamp; + # bytes32 commitment; + # } + @stored_batch_info_tuple [ + # batchNumber + uint: 64, + # batchHash + bytes: 32, + # indexRepeatedStorageChanges + uint: 64, + # numberOfLayer1Txs + uint: 256, + # priorityOperationsHash + bytes: 32, + # l2LogsTreeRoot + bytes: 32, + # timestamp + uint: 256, + # commitment + bytes: 32 + ] + + # /// @notice Recursive proof input data (individual commitments are constructed onchain) + # struct ProofInput { + # uint256[] recursiveAggregationInput; + # uint256[] serializedProof; + # } + @proof_input_tuple [ + # recursiveAggregationInput + array: {:uint, 256}, + # serializedProof + array: {:uint, 256} + ] + + @selector_prove_batches "7f61885c" + @selector_prove_batches_shared_bridge_c37533bb "c37533bb" + @selector_prove_batches_shared_bridge_e12a6137 "e12a6137" + + @doc """ + Returns selector of the `proveBatches` function + """ + def prove_batches_selector, do: @selector_prove_batches + + @doc """ + Returns selector of the `proveBatchesSharedBridge` function + """ + def prove_batches_shared_bridge_c37533bb_selector, do: @selector_prove_batches_shared_bridge_c37533bb + + @doc """ + Returns selector of the `proveBatchesSharedBridge` function with selector e12a6137 + """ + def prove_batches_shared_bridge_e12a6137_selector, do: @selector_prove_batches_shared_bridge_e12a6137 + + @doc """ + Returns selector with ABI (object of `ABI.FunctionSelector`) of the function: + + proveBatches( + StoredBatchInfo calldata _prevBatch, + StoredBatchInfo[] calldata _committedBatches, + ProofInput calldata _proof + ) + """ + def prove_batches_selector_with_abi, + do: %ABI.FunctionSelector{ + function: "proveBatches", + types: [ + {:tuple, @stored_batch_info_tuple}, + {:array, {:tuple, @stored_batch_info_tuple}}, + {:tuple, @proof_input_tuple} + ] + } + + @doc """ + Returns selector with ABI (object of `ABI.FunctionSelector`) of the function: + + proveBatchesSharedBridge( + uint256 _chainId, + StoredBatchInfo calldata _prevBatch, + StoredBatchInfo[] calldata _committedBatches, + ProofInput calldata _proof + ) + """ + def prove_batches_shared_bridge_c37533bb_selector_with_abi, + do: %ABI.FunctionSelector{ + function: "proveBatchesSharedBridge", + types: [ + {:uint, 256}, + {:tuple, @stored_batch_info_tuple}, + {:array, {:tuple, @stored_batch_info_tuple}}, + {:tuple, @proof_input_tuple} + ] + } + + @doc """ + Returns selector with ABI (object of `ABI.FunctionSelector`) of the function: + + proveBatchesSharedBridge( + uint256 _chainId, + uint256 _processBatchFrom, + uint256 _processBatchTo, + bytes calldata _proofData + ) + """ + def prove_batches_shared_bridge_e12a6137_selector_with_abi, + do: %ABI.FunctionSelector{ + function: "proveBatchesSharedBridge", + types: [ + # _chainId + {:uint, 256}, + # _processBatchFrom + {:uint, 256}, + # _processBatchTo + {:uint, 256}, + # _proofData + :bytes + ] + } +end diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 0b711163c806..10cd3e1c0ce3 100644 --- a/apps/ethereum_jsonrpc/mix.exs +++ b/apps/ethereum_jsonrpc/mix.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.MixProject do use Mix.Project @@ -15,15 +16,11 @@ defmodule EthereumJSONRPC.MixProject do plt_add_apps: [:mix], ignore_warnings: "../../.dialyzer_ignore.exs" ], - elixir: "~> 1.17", + elixir: "~> 1.19", elixirc_paths: elixirc_paths(Mix.env()), lockfile: "../../mix.lock", - preferred_cli_env: [ - credo: :test, - dialyzer: :test - ], start_permanent: Mix.env() == :prod, - version: "8.0.2" + version: "11.2.2" ] end @@ -31,10 +28,14 @@ defmodule EthereumJSONRPC.MixProject do def application do [ mod: {EthereumJSONRPC.Application, []}, - extra_applications: [:logger] + extra_applications: [:logger, :tesla] ] end + def cli do + [preferred_envs: [credo: :test, dialyzer: :test]] + end + defp aliases(env) do [ # to match behavior of `mix test` from project root, which needs to not start applications for `indexer` to @@ -67,13 +68,15 @@ defmodule EthereumJSONRPC.MixProject do {:jason, "~> 1.3"}, # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, + {:logger_json, "~> 7.0"}, # Mocking `EthereumJSONRPC.Transport` and `EthereumJSONRPC.HTTP` so we avoid hitting real chains for local testing - {:mox, "~> 1.0", only: [:test]}, - {:prometheus_ex, git: "https://github.com/lanodan/prometheus.ex", branch: "fix/elixir-1.14", override: true}, + {:mox, "~> 1.1.0", only: [:test]}, + {:prometheus_ex, "~> 5.1.0", override: true}, # Tracing {:spandex, "~> 3.0"}, # `:spandex` integration with Datadog {:spandex_datadog, "~> 1.0"}, + {:tesla, "~> 1.20.0"}, # Convert unix timestamps in JSONRPC to DateTimes {:timex, "~> 3.7.1"}, # Encode/decode function names and arguments @@ -85,9 +88,8 @@ defmodule EthereumJSONRPC.MixProject do {:decorator, "~> 1.4"}, {:hackney, "~> 1.18"}, {:poolboy, "~> 1.5.2"}, - {:logger_json, "~> 5.1"}, {:utils, in_umbrella: true}, - {:websockex, "~> 0.4.3"} + {:websockex, "~> 0.5.0"} ] end end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs index 47f01c803b24..834d7680baf1 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/block_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.BlockTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/blocks_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/blocks_test.exs index 37626d801d89..c158b3d2dae4 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/blocks_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/blocks_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.BlocksTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/contract_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/contract_test.exs index 76100d469b3c..1fabb6e498f1 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/contract_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/contract_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.ContractTest do use ExUnit.Case, async: true @@ -136,5 +137,182 @@ defmodule EthereumJSONRPC.ContractTest do json_rpc_named_arguments ) == blockchain_result end + + test "handles individual request processing errors without affecting other requests" do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + abi = [ + %{ + "constant" => true, + "inputs" => [], + "name" => "get1", + "method_id" => "054c1a75", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + %{ + "constant" => true, + "inputs" => [], + "name" => "get2", + "method_id" => "d2178b08", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + %{ + "constant" => true, + "inputs" => [], + "name" => "get3", + "method_id" => "8321045c", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + } + ] + + contract_address = "0x0000000000000000000000000000000000000000" + + functions = [ + %{contract_address: contract_address, method_id: "054c1a75", args: []}, + %{contract_address: contract_address, method_id: "d2178b08", args: []}, + %{contract_address: contract_address, method_id: "8321045c", args: []} + ] + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn requests, _options -> + {:ok, + requests + |> Enum.map(fn + %{id: id, method: "eth_call", params: [%{data: "0x054c1a75", to: ^contract_address}, "latest"]} -> + %{ + id: id, + result: "0x000000000000000000000000000000000000000000000000000000000000002a" + } + + %{id: id, method: "eth_call", params: [%{data: "0xd2178b08", to: ^contract_address}, "latest"]} -> + # This will cause a decoding error due to invalid hex + %{ + id: id, + result: "0xINVALID" + } + + %{id: id, method: "eth_call", params: [%{data: "0x8321045c", to: ^contract_address}, "latest"]} -> + %{ + id: id, + result: "0x0000000000000000000000000000000000000000000000000000000000000064" + } + end) + |> Enum.shuffle()} + end + ) + + results = + EthereumJSONRPC.execute_contract_functions( + functions, + abi, + json_rpc_named_arguments + ) + + # First request should succeed + assert {:ok, [42]} = Enum.at(results, 0) + + # Second request should fail with an error (not affecting others) + assert {:error, _error_message} = Enum.at(results, 1) + + # Third request should succeed + assert {:ok, [100]} = Enum.at(results, 2) + end + + test "handles bad_gateway error for entire batch" do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + abi = [ + %{ + "constant" => false, + "inputs" => [], + "name" => "get", + "method_id" => "6d4ce63c", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + ] + + contract_address = "0x0000000000000000000000000000000000000000" + + functions = [ + %{contract_address: contract_address, method_id: "6d4ce63c", args: []}, + %{contract_address: contract_address, method_id: "6d4ce63c", args: []} + ] + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn _requests, _options -> + {:error, {:bad_gateway, "http://localhost:8545"}} + end + ) + + blockchain_result = [ + {:error, "Bad gateway"}, + {:error, "Bad gateway"} + ] + + assert EthereumJSONRPC.execute_contract_functions( + functions, + abi, + json_rpc_named_arguments + ) == blockchain_result + end + + test "handles atom error for entire batch" do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + abi = [ + %{ + "constant" => false, + "inputs" => [], + "name" => "get", + "method_id" => "6d4ce63c", + "outputs" => [%{"name" => "", "type" => "uint256"}], + "payable" => false, + "stateMutability" => "nonpayable", + "type" => "function" + } + ] + + contract_address = "0x0000000000000000000000000000000000000000" + + functions = [ + %{contract_address: contract_address, method_id: "6d4ce63c", args: []}, + %{contract_address: contract_address, method_id: "6d4ce63c", args: []} + ] + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn _requests, _options -> + {:error, :timeout} + end + ) + + blockchain_result = [ + {:error, "timeout"}, + {:error, "timeout"} + ] + + assert EthereumJSONRPC.execute_contract_functions( + functions, + abi, + json_rpc_named_arguments + ) == blockchain_result + end end end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/encoder_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/encoder_test.exs index 7539b8e582ba..79dbcc8dc0df 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/encoder_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/encoder_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.EncoderTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/fetched_beneficiaries_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/fetched_beneficiaries_test.exs index b2450f2d6d33..de213edabf13 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/fetched_beneficiaries_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/fetched_beneficiaries_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FetchedBeneficiariesTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/filecoin_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/filecoin_test.exs index 122c428a8f97..dbffa49bc79a 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/filecoin_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/filecoin_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.FilecoinTest do use EthereumJSONRPC.Case, async: false @@ -105,7 +106,7 @@ defmodule EthereumJSONRPC.FilecoinTest do "0x868e10c40000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000", output: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000051000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000578449007d2903b8000000004a000190f76c1adff180004c00907e2dd41a18e7c7a7f2bd82581a0001916cb98a2c3dfb67a389a588fb0e593f762dd6c9195851235601fba7e16707ee65746d4671e80aa2bb15bc7d6ebe3b000000000000000000", - value: 0 + value: nil }, %{ block_number: ^block_number, @@ -123,7 +124,7 @@ defmodule EthereumJSONRPC.FilecoinTest do "0x868e10c400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000051000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000f2850d8182004081820d58c0960ee115a7a4b6f2fd36a83da26c608d49e4160a3737655d0f637b81be81b018539809d35519b0b75ca06304b3b4d40c810e50b954e82c5119a8b4a64c3e762a7ae8a2d465d1cd5bf096c87c56ab0da879568378e5a2368c902eea9898cf1e2a1974ddb479ec6257b69aca7734d3b3e1e70428c77f9e528ffcb3dc3f050f0193c2cc005927a765c39a4931d67fb29aaba6e99f2c7d2566b98fdbf30d6e15a2bbd63b8fa059cfad231ccba1d8964542b50419eaad4bc442d3a1dc1f41941944c11a0037e5f45820d41114bb6abbf966c2528f5705447a53ee37b7055cd4478503ea5eaf1fe165c60000000000000000000000000000", output: "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000", - value: 0 + value: nil } ]} = Filecoin.fetch_block_internal_transactions( @@ -186,7 +187,7 @@ defmodule EthereumJSONRPC.FilecoinTest do gas_used: 29_242_108, init: "0xfe", created_contract_code: "0xfe", - value: 0 + value: nil } ]} = Filecoin.fetch_block_internal_transactions( diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/call_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/call_test.exs index 686ba4f13a1d..4b5296a26213 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/call_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/call_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.CallTest do use ExUnit.Case, async: true @@ -41,7 +42,7 @@ defmodule EthereumJSONRPC.Geth.CallTest do transaction_hash: "0xbc38745b826f058ed2f6c93fa5b145323857f06bbb5230b6a6a50e09e0915857", transaction_index: 0, type: "call", - value: 0, + value: nil, error: "execution reverted" } end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/calls_tests.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/calls_tests.exs index 6d64dabd6220..fd6ba2cedd3d 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/calls_tests.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/calls_tests.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.CallsTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs index f2a8da987e2b..0d55feef46bf 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth/tracer_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Geth.TracerTest do use EthereumJSONRPC.Case, async: false diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth_test.exs index 6617bbc2423a..a3ac185aed22 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/geth_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.GethTest do use EthereumJSONRPC.Case, async: false @@ -484,7 +485,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: "0xb342cafc6ac552c3be2090561453204c8784caf025ac8267320834e4cd163d96", transaction_index: 13, type: "stop", - value: 0 + value: nil } ]} = Geth.fetch_internal_transactions([transaction_params], json_rpc_named_arguments) end @@ -554,6 +555,109 @@ defmodule EthereumJSONRPC.GethTest do assert uppercase_result == lowercase_result end + + test "fallback 'to' field to the parent object for selfdestruct", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + transaction_hash = "0xb342cafc6ac552c3be2090561453204c8784caf025ac8267320834e4cd163d96" + block_number = 3_287_375 + transaction_index = 13 + + transaction_params = %{ + block_number: block_number, + transaction_index: transaction_index, + hash_data: transaction_hash + } + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn + [%{id: id, params: [^transaction_hash, %{"tracer" => "callTracer"}]}], _ -> + {:ok, + [ + %{ + id: id, + result: %{ + "calls" => [ + %{ + "from" => "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", + "gas" => "0x12816", + "gasUsed" => "0x229e", + "input" => "0x", + "output" => "0x", + "to" => "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "type" => "CALL", + "value" => "0x0" + }, + %{ + "from" => "0x0000000000000000000000000000000000000000", + "gas" => "0x0", + "gasUsed" => "0x0", + "input" => "0x", + "type" => "SELFDESTRUCT" + } + ], + "from" => "0xd2ca697bb0114ca4f6a2ed28a5896d280a46d61b", + "gas" => "0x6799", + "gasUsed" => "0x6642", + "input" => "0x092a5cce", + "to" => "0x97f09972913935c098096d364a286c7b941070b3", + "type" => "CALL", + "value" => "0x0" + } + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, Geth, tracer: "call_tracer", debug_trace_timeout: "5s") + + assert {:ok, + [ + %{ + index: 0, + input: "0x092a5cce", + output: "0x", + type: "call", + value: nil, + call_type: "call", + block_number: ^block_number, + gas_used: 26178, + transaction_hash: ^transaction_hash, + transaction_index: 13, + gas: 26521, + from_address_hash: "0xd2ca697bb0114ca4f6a2ed28a5896d280a46d61b", + to_address_hash: "0x97f09972913935c098096d364a286c7b941070b3", + trace_address: [] + }, + %{ + index: 1, + input: "0x", + output: "0x", + type: "call", + value: nil, + call_type: "call", + block_number: ^block_number, + gas_used: 8862, + transaction_hash: ^transaction_hash, + transaction_index: 13, + gas: 75798, + from_address_hash: "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", + to_address_hash: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + trace_address: [0] + }, + %{ + index: 2, + type: "selfdestruct", + value: nil, + block_number: ^block_number, + gas_used: 0, + transaction_hash: ^transaction_hash, + transaction_index: 13, + gas: 0, + from_address_hash: "0x0000000000000000000000000000000000000000", + to_address_hash: "0x97f09972913935c098096d364a286c7b941070b3", + trace_address: [1] + } + ]} = Geth.fetch_internal_transactions([transaction_params], json_rpc_named_arguments) + end end describe "fetch_block_internal_transactions/1" do @@ -622,7 +726,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: ^transaction_hash, transaction_index: 0, type: "call", - value: 0 + value: nil }, %{ block_number: 3_287_375, @@ -639,7 +743,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: ^transaction_hash, transaction_index: 0, type: "call", - value: 0 + value: nil } ]} = Geth.fetch_block_internal_transactions([block_number], json_rpc_named_arguments) end @@ -742,7 +846,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: ^transaction_hash_1, transaction_index: 0, type: "call", - value: 0 + value: nil }, %{ block_number: ^block_number_1, @@ -759,7 +863,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: ^transaction_hash_1, transaction_index: 0, type: "call", - value: 0 + value: nil }, %{ block_number: ^block_number_2, @@ -776,7 +880,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: ^transaction_hash_2, transaction_index: 0, type: "call", - value: 0 + value: nil }, %{ block_number: ^block_number_2, @@ -793,7 +897,7 @@ defmodule EthereumJSONRPC.GethTest do transaction_hash: ^transaction_hash_2, transaction_index: 0, type: "call", - value: 0 + value: nil } ]} = Geth.fetch_block_internal_transactions([block_number_1, block_number_2], json_rpc_named_arguments) end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs index d72721d62ab8..ed0a2dc39a03 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/http/mox_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.HTTP.MoxTest do @moduledoc """ Tests differences in behavior of `EthereumJSONRPC` when `EthereumJSONRPC.HTTP` is used as the transport that are too @@ -6,6 +7,8 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do use ExUnit.Case, async: true + import ExUnit.CaptureLog, only: [capture_log: 1] + import EthereumJSONRPC, only: [request: 1] import EthereumJSONRPC.HTTP.Case import Mox @@ -27,6 +30,8 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do setup :verify_on_exit! + @moduletag :capture_log + describe "json_rpc/2" do # regression test for https://github.com/poanetwork/blockscout/issues/254 # @@ -36,6 +41,13 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do test "transparently splits batch payloads that would trigger a 413 Request Entity Too Large", %{ json_rpc_named_arguments: json_rpc_named_arguments } do + config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, Keyword.put(config, :batch_size, 15000)) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, config) + end) + if json_rpc_named_arguments[:transport_options][:http] == EthereumJSONRPC.HTTP.Mox do EthereumJSONRPC.HTTP.Mox |> expect(:json_rpc, 2, fn _url, json, _headers, _options -> @@ -281,6 +293,57 @@ defmodule EthereumJSONRPC.HTTP.MoxTest do assert MapSet.equal?(response_block_number_set, block_number_set) end + + test "splits batch into chunks with configured batch_size", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, Keyword.put(config, :batch_size, 2)) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.HTTP, config) + end) + + if json_rpc_named_arguments[:transport_options][:http] == EthereumJSONRPC.HTTP.Mox do + EthereumJSONRPC.HTTP.Mox + |> expect(:json_rpc, 5, fn _url, json, _headers, _options -> + assert [%{}, %{}] = decoded = Jason.decode!(json) + + body = + decoded + |> Enum.map(fn %{"id" => id} -> + %{jsonrpc: "2.0", id: id, result: %{number: EthereumJSONRPC.integer_to_quantity(id)}} + end) + |> Jason.encode!() + + {:ok, %{body: body, status_code: 200}} + end) + end + + block_numbers = 0..9 + + payload = + block_numbers + |> Stream.with_index() + |> Enum.map(&get_block_by_number_request/1) + + log = + capture_log(fn -> + assert {:ok, responses} = EthereumJSONRPC.json_rpc(payload, json_rpc_named_arguments) + assert Enum.count(responses) == Enum.count(block_numbers) + + block_number_set = MapSet.new(block_numbers) + + response_block_number_set = + Enum.into(responses, MapSet.new(), fn %{result: %{"number" => quantity}} -> + EthereumJSONRPC.quantity_to_integer(quantity) + end) + + assert MapSet.equal?(response_block_number_set, block_number_set) + end) + + assert log =~ "Big amount of node requests in batch: 10, 1st_chunk_1st_request: %{" + end end defp assert_payload_too_large(payload, json_rpc_named_arguments) do diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/log_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/log_test.exs index 3237ea830374..ca1be781bb91 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/log_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/log_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.LogTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/mox_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/mox_test.exs index f0c4fbaf2d65..937c4a49cf5a 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/mox_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/mox_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.MoxTest do @moduledoc """ Tests that only work with `EthereumJSONRPC.Mox` because they need precise data back from the network that can't be diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/action_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/action_test.exs index 467a9938bbce..b73252cc6377 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/action_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/action_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.Trace.ActionTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/result_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/result_test.exs index cbd19a3a54c1..b958067bf895 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/result_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace/result_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.Trace.ResultTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace_test.exs index 5a75a6d2aef2..d1998929504c 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind/trace_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Nethermind.TraceTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind_test.exs index 0d7eb0ad2988..74099b496d27 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/nethermind_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.NethermindTest do use ExUnit.Case, async: true use EthereumJSONRPC.Case @@ -96,7 +97,7 @@ defmodule EthereumJSONRPC.NethermindTest do trace_address: trace_address, transaction_hash: transaction_hash, type: type, - value: value, + value: nil, transaction_index: transaction_index } ] @@ -190,10 +191,11 @@ defmodule EthereumJSONRPC.NethermindTest do trace_address: trace_address, transaction_hash: transaction_hash, type: type, - value: value, + value: nil, transaction_index: transaction_index }, block_hash: block_hash, + block_number: block_number, json_rpc_named_arguments: [ transport: EthereumJSONRPC.Mox, transport_options: [], diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs index 763c4b62d1eb..033f2b4f71ab 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipt_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.ReceiptTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipts_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipts_test.exs index 112ba413deb0..c16a569b8bf3 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipts_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/receipts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.ReceiptsTest do use ExUnit.Case, async: true use EthereumJSONRPC.Case diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs index 0eaa6c1f3159..f60abd2ce51c 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/request_coordinator_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RequestCoordinatorTest do use ExUnit.Case use EthereumJSONRPC.Case diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/rolling_window_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/rolling_window_test.exs index 3ea1954ae331..f74c2c815c84 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/rolling_window_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/rolling_window_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.RollingWindowTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs index fd2715aa7e3e..7c75fa187e36 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transaction_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.TransactionTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transactions_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transactions_test.exs index 0ea88f015094..bfa363c28457 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transactions_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/transactions_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.TransactionsTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncle_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncle_test.exs index 7cc8f18acb04..071c173f3ba7 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncle_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncle_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.UncleTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncles_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncles_test.exs index b38e3ad1cd89..aaad5b26f1ac 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncles_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/uncles_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.UnclesTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/common_helper_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/common_helper_test.exs index 12dd93c30923..02e7131b9199 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/common_helper_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/common_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Utility.CommonHelperTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/ranges_helper_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/ranges_helper_test.exs new file mode 100644 index 000000000000..ca964c3d50ca --- /dev/null +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/utility/ranges_helper_test.exs @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule EthereumJSONRPC.Utility.RangesHelperTest do + use ExUnit.Case, async: true + + alias EthereumJSONRPC.Utility.RangesHelper + + describe "sanitize_ranges/1" do + test "list of ranges" do + assert RangesHelper.sanitize_ranges([1..2, 1..4, 3..6, 7..9, 11..12]) == [1..9, 11..12] + assert RangesHelper.sanitize_ranges([10..7//-1, 6..4//-1, 3..1//-1]) == [10..1//-1] + assert RangesHelper.sanitize_ranges([10..7//-1, 5..3//-1]) == [5..3//-1, 10..7//-1] + assert RangesHelper.sanitize_ranges([1..3, 7..9, 5..6]) == [1..3, 5..9] + assert RangesHelper.sanitize_ranges([1..3, 5..7, 4..4]) == [1..7] + assert RangesHelper.sanitize_ranges([]) == [] + end + end + + describe "parse_block_ranges/1" do + test "ranges string" do + assert RangesHelper.parse_block_ranges("100..200,300..400,500..latest") == [100..200, 300..400, 500] + assert RangesHelper.parse_block_ranges("100..200,150..300") == [100..300] + assert RangesHelper.parse_block_ranges("") == [] + end + end +end diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs index 245bf124ee1a..cfc762a44c1b 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket/web_socket_client_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.WebSocketClientTest do use ExUnit.Case diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket_test.exs index 0433d59a115b..f77e97771ee6 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/web_socket_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocketTest do use EthereumJSONRPC.WebSocket.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs index 8f8214c62d25..83cd46dbf68b 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawal_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WithdrawalTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs index a4d4b4845968..2d56affe145a 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/withdrawals_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WithdrawalsTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/zilliqa_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/zilliqa_test.exs index 41e67123b9eb..6542aec22ea5 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/zilliqa_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc/zilliqa_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.ZilliqaTest do use ExUnit.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs index f640a3b32e0f..27a09f53179e 100644 --- a/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs +++ b/apps/ethereum_jsonrpc/test/ethereum_jsonrpc_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPCTest do use EthereumJSONRPC.Case, async: true diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case.ex index fbbee61574da..4cefea71defb 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Case do @moduledoc """ Adds `json_rpc_named_arguments` and `subscribe_named_arguments` to context. @@ -15,7 +16,8 @@ defmodule EthereumJSONRPC.Case do environment variable to determine `:json_rpc_named_arguments` `:transport_options` `:url`. Failure to set `ETHEREUM_JSONRPC_HTTP_URL` in this case will raise an `ArgumentError`. - * `EthereumJSONRPC.HTTP.HTTPoison` - HTTP responses from calls to real chain URLs + * `EthereumJSONRPC.HTTP.HTTPoison` - HTTP responses from calls to real chain URLs (using HTTPoison client) + * `EthereumJSONRPC.HTTP.Tesla` - HTTP responses from calls to real chain URLs (using Tesla.Mint client) * `EthereumJSONRPC.HTTP.Mox` - mock HTTP responses, so can be used for HTTP-only behavior like status codes. ## `subscribe_named_arguments` diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/filecoin/mox.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/filecoin/mox.ex index 12510755ad9e..9e771898c521 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/filecoin/mox.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/filecoin/mox.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Case.Filecoin.Mox do @moduledoc """ `EthereumJSONRPC.Case` for mocking connecting to Filecoin using `Mox` diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/http_websocket.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/http_websocket.ex index 403daf407b1f..381dfced616b 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/http_websocket.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/http_websocket.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Case.Geth.HTTPWebSocket do @moduledoc """ `EthereumJSONRPC.Case` for connecting to Geth using `EthereumJSONRPC.HTTP` for `json_rpc_named_arguments` @@ -10,8 +11,8 @@ defmodule EthereumJSONRPC.Case.Geth.HTTPWebSocket do :json_rpc_named_arguments, transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, - http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]], + http: EthereumJSONRPC.HTTP.Tesla, + http_options: [recv_timeout: 60_000, timeout: 60_000, pool: :ethereum_jsonrpc], urls: ["https://mainnet.infura.io/8lTvJTKmHPCHazkneJsY"] ], variant: EthereumJSONRPC.Geth diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/mox.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/mox.ex index 41a2593cb946..f49bc81f40c7 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/mox.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/geth/mox.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Case.Geth.Mox do @moduledoc """ `EthereumJSONRPC.Case` for mocking connecting to Geth using `Mox` diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/http_websocket.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/http_websocket.ex index 4f516d59613f..4025592def0e 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/http_websocket.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/http_websocket.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Case.Nethermind.HTTPWebSocket do @moduledoc """ `EthereumJSONRPC.Case` for connecting to Nethermind using `EthereumJSONRPC.HTTP` for `json_rpc_named_arguments` @@ -10,8 +11,8 @@ defmodule EthereumJSONRPC.Case.Nethermind.HTTPWebSocket do :json_rpc_named_arguments, transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, - http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]], + http: EthereumJSONRPC.HTTP.Tesla, + http_options: [recv_timeout: 60_000, timeout: 60_000, pool: :ethereum_jsonrpc], urls: ["http://3.85.253.242:8545"] ], variant: EthereumJSONRPC.Nethermind diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/mox.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/mox.ex index 18b25d4cca55..4369aebe8763 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/mox.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/case/nethermind/mox.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.Case.Nethermind.Mox do @moduledoc """ `EthereumJSONRPC.Case` for mocking connecting to Nethermind using `Mox` diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/http/case.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/http/case.ex index 767f41e4d58f..453dd96348bb 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/http/case.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/http/case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.HTTP.Case do use ExUnit.CaseTemplate @@ -21,7 +22,7 @@ defmodule EthereumJSONRPC.HTTP.Case do end def http_options do - [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]] + [recv_timeout: 60_000, timeout: 60_000, pool: :ethereum_jsonrpc] end def url do diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case.ex index 7e3eb6356245..050f98fbda34 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.Case do use ExUnit.CaseTemplate diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/geth.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/geth.ex index 957d31e7a9b8..697ed3363e13 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/geth.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/geth.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.Case.Geth do @moduledoc """ `EthereumJSONRPC.WebSocket.Case` connecting to Geth. diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/mox.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/mox.ex index 465bd1d2da2b..2445fd835b32 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/mox.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/mox.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.Case.Mox do @moduledoc """ `EthereumJSONRPC.WebSocket.Case` using `Mox` diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/nethermind.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/nethermind.ex index 5d870bfa1e03..e702b1412002 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/nethermind.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/case/nethermind.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule EthereumJSONRPC.WebSocket.Case.Nethermind do @moduledoc """ `EthereumJSONRPC.WebSocket.Case` connecting to Nethermind. diff --git a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex index bdb7989d149a..0862d0a28746 100644 --- a/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex +++ b/apps/ethereum_jsonrpc/test/support/ethereum_jsonrpc/web_socket/cowboy/websocket_handler.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # See https://github.com/ninenines/cowboy/blob/2.6.1/examples/websocket/src/ws_h.erl # See https://ninenines.eu/docs/en/cowboy/2.6/guide/ws_handlers/ defmodule EthereumJSONRPC.WebSocket.Cowboy.WebSocketHandler do diff --git a/apps/ethereum_jsonrpc/test/test_helper.exs b/apps/ethereum_jsonrpc/test/test_helper.exs index 4853c7ee08f8..decbd9685828 100644 --- a/apps/ethereum_jsonrpc/test/test_helper.exs +++ b/apps/ethereum_jsonrpc/test/test_helper.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # https://github.com/CircleCI-Public/circleci-demo-elixir-phoenix/blob/a89de33a01df67b6773ac90adc74c34367a4a2d6/test/test_helper.exs#L1-L3 junit_folder = Mix.Project.build_path() <> "/junit/#{Mix.Project.config()[:app]}" File.mkdir_p!(junit_folder) diff --git a/apps/explorer/.sobelow-conf b/apps/explorer/.sobelow-conf index c2b7ff895f31..965cebfbbdf0 100644 --- a/apps/explorer/.sobelow-conf +++ b/apps/explorer/.sobelow-conf @@ -7,6 +7,7 @@ ignore: ["Config.HTTPS"], ignore_files: [ "lib/explorer/smart_contract/solidity/code_compiler.ex", - "lib/explorer/smart_contract/vyper/code_compiler.ex" + "lib/explorer/smart_contract/vyper/code_compiler.ex", + "lib/explorer/chain/csv_export/worker.ex" ] ] diff --git a/apps/explorer/README.md b/apps/explorer/README.md index c283c1c5214d..5ae360e139c6 100644 --- a/apps/explorer/README.md +++ b/apps/explorer/README.md @@ -31,8 +31,8 @@ To get BlockScout up and running locally: ### Benchmarking -#### `Explorer.Chain.recent_collated_transactions/0` +#### `Explorer.Chain.Transaction.recent_collated_transactions/0` * Reset the test database: `MIX_ENV=test mix do ecto.drop, ecto.create, ecto.migrate` -* Change `tag` in `benchmarks/explorer/chain/recent_collated_transactions.exs` to a new value, so that it will compare against the old values saved in `benchmarks/explorer/chain/recent_collated_transactions.benchee` -* Run the benchmark: `MIX_ENV=test mix run benchmarks/explorer/chain/recent_collated_transactions.exs` +* Change `tag` in `benchmarks/explorer/chain/transaction/recent_collated_transactions.exs` to a new value, so that it will compare against the old values saved in `benchmarks/explorer/chain/transaction/recent_collated_transactions.benchee` +* Run the benchmark: `MIX_ENV=test mix run benchmarks/explorer/chain/transaction/recent_collated_transactions.exs` diff --git a/apps/explorer/benchmarks/explorer/chain/tokens.exs b/apps/explorer/benchmarks/explorer/chain/tokens.exs new file mode 100644 index 000000000000..6866f1bad433 --- /dev/null +++ b/apps/explorer/benchmarks/explorer/chain/tokens.exs @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FiatValueBenchmark do + @moduledoc """ + Benchmark for the performance of fetching fiat value type data from the database. + """ + + use Explorer.BenchmarkCase + + alias Explorer.Repo + alias Explorer.Chain.Token + alias Explorer.Market.Fetcher.Token, as: TokenFetcher + + def list_tokens do + Benchee.run(%{"Fiat value type performance" => fn _ -> Repo.all(Token) end}, + inputs: + for with_market_data <- [false, true], + enabled_token_fetcher? when with_market_data or not enabled_token_fetcher? <- [false, true], + token_count <- [50, 100, 10000], + into: %{} do + {"#{token_count} tokens#{if with_market_data, do: " with market data", else: ""}#{if enabled_token_fetcher?, do: " with enabled token fetcher", else: ""}", + %{ + token_count: token_count, + with_market_data: with_market_data, + enabled_token_fetcher: enabled_token_fetcher? + }} + end, + before_scenario: fn %{ + token_count: token_count, + with_market_data: with_market_data, + enabled_token_fetcher: enabled_token_fetcher? + } = input -> + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo, ownership_timeout: :infinity) + + Repo.delete_all(Token) + + market_data = fn i -> + if with_market_data do + [fiat_value: Decimal.new(i), circulating_market_cap: Decimal.new(i)] + else + [fiat_value: nil, circulating_market_cap: nil] + end + end + + 1..token_count + |> Enum.each(fn i -> + insert(:token, market_data.(i)) + end) + + if enabled_token_fetcher? do + Application.put_env(:explorer, Explorer.Market.Source, tokens_source: Explorer.Market.Source.OneCoinSource) + TokenFetcher.start_link([]) + end + end, + load: @path, + save: [ + path: @path, + tag: "fiat-value-no-check" + ], + time: 5, + formatters: [Benchee.Formatters.Console] + ) + end +end + +Explorer.Chain.FiatValueBenchmark.list_tokens() diff --git a/apps/explorer/benchmarks/explorer/chain/recent_collated_transactions.benchee b/apps/explorer/benchmarks/explorer/chain/transaction/recent_collated_transactions.benchee similarity index 100% rename from apps/explorer/benchmarks/explorer/chain/recent_collated_transactions.benchee rename to apps/explorer/benchmarks/explorer/chain/transaction/recent_collated_transactions.benchee diff --git a/apps/explorer/benchmarks/explorer/chain/recent_collated_transactions.exs b/apps/explorer/benchmarks/explorer/chain/transaction/recent_collated_transactions.exs similarity index 90% rename from apps/explorer/benchmarks/explorer/chain/recent_collated_transactions.exs rename to apps/explorer/benchmarks/explorer/chain/transaction/recent_collated_transactions.exs index 335844236ea0..01bd51ab3969 100644 --- a/apps/explorer/benchmarks/explorer/chain/recent_collated_transactions.exs +++ b/apps/explorer/benchmarks/explorer/chain/transaction/recent_collated_transactions.exs @@ -1,14 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout path = "benchmarks/explorer/chain/recent_collated_transactions.benchee" import Explorer.Factory -alias Explorer.{Chain, Repo} -alias Explorer.Chain.Block +alias Explorer.Repo +alias Explorer.Chain.{Block, Transaction} Benchee.run( %{ - "Explorer.Chain.recent_collated_transactions" => fn _ -> - Chain.recent_collated_transactions(true) + "Explorer.Chain.Transaction.recent_collated_transactions" => fn _ -> + Transaction.recent_collated_transactions(true) end }, inputs: %{ diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index e9382e5a5195..1ca456c6cb60 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file is responsible for configuring your application # and its dependencies with the aid of the Config module. # @@ -12,6 +13,7 @@ import Config # General application configuration config :explorer, chain_type: ConfigHelper.chain_type(), + chain_identity: ConfigHelper.chain_identity(), ecto_repos: ConfigHelper.repos(), token_functions_reader_max_retries: 3, # for not fully indexed blockchains @@ -78,10 +80,6 @@ config :explorer, Explorer.Chain.Cache.Counters.Blackfort.ValidatorsCount, enable_consolidation: true, update_interval_in_milliseconds: update_interval_in_milliseconds_default -config :explorer, Explorer.Chain.Cache.TransactionActionTokensData, enabled: true - -config :explorer, Explorer.Chain.Cache.TransactionActionUniswapPools, enabled: true - config :explorer, Explorer.Market.Fetcher.Token, enabled: true config :explorer, Explorer.Chain.Cache.Counters.TokenHoldersCount, @@ -110,6 +108,14 @@ config :explorer, Explorer.Chain.Cache.Counters.BlockPriorityFeeCount, config :explorer, Explorer.TokenInstanceOwnerAddressMigration.Supervisor, enabled: true +config :explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, enabled: false + +config :explorer, Explorer.Chain.Mud, enabled: ConfigHelper.parse_bool_env_var("MUD_INDEXER_ENABLED") + +config :explorer, Explorer.Utility.VersionConstantsUpdater, enabled: true + +config :explorer, Explorer.Utility.VersionUpgrade, enabled: true + for migrator <- [ # Background migrations Explorer.Migrator.TransactionsDenormalization, @@ -129,9 +135,15 @@ for migrator <- [ Explorer.Migrator.RefetchContractCodes, Explorer.Migrator.BackfillMultichainSearchDB, Explorer.Migrator.SanitizeVerifiedAddresses, - Explorer.Migrator.SmartContractLanguage, Explorer.Migrator.SanitizeEmptyContractCodeAddresses, - Explorer.Migrator.BackfillMetadataURL + Explorer.Migrator.BackfillMetadataURL, + Explorer.Migrator.SanitizeErc1155TokenBalancesWithoutTokenIds, + Explorer.Migrator.MergeAdjacentMissingBlockRanges, + Explorer.Migrator.UnescapeQuotesInTokens, + Explorer.Migrator.UnescapeAmpersandsInTokens, + Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, + Explorer.Migrator.EmptyInternalTransactionsData, + Explorer.Migrator.FillInternalTransactionsAddressIds ] do config :explorer, migrator, enabled: true end @@ -163,7 +175,27 @@ for index_operation <- [ Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsToAddressHashWithPendingIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountDescPartialIndex, - Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBlockNumberTransactionIndexNotNull, + Explorer.Migrator.HeavyDbIndexOperation.CreateSmartContractAdditionalSourcesUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropTokenInstancesTokenIdIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateTokensNamePartialFtsIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA, + Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockHashTransactionIndexIndexIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsFromAddressIdPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsToAddressIdPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError, + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesHashContractCodeNotNullIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsToAddressHashPartialIndex ] do config :explorer, index_operation, enabled: true end @@ -180,7 +212,14 @@ config :explorer, Explorer.SmartContract.CertifiedSmartContractCataloger, enable config :explorer, Explorer.Utility.RateLimiter, enabled: true -config :explorer, Explorer.Repo, migration_timestamps: [type: :utc_datetime_usec] +config :explorer, Explorer.Utility.Hammer.Redis, enabled: true +config :explorer, Explorer.Utility.Hammer.ETS, enabled: true + +config :explorer, Explorer.Chain.Health.Monitor, enabled: true + +config :explorer, Explorer.Repo, + migration_timestamps: [type: :utc_datetime_usec], + disconnect_on_error_codes: [:query_canceled] config :explorer, Explorer.Tracer, service: :explorer, @@ -188,18 +227,14 @@ config :explorer, Explorer.Tracer, trace_key: :blockscout config :explorer, - solc_bin_api_url: "https://solc-bin.ethereum.org" + solc_bin_api_url: "https://binaries.soliditylang.org" -config :explorer, :http_adapter, HTTPoison +config :explorer, :http_client, Explorer.HttpClient.Tesla config :explorer, Explorer.Chain.BridgedToken, enabled: ConfigHelper.parse_bool_env_var("BRIDGED_TOKENS_ENABLED") config :logger, :explorer, - # keep synced with `config/config.exs` - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, + metadata: ConfigHelper.logger_metadata(), metadata_filter: [application: :explorer] config :spandex_ecto, SpandexEcto.EctoLogger, @@ -207,6 +242,8 @@ config :spandex_ecto, SpandexEcto.EctoLogger, tracer: Explorer.Tracer, otp_app: :explorer +config :tesla, adapter: Tesla.Adapter.Mint + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/apps/explorer/config/dev.exs b/apps/explorer/config/dev.exs index b6a0f56ad222..0acd9f631553 100644 --- a/apps/explorer/config/dev.exs +++ b/apps/explorer/config/dev.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # Configure your database @@ -13,6 +14,7 @@ for repo <- [ Explorer.Repo.Account, Explorer.Repo.BridgedTokens, Explorer.Repo.ShrunkInternalTransactions, + Explorer.Repo.EventNotifications, # Chain-type dependent repos Explorer.Repo.Arbitrum, @@ -23,7 +25,6 @@ for repo <- [ Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, @@ -38,16 +39,12 @@ end config :explorer, Explorer.Tracer, env: "dev", disabled?: true -config :logger, :explorer, - level: :debug, - path: Path.absname("logs/dev/explorer.log") +config :logger, :explorer, path: Path.absname("logs/dev/explorer.log") config :logger, :reading_token_functions, - level: :debug, path: Path.absname("logs/dev/explorer/tokens/reading_functions.log"), metadata_filter: [fetcher: :token_functions] config :logger, :token_instances, - level: :debug, path: Path.absname("logs/dev/explorer/tokens/token_instances.log"), metadata_filter: [fetcher: :token_instances] diff --git a/apps/explorer/config/dev/anvil.exs b/apps/explorer/config/dev/anvil.exs index be2764b3704f..2c60115e39ed 100644 --- a/apps/explorer/config/dev/anvil.exs +++ b/apps/explorer/config/dev/anvil.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), fallback_urls: ConfigHelper.parse_urls_list(:fallback_http), @@ -19,7 +17,7 @@ config :explorer, method_to_url: [ eth_call: :eth_call ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Anvil ], @@ -27,8 +25,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Anvil ] diff --git a/apps/explorer/config/dev/besu.exs b/apps/explorer/config/dev/besu.exs index 2493d3021123..d0a434683641 100644 --- a/apps/explorer/config/dev/besu.exs +++ b/apps/explorer/config/dev/besu.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Besu ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Besu ] diff --git a/apps/explorer/config/dev/erigon.exs b/apps/explorer/config/dev/erigon.exs index 0275a0a1067e..e0f6dbe5bc08 100644 --- a/apps/explorer/config/dev/erigon.exs +++ b/apps/explorer/config/dev/erigon.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Erigon ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Erigon ] diff --git a/apps/explorer/config/dev/filecoin.exs b/apps/explorer/config/dev/filecoin.exs index 9fcf631e1b29..946b9d049020 100644 --- a/apps/explorer/config/dev/filecoin.exs +++ b/apps/explorer/config/dev/filecoin.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http, "http://localhost:1234/rpc/v1"), trace_urls: ConfigHelper.parse_urls_list(:trace, "http://localhost:1234/rpc/v1"), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call, "http://localhost:1234/rpc/v1"), @@ -22,7 +20,7 @@ config :explorer, eth_call: :eth_call, trace_block: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Filecoin ], @@ -30,8 +28,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Filecoin ] diff --git a/apps/explorer/config/dev/geth.exs b/apps/explorer/config/dev/geth.exs index 745a74453dc6..48583ad4ed95 100644 --- a/apps/explorer/config/dev/geth.exs +++ b/apps/explorer/config/dev/geth.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, debug_traceTransaction: :trace, debug_traceBlockByNumber: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Geth ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Geth ] diff --git a/apps/explorer/config/dev/nethermind.exs b/apps/explorer/config/dev/nethermind.exs index 3147355e90e3..43d91848e7a1 100644 --- a/apps/explorer/config/dev/nethermind.exs +++ b/apps/explorer/config/dev/nethermind.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Nethermind ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Nethermind ] diff --git a/apps/explorer/config/dev/rsk.exs b/apps/explorer/config/dev/rsk.exs index 71782fd05901..ca2728bd18a2 100644 --- a/apps/explorer/config/dev/rsk.exs +++ b/apps/explorer/config/dev/rsk.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.RSK ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.RSK ] diff --git a/apps/explorer/config/prod.exs b/apps/explorer/config/prod.exs index b272b96fb969..fb1269d245f9 100644 --- a/apps/explorer/config/prod.exs +++ b/apps/explorer/config/prod.exs @@ -1,11 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # Configures the database config :explorer, Explorer.Repo, prepare: :unnamed, timeout: :timer.seconds(60), - migration_lock: nil, - ssl_opts: [verify: :verify_none] + migration_lock: nil for repo <- [ # Configures API the database @@ -15,6 +15,7 @@ for repo <- [ Explorer.Repo.Account, Explorer.Repo.BridgedTokens, Explorer.Repo.ShrunkInternalTransactions, + Explorer.Repo.EventNotifications, # Chain-type dependent repos Explorer.Repo.Arbitrum, @@ -25,7 +26,6 @@ for repo <- [ Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, @@ -37,25 +37,21 @@ for repo <- [ ] do config :explorer, repo, prepare: :unnamed, - timeout: :timer.seconds(60), - ssl_opts: [verify: :verify_none] + timeout: :timer.seconds(60) end config :explorer, Explorer.Tracer, env: "production", disabled?: true config :logger, :explorer, - level: :info, path: Path.absname("logs/prod/explorer.log"), rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :reading_token_functions, - level: :debug, path: Path.absname("logs/prod/explorer/tokens/reading_functions.log"), metadata_filter: [fetcher: :token_functions], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :token_instances, - level: :debug, path: Path.absname("logs/prod/explorer/tokens/token_instances.log"), metadata_filter: [fetcher: :token_instances], rotate: %{max_bytes: 52_428_800, keep: 19} diff --git a/apps/explorer/config/prod/anvil.exs b/apps/explorer/config/prod/anvil.exs index be2764b3704f..2c60115e39ed 100644 --- a/apps/explorer/config/prod/anvil.exs +++ b/apps/explorer/config/prod/anvil.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), fallback_urls: ConfigHelper.parse_urls_list(:fallback_http), @@ -19,7 +17,7 @@ config :explorer, method_to_url: [ eth_call: :eth_call ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Anvil ], @@ -27,8 +25,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Anvil ] diff --git a/apps/explorer/config/prod/besu.exs b/apps/explorer/config/prod/besu.exs index 2493d3021123..d0a434683641 100644 --- a/apps/explorer/config/prod/besu.exs +++ b/apps/explorer/config/prod/besu.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Besu ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Besu ] diff --git a/apps/explorer/config/prod/erigon.exs b/apps/explorer/config/prod/erigon.exs index 0275a0a1067e..e0f6dbe5bc08 100644 --- a/apps/explorer/config/prod/erigon.exs +++ b/apps/explorer/config/prod/erigon.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Erigon ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Erigon ] diff --git a/apps/explorer/config/prod/filecoin.exs b/apps/explorer/config/prod/filecoin.exs index 22c3862f7482..466f429bbce6 100644 --- a/apps/explorer/config/prod/filecoin.exs +++ b/apps/explorer/config/prod/filecoin.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -22,7 +20,7 @@ config :explorer, eth_call: :eth_call, trace_block: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Filecoin ], @@ -30,8 +28,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Filecoin ] diff --git a/apps/explorer/config/prod/geth.exs b/apps/explorer/config/prod/geth.exs index 745a74453dc6..48583ad4ed95 100644 --- a/apps/explorer/config/prod/geth.exs +++ b/apps/explorer/config/prod/geth.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, debug_traceTransaction: :trace, debug_traceBlockByNumber: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Geth ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Geth ] diff --git a/apps/explorer/config/prod/nethermind.exs b/apps/explorer/config/prod/nethermind.exs index 3147355e90e3..43d91848e7a1 100644 --- a/apps/explorer/config/prod/nethermind.exs +++ b/apps/explorer/config/prod/nethermind.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Nethermind ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.Nethermind ] diff --git a/apps/explorer/config/prod/rsk.exs b/apps/explorer/config/prod/rsk.exs index 71782fd05901..ca2728bd18a2 100644 --- a/apps/explorer/config/prod/rsk.exs +++ b/apps/explorer/config/prod/rsk.exs @@ -1,17 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :explorer, json_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -23,7 +21,7 @@ config :explorer, eth_getBalance: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.RSK ], @@ -31,8 +29,8 @@ config :explorer, transport: EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ], variant: EthereumJSONRPC.RSK ] diff --git a/apps/explorer/config/runtime/test.exs b/apps/explorer/config/runtime/test.exs index 99cf467ef614..8a853575d7e4 100644 --- a/apps/explorer/config/runtime/test.exs +++ b/apps/explorer/config/runtime/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant @@ -44,6 +45,11 @@ config :explorer, Explorer.TokenInstanceOwnerAddressMigration.Supervisor, enable config :explorer, Explorer.Utility.RateLimiter, enabled: false +config :explorer, Explorer.Utility.Hammer.Redis, enabled: false +config :explorer, Explorer.Utility.Hammer.ETS, enabled: true + +config :explorer, Explorer.Chain.Health.Monitor, enabled: false + for migrator <- [ # Background migrations Explorer.Migrator.TransactionsDenormalization, @@ -64,9 +70,16 @@ for migrator <- [ Explorer.Migrator.RefetchContractCodes, Explorer.Migrator.BackfillMultichainSearchDB, Explorer.Migrator.SanitizeVerifiedAddresses, - Explorer.Migrator.SmartContractLanguage, Explorer.Migrator.SanitizeEmptyContractCodeAddresses, Explorer.Migrator.BackfillMetadataURL, + Explorer.Migrator.SanitizeErc1155TokenBalancesWithoutTokenIds, + Explorer.Migrator.MergeAdjacentMissingBlockRanges, + Explorer.Migrator.UnescapeQuotesInTokens, + Explorer.Migrator.UnescapeAmpersandsInTokens, + Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, + Explorer.Migrator.DeleteZeroValueInternalTransactions, + Explorer.Migrator.EmptyInternalTransactionsData, + Explorer.Migrator.FillInternalTransactionsAddressIds, # Heavy DB index operations Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockHashIndex, @@ -94,7 +107,27 @@ for migrator <- [ Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsToAddressHashWithPendingIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndex, Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountDescPartialIndex, - Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBlockNumberTransactionIndexNotNull, + Explorer.Migrator.HeavyDbIndexOperation.CreateSmartContractAdditionalSourcesUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropTokenInstancesTokenIdIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateTokensNamePartialFtsIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA, + Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockHashTransactionIndexIndexIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsFromAddressIdPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsToAddressIdPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex, + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError, + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesHashContractCodeNotNullIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex, + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsToAddressHashPartialIndex ] do config :explorer, migrator, enabled: false end @@ -104,6 +137,12 @@ config :explorer, config :indexer, Indexer.Fetcher.TokenInstance.Helper, host_filtering_enabled?: false +# Enable Oban for async CSV export controller tests (testing: :manual from config/test.exs +# ensures jobs are not auto-executed) +config :explorer, Oban, + enabled: true, + queues: [csv_export: 1, csv_export_sanitize: 1] + variant = Variant.get() Code.require_file("#{variant}.exs", "#{__DIR__}/../../../explorer/config/test") diff --git a/apps/explorer/config/test.exs b/apps/explorer/config/test.exs index 675668e45d7a..4672eb0cf155 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # Lower hashing rounds for faster tests config :bcrypt_elixir, log_rounds: 4 -database_url = System.get_env("TEST_DATABASE_URL") +database_url = ConfigHelper.parse_url_env_var("TEST_DATABASE_URL") database = if database_url, do: nil, else: "explorer_test" hostname = if database_url, do: nil, else: "localhost" @@ -20,6 +21,18 @@ config :explorer, Explorer.Repo, migration_lock: nil, log: false +config :explorer, Explorer.Repo.EventNotifications, + database: database, + hostname: hostname, + url: database_url, + pool: Ecto.Adapters.SQL.Sandbox, + # Default of `5_000` was too low for `BlockFetcher` test + ownership_timeout: :timer.minutes(1), + timeout: :timer.seconds(60), + queue_target: 1000, + migration_lock: nil, + log: false + # Configure API database config :explorer, Explorer.Repo.Replica1, database: database, @@ -63,7 +76,6 @@ for repo <- [ Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, @@ -87,21 +99,11 @@ for repo <- [ pool_size: 1 end -config :explorer, Explorer.Repo.PolygonZkevm, - database: database, - hostname: hostname, - url: database_url, - pool: Ecto.Adapters.SQL.Sandbox, - # Default of `5_000` was too low for `BlockFetcher` test - ownership_timeout: :timer.minutes(1), - timeout: :timer.seconds(60), - queue_target: 1000 - -config :logger, :explorer, - level: :warn, - path: Path.absname("logs/test/explorer.log") +config :logger, :explorer, path: Path.absname("logs/test/explorer.log") config :explorer, Explorer.Chain.Fetcher.CheckBytecodeMatchingOnDemand, enabled: false config :explorer, Explorer.Chain.Fetcher.FetchValidatorInfoOnDemand, enabled: false +config :explorer, Explorer.Tags.AddressTag.Cataloger, enabled: false +config :explorer, Explorer.Utility.VersionUpgrade, enabled: false config :tesla, adapter: Explorer.Mock.TeslaAdapter diff --git a/apps/explorer/config/test/anvil.exs b/apps/explorer/config/test/anvil.exs index cebd36b30a84..57169bdaeef8 100644 --- a/apps/explorer/config/test/anvil.exs +++ b/apps/explorer/config/test/anvil.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :explorer, diff --git a/apps/explorer/config/test/besu.exs b/apps/explorer/config/test/besu.exs index 25b35515b4f3..20644ec90e97 100644 --- a/apps/explorer/config/test/besu.exs +++ b/apps/explorer/config/test/besu.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :explorer, diff --git a/apps/explorer/config/test/erigon.exs b/apps/explorer/config/test/erigon.exs index 268b90cf609c..f3aec5e358af 100644 --- a/apps/explorer/config/test/erigon.exs +++ b/apps/explorer/config/test/erigon.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout use Mix.Config config :explorer, diff --git a/apps/explorer/config/test/filecoin.exs b/apps/explorer/config/test/filecoin.exs index e25ea90700c9..44c6110336b4 100644 --- a/apps/explorer/config/test/filecoin.exs +++ b/apps/explorer/config/test/filecoin.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :explorer, diff --git a/apps/explorer/config/test/geth.exs b/apps/explorer/config/test/geth.exs index ba6792324806..27315f6e7561 100644 --- a/apps/explorer/config/test/geth.exs +++ b/apps/explorer/config/test/geth.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :explorer, diff --git a/apps/explorer/config/test/nethermind.exs b/apps/explorer/config/test/nethermind.exs index e43ed5f3b415..98422e218d69 100644 --- a/apps/explorer/config/test/nethermind.exs +++ b/apps/explorer/config/test/nethermind.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :explorer, diff --git a/apps/explorer/config/test/rsk.exs b/apps/explorer/config/test/rsk.exs index 1799a52dc41c..101363d5eb3d 100644 --- a/apps/explorer/config/test/rsk.exs +++ b/apps/explorer/config/test/rsk.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :explorer, diff --git a/apps/explorer/lib/explorer.ex b/apps/explorer/lib/explorer.ex index 0becee7b1aa3..9661f246ad84 100644 --- a/apps/explorer/lib/explorer.ex +++ b/apps/explorer/lib/explorer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer do @moduledoc """ Explorer keeps the contexts that define your domain @@ -19,4 +20,33 @@ defmodule Explorer do def coin_name do Application.get_env(:explorer, :coin_name) end + + @doc """ + Retrieves the current operational mode of the Explorer application. + + The mode determines which components of the Explorer application are active: + - `:all` - Both API web server and blockchain indexer run together + - `:indexer` - Only blockchain indexer modules run without the web server + - `:api` - Only the web server runs without performing indexing operations + - `:media_worker` - Standalone NFT media processing worker mode + + The function first checks if the application is configured as a standalone + media worker. If so, it returns `:media_worker` regardless of the configured + explorer mode. Otherwise, it returns the mode configured for the explorer + application. + + ## Returns + - `:media_worker` if configured as a standalone NFT media worker + - `:all` if both API and indexer components should be active + - `:indexer` if only the indexer component should be active + - `:api` if only the API web server component should be active + """ + @spec mode() :: :all | :indexer | :api | :media_worker + def mode do + if Application.get_env(:nft_media_handler, :standalone_media_worker?) do + :media_worker + else + Application.get_env(:explorer, :mode) + end + end end diff --git a/apps/explorer/lib/explorer/access_helper.ex b/apps/explorer/lib/explorer/access_helper.ex index 5a0ae37001ce..7e4a05e1a788 100644 --- a/apps/explorer/lib/explorer/access_helper.ex +++ b/apps/explorer/lib/explorer/access_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.AccessHelper do @moduledoc """ Helper to restrict access to some pages filtering by address diff --git a/apps/explorer/lib/explorer/account.ex b/apps/explorer/lib/explorer/account.ex index ecbfdda5f42f..8217fe51f280 100644 --- a/apps/explorer/lib/explorer/account.ex +++ b/apps/explorer/lib/explorer/account.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account do @moduledoc """ Context for Account module. @@ -9,7 +10,6 @@ defmodule Explorer.Account do alias Explorer.Account.{ CustomABI, Identity, - PublicTagsRequest, TagAddress, TagTransaction, Watchlist, @@ -72,7 +72,6 @@ defmodule Explorer.Account do {Multi.new() |> Key.merge(primary_identity_id, identities_to_merge_ids) |> CustomABI.merge(primary_identity_id, identities_to_merge_ids) - |> PublicTagsRequest.merge(primary_identity_id, identities_to_merge_ids) |> TagAddress.merge(primary_identity_id, identities_to_merge_ids) |> TagTransaction.merge(primary_identity_id, identities_to_merge_ids) |> Watchlist.acquire_for_merge(primary_identity_id, identities_to_merge_ids) diff --git a/apps/explorer/lib/explorer/account/api/key.ex b/apps/explorer/lib/explorer/account/api/key.ex index 2f86c1fabde2..753bbcda6ea6 100644 --- a/apps/explorer/lib/explorer/account/api/key.ex +++ b/apps/explorer/lib/explorer/account/api/key.ex @@ -1,12 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Api.Key do @moduledoc """ Module is responsible for schema for API keys, keys is used to track number of requests to the API endpoints """ use Explorer.Schema + alias Ecto.{Changeset, UUID} alias Ecto.Multi alias Explorer.Account.Identity - alias Ecto.{Changeset, UUID} alias Explorer.Repo import Ecto.Changeset diff --git a/apps/explorer/lib/explorer/account/api/plan.ex b/apps/explorer/lib/explorer/account/api/plan.ex index 599f133caa2f..66993e06e950 100644 --- a/apps/explorer/lib/explorer/account/api/plan.ex +++ b/apps/explorer/lib/explorer/account/api/plan.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Api.Plan do @moduledoc """ Module is responsible for schema for API plans, each plan contains its name and maximum number of requests per second diff --git a/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex b/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex new file mode 100644 index 000000000000..6228da8ba769 --- /dev/null +++ b/apps/explorer/lib/explorer/account/auth0_to_keycloak_migration.ex @@ -0,0 +1,886 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Account.Auth0ToKeycloakMigration do + @moduledoc """ + Migrates users from Auth0 to Keycloak in bulk using batch APIs. + + Supports merging multiple Auth0 tenants (different Blockscout instances) into + a single Keycloak realm. When a user with the same email already exists in + Keycloak (from a previous tenant migration), the new address is appended to + the existing user's multivalued `address` attribute. Each Blockscout identity + gets the Keycloak ID of the user matching their email. + + ## Phases + 1. Exports all Auth0 users via async job (~5 API calls instead of N) + 2. Matches DB identities with Auth0 export data (in-memory) + 3. Batch-imports users into Keycloak via partial import (~N/500 API calls) + 4. Updates identity UIDs from Auth0 ID to Keycloak UUID + 5. Deletes non-migrated identities (no user data, cascades via DB foreign keys) + + ## Usage + + ### Development (Mix) + + mix auth0_to_keycloak_migrate --dry-run + mix auth0_to_keycloak_migrate --batch-size 200 + + ### Production (Release) + + Use `rpc` to execute on the running node — this is required so that + `Application.put_env` changes (disable account, switch to Keycloak) + take effect on the live system. Do NOT use `eval`, as it spawns a + separate BEAM process whose config changes are discarded on exit. + + bin/blockscout rpc "Explorer.Account.Auth0ToKeycloakMigration.run(dry_run: true)" + bin/blockscout rpc "Explorer.Account.Auth0ToKeycloakMigration.run(batch_size: 200)" + + ## Options + - `:dry_run` - When true, logs what would happen without making changes (default: false) + - `:batch_size` - Number of users per Keycloak partial import batch (default: 500) + + ## Memory + + The Auth0 export is loaded into memory. For ~800k users expect ~1-2GB RAM usage. + """ + use Utils.RuntimeEnvHelper, + keycloak_domain: [:explorer, [Explorer.ThirdPartyIntegrations.Keycloak, :domain]], + keycloak_realm: [:explorer, [Explorer.ThirdPartyIntegrations.Keycloak, :realm]], + keycloak_client_id: [:explorer, [Explorer.ThirdPartyIntegrations.Keycloak, :client_id]], + keycloak_client_secret: [:explorer, [Explorer.ThirdPartyIntegrations.Keycloak, :client_secret]], + chain_id: [:block_scout_web, :chain_id] + + import Ecto.Query + + alias Explorer.{Account, HttpClient, Repo} + alias Explorer.Account.{Api.Key, CustomABI, Identity, TagAddress, TagTransaction, Watchlist, WatchlistAddress} + alias Explorer.ThirdPartyIntegrations.{Auth0, Keycloak} + alias Explorer.ThirdPartyIntegrations.Auth0.Internal, as: Auth0Internal + alias OAuth2.Client + alias Ueberauth.Strategy.Auth0.OAuth + + require Logger + + @json_headers [{"content-type", "application/json"}] + @auth0_json_headers [{"Content-type", "application/json"}] + + # Auth0 export job + @export_min_poll_interval_ms 3_000 + @export_max_poll_interval_ms 30_000 + @export_max_total_wait_ms 1_800_000 + + # Keycloak partial import + @default_batch_size 500 + @batch_pause_ms 1_000 + + @spec run(keyword()) :: [{:ok, String.t(), String.t()} | {:error, String.t(), any()}] + def run(opts \\ []) do + with :ok <- check_auth0_configured(), + :ok <- check_keycloak_configured() do + do_run(opts) + else + {:error, message} -> + Logger.error(message) + [] + end + end + + defp check_auth0_configured do + if Auth0.enabled?(), do: :ok, else: {:error, "Auth0 is not configured. Cannot read source users."} + end + + defp check_keycloak_configured do + if Keycloak.enabled?(), do: :ok, else: {:error, "Keycloak is not configured. Cannot create target users."} + end + + defp do_run(opts) do + dry_run = Keyword.get(opts, :dry_run, false) + batch_size = Keyword.get(opts, :batch_size, @default_batch_size) + + unless dry_run, do: disable_account() + + case run_phases(dry_run, batch_size) do + {:ok, results, migrated_ids} -> + unless dry_run, do: finalize(results, migrated_ids) + results + + {:error, reason} -> + Logger.error("Migration failed: #{inspect(reason)}") + unless dry_run, do: enable_account() + [] + end + end + + defp run_phases(dry_run, batch_size) do + # Phase 1: Export all Auth0 users + Logger.info("Phase 1: Exporting Auth0 users...") + + with {:ok, auth0_users} <- export_auth0_users(), + # Phase 2: Load identities and match with Auth0 data + Logger.info("Phase 2: Loading identities and matching with Auth0 data..."), + identities = load_auth0_identities(), + {[_ | _] = items, skipped_with_data} <- build_migration_items(identities, auth0_users) do + execute_or_dry_run(items, skipped_with_data, dry_run, batch_size) + else + {[], skipped_with_data} -> + Logger.info("No identities to migrate") + {:ok, [], MapSet.new(skipped_with_data)} + + {:error, _} = error -> + error + end + end + + defp execute_or_dry_run(items, skipped_with_data, dry_run, batch_size) do + Logger.info("Found #{Enum.count(items)} identities to migrate#{if dry_run, do: " (DRY RUN)"}") + + # Protect both migrated identities and skipped-with-data identities from deletion + protected_ids = + items + |> MapSet.new(& &1.identity_id) + |> MapSet.union(MapSet.new(skipped_with_data)) + + if dry_run do + log_dry_run(items) + log_dry_run_deletion_count(protected_ids) + {:ok, [], protected_ids} + else + # Phase 3: Batch import to Keycloak + Logger.info("Phase 3: Importing users to Keycloak...") + keycloak_map = batch_import_to_keycloak(items, batch_size) + + # Phase 4: Update identity UIDs + Logger.info("Phase 4: Updating identity UIDs...") + results = update_identities(items, keycloak_map) + + summarize(results) + {:ok, results, protected_ids} + end + end + + defp export_auth0_users do + with {:ok, job_id} <- create_export_job(), + {:ok, location} <- poll_export_job(job_id) do + download_and_parse_export(location) + end + end + + defp create_export_job do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + {:ok, %OAuth2.Response{status_code: 200, body: %{"id" => job_id}}} <- + Client.post(client, "/api/v2/jobs/users-exports", export_job_body(), @auth0_json_headers) do + Logger.info("Auth0 export job created: #{job_id}") + {:ok, job_id} + else + nil -> {:error, "Failed to get Auth0 M2M JWT"} + error -> {:error, "Failed to create Auth0 export job: #{inspect(error)}"} + end + end + + defp export_job_body do + %{ + format: "json", + fields: [ + %{name: "user_id"}, + %{name: "email"}, + %{name: "email_verified"}, + %{name: "username"}, + %{name: "nickname"}, + %{name: "name"}, + %{name: "given_name"}, + %{name: "family_name"}, + %{name: "picture"}, + %{name: "user_metadata"}, + %{name: "app_metadata"}, + %{name: "identities"} + ] + } + end + + defp poll_export_job(job_id, waited_ms \\ 0) + + defp poll_export_job(_job_id, waited_ms) when waited_ms >= @export_max_total_wait_ms do + {:error, "Auth0 export job timed out after #{div(@export_max_total_wait_ms, 1000)}s"} + end + + defp poll_export_job(job_id, waited_ms) do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + {:ok, %OAuth2.Response{status_code: 200, body: body}} <- + Client.get(client, "/api/v2/jobs/#{job_id}") do + case body do + %{"status" => "completed", "location" => location} -> + Logger.info("Auth0 export job completed") + {:ok, location} + + %{"status" => "failed"} -> + {:error, "Auth0 export job failed: #{inspect(body)}"} + + %{"status" => status} = response -> + sleep_ms = poll_interval_from_estimate(response) + + Logger.info( + "Auth0 export status: #{status}, " <> + "estimated time left: #{response["time_left_seconds"] || "unknown"}s, " <> + "polling again in #{div(sleep_ms, 1000)}s (waited #{div(waited_ms, 1000)}s total)" + ) + + Process.sleep(sleep_ms) + poll_export_job(job_id, waited_ms + sleep_ms) + end + else + nil -> {:error, "Failed to get Auth0 M2M JWT"} + error -> {:error, "Failed to poll Auth0 export job: #{inspect(error)}"} + end + end + + defp poll_interval_from_estimate(%{"time_left_seconds" => seconds}) when is_number(seconds) and seconds > 0 do + (seconds * 250) + |> round() + |> max(@export_min_poll_interval_ms) + |> min(@export_max_poll_interval_ms) + end + + defp poll_interval_from_estimate(_response), do: @export_min_poll_interval_ms + + defp download_and_parse_export(location) do + Logger.info("Downloading Auth0 export...") + + case HttpClient.get(location, []) do + {:ok, %{status_code: 200, body: body}} -> + text = safe_gunzip(body) + + users_map = + text + |> String.split("\n", trim: true) + |> Map.new(fn line -> + {:ok, %{"user_id" => user_id} = user} = Jason.decode(line) + {user_id, user} + end) + + Logger.info("Parsed #{map_size(users_map)} users from Auth0 export") + {:ok, users_map} + + {:ok, %{status_code: status, body: resp_body}} -> + {:error, "Failed to download Auth0 export: HTTP #{status}: #{resp_body}"} + + {:error, reason} -> + {:error, "Failed to download Auth0 export: #{inspect(reason)}"} + end + end + + # Auth0 export files are gzipped. If content is already decompressed + # (e.g. HTTP client handled Content-Encoding), pass through as-is. + defp safe_gunzip(data) do + :zlib.gunzip(data) + rescue + ErlangError -> data + end + + defp load_auth0_identities do + has_user_data = + dynamic( + [identity: i], + exists(from(ta in TagAddress, where: ta.identity_id == parent_as(:identity).id, select: 1)) or + exists(from(tt in TagTransaction, where: tt.identity_id == parent_as(:identity).id, select: 1)) or + exists(from(ca in CustomABI, where: ca.identity_id == parent_as(:identity).id, select: 1)) or + exists(from(ak in Key, where: ak.identity_id == parent_as(:identity).id, select: 1)) or + exists( + from(wa in WatchlistAddress, + join: w in Watchlist, + on: wa.watchlist_id == w.id, + where: w.identity_id == parent_as(:identity).id, + select: 1 + ) + ) or i.plan_id != 1 + ) + + where_condition = + dynamic( + [identity: i], + ^has_user_data or not is_nil(i.email) + ) + + dynamic_select = dynamic([identity: i], %{identity: i, has_user_data: ^has_user_data}) + + q = + from(i in Identity, + as: :identity, + where: ^where_condition, + select: ^dynamic_select + ) + + Repo.account_repo().all(q) + end + + defp build_migration_items(identities, auth0_users) do + {items, skipped, skipped_with_data} = + Enum.reduce(identities, {[], 0, []}, fn entry, {acc, skip_count, data_acc} -> + case build_migration_item(entry, auth0_users) do + {:ok, item} -> + {[item | acc], skip_count, data_acc} + + {:skip_with_data, id} -> + {acc, skip_count + 1, [id | data_acc]} + + :skip -> + {acc, skip_count + 1, data_acc} + end + end) + + if skipped > 0, + do: Logger.warning("Skipped #{skipped} identities (not found in Auth0 export, no data, or no email+address)") + + if skipped_with_data != [] do + Logger.warning( + "#{length(skipped_with_data)} identities with user data were NOT found in Auth0 export " <> + "and will be preserved (not deleted): #{inspect(skipped_with_data)}" + ) + end + + deduplicated_items = merge_duplicate_emails(items) + log_duplicate_addresses(deduplicated_items) + {deduplicated_items, skipped_with_data} + end + + # Merges identities that share the same email within this instance. + # Uses Account.merge/1 to consolidate all user data into one identity, + # then returns a deduplicated items list with only the surviving identities. + defp merge_duplicate_emails(items) do + {dupes, uniques} = + items + |> Enum.filter(& &1.email) + |> Enum.group_by(& &1.email) + |> Enum.split_with(fn {_email, group} -> length(group) > 1 end) + + no_email_items = Enum.filter(items, &is_nil(&1.email)) + unique_items = Enum.flat_map(uniques, fn {_email, [item]} -> [item] end) + + merged_items = + Enum.map(dupes, fn {email, group} -> + ids = Enum.map(group, & &1.identity_id) + Logger.info("Merging #{length(group)} identities with duplicate email #{email}: #{inspect(ids)}") + + # Pick primary: prefer one with address, then with user_data + sorted = Enum.sort_by(group, fn item -> {is_nil(item.address), !item.has_user_data} end) + [primary | _rest] = sorted + + identities = + ids + |> Enum.map(&Repo.account_repo().get(Identity, &1)) + |> Enum.reject(&is_nil/1) + + # Reorder identities so primary is first + primary_identity = Enum.find(identities, &(&1.id == primary.identity_id)) + rest_identities = Enum.reject(identities, &(&1.id == primary.identity_id)) + + case Account.merge([primary_identity | rest_identities]) do + {{:ok, _}, _} -> + Logger.info("Merged duplicate email #{email} into identity #{primary.identity_id}") + primary + + {{:error, reason}, _} -> + Logger.error("Failed to merge duplicate email #{email}: #{inspect(reason)}, keeping primary only") + primary + end + end) + + no_email_items ++ unique_items ++ merged_items + end + + defp log_duplicate_addresses(items) do + items + |> Enum.filter(& &1.address) + |> Enum.group_by(&to_string(&1.address)) + |> Enum.filter(fn {_addr, group} -> length(group) > 1 end) + |> Enum.each(fn {address, group} -> + ids = Enum.map(group, & &1.identity_id) + + Logger.warning( + "Duplicate address within same instance: #{address}, identity IDs: #{inspect(ids)}. " <> + "Manual resolution required." + ) + end) + end + + defp build_migration_item(%{identity: %{id: id, uid: auth0_id}, has_user_data: has_user_data}, auth0_users) do + with {:ok, user} <- Map.fetch(auth0_users, auth0_id), + identity = user |> Auth0Internal.create_auth() |> Identity.new_identity(), + true <- has_user_data || identity.address_hash != nil, + # Every Auth0 user should have either email or address; skip anomalies. + keycloak_username when not is_nil(keycloak_username) <- + identity.email || (identity.address_hash && String.downcase(to_string(identity.address_hash))) do + {:ok, + %{ + identity_id: id, + auth0_id: auth0_id, + email: identity.email, + address: identity.address_hash, + username: keycloak_username, + has_user_data: has_user_data + }} + else + :error -> + Logger.warning("Auth0 user not found in export for identity #{id} (#{auth0_id})") + if has_user_data, do: {:skip_with_data, id}, else: :skip + + nil -> + Logger.warning("Identity #{id} (#{auth0_id}) has no email and no address in Auth0, skipping") + if has_user_data, do: {:skip_with_data, id}, else: :skip + + false -> + Logger.debug("No user data and no address in Auth0 for identity #{id} (#{auth0_id}), skipping") + :skip + end + end + + # Creates or finds Keycloak users for each migration item. + # Across tenants, multiple identities with the same email map to one Keycloak user. + # Returns %{identity_id => keycloak_id}. + defp batch_import_to_keycloak(items, batch_size) do + # Pre-check which addresses already exist in Keycloak (from previous tenant migrations). + # These addresses will be excluded from user bodies to preserve uniqueness. + taken_addresses = pre_check_addresses(items) + keycloak_users = Enum.map(items, &build_keycloak_user(&1, taken_addresses)) + + Logger.info("#{length(keycloak_users)} Keycloak users to import") + + num_batches = ceil(length(keycloak_users) / batch_size) + + items_by_username = Map.new(items, &{&1.username, &1}) + + username_to_keycloak_id = + keycloak_users + |> Enum.chunk_every(batch_size) + |> Enum.with_index(1) + |> Enum.flat_map(fn {batch, batch_num} -> + Logger.info("Keycloak import batch #{batch_num}/#{num_batches}") + results = import_batch(batch) + unless batch_num == num_batches, do: Process.sleep(@batch_pause_ms) + results + end) + |> Map.new() + |> resolve_missing_ids(items_by_username) + + # Convert username → keycloak_id into identity_id → keycloak_id + Map.new(items, fn %{identity_id: id, username: username} -> + {id, Map.get(username_to_keycloak_id, username)} + end) + end + + defp build_keycloak_user(%{username: username, email: email, address: address}, taken_addresses) do + normalized_address = if address, do: String.downcase(to_string(address)) + + # Exclude address if already claimed by another Keycloak user + use_address = + if normalized_address && MapSet.member?(taken_addresses, normalized_address) do + Logger.info( + "Address #{normalized_address} already exists in Keycloak, " <> + "not assigning to user #{username} (#{email || "no email"})" + ) + + nil + else + normalized_address + end + + body = + %{username: username, enabled: true} + |> then(fn body -> if email, do: Map.merge(body, %{email: email, emailVerified: true}), else: body end) + |> then(fn body -> + if use_address, + do: Map.put(body, :attributes, %{address: [use_address]}), + else: body + end) + + {username, body} + end + + # Checks which addresses from migration items already exist in Keycloak. + # Returns a MapSet of lowercased addresses that are already claimed. + defp pre_check_addresses(items) do + addresses = + items + |> Enum.filter(& &1.address) + |> Enum.map(&String.downcase(to_string(&1.address))) + |> Enum.uniq() + + if Enum.empty?(addresses) do + MapSet.new() + else + Logger.info("Pre-checking #{length(addresses)} addresses against Keycloak...") + + taken = Enum.filter(addresses, &address_exists_in_keycloak?/1) + + if taken != [] do + Logger.info("#{length(taken)} addresses already exist in Keycloak and will be skipped") + end + + MapSet.new(taken) + end + end + + defp address_exists_in_keycloak?(address) do + match?({:ok, [_ | _]}, Keycloak.find_users_by_address(address)) + end + + defp import_batch(user_entries) do + users = Enum.map(user_entries, fn {_username, body} -> body end) + entries_by_username = Map.new(user_entries) + + case keycloak_partial_import(users) do + {:ok, %{"results" => results}} -> + Enum.flat_map(results, fn + %{"resourceName" => username, "id" => keycloak_id, "action" => "ADDED"} -> + [{username, keycloak_id}] + + %{"resourceName" => username, "id" => keycloak_id, "action" => "SKIPPED"} -> + # User already exists (same username) — append our address to their attributes + maybe_append_address(keycloak_id, entries_by_username[username]) + [{username, keycloak_id}] + + %{"resourceName" => username, "action" => "SKIPPED"} -> + # SKIPPED without ID — will be resolved in resolve_missing_ids + [{username, nil}] + + other -> + Logger.warning("Unexpected partial import result: #{inspect(other)}") + [] + end) + + {:error, reason} -> + Logger.error("Partial import failed: #{inspect(reason)}, falling back to individual creates") + individual_create_fallback(user_entries) + end + end + + defp individual_create_fallback(user_entries) do + Enum.map(user_entries, fn {username, body} -> + {username, create_or_reuse_keycloak_user(body)} + end) + end + + defp create_or_reuse_keycloak_user(body) do + case Keycloak.create_user(body) do + {:ok, keycloak_id} -> + keycloak_id + + {:error, "User already exists"} -> + case lookup_and_append_address(body) do + {:ok, keycloak_id} -> keycloak_id + _ -> nil + end + + error -> + Logger.error("Failed to create Keycloak user #{body[:username]}: #{inspect(error)}") + nil + end + end + + # When a user already exists in Keycloak (e.g. from another tenant), + # find them by email and append the new address to their attributes. + defp lookup_and_append_address(%{email: email} = body) when is_binary(email) do + case Keycloak.find_users_by_email(email) do + {:ok, [%{"id" => keycloak_id} | _]} -> + maybe_append_address(keycloak_id, body) + {:ok, keycloak_id} + + _ -> + Logger.warning("User already exists but could not find by email: #{email}") + {:error, :not_found} + end + end + + defp lookup_and_append_address(_body), do: {:error, :no_email} + + # For users where we didn't get a Keycloak ID (SKIPPED without id, or 409), + # fall back to individual lookup by email/address. + defp resolve_missing_ids(username_to_id, items_by_username) do + missing = Enum.filter(username_to_id, fn {_username, id} -> is_nil(id) end) + + if not Enum.empty?(missing) do + Logger.info("Resolving #{length(missing)} users without Keycloak IDs...") + end + + Enum.reduce(missing, username_to_id, fn {username, _nil}, acc -> + item = items_by_username[username] + + case resolve_single_user(item) do + {:ok, keycloak_id} -> Map.put(acc, username, keycloak_id) + :error -> acc + end + end) + end + + defp resolve_single_user(%{email: email, address: address, username: username}) do + case lookup_keycloak_user(email, address) do + {:ok, keycloak_id} -> + append_address_to_keycloak_user(keycloak_id, address) + {:ok, keycloak_id} + + _ -> + Logger.error("Could not resolve Keycloak ID for #{username}") + :error + end + end + + defp lookup_keycloak_user(email, address) do + with {:ok, []} <- maybe_find_by_email(email), + {:ok, []} <- maybe_find_by_address(address) do + {:error, :not_found} + else + {:ok, [%{"id" => keycloak_id} | _]} -> {:ok, keycloak_id} + error -> error + end + end + + defp maybe_find_by_email(nil), do: {:ok, []} + defp maybe_find_by_email(email), do: Keycloak.find_users_by_email(email) + + defp maybe_find_by_address(nil), do: {:ok, []} + defp maybe_find_by_address(address), do: Keycloak.find_users_by_address(address) + + # Extracts address from a Keycloak user body and appends it to the existing user. + defp maybe_append_address(keycloak_id, %{attributes: %{address: [address | _]}}) do + append_address_to_keycloak_user(keycloak_id, address) + end + + defp maybe_append_address(_keycloak_id, _body), do: :ok + + # Appends an address to a Keycloak user's multivalued address attribute. + # Address uniqueness is already guaranteed by pre_check_addresses — addresses + # claimed by other users were excluded from bodies before import started. + defp append_address_to_keycloak_user(keycloak_id, address) when is_binary(address) do + address = String.downcase(to_string(address)) + + with {:ok, user} <- Keycloak.get_user(keycloak_id) do + current_addresses = get_in(user, ["attributes", "address"]) || [] + + if address in current_addresses do + Logger.debug("Address #{address} already on Keycloak user #{keycloak_id}") + :ok + else + new_attributes = Map.put(user["attributes"] || %{}, "address", [address | current_addresses]) + merged = Map.put(user, "attributes", new_attributes) + Keycloak.update_user(keycloak_id, merged) + end + end + end + + defp append_address_to_keycloak_user(_keycloak_id, _address), do: :ok + + defp update_identities(items, keycloak_map) do + Enum.map(items, fn %{identity_id: id, auth0_id: auth0_id} -> + with {:ok, keycloak_id} when is_binary(keycloak_id) <- Map.fetch(keycloak_map, id), + :ok <- update_identity_uid(id, keycloak_id) do + {:ok, auth0_id, keycloak_id} + else + :error -> + Logger.error("No Keycloak ID for identity #{id} (#{auth0_id})") + {:error, auth0_id, :no_keycloak_id} + + {:ok, nil} -> + Logger.error("No Keycloak ID for identity #{id} (#{auth0_id})") + {:error, auth0_id, :no_keycloak_id} + + {:error, reason} -> + Logger.error("Failed to update identity #{id}: #{inspect(reason)}") + {:error, auth0_id, reason} + end + end) + end + + defp update_identity_uid(id, keycloak_id) do + case Repo.account_repo().get(Identity, id) do + nil -> + {:error, "Identity #{id} not found"} + + identity -> + identity + |> Identity.changeset(%{uid: keycloak_id}) + |> Repo.account_repo().update() + |> case do + {:ok, _} -> :ok + {:error, changeset} -> {:error, "Update failed: #{inspect(changeset.errors)}"} + end + end + end + + defp delete_non_migrated_identities(protected_ids) do + protected_id_list = MapSet.to_list(protected_ids) + + query = + if Enum.empty?(protected_id_list) do + from(i in Identity) + else + from(i in Identity, where: i.id not in ^protected_id_list) + end + + {deleted, _} = Repo.account_repo().delete_all(query) + Logger.info("Deleted #{deleted} non-migrated identities (cascading to their associated data)") + end + + defp keycloak_partial_import(users) do + body = %{ifResourceExists: "SKIP", users: users} + + with {:ok, token} <- get_keycloak_admin_token() do + url = keycloak_url("/admin/realms/#{URI.encode(keycloak_realm())}/partialImport") + + case HttpClient.post(url, Jason.encode!(body), keycloak_auth_headers(token) ++ @json_headers) do + {:ok, %{status_code: 200, body: resp_body}} -> + Jason.decode(resp_body) + + {:ok, %{status_code: status, body: resp_body}} -> + {:error, "HTTP #{status}: #{resp_body}"} + + {:error, reason} -> + {:error, reason} + end + end + end + + # Caches the admin token in the process dictionary for the duration of the migration. + defp get_keycloak_admin_token do + case Process.get(:keycloak_admin_token) do + {token, expires_at} when is_integer(expires_at) and expires_at > 0 -> + if System.system_time(:second) < expires_at do + {:ok, token} + else + fetch_keycloak_admin_token() + end + + _ -> + fetch_keycloak_admin_token() + end + end + + defp fetch_keycloak_admin_token do + url = keycloak_url("/realms/#{URI.encode(keycloak_realm())}/protocol/openid-connect/token") + + body = + URI.encode_query(%{ + grant_type: "client_credentials", + client_id: keycloak_client_id(), + client_secret: keycloak_client_secret() + }) + + case HttpClient.post(url, body, [{"content-type", "application/x-www-form-urlencoded"}]) do + {:ok, %{status_code: 200, body: resp_body}} -> + case Jason.decode(resp_body) do + {:ok, %{"access_token" => token, "expires_in" => ttl}} -> + Process.put(:keycloak_admin_token, {token, System.system_time(:second) + ttl - 30}) + {:ok, token} + + _ -> + {:error, "Invalid Keycloak token response"} + end + + error -> + {:error, "Failed to get Keycloak admin token: #{inspect(error)}"} + end + end + + defp keycloak_auth_headers(token), do: [{"authorization", "Bearer #{token}"}] + + defp keycloak_url(path) do + keycloak_domain() + |> URI.parse() + |> URI.append_path(path) + |> URI.to_string() + end + + defp disable_account do + Logger.info("Disabling account access for migration") + update_account_enabled(false) + end + + defp enable_account do + update_account_enabled(true) + end + + defp update_account_enabled(enabled) do + config = Application.get_env(:explorer, Account, []) + Application.put_env(:explorer, Account, Keyword.put(config, :enabled, enabled)) + end + + defp disable_auth0 do + config = Application.get_env(:ueberauth, OAuth, []) + Application.put_env(:ueberauth, OAuth, Keyword.put(config, :domain, nil)) + end + + defp finalize(results, protected_ids) do + failed = Enum.count(results, &match?({:error, _, _}, &1)) + + if failed == 0 do + Logger.info("Phase 5: Deleting non-migrated identities...") + delete_non_migrated_identities(protected_ids) + + disable_auth0() + invalidate_all_sessions() + enable_account() + Logger.info("Migration successful. Auth0 disabled, Keycloak is now the active auth provider.") + else + Logger.warning( + "Migration had #{failed} failures. Account access remains DISABLED. " <> + "Review errors and re-run, or manually enable account access." + ) + end + end + + # Invalidates all user sessions by removing their Redis validation keys. + # Session cookies are signed but validated against Redis on each request. + # Without the Redis key, the cookie is rejected and the user must re-authenticate. + defp invalidate_all_sessions do + Logger.info("Invalidating all user sessions...") + chain_id = chain_id() + pattern = if chain_id, do: "#{chain_id}_*", else: "*" + count = scan_and_delete(pattern) + Logger.info("Invalidated #{count} session keys from Redis") + end + + defp scan_and_delete(pattern, cursor \\ "0", count \\ 0) do + case Redix.command(:redix, ["SCAN", cursor, "MATCH", pattern, "COUNT", 1000]) do + {:ok, [next_cursor, keys]} -> + unless keys == [], do: Redix.command(:redix, ["DEL" | keys]) + new_count = count + length(keys) + + if next_cursor == "0" do + new_count + else + scan_and_delete(pattern, next_cursor, new_count) + end + + error -> + Logger.error("Redis SCAN failed: #{inspect(error)}") + count + end + end + + defp summarize(results) do + succeeded = Enum.count(results, &match?({:ok, _, _}, &1)) + failed = Enum.count(results, &match?({:error, _, _}, &1)) + Logger.info("Migration complete: #{succeeded} succeeded, #{failed} failed, #{succeeded + failed} total") + end + + defp log_dry_run_deletion_count(protected_ids) do + total_count = Repo.account_repo().aggregate(Identity, :count) + + would_delete = total_count - MapSet.size(protected_ids) + + Logger.info( + "[DRY RUN] Would delete #{would_delete} non-migrated identities " <> + "(#{total_count} total, #{MapSet.size(protected_ids)} protected)" + ) + end + + defp log_dry_run(items) do + Enum.each(items, fn item -> + identity_label = if Map.has_key?(item, :identity_id), do: "Identity #{item.identity_id}: ", else: "" + + Logger.info( + "[DRY RUN] #{identity_label}#{item.auth0_id} -> " <> + "email=#{inspect(item.email)}, address=#{inspect(item.address)}, " <> + "username=#{item.username}, has_user_data=#{item.has_user_data}" + ) + end) + end +end diff --git a/apps/explorer/lib/explorer/account/authentication.ex b/apps/explorer/lib/explorer/account/authentication.ex new file mode 100644 index 000000000000..6f4df7454aba --- /dev/null +++ b/apps/explorer/lib/explorer/account/authentication.ex @@ -0,0 +1,324 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Account.Authentication do + @moduledoc """ + Context module for user authentication and third-party identity management. + """ + + alias Explorer.{Account, Helper} + alias Explorer.Account.Identity + alias Explorer.Chain.{Address, Hash} + alias Explorer.ThirdPartyIntegrations.{Auth0, Dynamic, Keycloak} + alias Ueberauth.Auth + + require Logger + + @callback send_otp(String.t(), String.t()) :: :ok | {:error, String.t()} | :error | {:format, :email} + @callback send_otp_for_linking(String.t(), String.t()) :: :ok | {:error, String.t()} | :error | {:format, :email} + @callback confirm_otp_and_get_auth(String.t(), String.t(), String.t()) :: + {:ok, Auth.t()} | {:error, String.t()} | :error + @callback link_email(Identity.session(), String.t(), String.t(), String.t()) :: + {:ok, Auth.t()} | {:error, String.t()} | :error + @callback find_or_create_web3_user(String.t(), String.t()) :: {:ok, Auth.t()} | {:error, String.t()} | :error + @callback link_address(String.t(), String.t()) :: {:ok, Auth.t()} | {:error, String.t()} | :error + + @request_siwe_message "Request Sign in with Ethereum message via /api/account/v2/siwe_message" + @wrong_nonce "Wrong nonce in message" + @misconfiguration_detected "Misconfiguration detected, please contact support." + + @doc """ + Sends a one-time password to the specified email address using the enabled authentication provider. + + ## Parameters + - `email`: The email address to send the OTP to + - `ip`: The IP address of the requester + + ## Returns + - `:ok` if the OTP was sent successfully + - `{:error, String.t()}` if the email already exists or sending failed + - `:error` if there was an unexpected error + - `{:enabled, false}` if no authentication provider is enabled + - `{:format, :email}` if the email format is invalid + """ + @spec send_otp(String.t(), String.t()) :: :ok | {:error, String.t()} | :error | {:enabled, false} | {:format, :email} + def send_otp(email, ip) do + with {:ok, module} <- responsible_module() do + module.send_otp(email, ip) + end + end + + @doc """ + Sends a one-time password to the specified email address for account linking using the enabled authentication provider. + + ## Parameters + - `email`: The email address to send the OTP to + - `ip`: The IP address of the requester + + ## Returns + - `:ok` if the OTP was sent successfully + - `{:error, String.t()}` if an account with the given email already exists + or sending failed + - `:error` if there was an unexpected error + - `{:enabled, false}` if no authentication provider is enabled + - `{:format, :email}` if the email format is invalid + """ + @spec send_otp_for_linking(String.t(), String.t()) :: + :ok | {:error, String.t()} | :error | {:enabled, false} | {:format, :email} + def send_otp_for_linking(email, ip) do + with {:ok, module} <- responsible_module() do + module.send_otp_for_linking(email, ip) + end + end + + @doc """ + Confirms a one-time password and retrieves authentication data for the given email using the enabled authentication provider. + + ## Parameters + - `email`: The email address associated with the OTP + - `otp`: The one-time password to confirm + - `ip`: The IP address of the requester + + ## Returns + - `{:ok, Auth.t()}` if the OTP is confirmed successfully, where `Auth.t()` + contains the user's authentication data including UID, provider, strategy, + info, credentials, and extra information + - `{:error, String.t()}` if confirmation failed with a description of the + error + - `:error` if there was an unexpected error + - `{:enabled, false}` if no authentication provider is enabled + """ + @spec confirm_otp(String.t(), String.t(), String.t()) :: + {:ok, Auth.t()} | {:error, String.t()} | :error | {:enabled, false} + def confirm_otp(email, otp, ip) do + with {:ok, module} <- responsible_module() do + module.confirm_otp_and_get_auth(email, otp, ip) + end + end + + @doc """ + Links an email address to an existing user account by verifying a one-time password using the enabled authentication provider. + + ## Parameters + - `user`: The session map of the existing user account; the account must not + have an email linked (`email: nil`) for the linking to proceed + - `email`: The email address to link to the account + - `otp`: The one-time password for verification + - `ip`: The IP address of the requester + + ## Returns + - `{:ok, Auth.t()}` if the email was successfully linked, where `Auth.t()` + contains the user's authentication data including UID, provider, strategy, + info, credentials, and extra information + - `{:error, String.t()}` if the account already has an email linked, an + account with the given email already exists, the OTP is wrong or expired, + or linking failed with a description of the error + - `:error` if there was an unexpected error + - `{:enabled, false}` if no authentication provider is enabled + """ + @spec link_email(Identity.session(), String.t(), String.t(), String.t()) :: + {:ok, Auth.t()} | {:error, String.t()} | :error | {:enabled, false} + def link_email(user, email, otp, ip) do + with {:ok, module} <- responsible_module() do + module.link_email(user, email, otp, ip) + end + end + + @doc """ + Generates a Sign-In with Ethereum (SIWE) message for the given address. + + This function creates a SIWE message with a unique nonce, caches the nonce, + and returns the formatted message string. + + ## Parameters + - `address`: The Ethereum address for which to generate the SIWE message + + ## Returns + - `{:ok, String.t()}` containing the generated SIWE message + - `{:error, "Misconfiguration detected, please contact support."}` if the + nonce could not be cached due to a Redis configuration problem + - `{:error, String.t()}` if the SIWE message could not be formatted + """ + @spec generate_siwe_message(Hash.Address.t()) :: {:ok, String.t()} | {:error, String.t()} + def generate_siwe_message(address_hash) do + checksum_address = Address.checksum(address_hash) + nonce = Siwe.generate_nonce() + {int_chain_id, _} = Integer.parse(Application.get_env(:block_scout_web, :chain_id)) + + message = %Siwe.Message{ + domain: Helper.get_app_host(), + address: checksum_address, + statement: Application.get_env(:explorer, Account)[:siwe_message], + uri: + Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:scheme] <> + "://" <> Helper.get_app_host(), + version: "1", + chain_id: int_chain_id, + nonce: nonce, + issued_at: DateTime.utc_now() |> DateTime.to_iso8601(), + expiration_time: DateTime.utc_now() |> DateTime.add(300, :second) |> DateTime.to_iso8601() + } + + with {:cache, {:ok, _nonce}} <- {:cache, cache_nonce_for_address(nonce, checksum_address)}, + {:message, {:ok, message}} <- {:message, Siwe.to_str(message)} do + {:ok, message} + else + {:cache, {:error, error}} -> + Logger.error("Error while caching nonce: #{inspect(error)}") + {:error, @misconfiguration_detected} + + {:message, {:error, error}} -> + Logger.error("Error while generating Sign in with Ethereum Message: #{inspect(error)}") + {:error, error} + end + end + + @doc """ + Verifies a Sign-In with Ethereum (SIWE) message and signature, then finds or + creates the corresponding web3 user account. + + The message is parsed and validated against the stored nonce for the signing + address. A nonce must have been previously generated via `generate_siwe_message/1` + and is consumed (deleted from cache) upon successful verification. + + ## Parameters + - `message`: The raw SIWE message string to verify + - `signature`: The hex-encoded EIP-191 signature produced by signing `message` + + ## Returns + - `{:ok, Auth.t()}` if the message and signature are valid and the user was + found or created successfully + - `{:error, "Request Sign in with Ethereum message via /api/account/v2/siwe_message"}` + if no nonce exists for the signing address (i.e. a SIWE message was never + requested) + - `{:error, "Wrong nonce in message"}` if the nonce in the message does not + match the cached nonce for the address + - `{:error, String.t()}` if signature parsing or user lookup/creation failed + - `:error` if there was an unexpected error + - `{:enabled, false}` if no authentication provider is enabled + """ + @spec verify_siwe_message(String.t(), String.t()) :: + {:ok, Auth.t()} | {:error, String.t()} | :error | {:enabled, false} + def verify_siwe_message(message, signature) do + with {:module, {:ok, module}} <- {:module, responsible_module()}, + {:signature, {:ok, %{nonce: nonce, address: address}}} <- + {:signature, message |> String.trim() |> Siwe.parse_if_valid(signature)}, + {:nonce, {:ok, ^nonce}} <- {:nonce, get_nonce_for_address(address)} do + module.find_or_create_web3_user(address, signature) + else + {:nonce, :not_found} -> + {:error, @request_siwe_message} + + {:nonce, {:ok, _}} -> + {:error, @wrong_nonce} + + {_step, error} -> + error + end + end + + @doc """ + Links an Ethereum address to an existing user account by verifying a SIWE + message and signature. + + The message is parsed and validated against the stored nonce for the signing + address, in the same way as `verify_siwe_message/2`. On success, the verified + address is associated with the given user in the active authentication provider. + + ## Parameters + - `user_id`: The ID of the existing user account to link the address to + - `message`: The raw SIWE message string to verify + - `signature`: The hex-encoded EIP-191 signature produced by signing `message` + + ## Returns + - `{:ok, Auth.t()}` if the message and signature are valid and the address was + successfully linked to the user + - `{:error, "Wrong nonce in message"}` if the nonce in the message does not + match the cached nonce for the signing address + - `{:error, "Request Sign in with Ethereum message via /api/account/v2/siwe_message"}` + if no nonce is found for the address (e.g. Redis error or nonce was never + requested) + - `{:error, String.t()}` if signature parsing or the linking operation failed + - `:error` if there was an unexpected error + - `{:enabled, false}` if no authentication provider is enabled + """ + @spec link_address(String.t(), String.t(), String.t()) :: + {:ok, Auth.t()} | {:error, String.t()} | :error | {:enabled, false} + def link_address(user_id, message, signature) do + with {:module, {:ok, module}} <- {:module, responsible_module()}, + {:signature, {:ok, %{nonce: nonce, address: address}}} <- + {:signature, message |> String.trim() |> Siwe.parse_if_valid(signature)}, + {:nonce, {:ok, ^nonce}} <- {:nonce, get_nonce_for_address(address)} do + module.link_address(user_id, address) + else + {:nonce, {:ok, _}} -> + {:error, @wrong_nonce} + + {:nonce, :not_found} -> + {:error, @request_siwe_message} + + {:nonce, error} -> + Logger.error("Error while retrieving nonce for address: #{inspect(error)}") + :error + + {_step, error} -> + error + end + end + + @doc """ + Authenticates a user using a Dynamic-issued JWT token. + + ## Parameters + - `token`: A JWT token issued by the Dynamic authentication service + + ## Returns + - `{:ok, Auth.t()}` if the token is valid and the user was found or created + successfully + - `{:error, String.t()}` if token validation or user lookup failed + - `:error` if there was an unexpected error + """ + @spec authenticate_via_dynamic(String.t()) :: {:ok, Auth.t()} | {:error, String.t()} | :error | {:enabled, false} + def authenticate_via_dynamic(token) do + Dynamic.get_auth_from_token(token) + end + + defp cache_nonce_for_address(nonce, address_hash) do + case Redix.command(:redix, [ + "SET", + Helper.redis_key(String.downcase(address_hash) <> "siwe_nonce"), + nonce, + "EX", + 300 + ]) do + {:ok, _} -> + {:ok, nonce} + + error -> + Logger.error("Error while caching nonce: #{inspect(error)}") + {:error, "Redis configuration problem, please contact support."} + end + end + + defp get_nonce_for_address(address_hash) do + cookie_key = Helper.redis_key(String.downcase(address_hash) <> "siwe_nonce") + + case Redix.command(:redix, ["GETDEL", cookie_key]) do + {:ok, nil} -> + :not_found + + {:ok, nonce} -> + {:ok, nonce} + + error -> + Logger.error("Error while consuming nonce for address: #{inspect(error)}") + {:error, "Redis configuration problem, please contact support."} + end + end + + defp responsible_module do + cond do + Auth0.enabled?() -> {:ok, Auth0} + Keycloak.enabled?() -> {:ok, Keycloak} + true -> {:enabled, false} + end + end +end diff --git a/apps/explorer/lib/explorer/account/custom_abi.ex b/apps/explorer/lib/explorer/account/custom_abi.ex index 3cc9eae51c93..a63c17ac1a56 100644 --- a/apps/explorer/lib/explorer/account/custom_abi.ex +++ b/apps/explorer/lib/explorer/account/custom_abi.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.CustomABI do @moduledoc """ Module is responsible for schema for API keys, keys is used to track number of requests to the API endpoints @@ -103,7 +104,7 @@ defmodule Explorer.Account.CustomABI do defp check_is_abi_valid?(custom_abi, given_abi \\ nil) defp check_is_abi_valid?(%{abi: abi} = custom_abi, given_abi) when is_list(abi) do - with true <- length(abi) > 0, + with true <- not Enum.empty?(abi), filtered_abi <- filter_abi(abi), false <- Enum.empty?(filtered_abi) do Map.put(custom_abi, :abi, filtered_abi) diff --git a/apps/explorer/lib/explorer/account/identity.ex b/apps/explorer/lib/explorer/account/identity.ex index c5ab81b9c339..a50ffdf003c5 100644 --- a/apps/explorer/lib/explorer/account/identity.ex +++ b/apps/explorer/lib/explorer/account/identity.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Identity do @moduledoc """ Identity of user fetched via Oauth @@ -31,7 +32,7 @@ defmodule Explorer.Account.Identity do typed_schema "account_identities" do field(:uid_hash, Cloak.Ecto.SHA256) :: binary() | nil field(:uid, Explorer.Encrypted.Binary, null: false) - field(:email, Explorer.Encrypted.Binary, null: false) + field(:email, Explorer.Encrypted.Binary, null: true) field(:name, :string, virtual: true) field(:nickname, :string, virtual: true) field(:address_hash, Hash.Address, virtual: true) @@ -50,9 +51,10 @@ defmodule Explorer.Account.Identity do @doc false def changeset(identity, attrs) do identity - |> cast(attrs, [:uid, :email, :name, :nickname, :avatar, :verification_email_sent_at]) - |> validate_required([:uid, :email, :name]) + |> cast(attrs, [:uid, :email, :name, :nickname, :avatar, :verification_email_sent_at, :otp_sent_at]) + |> validate_required([:uid]) |> put_hashed_fields() + |> unique_constraint(:uid_hash, name: :account_identities_uid_hash_index) end defp put_hashed_fields(changeset) do @@ -75,7 +77,7 @@ defmodule Explorer.Account.Identity do - An updated Identity struct with populated virtual fields. """ @spec put_session_info(t(), session()) :: t() - def put_session_info(identity, %{name: name, nickname: nickname, address_hash: address_hash}) do + def put_session_info(%__MODULE__{} = identity, %{name: name, nickname: nickname, address_hash: address_hash}) do %__MODULE__{ identity | name: name, @@ -84,7 +86,7 @@ defmodule Explorer.Account.Identity do } end - def put_session_info(identity, %{name: name, nickname: nickname}) do + def put_session_info(%__MODULE__{} = identity, %{name: name, nickname: nickname}) do %__MODULE__{ identity | name: name, @@ -138,7 +140,8 @@ defmodule Explorer.Account.Identity do |> Repo.account_repo().update() end - defp new_identity(auth) do + @spec new_identity(Auth.t()) :: t() + def new_identity(auth) do %__MODULE__{ uid: auth.uid, uid_hash: auth.uid, @@ -335,6 +338,11 @@ defmodule Explorer.Account.Identity do - A string representation of the Ethereum address hash, or nil if not found. """ @spec address_hash_from_auth(Auth.t()) :: String.t() | nil + def address_hash_from_auth(%Auth{provider: provider, extra: %Extra{raw_info: %{address_hash: address_hash}}}) + when provider in ~w(dynamic keycloak)a do + address_hash + end + def address_hash_from_auth(%Auth{ extra: %Extra{raw_info: %{user: %{"user_metadata" => %{"web3_address_hash" => address_hash}}}} }) do diff --git a/apps/explorer/lib/explorer/account/notifier/email.ex b/apps/explorer/lib/explorer/account/notifier/email.ex index 7e0c4aa531aa..6eab3f9f0c5f 100644 --- a/apps/explorer/lib/explorer/account/notifier/email.ex +++ b/apps/explorer/lib/explorer/account/notifier/email.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.Email do @moduledoc """ Composing an email to sendgrid @@ -6,8 +7,9 @@ defmodule Explorer.Account.Notifier.Email do require Logger alias Explorer.Account.{Identity, Watchlist, WatchlistAddress, WatchlistNotification} - alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Chain.Address alias Explorer.Repo + alias Utils.Helper import Bamboo.{Email, SendGridHelper} @@ -28,11 +30,11 @@ defmodule Explorer.Account.Notifier.Email do email |> with_template(template()) |> add_dynamic_field("username", username(notification)) - |> add_dynamic_field("address_hash", address_hash_string(notification)) + |> add_dynamic_field("address_hash", Address.checksum(notification.watchlist_address.address_hash)) |> add_dynamic_field("address_name", notification.watchlist_address.name) - |> add_dynamic_field("transaction_hash", ExplorerHelper.add_0x_prefix(notification.transaction_hash)) - |> add_dynamic_field("from_address_hash", ExplorerHelper.add_0x_prefix(notification.from_address_hash)) - |> add_dynamic_field("to_address_hash", ExplorerHelper.add_0x_prefix(notification.to_address_hash)) + |> add_dynamic_field("transaction_hash", to_string(notification.transaction_hash)) + |> add_dynamic_field("from_address_hash", Address.checksum(notification.from_address_hash)) + |> add_dynamic_field("to_address_hash", Address.checksum(notification.to_address_hash)) |> add_dynamic_field("block_number", notification.block_number) |> add_dynamic_field("amount", amount(notification)) |> add_dynamic_field("name", notification.name) @@ -54,6 +56,9 @@ defmodule Explorer.Account.Notifier.Email do "ERC-20" -> amount + "ZRC-2" -> + amount + "ERC-721" -> "Token ID: " <> subject <> " of " @@ -87,11 +92,6 @@ defmodule Explorer.Account.Notifier.Email do }), do: name - defp address_hash_string(%WatchlistNotification{ - watchlist_address: %WatchlistAddress{address_hash: address_hash} - }), - do: ExplorerHelper.add_0x_prefix(address_hash.bytes) - defp direction(notification) do affect(notification) <> " " <> place(notification) end @@ -117,45 +117,15 @@ defmodule Explorer.Account.Notifier.Email do end defp address_url(address_hash) do - uri() |> URI.append_path("/address/#{address_hash}") |> to_string() + Helper.instance_url() |> URI.append_path("/address/#{address_hash}") |> to_string() end defp block_url(notification) do - uri() |> URI.append_path("/block/#{notification.block_number}") |> to_string() + Helper.instance_url() |> URI.append_path("/block/#{notification.block_number}") |> to_string() end defp transaction_url(notification) do - uri() |> URI.append_path("/tx/#{notification.transaction_hash}") |> to_string() - end - - defp url_params do - Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url] - end - - defp uri do - %URI{scheme: scheme(), host: host(), port: port(), path: path()} - end - - defp scheme do - Keyword.get(url_params(), :scheme, "http") - end - - defp host do - url_params()[:host] - end - - defp port do - url_params()[:http][:port] - end - - defp path do - raw_path = url_params()[:path] - - if raw_path |> String.ends_with?("/") do - raw_path |> String.slice(0..-2//1) - else - raw_path - end + Helper.instance_url() |> URI.append_path("/tx/#{notification.transaction_hash}") |> to_string() end defp sender do diff --git a/apps/explorer/lib/explorer/account/notifier/forbidden_address.ex b/apps/explorer/lib/explorer/account/notifier/forbidden_address.ex index 16ab861c4627..5c324812b90a 100644 --- a/apps/explorer/lib/explorer/account/notifier/forbidden_address.ex +++ b/apps/explorer/lib/explorer/account/notifier/forbidden_address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.ForbiddenAddress do @moduledoc """ Check if address is forbidden to notify @@ -49,7 +50,7 @@ defmodule Explorer.Account.Notifier.ForbiddenAddress do defp contract?(%Explorer.Chain.Hash{} = address_hash) do case hash_to_address(address_hash) do {:error, :not_found} -> false - {:ok, address} -> Address.smart_contract?(address) + {:ok, address} -> Address.smart_contract?(address) && !Address.eoa_with_code?(address) end end diff --git a/apps/explorer/lib/explorer/account/notifier/notify.ex b/apps/explorer/lib/explorer/account/notifier/notify.ex index 8e9289629b23..7bbaf6a06965 100644 --- a/apps/explorer/lib/explorer/account/notifier/notify.ex +++ b/apps/explorer/lib/explorer/account/notifier/notify.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.Notify do @moduledoc """ Composing notification, store and send it to email @@ -60,13 +61,7 @@ defmodule Explorer.Account.Notifier.Notify do summary, direction ) do - notification - |> query_notification(address) - |> Repo.account_repo().all() - |> case do - [] -> save_and_send_notification(notification, address) - _ -> :ok - end + handle_notification_save(notification, address) end {:error, _message} -> @@ -77,6 +72,16 @@ defmodule Explorer.Account.Notifier.Notify do end end + defp handle_notification_save(notification, address) do + notification + |> query_notification(address) + |> Repo.account_repo().all() + |> case do + [] -> save_and_send_notification(notification, address) + _ -> :ok + end + end + defp query_notification(notification, watchlist_address) do from(wn in WatchlistNotification, where: @@ -149,6 +154,8 @@ defmodule Explorer.Account.Notifier.Notify do {"ERC-1155", :outgoing} -> address.watch_erc_1155_output {"ERC-404", :incoming} -> address.watch_erc_404_input {"ERC-404", :outgoing} -> address.watch_erc_404_output + {"ZRC-2", :incoming} -> Map.get(address, :watch_zrc_2_input) + {"ZRC-2", :outgoing} -> Map.get(address, :watch_zrc_2_output) end end diff --git a/apps/explorer/lib/explorer/account/notifier/summary.ex b/apps/explorer/lib/explorer/account/notifier/summary.ex index 5ccfef8e6a1b..827efeedd146 100644 --- a/apps/explorer/lib/explorer/account/notifier/summary.ex +++ b/apps/explorer/lib/explorer/account/notifier/summary.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.Summary do @moduledoc """ Compose a summary from transactions @@ -41,7 +42,7 @@ defmodule Explorer.Account.Notifier.Summary do not (is_nil(summary) or summary == :nothing or is_nil(summary.amount) or - summary.amount == Decimal.new(0)) + (summary.amount == Decimal.new(0) and summary.type != "ERC-7984")) end) end @@ -100,7 +101,7 @@ defmodule Explorer.Account.Notifier.Summary do %Chain.TokenTransfer{} = transfer ) do case transfer.token.type do - "ERC-20" -> + type when type in ["ERC-20", "ZRC-2"] -> %Summary{ transaction_hash: transaction.hash, method: method(transfer), @@ -157,6 +158,20 @@ defmodule Explorer.Account.Notifier.Summary do name: token_name(transfer), type: transfer.token.type } + + "ERC-7984" -> + %Summary{ + amount: Decimal.new(0), + transaction_hash: transaction.hash, + method: method(transfer), + from_address_hash: transfer.from_address_hash, + to_address_hash: transfer.to_address_hash, + block_number: transfer.block_number, + subject: "Confidential transfer", + transaction_fee: fee(transaction), + name: token_name(transfer), + type: transfer.token.type + } end end diff --git a/apps/explorer/lib/explorer/account/notify.ex b/apps/explorer/lib/explorer/account/notify.ex index 8e717f538f41..cacc8820e017 100644 --- a/apps/explorer/lib/explorer/account/notify.ex +++ b/apps/explorer/lib/explorer/account/notify.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notify do @moduledoc """ Interface for notifier, for import and call from other modules @@ -5,6 +6,7 @@ defmodule Explorer.Account.Notify do alias Explorer.Account alias Explorer.Account.Notifier.Notify + alias Explorer.ThirdPartyIntegrations.{Auth0, Dynamic, Keycloak} require Logger @@ -24,15 +26,13 @@ defmodule Explorer.Account.Notify do end defp check_envs do - check_auth0() + check_authentication_provider() check_sendgrid() end - defp check_auth0 do - (Application.get_env(:ueberauth, Ueberauth.Strategy.Auth0.OAuth)[:client_id] && - Application.get_env(:ueberauth, Ueberauth.Strategy.Auth0.OAuth)[:client_secret] && - Application.get_env(:ueberauth, Ueberauth.Strategy.Auth0.OAuth)[:domain]) || - raise "Auth0 not configured" + defp check_authentication_provider do + Auth0.enabled?() || Keycloak.enabled?() || Dynamic.enabled?() || + raise "No authentication provider configured" end defp check_sendgrid do diff --git a/apps/explorer/lib/explorer/account/public_tags_request.ex b/apps/explorer/lib/explorer/account/public_tags_request.ex deleted file mode 100644 index a0e37a5ead7c..000000000000 --- a/apps/explorer/lib/explorer/account/public_tags_request.ex +++ /dev/null @@ -1,334 +0,0 @@ -defmodule Explorer.Account.PublicTagsRequest do - @moduledoc """ - Module is responsible for requests for public tags - """ - use Explorer.Schema - - alias Ecto.{Changeset, Multi} - alias Explorer.Account.Identity - alias Explorer.Chain.Hash - alias Explorer.{Helper, Repo} - alias Explorer.ThirdPartyIntegrations.AirTable - - import Ecto.Changeset - - @distance_between_same_addresses 24 * 3600 - - @max_public_tags_request_per_account 15 - @max_addresses_per_request 10 - @max_tags_per_request 2 - @max_tag_length 35 - - @user_not_found "User not found" - - typed_schema "account_public_tags_requests" do - field(:company, :string) - field(:website, :string) - field(:tags, :string, null: false) - field(:addresses, {:array, Hash.Address}, null: false) - field(:description, :string) - field(:additional_comment, :string, null: false) - field(:request_type, :string, null: false) - field(:is_owner, :boolean, default: true, null: false) - field(:remove_reason, :string) - field(:request_id, :string) - field(:full_name, Explorer.Encrypted.Binary, null: false) - field(:email, Explorer.Encrypted.Binary, null: false) - - belongs_to(:identity, Identity, null: false) - - timestamps() - end - - @local_fields [:__meta__, :inserted_at, :updated_at, :id, :request_id] - - def to_map(%__MODULE__{} = request) do - association_fields = request.__struct__.__schema__(:associations) - waste_fields = association_fields ++ @local_fields - - network = Helper.get_app_host() <> Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:path] - - request |> Map.from_struct() |> Map.drop(waste_fields) |> Map.put(:network, network) - end - - @attrs ~w(company website description remove_reason request_id)a - @required_attrs ~w(full_name email tags addresses additional_comment request_type is_owner identity_id)a - - def changeset(%__MODULE__{} = public_tags_request, attrs \\ %{}) do - public_tags_request - |> cast(trim_empty_addresses(attrs), @attrs ++ @required_attrs) - |> validate_tags() - |> validate_required(@required_attrs, message: "Required") - |> validate_format(:email, ~r/^[A-Z0-9._%+-]+@[A-Z0-9-]+.+.[A-Z]{2,4}$/i, message: "is invalid") - |> validate_length(:addresses, min: 1, max: @max_addresses_per_request) - |> extract_and_validate_addresses() - |> foreign_key_constraint(:identity_id, message: @user_not_found) - |> public_tags_request_count_constraint() - |> public_tags_request_time_interval_uniqueness() - end - - def changeset_without_constraints(%__MODULE__{} = public_tags_request \\ %__MODULE__{}, attrs \\ %{}) do - public_tags_request - |> cast(attrs, @attrs ++ @required_attrs) - end - - @doc """ - Creates a new public tags request within a database transaction. - - The creation process involves verifying the existence of the associated identity - and ensuring data consistency through a database lock. The transaction prevents - concurrent modifications of the same identity record. - - ## Parameters - - `attrs`: Map containing the following fields: - - `:identity_id`: Required. The ID of the identity associated with the request - - `:company`: Optional. The company name - - `:website`: Optional. The company's website - - `:tags`: Required. The requested tags - - `:addresses`: Required. List of blockchain addresses - - `:description`: Optional. Description of the request - - `:additional_comment`: Required. Additional information about the request - - `:request_type`: Required. The type of the request - - `:is_owner`: Optional. Boolean indicating ownership (defaults to true) - - `:remove_reason`: Optional. Reason for tag removal if applicable - - `:request_id`: Optional. External request identifier - - `:full_name`: Required. Encrypted full name of the requester - - `:email`: Required. Encrypted email of the requester - - ## Returns - - `{:ok, public_tags_request}` - Returns the created public tags request - - `{:error, changeset}` - Returns a changeset with errors if: - - The identity doesn't exist - - The provided data is invalid - - Required fields are missing - """ - @spec create(map()) :: {:ok, t()} | {:error, Changeset.t()} - def create(%{identity_id: identity_id} = attrs) do - Multi.new() - |> Identity.acquire_with_lock(identity_id) - |> Multi.insert(:public_tags_request, fn _ -> - %__MODULE__{} - |> changeset(Map.put(attrs, :request_type, "add")) - end) - |> Repo.account_repo().transaction() - |> case do - {:ok, %{public_tags_request: public_tags_request}} -> - {:ok, public_tags_request} |> AirTable.submit() - - {:error, :acquire_identity, :not_found, _changes} -> - {:error, - %__MODULE__{} - |> changeset(Map.put(attrs, :request_type, "add")) - |> add_error(:identity_id, @user_not_found, - constraint: :foreign, - constraint_name: "account_public_tags_requests_identity_id_fkey" - )} - end - end - - def create(attrs) do - {:error, - %__MODULE__{} - |> changeset(Map.put(attrs, :request_type, "add"))} - end - - defp trim_empty_addresses(%{addresses: addresses} = attrs) when is_list(addresses) do - filtered_addresses = Enum.filter(addresses, fn addr -> addr != "" and !is_nil(addr) end) - Map.put(attrs, :addresses, if(filtered_addresses == [], do: [""], else: filtered_addresses)) - end - - defp trim_empty_addresses(attrs), do: attrs - - def public_tags_request_count_constraint(%Changeset{changes: %{identity_id: identity_id}} = request) do - if identity_id - |> public_tags_requests_by_identity_id_query() - |> limit(@max_public_tags_request_per_account) - |> Repo.account_repo().aggregate(:count, :id) >= @max_public_tags_request_per_account do - request - |> add_error(:tags, "Max #{@max_public_tags_request_per_account} public tags requests per account") - else - request - end - end - - def public_tags_request_count_constraint(changeset), do: changeset - - defp public_tags_request_time_interval_uniqueness(%Changeset{changes: %{addresses: addresses}} = request) do - prepared_addresses = - if request.data && request.data.addresses, do: addresses -- request.data.addresses, else: addresses - - public_tags_request = - request - |> fetch_field!(:identity_id) - |> public_tags_requests_by_identity_id_query() - |> where( - [public_tags_request], - fragment("? && ?", public_tags_request.addresses, ^Enum.map(prepared_addresses, fn x -> x.bytes end)) - ) - |> limit(1) - |> Repo.account_repo().one() - - now = DateTime.utc_now() - - if !is_nil(public_tags_request) && - public_tags_request.inserted_at - |> DateTime.add(@distance_between_same_addresses, :second) - |> DateTime.compare(now) == :gt do - request - |> add_error(:addresses, "You have already submitted the same public tag address in the last 24 hours") - else - request - end - end - - defp public_tags_request_time_interval_uniqueness(changeset), do: changeset - - defp extract_and_validate_addresses(%Changeset{} = changeset) do - with {:fetch, {_src, addresses}} <- {:fetch, fetch_field(changeset, :addresses)}, - false <- is_nil(addresses), - {:uniqueness, true} <- {:uniqueness, Enum.count(Enum.uniq(addresses)) == Enum.count(addresses)} do - changeset - else - {:uniqueness, false} -> - add_error(changeset, :addresses, "All addresses should be unique") - - _ -> - add_error(changeset, :addresses, "No addresses") - end - end - - defp validate_tags(%Changeset{} = changeset) do - with {:fetch, {_src, tags}} <- {:fetch, fetch_field(changeset, :tags)}, - false <- is_nil(tags), - trimmed_tags <- String.trim(tags), - tags_list <- String.split(trimmed_tags, ";"), - {:filter_empty, [_ | _] = filtered_tags} <- {:filter_empty, Enum.filter(tags_list, fn tag -> tag != "" end)}, - trimmed_spaces_tags <- Enum.map(filtered_tags, fn tag -> String.trim(tag) end), - {:validate, false} <- {:validate, Enum.any?(tags_list, fn tag -> String.length(tag) > @max_tag_length end)}, - {:uniqueness, true} <- - {:uniqueness, - Enum.count(Enum.uniq_by(trimmed_spaces_tags, &String.downcase(&1))) == Enum.count(trimmed_spaces_tags)}, - trimmed_tags_list <- Enum.take(trimmed_spaces_tags, @max_tags_per_request) do - force_change(changeset, :tags, Enum.join(trimmed_tags_list, ";")) - else - {:uniqueness, false} -> - add_error(changeset, :tags, "All tags should be unique") - - {:filter_empty, _} -> - add_error(changeset, :tags, "All tags are empty strings") - - {:validate, _} -> - add_error(changeset, :tags, "Tags should contain less than #{@max_tag_length} characters") - - _ -> - add_error(changeset, :tags, "No tags") - end - end - - def public_tags_requests_by_identity_id_query(id) when not is_nil(id) do - __MODULE__ - |> where( - [request], - request.identity_id == ^id and request.request_type != "delete" and not is_nil(request.request_id) - ) - |> order_by([request], desc: request.id) - end - - def public_tags_requests_by_identity_id_query(_), do: nil - - def public_tags_request_by_id_and_identity_id_query(id, identity_id) - when not is_nil(id) and not is_nil(identity_id) do - __MODULE__ - |> where([public_tags_request], public_tags_request.identity_id == ^identity_id and public_tags_request.id == ^id) - end - - def public_tags_request_by_id_and_identity_id_query(_, _), do: nil - - def get_public_tags_request_by_id_and_identity_id(id, identity_id) when not is_nil(id) and not is_nil(identity_id) do - id |> public_tags_request_by_id_and_identity_id_query(identity_id) |> Repo.account_repo().one() - end - - def get_public_tags_request_by_id_and_identity_id(_, _), do: nil - - def get_public_tags_requests_by_identity_id(id) when not is_nil(id) do - id - |> public_tags_requests_by_identity_id_query() - |> Repo.account_repo().all() - end - - def get_public_tags_requests_by_identity_id(_), do: nil - - def delete_public_tags_request(identity_id, id) when not is_nil(id) and not is_nil(identity_id) do - id - |> public_tags_request_by_id_and_identity_id_query(identity_id) - |> Repo.account_repo().delete_all() - end - - def delete_public_tags_request(_, _), do: nil - - def update(%{id: id, identity_id: identity_id} = attrs) do - with public_tags_request <- get_public_tags_request_by_id_and_identity_id(id, identity_id), - false <- is_nil(public_tags_request), - {:ok, changeset} <- - public_tags_request |> changeset(Map.put(attrs, :request_type, "edit")) |> Repo.account_repo().update() do - AirTable.submit({:ok, changeset}) - else - true -> - {:error, %{reason: :item_not_found}} - - other -> - other - end - end - - def mark_as_deleted_public_tags_request(%{id: id, identity_id: identity_id, remove_reason: remove_reason}) do - with public_tags_request <- get_public_tags_request_by_id_and_identity_id(id, identity_id), - false <- is_nil(public_tags_request), - {:ok, changeset} <- - public_tags_request - |> changeset_without_constraints(%{request_type: "delete", remove_reason: remove_reason}) - |> Repo.account_repo().update() do - case AirTable.submit({:ok, changeset}) do - {:error, changeset} -> - changeset - - _ -> - true - end - else - {:error, changeset} -> - changeset - - _ -> - false - end - end - - def get_max_public_tags_request_count, do: @max_public_tags_request_per_account - - @doc """ - Merges public tags requests from multiple identities into a primary identity. - - This function updates the `identity_id` of all public tags requests belonging to the - identities specified in `ids_to_merge` to the `primary_id`. It's designed to - be used as part of an Ecto.Multi transaction. - - ## Parameters - - `multi`: An Ecto.Multi struct to which this operation will be added. - - `primary_id`: The ID of the primary identity that will own the merged keys. - - `ids_to_merge`: A list of identity IDs whose public tags requests will be merged. - - ## Returns - - An updated Ecto.Multi struct with the merge operation added. - """ - @spec merge(Multi.t(), integer(), [integer()]) :: Multi.t() - def merge(multi, primary_id, ids_to_merge) do - Multi.run(multi, :merge_public_tags_requests, fn repo, _ -> - {:ok, - repo.update_all( - from(key in __MODULE__, where: key.identity_id in ^ids_to_merge), - set: [identity_id: primary_id] - )} - end) - end -end diff --git a/apps/explorer/lib/explorer/account/tag_address.ex b/apps/explorer/lib/explorer/account/tag_address.ex index caea1f365f13..be96f44ddaf0 100644 --- a/apps/explorer/lib/explorer/account/tag_address.ex +++ b/apps/explorer/lib/explorer/account/tag_address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.TagAddress do @moduledoc """ User created custom address tags. diff --git a/apps/explorer/lib/explorer/account/tag_transaction.ex b/apps/explorer/lib/explorer/account/tag_transaction.ex index 893475894765..301e173ddafa 100644 --- a/apps/explorer/lib/explorer/account/tag_transaction.ex +++ b/apps/explorer/lib/explorer/account/tag_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.TagTransaction do @moduledoc """ This is a personal tag for transaction diff --git a/apps/explorer/lib/explorer/account/watchlist.ex b/apps/explorer/lib/explorer/account/watchlist.ex index bb32b0244b82..af21a665a89c 100644 --- a/apps/explorer/lib/explorer/account/watchlist.ex +++ b/apps/explorer/lib/explorer/account/watchlist.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Watchlist do @moduledoc """ Watchlist is root entity for WatchlistAddresses diff --git a/apps/explorer/lib/explorer/account/watchlist_address.ex b/apps/explorer/lib/explorer/account/watchlist_address.ex index 356734713057..abb85d419565 100644 --- a/apps/explorer/lib/explorer/account/watchlist_address.ex +++ b/apps/explorer/lib/explorer/account/watchlist_address.ex @@ -1,9 +1,83 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Account.WatchlistAddress.Schema do + @moduledoc """ + Models account's addresses watchlist. + """ + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + alias Explorer.Account.Watchlist + alias Explorer.Chain.Wei + + @chain_type_fields (case @chain_type do + :zilliqa -> + elem( + quote do + field(:watch_zrc_2_input, :boolean, default: true, null: false) + field(:watch_zrc_2_output, :boolean, default: true, null: false) + end, + 2 + ) + + _ -> + [] + end) + + @doc """ + Generates the typed schema for watchlist addresses. + + This macro dynamically creates the schema definition including both common fields and chain-type-specific + fields (e.g., ZRC-2 fields for Zilliqa). Should be invoked in the module that requires the schema definition. + + ## Returns + - The typed_schema block with all necessary fields. + """ + defmacro generate do + quote do + typed_schema "account_watchlist_addresses" do + field(:address_hash_hash, Cloak.Ecto.SHA256) :: binary() | nil + field(:name, Explorer.Encrypted.Binary, null: false) + field(:address_hash, Explorer.Encrypted.AddressHash, null: false) + field(:user_created, :boolean, null: false, default: true) + + belongs_to(:watchlist, Watchlist, null: false) + + field(:watch_coin_input, :boolean, default: true, null: false) + field(:watch_coin_output, :boolean, default: true, null: false) + field(:watch_erc_20_input, :boolean, default: true, null: false) + field(:watch_erc_20_output, :boolean, default: true, null: false) + field(:watch_erc_721_input, :boolean, default: true, null: false) + field(:watch_erc_721_output, :boolean, default: true, null: false) + field(:watch_erc_1155_input, :boolean, default: true, null: false) + field(:watch_erc_1155_output, :boolean, default: true, null: false) + field(:watch_erc_404_input, :boolean, default: true, null: false) + field(:watch_erc_404_output, :boolean, default: true, null: false) + field(:notify_email, :boolean, default: true, null: false) + field(:notify_epns, :boolean) + field(:notify_feed, :boolean) + field(:notify_inapp, :boolean) + + field(:fetched_coin_balance, Wei, virtual: true) + field(:tokens_fiat_value, :decimal, virtual: true) + field(:tokens_count, :integer, virtual: true) + field(:tokens_overflow, :boolean, virtual: true) + + timestamps() + + unquote_splicing(@chain_type_fields) + end + end + end +end + defmodule Explorer.Account.WatchlistAddress do @moduledoc """ WatchlistAddress entity """ + require Explorer.Account.WatchlistAddress.Schema + use Explorer.Schema + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] import Ecto.Changeset @@ -11,44 +85,25 @@ defmodule Explorer.Account.WatchlistAddress do alias Explorer.Account.Notifier.ForbiddenAddress alias Explorer.Account.Watchlist alias Explorer.{Chain, PagingOptions, Repo} - alias Explorer.Chain.{Address, Wei} + alias Explorer.Chain.{Address, Hash, Transaction} import Explorer.Chain, only: [hash_to_lower_case_string: 1] + @default_page_size 50 + @default_paging_options %PagingOptions{page_size: @default_page_size} + @watchlist_not_found "Watchlist not found" - typed_schema "account_watchlist_addresses" do - field(:address_hash_hash, Cloak.Ecto.SHA256) :: binary() | nil - field(:name, Explorer.Encrypted.Binary, null: false) - field(:address_hash, Explorer.Encrypted.AddressHash, null: false) - field(:user_created, :boolean, null: false, default: true) - - belongs_to(:watchlist, Watchlist, null: false) - - field(:watch_coin_input, :boolean, default: true, null: false) - field(:watch_coin_output, :boolean, default: true, null: false) - field(:watch_erc_20_input, :boolean, default: true, null: false) - field(:watch_erc_20_output, :boolean, default: true, null: false) - field(:watch_erc_721_input, :boolean, default: true, null: false) - field(:watch_erc_721_output, :boolean, default: true, null: false) - field(:watch_erc_1155_input, :boolean, default: true, null: false) - field(:watch_erc_1155_output, :boolean, default: true, null: false) - field(:watch_erc_404_input, :boolean, default: true, null: false) - field(:watch_erc_404_output, :boolean, default: true, null: false) - field(:notify_email, :boolean, default: true, null: false) - field(:notify_epns, :boolean) - field(:notify_feed, :boolean) - field(:notify_inapp, :boolean) - - field(:fetched_coin_balance, Wei, virtual: true) - field(:tokens_fiat_value, :decimal, virtual: true) - field(:tokens_count, :integer, virtual: true) - field(:tokens_overflow, :boolean, virtual: true) - - timestamps() + @attrs_base ~w(name address_hash watch_coin_input watch_coin_output watch_erc_20_input watch_erc_20_output watch_erc_721_input watch_erc_721_output watch_erc_1155_input watch_erc_1155_output watch_erc_404_input watch_erc_404_output notify_email notify_epns notify_feed notify_inapp watchlist_id)a + if @chain_type == :zilliqa do + @attrs_chain_type ~w(watch_zrc_2_input watch_zrc_2_output)a + else + @attrs_chain_type ~w()a end - @attrs ~w(name address_hash watch_coin_input watch_coin_output watch_erc_20_input watch_erc_20_output watch_erc_721_input watch_erc_721_output watch_erc_1155_input watch_erc_1155_output watch_erc_404_input watch_erc_404_output notify_email notify_epns notify_feed notify_inapp watchlist_id)a + @attrs @attrs_base ++ @attrs_chain_type + + Explorer.Account.WatchlistAddress.Schema.generate() def changeset do %__MODULE__{} @@ -164,12 +219,12 @@ defmodule Explorer.Account.WatchlistAddress do end end - def watchlist_addresses_by_watchlist_id_query(watchlist_id) when not is_nil(watchlist_id) do + defp watchlist_addresses_by_watchlist_id_query(watchlist_id) when not is_nil(watchlist_id) do __MODULE__ |> where([wl_address], wl_address.watchlist_id == ^watchlist_id) end - def watchlist_addresses_by_watchlist_id_query(_), do: nil + defp watchlist_addresses_by_watchlist_id_query(_), do: nil @doc """ Query paginated watchlist addresses by watchlist id @@ -308,4 +363,86 @@ defmodule Explorer.Account.WatchlistAddress do )} end) end + + @doc """ + Fetches the watchlist transactions for the given watchlist ID and options. + """ + @spec fetch_watchlist_transactions(integer(), keyword()) :: + {%{Hash.Address.t() => %{label: String.t(), display_name: String.t()}}, [Transaction.t()]} + def fetch_watchlist_transactions(watchlist_id, options) do + watchlist_addresses = + watchlist_id + |> watchlist_addresses_by_watchlist_id_query() + |> Repo.account_repo().all() + + address_hashes = Enum.map(watchlist_addresses, fn wa -> wa.address_hash end) + + watchlist_names = + Enum.reduce(watchlist_addresses, %{}, fn wa, acc -> + Map.put(acc, wa.address_hash, %{label: wa.name, display_name: wa.name}) + end) + + {watchlist_names, address_hashes_to_mined_transactions_without_rewards(address_hashes, options)} + end + + defp address_hashes_to_mined_transactions_without_rewards(address_hashes, options) do + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + address_hashes + |> address_hashes_to_mined_transactions_tasks(options) + |> Transaction.wait_for_address_transactions() + |> Enum.sort_by(&{&1.block_number, &1.index}, &>=/2) + |> Enum.dedup_by(& &1.hash) + |> Enum.take(paging_options.page_size) + |> Chain.select_repo(options).preload(Map.keys(necessity_by_association)) + end + + defp address_hashes_to_mined_transactions_tasks(address_hashes, options) do + direction = Keyword.get(options, :direction) + + options + |> Transaction.address_to_transactions_tasks_query(true) + |> Transaction.not_pending_transactions() + |> Transaction.matching_address_queries_list(direction, address_hashes) + |> Enum.map(fn query -> + Task.async(fn -> + query + |> Transaction.put_has_token_transfers_to_transaction(false, aliased?: true) + |> Chain.select_repo(options).all() + end) + end) + end + + @doc """ + Retrieves the ID of a WatchlistAddress entry for a given watchlist and address. + + This function queries the WatchlistAddress table to find an entry that matches + both the provided watchlist ID and address hash. It returns the ID of the first + matching entry, if found. + + ## Parameters + - `watchlist_id`: The ID of the watchlist to search within. + - `address_hash`: The address hash to look for, as a `Hash.Address.t()` struct. + + ## Returns + - An integer representing the ID of the matching WatchlistAddress entry, if found. + - `nil` if no matching entry is found or if either input is `nil`. + """ + @spec select_watchlist_address_id(integer() | nil, Hash.Address.t() | nil) :: integer() | nil + def select_watchlist_address_id(watchlist_id, address_hash) + when not is_nil(watchlist_id) and not is_nil(address_hash) do + wa_ids = + __MODULE__ + |> where([wa], wa.watchlist_id == ^watchlist_id and wa.address_hash_hash == ^address_hash) + |> select([wa], wa.id) + |> Repo.account_repo().all() + + case wa_ids do + [wa_id | _] -> wa_id + _ -> nil + end + end + + def select_watchlist_address_id(_watchlist_id, _address_hash), do: nil end diff --git a/apps/explorer/lib/explorer/account/watchlist_notification.ex b/apps/explorer/lib/explorer/account/watchlist_notification.ex index 9d5c9d4ca74f..ff12ccb866f2 100644 --- a/apps/explorer/lib/explorer/account/watchlist_notification.ex +++ b/apps/explorer/lib/explorer/account/watchlist_notification.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.WatchlistNotification do @moduledoc """ Stored notification about event @@ -10,8 +11,8 @@ defmodule Explorer.Account.WatchlistNotification do import Explorer.Chain, only: [hash_to_lower_case_string: 1] alias Ecto.Multi - alias Explorer.Repo alias Explorer.Account.{Watchlist, WatchlistAddress} + alias Explorer.Repo typed_schema "account_watchlist_notifications" do field(:amount, :decimal, null: false) diff --git a/apps/explorer/lib/explorer/accounts/accounts.ex b/apps/explorer/lib/explorer/accounts/accounts.ex index 079b8309cf35..614f2de7f7e7 100644 --- a/apps/explorer/lib/explorer/accounts/accounts.ex +++ b/apps/explorer/lib/explorer/accounts/accounts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts do @moduledoc """ Entrypoint for modifying user account information. diff --git a/apps/explorer/lib/explorer/accounts/user.ex b/apps/explorer/lib/explorer/accounts/user.ex index 20291b95c651..677cfe9b98a1 100644 --- a/apps/explorer/lib/explorer/accounts/user.ex +++ b/apps/explorer/lib/explorer/accounts/user.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts.User do @moduledoc """ An Explorer user. diff --git a/apps/explorer/lib/explorer/accounts/user/authenticate.ex b/apps/explorer/lib/explorer/accounts/user/authenticate.ex index ea127d1a05ab..17075ec06b5c 100644 --- a/apps/explorer/lib/explorer/accounts/user/authenticate.ex +++ b/apps/explorer/lib/explorer/accounts/user/authenticate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts.User.Authenticate do @moduledoc """ Represents the data required to authenticate a user. diff --git a/apps/explorer/lib/explorer/accounts/user/registration.ex b/apps/explorer/lib/explorer/accounts/user/registration.ex index b62dd1c25e40..5c63a910fc4a 100644 --- a/apps/explorer/lib/explorer/accounts/user/registration.ex +++ b/apps/explorer/lib/explorer/accounts/user/registration.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts.User.Registration do @moduledoc """ Represents the data required to register a new account. diff --git a/apps/explorer/lib/explorer/accounts/user_contact.ex b/apps/explorer/lib/explorer/accounts/user_contact.ex index 5b53692f38e1..75ea999c4ae8 100644 --- a/apps/explorer/lib/explorer/accounts/user_contact.ex +++ b/apps/explorer/lib/explorer/accounts/user_contact.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts.UserContact do @moduledoc """ A individual form of contacting the user. diff --git a/apps/explorer/lib/explorer/admin.ex b/apps/explorer/lib/explorer/admin.ex index 3fd30bafa666..285a5ae44fcc 100644 --- a/apps/explorer/lib/explorer/admin.ex +++ b/apps/explorer/lib/explorer/admin.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Admin do @moduledoc """ Context for performing administrative tasks. diff --git a/apps/explorer/lib/explorer/admin/administrator.ex b/apps/explorer/lib/explorer/admin/administrator.ex index 2f3109c10bc4..34bd52fd80ee 100644 --- a/apps/explorer/lib/explorer/admin/administrator.ex +++ b/apps/explorer/lib/explorer/admin/administrator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Admin.Administrator do @moduledoc """ Represents a user with administrative privileges. diff --git a/apps/explorer/lib/explorer/admin/recovery.ex b/apps/explorer/lib/explorer/admin/recovery.ex index 0e12681d1997..f10f2200e2e3 100644 --- a/apps/explorer/lib/explorer/admin/recovery.ex +++ b/apps/explorer/lib/explorer/admin/recovery.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Admin.Recovery do @moduledoc """ Generates a recovery key to configure the application as an administrator. diff --git a/apps/explorer/lib/explorer/admin/role.ex b/apps/explorer/lib/explorer/admin/role.ex index ef31ed957c3f..f53f5c5cb0cb 100644 --- a/apps/explorer/lib/explorer/admin/role.ex +++ b/apps/explorer/lib/explorer/admin/role.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Admin.Administrator.Role do @moduledoc """ Supported roles for an administrator. diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index a291bdf6bdc4..be49be5f390b 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Application do @moduledoc """ This is the Application module for Explorer. @@ -5,7 +6,7 @@ defmodule Explorer.Application do use Application - alias Explorer.Admin + alias Explorer.{Admin, Helper} alias Explorer.Chain.Cache.{ Accounts, @@ -17,7 +18,6 @@ defmodule Explorer.Application do MinMissingBlockNumber, StateChanges, Transactions, - TransactionsApiV2, Uncles } @@ -28,6 +28,7 @@ defmodule Explorer.Application do BlocksCount, GasUsageSum, PendingBlockOperationCount, + PendingTransactionOperationCount, TransactionsCount } @@ -36,11 +37,21 @@ defmodule Explorer.Application do alias Explorer.Market.MarketHistoryCache alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Prometheus.Instrumenter alias Explorer.Repo.PrometheusLogger + alias Explorer.Stats.HotSmartContractsCache + alias Explorer.Utility.Hammer + alias Oban.Telemetry, as: ObanTelemetry + alias Utils.ConfigHelper @impl Application def start(_type, _args) do PrometheusLogger.setup() + Instrumenter.setup() + + if Application.get_env(:explorer, Oban, [])[:enabled] && Explorer.mode() in [:api, :all] do + ObanTelemetry.attach_default_logger() + end :telemetry.attach( "prometheus-ecto", @@ -55,7 +66,9 @@ defmodule Explorer.Application do Explorer.Repo.Replica1, Explorer.Vault, Supervisor.child_spec({SpandexDatadog.ApiServer, datadog_opts()}, id: SpandexDatadog.ApiServer), - Supervisor.child_spec({Task.Supervisor, name: Explorer.HistoryTaskSupervisor}, id: Explorer.HistoryTaskSupervisor), + Supervisor.child_spec({Task.Supervisor, name: Explorer.HistoryTaskSupervisor}, + id: Explorer.HistoryTaskSupervisor + ), Supervisor.child_spec({Task.Supervisor, name: Explorer.MarketTaskSupervisor}, id: Explorer.MarketTaskSupervisor), Supervisor.child_spec({Task.Supervisor, name: Explorer.GenesisDataTaskSupervisor}, id: GenesisDataTaskSupervisor), Supervisor.child_spec({Task.Supervisor, name: Explorer.TaskSupervisor}, id: Explorer.TaskSupervisor), @@ -63,11 +76,7 @@ defmodule Explorer.Application do id: LookUpSmartContractSourcesTaskSupervisor ), Supervisor.child_spec({Task.Supervisor, name: Explorer.WETHMigratorSupervisor}, id: WETHMigratorSupervisor), - Explorer.SmartContract.SolcDownloader, - Explorer.SmartContract.VyperDownloader, - Explorer.Chain.Health.Monitor, {Registry, keys: :duplicate, name: Registry.ChainEvents, id: Registry.ChainEvents}, - {Admin.Recovery, [[], [name: Admin.Recovery]]}, Accounts, AddressesCoinBalanceSum, AddressesCoinBalanceSumMinusBurnt, @@ -79,17 +88,26 @@ defmodule Explorer.Application do GasPriceOracle, GasUsageSum, PendingBlockOperationCount, + PendingTransactionOperationCount, TransactionsCount, StateChanges, Transactions, - TransactionsApiV2, Uncles, AddressTabsElementsCount, con_cache_child_spec(MarketHistoryCache.cache_name()), + con_cache_child_spec(HotSmartContractsCache.cache_name(), + ttl_check_interval: :timer.seconds(1), + global_ttl: :infinity + ), con_cache_child_spec(RSK.cache_name(), ttl_check_interval: :timer.minutes(1), global_ttl: :timer.minutes(30)), {Redix, redix_opts()}, - {Explorer.Utility.MissingRangesManipulator, []}, - {Explorer.Utility.ReplicaAccessibilityManager, []} + {Explorer.Utility.ReplicaAccessibilityManager, []}, + :hackney_pool.child_spec(:default, + recv_timeout: 60_000, + timeout: 60_000, + max_connections: Application.get_env(:explorer, :hackney_default_pool_size) + ), + Explorer.Promo.Autoscout ] children = base_children ++ configurable_children() @@ -97,7 +115,7 @@ defmodule Explorer.Application do opts = [strategy: :one_for_one, name: Explorer.Supervisor, max_restarts: 1_000] if Application.get_env(:nft_media_handler, :standalone_media_worker?) do - Supervisor.start_link([], opts) + Supervisor.start_link([libcluster()] |> List.flatten(), opts) else Supervisor.start_link(children, opts) end @@ -106,73 +124,90 @@ defmodule Explorer.Application do defp configurable_children do configurable_children_set = [ + configure(Explorer.Chain.Health.Monitor), + only_in_mode(Explorer.SmartContract.SolcDownloader, :api), + only_in_mode(Explorer.SmartContract.VyperDownloader, :api), + only_in_mode({Admin.Recovery, [[], [name: Admin.Recovery]]}, :api), + configure(Explorer.Utility.VersionUpgrade), + configure_mode_dependent_process(Explorer.Utility.VersionConstantsUpdater, :indexer), configure_mode_dependent_process(Explorer.Market.Fetcher.Coin, :api), configure_mode_dependent_process(Explorer.Market.Fetcher.Token, :indexer), + configure_mode_dependent_process(Explorer.Market.Fetcher.TokenList, :indexer), configure_mode_dependent_process(Explorer.Market.Fetcher.History, :indexer), - configure(Explorer.ChainSpec.GenesisData), - configure(Explorer.Chain.Cache.Counters.ContractsCount), - configure(Explorer.Chain.Cache.Counters.NewContractsCount), - configure(Explorer.Chain.Cache.Counters.VerifiedContractsCount), - configure(Explorer.Chain.Cache.Counters.NewVerifiedContractsCount), - configure(Explorer.Chain.Cache.TransactionActionTokensData), - configure(Explorer.Chain.Cache.TransactionActionUniswapPools), - configure(Explorer.Chain.Cache.Counters.WithdrawalsSum), - configure(Explorer.Chain.Transaction.History.Historian), + configure_mode_dependent_process(Explorer.Market, :api), + configure_mode_dependent_process(Explorer.ChainSpec.GenesisData, :indexer), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.ContractsCount, :indexer), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.NewContractsCount, :indexer), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.VerifiedContractsCount, :indexer), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.NewVerifiedContractsCount, :indexer), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.WithdrawalsSum, :indexer), + configure_mode_dependent_process(Explorer.Chain.Transaction.History.Historian, :indexer), configure(Explorer.Chain.Events.Listener), - configure(Explorer.Chain.Cache.Counters.AddressesCount), - configure(Explorer.Chain.Cache.Counters.AddressTransactionsCount), - configure(Explorer.Chain.Cache.Counters.AddressTokenTransfersCount), - configure(Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum), - configure(Explorer.Chain.Cache.Counters.AddressTokensUsdSum), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressesCount, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTransactionsCount, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTokenTransfersCount, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.AddressTokensUsdSum, :api), configure(Explorer.Chain.Cache.Counters.TokenHoldersCount), configure(Explorer.Chain.Cache.Counters.TokenTransfersCount), - configure(Explorer.Chain.Cache.Counters.BlockBurntFeeCount), - configure(Explorer.Chain.Cache.Counters.BlockPriorityFeeCount), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.BlockBurntFeeCount, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.BlockPriorityFeeCount, :api), configure(Explorer.Chain.Cache.Counters.AverageBlockTime), - configure(Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount), - configure(Explorer.Chain.Cache.Counters.NewPendingTransactionsCount), - configure(Explorer.Chain.Cache.Counters.Transactions24hCount), - configure(Explorer.Validator.MetadataProcessor), - configure(Explorer.Tags.AddressTag.Cataloger), - configure(Explorer.SmartContract.CertifiedSmartContractCataloger), - configure(MinMissingBlockNumber), - configure(Explorer.Chain.Fetcher.CheckBytecodeMatchingOnDemand), + Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount + |> configure_mode_dependent_process(:indexer) + |> configure_chain_type_dependent_process(:optimism), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.NewPendingTransactionsCount, :indexer), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.Transactions24hCount, :indexer), + configure_mode_dependent_process(Explorer.Validator.MetadataProcessor, :indexer), + configure_mode_dependent_process(Explorer.Tags.AddressTag.Cataloger, :indexer), + configure_mode_dependent_process(Explorer.SmartContract.CertifiedSmartContractCataloger, :indexer), + configure_mode_dependent_process(MinMissingBlockNumber, :indexer), + configure_mode_dependent_process(Explorer.Chain.Fetcher.CheckBytecodeMatchingOnDemand, :api), configure(Explorer.Chain.Fetcher.FetchValidatorInfoOnDemand), - configure(Explorer.TokenInstanceOwnerAddressMigration.Supervisor), - configure_sc_microservice(Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand), - configure(Explorer.Chain.Cache.Counters.Rootstock.LockedBTCCount), - configure(Explorer.Chain.Cache.OptimismFinalizationPeriod), - configure(Explorer.Migrator.TransactionsDenormalization), - configure(Explorer.Migrator.AddressCurrentTokenBalanceTokenType), - configure(Explorer.Migrator.AddressTokenBalanceTokenType), - configure(Explorer.Migrator.SanitizeMissingBlockRanges), - configure(Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers), - configure(Explorer.Migrator.TokenTransferTokenType), - configure(Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers), - configure(Explorer.Migrator.TransactionBlockConsensus), - configure(Explorer.Migrator.TokenTransferBlockConsensus), - configure(Explorer.Migrator.RestoreOmittedWETHTransfers), - configure(Explorer.Migrator.FilecoinPendingAddressOperations), - configure(Explorer.Migrator.SmartContractLanguage), + configure_mode_dependent_process(Explorer.TokenInstanceOwnerAddressMigration.Supervisor, :indexer), + Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand + |> configure_sc_microservice() + |> only_in_mode(:api), + configure_mode_dependent_process(Explorer.Chain.Cache.Counters.Rootstock.LockedBTCCount, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.OptimismFinalizationPeriod, :api), + configure_mode_dependent_process(Explorer.Chain.Cache.CeloEpochs, :api), + configure_mode_dependent_process(Explorer.Migrator.TransactionsDenormalization, :indexer), + configure_mode_dependent_process(Explorer.Migrator.AddressCurrentTokenBalanceTokenType, :indexer), + configure_mode_dependent_process(Explorer.Migrator.AddressTokenBalanceTokenType, :indexer), + configure_mode_dependent_process(Explorer.Migrator.SanitizeMissingBlockRanges, :indexer), + configure_mode_dependent_process(Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers, :indexer), + configure_mode_dependent_process(Explorer.Migrator.TokenTransferTokenType, :indexer), + configure_mode_dependent_process(Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers, :indexer), + configure_mode_dependent_process(Explorer.Migrator.TransactionBlockConsensus, :indexer), + configure_mode_dependent_process(Explorer.Migrator.TokenTransferBlockConsensus, :indexer), + configure_mode_dependent_process(Explorer.Migrator.RestoreOmittedWETHTransfers, :indexer), + configure_mode_dependent_process(Explorer.Migrator.FilecoinPendingAddressOperations, :indexer), + configure_mode_dependent_process(Explorer.Migrator.CeloL2Epochs, :indexer), + configure_mode_dependent_process(Explorer.Migrator.CeloAccounts, :indexer), + configure_mode_dependent_process(Explorer.Migrator.CeloAggregatedElectionRewards, :indexer), + configure_mode_dependent_process(Explorer.Migrator.SanitizeErc1155TokenBalancesWithoutTokenIds, :indexer), Explorer.Migrator.BackfillMultichainSearchDB |> configure_mode_dependent_process(:indexer) |> configure_multichain_search_microservice(), configure_mode_dependent_process(Explorer.Migrator.ArbitrumDaRecordsNormalization, :indexer), configure_mode_dependent_process(Explorer.Migrator.ShrinkInternalTransactions, :indexer), - configure_chain_type_dependent_process(Explorer.Chain.Cache.Counters.Blackfort.ValidatorsCount, :blackfort), - configure_chain_type_dependent_process(Explorer.Chain.Cache.Counters.Stability.ValidatorsCount, :stability), - configure_chain_type_dependent_process(Explorer.Chain.Cache.LatestL1BlockNumber, [ + Explorer.Chain.Cache.Counters.Blackfort.ValidatorsCount + |> configure_chain_type_dependent_process(:blackfort) + |> only_in_mode(:indexer), + Explorer.Chain.Cache.Counters.Stability.ValidatorsCount + |> configure_chain_type_dependent_process(:stability) + |> only_in_mode(:indexer), + Explorer.Chain.Cache.LatestL1BlockNumber + |> configure_chain_type_dependent_process([ :optimism, - :polygon_edge, - :polygon_zkevm, :scroll, :shibarium - ]), + ]) + |> only_in_mode(:indexer), configure_chain_type_dependent_con_cache(), Explorer.Migrator.SanitizeDuplicatedLogIndexLogs - |> configure() + |> configure_mode_dependent_process(:indexer) |> configure_chain_type_dependent_process([ - :polygon_zkevm, :rsk, :filecoin ]), @@ -181,15 +216,23 @@ defmodule Explorer.Application do configure_mode_dependent_process(Explorer.Migrator.SanitizeVerifiedAddresses, :indexer), configure_mode_dependent_process(Explorer.Migrator.SanitizeEmptyContractCodeAddresses, :indexer), configure_mode_dependent_process(Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus, :indexer), + configure_mode_dependent_process(Explorer.Migrator.MergeAdjacentMissingBlockRanges, :indexer), + configure_mode_dependent_process(Explorer.Migrator.UnescapeQuotesInTokens, :indexer), + configure_mode_dependent_process(Explorer.Migrator.UnescapeAmpersandsInTokens, :indexer), + configure_mode_dependent_process(Explorer.Migrator.ReindexBlocksWithMissingTransactions, :indexer), + configure_mode_dependent_process(Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources, :indexer), + configure_mode_dependent_process(Explorer.Migrator.DeleteZeroValueInternalTransactions, :indexer), + configure_mode_dependent_process(Explorer.Migrator.EmptyInternalTransactionsData, :indexer), + configure_mode_dependent_process(Explorer.Migrator.FillInternalTransactionsAddressIds, :indexer), configure_mode_dependent_process( Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesVerifiedIndex, :indexer ), - configure_mode_dependent_process(Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockHashIndex, :indexer), configure_mode_dependent_process( Explorer.Migrator.HeavyDbIndexOperation.DropLogsBlockNumberAscIndexAscIndex, :indexer ), + configure_mode_dependent_process(Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockHashIndex, :indexer), configure_mode_dependent_process( Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashBlockNumberDescIndexDescIndex, :indexer @@ -287,14 +330,120 @@ defmodule Explorer.Application do Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, :indexer ), - Explorer.Migrator.RefetchContractCodes |> configure() |> configure_chain_type_dependent_process(:zksync), - configure(Explorer.Chain.Fetcher.AddressesBlacklist), - Explorer.Migrator.SwitchPendingOperations, - configure_mode_dependent_process(Explorer.Utility.RateLimiter, :api) + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBlockNumberTransactionIndexNotNull, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateSmartContractAdditionalSourcesUniqueIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsOperatorFeeConstantIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropTokenInstancesTokenIdIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashPartialIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTokensNamePartialFtsIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdMcapFiatHolderNameIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdFiatHolderNameIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdHolderNameIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockHashTransactionIndexIndexIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsFromAddressIdPartialIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsToAddressIdPartialIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsToAddressHashPartialIndex, + :indexer + ), + configure_mode_dependent_process( + Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesHashContractCodeNotNullIndex, + :indexer + ), + Explorer.Migrator.RefetchContractCodes + |> configure_mode_dependent_process(:indexer) + |> configure_chain_type_dependent_process(:zksync), + configure_mode_dependent_process(Explorer.Chain.Fetcher.AddressesBlacklist, :api), + only_in_mode(Explorer.Migrator.SwitchPendingOperations, :indexer), + configure_mode_dependent_process(Explorer.Utility.RateLimiter, :api), + Hammer.child_for_supervisor() |> configure_mode_dependent_process(:api), + configure_mode_dependent_process(Explorer.ThirdPartyIntegrations.Dynamic.Strategy, :api), + # keep at the end + configure_libcluster(), + configure_mode_dependent_process( + {Oban, :explorer |> Application.fetch_env!(Oban) |> Keyword.delete(:enabled)}, + :api + ) ] |> List.flatten() - repos_by_chain_type() ++ account_repo() ++ mud_repo() ++ configurable_children_set + repos_by_chain_type() ++ account_repo() ++ mud_repo() ++ event_notification_repo() ++ configurable_children_set end defp repos_by_chain_type do @@ -308,7 +457,6 @@ defmodule Explorer.Application do Explorer.Repo.Filecoin, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, @@ -339,6 +487,18 @@ defmodule Explorer.Application do end end + defp event_notification_repo do + if Application.get_env(:explorer, :realtime_events_sender) == Explorer.Chain.Events.DBSender || Mix.env() == :test do + [Explorer.Repo.EventNotifications] + else + [] + end + end + + defp should_start?({process, _opts}) do + Application.get_env(:explorer, process, [])[:enabled] == true + end + defp should_start?(process) do Application.get_env(:explorer, process, [])[:enabled] == true end @@ -381,7 +541,11 @@ defmodule Explorer.Application do end defp configure_mode_dependent_process(process, mode) do - if should_start?(process) and Application.get_env(:explorer, :mode) in [mode, :all] do + process |> configure() |> only_in_mode(mode) + end + + defp only_in_mode(process, mode) do + if Explorer.mode() in [mode, :all] do process else [] @@ -445,6 +609,24 @@ defmodule Explorer.Application do end defp redix_opts do - {System.get_env("ACCOUNT_REDIS_URL") || "redis://127.0.0.1:6379", [name: :redix]} + "ACCOUNT_REDIS_URL" + |> ConfigHelper.parse_url_env_var("redis://127.0.0.1:6379") + |> Helper.redix_opts( + String.downcase(ConfigHelper.safe_get_env("ACCOUNT_REDIS_SSL_ENABLED", "false")) == "true", + ConfigHelper.safe_get_env("ACCOUNT_REDIS_SENTINEL_URLS", ""), + ConfigHelper.safe_get_env("ACCOUNT_REDIS_SENTINEL_MASTER_NAME", "") + ) + |> Keyword.merge(name: :redix) end + + defp configure_libcluster do + if Explorer.mode() in [:indexer, :api] do + libcluster() + else + [] + end + end + + defp libcluster, + do: {Cluster.Supervisor, [Application.get_env(:libcluster, :topologies), [name: Explorer.ClusterSupervisor]]} end diff --git a/apps/explorer/lib/explorer/application/constants.ex b/apps/explorer/lib/explorer/application/constants.ex index d5e2413d90ea..76f5baf56e76 100644 --- a/apps/explorer/lib/explorer/application/constants.ex +++ b/apps/explorer/lib/explorer/application/constants.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Application.Constants do @moduledoc """ Tracks some kv info @@ -9,6 +10,7 @@ defmodule Explorer.Application.Constants do @keys_manager_contract_address_key "keys_manager_contract_address" @last_processed_erc_721_token "token_instance_sanitizer_last_processed_erc_721_token" + @current_backend_version_key "current_backend_version" @primary_key false typed_schema "constants" do @@ -100,6 +102,31 @@ defmodule Explorer.Application.Constants do """ @spec get_last_processed_token_address_hash(keyword()) :: nil | Explorer.Chain.Hash.t() def get_last_processed_token_address_hash(options \\ []) do - @last_processed_erc_721_token |> get_constant_by_key(options) |> Chain.string_to_address_hash_or_nil() + @last_processed_erc_721_token |> get_constant_value(options) |> Chain.string_to_address_hash_or_nil() + end + + @doc """ + Inserts the current running backend version into constants storage. + + ## Parameters + - `version`: Backend version string to persist. + + ## Returns + - The inserted or updated `%Explorer.Application.Constants{}` record. + """ + @spec insert_current_backend_version(String.t()) :: Ecto.Schema.t() + def insert_current_backend_version(version) do + set_constant_value(@current_backend_version_key, version) + end + + @doc """ + Fetches the current running backend version from constants storage. + + ## Returns + - Current backend version. + """ + @spec get_current_backend_version() :: nil | String.t() + def get_current_backend_version do + get_constant_value(@current_backend_version_key) end end diff --git a/apps/explorer/lib/explorer/arbitrum/claim_rollup_message.ex b/apps/explorer/lib/explorer/arbitrum/claim_rollup_message.ex index 5dd5c73df74d..8660d9356d78 100644 --- a/apps/explorer/lib/explorer/arbitrum/claim_rollup_message.ex +++ b/apps/explorer/lib/explorer/arbitrum/claim_rollup_message.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Arbitrum.ClaimRollupMessage do @moduledoc """ Provides functionality to read L2->L1 messages and prepare withdrawal claims in the Arbitrum protocol. @@ -27,7 +28,6 @@ defmodule Explorer.Arbitrum.ClaimRollupMessage do alias Explorer.Chain.Arbitrum.Reader.Indexer.Messages, as: MessagesIndexerReader alias Explorer.Chain.{Data, Hash} alias Explorer.Chain.Hash.Address - alias Explorer.Helper, as: ExplorerHelper alias Indexer.Helper, as: IndexerHelper require Logger @@ -237,7 +237,7 @@ defmodule Explorer.Arbitrum.ClaimRollupMessage do } else Logger.error( - "message_to_withdrawal: log doesn't correspond message (#{fields.position} != #{message.message_id})" + "message_to_withdrawal: log doesn't correspond message (#{fields.message_id} != #{message.message_id})" ) nil @@ -380,16 +380,15 @@ defmodule Explorer.Arbitrum.ClaimRollupMessage do # - `data`: Binary data containing the finalizeInboundTransfer calldata # # ## Returns - # - Map containing token contract `address`, `destination` address, token `amount`, + # - Map containing token contract `address_hash`, `destination_address_hash`, token `amount`, # token `name`, `symbol` and `decimals` if the data corresponds to finalizeInboundTransfer selector # - `nil` if data is void or doesn't match finalizeInboundTransfer method (which # happens when the L2->L1 message is for arbitrary data transfer, such as a remote # call of a smart contract on L1) @spec obtain_token_withdrawal_data(binary()) :: %{ - address_hash: Explorer.Chain.Hash.Address.t(), - address: Explorer.Chain.Hash.Address.t(), - destination: Explorer.Chain.Hash.Address.t(), + address_hash: Explorer.Chain.Hash.Address.t() | nil, + destination_address_hash: Explorer.Chain.Hash.Address.t() | nil, amount: non_neg_integer(), decimals: non_neg_integer() | nil, name: binary() | nil, @@ -420,11 +419,7 @@ defmodule Explorer.Arbitrum.ClaimRollupMessage do %{ address_hash: token_bin, - # todo: "address" should be removed in favour `address_hash` property with the next release after 8.0.0 - address: token_bin, destination_address_hash: to_bin, - # todo: "destination" should be removed in favour `destination_address_hash` property with the next release after 8.0.0 - destination: to_bin, amount: amount, decimals: token_info.decimals, name: token_info.name, @@ -508,7 +503,7 @@ defmodule Explorer.Arbitrum.ClaimRollupMessage do # Converts list of binaries into the hex-encoded 0x-prefixed strings defp raw_proof_to_hex(proof) do proof - |> Enum.map(fn p -> ExplorerHelper.add_0x_prefix(p) end) + |> Enum.map(fn p -> %Data{bytes: p} |> to_string() end) end # Retrieves the size parameter (total count of L2->L1 messages) needed for outbox @@ -645,7 +640,7 @@ defmodule Explorer.Arbitrum.ClaimRollupMessage do [ArbitrumEvents.node_created()], json_l1_rpc_named_arguments ) do - {:ok, events} when is_list(events) and length(events) > 0 -> + {:ok, events} when is_list(events) and events !== [] -> node_created_event = List.last(events) # extract L2 block hash from the NodeCreated event l2_block_hash = l2_block_hash_from_node_created_event(node_created_event) diff --git a/apps/explorer/lib/explorer/arbitrum/withdraw.ex b/apps/explorer/lib/explorer/arbitrum/withdraw.ex index d026f829bbda..f18a607c1a6d 100644 --- a/apps/explorer/lib/explorer/arbitrum/withdraw.ex +++ b/apps/explorer/lib/explorer/arbitrum/withdraw.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Arbitrum.Withdraw do @moduledoc """ Models an L2->L1 withdraw on Arbitrum. @@ -21,8 +22,8 @@ defmodule Explorer.Arbitrum.Withdraw do otherwise the transaction will fail. Typically this field contain calldata for `finalizeInboundTransfer(address,address,address,uint256,bytes)` method of the Bridge contract and it intended to withdraw supported tokens instead of native coins. - * `token_address` - extracted L1 address of the token to withdraw in case of `data` field represents Bridge transaction - * `token_destination` - extracted L1 receiver address in case of `data` field represents Bridge transaction + * `token_address_hash` - extracted L1 address of the token to withdraw in case of `data` field represents Bridge transaction + * `token_destination_address_hash` - extracted L1 receiver address in case of `data` field represents Bridge transaction * `token_amount` - extracted token amount in case of `data` field represents Bridge transaction * `token_decimals` - how many decimal places the associated L1 token has * `token_name` - the name of the associated L1 token @@ -42,8 +43,8 @@ defmodule Explorer.Arbitrum.Withdraw do data: data, token: %{ - address: token_address, - destination: token_destination, + address_hash: token_address_hash, + destination_address_hash: token_destination_address_hash, amount: token_amount, decimals: token_decimals, name: token_name, @@ -62,8 +63,8 @@ defmodule Explorer.Arbitrum.Withdraw do @typep l2_timestamp :: non_neg_integer() @typep callvalue :: non_neg_integer() @typep data :: binary() - @typep token_address :: Hash.Address.t() - @typep token_destination :: Hash.Address.t() + @typep token_address_hash :: Hash.Address.t() | nil + @typep token_destination_address_hash :: Hash.Address.t() | nil @typep token_amount :: non_neg_integer() @typep token_decimals :: non_neg_integer() | nil @typep token_name :: binary() | nil diff --git a/apps/explorer/lib/explorer/bloom_filter.ex b/apps/explorer/lib/explorer/bloom_filter.ex index a5b636207a9a..c869cc7d6963 100644 --- a/apps/explorer/lib/explorer/bloom_filter.ex +++ b/apps/explorer/lib/explorer/bloom_filter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.BloomFilter do @moduledoc """ Eth Bloom filter realization. Reference: https://github.com/NethermindEth/nethermind/blob/d61c78af6de2d0a89bd4efd6bfed62cb6b774f59/src/Nethermind/Nethermind.Core/Bloom.cs diff --git a/apps/explorer/lib/explorer/bound_queue.ex b/apps/explorer/lib/explorer/bound_queue.ex index daa44deb641d..6402a6546753 100644 --- a/apps/explorer/lib/explorer/bound_queue.ex +++ b/apps/explorer/lib/explorer/bound_queue.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.BoundQueue do @moduledoc """ A queue that tracks its size and can have its size bound to a maximum. diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 1425debebffc..3068a32dcd17 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain do @moduledoc """ The chain context. @@ -5,38 +6,27 @@ defmodule Explorer.Chain do import Ecto.Query, only: [ - dynamic: 1, dynamic: 2, from: 2, join: 4, join: 5, limit: 2, - lock: 2, - offset: 2, order_by: 2, order_by: 3, preload: 2, preload: 3, - select: 2, select: 3, subquery: 1, - union: 2, where: 2, where: 3 ] - import EthereumJSONRPC, only: [integer_to_quantity: 1, fetch_block_internal_transactions: 2] - require Logger alias ABI.TypeDecoder - alias Ecto.Changeset - alias EthereumJSONRPC.Transaction, as: EthereumJSONRPCTransaction alias EthereumJSONRPC.Utility.RangesHelper - alias Explorer.Account.WatchlistAddress - alias Explorer.Chain alias Explorer.Chain.{ @@ -49,12 +39,10 @@ defmodule Explorer.Chain do BlockNumberHelper, CurrencyHelper, Data, - DenormalizationHelper, Hash, Import, InternalTransaction, Log, - PendingBlockOperation, PendingOperationsHelper, PendingTransactionOperation, SmartContract, @@ -70,25 +58,21 @@ defmodule Explorer.Chain do alias Explorer.Chain.Cache.{ BlockNumber, Blocks, - Transactions, Uncles } alias Explorer.Chain.Cache.Counters.{ BlocksCount, ContractsCount, - LastFetchedCounter, NewContractsCount, NewVerifiedContractsCount, - TokenHoldersCount, - TokenTransfersCount, - VerifiedContractsCount, - WithdrawalsSum + PendingBlockOperationCount, + PendingTransactionOperationCount, + VerifiedContractsCount } alias Explorer.Chain.Cache.Counters.Helper, as: CacheCountersHelper alias Explorer.Chain.Health.Helper, as: HealthHelper - alias Explorer.Chain.InternalTransaction.{CallType, Type} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Helper, as: ExplorerHelper @@ -103,14 +87,6 @@ defmodule Explorer.Chain do @token_transfers_per_transaction_preview 10 - @method_name_to_id_map %{ - "approve" => "095ea7b3", - "transfer" => "a9059cbb", - "multicall" => "5ae401dc", - "mint" => "40c10f19", - "commit" => "f14fcbc8" - } - @revert_msg_prefix_1 "Revert: " @revert_msg_prefix_2 "revert: " @revert_msg_prefix_3 "reverted " @@ -118,9 +94,7 @@ defmodule Explorer.Chain do # Geth-like node @revert_msg_prefix_5 "execution reverted: " @revert_msg_prefix_6_empty "execution reverted" - - @limit_showing_transactions 10_000 - + @deduplicated_optional_address_associations [:from_address, :to_address, :created_contract_address] @typedoc """ The name of an association on the `t:Ecto.Schema.t/0` """ @@ -165,89 +139,7 @@ defmodule Explorer.Chain do @type api? :: {:api?, true | false} @type ip :: {:ip, String.t()} @type show_scam_tokens? :: {:show_scam_tokens?, true | false} - - @doc """ - `t:Explorer.Chain.InternalTransaction/0`s from the address with the given `hash`. - - This function excludes any internal transactions in the results where the - internal transaction has no siblings within the parent transaction. - - ## Options - - * `:direction` - if specified, will filter internal transactions by address type. If `:to` is specified, only - internal transactions where the "to" address matches will be returned. Likewise, if `:from` is specified, only - internal transactions where the "from" address matches will be returned. If `:direction` is omitted, internal - transactions either to or from the address will be returned. - * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is - `:required`, and the `t:Explorer.Chain.InternalTransaction.t/0` has no associated record for that association, - then the `t:Explorer.Chain.InternalTransaction.t/0` will not be included in the page `entries`. - * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` and - `:key` (a tuple of the lowest/oldest `{block_number, transaction_index, index}`) and. Results will be the internal - transactions older than the `block_number`, `transaction index`, and `index` that are passed. - - """ - @spec address_to_internal_transactions(Hash.Address.t(), [paging_options | necessity_by_association_option]) :: [ - InternalTransaction.t() - ] - def address_to_internal_transactions(hash, options \\ []) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - direction = Keyword.get(options, :direction) - - from_block = from_block(options) - to_block = to_block(options) - - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - case paging_options do - %PagingOptions{key: {0, 0, 0}} -> - [] - - _ -> - if direction == nil || direction == "" do - query_to_address_hash_wrapped = - InternalTransaction - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_address_fields_match(hash, :to_address_hash) - |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) - |> common_where_limit_order(paging_options) - |> wrapped_union_subquery() - - query_from_address_hash_wrapped = - InternalTransaction - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_address_fields_match(hash, :from_address_hash) - |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) - |> common_where_limit_order(paging_options) - |> wrapped_union_subquery() - - query_created_contract_address_hash_wrapped = - InternalTransaction - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_address_fields_match(hash, :created_contract_address_hash) - |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) - |> common_where_limit_order(paging_options) - |> wrapped_union_subquery() - - query_to_address_hash_wrapped - |> union(^query_from_address_hash_wrapped) - |> union(^query_created_contract_address_hash_wrapped) - |> wrapped_union_subquery() - |> common_where_limit_order(paging_options) - |> preload(:block) - |> join_associations(necessity_by_association) - |> select_repo(options).all() - else - InternalTransaction - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_address_fields_match(hash, direction) - |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) - |> common_where_limit_order(paging_options) - |> preload(:block) - |> join_associations(necessity_by_association) - |> select_repo(options).all() - end - end - end + @type timeout_option :: {:timeout, timeout() | nil} def wrapped_union_subquery(query) do from( @@ -256,48 +148,6 @@ defmodule Explorer.Chain do ) end - defp common_where_limit_order(query, paging_options) do - query - |> InternalTransaction.where_is_different_from_parent_transaction() - |> page_internal_transaction(paging_options, %{index_internal_transaction_desc_order: true}) - |> limit(^paging_options.page_size) - |> order_by( - [it], - desc: it.block_number, - desc: it.transaction_index, - desc: it.index - ) - end - - def address_hashes_to_mined_transactions_without_rewards(address_hashes, options) do - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - - address_hashes - |> address_hashes_to_mined_transactions_tasks(options) - |> Transaction.wait_for_address_transactions() - |> Enum.sort_by(&{&1.block_number, &1.index}, &>=/2) - |> Enum.dedup_by(& &1.hash) - |> Enum.take(paging_options.page_size) - |> select_repo(options).preload(Map.keys(necessity_by_association)) - end - - defp address_hashes_to_mined_transactions_tasks(address_hashes, options) do - direction = Keyword.get(options, :direction) - - options - |> Transaction.address_to_transactions_tasks_query(true) - |> Transaction.not_pending_transactions() - |> Transaction.matching_address_queries_list(direction, address_hashes) - |> Enum.map(fn query -> - Task.async(fn -> - query - |> Transaction.put_has_token_transfers_to_transaction(false, aliased?: true) - |> select_repo(options).all() - end) - end) - end - @spec address_hash_to_token_transfers(Hash.Address.t(), Keyword.t()) :: [Transaction.t()] def address_hash_to_token_transfers(address_hash, options \\ []) do paging_options = Keyword.get(options, :paging_options, @default_paging_options) @@ -346,10 +196,12 @@ defmodule Explorer.Chain do |> select_repo(options).all() end - @spec address_to_logs(Hash.Address.t(), [paging_options | necessity_by_association_option | api?]) :: [Log.t()] + @spec address_to_logs(Hash.Address.t(), [paging_options | necessity_by_association_option | api? | timeout_option]) :: + [Log.t()] def address_to_logs(address_hash, csv_export?, options \\ []) when is_list(options) do paging_options = Keyword.get(options, :paging_options) || %PagingOptions{page_size: 50} necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + timeout = Keyword.get(options, :timeout) case paging_options do %PagingOptions{key: {0, 0}} -> @@ -388,7 +240,7 @@ defmodule Explorer.Chain do |> filter_topic(Keyword.get(options, :topic)) |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) |> join_associations(necessity_by_association) - |> select_repo(options).all() + |> select_repo(options).all(ExplorerHelper.maybe_timeout(timeout)) |> Enum.take(paging_options.page_size) end end @@ -460,58 +312,6 @@ defmodule Explorer.Chain do Repo.aggregate(Block, :count, :hash) end - @doc """ - The `t:Explorer.Chain.Wei.t/0` paid to the miners of the `t:Explorer.Chain.Block.t/0`s with `hash` - `Explorer.Chain.Hash.Full.t/0` by the signers of the transactions in those blocks to cover the gas fee - (`gas_used * gas_price`). - """ - @spec gas_payment_by_block_hash([Hash.Full.t()]) :: %{Hash.Full.t() => Wei.t()} - def gas_payment_by_block_hash(block_hashes) when is_list(block_hashes) do - query = - if DenormalizationHelper.transactions_denormalization_finished?() do - from( - transaction in Transaction, - where: transaction.block_hash in ^block_hashes and transaction.block_consensus == true, - group_by: transaction.block_hash, - select: {transaction.block_hash, %Wei{value: coalesce(sum(transaction.gas_used * transaction.gas_price), 0)}} - ) - else - from( - block in Block, - left_join: transaction in assoc(block, :transactions), - where: block.hash in ^block_hashes and block.consensus == true, - group_by: block.hash, - select: {block.hash, %Wei{value: coalesce(sum(transaction.gas_used * transaction.gas_price), 0)}} - ) - end - - initial_gas_payments = - block_hashes - |> Enum.map(&{&1, %Wei{value: Decimal.new(0)}}) - |> Enum.into(%{}) - - existing_data = - query - |> Repo.all() - |> Enum.into(%{}) - - Map.merge(initial_gas_payments, existing_data) - end - - def timestamp_by_block_hash(block_hashes) when is_list(block_hashes) do - query = - from( - block in Block, - where: block.hash in ^block_hashes and block.consensus == true, - group_by: block.hash, - select: {block.hash, block.timestamp} - ) - - query - |> Repo.all() - |> Enum.into(%{}) - end - @doc """ Finds all `t:Explorer.Chain.Transaction.t/0`s in the `t:Explorer.Chain.Block.t/0`. @@ -530,20 +330,210 @@ defmodule Explorer.Chain do ] def block_to_transactions(block_hash, options \\ [], old_ui? \\ true) when is_list(options) do necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + {optional_address_associations, other_necessity_by_association} = + split_optional_address_associations(necessity_by_association) + type_filter = Keyword.get(options, :type) options |> Keyword.get(:paging_options, @default_paging_options) |> fetch_transactions_in_ascending_order_by_index() - |> join(:inner, [transaction], block in assoc(transaction, :block)) - |> where([_, block], block.hash == ^block_hash) - |> apply_filter_by_type_to_transactions(type_filter) - |> join_associations(necessity_by_association) + |> where([transaction], transaction.block_hash == ^block_hash) + |> Transaction.apply_filter_by_type_to_transactions(type_filter) + |> join_associations(other_necessity_by_association) |> Transaction.put_has_token_transfers_to_transaction(old_ui?) |> (&if(old_ui?, do: preload(&1, [{:token_transfers, [:token, :from_address, :to_address]}]), else: &1)).() |> select_repo(options).all() + |> preload_optional_address_associations(optional_address_associations, options) + end + + defp split_optional_address_associations(necessity_by_association) when is_map(necessity_by_association) do + {optional, other} = + Enum.split_with(necessity_by_association, fn {association, necessity} -> + necessity == :optional and address_association_key?(association) + end) + + {optional, Map.new(other)} + end + + defp address_association_key?(association) when is_atom(association), + do: association in @deduplicated_optional_address_associations + + defp address_association_key?([{association, _nested_preloads}]), + do: association in @deduplicated_optional_address_associations + + defp address_association_key?(_), do: false + + defp preload_optional_address_associations([], _optional_address_associations, _options), do: [] + + defp preload_optional_address_associations(entities, [], _options) when is_list(entities), do: entities + + defp preload_optional_address_associations(entities, optional_address_associations, options) + when is_list(entities) and is_list(optional_address_associations) do + associations_and_nested_preloads = + Enum.map(optional_address_associations, &association_with_nested_preloads/1) + + associations = + associations_and_nested_preloads + |> Enum.map(&elem(&1, 0)) + |> Enum.uniq() + + address_nested_preloads = + associations_and_nested_preloads + |> Enum.flat_map(&normalize_nested_preloads(elem(&1, 1))) + |> Enum.uniq() + + hashes = + entities + |> Enum.flat_map(fn entity -> + Enum.flat_map(associations, fn association -> + association + |> address_hash_field_for_association() + |> then(&Map.get(entity, &1)) + |> hash_to_list() + end) + end) + |> Enum.uniq() + + addresses_by_hash = fetch_addresses_by_hash(hashes, address_nested_preloads, options) + + Enum.map(entities, fn entity -> + Enum.reduce(associations, entity, fn association, acc_entity -> + address = + association + |> address_hash_field_for_association() + |> then(&Map.get(acc_entity, &1)) + |> then(&Map.get(addresses_by_hash, &1)) + + Map.put(acc_entity, association, address) + end) + end) + end + + defp hash_to_list(nil), do: [] + defp hash_to_list(hash), do: [hash] + + defp fetch_addresses_by_hash([], _address_nested_preloads, _options), do: %{} + + defp fetch_addresses_by_hash(hashes, address_nested_preloads, options) do + # Split nested preloads into proxy and non-proxy preloads + # This allows us to load proxy implementations conditionally only for contract addresses + {proxy_preloads, non_proxy_preloads} = split_proxy_preloads(address_nested_preloads) + + addresses = + Address + |> where([address], address.hash in ^hashes) + |> maybe_preload_address_nested(non_proxy_preloads) + |> select_repo(options).all() + |> strip_smart_contract_large_fields() + + # Only fetch proxy implementations for contract addresses (where smart_contract exists) + addresses_with_proxy = + if Enum.any?(proxy_preloads) and Enum.any?(addresses, &proxy_applicable?/1) do + contract_addresses = Enum.filter(addresses, &proxy_applicable?/1) + batch_load_proxy_implementations_for_contracts(contract_addresses, proxy_preloads, options) + else + addresses + end + + Map.new(addresses_with_proxy, &{&1.hash, &1}) + end + + # Check if an address can have proxy implementations. + # Address.smart_contract?/1 relies on contract code presence, so this also covers EIP-7702 addresses. + defp proxy_applicable?(%Address{} = address), do: Address.smart_contract?(address) + defp proxy_applicable?(_), do: false + + # Split proxy-related preloads from other nested preloads + defp split_proxy_preloads(nested_preloads) when is_list(nested_preloads) do + {proxy, non_proxy} = + Enum.split_with(nested_preloads, fn + :proxy_implementations -> true + [{:proxy_implementations, _}] -> true + _ -> false + end) + + {proxy, non_proxy} + end + + # Batch load proxy implementations for contract addresses and attach to originating addresses + defp batch_load_proxy_implementations_for_contracts(addresses, proxy_preloads, options) when is_list(addresses) do + address_hashes = Enum.map(addresses, & &1.hash) + + proxy_implementations = + address_hashes + |> Implementation.get_proxy_implementations_for_multiple_proxies(options) + |> maybe_preload_proxy_implementation_nested(proxy_preloads, options) + + # Group proxy implementations by proxy_address_hash for efficient lookup + proxy_by_address = Map.new(proxy_implementations, &{&1.proxy_address_hash, &1}) + + # Attach proxy data to addresses + Enum.map(addresses, fn address -> + Map.put(address, :proxy_implementations, Map.get(proxy_by_address, address.hash)) + end) + end + + defp maybe_preload_proxy_implementation_nested(proxy_implementations, proxy_preloads, options) + when is_list(proxy_implementations) and is_list(proxy_preloads) do + case build_proxy_preload_spec(proxy_preloads) do + [] -> + proxy_implementations + + proxy_spec -> + select_repo(options).preload(proxy_implementations, proxy_spec) + end + end + + # Build the nested preload specification for implementation structs. + defp build_proxy_preload_spec(proxy_preloads) when is_list(proxy_preloads) do + Enum.find_value(proxy_preloads, fn + :proxy_implementations -> Implementation.proxy_implementations_addresses_association() + [{:proxy_implementations, nested}] -> nested + _ -> nil + end) || [] + end + + defp association_with_nested_preloads({association, _necessity}) when is_atom(association), + do: {association, []} + + defp association_with_nested_preloads({[{association, nested_preloads}], _necessity}), + do: {association, nested_preloads} + + defp normalize_nested_preloads(nil), do: [] + defp normalize_nested_preloads(nested_preload) when is_atom(nested_preload), do: [nested_preload] + defp normalize_nested_preloads(nested_preloads) when is_list(nested_preloads), do: nested_preloads + + defp maybe_preload_address_nested(query, []), do: query + defp maybe_preload_address_nested(query, nested_preloads), do: preload(query, ^nested_preloads) + + # Post-processes loaded addresses to remove large SmartContract fields to reduce memory usage + # and deserialization overhead for transaction listings. + + # Removes: + # - contract_source_code (can be hundreds of KB) + # - abi (can be hundreds of KB) + # - constructor_arguments (potentially large) + defp strip_smart_contract_large_fields(addresses) when is_list(addresses) do + Enum.map(addresses, fn address -> + if Map.has_key?(address, :smart_contract) && address.smart_contract && + !is_struct(address.smart_contract, Ecto.Association.NotLoaded) do + filtered_contract = + address.smart_contract + |> Map.drop([:contract_source_code, :abi, :constructor_arguments]) + + Map.put(address, :smart_contract, filtered_contract) + else + address + end + end) end + defp address_hash_field_for_association(:from_address), do: :from_address_hash + defp address_hash_field_for_association(:to_address), do: :to_address_hash + defp address_hash_field_for_association(:created_contract_address), do: :created_contract_address_hash + @spec execution_node_to_transactions(Hash.Address.t(), [paging_options | necessity_by_association_option | api?()]) :: [Transaction.t()] def execution_node_to_transactions(execution_node_hash, options \\ []) when is_list(options) do @@ -771,11 +761,21 @@ defmodule Explorer.Chain do |> Decimal.to_integer() query = - from( - block in Block, - join: pending_ops in assoc(block, :pending_operations), - where: block.consensus and block.number == ^min_block_number - ) + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> + from( + block in Block, + join: pending_ops in assoc(block, :pending_operations), + where: block.consensus and block.number == ^min_block_number + ) + + "transactions" -> + from( + pto in PendingTransactionOperation, + join: t in assoc(pto, :transaction), + where: t.block_consensus and t.block_number == ^min_block_number + ) + end if select_repo(options).exists?(query) do false @@ -789,14 +789,13 @@ defmodule Explorer.Chain do end defp check_indexing_internal_transactions_threshold do - min_blockchain_trace_block_number = - RangesHelper.get_min_block_number_from_range_string(Application.get_env(:indexer, :trace_block_ranges)) - - %{max: max_saved_block_number} = BlockNumber.get_all() - pending_ops_entity = PendingOperationsHelper.actual_entity() - pbo_count = pending_ops_entity.blocks_count_in_range(min_blockchain_trace_block_number, max_saved_block_number) + pending_ops_count = + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> PendingBlockOperationCount.get() + "transactions" -> PendingTransactionOperationCount.get() + end - if pbo_count < + if pending_ops_count < Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction)[:indexing_finished_threshold] do true else @@ -804,6 +803,10 @@ defmodule Explorer.Chain do end end + @doc """ + Checks if indexing of blocks finished based on the ratio of indexed blocks to blockchain height + """ + @spec finished_indexing_from_ratio?(Decimal.t()) :: boolean() def finished_indexing_from_ratio?(ratio) do Decimal.compare(ratio, 1) !== :lt end @@ -857,26 +860,27 @@ defmodule Explorer.Chain do then the `t:Explorer.Chain.Address.t/0` will not be included in the list. """ - @spec hash_to_address(Hash.Address.t() | binary(), [necessity_by_association_option | api?]) :: + @spec hash_to_address( + Hash.Address.t() | binary(), + [necessity_by_association_option | api?] + ) :: {:ok, Address.t()} | {:error, :not_found} def hash_to_address( hash, options \\ [ - necessity_by_association: %{ - :names => :optional, - :smart_contract => :optional, - :token => :optional, - Address.contract_creation_transaction_associations() => :optional - } + necessity_by_association: default_hash_to_address_necessity_by_association() ] ) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + necessity_by_association = + options + |> Keyword.get(:necessity_by_association, default_hash_to_address_necessity_by_association()) query = Address.address_query(hash) query |> join_associations(necessity_by_association) |> select_repo(options).one() + |> Address.maybe_preload_contract_creation_internal_transaction(select_repo(options)) |> SmartContract.compose_address_for_unverified_smart_contract(hash, options) |> case do nil -> {:error, :not_found} @@ -884,28 +888,13 @@ defmodule Explorer.Chain do end end - @spec token_contract_address_from_token_name(String.t()) :: {:ok, Hash.Address.t()} | {:error, :not_found} - def token_contract_address_from_token_name(name) when is_binary(name) do - query = - from(token in Token, - where: ilike(token.symbol, ^name), - or_where: ilike(token.name, ^name), - select: token.contract_address_hash - ) - - query - |> Repo.all() - |> case do - [] -> - {:error, :not_found} - - hashes -> - if Enum.count(hashes) == 1 do - {:ok, List.first(hashes)} - else - {:error, :not_found} - end - end + defp default_hash_to_address_necessity_by_association do + %{ + :names => :optional, + :smart_contract => :optional, + :token => :optional, + Address.contract_creation_transaction_association() => :optional + } end @doc """ @@ -946,7 +935,7 @@ defmodule Explorer.Chain do :names => :optional, :smart_contract => :optional, :token => :optional, - Address.contract_creation_transaction_associations() => :optional + Address.contract_creation_transaction_association() => :optional } ] ) do @@ -1002,41 +991,6 @@ defmodule Explorer.Chain do ) end - @doc """ - Finds an `t:Explorer.Chain.Address.t/0` that has the provided `t:Explorer.Chain.Address.t/0` `hash` and a contract. - - ## Options - - * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is - `:required`, and the `t:Explorer.Chain.Address.t/0` has no associated record for that association, - then the `t:Explorer.Chain.Address.t/0` will not be included in the list. - - """ - @spec find_contract_address(Hash.Address.t(), [necessity_by_association_option]) :: - {:ok, Address.t()} | {:error, :not_found} - def find_contract_address( - %Hash{byte_count: unquote(Hash.Address.byte_count())} = hash, - options \\ [] - ) do - necessity_by_association = - options - |> Keyword.get(:necessity_by_association, %{}) - |> Map.merge(%{ - [smart_contract: :smart_contract_additional_sources] => :optional, - Implementation.proxy_implementations_association() => :optional - }) - - hash - |> Address.address_with_bytecode_query() - |> join_associations(necessity_by_association) - |> select_repo(options).one() - |> Address.update_address_result(options, false) - |> case do - nil -> {:error, :not_found} - address -> {:ok, address} - end - end - @doc """ Converts `t:Explorer.Chain.Block.t/0` `hash` to the `t:Explorer.Chain.Block.t/0` with that `hash`. @@ -1051,7 +1005,7 @@ defmodule Explorer.Chain do Returns `{:error, :not_found}` if not found - iex> {:ok, hash} = Explorer.Chain.string_to_block_hash( + iex> {:ok, hash} = Explorer.Chain.string_to_full_hash( ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" ...> ) iex> Explorer.Chain.hash_to_block(hash) @@ -1122,7 +1076,7 @@ defmodule Explorer.Chain do Returns `{:error, :not_found}` if not found - iex> {:ok, hash} = Explorer.Chain.string_to_transaction_hash( + iex> {:ok, hash} = Explorer.Chain.string_to_full_hash( ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" ...> ) iex> Explorer.Chain.hash_to_transaction(hash) @@ -1203,7 +1157,7 @@ defmodule Explorer.Chain do Returns `[]` if not found - iex> {:ok, hash} = Explorer.Chain.string_to_transaction_hash( + iex> {:ok, hash} = Explorer.Chain.string_to_full_hash( ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" ...> ) iex> Explorer.Chain.hashes_to_transactions([hash]) @@ -1255,22 +1209,18 @@ defmodule Explorer.Chain do assets_to_import = %{ addresses: imported[:addresses] || [], blocks: imported[:blocks] || [], - transactions: imported[:transactions] || [] + transactions: imported[:transactions] || [], + address_current_token_balances: imported[:address_current_token_balances] || [] } - if assets_to_import == %{ - addresses: [], - blocks: [], - transactions: [] - } do - result - else - # credo:disable-for-next-line Credo.Check.Refactor.Nesting - case MultichainSearch.batch_import(assets_to_import) do - {:ok, _} -> result - _ -> {:error, :insert_to_multichain_search_db_failed} - end - end + filtered_addresses_to_import = + MultichainSearch.filter_addresses_to_multichain_import(assets_to_import[:addresses], options[:broadcast]) + + assets_to_import = Map.put(assets_to_import, :addresses, filtered_addresses_to_import) + + MultichainSearch.send_data_to_queue(assets_to_import) + + result other_result -> other_result @@ -1322,6 +1272,12 @@ defmodule Explorer.Chain do if indexer_running?() and internal_transactions_fetcher_running?() do %{max: max_saved_block_number} = BlockNumber.get_all() + pending_ops_count = + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> PendingBlockOperationCount.get() + "transactions" -> PendingTransactionOperationCount.get() + end + min_blockchain_trace_block_number = Application.get_env(:indexer, :trace_first_block) case max_saved_block_number do @@ -1332,12 +1288,7 @@ defmodule Explorer.Chain do full_blocks_range = max_saved_block_number - min_blockchain_trace_block_number - BlockNumberHelper.null_rounds_count() + 1 - pending_ops_entity = PendingOperationsHelper.actual_entity() - - pbo_count = - pending_ops_entity.blocks_count_in_range(min_blockchain_trace_block_number, max_saved_block_number) - - processed_int_transactions_for_blocks_count = max(0, full_blocks_range - pbo_count) + processed_int_transactions_for_blocks_count = max(0, full_blocks_range - pending_ops_count) ratio = get_ratio(processed_int_transactions_for_blocks_count, full_blocks_range) @@ -1374,91 +1325,6 @@ defmodule Explorer.Chain do |> Decimal.min(Decimal.new(1)) end - @doc """ - Fetches the lowest block number available in the database. - - Queries the database for the minimum block number among blocks marked as consensus - blocks. Returns 0 if no consensus blocks exist or if the query fails. - - ## Returns - - `non_neg_integer`: The lowest block number from consensus blocks, or 0 if none found - """ - @spec fetch_min_block_number() :: non_neg_integer - def fetch_min_block_number do - query = - from(block in Block, - select: block.number, - where: block.consensus == true, - order_by: [asc: block.number], - limit: 1 - ) - - Repo.one(query) || 0 - rescue - _ -> - 0 - end - - @doc """ - Fetches the highest block number available in the database. - - Queries the database for the maximum block number among blocks marked as consensus - blocks. Returns 0 if no consensus blocks exist or if the query fails. - - ## Returns - - `non_neg_integer`: The highest block number from consensus blocks, or 0 if none found - """ - @spec fetch_max_block_number() :: non_neg_integer - def fetch_max_block_number do - query = - from(block in Block, - select: block.number, - where: block.consensus == true, - order_by: [desc: block.number], - limit: 1 - ) - - Repo.one(query) || 0 - rescue - _ -> - 0 - end - - def fetch_block_by_hash(block_hash) do - Repo.get(Block, block_hash) - end - - def filter_non_refetch_needed_block_numbers(block_numbers) do - query = - from( - block in Block, - where: block.number in ^block_numbers, - where: block.consensus == true, - where: block.refetch_needed == false, - select: block.number - ) - - Repo.all(query) - end - - @doc """ - The number of `t:Explorer.Chain.InternalTransaction.t/0`. - - iex> transaction = :transaction |> insert() |> with_block() - iex> insert(:internal_transaction, index: 0, transaction: transaction, block_hash: transaction.block_hash, block_index: 0) - iex> Explorer.Chain.internal_transaction_count() - 1 - - If there are none, the count is `0`. - - iex> Explorer.Chain.internal_transaction_count() - 0 - - """ - def internal_transaction_count do - Repo.aggregate(InternalTransaction.where_nonpending_block(), :count, :transaction_hash) - end - @doc """ Finds all `t:Explorer.Chain.Transaction.t/0` in the `t:Explorer.Chain.Block.t/0`. @@ -1501,11 +1367,11 @@ defmodule Explorer.Chain do elements blocks -> - blocks |> Repo.preload(Map.keys(necessity_by_association)) + blocks |> select_repo(options).preload(Map.keys(necessity_by_association)) end end - def uncles_from_cache(block_type, paging_options, necessity_by_association, options) do + defp uncles_from_cache(block_type, paging_options, necessity_by_association, options) do case Uncles.atomic_take_enough(paging_options.page_size) do nil -> elements = fetch_blocks(block_type, paging_options, necessity_by_association, options) @@ -1536,40 +1402,17 @@ defmodule Explorer.Chain do end @doc """ - Map `block_number`s to their `t:Explorer.Chain.Block.t/0` `hash` `t:Explorer.Chain.Hash.Full.t/0`. + Retrieves the total row count for a given table. - Does not include non-consensus blocks. + This function estimates the row count using system catalogs. If the estimate + is unavailable, it performs an exact count using an aggregate query. - iex> block = insert(:block, consensus: false) - iex> Explorer.Chain.block_hash_by_number([block.number]) - %{} + ## Parameters + - `module`: The module representing the table schema. + - `options`: An optional keyword list of options, such as selecting a specific repository. - """ - @spec block_hash_by_number([Block.block_number()]) :: %{Block.block_number() => Hash.Full.t()} - def block_hash_by_number(block_numbers) when is_list(block_numbers) do - query = - from(block in Block, - where: block.consensus == true and block.number in ^block_numbers, - select: {block.number, block.hash} - ) - - query - |> Repo.all() - |> Enum.into(%{}) - end - - @doc """ - Retrieves the total row count for a given table. - - This function estimates the row count using system catalogs. If the estimate - is unavailable, it performs an exact count using an aggregate query. - - ## Parameters - - `module`: The module representing the table schema. - - `options`: An optional keyword list of options, such as selecting a specific repository. - - ## Returns - - The total row count as a non-negative integer. + ## Returns + - The total row count as a non-negative integer. """ @spec get_table_rows_total_count(atom(), keyword()) :: non_neg_integer() def get_table_rows_total_count(module, options) do @@ -1584,79 +1427,6 @@ defmodule Explorer.Chain do end end - @doc """ - Calls `reducer` on a stream of `t:Explorer.Chain.Block.t/0` without `t:Explorer.Chain.Block.Reward.t/0`. - """ - def stream_blocks_without_rewards(initial, reducer, limited? \\ false) when is_function(reducer, 2) do - Block.blocks_without_reward_query() - |> add_fetcher_limit(limited?) - |> Repo.stream_reduce(initial, reducer) - end - - @doc """ - Finds all transactions of a certain block number - """ - def get_transactions_of_block_number(block_number) do - block_number - |> Transaction.transactions_with_block_number() - |> Repo.all() - end - - @doc """ - Finds all transactions of a certain block numbers - """ - def get_transactions_of_block_numbers(block_numbers) do - block_numbers - |> Transaction.transactions_for_block_numbers() - |> Repo.all() - end - - @doc """ - Finds transactions by hashes - """ - @spec get_transactions_by_hashes([Hash.t()]) :: [Transaction.t()] - def get_transactions_by_hashes(transaction_hashes) do - transaction_hashes - |> Transaction.transactions_by_hashes() - |> Repo.all() - end - - @doc """ - Finds all Blocks validated by the address with the given hash. - - ## Options - * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is - `:required`, and the `t:Explorer.Chain.Block.t/0` has no associated record for that association, then the - `t:Explorer.Chain.Block.t/0` will not be included in the page `entries`. - * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` and - `:key` (a tuple of the lowest/oldest `{block_number}`) and. Results will be the internal - transactions older than the `block_number` that are passed. - - Returns all blocks validated by the address given. - """ - @spec get_blocks_validated_by_address( - [paging_options | necessity_by_association_option], - Hash.Address.t() - ) :: [Block.t()] - def get_blocks_validated_by_address(options \\ [], address_hash) when is_list(options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - Block - |> join_associations(necessity_by_association) - |> where(miner_hash: ^address_hash) - |> page_blocks(paging_options) - |> limit(^paging_options.page_size) - |> order_by(desc: :number) - |> select_repo(options).all() - end - end - @doc """ Return the balance in usd corresponding to this token. Return nil if the fiat_value of the token is not present. """ @@ -1673,200 +1443,6 @@ defmodule Explorer.Chain do Decimal.mult(tokens, fiat_value) end - @doc """ - Returns a stream of unfetched `t:Explorer.Chain.Address.CoinBalance.t/0`. - - When there are addresses, the `reducer` is called for each `t:Explorer.Chain.Address.t/0` `hash` and all - `t:Explorer.Chain.Block.t/0` `block_number` that address is mentioned. - - | Address Hash Schema | Address Hash Field | Block Number Schema | Block Number Field | - |--------------------------------------------|---------------------------------|------------------------------------|--------------------| - | `t:Explorer.Chain.Block.t/0` | `miner_hash` | `t:Explorer.Chain.Block.t/0` | `number` | - | `t:Explorer.Chain.Transaction.t/0` | `from_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | - | `t:Explorer.Chain.Transaction.t/0` | `to_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | - | `t:Explorer.Chain.Log.t/0` | `address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | - | `t:Explorer.Chain.InternalTransaction.t/0` | `created_contract_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | - | `t:Explorer.Chain.InternalTransaction.t/0` | `from_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | - | `t:Explorer.Chain.InternalTransaction.t/0` | `to_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | - - Pending `t:Explorer.Chain.Transaction.t/0` `from_address_hash` and `to_address_hash` aren't returned because they - don't have an associated block number. - - When there are no addresses, the `reducer` is never called and the `initial` is returned in an `:ok` tuple. - - When an `t:Explorer.Chain.Address.t/0` `hash` is used multiple times, all unique `t:Explorer.Chain.Block.t/0` `number` - will be returned. - """ - @spec stream_unfetched_balances( - initial :: accumulator, - reducer :: - (entry :: %{address_hash: Hash.Address.t(), block_number: Block.block_number()}, accumulator -> accumulator), - limited? :: boolean() - ) :: {:ok, accumulator} - when accumulator: term() - def stream_unfetched_balances(initial, reducer, limited? \\ false) when is_function(reducer, 2) do - query = - from( - balance in CoinBalance, - where: is_nil(balance.value_fetched_at), - select: %{address_hash: balance.address_hash, block_number: balance.block_number} - ) - - query - |> add_coin_balances_fetcher_limit(limited?) - |> Repo.stream_reduce(initial, reducer) - end - - @doc """ - Returns a stream of all token balances that weren't fetched values. - """ - @spec stream_unfetched_token_balances( - initial :: accumulator, - reducer :: (entry :: TokenBalance.t(), accumulator -> accumulator), - limited? :: boolean() - ) :: {:ok, accumulator} - when accumulator: term() - def stream_unfetched_token_balances(initial, reducer, limited? \\ false) when is_function(reducer, 2) do - TokenBalance.unfetched_token_balances() - |> add_token_balances_fetcher_limit(limited?) - |> Repo.stream_reduce(initial, reducer) - end - - def remove_nonconsensus_blocks_from_pending_ops(block_hashes) do - query = - case PendingOperationsHelper.pending_operations_type() do - "blocks" -> - from( - pbo in PendingBlockOperation, - where: pbo.block_hash in ^block_hashes - ) - - "transactions" -> - from( - pto in PendingTransactionOperation, - join: t in assoc(pto, :transaction), - where: t.block_hash in ^block_hashes - ) - end - - {_, _} = Repo.delete_all(query) - - :ok - end - - def remove_nonconsensus_blocks_from_pending_ops do - query = - case PendingOperationsHelper.pending_operations_type() do - "blocks" -> - from( - pbo in PendingBlockOperation, - inner_join: block in Block, - on: block.hash == pbo.block_hash, - where: block.consensus == false - ) - - "transactions" -> - from( - pto in PendingTransactionOperation, - join: t in assoc(pto, :transaction), - where: t.block_consensus == false - ) - end - - {_, _} = Repo.delete_all(query) - - :ok - end - - @spec stream_mined_transactions( - fields :: [ - :block_hash - | :created_contract_code_indexed_at - | :from_address_hash - | :gas - | :gas_price - | :hash - | :index - | :input - | :nonce - | :r - | :s - | :to_address_hash - | :v - | :value - ], - initial :: accumulator, - reducer :: (entry :: term(), accumulator -> accumulator) - ) :: {:ok, accumulator} - when accumulator: term() - def stream_mined_transactions(fields, initial, reducer) when is_function(reducer, 2) do - query = - from(t in Transaction, - where: not is_nil(t.block_hash) and not is_nil(t.nonce) and not is_nil(t.from_address_hash), - select: ^fields - ) - - Repo.stream_reduce(query, initial, reducer) - end - - @spec stream_pending_transactions( - fields :: [ - :block_hash - | :created_contract_code_indexed_at - | :from_address_hash - | :gas - | :gas_price - | :hash - | :index - | :input - | :nonce - | :r - | :s - | :to_address_hash - | :v - | :value - ], - initial :: accumulator, - reducer :: (entry :: term(), accumulator -> accumulator), - limited? :: boolean() - ) :: {:ok, accumulator} - when accumulator: term() - def stream_pending_transactions(fields, initial, reducer, limited? \\ false) when is_function(reducer, 2) do - query = - Transaction - |> pending_transactions_query() - |> select(^fields) - |> add_fetcher_limit(limited?) - - Repo.stream_reduce(query, initial, reducer) - end - - @doc """ - Returns a stream of all blocks that are marked as unfetched in `t:Explorer.Chain.Block.SecondDegreeRelation.t/0`. - For each uncle block a `hash` of nephew block and an `index` of the block in it are returned. - - When a block is fetched, its uncles are transformed into `t:Explorer.Chain.Block.SecondDegreeRelation.t/0` and can be - returned. Once the uncle is imported its corresponding `t:Explorer.Chain.Block.SecondDegreeRelation.t/0` - `uncle_fetched_at` will be set and it won't be returned anymore. - """ - @spec stream_unfetched_uncles( - initial :: accumulator, - reducer :: (entry :: term(), accumulator -> accumulator), - limited? :: boolean() - ) :: {:ok, accumulator} - when accumulator: term() - def stream_unfetched_uncles(initial, reducer, limited? \\ false) when is_function(reducer, 2) do - query = - from(bsdr in Block.SecondDegreeRelation, - where: is_nil(bsdr.uncle_fetched_at) and not is_nil(bsdr.index), - select: [:nephew_hash, :index] - ) - - query - |> add_fetcher_limit(limited?) - |> Repo.stream_reduce(initial, reducer) - end - @doc """ Max consensus block numbers. @@ -1891,7 +1467,7 @@ defmodule Explorer.Chain do """ @spec max_consensus_block_number(Keyword.t()) :: {:ok, Block.block_number()} | {:error, :not_found} - def max_consensus_block_number(options \\ []) do + def max_consensus_block_number(options \\ [api?: true]) do Block |> where(consensus: true) |> select_repo(options).aggregate(:max, :number) @@ -1908,28 +1484,47 @@ defmodule Explorer.Chain do select_repo(options).one!(query) end - def indexer_running? do + @spec indexer_running?() :: boolean() + defp indexer_running? do Application.get_env(:indexer, Indexer.Supervisor)[:enabled] or match?({:ok, _, _}, HealthHelper.last_db_block_status()) end - def internal_transactions_fetcher_running? do + @spec internal_transactions_fetcher_running?() :: boolean() + defp internal_transactions_fetcher_running? do not Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor)[:disabled?] or match?({:ok, _, _}, last_db_internal_transaction_block_status()) end - def last_db_internal_transaction_block_status do - query = - from(it in InternalTransaction, - join: block in assoc(it, :block), - select: {block.number, block.timestamp}, - order_by: [desc: block.number], + @spec last_db_internal_transaction_block_status() :: + {:ok, Block.block_number(), DateTime.t()} + | {:stale, Block.block_number(), DateTime.t()} + | {:error, :no_blocks} + defp last_db_internal_transaction_block_status do + it_query = + from(internal_transaction in InternalTransaction, + select: internal_transaction.block_number, + order_by: [desc: internal_transaction.block_number], limit: 1 ) - query - |> Repo.one() - |> HealthHelper.block_status() + last_it_block_number = + it_query + |> Repo.one() + + if is_nil(last_it_block_number) do + {:error, :no_blocks} + else + block_query = + from(block in Block, + select: {block.number, block.timestamp}, + where: block.consensus == true and block.number == ^last_it_block_number + ) + + block_query + |> Repo.one() + |> HealthHelper.block_status() + end end def fetch_min_missing_block_cache(from \\ nil, to \\ nil) do @@ -2149,224 +1744,8 @@ defmodule Explorer.Chain do end end - @spec nonconsensus_block_by_number(Block.block_number(), [api?]) :: {:ok, Block.t()} | {:error, :not_found} - def nonconsensus_block_by_number(number, options) do - Block - |> where(consensus: false, number: ^number) - |> select_repo(options).one() - |> case do - nil -> {:error, :not_found} - block -> {:ok, block} - end - end - - @doc """ - Count of pending `t:Explorer.Chain.Transaction.t/0`. - - A count of all pending transactions. - - iex> insert(:transaction) - iex> :transaction |> insert() |> with_block() - iex> Explorer.Chain.pending_transaction_count() - 1 - - """ - @spec pending_transaction_count() :: non_neg_integer() - def pending_transaction_count do - Transaction - |> pending_transactions_query() - |> Repo.aggregate(:count, :hash) - end - - @doc """ - Returns the paged list of collated transactions that occurred recently from newest to oldest using `block_number` - and `index`. - - iex> newest_first_transactions = 50 |> insert_list(:transaction) |> with_block() |> Enum.reverse() - iex> oldest_seen = Enum.at(newest_first_transactions, 9) - iex> paging_options = %Explorer.PagingOptions{page_size: 10, key: {oldest_seen.block_number, oldest_seen.index}} - iex> recent_collated_transactions = Explorer.Chain.recent_collated_transactions(true, paging_options: paging_options) - iex> length(recent_collated_transactions) - 10 - iex> hd(recent_collated_transactions).hash == Enum.at(newest_first_transactions, 10).hash - true - - ## Options - - * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is - `:required`, and the `t:Explorer.Chain.Transaction.t/0` has no associated record for that association, - then the `t:Explorer.Chain.Transaction.t/0` will not be included in the list. - * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` and - `:key` (a tuple of the lowest/oldest `{block_number, index}`) and. Results will be the transactions older than - the `block_number` and `index` that are passed. - - """ - @spec recent_collated_transactions(true | false, [paging_options | necessity_by_association_option | api?]) :: [ - Transaction.t() - ] - def recent_collated_transactions(old_ui?, options \\ []) - when is_list(options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - method_id_filter = Keyword.get(options, :method) - type_filter = Keyword.get(options, :type) - - fetch_recent_collated_transactions( - old_ui?, - paging_options, - necessity_by_association, - method_id_filter, - type_filter, - options - ) - end - - # RAP - random access pagination - @spec recent_collated_transactions_for_rap([paging_options | necessity_by_association_option]) :: %{ - :total_transactions_count => non_neg_integer(), - :transactions => [Transaction.t()] - } - def recent_collated_transactions_for_rap(options \\ []) when is_list(options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - total_transactions_count = transactions_available_count() - - fetched_transactions = - if is_nil(paging_options.key) or paging_options.page_number == 1 do - paging_options.page_size - |> Kernel.+(1) - |> Transactions.atomic_take_enough() - |> case do - nil -> - transactions = fetch_recent_collated_transactions_for_rap(paging_options, necessity_by_association) - Transactions.update(transactions) - transactions - - transactions -> - transactions - end - else - fetch_recent_collated_transactions_for_rap(paging_options, necessity_by_association) - end - - %{total_transactions_count: total_transactions_count, transactions: fetched_transactions} - end - def default_page_size, do: @default_page_size - def fetch_recent_collated_transactions_for_rap(paging_options, necessity_by_association) do - fetch_transactions_for_rap() - |> where([transaction], not is_nil(transaction.block_number) and not is_nil(transaction.index)) - |> handle_random_access_paging_options(paging_options) - |> join_associations(necessity_by_association) - |> preload([{:token_transfers, [:token, :from_address, :to_address]}]) - |> Repo.all() - end - - defp fetch_transactions_for_rap do - Transaction - |> order_by([transaction], desc: transaction.block_number, desc: transaction.index) - end - - def transactions_available_count do - Transaction - |> where([transaction], not is_nil(transaction.block_number) and not is_nil(transaction.index)) - |> limit(^@limit_showing_transactions) - |> Repo.aggregate(:count, :hash) - end - - def fetch_recent_collated_transactions( - old_ui?, - paging_options, - necessity_by_association, - method_id_filter, - type_filter, - options - ) do - case paging_options do - %PagingOptions{key: {0, 0}, is_index_in_asc_order: false} -> - [] - - _ -> - paging_options - |> Transaction.fetch_transactions() - |> where([transaction], not is_nil(transaction.block_number) and not is_nil(transaction.index)) - |> apply_filter_by_method_id_to_transactions(method_id_filter) - |> apply_filter_by_type_to_transactions(type_filter) - |> join_associations(necessity_by_association) - |> Transaction.put_has_token_transfers_to_transaction(old_ui?) - |> (&if(old_ui?, do: preload(&1, [{:token_transfers, [:token, :from_address, :to_address]}]), else: &1)).() - |> select_repo(options).all() - end - end - - @doc """ - Return the list of pending transactions that occurred recently. - - iex> 2 |> insert_list(:transaction) - iex> :transaction |> insert() |> with_block() - iex> 8 |> insert_list(:transaction) - iex> recent_pending_transactions = Explorer.Chain.recent_pending_transactions() - iex> length(recent_pending_transactions) - 10 - iex> Enum.all?(recent_pending_transactions, fn %Explorer.Chain.Transaction{block_hash: block_hash} -> - ...> is_nil(block_hash) - ...> end) - true - - ## Options - - * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is - `:required`, and the `t:Explorer.Chain.Transaction.t/0` has no associated record for that association, - then the `t:Explorer.Chain.Transaction.t/0` will not be included in the list. - * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` (defaults to - `#{@default_paging_options.page_size}`) and `:key` (a tuple of the lowest/oldest `{inserted_at, hash}`) and. - Results will be the transactions older than the `inserted_at` and `hash` that are passed. - - """ - @spec recent_pending_transactions([paging_options | necessity_by_association_option], true | false) :: [ - Transaction.t() - ] - def recent_pending_transactions(options \\ [], old_ui? \\ true) - when is_list(options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - method_id_filter = Keyword.get(options, :method) - type_filter = Keyword.get(options, :type) - - Transaction - |> Transaction.page_pending_transaction(paging_options) - |> limit(^paging_options.page_size) - |> pending_transactions_query() - |> apply_filter_by_method_id_to_transactions(method_id_filter) - |> apply_filter_by_type_to_transactions(type_filter) - |> order_by([transaction], desc: transaction.inserted_at, asc: transaction.hash) - |> join_associations(necessity_by_association) - |> (&if(old_ui?, do: preload(&1, [{:token_transfers, [:token, :from_address, :to_address]}]), else: &1)).() - |> select_repo(options).all() - end - - @doc """ - Query to return all pending transactions - """ - @spec pending_transactions_query(Ecto.Queryable.t()) :: Ecto.Queryable.t() - def pending_transactions_query(query) do - from(transaction in query, - where: is_nil(transaction.block_hash) and (is_nil(transaction.error) or transaction.error != "dropped/replaced") - ) - end - - @doc """ - Returns pending transactions list from the DB - """ - @spec pending_transactions_list() :: Ecto.Schema.t() | term() - def pending_transactions_list do - Transaction - |> pending_transactions_query() - |> Repo.all(timeout: :infinity) - end - @doc """ The `string` must start with `0x`, then is converted to an integer and then to `t:Explorer.Chain.Hash.Address.t/0`. @@ -2417,8 +1796,8 @@ defmodule Explorer.Chain do @doc """ The `string` must start with `0x`, then is converted to an integer and then to `t:Explorer.Chain.Hash.t/0`. - iex> Explorer.Chain.string_to_block_hash( - ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" + iex> Explorer.Chain.string_to_full_hash( + ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" ...> ) { :ok, @@ -2430,46 +1809,19 @@ defmodule Explorer.Chain do `String.t` format must always have 64 hexadecimal digits after the `0x` base prefix. - iex> Explorer.Chain.string_to_block_hash("0x0") + iex> Explorer.Chain.string_to_full_hash("0x0") :error """ - @spec string_to_block_hash(String.t()) :: {:ok, Hash.t()} | :error - def string_to_block_hash(string) when is_binary(string) do + @spec string_to_full_hash(String.t()) :: {:ok, Hash.t()} | :error + def string_to_full_hash(string) when is_binary(string) do Hash.Full.cast(string) end - def string_to_block_hash(_), do: :error + def string_to_full_hash(_), do: :error @doc """ - The `string` must start with `0x`, then is converted to an integer and then to `t:Explorer.Chain.Hash.t/0`. - - iex> Explorer.Chain.string_to_transaction_hash( - ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" - ...> ) - { - :ok, - %Explorer.Chain.Hash{ - byte_count: 32, - bytes: <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: big-integer-size(32)-unit(8)>> - } - } - - `String.t` format must always have 64 hexadecimal digits after the `0x` base prefix. - - iex> Explorer.Chain.string_to_transaction_hash("0x0") - :error - - """ - @spec string_to_transaction_hash(String.t()) :: {:ok, Hash.t()} | :error - def string_to_transaction_hash(string) when is_binary(string) do - Hash.Full.cast(string) - end - - def string_to_transaction_hash(_), do: :error - - @doc """ - Constructs the base query `Ecto.Query.t()/0` to create requests to the transaction logs + Constructs the base query `Ecto.Query.t()/0` to create requests to the transaction logs ## Returns @@ -2550,7 +1902,7 @@ defmodule Explorer.Chain do |> join(:inner, [tt], token in assoc(tt, :token), as: :token) |> preload([token: token], [{:token, token}]) |> TokenTransfer.filter_by_type(token_type) - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) |> TokenTransfer.page_token_transfer(paging_options) |> limit(^paging_options.page_size) |> order_by([token_transfer], asc: token_transfer.log_index) @@ -2595,87 +1947,12 @@ defmodule Explorer.Chain do %Transaction{revert_reason: revert_reason} = transaction if revert_reason == nil do - fetch_transaction_revert_reason(transaction) + Transaction.fetch_transaction_revert_reason(transaction) else revert_reason end end - def fetch_transaction_revert_reason(transaction) do - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - hash_string = to_string(transaction.hash) - - response = - fetch_first_trace( - [ - %{ - block_hash: transaction.block_hash, - block_number: transaction.block_number, - hash_data: hash_string, - transaction_index: transaction.index - } - ], - json_rpc_named_arguments - ) - - revert_reason = - case response do - {:ok, first_trace_params} -> - first_trace_params |> Enum.at(0) |> Map.get(:output, %Data{bytes: <<>>}) |> to_string() - - {:error, reason} -> - Logger.error(fn -> - ["Error while fetching first trace for transaction: #{hash_string} error reason: ", inspect(reason)] - end) - - fetch_transaction_revert_reason_using_call(transaction) - - :ignore -> - fetch_transaction_revert_reason_using_call(transaction) - end - - if !is_nil(revert_reason) do - transaction - |> Changeset.change(%{revert_reason: revert_reason}) - |> Repo.update() - end - - revert_reason - end - - defp fetch_transaction_revert_reason_using_call(%Transaction{ - block_number: block_number, - to_address_hash: to_address_hash, - from_address_hash: from_address_hash, - input: data, - gas: gas, - gas_price: gas_price, - value: value - }) do - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - req = - EthereumJSONRPCTransaction.eth_call_request( - 0, - block_number, - data, - to_address_hash, - from_address_hash, - Wei.hex_format(gas), - Wei.hex_format(gas_price), - Wei.hex_format(value) - ) - - case EthereumJSONRPC.json_rpc(req, json_rpc_named_arguments) do - {:error, error} -> - parse_revert_reason_from_error(error) - - _ -> - nil - end - end - @doc """ Fetches the raw traces of transaction. """ @@ -2791,20 +2068,9 @@ defmodule Explorer.Chain do %{init: Data.to_string(input), created_contract_code: Data.to_string(created_contract_code)} end else - creation_int_transaction_query = - from( - itx in InternalTransaction, - join: t in assoc(itx, :transaction), - where: itx.created_contract_address_hash == ^address_hash, - where: t.status == ^1, - select: %{init: itx.init, created_contract_code: itx.created_contract_code}, - order_by: [desc: itx.block_number], - limit: ^1 - ) - - res = creation_int_transaction_query |> Repo.one() - - case res do + case address_hash + |> Address.creation_internal_transaction_query() + |> Repo.one() do %{init: init, created_contract_code: created_contract_code} -> init_str = Data.to_string(init) created_contract_code_str = Data.to_string(created_contract_code) @@ -2816,42 +2082,6 @@ defmodule Explorer.Chain do end end - @doc """ - Checks if an address is a contract - """ - @spec contract_address?(String.t(), non_neg_integer(), Keyword.t()) :: boolean() | :json_rpc_error - def contract_address?(address_hash, block_number, json_rpc_named_arguments \\ []) do - {:ok, binary_hash} = Explorer.Chain.Hash.Address.cast(address_hash) - - query = Address.address_query(binary_hash) - - address = Repo.one(query) - - cond do - is_nil(address) -> - block_quantity = integer_to_quantity(block_number) - - case EthereumJSONRPC.fetch_codes( - [%{block_quantity: block_quantity, address: address_hash}], - json_rpc_named_arguments - ) do - {:ok, %EthereumJSONRPC.FetchedCodes{params_list: fetched_codes}} -> - result = List.first(fetched_codes) - - result && !(is_nil(result[:code]) || result[:code] == "" || result[:code] == "0x") - - _ -> - :json_rpc_error - end - - is_nil(address.contract_code) -> - false - - true -> - true - end - end - @doc """ Fetches contract creation input data from the transaction (not internal transaction). """ @@ -2909,47 +2139,6 @@ defmodule Explorer.Chain do |> limit(^paging_options.page_size) end - defp handle_random_access_paging_options(query, empty_options) when empty_options in [nil, [], %{}], - do: limit(query, ^(@default_page_size + 1)) - - defp handle_random_access_paging_options(query, paging_options) do - query - |> (&if(paging_options |> Map.get(:page_number, 1) |> process_page_number() == 1, - do: &1, - else: Transaction.page_transaction(&1, paging_options) - )).() - |> handle_page(paging_options) - end - - defp handle_page(query, paging_options) do - page_number = paging_options |> Map.get(:page_number, 1) |> process_page_number() - page_size = Map.get(paging_options, :page_size, @default_page_size) - - cond do - page_in_bounds?(page_number, page_size) && page_number == 1 -> - query - |> limit(^(page_size + 1)) - - page_in_bounds?(page_number, page_size) -> - query - |> limit(^page_size) - |> offset(^((page_number - 2) * page_size)) - - true -> - query - |> limit(^(@default_page_size + 1)) - end - end - - defp process_page_number(number) when number < 1, do: 1 - - defp process_page_number(number), do: number - - defp page_in_bounds?(page_number, page_size), - do: page_size <= @limit_showing_transactions && @limit_showing_transactions - page_number * page_size >= 0 - - def limit_showing_transactions, do: @limit_showing_transactions - @doc """ Dynamically joins and preloads associations in a query based on necessity. @@ -3012,130 +2201,27 @@ defmodule Explorer.Chain do """ @spec join_associations(atom() | Ecto.Query.t(), %{any() => :optional | :required}) :: Ecto.Query.t() def join_associations(query, necessity_by_association) when is_map(necessity_by_association) do - Enum.reduce(necessity_by_association, query, fn {association, join}, acc_query -> - join_association(acc_query, association, join) - end) - end - - defp page_blocks(query, %PagingOptions{key: nil}), do: query - - defp page_blocks(query, %PagingOptions{key: {block_number}}) do - where(query, [block], block.number < ^block_number) - end - - defp page_coin_balances(query, %PagingOptions{key: nil}), do: query - - defp page_coin_balances(query, %PagingOptions{key: {block_number}}) do - where(query, [coin_balance], coin_balance.block_number < ^block_number) - end - - def page_internal_transaction(_, _, _ \\ %{index_internal_transaction_desc_order: false}) + {optional_associations, required_associations} = + Enum.split_with(necessity_by_association, fn {_association, join} -> join == :optional end) - def page_internal_transaction(query, %PagingOptions{key: nil}, _), do: query + query_with_required_joins = + Enum.reduce(required_associations, query, fn {association, _join}, acc_query -> + join_association(acc_query, association, :required) + end) - def page_internal_transaction(query, %PagingOptions{key: {block_number, transaction_index, index}}, %{ - index_internal_transaction_desc_order: desc_order - }) do - hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, index, desc_order) - end + optional_preloads = Enum.map(optional_associations, fn {association, _join} -> association end) - def page_internal_transaction(query, %PagingOptions{key: {0}}, %{index_internal_transaction_desc_order: desc_order}) do - if desc_order do - query - else - where(query, [internal_transaction], internal_transaction.index > 0) + case optional_preloads do + [] -> query_with_required_joins + _ -> preload(query_with_required_joins, ^optional_preloads) end end - def page_internal_transaction(query, %PagingOptions{key: {index}}, %{ - index_internal_transaction_desc_order: desc_order - }) do - if desc_order do - where(query, [internal_transaction], internal_transaction.index < ^index) - else - where(query, [internal_transaction], internal_transaction.index > ^index) - end - end - - defp hardcoded_where_for_page_internal_transaction(query, 0, 0, index, false), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number == 0 and - internal_transaction.transaction_index == 0 and internal_transaction.index > ^index - ) - - defp hardcoded_where_for_page_internal_transaction(query, block_number, 0, index, false), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number < ^block_number or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index == 0 and internal_transaction.index > ^index) - ) - - defp hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, index, false), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number < ^block_number or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index < ^transaction_index) or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index == ^transaction_index and internal_transaction.index > ^index) - ) - - defp hardcoded_where_for_page_internal_transaction(query, 0, 0, index, true), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number == 0 and - internal_transaction.transaction_index == 0 and internal_transaction.index < ^index - ) - - defp hardcoded_where_for_page_internal_transaction(query, block_number, 0, 0, true), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number < ^block_number - ) - - defp hardcoded_where_for_page_internal_transaction(query, block_number, 0, index, true), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number < ^block_number or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index == 0 and internal_transaction.index < ^index) - ) - - defp hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, 0, true), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number < ^block_number or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index < ^transaction_index) - ) + def page_blocks(query, %PagingOptions{key: nil}), do: query - defp hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, index, true), - do: - where( - query, - [internal_transaction], - internal_transaction.block_number < ^block_number or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index < ^transaction_index) or - (internal_transaction.block_number == ^block_number and - internal_transaction.transaction_index == ^transaction_index and internal_transaction.index < ^index) - ) + def page_blocks(query, %PagingOptions{key: {block_number}}) do + where(query, [block], block.number < ^block_number) + end defp page_logs(query, %PagingOptions{key: nil}), do: query @@ -3189,11 +2275,22 @@ defmodule Explorer.Chain do def page_token_balances(query, %PagingOptions{key: nil}), do: query + # case for ERC-7984 token types + def page_token_balances(query, %PagingOptions{key: {nil, address_hash}}) do + where( + query, + [tb], + # case for ERC-7984 token types + is_nil(tb.value) and tb.address_hash < ^address_hash + ) + end + def page_token_balances(query, %PagingOptions{key: {value, address_hash}}) do where( query, [tb], - tb.value < ^value or (tb.value == ^value and tb.address_hash < ^address_hash) + tb.value < ^value or + (tb.value == ^value and tb.address_hash < ^address_hash) ) end @@ -3348,106 +2445,21 @@ defmodule Explorer.Chain do Repo.exists?(query) end - @spec address_tokens_with_balance(Hash.Address.t(), [any()]) :: [] - def address_tokens_with_balance(address_hash, paging_options \\ []) do - address_hash - |> Address.Token.list_address_tokens_with_balance(paging_options) - |> Repo.all() - end - - @spec find_and_update_replaced_transactions([ - %{ - required(:nonce) => non_neg_integer, - required(:from_address_hash) => Hash.Address.t(), - required(:hash) => Hash.t() - } - ]) :: {integer(), nil | [term()]} - def find_and_update_replaced_transactions(transactions, timeout \\ :infinity) do - query = - transactions - |> Enum.reduce( - Transaction, - fn %{hash: hash, nonce: nonce, from_address_hash: from_address_hash}, query -> - from(t in query, - or_where: - t.nonce == ^nonce and t.from_address_hash == ^from_address_hash and t.hash != ^hash and - not is_nil(t.block_number) - ) - end - ) - # Enforce Transaction ShareLocks order (see docs: sharelocks.md) - |> order_by(asc: :hash) - |> lock("FOR NO KEY UPDATE") - - hashes = Enum.map(transactions, & &1.hash) - - transactions_to_update = - from(pending in Transaction, - join: duplicate in subquery(query), - on: duplicate.nonce == pending.nonce, - on: duplicate.from_address_hash == pending.from_address_hash, - where: pending.hash in ^hashes and is_nil(pending.block_hash) - ) - - Repo.update_all(transactions_to_update, [set: [error: "dropped/replaced", status: :error]], timeout: timeout) - end - - @spec update_replaced_transactions([ - %{ - required(:nonce) => non_neg_integer, - required(:from_address_hash) => Hash.Address.t(), - required(:block_hash) => Hash.Full.t() - } - ]) :: {integer(), nil | [term()]} - def update_replaced_transactions(transactions, timeout \\ :infinity) do - filters = - transactions - |> Enum.filter(fn transaction -> - transaction.block_hash && transaction.nonce && transaction.from_address_hash - end) - |> Enum.map(fn transaction -> - {transaction.nonce, transaction.from_address_hash} - end) - |> Enum.uniq() - - if Enum.empty?(filters) do - {0, []} - else - query = - filters - |> Enum.reduce(Transaction, fn {nonce, from_address}, query -> - from(t in query, - or_where: - t.nonce == ^nonce and - t.from_address_hash == ^from_address and - is_nil(t.block_hash) and - (is_nil(t.error) or t.error != "dropped/replaced") - ) - end) - # Enforce Transaction ShareLocks order (see docs: sharelocks.md) - |> order_by(asc: :hash) - |> lock("FOR NO KEY UPDATE") - - Repo.update_all( - from(t in Transaction, join: s in subquery(query), on: t.hash == s.hash), - [set: [error: "dropped/replaced", status: :error]], - timeout: timeout - ) - end - end - @spec fetch_last_token_balances_include_unfetched([Hash.Address.t()], [api?]) :: [] - def fetch_last_token_balances_include_unfetched(address_hashes, options \\ []) do + def fetch_last_token_balances_include_unfetched(address_hashes, options \\ []) when is_list(address_hashes) do address_hashes |> CurrentTokenBalance.last_token_balances_include_unfetched() |> select_repo(options).all() end - @spec fetch_last_token_balances(Hash.Address.t(), [api?]) :: [] + @spec fetch_last_token_balances(Hash.Address.t(), [api? | necessity_by_association_option]) :: [] def fetch_last_token_balances(address_hash, options \\ []) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + address_hash |> CurrentTokenBalance.last_token_balances() |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) + |> join_associations(necessity_by_association) |> select_repo(options).all() end @@ -3456,6 +2468,7 @@ defmodule Explorer.Chain do filter = Keyword.get(options, :token_type) options = Keyword.delete(options, :token_type) paging_options = Keyword.get(options, :paging_options) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) case paging_options do %PagingOptions{key: {nil, 0, _id}} -> @@ -3466,190 +2479,17 @@ defmodule Explorer.Chain do |> CurrentTokenBalance.last_token_balances(options, filter) |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) |> page_current_token_balances(paging_options) + |> join_associations(necessity_by_association) |> select_repo(options).all() end end - defp fetch_coin_balances(address, paging_options) do - address.hash - |> CoinBalance.fetch_coin_balances(paging_options) - end - - @spec fetch_last_token_balance(Hash.Address.t(), Hash.Address.t()) :: Decimal.t() - def fetch_last_token_balance(address_hash, token_contract_address_hash) do - if address_hash !== %{} do - address_hash - |> CurrentTokenBalance.last_token_balance(token_contract_address_hash) || Decimal.new(0) - else - Decimal.new(0) - end - end - - # @spec fetch_last_token_balance_1155(Hash.Address.t(), Hash.Address.t()) :: Decimal.t() - def fetch_last_token_balance_1155(address_hash, token_contract_address_hash, token_id) do - if address_hash !== %{} do - address_hash - |> CurrentTokenBalance.last_token_balance_1155(token_contract_address_hash, token_id) || Decimal.new(0) - else - Decimal.new(0) - end - end - - @spec address_to_coin_balances(Address.t(), [paging_options | api?]) :: [] - def address_to_coin_balances(address, options) do - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - address_to_coin_balances_internal(address, options, paging_options) - end - end - - defp address_to_coin_balances_internal(address, options, paging_options) do - balances_raw = - address - |> fetch_coin_balances(paging_options) - |> page_coin_balances(paging_options) - |> select_repo(options).all() - |> preload_transactions(options) - - if Enum.empty?(balances_raw) do - balances_raw - else - balances_raw_filtered = - balances_raw - |> Enum.filter(fn balance -> balance.value end) - - min_block_number = - balances_raw_filtered - |> Enum.min_by(fn balance -> balance.block_number end, fn -> %{} end) - |> Map.get(:block_number) - - max_block_number = - balances_raw_filtered - |> Enum.max_by(fn balance -> balance.block_number end, fn -> %{} end) - |> Map.get(:block_number) - - min_block_timestamp = find_block_timestamp(min_block_number, options) - max_block_timestamp = find_block_timestamp(max_block_number, options) - - min_block_unix_timestamp = - min_block_timestamp - |> Timex.to_unix() - - max_block_unix_timestamp = - max_block_timestamp - |> Timex.to_unix() - - blocks_delta = max_block_number - min_block_number - - balances_with_dates = - if blocks_delta > 0 do - add_block_timestamp_to_balances( - balances_raw_filtered, - min_block_number, - min_block_unix_timestamp, - max_block_unix_timestamp, - blocks_delta - ) - else - add_min_block_timestamp_to_balances(balances_raw_filtered, min_block_unix_timestamp) - end - - balances_with_dates - |> Enum.sort(fn balance1, balance2 -> balance1.block_number >= balance2.block_number end) - end - end - - # Here we fetch from DB one transaction per one coin balance. It's much more faster than LEFT OUTER JOIN which was before. - defp preload_transactions(balances, options) do - tasks = - Enum.map(balances, fn balance -> - Task.async(fn -> - Transaction - |> where( - [transaction], - transaction.block_number == ^balance.block_number and - (transaction.value > ^0 or (transaction.gas_price > ^0 and transaction.gas_used > ^0)) and - (transaction.to_address_hash == ^balance.address_hash or - transaction.from_address_hash == ^balance.address_hash) - ) - |> select([transaction], transaction.hash) - |> limit(1) - |> select_repo(options).one() - end) - end) - - tasks - |> Task.yield_many(120_000) - |> Enum.zip(balances) - |> Enum.map(fn {{task, res}, balance} -> - case res do - {:ok, hash} -> - put_transaction_hash(hash, balance) - - {:exit, _reason} -> - balance - - nil -> - Task.shutdown(task, :brutal_kill) - balance - end - end) - end - - defp put_transaction_hash(hash, coin_balance), - do: if(hash, do: %CoinBalance{coin_balance | transaction_hash: hash}, else: coin_balance) - - defp add_block_timestamp_to_balances( - balances_raw_filtered, - min_block_number, - min_block_unix_timestamp, - max_block_unix_timestamp, - blocks_delta - ) do - balances_raw_filtered - |> Enum.map(fn balance -> - date = - trunc( - min_block_unix_timestamp + - (balance.block_number - min_block_number) * (max_block_unix_timestamp - min_block_unix_timestamp) / - blocks_delta - ) - - add_date_to_balance(balance, date) - end) - end - - defp add_min_block_timestamp_to_balances(balances_raw_filtered, min_block_unix_timestamp) do - balances_raw_filtered - |> Enum.map(fn balance -> - date = min_block_unix_timestamp - - add_date_to_balance(balance, date) - end) - end - - defp add_date_to_balance(balance, date) do - formatted_date = Timex.from_unix(date) - %{balance | block_timestamp: formatted_date} - end - def get_token_balance(address_hash, token_contract_address_hash, block_number, token_id \\ nil, options \\ []) do query = TokenBalance.fetch_token_balance(address_hash, token_contract_address_hash, block_number, token_id) select_repo(options).one(query) end - def get_coin_balance(address_hash, block_number, options \\ []) do - query = CoinBalance.fetch_coin_balance(address_hash, block_number) - - select_repo(options).one(query) - end - @spec address_to_balances_by_day(Hash.Address.t(), [api?]) :: [balance_by_day] def address_to_balances_by_day(address_hash, options \\ []) do latest_block_timestamp = @@ -3676,12 +2516,19 @@ defmodule Explorer.Chain do result = balances_by_day |> Enum.filter(fn day -> day.value end) - |> (&if(api?, do: &1, else: Enum.map(&1, fn day -> Map.update!(day, :date, fn x -> to_string(x) end) end))).() - |> (&if(api?, do: &1, else: Enum.map(&1, fn day -> Map.update!(day, :value, fn x -> Wei.to(x, :ether) end) end))).() + |> (&if(api?, + do: &1, + else: + Enum.map(&1, fn day -> + day + |> Map.update!(:date, fn x -> to_string(x) end) + |> Map.update!(:value, fn x -> Wei.to(x, :ether) end) + end) + )).() today = Date.to_string(NaiveDateTime.utc_now()) - if not Enum.empty?(result) && !Enum.any?(result, fn map -> map[:date] == today end) do + if not Enum.empty?(result) && !Enum.any?(result, fn map -> to_string(map[:date]) == today end) do List.flatten([result | [%{date: today, value: List.last(result)[:value]}]]) else result @@ -3698,14 +2545,18 @@ defmodule Explorer.Chain do |> select_repo(options).all() end - @spec fetch_token_holders_from_token_hash_for_csv(Hash.Address.t(), [paging_options | api?]) :: [TokenBalance.t()] + @spec fetch_token_holders_from_token_hash_for_csv(Hash.Address.t(), [paging_options | api? | timeout_option]) :: [ + TokenBalance.t() + ] def fetch_token_holders_from_token_hash_for_csv(contract_address_hash, options \\ []) do + timeout = Keyword.get(options, :timeout) + query = contract_address_hash |> CurrentTokenBalance.token_holders_ordered_by_value_query_without_address_preload(options) query - |> select_repo(options).all() + |> select_repo(options).all(ExplorerHelper.maybe_timeout(timeout)) end def fetch_token_holders_from_token_hash_and_token_id(contract_address_hash, token_id, options \\ []) do @@ -3729,27 +2580,33 @@ defmodule Explorer.Chain do end end - def get_token_ids_1155(contract_address_hash) do - contract_address_hash - |> CurrentTokenBalance.token_ids_query() - |> Repo.all() - end - @spec data() :: Dataloader.Ecto.t() def data, do: DataloaderEcto.new(Repo) + @doc """ + Determines token transfer type by its transaction. + + ## Parameters + - `transaction`: The transaction which token transfer type we need to determine. + + ## Returns + - A token transfer type which can be one of the following cases: + :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2 | :token_transfer + The `:token_transfer` means the token transfer of unknown type. + - `nil` if this transaction is not related to any token transfer. + """ @spec transaction_token_transfer_type(Transaction.t()) :: - :erc20 | :erc721 | :erc1155 | :erc404 | :token_transfer | nil + :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2 | :token_transfer | nil def transaction_token_transfer_type( %Transaction{ status: :ok, created_contract_address_hash: nil, - input: input, + input: _input, value: value } = transaction ) do zero_wei = %Wei{value: Decimal.new(0)} - result = find_token_transfer_type(transaction, input, value) + result = find_token_transfer_type(transaction) if is_nil(result) && not Enum.empty?(transaction.token_transfers) && value == zero_wei, do: :token_transfer, @@ -3760,11 +2617,21 @@ defmodule Explorer.Chain do def transaction_token_transfer_type(_), do: nil - defp find_token_transfer_type(transaction, input, value) do + # Determines token transfer type by its transaction. + # + # ## Parameters + # - `transaction`: The transaction which token transfer type we need to determine. + # + # ## Returns + # - A token transfer type which can be one of the following cases: + # :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2 + # - `nil` if this transaction has unknown transfer type or not related to any token transfer. + @spec find_token_transfer_type(Transaction.t()) :: :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2 | nil + defp find_token_transfer_type(transaction) do zero_wei = %Wei{value: Decimal.new(0)} # https://github.com/OpenZeppelin/openzeppelin-solidity/blob/master/contracts/token/ERC721/ERC721.sol#L35 - case {to_string(input), value} do + case {to_string(transaction.input), transaction.value} do # transferFrom(address,address,uint256) {"0x23b872dd" <> params, ^zero_wei} -> types = [:address, :address, {:uint, 256}] @@ -3800,7 +2667,7 @@ defmodule Explorer.Chain do find_erc1155_token_transfer(transaction.token_transfers, {from_address, to_address}) - # check for ERC-20 or for old ERC-721, ERC-1155, ERC-404 token versions + # check for ERC-20, ZRC-2 or for old ERC-721, ERC-1155, ERC-404 token versions {unquote(TokenTransfer.transfer_function_signature()) <> params, ^zero_wei} -> types = [:address, {:uint, 256}] @@ -3808,7 +2675,7 @@ defmodule Explorer.Chain do decimal_value = Decimal.new(value) - find_known_token_transfer(transaction.token_transfers, {address, decimal_value}) + find_known_token_transfer(transaction.token_transfers, address, decimal_value) _ -> nil @@ -3833,7 +2700,20 @@ defmodule Explorer.Chain do if token_transfer, do: :erc1155 end - defp find_known_token_transfer(token_transfers, {address, decimal_value}) do + # Finds token transfer type by the given list of token transfers in a transaction. + # To filter transaction's token transfers, the `to_address_hash` and `amount` fields are used. + # + # ## Parameters + # - `token_transfers`: The list of transaction's token transfers. + # - `address`: The destination address of the transfer. + # - `decimal_value`: The decimal amount of the transfer. + # + # ## Returns + # - A token transfer type which can be one of the following cases: + # :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2 + # - `nil` if the corresponding transaction has unknown transfer type. + @spec find_known_token_transfer(list(), binary(), Decimal.t()) :: :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2 | nil + defp find_known_token_transfer(token_transfers, address, decimal_value) do token_transfer = Enum.find(token_transfers, fn token_transfer -> token_transfer.to_address_hash.bytes == address && token_transfer.amount == decimal_value @@ -3845,6 +2725,7 @@ defmodule Explorer.Chain do %Token{type: "ERC-721"} -> :erc721 %Token{type: "ERC-1155"} -> :erc1155 %Token{type: "ERC-404"} -> :erc404 + %Token{type: "ZRC-2"} -> :zrc2 _ -> nil end else @@ -3852,56 +2733,12 @@ defmodule Explorer.Chain do end end - @doc """ - Combined block reward from all the fees. - """ - @spec block_combined_rewards(Block.t()) :: Wei.t() - def block_combined_rewards(block) do - {:ok, value} = - block.rewards - |> Enum.reduce( - 0, - fn block_reward, acc -> - {:ok, decimal} = Wei.dump(block_reward.reward) - - Decimal.add(decimal, acc) - end - ) - |> Wei.cast() - - value - end - defp decode_params(params, types) do params |> Base.decode16!(case: :mixed) |> TypeDecoder.decode_raw(types) end - @spec get_token_types([String.t()]) :: [{Hash.Address.t(), String.t()}] - def get_token_types(hashes) do - query = - from( - token in Token, - where: token.contract_address_hash in ^hashes, - select: {token.contract_address_hash, token.type} - ) - - Repo.all(query) - end - - @spec get_token_type(Hash.Address.t()) :: String.t() | nil - def get_token_type(hash) do - query = - from( - token in Token, - where: token.contract_address_hash == ^hash, - select: token.type - ) - - Repo.one(query) - end - @spec erc_20_token?(Token.t()) :: bool def erc_20_token?(token) do erc_20_token_type?(token.type) @@ -3914,281 +2751,10 @@ defmodule Explorer.Chain do end end - @doc """ - Checks if it exists an `t:Explorer.Chain.Address.t/0` that has the provided - `t:Explorer.Chain.Address.t/0` `hash` and a contract. - - Returns `:ok` if found and `:not_found` otherwise. - """ - @spec check_contract_address_exists(Hash.Address.t()) :: :ok | :not_found - def check_contract_address_exists(address_hash) do - address_hash - |> contract_address_exists?() - |> boolean_to_check_result() - end - - @doc """ - Checks if it exists an `t:Explorer.Chain.Address.t/0` that has the provided - `t:Explorer.Chain.Address.t/0` `hash` and a contract. - - Returns `true` if found and `false` otherwise. - """ - @spec contract_address_exists?(Hash.Address.t()) :: boolean() - def contract_address_exists?(address_hash) do - query = - from( - address in Address, - where: address.hash == ^address_hash and not is_nil(address.contract_code) - ) - - Repo.exists?(query) - end - - @doc """ - Checks if a `t:Explorer.Chain.Transaction.t/0` with the given `hash` exists. - - Returns `:ok` if found - - iex> %Transaction{hash: hash} = insert(:transaction) - iex> Explorer.Chain.check_transaction_exists(hash) - :ok - - Returns `:not_found` if not found - - iex> {:ok, hash} = Explorer.Chain.string_to_transaction_hash( - ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" - ...> ) - iex> Explorer.Chain.check_transaction_exists(hash) - :not_found - """ - @spec check_transaction_exists(Hash.Full.t()) :: :ok | :not_found - def check_transaction_exists(hash) do - hash - |> transaction_exists?() - |> boolean_to_check_result() - end - - @doc """ - Checks if a `t:Explorer.Chain.Transaction.t/0` with the given `hash` exists. - - Returns `true` if found - - iex> %Transaction{hash: hash} = insert(:transaction) - iex> Explorer.Chain.transaction_exists?(hash) - true - - Returns `false` if not found - - iex> {:ok, hash} = Explorer.Chain.string_to_transaction_hash( - ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" - ...> ) - iex> Explorer.Chain.transaction_exists?(hash) - false - """ - @spec transaction_exists?(Hash.Full.t()) :: boolean() - def transaction_exists?(hash) do - query = - from( - transaction in Transaction, - where: transaction.hash == ^hash - ) - - Repo.exists?(query) - end - - @doc """ - Checks if a `t:Explorer.Chain.Token.t/0` with the given `hash` exists. - - Returns `:ok` if found - - iex> address = insert(:address) - iex> insert(:token, contract_address: address) - iex> Explorer.Chain.check_token_exists(address.hash) - :ok - - Returns `:not_found` if not found - - iex> {:ok, hash} = Explorer.Chain.string_to_address_hash("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed") - iex> Explorer.Chain.check_token_exists(hash) - :not_found - """ - @spec check_token_exists(Hash.Address.t()) :: :ok | :not_found - def check_token_exists(hash) do - hash - |> token_exists?() - |> boolean_to_check_result() - end - - @doc """ - Checks if a `t:Explorer.Chain.Token.t/0` with the given `hash` exists. - - Returns `true` if found - - iex> address = insert(:address) - iex> insert(:token, contract_address: address) - iex> Explorer.Chain.token_exists?(address.hash) - true - - Returns `false` if not found - - iex> {:ok, hash} = Explorer.Chain.string_to_address_hash("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed") - iex> Explorer.Chain.token_exists?(hash) - false - """ - @spec token_exists?(Hash.Address.t()) :: boolean() - def token_exists?(hash) do - query = - from( - token in Token, - where: token.contract_address_hash == ^hash - ) - - Repo.exists?(query) - end - def boolean_to_check_result(true), do: :ok def boolean_to_check_result(false), do: :not_found - @doc """ - Fetches the first trace from the Nethermind trace URL. - """ - def fetch_first_trace(transactions_params, json_rpc_named_arguments) do - case EthereumJSONRPC.fetch_first_trace(transactions_params, json_rpc_named_arguments) do - {:ok, [%{first_trace: first_trace, block_hash: block_hash, json_rpc_named_arguments: json_rpc_named_arguments}]} -> - format_transaction_first_trace(first_trace, block_hash, json_rpc_named_arguments) - - {:error, error} -> - {:error, error} - - :ignore -> - :ignore - end - end - - defp format_transaction_first_trace(first_trace, block_hash, json_rpc_named_arguments) do - {:ok, to_address_hash} = - if Map.has_key?(first_trace, :to_address_hash) do - Chain.string_to_address_hash(first_trace.to_address_hash) - else - {:ok, nil} - end - - {:ok, from_address_hash} = Chain.string_to_address_hash(first_trace.from_address_hash) - - {:ok, created_contract_address_hash} = - if Map.has_key?(first_trace, :created_contract_address_hash) do - Chain.string_to_address_hash(first_trace.created_contract_address_hash) - else - {:ok, nil} - end - - {:ok, transaction_hash} = Chain.string_to_transaction_hash(first_trace.transaction_hash) - - {:ok, call_type} = - if Map.has_key?(first_trace, :call_type) do - CallType.load(first_trace.call_type) - else - {:ok, nil} - end - - {:ok, type} = Type.load(first_trace.type) - - {:ok, input} = - if Map.has_key?(first_trace, :input) do - Data.cast(first_trace.input) - else - {:ok, nil} - end - - {:ok, output} = - if Map.has_key?(first_trace, :output) do - Data.cast(first_trace.output) - else - {:ok, nil} - end - - {:ok, created_contract_code} = - if Map.has_key?(first_trace, :created_contract_code) do - Data.cast(first_trace.created_contract_code) - else - {:ok, nil} - end - - {:ok, init} = - if Map.has_key?(first_trace, :init) do - Data.cast(first_trace.init) - else - {:ok, nil} - end - - block_index = - get_block_index(%{ - transaction_index: first_trace.transaction_index, - transaction_hash: first_trace.transaction_hash, - block_number: first_trace.block_number, - json_rpc_named_arguments: json_rpc_named_arguments - }) - - value = %Wei{value: Decimal.new(first_trace.value)} - - first_trace_formatted = - first_trace - |> Map.merge(%{ - block_index: block_index, - block_hash: block_hash, - call_type: call_type, - to_address_hash: to_address_hash, - created_contract_address_hash: created_contract_address_hash, - from_address_hash: from_address_hash, - input: input, - output: output, - created_contract_code: created_contract_code, - init: init, - transaction_hash: transaction_hash, - type: type, - value: value - }) - - {:ok, [first_trace_formatted]} - end - - defp get_block_index(%{ - transaction_index: transaction_index, - transaction_hash: transaction_hash, - block_number: block_number, - json_rpc_named_arguments: json_rpc_named_arguments - }) do - if transaction_index == 0 do - 0 - else - filtered_block_numbers = RangesHelper.filter_traceable_block_numbers([block_number]) - {:ok, traces} = fetch_block_internal_transactions(filtered_block_numbers, json_rpc_named_arguments) - - sorted_traces = - traces - |> Enum.sort_by(&{&1.transaction_index, &1.index}) - |> Enum.with_index() - - {_, block_index} = - sorted_traces - |> Enum.find({nil, -1}, fn {trace, _} -> - trace.transaction_index == transaction_index && - trace.transaction_hash == transaction_hash - end) - - block_index - end - end - - defp find_block_timestamp(number, options) do - Block - |> where([block], block.number == ^number) - |> select([block], block.timestamp) - |> limit(1) - |> select_repo(options).one() - end - @spec get_token_transfer_type(TokenTransfer.t()) :: :token_burning | :token_minting | :token_spawning | :token_transfer def get_token_transfer_type(transfer) do @@ -4265,180 +2831,11 @@ defmodule Explorer.Chain do end def recent_transactions(options, [:pending | _]) do - recent_pending_transactions(options, false) + Transaction.recent_pending_transactions(options, false) end def recent_transactions(options, _) do - recent_collated_transactions(false, options) - end - - def apply_filter_by_method_id_to_transactions(query, nil), do: query - - def apply_filter_by_method_id_to_transactions(query, filter) when is_list(filter) do - method_ids = Enum.flat_map(filter, &map_name_or_method_id_to_method_id/1) - - if method_ids != [] do - query - |> where([transaction], fragment("SUBSTRING(? FOR 4)", transaction.input) in ^method_ids) - else - query - end - end - - def apply_filter_by_method_id_to_transactions(query, filter), - do: apply_filter_by_method_id_to_transactions(query, [filter]) - - defp map_name_or_method_id_to_method_id(string) when is_binary(string) do - if id = @method_name_to_id_map[string] do - decode_method_id(id) - else - trimmed = - string - |> String.replace("0x", "", global: false) - - decode_method_id(trimmed) - end - end - - defp decode_method_id(method_id) when is_binary(method_id) do - case String.length(method_id) == 8 && Base.decode16(method_id, case: :mixed) do - {:ok, bytes} -> - [bytes] - - _ -> - [] - end - end - - def apply_filter_by_type_to_transactions(query, [_ | _] = filter) do - {dynamic, modified_query} = apply_filter_by_type_to_transactions_inner(filter, query) - - modified_query - |> where(^dynamic) - end - - def apply_filter_by_type_to_transactions(query, _filter), do: query - - def apply_filter_by_type_to_transactions_inner(dynamic \\ dynamic(false), filter, query) - - def apply_filter_by_type_to_transactions_inner(dynamic, [type | remain], query) do - case type do - :contract_call -> - dynamic - |> filter_contract_call_dynamic() - |> apply_filter_by_type_to_transactions_inner( - remain, - join(query, :inner, [transaction], address in assoc(transaction, :to_address), as: :to_address) - ) - - :contract_creation -> - dynamic - |> filter_contract_creation_dynamic() - |> apply_filter_by_type_to_transactions_inner(remain, query) - - :coin_transfer -> - dynamic - |> filter_transaction_dynamic() - |> apply_filter_by_type_to_transactions_inner(remain, query) - - :token_transfer -> - dynamic - |> filter_token_transfer_dynamic() - |> apply_filter_by_type_to_transactions_inner(remain, query) - - :token_creation -> - dynamic - |> filter_token_creation_dynamic() - |> apply_filter_by_type_to_transactions_inner( - remain, - join(query, :inner, [transaction], token in Token, - on: token.contract_address_hash == transaction.created_contract_address_hash, - as: :created_token - ) - ) - - :blob_transaction -> - dynamic - |> filter_blob_transaction_dynamic() - |> apply_filter_by_type_to_transactions_inner(remain, query) - end - end - - def apply_filter_by_type_to_transactions_inner(dynamic_query, _, query), do: {dynamic_query, query} - - def filter_contract_creation_dynamic(dynamic) do - dynamic([transaction], ^dynamic or is_nil(transaction.to_address_hash)) - end - - def filter_transaction_dynamic(dynamic) do - dynamic([transaction], ^dynamic or transaction.value > ^0) - end - - def filter_contract_call_dynamic(dynamic) do - dynamic([transaction, to_address: to_address], ^dynamic or not is_nil(to_address.contract_code)) - end - - def filter_token_transfer_dynamic(dynamic) do - # TokenTransfer.__struct__.__meta__.source - dynamic( - [transaction], - ^dynamic or - fragment( - "NOT (SELECT transaction_hash FROM token_transfers WHERE transaction_hash = ? LIMIT 1) IS NULL", - transaction.hash - ) - ) - end - - def filter_token_creation_dynamic(dynamic) do - dynamic([transaction, created_token: created_token], ^dynamic or not is_nil(created_token)) - end - - def filter_blob_transaction_dynamic(dynamic) do - # EIP-2718 blob transaction type - dynamic([transaction], ^dynamic or transaction.type == 3) - end - - def count_verified_contracts do - Repo.aggregate(SmartContract, :count, timeout: :infinity) - end - - def count_new_verified_contracts do - query = - from(contract in SmartContract, - select: contract.inserted_at, - where: fragment("NOW() - ? at time zone 'UTC' <= interval '24 hours'", contract.inserted_at) - ) - - query - |> Repo.aggregate(:count, timeout: :infinity) - end - - def count_contracts do - query = - from(address in Address, - select: address, - where: not is_nil(address.contract_code) - ) - - query - |> Repo.aggregate(:count, timeout: :infinity) - end - - def count_new_contracts do - query = - from(transaction in Transaction, - select: transaction, - where: - transaction.status == ^:ok and - fragment( - "NOW() - ? at time zone 'UTC' <= interval '24 hours'", - transaction.created_contract_code_indexed_at - ) - ) - - query - |> Repo.aggregate(:count, timeout: :infinity) + Transaction.recent_collated_transactions(false, options) end def count_verified_contracts_from_cache(options \\ []) do @@ -4457,36 +2854,6 @@ defmodule Explorer.Chain do NewContractsCount.fetch(options) end - def fetch_token_counters(address_hash, timeout) do - total_token_transfers_task = - Task.async(fn -> - TokenTransfersCount.fetch(address_hash) - end) - - total_token_holders_task = - Task.async(fn -> - TokenHoldersCount.fetch(address_hash) - end) - - [total_token_transfers_task, total_token_holders_task] - |> Task.yield_many(timeout) - |> Enum.map(fn {_task, res} -> - case res do - {:ok, result} -> - result - - {:exit, reason} -> - Logger.warning("Query fetching token counters terminated: #{inspect(reason)}") - 0 - - nil -> - Logger.warning("Query fetching token counters timed out.") - 0 - end - end) - |> List.to_tuple() - end - @spec flat_1155_batch_token_transfers([TokenTransfer.t()], Decimal.t() | nil) :: [TokenTransfer.t()] def flat_1155_batch_token_transfers(token_transfers, token_id \\ nil) when is_list(token_transfers) do token_transfers @@ -4504,7 +2871,7 @@ defmodule Explorer.Chain do |> Enum.reverse() end - defp flat_1155_batch_token_transfer(tt, amounts, token_ids, token_id_to_filter) do + defp flat_1155_batch_token_transfer(%TokenTransfer{} = tt, amounts, token_ids, token_id_to_filter) do amounts |> Enum.zip(token_ids) |> Enum.with_index() @@ -4528,7 +2895,7 @@ defmodule Explorer.Chain do transfer end - defp group_batch_reducer(transfer, acc) do + defp group_batch_reducer(transfer, %TokenTransfer{} = acc) do %TokenTransfer{acc | amount: Decimal.add(acc.amount, transfer.amount)} end @@ -4569,54 +2936,6 @@ defmodule Explorer.Chain do end end - @doc """ - Retrieves the ID of a WatchlistAddress entry for a given watchlist and address. - - This function queries the WatchlistAddress table to find an entry that matches - both the provided watchlist ID and address hash. It returns the ID of the first - matching entry, if found. - - ## Parameters - - `watchlist_id`: The ID of the watchlist to search within. - - `address_hash`: The address hash to look for, as a `Hash.Address.t()` struct. - - ## Returns - - An integer representing the ID of the matching WatchlistAddress entry, if found. - - `nil` if no matching entry is found or if either input is `nil`. - """ - @spec select_watchlist_address_id(integer() | nil, Hash.Address.t() | nil) :: integer() | nil - def select_watchlist_address_id(watchlist_id, address_hash) - when not is_nil(watchlist_id) and not is_nil(address_hash) do - wa_ids = - WatchlistAddress - |> where([wa], wa.watchlist_id == ^watchlist_id and wa.address_hash_hash == ^address_hash) - |> select([wa], wa.id) - |> Repo.account_repo().all() - - case wa_ids do - [wa_id | _] -> wa_id - _ -> nil - end - end - - def select_watchlist_address_id(_watchlist_id, _address_hash), do: nil - - def fetch_watchlist_transactions(watchlist_id, options) do - watchlist_addresses = - watchlist_id - |> WatchlistAddress.watchlist_addresses_by_watchlist_id_query() - |> Repo.account_repo().all() - - address_hashes = Enum.map(watchlist_addresses, fn wa -> wa.address_hash end) - - watchlist_names = - Enum.reduce(watchlist_addresses, %{}, fn wa, acc -> - Map.put(acc, wa.address_hash, %{label: wa.name, display_name: wa.name}) - end) - - {watchlist_names, address_hashes_to_mined_transactions_without_rewards(address_hashes, options)} - end - def list_withdrawals(options \\ []) do paging_options = Keyword.get(options, :paging_options, @default_paging_options) @@ -4634,25 +2953,6 @@ defmodule Explorer.Chain do end end - def sum_withdrawals do - Repo.aggregate(Withdrawal, :sum, :amount, timeout: :infinity) - end - - def upsert_count_withdrawals(index) do - LastFetchedCounter.upsert(%{ - counter_type: "withdrawals_count", - value: index - }) - end - - def sum_withdrawals_from_cache(options \\ []) do - WithdrawalsSum.fetch(options) - end - - def count_withdrawals_from_cache(options \\ []) do - "withdrawals_count" |> LastFetchedCounter.get(options) |> Decimal.add(1) - end - def add_fetcher_limit(query, false), do: query def add_fetcher_limit(query, true) do @@ -4661,22 +2961,6 @@ defmodule Explorer.Chain do limit(query, ^fetcher_limit) end - defp add_token_balances_fetcher_limit(query, false), do: query - - defp add_token_balances_fetcher_limit(query, true) do - token_balances_fetcher_limit = Application.get_env(:indexer, :token_balances_fetcher_init_limit) - - limit(query, ^token_balances_fetcher_limit) - end - - defp add_coin_balances_fetcher_limit(query, false), do: query - - defp add_coin_balances_fetcher_limit(query, true) do - coin_balances_fetcher_limit = Application.get_env(:indexer, :coin_balances_fetcher_init_limit) - - limit(query, ^coin_balances_fetcher_limit) - end - @spec default_paging_options() :: map() def default_paging_options do @default_paging_options diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index 2bf29aee14a2..57bb153ec3a6 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.Schema do @moduledoc """ A stored representation of a web3 address. @@ -5,10 +6,13 @@ defmodule Explorer.Chain.Address.Schema do Changes in the schema should be reflected in the bulk import module: - Explorer.Chain.Import.Runner.Addresses """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] alias Explorer.Chain.{ Address, + Address.Reputation, Block, Data, Hash, @@ -59,6 +63,35 @@ defmodule Explorer.Chain.Address.Schema do ] end + :zilliqa -> + alias Explorer.Chain.Zilliqa.Zrc2.TokenAdapter, as: Zrc2TokenAdapter + alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer + + quote do + [ + has_one(:zilliqa_zrc2_token_contract, Zrc2TokenAdapter, + foreign_key: :zrc2_address_hash, + references: :hash + ), + has_one(:zilliqa_zrc2_token_adapter, Zrc2TokenAdapter, + foreign_key: :adapter_address_hash, + references: :hash + ), + has_many(:zilliqa_zrc2_token_transfers_from, Zrc2TokenTransfer, + foreign_key: :from_address_hash, + references: :hash + ), + has_many(:zilliqa_zrc2_token_transfers_to, Zrc2TokenTransfer, + foreign_key: :to_address_hash, + references: :hash + ), + has_many(:zilliqa_zrc2_token_transfers_contract, Zrc2TokenTransfer, + foreign_key: :zrc2_address_hash, + references: :hash + ) + ] + end + :zksync -> quote do [ @@ -70,6 +103,23 @@ defmodule Explorer.Chain.Address.Schema do [] end) + @chain_identity_fields (case @chain_identity do + {:optimism, :celo} -> + quote do + [ + has_one( + :celo_account, + Explorer.Chain.Celo.Account, + foreign_key: :address_hash, + references: :hash + ) + ] + end + + _ -> + [] + end) + defmacro generate do quote do @primary_key false @@ -88,18 +138,12 @@ defmodule Explorer.Chain.Address.Schema do field(:gas_used, :integer) field(:ens_domain_name, :string, virtual: true) field(:metadata, :any, virtual: true) + field(:contract_creation_internal_transaction, :map, virtual: true) has_one(:smart_contract, SmartContract, references: :hash) has_one(:token, Token, foreign_key: :contract_address_hash, references: :hash) has_one(:proxy_implementations, Implementation, foreign_key: :proxy_address_hash, references: :hash) - has_one( - :contract_creation_internal_transaction, - InternalTransaction, - foreign_key: :created_contract_address_hash, - references: :hash - ) - has_one( :contract_creation_transaction, Transaction, @@ -110,6 +154,7 @@ defmodule Explorer.Chain.Address.Schema do has_many(:names, Address.Name, foreign_key: :address_hash, references: :hash) has_one(:scam_badge, Address.ScamBadgeToAddress, foreign_key: :address_hash, references: :hash) has_many(:withdrawals, Withdrawal, foreign_key: :address_hash, references: :hash) + has_one(:reputation, Reputation, foreign_key: :address_hash, references: :hash) # In practice, this is a one-to-many relationship, but we only need to check if any signed authorization # exists for a given address. This done this way to avoid loading all signed authorizations for an address. @@ -118,6 +163,7 @@ defmodule Explorer.Chain.Address.Schema do timestamps() unquote_splicing(@chain_type_fields) + unquote_splicing(@chain_identity_fields) end end end @@ -136,12 +182,12 @@ defmodule Explorer.Chain.Address do alias Ecto.Association.NotLoaded alias Ecto.Changeset - alias Explorer.Chain.Cache.Accounts - alias Explorer.Chain.SmartContract.Proxy.EIP7702 + alias Explorer.{Chain, PagingOptions, Repo, SortingHelper} alias Explorer.Chain.{Address, Data, Hash, InternalTransaction, SmartContract, Transaction} + alias Explorer.Chain.Cache.Accounts alias Explorer.Chain.Fetcher.{CheckBytecodeMatchingOnDemand, LookUpSmartContractSourcesOnDemand} + alias Explorer.Chain.SmartContract.Proxy.EIP7702 alias Explorer.Chain.SmartContract.Proxy.Models.Implementation - alias Explorer.{Chain, PagingOptions, Repo, SortingHelper} import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] @@ -260,7 +306,7 @@ defmodule Explorer.Chain.Address do """ @spec create_multiple(list()) :: {non_neg_integer(), nil | [term()]} def create_multiple(address_insert_params) do - Repo.insert_all(Address, address_insert_params, on_conflict: :nothing, returning: [:hash]) + Repo.insert_all(__MODULE__, address_insert_params, on_conflict: :nothing, returning: [:hash]) end def balance_changeset(%__MODULE__{} = address, attrs) do @@ -307,7 +353,7 @@ defmodule Explorer.Chain.Address do """ @spec address_query(Hash.Address.t() | binary()) :: Ecto.Query.t() def address_query(hash) do - from(address in Address, where: address.hash == ^hash) + from(address in __MODULE__, where: address.hash == ^hash) end def checksum(address_or_hash, iodata? \\ false) @@ -420,26 +466,33 @@ defmodule Explorer.Chain.Address do @doc """ Preloads provided contracts associations if address has contract_code which is not nil """ - @spec maybe_preload_smart_contract_associations(Address.t(), list, list) :: Address.t() - def maybe_preload_smart_contract_associations(%Address{contract_code: nil} = address, _associations, _options), + @spec maybe_preload_smart_contract_associations(__MODULE__.t(), list, list) :: __MODULE__.t() + def maybe_preload_smart_contract_associations(address, associations, options) + + def maybe_preload_smart_contract_associations(%__MODULE__{contract_code: nil} = address, _associations, _options), do: address - def maybe_preload_smart_contract_associations(%Address{contract_code: _} = address, associations, options), - do: Chain.select_repo(options).preload(address, associations) + def maybe_preload_smart_contract_associations(%__MODULE__{contract_code: _} = address, associations, options) do + repo = Chain.select_repo(options) + + address + |> repo.preload(associations) + |> maybe_preload_contract_creation_internal_transaction(repo) + end @doc """ Counts all the addresses where the `fetched_coin_balance` is > 0. """ def count_with_fetched_coin_balance do from( - a in Address, + a in __MODULE__, select: fragment("COUNT(*)"), where: a.fetched_coin_balance > ^0 ) end def fetched_coin_balance(address_hash) when not is_nil(address_hash) do - Address + __MODULE__ |> where([address], address.hash == ^address_hash) |> select([address], address.fetched_coin_balance) end @@ -453,7 +506,7 @@ defmodule Explorer.Chain.Address do For more information: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-55.md#specification To bypass the checksum formatting, use `to_string/1` on the hash itself. - #{unless @chain_type == :rsk do + #{if @chain_type != :rsk do """ iex> address = %Explorer.Chain.Address{ ...> hash: %Explorer.Chain.Hash{ @@ -479,7 +532,7 @@ defmodule Explorer.Chain.Address do Lists the top `t:Explorer.Chain.Address.t/0`'s' in descending order based on coin balance and address hash. """ - @spec list_top_addresses :: [{Address.t(), non_neg_integer()}] + @spec list_top_addresses :: [{__MODULE__.t(), non_neg_integer()}] def list_top_addresses(options \\ []) do paging_options = Keyword.get(options, :paging_options, @default_paging_options) sorting_options = Keyword.get(options, :sorting, []) @@ -514,7 +567,7 @@ defmodule Explorer.Chain.Address do joins necessary associations with `Chain.join_associations/2`, and finally selects the repository with `Chain.select_repo/1` to fetch all the addresses. """ - @spec get_addresses_by_hashes([Hash.Address.t()]) :: [Chain.Address.t()] + @spec get_addresses_by_hashes([Hash.Address.t()]) :: [__MODULE__.t()] def get_addresses_by_hashes(address_hashes) do necessity_by_association = %{:smart_contract => :optional, proxy_implementations_association() => :optional} @@ -536,7 +589,7 @@ defmodule Explorer.Chain.Address do - `address`: The address if found. - `nil`: If the address is not found. """ - @spec get_by_hash(Hash.Address.t()) :: Chain.Address.t() | nil + @spec get_by_hash(Hash.Address.t()) :: __MODULE__.t() | nil def get_by_hash(address_hash) do case Chain.hash_to_address( address_hash, @@ -613,8 +666,8 @@ defmodule Explorer.Chain.Address do - `nil` if the contract code hasn't been loaded """ @spec eoa_with_code?(any()) :: boolean() | nil - def eoa_with_code?(%__MODULE__{contract_code: %Data{bytes: code}}) do - EIP7702.get_delegate_address(code) != nil + def eoa_with_code?(%__MODULE__{} = address) do + !is_nil(EIP7702.quick_resolve_implementations(address)) end def eoa_with_code?(%NotLoaded{}), do: nil @@ -648,7 +701,7 @@ defmodule Explorer.Chain.Address do _ -> base_query = - from(a in Address, + from(a in __MODULE__, where: a.fetched_coin_balance > ^0 ) @@ -705,7 +758,7 @@ defmodule Explorer.Chain.Address do """ @spec address_exists?(Hash.Address.t(), [Chain.api?()]) :: boolean() def address_exists?(address_hash, options \\ []) do - query = Address.address_query(address_hash) + query = address_query(address_hash) Chain.select_repo(options).exists?(query) end @@ -732,21 +785,19 @@ defmodule Explorer.Chain.Address do def creation_transaction(_address), do: nil - @doc """ - Creates a query for preloading contract creation transactions. + # Creates a query for preloading contract creation transactions. - This query sorts transactions by: + # This query sorts transactions by: - 1. status (descending with nulls last) - 2. block number (descending with nulls last) - 3. index (descending with nulls last), + # 1. status (descending with nulls last) + # 2. block number (descending with nulls last) + # 3. index (descending with nulls last), - and limits to one result. + # and limits to one result. - ## Returns + # ## Returns - A `Ecto.Query` that can be used to preload the contract creation transaction. - """ + # A `Ecto.Query` that can be used to preload the contract creation transaction. @spec contract_creation_transaction_preload_query() :: Ecto.Query.t() def contract_creation_transaction_preload_query do from( @@ -760,34 +811,91 @@ defmodule Explorer.Chain.Address do ) end - @doc """ - Generates a query to fetch an address with associated bytecode. + # Generates a query to fetch an address with associated bytecode. - This function constructs an Ecto query that retrieves an address - from the database where the `hash` matches the given `address_hash` - and the `contract_code` is not `nil`. + # This function constructs an Ecto query that retrieves an address + # from the database where the `hash` matches the given `address_hash` + # and the `contract_code` is not `nil`. - ## Parameters + # ## Parameters - - `address_hash`: The hash of the address to query for. - - ## Returns + # - `address_hash`: The hash of the address to query for. - An Ecto query that can be executed to fetch the desired address. + # ## Returns - """ + # An Ecto query that can be executed to fetch the desired address. @spec address_with_bytecode_query(Hash.Address.t()) :: Ecto.Query.t() - def address_with_bytecode_query(address_hash) do + defp address_with_bytecode_query(address_hash) do from( address in __MODULE__, where: address.hash == ^address_hash and not is_nil(address.contract_code) ) end + @doc """ + Preloads the contract creation internal transaction for the given address or + list of addresses. + + For each address, this function finds the most relevant internal transaction + whose `created_contract_address_hash` resolves to the address hash, preloads + its related addresses, and assigns it to the virtual + `:contract_creation_internal_transaction` field. + + When a list of addresses is provided, the function performs a single batch + query for all address hashes, builds a map keyed by + `created_contract_address_hash`, and attaches the matched internal transaction + to each address. + + ## Parameters + + - `addresses`: An `Explorer.Chain.Address.t/0`, a list of addresses, `[]`, or `nil` + - `repo`: The repo module used to execute the query. Defaults to `Explorer.Repo` + + ## Returns + + - A list of addresses with `:contract_creation_internal_transaction` + populated when the input is a list + - A single address with `:contract_creation_internal_transaction` + populated when the input is a single struct + """ + @spec maybe_preload_contract_creation_internal_transaction([__MODULE__.t()] | __MODULE__.t() | nil, module()) :: + [__MODULE__.t()] | __MODULE__.t() | nil + def maybe_preload_contract_creation_internal_transaction(addresses, repo \\ Repo) + + def maybe_preload_contract_creation_internal_transaction([], _repo), do: [] + def maybe_preload_contract_creation_internal_transaction(nil, _repo), do: nil + + def maybe_preload_contract_creation_internal_transaction(addresses, repo) when is_list(addresses) do + if Application.get_env(:explorer, :api_disable_contract_creation_internal_transaction_association, false) do + addresses + else + address_hashes = Enum.map(addresses, & &1.hash) + + internal_transactions_map = + contract_creation_internal_transaction_preload_query() + |> InternalTransaction.where_address_match(:created_contract_address, address_hashes) + |> repo.all() + |> InternalTransaction.preload_addresses([], repo) + |> Map.new(&{&1.created_contract_address_hash, &1}) + + Enum.map(addresses, &%{&1 | contract_creation_internal_transaction: internal_transactions_map[&1.hash]}) + end + end + + def maybe_preload_contract_creation_internal_transaction(address, repo) do + if Application.get_env(:explorer, :api_disable_contract_creation_internal_transaction_association, false) do + address + else + [address] + |> maybe_preload_contract_creation_internal_transaction(repo) + |> List.first() + end + end + @doc """ Creates a query for preloading contract creation internal transactions. - This query sorts internal transactions by: + This query filters for internal transactions with index > 0, sorts them by: 1. error (ascending with nulls first) 2. block number (descending) @@ -797,19 +905,22 @@ defmodule Explorer.Chain.Address do ## Returns - A `Ecto.Query` that can be used to preload the contract creation internal transaction. + A `Ecto.Query` that can be used to preload the contract creation internal + transaction. """ @spec contract_creation_internal_transaction_preload_query() :: Ecto.Query.t() def contract_creation_internal_transaction_preload_query do - from( - it in InternalTransaction, - order_by: [ - asc_nulls_first: it.error, - desc: it.block_number, - desc: it.block_index - ], - limit: 1 + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> where([it], it.index > 0) + |> order_by([it], + asc_nulls_first: it.error_id, + desc: it.block_number, + desc: it.transaction_index, + desc: it.index ) + |> limit(1) + |> select_merge([_it, t], %{transaction: t}) end @doc """ @@ -851,7 +962,7 @@ defmodule Explorer.Chain.Address do @spec contract_creation_transaction_association() :: keyword() def contract_creation_transaction_association do [ - contract_creation_transaction: Address.contract_creation_transaction_preload_query() + contract_creation_transaction: contract_creation_transaction_preload_query() ] end @@ -863,149 +974,99 @@ defmodule Explorer.Chain.Address do def contract_creation_transaction_with_from_address_association do [ contract_creation_transaction: { - Address.contract_creation_transaction_preload_query(), + contract_creation_transaction_preload_query(), :from_address } ] end - @doc """ - Returns contract creation internal transaction association specification. + @spec update_address_result( + map() | nil, + [Chain.necessity_by_association_option() | Chain.api?() | Chain.ip()], + boolean() + ) :: + map() | nil + defp update_address_result(address_result, options, decoding_from_list?) do + LookUpSmartContractSourcesOnDemand.trigger_fetch(options[:ip], address_result) - ## Note - IMPORTANT: This association function should be used ONLY for single address - operations. Using it with multiple addresses may produce unexpected results. + case address_result do + %{smart_contract: nil} -> + if decoding_from_list? do + address_result + else + SmartContract.compose_address_for_unverified_smart_contract(address_result, options) + end - As noted in [Ecto documentation](https://hexdocs.pm/ecto/Ecto.Query.html#preload/3-preload-queries), - operations like `limit` and `offset` in preload queries affect the entire - result set, not each individual association. When working with collections of - addresses, consider using window functions instead of these helpers. + %{smart_contract: smart_contract} -> + CheckBytecodeMatchingOnDemand.trigger_check(options[:ip], address_result, smart_contract) - ## Returns - A keyword list with the contract creation internal transaction association. - """ - @spec contract_creation_internal_transaction_association() :: keyword() - def contract_creation_internal_transaction_association do - [ - contract_creation_internal_transaction: Address.contract_creation_internal_transaction_preload_query() - ] - end + SmartContract.check_and_update_constructor_args(address_result) - @doc """ - Same as `contract_creation_internal_transaction_association/0`, but - preloads a nested association for the `from_address` field. Used for Filecoin - chain type. - """ - @spec contract_creation_internal_transaction_with_from_address_association() :: keyword() - def contract_creation_internal_transaction_with_from_address_association do - [ - contract_creation_internal_transaction: { - Address.contract_creation_internal_transaction_preload_query(), - :from_address - } - ] + _ -> + address_result + end end @doc """ - Returns both contract creation transaction and internal transaction - associations. - - This is a convenience function that combines both types of contract creation - associations. + Constructs a query to retrieve the most recent internal transaction that created + a smart contract at the specified `address_hash`. - ## Returns + The query joins the `InternalTransaction` with its associated `Transaction`, + filters for internal transactions where the `created_contract_address_hash` matches + the given `address_hash`, and ensures that the transaction status is successful (`status == 1`). - A list containing both contract creation transaction and internal transaction - associations. + The results are ordered by `block_number` in descending order, and the query is limited + to return only the most recent matching internal transaction. """ - @spec contract_creation_transaction_associations() :: [keyword()] - def contract_creation_transaction_associations do - [ - contract_creation_transaction_association(), - contract_creation_internal_transaction_association() - ] - end - - @doc """ - Same as `contract_creation_transaction_associations/0`, but preloads a nested - association for the `from_address` field. Used for Filecoin chain type. - """ - @spec contract_creation_transaction_with_from_address_associations() :: [keyword()] - def contract_creation_transaction_with_from_address_associations do - [ - contract_creation_transaction_with_from_address_association(), - contract_creation_internal_transaction_with_from_address_association() - ] + @spec creation_internal_transaction_query(binary() | Hash.t()) :: Ecto.Query.t() + def creation_internal_transaction_query(address_hash) do + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.where_address_match(:created_contract_address, address_hash) + |> where(as(:transaction).status == ^:ok) + |> order_by([it], desc: it.block_number, desc: it.transaction_index, desc: it.index) + |> limit(1) end @doc """ - Finds contract addresses from a list of hashes. - - ## Parameters - - - `hashes`: A list of hashes to search for contract addresses. - - `options`: An optional keyword list of options. + Finds an `t:Explorer.Chain.Address.t/0` that has the provided `t:Explorer.Chain.Address.t/0` `hash` and a contract. ## Options - - `:necessity_by_association`: A map of associations with their necessity (default: `%{}`). - - ## Returns - - - `{:ok, addresses}`: A tuple with `:ok` and a list of found addresses. - - `{:error, :not_found}`: A tuple with `:error` and `:not_found` if no addresses are found. + * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is + `:required`, and the `t:Explorer.Chain.Address.t/0` has no associated record for that association, + then the `t:Explorer.Chain.Address.t/0` will not be included in the list. """ - @spec find_contract_addresses([Hash.Address.t()], [Chain.necessity_by_association_option() | Chain.api?()]) :: - {:ok, [Address.t()]} | {:error, :not_found} - def find_contract_addresses( - hashes, + @spec find_contract_address(Hash.Address.t(), [Chain.necessity_by_association_option()]) :: + {:ok, __MODULE__.t()} | {:error, :not_found} + def find_contract_address( + %Hash{byte_count: unquote(Hash.Address.byte_count())} = hash, options \\ [] ) do necessity_by_association = options |> Keyword.get(:necessity_by_association, %{}) |> Map.merge(%{ + [smart_contract: :smart_contract_additional_sources] => :optional, Implementation.proxy_implementations_association() => :optional }) - hashes - |> addresses_with_bytecode_query() + hash + |> address_with_bytecode_query() |> Chain.join_associations(necessity_by_association) - |> Chain.select_repo(options).all() - |> Enum.map(fn address_result -> - update_address_result(address_result, options, true) + |> Chain.select_repo(options).one() + |> then(fn address -> + if Keyword.get(options, :preload_contract_creation_internal_transaction, false) do + Address.maybe_preload_contract_creation_internal_transaction(address) + else + address + end end) + |> update_address_result(options, false) |> case do - [] -> {:error, :not_found} - addresses -> {:ok, addresses} - end - end - - @spec update_address_result( - map() | nil, - [Chain.necessity_by_association_option() | Chain.api?() | Chain.ip()], - boolean() - ) :: - map() | nil - def update_address_result(address_result, options, decoding_from_list?) do - LookUpSmartContractSourcesOnDemand.trigger_fetch(options[:ip], address_result) - - case address_result do - %{smart_contract: nil} -> - if decoding_from_list? do - address_result - else - SmartContract.compose_address_for_unverified_smart_contract(address_result, options) - end - - %{smart_contract: smart_contract} -> - CheckBytecodeMatchingOnDemand.trigger_check(options[:ip], address_result, smart_contract) - - SmartContract.check_and_update_constructor_args(address_result) - - _ -> - address_result + nil -> {:error, :not_found} + address -> {:ok, address} end end end diff --git a/apps/explorer/lib/explorer/chain/address/coin_balance.ex b/apps/explorer/lib/explorer/chain/address/coin_balance.ex index 3f0f9d7f5277..980991f4a6eb 100644 --- a/apps/explorer/lib/explorer/chain/address/coin_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/coin_balance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.CoinBalance do @moduledoc """ The `t:Explorer.Chain.Wei.t/0` `value` of `t:Explorer.Chain.Address.t/0` at the end of a `t:Explorer.Chain.Block.t/0` @@ -6,8 +7,8 @@ defmodule Explorer.Chain.Address.CoinBalance do use Explorer.Schema - alias Explorer.PagingOptions - alias Explorer.Chain.{Address, Block, Hash, Wei} + alias Explorer.{Chain, PagingOptions, Repo} + alias Explorer.Chain.{Address, Block, Hash, InternalTransaction, Transaction, Wei} alias Explorer.Chain.Address.CoinBalance @optional_fields ~w(value value_fetched_at)a @@ -42,9 +43,349 @@ defmodule Explorer.Chain.Address.CoinBalance do end @doc """ - Builds an `Ecto.Query` to fetch the coin balance of the given address in the given block. + Builds a query to fetch the timestamp and value of the most recent coin balance for a given address. + + This function constructs a query that retrieves the latest coin balance record + for the specified address and joins it with block information to get the + timestamp when that balance was recorded. The query ensures that only + consensus blocks (not uncle blocks) are considered for the balance data. + + ## Parameters + - `address_hash`: The address hash to look up the most recent coin balance for + + ## Returns + - An Ecto query that when executed returns a map with: + - `timestamp`: The timestamp when the block containing the latest balance was mined + - `value`: The coin balance value in Wei at that block + """ + @spec last_coin_balance_timestamp(Hash.Address.t()) :: Ecto.Query.t() + def last_coin_balance_timestamp(address_hash) do + coin_balance_query = + CoinBalance + |> where([cb], cb.address_hash == ^address_hash) + |> last(:block_number) + |> select([cb, b], %{block_number: cb.block_number, value: cb.value}) + + from( + cb in subquery(coin_balance_query), + inner_join: block in Block, + on: cb.block_number == block.number, + where: block.consensus == true, + select: %{timestamp: block.timestamp, value: cb.value} + ) + end + + def changeset(%__MODULE__{} = balance, params) do + balance + |> cast(params, @allowed_fields) + |> validate_required(@required_fields) + |> unique_constraint(:block_number, name: :address_coin_balances_address_hash_block_number_index) + end + + @doc """ + Query to fetch latest coin balance for the given address + """ + @spec latest_coin_balance_query(Hash.Address.t(), non_neg_integer()) :: Ecto.Query.t() + def latest_coin_balance_query(address_hash, stale_balance_window) do + from( + cb in __MODULE__, + where: cb.address_hash == ^address_hash, + where: cb.block_number >= ^stale_balance_window, + where: is_nil(cb.value_fetched_at), + order_by: [desc: :block_number], + limit: 1 + ) + end + + @doc """ + Returns a stream of unfetched `t:Explorer.Chain.Address.CoinBalance.t/0`. + + When there are addresses, the `reducer` is called for each `t:Explorer.Chain.Address.t/0` `hash` and all + `t:Explorer.Chain.Block.t/0` `block_number` that address is mentioned. + + | Address Hash Schema | Address Hash Field | Block Number Schema | Block Number Field | + |--------------------------------------------|---------------------------------|------------------------------------|--------------------| + | `t:Explorer.Chain.Block.t/0` | `miner_hash` | `t:Explorer.Chain.Block.t/0` | `number` | + | `t:Explorer.Chain.Transaction.t/0` | `from_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | + | `t:Explorer.Chain.Transaction.t/0` | `to_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | + | `t:Explorer.Chain.Log.t/0` | `address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | + | `t:Explorer.Chain.InternalTransaction.t/0` | `created_contract_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | + | `t:Explorer.Chain.InternalTransaction.t/0` | `from_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | + | `t:Explorer.Chain.InternalTransaction.t/0` | `to_address_hash` | `t:Explorer.Chain.Transaction.t/0` | `block_number` | + + Pending `t:Explorer.Chain.Transaction.t/0` `from_address_hash` and `to_address_hash` aren't returned because they + don't have an associated block number. + + When there are no addresses, the `reducer` is never called and the `initial` is returned in an `:ok` tuple. + + When an `t:Explorer.Chain.Address.t/0` `hash` is used multiple times, all unique `t:Explorer.Chain.Block.t/0` `number` + will be returned. """ - def fetch_coin_balance(address_hash, block_number) do + @spec stream_unfetched_balances( + initial :: accumulator, + reducer :: + (entry :: %{address_hash: Hash.Address.t(), block_number: Block.block_number()}, accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_unfetched_balances(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + query = + from( + balance in CoinBalance, + where: is_nil(balance.value_fetched_at), + select: %{address_hash: balance.address_hash, block_number: balance.block_number} + ) + + query + |> add_coin_balances_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Retrieves the most recent coin balance for an address at or before a specified block number. + + This function queries the coin balance records for the given address hash, + filtering for balances at or before the specified block number. It returns + the most recent balance record, including calculated delta values and block + timestamps. The query uses a subquery approach to efficiently find the + latest balance while computing the difference from the previous balance. + + ## Parameters + - `address_hash`: Hash.Address.t() - The address hash to query balances for + - `block_number`: Block.block_number() - The maximum block number to consider + - `options`: keyword() - Query options including `:api?` for replica selection + + ## Returns + - `t() | nil` - The coin balance record with delta and timestamp fields, or + `nil` if no balance exists for the address at or before the block number + """ + @spec get_coin_balance(Hash.Address.t(), Block.block_number(), keyword()) :: t() | nil + def get_coin_balance(address_hash, block_number, options \\ []) do + query = fetch_coin_balance(address_hash, block_number) + + Chain.select_repo(options).one(query) + end + + @doc """ + Retrieves paginated coin balance records for a given address with timestamp interpolation. + + This function fetches coin balance history for an address, applying pagination + and performing timestamp calculations for blocks. It includes an optimization + that returns an empty list immediately when the paging key is `{0}`, avoiding + unnecessary database queries. For other cases, it processes balances by + filtering records with values, calculating block timestamp ranges, and + interpolating timestamps for intermediate blocks when multiple blocks are + present. + + ## Parameters + - `address`: Address.t() - The address record to fetch coin balances for + - `options`: [Chain.paging_options() | Chain.api?()] - Query options including + paging configuration and API mode selection + + ## Returns + - `[t()]` - List of coin balance records sorted by block number in descending + order, with interpolated timestamps, or empty list if paging key is `{0}` or + no balances exist + """ + @spec address_to_coin_balances(Address.t(), [Chain.paging_options() | Chain.api?()]) :: [t()] + def address_to_coin_balances(address, options) do + paging_options = Keyword.get(options, :paging_options, PagingOptions.default_paging_options()) + + case paging_options do + %PagingOptions{key: {0}} -> + [] + + _ -> + address_to_coin_balances_internal(address, options, paging_options) + end + end + + defp address_to_coin_balances_internal(address, options, paging_options) do + balances_raw = + address.hash + |> fetch_coin_balances(paging_options) + |> page_coin_balances(paging_options) + |> Chain.select_repo(options).all() + |> preload_transactions(options) + + if Enum.empty?(balances_raw) do + balances_raw + else + balances_raw_filtered = + balances_raw + |> Enum.filter(fn balance -> balance.value end) + + min_block_number = + balances_raw_filtered + |> Enum.min_by(fn balance -> balance.block_number end, fn -> %{} end) + |> Map.get(:block_number) + + max_block_number = + balances_raw_filtered + |> Enum.max_by(fn balance -> balance.block_number end, fn -> %{} end) + |> Map.get(:block_number) + + min_block_timestamp = find_block_timestamp(min_block_number, options) + max_block_timestamp = find_block_timestamp(max_block_number, options) + + min_block_unix_timestamp = + min_block_timestamp + |> Timex.to_unix() + + max_block_unix_timestamp = + max_block_timestamp + |> Timex.to_unix() + + blocks_delta = max_block_number - min_block_number + + balances_with_dates = + if blocks_delta > 0 do + add_block_timestamp_to_balances( + balances_raw_filtered, + min_block_number, + min_block_unix_timestamp, + max_block_unix_timestamp, + blocks_delta + ) + else + add_min_block_timestamp_to_balances(balances_raw_filtered, min_block_unix_timestamp) + end + + balances_with_dates + |> Enum.sort(fn balance1, balance2 -> balance1.block_number >= balance2.block_number end) + end + end + + # Here we fetch from DB one transaction per one coin balance. It's much more faster than LEFT OUTER JOIN which was before. + defp preload_transactions(balances, options) do + tasks = + Enum.map(balances, fn balance -> + Task.async(fn -> preload_transactions_task(balance, options) end) + end) + + tasks + |> Task.yield_many(120_000) + |> Enum.zip(balances) + |> Enum.map(fn {{task, res}, balance} -> + case res do + {:ok, hash} -> + put_transaction_hash(hash, balance) + + {:exit, _reason} -> + balance + + nil -> + Task.shutdown(task, :brutal_kill) + balance + end + end) + end + + defp preload_transactions_task(balance, options) do + transaction_hash = + balance + |> preload_transaction_query() + |> Chain.select_repo(options).one() + + if is_nil(transaction_hash) do + balance + |> preload_internal_transaction_query() + |> Chain.select_repo(options).one() + else + transaction_hash + end + end + + defp preload_transaction_query(balance) do + Transaction + |> where( + [transaction], + transaction.block_number == ^balance.block_number and + (transaction.value > ^0 or (transaction.gas_price > ^0 and transaction.gas_used > ^0)) and + (transaction.to_address_hash == ^balance.address_hash or + transaction.from_address_hash == ^balance.address_hash) + ) + |> select([transaction], transaction.hash) + |> limit(1) + end + + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + defp preload_internal_transaction_query(balance) do + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> where( + [internal_transaction], + internal_transaction.block_number == ^balance.block_number and + internal_transaction.type in ~w(call create create2 selfdestruct)a and + (is_nil(coalesce(type(internal_transaction.call_type_enum, :string), internal_transaction.call_type)) or + coalesce(type(internal_transaction.call_type_enum, :string), internal_transaction.call_type) == ^"call") and + internal_transaction.value > ^0 and is_nil(internal_transaction.error_id) and + (internal_transaction.to_address_hash == ^balance.address_hash or + as(:to_address_mapping).address_hash == ^balance.address_hash or + internal_transaction.from_address_hash == ^balance.address_hash or + as(:from_address_mapping).address_hash == ^balance.address_hash or + internal_transaction.created_contract_address_hash == ^balance.address_hash or + as(:created_contract_address_mapping).address_hash == ^balance.address_hash) + ) + |> select([_internal_transaction, transaction], transaction.hash) + |> limit(1) + end + + defp put_transaction_hash(hash, %CoinBalance{} = coin_balance), + do: if(hash, do: %CoinBalance{coin_balance | transaction_hash: hash}, else: coin_balance) + + defp add_block_timestamp_to_balances( + balances_raw_filtered, + min_block_number, + min_block_unix_timestamp, + max_block_unix_timestamp, + blocks_delta + ) do + balances_raw_filtered + |> Enum.map(fn balance -> + date = + trunc( + min_block_unix_timestamp + + (balance.block_number - min_block_number) * (max_block_unix_timestamp - min_block_unix_timestamp) / + blocks_delta + ) + + add_date_to_balance(balance, date) + end) + end + + defp add_min_block_timestamp_to_balances(balances_raw_filtered, min_block_unix_timestamp) do + balances_raw_filtered + |> Enum.map(fn balance -> + date = min_block_unix_timestamp + + add_date_to_balance(balance, date) + end) + end + + defp add_date_to_balance(balance, date) do + formatted_date = Timex.from_unix(date) + %{balance | block_timestamp: formatted_date} + end + + defp page_coin_balances(query, %PagingOptions{key: nil}), do: query + + defp page_coin_balances(query, %PagingOptions{key: {block_number}}) do + where(query, [coin_balance], coin_balance.block_number < ^block_number) + end + + defp find_block_timestamp(number, options) do + Block + |> where([block], block.number == ^number) + |> select([block], block.timestamp) + |> limit(1) + |> Chain.select_repo(options).one() + end + + defp fetch_coin_balance(address_hash, block_number) do coin_balance_subquery = from( cb in CoinBalance, @@ -65,6 +406,7 @@ defmodule Explorer.Chain.Address.CoinBalance do ) end + @doc false def fetch_coin_balances(address_hash, %PagingOptions{page_size: page_size}) do query = from( @@ -86,10 +428,7 @@ defmodule Explorer.Chain.Address.CoinBalance do ) end - @doc """ - Builds an `Ecto.Query` to fetch a series of balances by day for the given account. Each element in the series - corresponds to the maximum balance in that day. Only the last 90 days of data are used. - """ + @doc false def balances_by_day(address_hash, block_timestamp \\ nil) do days_to_consider = Application.get_env(:block_scout_web, BlockScoutWeb.Chain.Address.CoinBalance)[:coin_balance_history_days] @@ -103,7 +442,7 @@ defmodule Explorer.Chain.Address.CoinBalance do |> select([cb, block], %{date: type(fragment("date_trunc('day', ?)", block.timestamp), :date), value: max(cb.value)}) end - def limit_time_interval(query, days_to_consider, nil) do + defp limit_time_interval(query, days_to_consider, nil) do query |> where( [cb, block], @@ -112,7 +451,7 @@ defmodule Explorer.Chain.Address.CoinBalance do ) end - def limit_time_interval(query, days_to_consider, %{timestamp: timestamp}) do + defp limit_time_interval(query, days_to_consider, %{timestamp: timestamp}) do query |> where( [cb, block], @@ -126,41 +465,11 @@ defmodule Explorer.Chain.Address.CoinBalance do ) end - def last_coin_balance_timestamp(address_hash) do - coin_balance_query = - CoinBalance - |> where([cb], cb.address_hash == ^address_hash) - |> last(:block_number) - |> select([cb, b], %{block_number: cb.block_number, value: cb.value}) + defp add_coin_balances_fetcher_limit(query, false), do: query - from( - cb in subquery(coin_balance_query), - inner_join: block in Block, - on: cb.block_number == block.number, - where: block.consensus == true, - select: %{timestamp: block.timestamp, value: cb.value} - ) - end + defp add_coin_balances_fetcher_limit(query, true) do + coin_balances_fetcher_limit = Application.get_env(:indexer, :coin_balances_fetcher_init_limit) - def changeset(%__MODULE__{} = balance, params) do - balance - |> cast(params, @allowed_fields) - |> validate_required(@required_fields) - |> unique_constraint(:block_number, name: :address_coin_balances_address_hash_block_number_index) - end - - @doc """ - Query to fetch latest coin balance for the given address - """ - @spec latest_coin_balance_query(Hash.Address.t(), non_neg_integer() | {:error, :empty_database}) :: Ecto.Query.t() - def latest_coin_balance_query(address_hash, stale_balance_window) do - from( - cb in __MODULE__, - where: cb.address_hash == ^address_hash, - where: cb.block_number >= ^stale_balance_window, - where: is_nil(cb.value_fetched_at), - order_by: [desc: :block_number], - limit: 1 - ) + limit(query, ^coin_balances_fetcher_limit) end end diff --git a/apps/explorer/lib/explorer/chain/address/coin_balance_daily.ex b/apps/explorer/lib/explorer/chain/address/coin_balance_daily.ex index 37edd6c2a19f..7b4191b47da5 100644 --- a/apps/explorer/lib/explorer/chain/address/coin_balance_daily.ex +++ b/apps/explorer/lib/explorer/chain/address/coin_balance_daily.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.CoinBalanceDaily do @moduledoc """ Maximum `t:Explorer.Chain.Wei.t/0` `value` of `t:Explorer.Chain.Address.t/0` at the day. diff --git a/apps/explorer/lib/explorer/chain/address/counters.ex b/apps/explorer/lib/explorer/chain/address/counters.ex index 7a534d5a51be..57e7565bf009 100644 --- a/apps/explorer/lib/explorer/chain/address/counters.ex +++ b/apps/explorer/lib/explorer/chain/address/counters.ex @@ -1,13 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.Counters do @moduledoc """ Functions related to Explorer.Chain.Address counters """ - import Ecto.Query, only: [from: 2, limit: 2, select: 3, union: 2, where: 3] + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + import Ecto.Query, only: [from: 2, limit: 2, select: 3, union_all: 2, where: 3] import Explorer.Chain, only: [select_repo: 1, wrapped_union_subquery: 1] - alias Explorer.{Chain, Repo} + alias Explorer.{Chain, PagingOptions, Repo} alias Explorer.Chain.Cache.Counters.{ AddressTabsElementsCount, @@ -28,6 +32,7 @@ defmodule Explorer.Chain.Address.Counters do Withdrawal } + alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward require Logger @@ -35,7 +40,16 @@ defmodule Explorer.Chain.Address.Counters do @typep counter :: non_neg_integer() | nil @counters_limit 51 - @types [:validations, :transactions, :token_transfers, :token_balances, :logs, :withdrawals, :internal_transactions] + @types [ + :validations, + :transactions, + :token_transfers, + :token_balances, + :logs, + :withdrawals, + :internal_transactions, + :beacon_deposits + ] @transactions_types [:transactions_from, :transactions_to, :transactions_contract] defp address_hash_to_logs_query(address_hash) do @@ -167,7 +181,7 @@ defmodule Explorer.Chain.Address.Counters do from( tb in CurrentTokenBalance, where: tb.address_hash == ^address_hash, - where: tb.value > 0 + where: tb.value > 0 or tb.token_type == "ERC-7984" ) end @@ -214,34 +228,30 @@ defmodule Explorer.Chain.Address.Counters do defp address_hash_to_internal_transactions_limited_count_query(address_hash) do query_to_address_hash_wrapped = InternalTransaction - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_address_fields_match(address_hash, :to_address_hash) + |> InternalTransaction.where_nonpending_operation() + |> InternalTransaction.where_address_fields_match(address_hash, :to) |> InternalTransaction.where_is_different_from_parent_transaction() |> limit(@counters_limit) |> wrapped_union_subquery() query_from_address_hash_wrapped = InternalTransaction - |> InternalTransaction.where_nonpending_block() + |> InternalTransaction.where_nonpending_operation() |> InternalTransaction.where_address_fields_match(address_hash, :from_address_hash) |> InternalTransaction.where_is_different_from_parent_transaction() |> limit(@counters_limit) |> wrapped_union_subquery() - query_created_contract_address_hash_wrapped = - InternalTransaction - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_address_fields_match(address_hash, :created_contract_address_hash) - |> InternalTransaction.where_is_different_from_parent_transaction() - |> limit(@counters_limit) - |> wrapped_union_subquery() - query_to_address_hash_wrapped - |> union(^query_from_address_hash_wrapped) - |> union(^query_created_contract_address_hash_wrapped) + |> union_all(^query_from_address_hash_wrapped) |> wrapped_union_subquery() - |> InternalTransaction.where_is_different_from_parent_transaction() - |> limit(@counters_limit) + end + + defp address_hash_to_beacon_deposits_unordered_query(address_hash) do + from( + deposit in BeaconDeposit, + where: deposit.from_address_hash == ^address_hash + ) end def address_counters(address, options \\ []) do @@ -251,7 +261,7 @@ defmodule Explorer.Chain.Address.Counters do end) Task.start_link(fn -> - transaction_count(address) + transactions_count(address) end) Task.start_link(fn -> @@ -281,7 +291,7 @@ defmodule Explorer.Chain.Address.Counters do |> List.to_tuple() end - def transaction_count(address) do + def transactions_count(address) do AddressTransactionsCount.fetch(address) end @@ -438,7 +448,7 @@ defmodule Explorer.Chain.Address.Counters do ) celo_election_rewards_count_task = - if Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() == {:optimism, :celo} do configure_task( :celo_election_rewards, cached_counters, @@ -450,6 +460,17 @@ defmodule Explorer.Chain.Address.Counters do nil end + beacon_deposits_count_task = + if Application.get_env(:explorer, :chain_type) == :ethereum do + configure_task( + :beacon_deposits, + cached_counters, + address_hash_to_beacon_deposits_unordered_query(address_hash), + address_hash, + options + ) + end + map = [ validations_count_task, @@ -461,7 +482,8 @@ defmodule Explorer.Chain.Address.Counters do logs_count_task, withdrawals_count_task, internal_transactions_count_task, - celo_election_rewards_count_task + celo_election_rewards_count_task, + beacon_deposits_count_task ] |> Enum.reject(&is_nil/1) |> Task.yield_many(:timer.seconds(1)) @@ -521,8 +543,7 @@ defmodule Explorer.Chain.Address.Counters do run_or_ignore(cache[counter_type], counter_type, address_hash, fn -> result = query - |> limit(@counters_limit) - |> select_repo(options).aggregate(:count) + |> count(options, counter_type) stop = System.monotonic_time() diff = System.convert_time_unit(stop - start, :native, :millisecond) @@ -536,6 +557,19 @@ defmodule Explorer.Chain.Address.Counters do end) end + defp count(query, options, :internal_transactions) do + query + |> select_repo(options).all() + |> InternalTransaction.deduplicate_and_trim_internal_transactions(%PagingOptions{page_size: @counters_limit}) + |> Enum.count() + end + + defp count(query, options, _counter_type) do + query + |> limit(@counters_limit) + |> select_repo(options).aggregate(:count) + end + defp process_transactions_counter( %{transactions_types: [_ | _] = transactions_types, transactions_hashes: hashes} = map ) do diff --git a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex index d2f996eedabf..928854f0b2ac 100644 --- a/apps/explorer/lib/explorer/chain/address/current_token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/current_token_balance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.CurrentTokenBalance do @moduledoc """ Represents the current token balance from addresses according to the last block. @@ -14,8 +15,10 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] alias Explorer.{Chain, PagingOptions, Repo} - alias Explorer.Chain.{Address, Block, CurrencyHelper, Hash, Token} + alias Explorer.Chain.{Address, Block, Hash, Token} alias Explorer.Chain.Address.TokenBalance + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Utility.MissingBalanceOfToken @default_paging_options %PagingOptions{page_size: 50} @@ -28,6 +31,8 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do * `value` - The value that's represents the balance. * `token_id` - The token_id of the transferred token (applicable for ERC-1155) * `token_type` - The type of the token + * `refetch_after` - when to refetch the balance + * `retries_count` - number of times the balance has been retried """ typed_schema "address_current_token_balances" do field(:value, :decimal) @@ -40,6 +45,8 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do field(:distinct_token_instances_count, :integer, virtual: true) field(:token_ids, {:array, :decimal}, virtual: true) field(:preloaded_token_instances, {:array, :any}, virtual: true) + field(:refetch_after, :utc_datetime_usec) + field(:retries_count, :integer) # A transient field for deriving token holder count deltas during address_current_token_balances upserts field(:old_value, :decimal) @@ -58,7 +65,7 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do timestamps() end - @optional_fields ~w(value value_fetched_at token_id)a + @optional_fields ~w(value value_fetched_at token_id refetch_after retries_count)a @required_fields ~w(address_hash block_number token_contract_address_hash token_type)a @allowed_fields @optional_fields ++ @required_fields @@ -103,7 +110,7 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do offset = (max(paging_options.page_number, 1) - 1) * paging_options.page_size token_contract_address_hash - |> token_holders_query + |> token_holders_query() |> order_by([tb], desc: :value, desc: :address_hash) |> Chain.page_token_balances(paging_options) |> limit(^paging_options.page_size) @@ -162,7 +169,7 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do ctb in __MODULE__, where: ctb.token_contract_address_hash == ^token_contract_address_hash, where: ctb.address_hash != ^@burn_address_hash, - where: ctb.value > 0 + where: ctb.value > 0 or ctb.token_type == "ERC-7984" ) end @@ -173,12 +180,13 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do @doc """ Builds an `t:Ecto.Query.t/0` to fetch the current token balances of the given addresses (include unfetched). """ - def last_token_balances_include_unfetched(address_hashes) do + def last_token_balances_include_unfetched(address_hashes) when is_list(address_hashes) do fiat_balance = fiat_value_query() from( ctb in __MODULE__, where: ctb.address_hash in ^address_hashes, + where: ctb.token_type != "ERC-7984", left_join: t in assoc(ctb, :token), on: ctb.token_contract_address_hash == t.contract_address_hash, preload: [token: t], @@ -192,17 +200,17 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do """ def last_token_balances(address_hash, type \\ []) - def last_token_balances(address_hash, [type | _]) do + def last_token_balances(address_hash, types) when is_list(types) and types != [] do fiat_balance = fiat_value_query() from( ctb in __MODULE__, where: ctb.address_hash == ^address_hash, - where: ctb.value > 0, + where: ctb.value > 0 or ctb.token_type == "ERC-7984", left_join: t in assoc(ctb, :token), on: ctb.token_contract_address_hash == t.contract_address_hash, preload: [token: t], - where: t.type == ^type, + where: t.type in ^types, select: ctb, select_merge: ^%{fiat_value: fiat_balance}, order_by: ^[desc_nulls_last: fiat_balance], @@ -216,7 +224,7 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do from( ctb in __MODULE__, where: ctb.address_hash == ^address_hash, - where: ctb.value > 0, + where: ctb.value > 0 or ctb.token_type == "ERC-7984", left_join: t in assoc(ctb, :token), on: ctb.token_contract_address_hash == t.contract_address_hash, preload: [token: t], @@ -303,14 +311,14 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do @doc """ Builds an `t:Ecto.Query.t/0` to fetch addresses that hold the token. - Token holders cannot be the burn address (#{@burn_address_hash}) and must have a non-zero value. + Token holders cannot be the burn address (#{@burn_address_hash}) and must have a non-zero value or be an ERC-7984 token. """ def token_holders_query(token_contract_address_hash) do from( tb in __MODULE__, where: tb.token_contract_address_hash == ^token_contract_address_hash, where: tb.address_hash != ^@burn_address_hash, - where: tb.value > 0 + where: tb.value > 0 or tb.token_type == "ERC-7984" ) end @@ -334,24 +342,51 @@ defmodule Explorer.Chain.Address.CurrentTokenBalance do end @doc """ - Converts CurrentTokenBalances to CSV format. Used in `BlockScoutWeb.API.V2.CsvExportController.export_token_holders/2` + Returns a stream of all current token balances that weren't fetched values. + """ + @spec stream_unfetched_current_token_balances( + initial :: accumulator, + reducer :: (entry :: __MODULE__.t(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_unfetched_current_token_balances(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + unfetched_current_token_balances() + |> MissingBalanceOfToken.filter_token_balances_query() + |> TokenBalance.add_token_balances_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Builds an `Ecto.Query` to fetch the unfetched current token balances. + + Unfetched current token balances are the ones that have the column `value_fetched_at` nil or the value is null. This query also + ignores the burn_address for tokens ERC-721 since the most tokens ERC-721 don't allow get the + balance for burn_address. """ - @spec to_csv_format([t()], Token.t()) :: (any(), any() -> {:halted, any()} | {:suspended, any(), (any() -> any())}) - def to_csv_format(holders, token) do - row_names = [ - "HolderAddress", - "Balance" - ] - - holders_list = - holders - |> Stream.map(fn ctb -> - [ - Address.checksum(ctb.address_hash), - ctb.value |> CurrencyHelper.divide_decimals(token.decimals) |> Decimal.to_string(:xsd) - ] - end) - - Stream.concat([row_names], holders_list) + # credo:disable-for-next-line /Complexity/ + def unfetched_current_token_balances do + if BackgroundMigrations.get_ctb_token_type_finished() do + from( + ctb in __MODULE__, + where: + ((ctb.address_hash != ^@burn_address_hash and ctb.token_type == "ERC-721") or ctb.token_type == "ERC-20" or + ctb.token_type == "ZRC-2" or + ctb.token_type == "ERC-1155" or ctb.token_type == "ERC-404") and + (is_nil(ctb.value_fetched_at) or is_nil(ctb.value)) and + (is_nil(ctb.refetch_after) or ctb.refetch_after < ^Timex.now()) + ) + else + from( + ctb in __MODULE__, + join: t in Token, + on: ctb.token_contract_address_hash == t.contract_address_hash, + where: + ((ctb.address_hash != ^@burn_address_hash and t.type == "ERC-721") or t.type == "ERC-20" or t.type == "ZRC-2" or + t.type == "ERC-1155" or t.type == "ERC-404") and + (is_nil(ctb.value_fetched_at) or is_nil(ctb.value)) and + (is_nil(ctb.refetch_after) or ctb.refetch_after < ^Timex.now()) + ) + end end end diff --git a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex index c7307b9d5346..ea8291cbba69 100644 --- a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex +++ b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.MetadataPreloader do @moduledoc """ Module responsible for preloading metadata (from BENS, Metadata microservices) to addresses. @@ -8,6 +9,7 @@ defmodule Explorer.Chain.Address.MetadataPreloader do alias Explorer.Chain.{ Address, Address.CurrentTokenBalance, + Beacon.Deposit, Block, InternalTransaction, Log, @@ -17,6 +19,8 @@ defmodule Explorer.Chain.Address.MetadataPreloader do Withdrawal } + alias Explorer.Stats.HotSmartContracts + @type supported_types :: Address.t() | Block.t() @@ -59,6 +63,15 @@ defmodule Explorer.Chain.Address.MetadataPreloader do address_with_ens end + @doc """ + Preloads ENS name to Block.t() + """ + @spec preload_ens_to_block(Block.t()) :: Block.t() + def preload_ens_to_block(block) do + [block_with_ens] = preload_ens_to_list([block]) + block_with_ens + end + @doc """ Preloads ENS names to list of supported entities """ @@ -110,6 +123,15 @@ defmodule Explorer.Chain.Address.MetadataPreloader do transaction_with_metadata end + @doc """ + Preloads metadata to Block.t() + """ + @spec preload_metadata_to_block(Block.t()) :: Block.t() + def preload_metadata_to_block(block) do + [block_with_metadata] = preload_metadata_to_list([block]) + block_with_metadata + end + @doc """ Preload ENS info to search result, using get_address/1 """ @@ -188,6 +210,21 @@ defmodule Explorer.Chain.Address.MetadataPreloader do [to_string(owner_address_hash)] end + defp item_to_address_hash_strings(%Deposit{ + from_address_hash: from_address_hash, + withdrawal_address_hash: withdrawal_address_hash + }) do + if withdrawal_address_hash do + [to_string(withdrawal_address_hash), to_string(from_address_hash)] + else + [to_string(from_address_hash)] + end + end + + defp item_to_address_hash_strings(%HotSmartContracts{contract_address_hash: contract_address_hash}) do + [to_string(contract_address_hash)] + end + defp put_ens_names(names, items) do Enum.map(items, &put_meta_to_item(&1, names, :ens_domain_name)) end @@ -292,6 +329,35 @@ defmodule Explorer.Chain.Address.MetadataPreloader do %Instance{instance | owner: alter_address(owner_address, owner_address_hash, names, field_to_put_info)} end + defp put_meta_to_item( + %Deposit{ + from_address: from_address, + from_address_hash: from_address_hash, + withdrawal_address: withdrawal_address, + withdrawal_address_hash: withdrawal_address_hash + } = deposit, + names, + field_to_put_info + ) do + %Deposit{ + deposit + | from_address: alter_address(from_address, from_address_hash, names, field_to_put_info), + withdrawal_address: alter_address(withdrawal_address, withdrawal_address_hash, names, field_to_put_info) + } + end + + defp put_meta_to_item( + %HotSmartContracts{contract_address_hash: contract_address_hash, contract_address: contract_address} = + hot_contract, + names, + field_to_put_info + ) do + %HotSmartContracts{ + hot_contract + | contract_address: alter_address(contract_address, contract_address_hash, names, field_to_put_info) + } + end + defp alter_address(address, nil, _names, _field), do: address defp alter_address(%NotLoaded{}, address_hash, names, field) do diff --git a/apps/explorer/lib/explorer/chain/address/name.ex b/apps/explorer/lib/explorer/chain/address/name.ex index d05e04ae509c..335e0f3c8b41 100644 --- a/apps/explorer/lib/explorer/chain/address/name.ex +++ b/apps/explorer/lib/explorer/chain/address/name.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.Name do @moduledoc """ Represents a name for an Address. @@ -20,11 +21,17 @@ defmodule Explorer.Chain.Address.Name do """ @primary_key false typed_schema "address_names" do - field(:id, :integer, autogenerate: false, primary_key: true, null: false) - field(:name, :string, null: false) + field(:name, :string, null: false, primary_key: true) field(:primary, :boolean) field(:metadata, :map) - belongs_to(:address, Address, foreign_key: :address_hash, references: :hash, type: Hash.Address, null: false) + + belongs_to(:address, Address, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address, + null: false, + primary_key: true + ) timestamps() end diff --git a/apps/explorer/lib/explorer/chain/address/reputation.ex b/apps/explorer/lib/explorer/chain/address/reputation.ex new file mode 100644 index 000000000000..1e74e5b14951 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/address/reputation.ex @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Address.Reputation do + @moduledoc """ + This module defines the reputation enum values. + """ + use Explorer.Schema + + alias Explorer.Chain.Address.ScamBadgeToAddress + alias Explorer.Chain.Hash + alias Explorer.Repo + + @enum_values [:ok, :scam] + def enum_values, do: @enum_values + + @primary_key false + typed_embedded_schema do + field(:address_hash, Hash.Address) + field(:reputation, Ecto.Enum, values: @enum_values) + end + + def preload_reputation(address_hashes) do + scam_badges = + if Application.get_env(:block_scout_web, :hide_scam_addresses) do + ScamBadgeToAddress + |> where([sb], sb.address_hash in ^address_hashes) + |> Repo.replica().all() + |> Map.new(&{&1.address_hash, &1}) + else + %{} + end + + Enum.map(address_hashes, fn address_hash -> + case Map.get(scam_badges, address_hash) do + nil -> {address_hash, %__MODULE__{reputation: "ok"}} + _badge -> {address_hash, %__MODULE__{reputation: "scam"}} + end + end) + end + + def reputation_association do + [reputation: &__MODULE__.preload_reputation/1] + end +end + +defimpl Jason.Encoder, for: Explorer.Chain.Address.Reputation do + def encode(reputation, opts) do + Jason.Encode.string(reputation.reputation, opts) + end +end diff --git a/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex b/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex index e911cfbc9286..41d86b36e0f4 100644 --- a/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex +++ b/apps/explorer/lib/explorer/chain/address/scam_badge_to_address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.ScamBadgeToAddress do @moduledoc """ Defines Address.ScamBadgeToAddress.t() mapping with Address.t() diff --git a/apps/explorer/lib/explorer/chain/address/token.ex b/apps/explorer/lib/explorer/chain/address/token.ex index 69930d3a7e4c..ab3d36fba52e 100644 --- a/apps/explorer/lib/explorer/chain/address/token.ex +++ b/apps/explorer/lib/explorer/chain/address/token.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.Token do @moduledoc """ A projection that represents the relation between a Token and a specific Address. @@ -43,7 +44,7 @@ defmodule Explorer.Chain.Address.Token do defp filter_and_group(query) do from( [token, balance] in query, - where: balance.value > 0, + where: balance.value > 0 or token.type == "ERC-7984", select: %Address.Token{ contract_address_hash: token.contract_address_hash, inserted_at: max(token.inserted_at), diff --git a/apps/explorer/lib/explorer/chain/address/token_balance.ex b/apps/explorer/lib/explorer/chain/address/token_balance.ex index 85a7314eb874..7b64fba20e58 100644 --- a/apps/explorer/lib/explorer/chain/address/token_balance.ex +++ b/apps/explorer/lib/explorer/chain/address/token_balance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.TokenBalance do @moduledoc """ Represents a token balance from an address. @@ -10,11 +11,12 @@ defmodule Explorer.Chain.Address.TokenBalance do use Explorer.Schema import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] + import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] alias Explorer.{Chain, Repo} - alias Explorer.Chain.Address.TokenBalance - alias Explorer.Chain.Cache.BackgroundMigrations alias Explorer.Chain.{Address, Block, Hash, Token} + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Utility.MissingBalanceOfToken @typedoc """ * `address` - The `t:Explorer.Chain.Address.t/0` that is the balance's owner. @@ -56,7 +58,7 @@ defmodule Explorer.Chain.Address.TokenBalance do @allowed_fields @optional_fields ++ @required_fields @doc false - def changeset(%TokenBalance{} = token_balance, attrs) do + def changeset(%__MODULE__{} = token_balance, attrs) do token_balance |> cast(attrs, @allowed_fields) |> validate_required(@required_fields) @@ -77,20 +79,21 @@ defmodule Explorer.Chain.Address.TokenBalance do def unfetched_token_balances do if BackgroundMigrations.get_tb_token_type_finished() do from( - tb in TokenBalance, + tb in __MODULE__, where: ((tb.address_hash != ^@burn_address_hash and tb.token_type == "ERC-721") or tb.token_type == "ERC-20" or + tb.token_type == "ZRC-2" or tb.token_type == "ERC-1155" or tb.token_type == "ERC-404") and (is_nil(tb.value_fetched_at) or is_nil(tb.value)) and (is_nil(tb.refetch_after) or tb.refetch_after < ^Timex.now()) ) else from( - tb in TokenBalance, + tb in __MODULE__, join: t in Token, on: tb.token_contract_address_hash == t.contract_address_hash, where: - ((tb.address_hash != ^@burn_address_hash and t.type == "ERC-721") or t.type == "ERC-20" or + ((tb.address_hash != ^@burn_address_hash and t.type == "ERC-721") or t.type == "ERC-20" or t.type == "ZRC-2" or t.type == "ERC-1155" or t.type == "ERC-404") and (is_nil(tb.value_fetched_at) or is_nil(tb.value)) and (is_nil(tb.refetch_after) or tb.refetch_after < ^Timex.now()) @@ -105,7 +108,7 @@ defmodule Explorer.Chain.Address.TokenBalance do def fetch_token_balance(address_hash, token_contract_address_hash, block_number, nil) do from( - tb in TokenBalance, + tb in __MODULE__, where: tb.address_hash == ^address_hash, where: tb.token_contract_address_hash == ^token_contract_address_hash, where: tb.block_number <= ^block_number, @@ -116,7 +119,7 @@ defmodule Explorer.Chain.Address.TokenBalance do def fetch_token_balance(address_hash, token_contract_address_hash, block_number, token_id) do from( - tb in TokenBalance, + tb in __MODULE__, where: tb.address_hash == ^address_hash, where: tb.token_contract_address_hash == ^token_contract_address_hash, where: tb.token_id == ^token_id, @@ -142,10 +145,57 @@ defmodule Explorer.Chain.Address.TokenBalance do @spec delete_token_balance_placeholders_below(atom(), Hash.Address.t(), Block.block_number()) :: {non_neg_integer(), nil | [term()]} def delete_token_balance_placeholders_below(module, token_contract_address_hash, block_number) do - module - |> where([tb], tb.token_contract_address_hash == ^token_contract_address_hash) - |> where([tb], tb.block_number <= ^block_number) - |> where([tb], is_nil(tb.value_fetched_at) or is_nil(tb.value)) - |> Repo.delete_all() + {:ok, result} = + Repo.transaction(fn -> + ordered_query = + from(tb in module, + where: tb.token_contract_address_hash == ^token_contract_address_hash, + where: tb.block_number <= ^block_number, + where: is_nil(tb.value_fetched_at) or is_nil(tb.value), + select: select_ctid(tb), + # Enforce TokenBalance ShareLocks order (see docs: sharelocks.md) + order_by: [ + tb.token_contract_address_hash, + tb.token_id, + tb.address_hash, + tb.block_number + ], + lock: "FOR UPDATE" + ) + + query = + from(tb in module, + inner_join: ordered_address_token_balance in subquery(ordered_query), + on: join_on_ctid(tb, ordered_address_token_balance) + ) + + Repo.delete_all(query) + end) + + result + end + + @doc """ + Returns a stream of all token balances that weren't fetched values. + """ + @spec stream_unfetched_token_balances( + initial :: accumulator, + reducer :: (entry :: __MODULE__.t(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_unfetched_token_balances(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + __MODULE__.unfetched_token_balances() + |> MissingBalanceOfToken.filter_token_balances_query() + |> add_token_balances_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + def add_token_balances_fetcher_limit(query, false), do: query + + def add_token_balances_fetcher_limit(query, true) do + token_balances_fetcher_limit = Application.get_env(:indexer, :token_balances_fetcher_init_limit) + + limit(query, ^token_balances_fetcher_limit) end end diff --git a/apps/explorer/lib/explorer/chain/advanced_filter.ex b/apps/explorer/lib/explorer/chain/advanced_filter.ex index a5cec7026381..bb65a51ce462 100644 --- a/apps/explorer/lib/explorer/chain/advanced_filter.ex +++ b/apps/explorer/lib/explorer/chain/advanced_filter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.AdvancedFilter do @moduledoc """ Models an advanced filter. @@ -8,10 +9,22 @@ defmodule Explorer.Chain.AdvancedFilter do import Ecto.Query import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] - alias Explorer.Helper, as: ExplorerHelper alias Explorer.{Chain, Helper, PagingOptions} - alias Explorer.Chain.{Address, Data, DenormalizationHelper, Hash, InternalTransaction, TokenTransfer, Transaction} + alias Explorer.Helper, as: ExplorerHelper + + alias Explorer.Chain.{ + Address, + Address.Reputation, + Data, + DenormalizationHelper, + Hash, + InternalTransaction, + TokenTransfer, + Transaction + } + alias Explorer.Chain.Block.Reader.General, as: BlockGeneralReader + alias Explorer.Utility.AddressIdToAddressHash @primary_key false typed_embedded_schema null: false do @@ -20,6 +33,15 @@ defmodule Explorer.Chain.AdvancedFilter do field(:input, Data) field(:timestamp, :utc_datetime_usec) + field(:status) :: + :pending + | :awaiting_internal_transactions + | :success + | {:error, :awaiting_internal_transactions} + | {:error, reason :: String.t()} + + field(:created_from, Ecto.Enum, values: [:transaction, :internal_transaction, :token_transfer]) + belongs_to( :from_address, Address, @@ -80,7 +102,7 @@ defmodule Explorer.Chain.AdvancedFilter do | {:timeout, timeout()} ] - @spec list(options()) :: [__MODULE__.t()] + @spec list(options()) :: [t()] def list(options \\ []) do paging_options = Keyword.get(options, :paging_options) @@ -109,8 +131,10 @@ defmodule Explorer.Chain.AdvancedFilter do tasks = options |> Keyword.put(:block_numbers_age, block_numbers_age) - |> queries(paging_options) - |> Enum.map(fn query -> Task.async(fn -> Chain.select_repo(options).all(query, timeout: timeout) end) end) + |> query_functions(paging_options) + |> Enum.map(fn query_function -> + Task.async(fn -> query_function.(Chain.select_repo(options), timeout: timeout) end) + end) tasks |> Task.yield_many(timeout: timeout, on_timeout: :kill_task) @@ -134,48 +158,90 @@ defmodule Explorer.Chain.AdvancedFilter do to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], created_contract_address: [:names, :smart_contract, proxy_implementations_association()] ) + |> sanitize_fee() + |> assign_type() + |> Enum.to_list() end - defp queries(options, paging_options) do + defp query_functions(options, paging_options) do [] |> maybe_add_transactions_queries(options, paging_options) |> maybe_add_token_transfers_queries(options, paging_options) end - defp maybe_add_transactions_queries(queries, options, paging_options) do + @transaction_types ["COIN_TRANSFER", "CONTRACT_INTERACTION", "CONTRACT_CREATION"] + defp maybe_add_transactions_queries(query_functions, options, paging_options) do transaction_types = options[:transaction_types] || [] tokens_to_include = options[:token_contract_address_hashes][:include] || [] tokens_to_exclude = options[:token_contract_address_hashes][:exclude] || [] - if (transaction_types == [] or "COIN_TRANSFER" in transaction_types) and + if (transaction_types == [] or Enum.any?(@transaction_types, &Enum.member?(transaction_types, &1))) and (tokens_to_include == [] or "native" in tokens_to_include) and "native" not in tokens_to_exclude do - [transactions_query(paging_options, options), internal_transactions_query(paging_options, options) | queries] + [ + transactions_query_function(paging_options, options), + internal_transactions_query_function(paging_options, options) | query_functions + ] else - queries + query_functions end end - defp maybe_add_token_transfers_queries(queries, options, paging_options) do + defp maybe_add_token_transfers_queries(query_functions, options, paging_options) do transaction_types = options[:transaction_types] || [] tokens_to_include = options[:token_contract_address_hashes][:include] || [] - if (transaction_types == [] or not (transaction_types |> Enum.reject(&(&1 == "COIN_TRANSFER")) |> Enum.empty?())) and + if (transaction_types == [] or not (transaction_types |> Enum.reject(&(&1 in @transaction_types)) |> Enum.empty?())) and (tokens_to_include == [] or not (tokens_to_include |> Enum.reject(&(&1 == "native")) |> Enum.empty?())) do - [token_transfers_query(paging_options, options) | queries] + [token_transfers_query_function(paging_options, options) | query_functions] else - queries + query_functions end end + defp sanitize_fee(advanced_filters) do + Stream.scan(advanced_filters, fn + %__MODULE__{hash: hash, created_from: created_from} = advanced_filter, %__MODULE__{hash: hash} + when created_from != :internal_transaction -> + %__MODULE__{advanced_filter | fee: Decimal.new(0)} + + advanced_filter, _ -> + advanced_filter + end) + end + + defp assign_type(advanced_filters) do + Stream.map(advanced_filters, fn advanced_filter -> + type = + cond do + advanced_filter.created_from in ~w(transaction internal_transaction)a and + is_nil(advanced_filter.to_address_hash) -> + "contract_creation" + + advanced_filter.created_from in ~w(transaction internal_transaction)a and + not is_nil(advanced_filter.to_address.contract_code) -> + "contract_interaction" + + advanced_filter.created_from in ~w(transaction internal_transaction)a -> + "coin_transfer" + + true -> + advanced_filter.token_transfer.token_type + end + + %{advanced_filter | type: type} + end) + end + defp to_advanced_filter(%Transaction{} = transaction) do %{value: decimal_transaction_value} = transaction.value %__MODULE__{ hash: transaction.hash, - type: "coin_transfer", + created_from: :transaction, input: transaction.input, timestamp: transaction.block_timestamp, + status: Chain.transaction_to_status(transaction), from_address_hash: transaction.from_address_hash, to_address_hash: transaction.to_address_hash, created_contract_address_hash: transaction.created_contract_address_hash, @@ -187,17 +253,16 @@ defmodule Explorer.Chain.AdvancedFilter do end defp to_advanced_filter(%InternalTransaction{} = internal_transaction) do - %{value: decimal_internal_transaction_value} = internal_transaction.value - %__MODULE__{ hash: internal_transaction.transaction.hash, - type: "coin_transfer", + created_from: :internal_transaction, input: internal_transaction.input, timestamp: internal_transaction.transaction.block_timestamp, + status: Chain.transaction_to_status(internal_transaction.transaction), from_address_hash: internal_transaction.from_address_hash, to_address_hash: internal_transaction.to_address_hash, created_contract_address_hash: internal_transaction.created_contract_address_hash, - value: decimal_internal_transaction_value, + value: (internal_transaction.value && internal_transaction.value.value) || Decimal.new(0), fee: internal_transaction.transaction.gas_price && internal_transaction.gas_used && Decimal.mult(internal_transaction.transaction.gas_price.value, internal_transaction.gas_used), @@ -210,9 +275,10 @@ defmodule Explorer.Chain.AdvancedFilter do defp to_advanced_filter(%TokenTransfer{} = token_transfer) do %__MODULE__{ hash: token_transfer.transaction.hash, - type: token_transfer.token_type, + created_from: :token_transfer, input: token_transfer.transaction.input, timestamp: token_transfer.transaction.block_timestamp, + status: Chain.transaction_to_status(token_transfer.transaction), from_address_hash: token_transfer.from_address_hash, to_address_hash: token_transfer.to_address_hash, created_contract_address_hash: nil, @@ -270,7 +336,7 @@ defmodule Explorer.Chain.AdvancedFilter do defp take_page_size(list, _), do: list - defp transactions_query(paging_options, options) do + defp transactions_query_function(paging_options, options) do query = if DenormalizationHelper.transactions_denormalization_finished?() do from(transaction in Transaction, @@ -294,14 +360,17 @@ defmodule Explorer.Chain.AdvancedFilter do ) end - query - |> page_transactions(paging_options) - |> limit_query(paging_options) - |> apply_transactions_filters( - options, - fn query -> query |> order_by([transaction], desc: transaction.block_number, desc: transaction.index) end - ) - |> limit_query(paging_options) + filtered_and_paginated_query = + query + |> page_transactions(paging_options) + |> limit_query(paging_options) + |> apply_transactions_filters( + options, + fn query -> query |> order_by([transaction], desc: transaction.block_number, desc: transaction.index) end + ) + |> limit_query(paging_options) + + fn repo, repo_options -> repo.all(filtered_and_paginated_query, repo_options) end end defp page_transactions(query, %PagingOptions{ @@ -321,55 +390,59 @@ defmodule Explorer.Chain.AdvancedFilter do defp page_transactions(query, _), do: query - defp internal_transactions_query(paging_options, options) do + defp internal_transactions_query_function(paging_options, options) do query = if DenormalizationHelper.transactions_denormalization_finished?() do - from(internal_transaction in InternalTransaction, - as: :internal_transaction, - join: transaction in assoc(internal_transaction, :transaction), - as: :transaction, - where: transaction.block_consensus == true, - where: - (internal_transaction.type == :call and internal_transaction.index > 0) or - internal_transaction.type != :call, - order_by: [ - desc: transaction.block_number, - desc: transaction.index, - desc: internal_transaction.index - ] + InternalTransaction + |> from(as: :internal_transaction) + |> InternalTransaction.join_transaction_query() + |> where(as(:transaction).block_consensus == true) + |> where( + (as(:internal_transaction).type == :call and as(:internal_transaction).index > 0) or + as(:internal_transaction).type != :call + ) + |> order_by( + desc: as(:transaction).block_number, + desc: as(:transaction).index, + desc: as(:internal_transaction).index ) else - from(internal_transaction in InternalTransaction, - as: :internal_transaction, - join: transaction in assoc(internal_transaction, :transaction), - as: :transaction, - join: block in assoc(internal_transaction, :block), - as: :block, - where: block.consensus == true, - where: - (internal_transaction.type == :call and internal_transaction.index > 0) or - internal_transaction.type != :call, - order_by: [ - desc: transaction.block_number, - desc: transaction.index, - desc: internal_transaction.index - ] + InternalTransaction + |> from(as: :internal_transaction) + |> InternalTransaction.join_transaction_query() + |> join(:inner, [internal_transaction], block in assoc(internal_transaction, :block), as: :block) + |> where(as(:block).consensus == true) + |> where( + (as(:internal_transaction).type == :call and as(:internal_transaction).index > 0) or + as(:internal_transaction).type != :call + ) + |> order_by( + desc: as(:transaction).block_number, + desc: as(:transaction).index, + desc: as(:internal_transaction).index ) end - query - |> page_internal_transactions(paging_options) - |> limit_query(paging_options) - |> apply_transactions_filters(options, fn query -> + filtered_and_paginated_query = query - |> order_by([internal_transaction], - desc: internal_transaction.block_number, - desc: internal_transaction.transaction_index, - desc: internal_transaction.index - ) - end) - |> limit_query(paging_options) - |> preload([:transaction]) + |> page_internal_transactions(paging_options) + |> limit_query(paging_options) + |> apply_internal_transactions_filters(options, fn query -> + query + |> order_by([internal_transaction], + desc: internal_transaction.block_number, + desc: internal_transaction.transaction_index, + desc: internal_transaction.index + ) + end) + |> limit_query(paging_options) + + fn repo, repo_options -> + filtered_and_paginated_query + |> repo.all(repo_options) + |> InternalTransaction.preload_transaction(repo) + |> InternalTransaction.preload_addresses([], repo) + end end defp page_internal_transactions(query, %PagingOptions{ @@ -449,7 +522,7 @@ defmodule Explorer.Chain.AdvancedFilter do defp page_internal_transactions(query, _), do: query - defp token_transfers_query(paging_options, options) do + defp token_transfers_query_function(paging_options, options) do token_transfer_query = if DenormalizationHelper.transactions_denormalization_finished?() do from(token_transfer in TokenTransfer, @@ -504,13 +577,22 @@ defmodule Explorer.Chain.AdvancedFilter do |> apply_token_transfers_filters(options) |> page_token_transfers(paging_options) - token_transfer_query - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) - |> limit_query(paging_options) - |> query_function.(false) - |> limit_query(paging_options) - |> preload([:transaction, :token]) - |> select_merge([token_transfer], %{token_ids: [token_transfer.token_id], amounts: [token_transfer.amount]}) + filtered_and_paginated_query = + token_transfer_query + |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) + |> limit_query(paging_options) + |> query_function.(false) + |> limit_query(paging_options) + |> select_merge([unnested_token_transfer: unnested_token_transfer], %{ + token_ids: [unnested_token_transfer.token_id], + amounts: [unnested_token_transfer.amount] + }) + + fn repo, repo_options -> + filtered_and_paginated_query + |> repo.all(repo_options) + |> repo.preload([:transaction, [token: Reputation.reputation_association()]]) + end end defp page_token_transfers(query_function, %PagingOptions{ @@ -712,8 +794,12 @@ defmodule Explorer.Chain.AdvancedFilter do defp apply_token_transfers_filters(query_function, options) do query_function - |> filter_by_transaction_type(options[:transaction_types]) - |> filter_token_transfers_by_methods(options[:methods]) + |> filter_token_transfer_by_types(options[:transaction_types]) + |> filter_token_transfers_by_methods( + options[:methods], + [include: nil, exclude: nil] == options[:from_address_hashes] and + options[:to_address_hashes] == [include: nil, exclude: nil] + ) |> filter_token_transfers_by_age(options) |> filter_by_token(options[:token_contract_address_hashes]) |> filter_token_transfers_by_addresses( @@ -726,6 +812,7 @@ defmodule Explorer.Chain.AdvancedFilter do defp apply_transactions_filters(query, options, order_by) do query + |> filter_transaction_by_types(options[:transaction_types]) |> filter_transactions_by_amount(options[:amount][:from], options[:amount][:to]) |> filter_transactions_by_methods(options[:methods]) |> only_collated_transactions() @@ -738,23 +825,120 @@ defmodule Explorer.Chain.AdvancedFilter do ) end + defp apply_internal_transactions_filters(query, options, order_by) do + query + |> filter_internal_transaction_by_types(options[:transaction_types]) + |> filter_transactions_by_amount(options[:amount][:from], options[:amount][:to]) + |> filter_transactions_by_methods(options[:methods]) + |> only_collated_transactions() + |> filter_by_age(:transaction, options) + |> filter_internal_transactions_by_addresses( + options[:from_address_hashes], + options[:to_address_hashes], + options[:address_relation], + order_by + ) + end + defp only_collated_transactions(query) do query |> where(not is_nil(as(:transaction).block_number) and not is_nil(as(:transaction).index)) end - defp filter_by_transaction_type(query_function, [_ | _] = transaction_types) do + defp filter_transaction_by_types(query, types) when types in [nil, []], do: query + + defp filter_transaction_by_types(query, types) do + if Enum.all?(@transaction_types, &Enum.member?(types, &1)) do + query + else + dynamic_condition = + types + |> Enum.reduce(nil, fn + type, dynamic_condition when type in @transaction_types -> + filter_transaction_by_type(type, dynamic_condition) + + _, dynamic_condition -> + dynamic_condition + end) + + query = + if "CONTRACT_INTERACTION" in types and not has_named_binding?(query, :to_address) do + join(query, :left, [t], a in assoc(t, :to_address), as: :to_address) + else + query + end + + query |> where(^dynamic_condition) + end + end + + defp filter_internal_transaction_by_types(query, types) when types in [nil, []], do: query + + defp filter_internal_transaction_by_types(query, types) do + if Enum.all?(@transaction_types, &Enum.member?(types, &1)) do + query + else + dynamic_condition = + types + |> Enum.reduce(nil, fn + type, dynamic_condition when type in @transaction_types -> + filter_internal_transaction_by_type(type, dynamic_condition) + + _, dynamic_condition -> + dynamic_condition + end) + + query = + if "CONTRACT_INTERACTION" in types or "CONTRACT_CREATION" in types do + InternalTransaction.join_address_query(query, :to_address) + else + query + end + + query |> where(^dynamic_condition) + end + end + + defp filter_transaction_by_type("COIN_TRANSFER", nil), do: dynamic([t], t.value > ^0) + + defp filter_transaction_by_type("COIN_TRANSFER", dynamic_condition), + do: dynamic([t], t.value > ^0 or ^dynamic_condition) + + defp filter_transaction_by_type("CONTRACT_INTERACTION", nil), + do: dynamic([to_address: to_address], not is_nil(to_address.contract_code)) + + defp filter_transaction_by_type("CONTRACT_INTERACTION", dynamic_condition), + do: dynamic([to_address: to_address], not is_nil(to_address.contract_code) or ^dynamic_condition) + + defp filter_transaction_by_type("CONTRACT_CREATION", nil), do: dynamic([t], is_nil(t.to_address_hash)) + + defp filter_transaction_by_type("CONTRACT_CREATION", dynamic_condition), + do: dynamic([t], is_nil(t.to_address_hash) or ^dynamic_condition) + + defp filter_internal_transaction_by_type("CONTRACT_CREATION", nil), + do: dynamic([it], is_nil(as(:to_address_mapping).address_hash) and is_nil(it.to_address_hash)) + + defp filter_internal_transaction_by_type("CONTRACT_CREATION", dynamic_condition), + do: + dynamic([it], (is_nil(as(:to_address_mapping).address_hash) and is_nil(it.to_address_hash)) or ^dynamic_condition) + + defp filter_internal_transaction_by_type(type, dynamic_condition), + do: filter_transaction_by_type(type, dynamic_condition) + + defp filter_token_transfer_by_types(query_function, [_ | _] = types) do + types = types -- @transaction_types + if DenormalizationHelper.tt_denormalization_finished?() do fn query, unnested? -> - query |> where([token_transfer], token_transfer.token_type in ^transaction_types) |> query_function.(unnested?) + query |> where([token_transfer], token_transfer.token_type in ^types) |> query_function.(unnested?) end else fn query, unnested? -> - query |> where([token: token], token.type in ^transaction_types) |> query_function.(unnested?) + query |> where([token: token], token.type in ^types) |> query_function.(unnested?) end end end - defp filter_by_transaction_type(query_function, _), do: query_function + defp filter_token_transfer_by_types(query_function, _), do: query_function defp filter_transactions_by_methods(query, [_ | _] = methods) do prepared_methods = prepare_methods(methods) @@ -764,7 +948,31 @@ defmodule Explorer.Chain.AdvancedFilter do defp filter_transactions_by_methods(query, _), do: query - defp filter_token_transfers_by_methods(query_function, [_ | _] = methods) do + defp filter_token_transfers_by_methods(query_function, [_ | _] = methods, true) do + fn query, _unnested? -> + from( + method_ids in fragment("SELECT unnest(?) as method_id", type(^methods, {:array, Data})), + as: :method_ids, + cross_lateral_join: + token_transfer in subquery( + query + |> where(fragment("substring(? FOR 4)", as(:transaction).input) == parent_as(:method_ids).method_id) + |> exclude(:order_by) + |> order_by( + desc: as(:transaction).block_number, + desc: as(:transaction).index, + desc: as(:token_transfer).log_index + ) + |> query_function.(true) + ), + as: :unnested_token_transfer, + select: token_transfer, + order_by: [desc: token_transfer.block_number, desc: token_transfer.log_index] + ) + end + end + + defp filter_token_transfers_by_methods(query_function, [_ | _] = methods, false) do prepared_methods = prepare_methods(methods) fn query, unnested? -> @@ -774,7 +982,7 @@ defmodule Explorer.Chain.AdvancedFilter do end end - defp filter_token_transfers_by_methods(query_function, _), do: query_function + defp filter_token_transfers_by_methods(query_function, _, _), do: query_function defp prepare_methods(methods) do methods @@ -865,7 +1073,13 @@ defmodule Explorer.Chain.AdvancedFilter do end end - defp do_filter_token_transfers_by_both_addresses(query_function, {:include, from}, {:include, to}, relation) do + defp do_filter_token_transfers_by_both_addresses(query_function, {:include, from}, {:include, to}, :and) do + fn query, unnested? -> + query |> where([t], t.from_address_hash in ^from and t.to_address_hash in ^to) |> query_function.(unnested?) + end + end + + defp do_filter_token_transfers_by_both_addresses(query_function, {:include, from}, {:include, to}, _relation) do fn query, _unnested? -> from_queries = from @@ -879,7 +1093,16 @@ defmodule Explorer.Chain.AdvancedFilter do query |> where([token_transfer], token_transfer.to_address_hash == ^to_address) |> query_function.(true) end) - do_filter_token_transfers_by_both_addresses_to_include(from_queries, to_queries, relation) + union_query = + from_queries + |> Kernel.++(to_queries) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union(acc, ^query) end) + + from(token_transfer in subquery(union_query), + as: :unnested_token_transfer, + order_by: [desc: token_transfer.block_number, desc: token_transfer.log_index] + ) end end @@ -987,34 +1210,6 @@ defmodule Explorer.Chain.AdvancedFilter do end end - defp do_filter_token_transfers_by_both_addresses_to_include(from_queries, to_queries, relation) do - case relation do - :and -> - united_from_queries = - from_queries |> map_first(&subquery/1) |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) - - united_to_queries = - to_queries |> map_first(&subquery/1) |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) - - from(token_transfer in subquery(intersect_all(united_from_queries, ^united_to_queries)), - as: :unnested_token_transfer, - order_by: [desc: token_transfer.block_number, desc: token_transfer.log_index] - ) - - _ -> - union_query = - from_queries - |> Kernel.++(to_queries) - |> map_first(&subquery/1) - |> Enum.reduce(fn query, acc -> union(acc, ^query) end) - - from(token_transfer in subquery(union_query), - as: :unnested_token_transfer, - order_by: [desc: token_transfer.block_number, desc: token_transfer.log_index] - ) - end - end - defp filter_transactions_by_addresses(query, from_addresses, to_addresses, relation, order_by) do order_by = fn query -> query |> exclude(:order_by) |> order_by.() end @@ -1026,6 +1221,17 @@ defmodule Explorer.Chain.AdvancedFilter do end end + defp filter_internal_transactions_by_addresses(query, from_addresses, to_addresses, relation, order_by) do + order_by = fn query -> query |> exclude(:order_by) |> order_by.() end + + case {process_address_inclusion(from_addresses), process_address_inclusion(to_addresses)} do + {nil, nil} -> query + {from, nil} -> do_filter_internal_transactions_by_address(query, from, :from_address, order_by) + {nil, to} -> do_filter_internal_transactions_by_address(query, to, :to_address, order_by) + {from, to} -> do_filter_internal_transactions_by_both_addresses(query, from, to, relation, order_by) + end + end + defp do_filter_transactions_by_address(query, {:include, addresses}, field, order_by) do queries = addresses @@ -1047,7 +1253,40 @@ defmodule Explorer.Chain.AdvancedFilter do |> order_by.() end - defp do_filter_transactions_by_both_addresses(query, {:include, from}, {:include, to}, relation, order_by) do + defp do_filter_internal_transactions_by_address(query, {:include, addresses}, binding, order_by) do + queries = + addresses + |> Enum.map(fn address -> + query + |> InternalTransaction.where_address_match(binding, address) + |> order_by.() + end) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) + + order_by.(from(internal_transaction in subquery(queries))) + end + + defp do_filter_internal_transactions_by_address(query, {:exclude, addresses}, binding, order_by) do + address_ids = AddressIdToAddressHash.hashes_to_ids(addresses) + address_id_field = String.to_existing_atom("#{binding}_id") + address_hash_field = String.to_existing_atom("#{binding}_hash") + + query + |> where( + [it], + field(it, ^address_id_field) not in ^address_ids and field(it, ^address_hash_field) not in ^addresses + ) + |> order_by.() + end + + defp do_filter_transactions_by_both_addresses(query, {:include, from}, {:include, to}, :and, order_by) do + query + |> where([transaction], transaction.from_address_hash in ^from and transaction.to_address_hash in ^to) + |> order_by.() + end + + defp do_filter_transactions_by_both_addresses(query, {:include, from}, {:include, to}, _relation, order_by) do from_queries = from |> Enum.map(fn from_address -> @@ -1064,7 +1303,14 @@ defmodule Explorer.Chain.AdvancedFilter do |> order_by.() end) - do_filter_transactions_by_both_addresses_to_include(from_queries, to_queries, relation, order_by) + union_query = + from_queries + |> Kernel.++(to_queries) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union(acc, ^query) end) + + filtered_query = from(transaction in subquery(union_query)) + filtered_query |> order_by.() end defp do_filter_transactions_by_both_addresses(query, {:include, from}, {:exclude, to}, :and, order_by) do @@ -1157,30 +1403,129 @@ defmodule Explorer.Chain.AdvancedFilter do |> order_by.() end - defp do_filter_transactions_by_both_addresses_to_include(from_queries, to_queries, relation, order_by) do - case relation do - :and -> - united_from_queries = - from_queries |> map_first(&subquery/1) |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) + defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:include, to}, :and, order_by) do + query + |> InternalTransaction.where_address_match(:from_address, from) + |> InternalTransaction.where_address_match(:to_address, to) + |> order_by.() + end + + defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:include, to}, _relation, order_by) do + from_queries = + from + |> Enum.map(fn from_address -> + query + |> InternalTransaction.where_address_match(:from_address, from_address) + |> order_by.() + end) + + to_queries = + to + |> Enum.map(fn to_address -> + query + |> InternalTransaction.where_address_match(:to_address, to_address) + |> order_by.() + end) + + union_query = + from_queries + |> Kernel.++(to_queries) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union(acc, ^query) end) + + order_by.(from(internal_transaction in subquery(union_query))) + end + + defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:exclude, to}, :and, order_by) do + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) - united_to_queries = - to_queries |> map_first(&subquery/1) |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) + from_queries = + from + |> Enum.map(fn from_address -> + query + |> InternalTransaction.where_address_match(:from_address, from_address) + |> where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + |> order_by.() + end) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) - filtered_query = from(transaction in subquery(intersect_all(united_from_queries, ^united_to_queries))) + order_by.(from(internal_transaction in subquery(from_queries))) + end - filtered_query + defp do_filter_internal_transactions_by_both_addresses(query, {:include, from}, {:exclude, to}, _relation, order_by) do + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + + from_queries = + from + |> Enum.map(fn from_address -> + query + |> InternalTransaction.where_address_match(:from_address, from_address) + |> or_where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) |> order_by.() + end) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) - _ -> - union_query = - from_queries - |> Kernel.++(to_queries) - |> map_first(&subquery/1) - |> Enum.reduce(fn query, acc -> union(acc, ^query) end) - - filtered_query = from(transaction in subquery(union_query)) - filtered_query |> order_by.() - end + order_by.(from(internal_transaction in subquery(from_queries))) + end + + defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:include, to}, :and, order_by) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) + + to_queries = + to + |> Enum.map(fn to_address -> + query + |> InternalTransaction.where_address_match(:to_address, to_address) + |> where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) + |> order_by.() + end) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) + + order_by.(from(internal_transaction in subquery(to_queries))) + end + + defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:include, to}, _relation, order_by) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) + + to_queries = + to + |> Enum.map(fn to_address -> + query + |> InternalTransaction.where_address_match(:to_address, to_address) + |> or_where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) + |> order_by.() + end) + |> map_first(&subquery/1) + |> Enum.reduce(fn query, acc -> union_all(acc, ^query) end) + + order_by.(from(internal_transaction in subquery(to_queries))) + end + + defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:exclude, to}, :and, order_by) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + + query + |> where([it], it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) + |> where([it], it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + |> order_by.() + end + + defp do_filter_internal_transactions_by_both_addresses(query, {:exclude, from}, {:exclude, to}, _relation, order_by) do + from_address_ids = AddressIdToAddressHash.hashes_to_ids(from) + to_address_ids = AddressIdToAddressHash.hashes_to_ids(to) + + query + |> where(as(:from_address).hash not in ^from or as(:to_address).hash not in ^to) + |> where( + [it], + (it.from_address_id not in ^from_address_ids and it.from_address_hash not in ^from) or + (it.to_address_id not in ^to_address_ids and it.to_address_hash not in ^to) + ) + |> order_by.() end @eth_decimals 1_000_000_000_000_000_000 diff --git a/apps/explorer/lib/explorer/chain/arbitrum/batch_block.ex b/apps/explorer/lib/explorer/chain/arbitrum/batch_block.ex index 2efc578b824c..30b59ce87b49 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/batch_block.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/batch_block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.BatchBlock do @moduledoc """ Models a list of blocks related to a batch for Arbitrum. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/batch_to_da_blob.ex b/apps/explorer/lib/explorer/chain/arbitrum/batch_to_da_blob.ex index 06dde817ff32..7ee8311fda16 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/batch_to_da_blob.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/batch_to_da_blob.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.BatchToDaBlob do @moduledoc """ Models a link between an Arbitrum L1 batch and its corresponding data blob. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/batch_transaction.ex b/apps/explorer/lib/explorer/chain/arbitrum/batch_transaction.ex index dca5ed99b716..288b9a7fc8c0 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/batch_transaction.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/batch_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.BatchTransaction do @moduledoc """ Models a list of transactions related to a batch for Arbitrum. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/da_multi_purpose_record.ex b/apps/explorer/lib/explorer/chain/arbitrum/da_multi_purpose_record.ex index c7ef42e98e60..36144dae490c 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/da_multi_purpose_record.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/da_multi_purpose_record.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.DaMultiPurposeRecord do @moduledoc """ Models a multi purpose record related to Data Availability for Arbitrum. @@ -103,4 +104,22 @@ defmodule Explorer.Chain.Arbitrum.DaMultiPurposeRecord.Helper do when is_integer(height) and is_binary(transaction_commitment) do :crypto.hash(:sha256, :binary.encode_unsigned(height) <> transaction_commitment) end + + @doc """ + Calculates the data key for `Explorer.Chain.Arbitrum.DaMultiPurposeRecord` that contains EigenDA blob data. + + ## Parameters + - `blob_header`: The binary blob header from EigenDA. + + ## Returns + - A binary representing the calculated data key for the record containing + EigenDA blob data. + """ + @spec calculate_eigenda_data_key(binary()) :: binary() + def calculate_eigenda_data_key(blob_header) when is_binary(blob_header) do + # The data key is calculated as the hash of the blob header as per + # https://layr-labs.github.io/eigenda/integration/spec/3-data-structs.html#blobheader + + ExKeccak.hash_256(blob_header) + end end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/l1_batch.ex b/apps/explorer/lib/explorer/chain/arbitrum/l1_batch.ex index 624d75832750..bfb36ef8f6d9 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/l1_batch.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/l1_batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.L1Batch do @moduledoc """ Models an L1 batch for Arbitrum. @@ -8,6 +9,7 @@ defmodule Explorer.Chain.Arbitrum.L1Batch do Migrations: - Explorer.Repo.Arbitrum.Migrations.CreateArbitrumTables - Explorer.Repo.Arbitrum.Migrations.AddDaInfo + - Explorer.Repo.Arbitrum.Migrations.AddEigendaBatches """ use Explorer.Schema @@ -31,7 +33,7 @@ defmodule Explorer.Chain.Arbitrum.L1Batch do * `before_acc` - The hash of the state before the batch. * `after_acc` - The hash of the state after the batch. * `commitment_id` - The ID of the commitment L1 transaction from Explorer.Chain.Arbitrum.LifecycleTransaction. - * `batch_container` - The tag meaning the container of the batch data: `:in_blob4844`, `:in_calldata`, `:in_celestia`, `:in_anytrust` + * `batch_container` - The tag meaning the container of the batch data: `:in_blob4844`, `:in_calldata`, `:in_celestia`, `:in_anytrust`, `:in_eigenda` """ @type to_import :: %{ number: non_neg_integer(), @@ -41,7 +43,7 @@ defmodule Explorer.Chain.Arbitrum.L1Batch do before_acc: binary(), after_acc: binary(), commitment_id: non_neg_integer(), - batch_container: :in_blob4844 | :in_calldata | :in_celestia | :in_anytrust + batch_container: :in_blob4844 | :in_calldata | :in_celestia | :in_anytrust | :in_eigenda } @typedoc """ @@ -53,7 +55,7 @@ defmodule Explorer.Chain.Arbitrum.L1Batch do * `after_acc` - The hash of the state after the batch. * `commitment_id` - The ID of the commitment L1 transaction from `Explorer.Chain.Arbitrum.LifecycleTransaction`. * `commitment_transaction` - An instance of `Explorer.Chain.Arbitrum.LifecycleTransaction` referenced by `commitment_id`. - * `batch_container` - The tag meaning the container of the batch data: `:in_blob4844`, `:in_calldata`, `:in_celestia`, `:in_anytrust` + * `batch_container` - The tag meaning the container of the batch data: `:in_blob4844`, `:in_calldata`, `:in_celestia`, `:in_anytrust`, `:in_eigenda` """ @primary_key {:number, :integer, autogenerate: false} typed_schema "arbitrum_l1_batches" do @@ -69,7 +71,7 @@ defmodule Explorer.Chain.Arbitrum.L1Batch do type: :integer ) - field(:batch_container, Ecto.Enum, values: [:in_blob4844, :in_calldata, :in_celestia, :in_anytrust]) + field(:batch_container, Ecto.Enum, values: [:in_blob4844, :in_calldata, :in_celestia, :in_anytrust, :in_eigenda]) timestamps() end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/l1_execution.ex b/apps/explorer/lib/explorer/chain/arbitrum/l1_execution.ex index 623c36e231cd..a98d9a8d8e47 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/l1_execution.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/l1_execution.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.L1Execution do @moduledoc """ Models a list of execution transactions related to a L2 to L1 messages on Arbitrum. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/lifecycle_transaction.ex b/apps/explorer/lib/explorer/chain/arbitrum/lifecycle_transaction.ex index a70f15e20c39..944c02200d88 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/lifecycle_transaction.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/lifecycle_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.LifecycleTransaction do @moduledoc """ Models an L1 lifecycle transaction for Arbitrum. Lifecycle transactions are transactions that change the state of transactions and blocks on Arbitrum rollups. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/message.ex b/apps/explorer/lib/explorer/chain/arbitrum/message.ex index 4387096e6c8e..0464a63c7a34 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/message.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/message.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Message do @moduledoc """ Models an L1<->L2 messages on Arbitrum. @@ -13,6 +14,8 @@ defmodule Explorer.Chain.Arbitrum.Message do alias Explorer.Chain.Hash + @insert_result_key :insert_arbitrum_messages + @optional_attrs ~w(originator_address originating_transaction_hash origination_timestamp originating_transaction_block_number completion_transaction_hash)a @required_attrs ~w(direction message_id status)a @@ -83,4 +86,10 @@ defmodule Explorer.Chain.Arbitrum.Message do |> validate_required(@required_attrs) |> unique_constraint([:direction, :message_id]) end + + @doc """ + Shared result key used by import runners to return inserted Arbitrum messages. + """ + @spec insert_result_key() :: atom() + def insert_result_key, do: @insert_result_key end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex index 3a555e720ab2..09539e97053b 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/general.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.API.General do @moduledoc """ Provides API-specific functions for querying general Arbitrum data from the database. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/messages.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/messages.ex index cd51e8e0e3d3..20c08c42631e 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/messages.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/messages.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.API.Messages do @moduledoc """ Provides API-specific functions for querying Arbitrum cross-chain message data from the database. @@ -21,8 +22,8 @@ defmodule Explorer.Chain.Arbitrum.Reader.API.Messages do import Ecto.Query, only: [from: 2, limit: 2, where: 3] import Explorer.Chain, only: [select_repo: 1] - alias Explorer.Chain.Arbitrum.Message alias Explorer.{Chain, PagingOptions} + alias Explorer.Chain.Arbitrum.Message @api_true [api?: true] diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/settlement.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/settlement.ex index 3b376ec4ec76..274a309324db 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/api/settlement.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/api/settlement.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.API.Settlement do @moduledoc """ Provides API-specific functions for querying Arbitrum settlement data from the database. @@ -31,10 +32,10 @@ defmodule Explorer.Chain.Arbitrum.Reader.API.Settlement do L1Batch } + alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.Arbitrum.Reader.Common alias Explorer.Chain.Block, as: FullBlock alias Explorer.Chain.Cache.BackgroundMigrations, as: MigrationStatuses - alias Explorer.{Chain, PagingOptions} @api_true [api?: true] @@ -255,7 +256,7 @@ defmodule Explorer.Chain.Arbitrum.Reader.API.Settlement do """ @spec get_da_record_by_data_key(binary()) :: {:ok, {non_neg_integer(), map()}} | {:error, :not_found} def get_da_record_by_data_key("0x" <> _ = data_key) do - data_key_bytes = data_key |> Chain.string_to_block_hash() |> Kernel.elem(1) |> Map.get(:bytes) + data_key_bytes = data_key |> Chain.string_to_full_hash() |> Kernel.elem(1) |> Map.get(:bytes) get_da_record_by_data_key(data_key_bytes) end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/common.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/common.ex index 3490ef79d948..420727d7c02a 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/common.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/common.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.Common do @moduledoc """ Provides common database query functions for Arbitrum-specific data that are shared @@ -27,7 +28,7 @@ defmodule Explorer.Chain.Arbitrum.Reader.Common do """ import Ecto.Query, only: [from: 2] - import Explorer.Chain, only: [select_repo: 1, string_to_block_hash: 1] + import Explorer.Chain, only: [select_repo: 1, string_to_full_hash: 1] alias Explorer.Chain.Arbitrum.{ BatchBlock, @@ -76,7 +77,7 @@ defmodule Explorer.Chain.Arbitrum.Reader.Common do """ @spec get_anytrust_keyset(binary(), api?: boolean()) :: map() def get_anytrust_keyset("0x" <> <<_::binary-size(64)>> = keyset_hash, options) do - get_anytrust_keyset(keyset_hash |> string_to_block_hash() |> Kernel.elem(1) |> Map.get(:bytes), options) + get_anytrust_keyset(keyset_hash |> string_to_full_hash() |> Kernel.elem(1) |> Map.get(:bytes), options) end def get_anytrust_keyset(keyset_hash, options) do diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/general.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/general.ex index f50fcd8247e3..9c4b4dd9efc1 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/general.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/general.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.Indexer.General do @moduledoc """ Provides general-purpose blockchain data reading functionality that is currently diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex index be2edad3ccf0..5d52bc38bfb2 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/messages.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Messages do @moduledoc """ Provides functions for querying and managing Arbitrum cross-chain messages in the Blockscout indexer. @@ -18,9 +19,9 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Messages do Message } + alias Explorer.{Chain, Repo} alias Explorer.Chain.Block, as: FullBlock alias Explorer.Chain.{Hash, Log, Transaction} - alias Explorer.{Chain, Repo} # https://github.com/OffchainLabs/go-ethereum/blob/dff302de66598c36b964b971f72d35a95148e650/core/types/transaction.go#L44C2-L50 @message_to_l2_eth_deposit 100 @@ -51,16 +52,53 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Messages do end @doc """ - Retrieves the number of the earliest L1 block where an L1-to-L2 message was discovered. + Retrieves the highest message ID for an L1-to-L2 message that has both the originating transaction information and has been relayed on L2, within or before a specified safe L1 block. + + This function filters messages to only include those whose originating + transaction occurred at or before the safe block number, ensuring that + only finalized L1 data is considered. + + ## Parameters + - `safe_block`: The L1 block number threshold. Only messages with + originating transaction block numbers at or below this value are + considered. ## Returns - - The number of L1 block, or `nil` if no L1-to-L2 messages are found. + - The highest message ID for a fully indexed and relayed L1-to-L2 message + within the safe block range, or `nil` if no such messages are found. """ - @spec l1_block_of_earliest_discovered_message_to_l2() :: FullBlock.block_number() | nil - def l1_block_of_earliest_discovered_message_to_l2 do + @spec highest_safe_fully_indexed_message_to_l2(FullBlock.block_number()) :: non_neg_integer() | nil + def highest_safe_fully_indexed_message_to_l2(safe_block) + when is_integer(safe_block) and safe_block >= 0 do query = from(msg in Message, - select: msg.originating_transaction_block_number, + select: msg.message_id, + where: + msg.direction == :to_l2 and + not is_nil(msg.originating_transaction_block_number) and + msg.originating_transaction_block_number <= ^safe_block and + msg.status == :relayed, + order_by: [desc: msg.message_id], + limit: 1 + ) + + query + |> Repo.one(timeout: :infinity) + end + + @doc """ + Retrieves the message ID and L1 block number of the earliest L1-to-L2 message that was discovered. + + ## Returns + - A tuple `{message_id, block_number}` for the earliest discovered L1-to-L2 message, + or `nil` if no L1-to-L2 messages are found. + """ + @spec earliest_discovered_message_to_l2() :: + {non_neg_integer(), FullBlock.block_number()} | nil + def earliest_discovered_message_to_l2 do + query = + from(msg in Message, + select: {msg.message_id, msg.originating_transaction_block_number}, where: msg.direction == :to_l2 and not is_nil(msg.originating_transaction_block_number), order_by: [asc: msg.message_id], limit: 1 @@ -370,4 +408,107 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Messages do Repo.all(query) end + + @doc """ + Retrieves the message IDs of L1-to-L2 messages that have completion transactions + but are missing originating transaction information within a specified message ID range. + + This function identifies messages where the completion transaction has been discovered + on L2 (indicated by the `:relayed` status), but the corresponding originating transaction + on L1 has not yet been indexed. + + ## Parameters + - `start_message_id`: The starting message ID of the range (inclusive). + - `end_message_id`: The ending message ID of the range (inclusive). + + ## Returns + - A list of message IDs for L1-to-L2 messages that have completion transactions + but lack originating transaction information, ordered by message ID in descending + order (highest to lowest). + """ + @spec messages_to_l2_completed_but_originating_info_missed(non_neg_integer(), non_neg_integer()) :: [ + non_neg_integer() + ] + def messages_to_l2_completed_but_originating_info_missed(start_message_id, end_message_id) + when is_integer(start_message_id) and start_message_id >= 0 and + is_integer(end_message_id) and end_message_id >= 0 and + start_message_id <= end_message_id do + query = + from(msg in Message, + where: + msg.direction == :to_l2 and + msg.message_id >= ^start_message_id and + msg.message_id <= ^end_message_id and + is_nil(msg.originating_transaction_block_number) and + msg.status == :relayed, + select: msg.message_id, + order_by: [desc: msg.message_id] + ) + + Repo.all(query, timeout: :infinity) + end + + @doc """ + Retrieves the message ID and L1 block number of the closest preceding L1-to-L2 message + that has originating transaction information. + + This function finds the nearest message with a lower message ID that contains + an originating transaction block number. + + ## Parameters + - `message_id`: The message ID to find the closest preceding message for. + + ## Returns + - A tuple `{message_id, block_number}` for the closest preceding message with originating information, + or `nil` if no such message exists. + """ + @spec l1_block_of_closest_preceding_message_to_l2(non_neg_integer()) :: + {non_neg_integer(), FullBlock.block_number()} | nil + def l1_block_of_closest_preceding_message_to_l2(message_id) + when is_integer(message_id) and message_id >= 0 do + query = + from(msg in Message, + where: + msg.direction == :to_l2 and + msg.message_id < ^message_id and + not is_nil(msg.originating_transaction_block_number), + select: {msg.message_id, msg.originating_transaction_block_number}, + order_by: [desc: msg.message_id], + limit: 1 + ) + + Repo.one(query, timeout: :infinity) + end + + @doc """ + Retrieves the message ID and L1 block number of the closest following L1-to-L2 message + that has originating transaction information. + + This function finds the nearest message with a higher message ID that contains + an originating transaction block number. + + ## Parameters + - `message_id`: The message ID to find the closest following message for. + + ## Returns + - A tuple `{message_id, block_number}` for the closest following message with originating information, + or `nil` if no such message exists. + """ + @spec l1_block_of_closest_following_message_to_l2(non_neg_integer()) :: + {non_neg_integer(), FullBlock.block_number()} | nil + def l1_block_of_closest_following_message_to_l2(message_id) + when is_integer(message_id) and message_id >= 0 do + query = + from(msg in Message, + where: + msg.direction == :to_l2 and + msg.message_id > ^message_id and + not is_nil(msg.originating_transaction_block_number), + select: {msg.message_id, msg.originating_transaction_block_number}, + order_by: [asc: msg.message_id], + limit: 1 + ) + + Repo.one(query, timeout: :infinity) + end end diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/parent_chain_transactions.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/parent_chain_transactions.ex index 42543965e8d2..e673b954eadf 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/parent_chain_transactions.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/parent_chain_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.Indexer.ParentChainTransactions do @moduledoc """ Provides functions for querying Arbitrum L1 (parent chain) lifecycle transactions. diff --git a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/settlement.ex b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/settlement.ex index 205dff778ad3..1f450faa641a 100644 --- a/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/settlement.ex +++ b/apps/explorer/lib/explorer/chain/arbitrum/reader/indexer/settlement.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Settlement do @moduledoc """ Provides database query functions for retrieving information about Arbitrum rollup batches @@ -247,11 +248,34 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Settlement do end @doc """ - Retrieves all unconfirmed rollup blocks within the specified range from `first_block` to `last_block`, - inclusive, where `first_block` is less than or equal to `last_block`. + Retrieves all rollup blocks within the specified range from `first_block` to `last_block`, inclusive. - Since the function relies on the block data generated by the block fetcher, the returned list - may contain fewer blocks than actually exist if some of the blocks have not been indexed by the fetcher yet. + The results are ordered by block_number in descending order. + + ## Parameters + - `first_block`: The rollup block number starting the lookup range. + - `last_block`: The rollup block number ending the lookup range. + + ## Returns + - A list of blocks, ordered by block_number in descending order. Returns `[]` + if no blocks are found within the range. + """ + @spec rollup_blocks_by_range(FullBlock.block_number(), FullBlock.block_number()) :: [BatchBlock.t()] + def rollup_blocks_by_range(first_block, last_block) + when is_integer(first_block) and first_block >= 0 and + is_integer(last_block) and first_block <= last_block do + query = + from( + rb in BatchBlock, + where: rb.block_number >= ^first_block and rb.block_number <= ^last_block, + order_by: [desc: rb.block_number] + ) + + Repo.all(query) + end + + @doc """ + Retrieves all unconfirmed rollup blocks within the specified range from `first_block` to `last_block`, inclusive. The results are ordered by block_number in descending order to take advantage of the index (confirmation_id, block_number DESC) where confirmation_id IS NULL. @@ -261,10 +285,9 @@ defmodule Explorer.Chain.Arbitrum.Reader.Indexer.Settlement do - `last_block`:The rollup block number ending the lookup range. ## Returns - - A list of maps containing the batch number, rollup block number and hash for each - unconfirmed block within the range, ordered by block_number in descending order. - Returns `[]` if no unconfirmed blocks are found within the range, or if the block - fetcher has not indexed them. + - A list of unconfirmed blocks within the range, ordered by block_number in + descending order. Returns `[]` if no unconfirmed blocks are found within + the range. """ @spec unconfirmed_rollup_blocks(FullBlock.block_number(), FullBlock.block_number()) :: [BatchBlock.t()] def unconfirmed_rollup_blocks(first_block, last_block) diff --git a/apps/explorer/lib/explorer/chain/beacon/blob.ex b/apps/explorer/lib/explorer/chain/beacon/blob.ex index d6cb28a27402..40478593d1a1 100644 --- a/apps/explorer/lib/explorer/chain/beacon/blob.ex +++ b/apps/explorer/lib/explorer/chain/beacon/blob.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Beacon.Blob do @moduledoc "Models a data blob broadcasted using eip4844 blob transactions." diff --git a/apps/explorer/lib/explorer/chain/beacon/blob_transaction.ex b/apps/explorer/lib/explorer/chain/beacon/blob_transaction.ex index 59e3d400fefb..121c8e5d6413 100644 --- a/apps/explorer/lib/explorer/chain/beacon/blob_transaction.ex +++ b/apps/explorer/lib/explorer/chain/beacon/blob_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Beacon.BlobTransaction do @moduledoc "Models a transaction extension with extra fields from eip4844 blob transactions." diff --git a/apps/explorer/lib/explorer/chain/beacon/deposit.ex b/apps/explorer/lib/explorer/chain/beacon/deposit.ex new file mode 100644 index 000000000000..d96ed7fc6dac --- /dev/null +++ b/apps/explorer/lib/explorer/chain/beacon/deposit.ex @@ -0,0 +1,307 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Beacon.Deposit do + @moduledoc """ + Models a deposit in the beacon chain. + """ + + use Explorer.Schema + + alias Explorer.{Chain, Repo, SortingHelper} + alias Explorer.Chain.{Address, Block, Data, Hash, Log, Transaction, Wei} + + @deposit_event_signature "0x649BBC62D0E31342AFEA4E5CD82D4049E7E1EE912FC0889AA790803BE39038C5" + + @required_attrs ~w(pubkey withdrawal_credentials amount signature index block_number block_timestamp log_index status from_address_hash block_hash transaction_hash)a + + @statuses_enum ~w(invalid pending completed)a + + @primary_key false + typed_schema "beacon_deposits" do + field(:pubkey, Data, null: false) + field(:withdrawal_credentials, Data, null: false) + field(:amount, Wei, null: false) + field(:signature, Data, null: false) + field(:index, :integer, primary_key: true) + field(:block_number, :integer, null: false) + field(:block_timestamp, :utc_datetime_usec, null: false) + field(:log_index, :integer, null: false) + + field(:withdrawal_address_hash, Hash.Address, virtual: true) + + field(:status, Ecto.Enum, values: @statuses_enum, null: false) + + belongs_to(:withdrawal_address, Address, + foreign_key: :withdrawal_address_hash, + references: :hash, + define_field: false + ) + + belongs_to( + :from_address, + Address, + foreign_key: :from_address_hash, + references: :hash, + type: Hash.Address, + null: false + ) + + belongs_to(:block, Block, foreign_key: :block_hash, references: :hash, type: Hash.Full, null: false) + + belongs_to(:transaction, Transaction, + foreign_key: :transaction_hash, + references: :hash, + type: Hash.Full, + null: false + ) + + timestamps() + end + + @doc """ + Validates that the `attrs` are valid. + """ + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(deposit, attrs) do + deposit + |> cast(attrs, @required_attrs) + |> validate_required(@required_attrs) + end + + @spec statuses :: [atom()] + def statuses, do: @statuses_enum + + @spec event_signature :: String.t() + def event_signature, do: @deposit_event_signature + + @sorting [desc: :index] + + @doc """ + Fetches beacon deposits with pagination and association preloading. + + Retrieves beacon deposits sorted by index in descending order (newest first). + For each deposit, extracts the withdrawal address from the withdrawal + credentials if they have prefix 0x01 or 0x02, making it available as a + virtual field for association preloading. + + ## Parameters + - `options`: A keyword list of options: + - `:paging_options` - Pagination configuration (defaults to + `Chain.default_paging_options()`). + - `:necessity_by_association` - A map specifying which associations to + preload and whether they are `:required` or `:optional`. + - `:api?` - Boolean flag for API context. + + ## Returns + - A list of beacon deposits with requested associations preloaded and + withdrawal addresses extracted where applicable. + """ + @spec all([Chain.paging_options() | Chain.necessity_by_association() | Chain.api?()]) :: [t()] + def all(options \\ []) do + beacon_deposits_list_query(:all, nil, options) + end + + @doc """ + Fetches beacon deposits from a specific block with pagination and association preloading. + + Retrieves all beacon deposits that were included in the specified block, + sorted by index in descending order (newest first). For each deposit, + extracts the withdrawal address from the withdrawal credentials if they have + prefix 0x01 or 0x02, making it available as a virtual field for association + preloading. + + ## Parameters + - `block_hash`: The hash of the block to fetch deposits from. + - `options`: A keyword list of options: + - `:paging_options` - Pagination configuration (defaults to + `Chain.default_paging_options()`). + - `:necessity_by_association` - A map specifying which associations to + preload and whether they are `:required` or `:optional`. + - `:api?` - Boolean flag for API context. + + ## Returns + - A list of beacon deposits from the specified block with requested + associations preloaded and withdrawal addresses extracted where applicable. + """ + @spec from_block_hash(Hash.Full.t(), [Chain.paging_options() | Chain.necessity_by_association() | Chain.api?()]) :: [ + t() + ] + def from_block_hash(block_hash, options \\ []) do + beacon_deposits_list_query(:block_hash, block_hash, options) + end + + @doc """ + Fetches beacon deposits from a specific transaction with pagination and association preloading. + + Retrieves all beacon deposits that were included in the specified transaction, + sorted by index in descending order (newest first). For each deposit, + extracts the withdrawal address from the withdrawal credentials if they have + prefix 0x01 or 0x02, making it available as a virtual field for association + preloading. + + ## Parameters + - `transaction_hash`: The hash of the transaction to fetch deposits from. + - `options`: A keyword list of options: + - `:paging_options` - Pagination configuration (defaults to + `Chain.default_paging_options()`). + - `:necessity_by_association` - A map specifying which associations to + preload and whether they are `:required` or `:optional`. + - `:api?` - Boolean flag for API context. + + ## Returns + - A list of beacon deposits from the specified transaction with requested + associations preloaded and withdrawal addresses extracted where applicable. + """ + @spec from_transaction_hash(Hash.Full.t(), [Chain.paging_options() | Chain.necessity_by_association() | Chain.api?()]) :: + [ + t() + ] + def from_transaction_hash(transaction_hash, options \\ []) do + beacon_deposits_list_query(:transaction_hash, transaction_hash, options) + end + + @doc """ + Fetches beacon deposits from a specific address (`from_address`) with pagination and + association preloading. + + Retrieves all beacon deposits that were sent from the specified address, + sorted by index in descending order (newest first). For each deposit, + extracts the withdrawal address from the withdrawal credentials if they have + prefix 0x01 or 0x02, making it available as a virtual field for association + preloading. + + ## Parameters + - `from_address`: The address hash to fetch deposits from. + - `options`: A keyword list of options: + - `:paging_options` - Pagination configuration (defaults to + `Chain.default_paging_options()`). + - `:necessity_by_association` - A map specifying which associations to + preload and whether they are `:required` or `:optional`. + - `:api?` - Boolean flag for API context. + + ## Returns + - A list of beacon deposits from the specified address with requested + associations preloaded and withdrawal addresses extracted where applicable. + """ + @spec from_address_hash(Hash.Address.t(), [Chain.paging_options() | Chain.necessity_by_association() | Chain.api?()]) :: + [t()] + def from_address_hash(address_hash, options \\ []) do + beacon_deposits_list_query(:from_address_hash, address_hash, options) + end + + defp beacon_deposits_list_query(entity, hash, options) do + paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) + + {required_necessity_by_association, optional_necessity_by_association} = + options |> Keyword.get(:necessity_by_association, %{}) |> Enum.split_with(fn {_, v} -> v == :required end) + + __MODULE__ + |> then(fn q -> + case entity do + :block_hash -> where(q, [deposit], deposit.block_hash == ^hash) + :from_address_hash -> where(q, [deposit], deposit.from_address_hash == ^hash) + :transaction_hash -> where(q, [deposit], deposit.transaction_hash == ^hash) + :all -> q + end + end) + |> SortingHelper.apply_sorting(@sorting, []) + |> SortingHelper.page_with_sorting(paging_options, @sorting, []) + |> Chain.join_associations(Map.new(required_necessity_by_association)) + |> Chain.select_repo(options).all() + |> Enum.map(&put_withdrawal_address_hash/1) + |> Chain.select_repo(options).preload(optional_necessity_by_association |> Enum.map(&elem(&1, 0))) + end + + @doc """ + Retrieves the most recent beacon deposit by index. + + Fetches the beacon deposit with the highest index value, which represents + the most recently indexed deposit in the system. + + ## Parameters + - `options`: A keyword list of options: + - `:api?` - Boolean flag for API context, determines which repository to + use for the query. + + ## Returns + - The beacon deposit with the highest index. + - `nil` if no deposits exist. + """ + @spec get_latest_deposit([Chain.api?()]) :: t() | nil + def get_latest_deposit(options \\ []) do + Chain.select_repo(options).one(from(deposit in __MODULE__, order_by: [desc: deposit.index], limit: 1)) + end + + @doc """ + Fetches beacon deposit event logs from the deposit contract. + + Retrieves deposit event logs from the specified deposit contract address, + starting after the given block number and log index position. This function + is used for paginated retrieval of deposit events, ensuring only logs from + consensus blocks are included. + + ## Parameters + - `deposit_contract_address_hash`: The address hash of the deposit contract. + - `log_block_number`: The block number to start searching after (for + pagination). + - `log_index`: The log index within the block to start searching after (for + pagination). + - `limit`: The maximum number of logs to retrieve. + + ## Returns + - A list of deposit event logs with the following fields: + - Log fields: `first_topic`, `second_topic`, `third_topic`, + `fourth_topic`, `data`, `index`, `block_number`, `block_hash`, + `transaction_hash`. + - Transaction fields: `from_address_hash`, `block_timestamp`. + """ + @spec get_logs_with_deposits( + Hash.Address.t(), + non_neg_integer(), + non_neg_integer(), + non_neg_integer() + ) :: [ + %{ + first_topic: Hash.Full.t(), + data: Data.t(), + index: non_neg_integer(), + block_number: non_neg_integer(), + block_hash: Hash.Full.t(), + transaction_hash: Hash.Full.t(), + from_address_hash: Hash.Address.t(), + block_timestamp: DateTime.t() + } + ] + def get_logs_with_deposits(deposit_contract_address_hash, log_block_number, log_index, limit) do + query = + from(log in Log, + join: transaction in assoc(log, :transaction), + where: transaction.block_consensus == true, + where: log.block_hash == transaction.block_hash, + where: log.address_hash == ^deposit_contract_address_hash, + where: log.first_topic == ^@deposit_event_signature, + where: {log.block_number, log.index} > {^log_block_number, ^log_index}, + limit: ^limit, + select: + map( + log, + ^~w(first_topic data index block_number block_hash transaction_hash)a + ), + order_by: [asc: log.block_number, asc: log.index], + select_merge: map(transaction, ^~w(from_address_hash block_timestamp)a) + ) + + Repo.all(query) + end + + defp put_withdrawal_address_hash(deposit) do + case deposit.withdrawal_credentials do + %Data{bytes: <>} + when prefix in [0x01, 0x02] -> + {:ok, withdrawal_address_hash} = Hash.Address.cast(withdrawal_address_hash_bytes) + Map.put(deposit, :withdrawal_address_hash, withdrawal_address_hash) + + _ -> + deposit + end + end +end diff --git a/apps/explorer/lib/explorer/chain/beacon/deposit/pending.ex b/apps/explorer/lib/explorer/chain/beacon/deposit/pending.ex new file mode 100644 index 000000000000..d3e6b2ef61d9 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/beacon/deposit/pending.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Beacon.Deposit.Pending do + @moduledoc """ + Models a pending deposit in the beacon chain. + """ + + use Explorer.Schema + + alias Explorer.Chain.{Data, Wei} + + @primary_key false + typed_schema "temp_beacon_pending_deposits" do + field(:pubkey, Data, null: false) + field(:withdrawal_credentials, Data, null: false) + field(:amount, Wei, null: false) + field(:signature, Data, null: false) + field(:block_timestamp, :utc_datetime_usec, null: false) + end + + @doc """ + Cast and validates that the `attrs` are valid. + """ + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(deposit, attrs) do + deposit + |> cast(attrs, [:pubkey, :withdrawal_credentials, :amount, :signature, :block_timestamp]) + |> validate_required([:pubkey, :withdrawal_credentials, :amount, :signature, :block_timestamp]) + end +end diff --git a/apps/explorer/lib/explorer/chain/beacon/reader.ex b/apps/explorer/lib/explorer/chain/beacon/reader.ex index 5fb8189ec585..c57a850aa824 100644 --- a/apps/explorer/lib/explorer/chain/beacon/reader.ex +++ b/apps/explorer/lib/explorer/chain/beacon/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Beacon.Reader do @moduledoc "Contains read functions for beacon chain related modules." @@ -18,8 +19,8 @@ defmodule Explorer.Chain.Beacon.Reader do import Explorer.Chain, only: [select_repo: 1] alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Block, DenormalizationHelper, Hash, Transaction} alias Explorer.Chain.Beacon.{Blob, BlobTransaction} + alias Explorer.Chain.{Block, DenormalizationHelper, Hash, Transaction} @doc """ Finds `t:Explorer.Chain.Beacon.Blob.t/0` by its `hash`. @@ -33,7 +34,7 @@ defmodule Explorer.Chain.Beacon.Reader do Returns `{:error, :not_found}` if not found - iex> {:ok, hash} = Explorer.Chain.string_to_transaction_hash( + iex> {:ok, hash} = Explorer.Chain.string_to_full_hash( ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" ...> ) iex> Explorer.Chain.Beacon.Reader.blob(hash, true) diff --git a/apps/explorer/lib/explorer/chain/blackfort/validator.ex b/apps/explorer/lib/explorer/chain/blackfort/validator.ex index 3152b9620b1a..79ed06cbf419 100644 --- a/apps/explorer/lib/explorer/chain/blackfort/validator.ex +++ b/apps/explorer/lib/explorer/chain/blackfort/validator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Blackfort.Validator do @moduledoc """ Blackfort validators @@ -5,9 +6,10 @@ defmodule Explorer.Chain.Blackfort.Validator do use Explorer.Schema + alias Explorer.{Chain, HttpClient, Repo, SortingHelper} alias Explorer.Chain.{Address, Import} alias Explorer.Chain.Hash.Address, as: HashAddress - alias Explorer.{Chain, Helper, Repo, SortingHelper} + alias Utils.ConfigHelper, as: UtilsConfigHelper require Logger @@ -73,7 +75,7 @@ defmodule Explorer.Chain.Blackfort.Validator do Delete validators by address hashes """ @spec delete_validators_by_address_hashes([binary() | HashAddress.t()]) :: {non_neg_integer(), nil | []} | :ignore - def delete_validators_by_address_hashes(list) when is_list(list) and length(list) > 0 do + def delete_validators_by_address_hashes(list) when is_list(list) and list !== [] do __MODULE__ |> where([vs], vs.address_hash in ^list) |> Repo.delete_all() @@ -147,9 +149,8 @@ defmodule Explorer.Chain.Blackfort.Validator do def fetch_validators_list do url = validator_url() - with {:url, true} <- {:url, Helper.valid_url?(url)}, - {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- - HTTPoison.get(validator_url(), [], follow_redirect: true) do + with {:url, true} <- {:url, UtilsConfigHelper.valid_url?(url)}, + {:ok, %{status_code: 200, body: body}} <- HttpClient.get(validator_url(), [], follow_redirect: true) do body |> Jason.decode() |> parse_validators_info() else {:url, false} -> diff --git a/apps/explorer/lib/explorer/chain/block.ex b/apps/explorer/lib/explorer/chain/block.ex index 92e8620e792b..f36c5569151b 100644 --- a/apps/explorer/lib/explorer/chain/block.ex +++ b/apps/explorer/lib/explorer/chain/block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.Schema do @moduledoc """ Models blocks. @@ -5,12 +6,15 @@ defmodule Explorer.Chain.Block.Schema do Changes in the schema should be reflected in the bulk import module: - Explorer.Chain.Import.Runner.Blocks """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] alias Explorer.Chain.{ Address, Block, Hash, + InternalTransaction, PendingBlockOperation, Transaction, Wei, @@ -18,11 +22,13 @@ defmodule Explorer.Chain.Block.Schema do } alias Explorer.Chain.Arbitrum.BatchBlock, as: ArbitrumBatchBlock + alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Explorer.Chain.Block.{Reward, SecondDegreeRelation} - alias Explorer.Chain.Celo.EpochReward, as: CeloEpochReward + alias Explorer.Chain.Celo.Epoch, as: CeloEpoch alias Explorer.Chain.Optimism.TransactionBatch, as: OptimismTransactionBatch alias Explorer.Chain.Zilliqa.AggregateQuorumCertificate, as: ZilliqaAggregateQuorumCertificate alias Explorer.Chain.Zilliqa.QuorumCertificate, as: ZilliqaQuorumCertificate + alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer alias Explorer.Chain.ZkSync.BatchBlock, as: ZkSyncBatchBlock @chain_type_fields (case @chain_type do @@ -31,6 +37,7 @@ defmodule Explorer.Chain.Block.Schema do quote do field(:blob_gas_used, :decimal) field(:excess_blob_gas, :decimal) + has_many(:beacon_deposits, BeaconDeposit, foreign_key: :block_hash, references: :hash) end, 2 ) @@ -72,19 +79,6 @@ defmodule Explorer.Chain.Block.Schema do 2 ) - :celo -> - elem( - quote do - has_one(:celo_epoch_reward, CeloEpochReward, foreign_key: :block_hash, references: :hash) - - has_many(:celo_epoch_election_rewards, CeloEpochReward, - foreign_key: :block_hash, - references: :hash - ) - end, - 2 - ) - :arbitrum -> elem( quote do @@ -124,6 +118,11 @@ defmodule Explorer.Chain.Block.Schema do foreign_key: :block_hash, references: :hash ) + + has_many(:zilliqa_zrc2_token_transfers, Zrc2TokenTransfer, + foreign_key: :block_hash, + references: :hash + ) end, 2 ) @@ -132,6 +131,27 @@ defmodule Explorer.Chain.Block.Schema do [] end) + @chain_identity_fields (case @chain_identity do + {:optimism, :celo} -> + elem( + quote do + has_one(:celo_initiated_epoch, CeloEpoch, + foreign_key: :start_processing_block_hash, + references: :hash + ) + + has_one(:celo_terminated_epoch, CeloEpoch, + foreign_key: :end_processing_block_hash, + references: :hash + ) + end, + 2 + ) + + _ -> + [] + end) + defmacro generate do quote do @primary_key false @@ -149,6 +169,12 @@ defmodule Explorer.Chain.Block.Schema do field(:refetch_needed, :boolean) field(:base_fee_per_gas, Wei) field(:is_empty, :boolean) + field(:aggregated?, :boolean, virtual: true) + field(:transactions_count, :integer, virtual: true) + field(:blob_transactions_count, :integer, virtual: true) + field(:transactions_fees, :decimal, virtual: true) + field(:burnt_fees, :decimal, virtual: true) + field(:priority_fees, :decimal, virtual: true) timestamps() @@ -165,6 +191,8 @@ defmodule Explorer.Chain.Block.Schema do has_many(:transactions, Transaction, references: :hash) has_many(:transaction_forks, Transaction.Fork, foreign_key: :uncle_hash, references: :hash) + has_many(:internal_transactions, InternalTransaction, foreign_key: :block_number, references: :number) + has_many(:rewards, Reward, foreign_key: :block_hash, references: :hash) has_many(:withdrawals, Withdrawal, foreign_key: :block_hash, references: :hash) @@ -172,6 +200,7 @@ defmodule Explorer.Chain.Block.Schema do has_one(:pending_operations, PendingBlockOperation, foreign_key: :block_hash, references: :hash) unquote_splicing(@chain_type_fields) + unquote_splicing(@chain_identity_fields) end end end @@ -189,10 +218,26 @@ defmodule Explorer.Chain.Block do use Explorer.Schema use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - alias Explorer.Chain.{Block, Hash, Transaction, Wei} - alias Explorer.Chain.Block.{EmissionReward, Reward} - alias Explorer.Repo - alias Explorer.Utility.MissingRangesManipulator + use Utils.RuntimeEnvHelper, + miner_gets_burnt_fees?: [:explorer, [Explorer.Chain.Transaction, :block_miner_gets_burnt_fees?]] + + alias EthereumJSONRPC.Utility.RangesHelper + + alias Explorer.Chain.{ + Block, + DenormalizationHelper, + Hash, + PendingBlockOperation, + PendingOperationsHelper, + PendingTransactionOperation, + Transaction, + Wei + } + + alias Explorer.{Chain, Helper, PagingOptions, Repo} + alias Explorer.Chain.Block.{EmissionReward, Reward, SecondDegreeRelation} + alias Explorer.Chain.InternalTransaction.DeleteQueue, as: InternalTransactionDeleteQueue + alias Explorer.Utility.MissingBlockRange @optional_attrs ~w(size refetch_needed total_difficulty difficulty base_fee_per_gas)a @@ -282,6 +327,10 @@ defmodule Explorer.Chain.Block do |> unique_constraint(:hash, name: :blocks_pkey) end + @doc """ + Returns a query that fetches consensus blocks that do not have validator rewards. + """ + @spec blocks_without_reward_query() :: Ecto.Query.t() def blocks_without_reward_query do validator_rewards = from( @@ -358,7 +407,7 @@ defmodule Explorer.Chain.Block do if gas_price do gas_used |> Decimal.new() - |> Decimal.mult(gas_price_to_decimal(gas_price)) + |> Decimal.mult(Helper.number_to_decimal(gas_price)) |> Decimal.add(acc) else acc @@ -376,21 +425,17 @@ defmodule Explorer.Chain.Block do if is_nil(beacon_blob_transaction) do nil else - gas_price_to_decimal(beacon_blob_transaction.blob_gas_price) + Helper.number_to_decimal(beacon_blob_transaction.blob_gas_price) end end) end - defp gas_price_to_decimal(nil), do: nil - defp gas_price_to_decimal(%Wei{} = wei), do: wei.value - defp gas_price_to_decimal(gas_price), do: Decimal.new(gas_price) - @doc """ Calculates burnt fees for the list of transactions (from a single block) """ @spec burnt_fees(list(), Decimal.t() | nil) :: Decimal.t() def burnt_fees(transactions, base_fee_per_gas) do - if is_nil(base_fee_per_gas) do + if is_nil(base_fee_per_gas) or miner_gets_burnt_fees?() do Decimal.new(0) else transactions @@ -399,7 +444,7 @@ defmodule Explorer.Chain.Block do |> Decimal.new() |> Decimal.add(acc) end) - |> Decimal.mult(gas_price_to_decimal(base_fee_per_gas)) + |> Decimal.mult(Helper.number_to_decimal(base_fee_per_gas)) end end @@ -426,7 +471,7 @@ defmodule Explorer.Chain.Block do where: fragment("int8range(?, ?) <@ ?", ^block_number, ^(block_number + 1), er.block_range), select: er.reward ) - ) || %Wei{value: Decimal.new(0)} + ) || Wei.zero() uncles_count = if is_list(block.uncles), do: Enum.count(block.uncles), else: 0 @@ -461,7 +506,8 @@ defmodule Explorer.Chain.Block do # credo:disable-for-next-line Credo.Check.Design.AliasUsage config = Explorer.Chain.Optimism.EIP1559ConfigUpdate.actual_config_for_block(block_number), false <- is_nil(config) do - config + {denominator, multiplier, _min_base_fee} = config + {denominator, multiplier} else _ -> {Application.get_env(:explorer, :base_fee_max_change_denominator), @@ -543,20 +589,90 @@ defmodule Explorer.Chain.Block do def next_block_base_fee_per_gas(block) do {base_fee_max_change_denominator, elasticity_multiplier} = get_eip1559_config(block.number) - gas_target = Decimal.div(block.gas_limit, elasticity_multiplier) + gas_target = Decimal.div_int(block.gas_limit, elasticity_multiplier) - gas_used_delta = Decimal.sub(block.gas_used, gas_target) + lower_bound = Application.get_env(:explorer, :base_fee_lower_bound) base_fee_per_gas_decimal = block.base_fee_per_gas |> Wei.to(:wei) base_fee_per_gas_decimal && - base_fee_per_gas_decimal - |> Decimal.mult(gas_used_delta) - |> Decimal.div(gas_target) - |> Decimal.div(base_fee_max_change_denominator) - |> Decimal.add(base_fee_per_gas_decimal) + block.gas_used + |> Decimal.gt?(gas_target) + |> if do + gas_used_delta = Decimal.sub(block.gas_used, gas_target) + + base_fee_per_gas_decimal + |> get_base_fee_per_gas_delta(gas_used_delta, gas_target, base_fee_max_change_denominator) + |> Decimal.max(Decimal.new(1)) + |> Decimal.add(base_fee_per_gas_decimal) + else + gas_used_delta = Decimal.sub(gas_target, block.gas_used) + + base_fee_per_gas_decimal + |> get_base_fee_per_gas_delta(gas_used_delta, gas_target, base_fee_max_change_denominator) + |> Decimal.negate() + |> Decimal.add(base_fee_per_gas_decimal) + end + |> Decimal.max(lower_bound) + end + + defp get_base_fee_per_gas_delta(base_fee_per_gas_decimal, gas_used_delta, gas_target, base_fee_max_change_denominator) do + base_fee_per_gas_decimal + |> Decimal.mult(gas_used_delta) + |> Decimal.div_int(gas_target) + |> Decimal.div_int(base_fee_max_change_denominator) + end + + @doc """ + Queues blocks for a full refetch and marks them as needing refetch. + + This function accepts either a block ranges string, a list of block numbers, + or a single block number. When given a ranges string, it parses the string into + individual block numbers. It then enqueues the blocks for internal transaction + cleanup and marks the corresponding blocks with `refetch_needed: true` inside a + single database transaction. + + ## Parameters + + - `block_ranges_string_or_numbers`: A block ranges string, a list of block numbers, or a single block number. + + ## Returns + + - The result of `Repo.transaction/2` for range strings and lists. + - The delegated result for a single block number. + + ## Examples + + iex> full_refetch("1..3,5..6") + {:ok, _} + + iex> full_refetch([10, 11, 12]) + {:ok, _} + + iex> full_refetch(15) + {:ok, _} + + """ + @spec full_refetch(binary() | [integer()] | integer()) :: {:ok, any()} | {:error, any()} + def full_refetch(block_ranges_string) when is_binary(block_ranges_string) do + block_ranges_string + |> RangesHelper.parse_block_ranges_to_numbers() + |> full_refetch() end + def full_refetch(block_numbers) when is_list(block_numbers) do + Repo.transaction( + fn -> + # TODO: delete other crucial entities as well + InternalTransactionDeleteQueue.batch_insert(block_numbers) + set_refetch_needed(block_numbers) + end, + timeout: :infinity + ) + end + + def full_refetch(block_number), do: full_refetch([block_number]) + @spec set_refetch_needed(integer | [integer]) :: :ok def set_refetch_needed(block_numbers) when is_list(block_numbers) do query = @@ -573,8 +689,476 @@ defmodule Explorer.Chain.Block do set: [refetch_needed: true, updated_at: Timex.now()] ) - MissingRangesManipulator.add_ranges_by_block_numbers(updated_numbers) + MissingBlockRange.add_ranges_by_block_numbers(updated_numbers) + + :ok end def set_refetch_needed(block_number), do: set_refetch_needed([block_number]) + + @doc """ + Generates a query to fetch blocks by their hashes. + + ## Parameters + + - `hashes`: A list of block hashes to filter by. + + ## Returns + + - An Ecto query that can be used to retrieve blocks matching the given hashes. + """ + @spec by_hashes_query([binary()]) :: Ecto.Query.t() + def by_hashes_query(hashes) do + __MODULE__ + |> where([block], block.hash in ^hashes) + end + + @doc """ + Calculates and aggregates transaction-related metrics for a block if not already aggregated. + + This function processes all transactions in a block to compute aggregate + statistics including transaction counts, fees, burnt fees, and priority fees. + The aggregation only occurs if the block has not been previously aggregated + (when `aggregated?` is `nil` or `false`) and contains a list of transactions. + + For each transaction, the function calculates: + - Total transaction fees (gas_used * gas_price) + - Burnt fees (gas_used * base_fee_per_gas) + - Priority fees paid to miners (min of priority fee and effective fee) + - Blob transaction detection (type 3 transactions) + + ## Parameters + - `block`: A Block struct containing transactions to be aggregated + + ## Returns + - Block struct with aggregated transaction metrics and `aggregated?` set to `true` + - Original block unchanged if already aggregated or transactions is not a list + """ + @spec aggregate_transactions(t()) :: t() + def aggregate_transactions(%__MODULE__{transactions: transactions, aggregated?: aggregated?} = block) + when is_list(transactions) and aggregated? in [nil, false] do + aggregate_results = + Enum.reduce( + transactions, + %{ + transactions_count: 0, + blob_transactions_count: 0, + transactions_fees: Decimal.new(0), + burnt_fees: Decimal.new(0), + priority_fees: Decimal.new(0) + }, + &transaction_aggregator(&1, &2, block.base_fee_per_gas) + ) + + block + |> Map.merge(aggregate_results) + |> Map.put(:aggregated?, true) + end + + def aggregate_transactions(block), do: block + + @doc """ + Checks if block with consensus and not marked to re-fetch block is present in the DB with the given number + """ + @spec indexed?(non_neg_integer()) :: boolean() + def indexed?(block_number) do + query = + from( + block in __MODULE__, + where: block.number == ^block_number, + where: block.consensus == true, + where: block.refetch_needed == false + ) + + Repo.exists?(query) + end + + defp transaction_aggregator(transaction, acc, block_base_fee_per_gas) do + gas_used = Helper.number_to_decimal(transaction.gas_used) + + transaction_fees = + if is_nil(transaction.gas_price) do + acc.transactions_fees + else + gas_used + |> Decimal.new() + |> Decimal.mult(Helper.number_to_decimal(transaction.gas_price)) + |> Decimal.add(acc.transactions_fees) + end + + burnt_fees = + if is_nil(block_base_fee_per_gas) or miner_gets_burnt_fees?() do + acc.burnt_fees + else + transaction.gas_used + |> Decimal.new() + |> Decimal.mult(Helper.number_to_decimal(block_base_fee_per_gas)) + |> Decimal.add(acc.burnt_fees) + end + + priority_fees = + block_base_fee_per_gas + |> is_nil() + |> if do + acc.priority_fees + else + max_fee = Helper.number_to_decimal(transaction.max_fee_per_gas || transaction.gas_price) + priority_fee = Helper.number_to_decimal(transaction.max_priority_fee_per_gas || transaction.gas_price) + + max_fee + |> Decimal.eq?(Decimal.new(0)) + |> if do + Decimal.new(0) + else + max_fee + |> Decimal.sub(Helper.number_to_decimal(block_base_fee_per_gas)) + |> Decimal.min(priority_fee) + |> Decimal.mult(gas_used) + end + |> Decimal.add(acc.priority_fees) + end + + %{ + transactions_count: acc.transactions_count + 1, + blob_transactions_count: acc.blob_transactions_count + if(transaction.type == 3, do: 1, else: 0), + transactions_fees: transaction_fees, + burnt_fees: burnt_fees, + priority_fees: priority_fees + } + end + + @doc """ + Filters block numbers that do not require refetching. + ## Parameters + - `block_numbers`: A list of block numbers to check. + ## Returns + - A list of block numbers that do not need to be refetched. + """ + @spec filter_non_refetch_needed_block_numbers([integer()]) :: [integer()] + def filter_non_refetch_needed_block_numbers(block_numbers) do + query = + from( + block in __MODULE__, + where: block.number in ^block_numbers, + where: block.consensus == true, + where: block.refetch_needed == false, + select: block.number + ) + + Repo.all(query) + end + + @doc """ + Combined block reward from all the fees. + """ + @spec block_combined_rewards(__MODULE__.t()) :: Wei.t() + def block_combined_rewards(block) do + block.rewards + |> Enum.reduce( + 0, + fn block_reward, acc -> + {:ok, decimal} = Wei.dump(block_reward.reward) + + Decimal.add(decimal, acc) + end + ) + |> Wei.cast() + |> case do + {:ok, value} -> value + _ -> Wei.zero() + end + end + + @doc """ + Fetches the lowest block number available in the database. + + Queries the database for the minimum block number among blocks marked as consensus + blocks. Returns 0 if no consensus blocks exist or if the query fails. + + ## Returns + - `non_neg_integer`: The lowest block number from consensus blocks, or 0 if none found + """ + @spec fetch_min_block_number() :: non_neg_integer + def fetch_min_block_number do + query = + from(block in __MODULE__, + select: block.number, + where: block.consensus == true, + order_by: [asc: block.number], + limit: 1 + ) + + Repo.one(query) || 0 + rescue + _ -> + 0 + end + + @doc """ + Fetches the highest block number available in the database. + + Queries the database for the maximum block number among blocks marked as consensus + blocks. Returns 0 if no consensus blocks exist or if the query fails. + + ## Returns + - `non_neg_integer`: The highest block number from consensus blocks, or 0 if none found + """ + @spec fetch_max_block_number() :: non_neg_integer + def fetch_max_block_number do + query = + from(block in __MODULE__, + select: block.number, + where: block.consensus == true, + order_by: [desc: block.number], + limit: 1 + ) + + Repo.one(query) || 0 + rescue + _ -> + 0 + end + + @doc """ + Fetches a block by its hash. + """ + @spec fetch_block_by_hash(Hash.Full.t()) :: Block.t() | nil + def fetch_block_by_hash(block_hash) do + Repo.get(__MODULE__, block_hash) + end + + @default_page_size 50 + @default_paging_options %PagingOptions{page_size: @default_page_size} + + @doc """ + Finds all Blocks validated by the address with the given hash. + + ## Options + * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is + `:required`, and the `t:Explorer.Chain.Block.t/0` has no associated record for that association, then the + `t:Explorer.Chain.Block.t/0` will not be included in the page `entries`. + * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` and + `:key` (a tuple of the lowest/oldest `{block_number}`) and. Results will be the internal + transactions older than the `block_number` that are passed. + + Returns all blocks validated by the address given. + """ + @spec get_blocks_validated_by_address( + [Chain.paging_options() | Chain.necessity_by_association_option()], + Hash.Address.t() + ) :: [Block.t()] + def get_blocks_validated_by_address(options \\ [], address_hash) when is_list(options) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + case paging_options do + %PagingOptions{key: {0}} -> + [] + + _ -> + __MODULE__ + |> Chain.join_associations(necessity_by_association) + |> where(miner_hash: ^address_hash) + |> Chain.page_blocks(paging_options) + |> limit(^paging_options.page_size) + |> order_by(desc: :number) + |> Chain.select_repo(options).all() + end + end + + @doc """ + Calls `reducer` on a stream of `t:Explorer.Chain.Block.t/0` without `t:Explorer.Chain.Block.Reward.t/0`. + """ + def stream_blocks_without_rewards(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + blocks_without_reward_query() + |> Chain.add_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Returns a stream of all blocks that are marked as unfetched in `t:Explorer.Chain.Block.SecondDegreeRelation.t/0`. + For each uncle block a `hash` of nephew block and an `index` of the block in it are returned. + + When a block is fetched, its uncles are transformed into `t:Explorer.Chain.Block.SecondDegreeRelation.t/0` and can be + returned. Once the uncle is imported its corresponding `t:Explorer.Chain.Block.SecondDegreeRelation.t/0` + `uncle_fetched_at` will be set and it won't be returned anymore. + """ + @spec stream_unfetched_uncles( + initial :: accumulator, + reducer :: (entry :: term(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_unfetched_uncles(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + query = + from(bsdr in SecondDegreeRelation, + where: is_nil(bsdr.uncle_fetched_at) and not is_nil(bsdr.index), + select: [:nephew_hash, :index] + ) + + query + |> Chain.add_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Map `block_number`s to their `t:Explorer.Chain.Block.t/0` `hash` `t:Explorer.Chain.Hash.Full.t/0`. + + Does not include non-consensus blocks. + + iex> block = insert(:block, consensus: false) + iex> Explorer.Chain.Block.block_hash_by_number([block.number]) + %{} + + """ + @spec block_hash_by_number([Block.block_number()]) :: %{Block.block_number() => Hash.Full.t()} + def block_hash_by_number(block_numbers) when is_list(block_numbers) do + query = + from(block in __MODULE__, + where: block.consensus == true and block.number in ^block_numbers, + select: {block.number, block.hash} + ) + + query + |> Repo.all() + |> Enum.into(%{}) + end + + @doc """ + Removes pending operations associated with non-consensus blocks. + """ + @spec remove_nonconsensus_blocks_from_pending_ops([Hash.Full.t()]) :: :ok + def remove_nonconsensus_blocks_from_pending_ops(block_hashes) do + query = + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> + PendingOperationsHelper.block_hash_in_query(block_hashes) + + "transactions" -> + from( + pto in PendingTransactionOperation, + join: t in assoc(pto, :transaction), + where: t.block_hash in ^block_hashes + ) + end + + {_, _} = Repo.delete_all(query) + + :ok + end + + @doc """ + Removes pending operations associated with all non-consensus blocks. + """ + @spec remove_nonconsensus_blocks_from_pending_ops() :: :ok + def remove_nonconsensus_blocks_from_pending_ops do + query = + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> + from( + pbo in PendingBlockOperation, + inner_join: block in Block, + on: block.hash == pbo.block_hash, + where: block.consensus == false + ) + + "transactions" -> + from( + pto in PendingTransactionOperation, + join: t in assoc(pto, :transaction), + where: t.block_consensus == false + ) + end + + {_, _} = Repo.delete_all(query) + + :ok + end + + @spec nonconsensus_block_by_number(Block.block_number(), [Chain.api?()]) :: {:ok, Block.t()} | {:error, :not_found} + def nonconsensus_block_by_number(number, options) do + __MODULE__ + |> where(consensus: false, number: ^number) + |> Chain.select_repo(options).one() + |> case do + nil -> {:error, :not_found} + block -> {:ok, block} + end + end + + @doc """ + The `t:Explorer.Chain.Wei.t/0` paid to the miners of the `t:Explorer.Chain.Block.t/0`s with `hash` + `Explorer.Chain.Hash.Full.t/0` by the signers of the transactions in those blocks to cover the gas fee + (`gas_used * gas_price`). + """ + @spec gas_payment_by_block_hash([Hash.Full.t()]) :: %{Hash.Full.t() => Wei.t()} + def gas_payment_by_block_hash(block_hashes) when is_list(block_hashes) do + query = + if DenormalizationHelper.transactions_denormalization_finished?() do + from( + transaction in Transaction, + where: transaction.block_hash in ^block_hashes and transaction.block_consensus == true, + group_by: transaction.block_hash, + select: {transaction.block_hash, %Wei{value: coalesce(sum(transaction.gas_used * transaction.gas_price), 0)}} + ) + else + from( + block in __MODULE__, + left_join: transaction in assoc(block, :transactions), + where: block.hash in ^block_hashes and block.consensus == true, + group_by: block.hash, + select: {block.hash, %Wei{value: coalesce(sum(transaction.gas_used * transaction.gas_price), 0)}} + ) + end + + initial_gas_payments = + block_hashes + |> Enum.map(&{&1, Wei.zero()}) + |> Enum.into(%{}) + + existing_data = + query + |> Repo.all() + |> Enum.into(%{}) + + Map.merge(initial_gas_payments, existing_data) + end + + @doc """ + The `timestamp` of the `t:Explorer.Chain.Block.t/0`s with `hash` `Explorer.Chain.Hash.Full.t/0`. + """ + @spec timestamp_by_block_hash([Hash.Full.t()]) :: %{Hash.Full.t() => non_neg_integer()} + def timestamp_by_block_hash(block_hashes) when is_list(block_hashes) do + query = + from( + block in __MODULE__, + where: block.hash in ^block_hashes and block.consensus == true, + group_by: block.hash, + select: {block.hash, block.timestamp} + ) + + query + |> Repo.all() + |> Enum.into(%{}) + end + + @doc """ + Fetches the second block in the database ordered by number in ascending order. + """ + @spec fetch_second_block_in_database() :: {:ok, __MODULE__.t()} | {:error, :not_found} + def fetch_second_block_in_database do + query = + from(block in __MODULE__, + where: block.consensus == true, + order_by: [asc: block.number], + offset: 1, + limit: 1, + select: block + ) + + case Repo.one(query) do + nil -> {:error, :not_found} + block -> {:ok, block} + end + end end diff --git a/apps/explorer/lib/explorer/chain/block/emission_reward.ex b/apps/explorer/lib/explorer/chain/block/emission_reward.ex index 8be68584b56e..32521faadfe4 100644 --- a/apps/explorer/lib/explorer/chain/block/emission_reward.ex +++ b/apps/explorer/lib/explorer/chain/block/emission_reward.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.EmissionReward do @moduledoc """ Represents the static reward given to the miner of a block in a range of block numbers. diff --git a/apps/explorer/lib/explorer/chain/block/range.ex b/apps/explorer/lib/explorer/chain/block/range.ex index 354ccd6d420a..468341c1fdb5 100644 --- a/apps/explorer/lib/explorer/chain/block/range.ex +++ b/apps/explorer/lib/explorer/chain/block/range.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.Range do @moduledoc """ Represents a range of block numbers. diff --git a/apps/explorer/lib/explorer/chain/block/reader/general.ex b/apps/explorer/lib/explorer/chain/block/reader/general.ex index 6886c8561824..7012ea30041e 100644 --- a/apps/explorer/lib/explorer/chain/block/reader/general.ex +++ b/apps/explorer/lib/explorer/chain/block/reader/general.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.Reader.General do @moduledoc """ Provides general methods for reading block data from the database. diff --git a/apps/explorer/lib/explorer/chain/block/reward.ex b/apps/explorer/lib/explorer/chain/block/reward.ex index f558c0eced42..dfa6adc94162 100644 --- a/apps/explorer/lib/explorer/chain/block/reward.ex +++ b/apps/explorer/lib/explorer/chain/block/reward.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.Reward do @moduledoc """ Represents the total reward given to an address in a block. @@ -7,8 +8,8 @@ defmodule Explorer.Chain.Block.Reward do alias Explorer.Application.Constants alias Explorer.{Chain, PagingOptions, Repo} - alias Explorer.Chain.Block.Reward.AddressType alias Explorer.Chain.{Address, Block, Hash, Validator, Wei} + alias Explorer.Chain.Block.Reward.AddressType alias Explorer.Chain.Fetcher.FetchValidatorInfoOnDemand alias Explorer.Chain.SmartContract alias Explorer.SmartContract.Reader diff --git a/apps/explorer/lib/explorer/chain/block/reward/address_type.ex b/apps/explorer/lib/explorer/chain/block/reward/address_type.ex index 9e0fe2ac52e6..efc4d761e73a 100644 --- a/apps/explorer/lib/explorer/chain/block/reward/address_type.ex +++ b/apps/explorer/lib/explorer/chain/block/reward/address_type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.Reward.AddressType do @moduledoc """ Block reward address types diff --git a/apps/explorer/lib/explorer/chain/block/second_degree_relation.ex b/apps/explorer/lib/explorer/chain/block/second_degree_relation.ex index 941dc51480e7..abbaf460e4b4 100644 --- a/apps/explorer/lib/explorer/chain/block/second_degree_relation.ex +++ b/apps/explorer/lib/explorer/chain/block/second_degree_relation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.SecondDegreeRelation do @moduledoc """ A [second-degree relative](https://en.wikipedia.org/wiki/Second-degree_relative) is a relative where the share diff --git a/apps/explorer/lib/explorer/chain/block_number_helper.ex b/apps/explorer/lib/explorer/chain/block_number_helper.ex index 5701118e612e..ca97f7eebdb5 100644 --- a/apps/explorer/lib/explorer/chain/block_number_helper.ex +++ b/apps/explorer/lib/explorer/chain/block_number_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule Explorer.Chain.BlockNumberHelper do @moduledoc """ diff --git a/apps/explorer/lib/explorer/chain/bridged_token.ex b/apps/explorer/lib/explorer/chain/bridged_token.ex index 2f648e3396b2..e6cea0c88572 100644 --- a/apps/explorer/lib/explorer/chain/bridged_token.ex +++ b/apps/explorer/lib/explorer/chain/bridged_token.ex @@ -1,18 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.BridgedToken do @moduledoc """ Represents a bridged token. """ use Explorer.Schema - import Ecto.Changeset import EthereumJSONRPC, only: [json_rpc: 2] - - import Ecto.Query, - only: [ - from: 2, - limit: 2, - where: 2 - ] + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] alias ABI.{TypeDecoder, TypeEncoder} alias Ecto.Changeset @@ -20,6 +14,7 @@ defmodule Explorer.Chain.BridgedToken do alias Explorer.{Chain, PagingOptions, Repo, SortingHelper} alias Explorer.Chain.{ + Address, BridgedToken, Hash, InternalTransaction, @@ -28,8 +23,6 @@ defmodule Explorer.Chain.BridgedToken do Transaction } - alias Explorer.Helper, as: ExplorerHelper - require Logger # TODO: Consider using the `EthereumJSONRPC.ERC20` module to retrieve token metadata @@ -218,16 +211,10 @@ defmodule Explorer.Chain.BridgedToken do def fetch_omni_bridged_tokens_metadata(token_addresses) do Enum.each(token_addresses, fn token_address_hash -> created_from_internal_transaction_success_query = - from( - it in InternalTransaction, - inner_join: t in assoc(it, :transaction), - where: it.created_contract_address_hash == ^token_address_hash, - where: t.status == ^1 - ) + Address.creation_internal_transaction_query(token_address_hash) created_from_internal_transaction_success = created_from_internal_transaction_success_query - |> limit(1) |> Repo.one() created_from_transaction_query = @@ -242,10 +229,8 @@ defmodule Explorer.Chain.BridgedToken do |> Enum.count() > 0 created_from_internal_transaction_query = - from( - it in InternalTransaction, - where: it.created_contract_address_hash == ^token_address_hash - ) + InternalTransaction + |> InternalTransaction.where_address_match(:created_contract_address, token_address_hash) created_from_internal_transaction = created_from_internal_transaction_query @@ -311,16 +296,20 @@ defmodule Explorer.Chain.BridgedToken do mediator ) do omni_bridge_mediator = Application.get_env(:explorer, __MODULE__)[mediator] - %{transaction_hash: transaction_hash} = created_from_internal_transaction_success + %{block_number: block_number, transaction_index: transaction_index} = created_from_internal_transaction_success if omni_bridge_mediator && omni_bridge_mediator !== "" do {:ok, omni_bridge_mediator_hash} = Chain.string_to_address_hash(omni_bridge_mediator) created_by_amb_mediator_query = - from( - it in InternalTransaction, - where: it.transaction_hash == ^transaction_hash, - where: it.to_address_hash == ^omni_bridge_mediator_hash + InternalTransaction + |> InternalTransaction.join_address_mapping_query(:to_address) + |> where([it], it.block_number == ^block_number) + |> where([it], it.transaction_index == ^transaction_index) + |> where( + [it], + it.to_address_hash == ^omni_bridge_mediator_hash or + as(:to_address_mapping).address_hash == ^omni_bridge_mediator_hash ) created_by_amb_mediator = @@ -587,10 +576,10 @@ defmodule Explorer.Chain.BridgedToken do |> json_rpc(eth_call_foreign_json_rpc_named_arguments), token0_hash <- parse_contract_response(token0_encoded, :address), token1_hash <- parse_contract_response(token1_encoded, :address), - false <- is_nil(token0_hash), - false <- is_nil(token1_hash), - token0_hash_str <- ExplorerHelper.add_0x_prefix(token0_hash), - token1_hash_str <- ExplorerHelper.add_0x_prefix(token1_hash), + {:ok, token0_hash} <- Hash.Address.cast(token0_hash), + {:ok, token1_hash} <- Hash.Address.cast(token1_hash), + token0_hash_str <- to_string(token0_hash), + token1_hash_str <- to_string(token1_hash), {:ok, "0x" <> token0_name_encoded} <- @name_signature |> Contract.eth_call_request(token0_hash_str, 1, nil, nil) @@ -709,8 +698,8 @@ defmodule Explorer.Chain.BridgedToken do |> json_rpc(eth_call_foreign_json_rpc_named_arguments) do {:ok, "0x" <> token_encoded} -> with token_hash <- parse_contract_response(token_encoded, :address), - false <- is_nil(token_hash), - token_hash_str <- ExplorerHelper.add_0x_prefix(token_hash), + {:ok, token_hash} <- Hash.Address.cast(token_hash), + token_hash_str <- to_string(token_hash), {:ok, "0x" <> token_decimals_encoded} <- @decimals_signature |> Contract.eth_call_request(token_hash_str, 1, nil, nil) @@ -737,23 +726,27 @@ defmodule Explorer.Chain.BridgedToken do |> Decimal.mult(home_token_total_supply) |> Decimal.div(token_decimals_divider) - token = Token.get_by_contract_address_hash(token_hash_str, []) - - token_cap_usd = - if token && token.fiat_value do - token.fiat_value - |> Decimal.mult(token_cap) - else - 0 - end - - {:ok, token_cap_usd} + compute_token_cap_usd(token_hash_str, token_cap) else _ -> :error end end end + defp compute_token_cap_usd(token_hash_str, token_cap) do + token = Token.get_by_contract_address_hash(token_hash_str, []) + + token_cap_usd = + if token && token.fiat_value do + token.fiat_value + |> Decimal.mult(token_cap) + else + 0 + end + + {:ok, token_cap_usd} + end + defp parse_contract_response(abi_encoded_value, types) when is_list(types) do values = try do @@ -950,7 +943,7 @@ defmodule Explorer.Chain.BridgedToken do where: t.total_supply > ^0, where: t.bridged, select: {t, bt}, - preload: [:contract_address] + preload: [:contract_address, ^reputation_association()] ) base_query_with_paging = @@ -963,7 +956,7 @@ defmodule Explorer.Chain.BridgedToken do case Search.prepare_search_term(filter) do {:some, filter_term} -> base_query_with_paging - |> where(fragment("to_tsvector('english', symbol || ' ' || name) @@ to_tsquery(?)", ^filter_term)) + |> Token.apply_fts_filter(filter_term) _ -> base_query_with_paging diff --git a/apps/explorer/lib/explorer/chain/cache/accounts.ex b/apps/explorer/lib/explorer/chain/cache/accounts.ex index f24340548467..cfee59fbe243 100644 --- a/apps/explorer/lib/explorer/chain/cache/accounts.ex +++ b/apps/explorer/lib/explorer/chain/cache/accounts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Accounts do @moduledoc """ Caches the top Addresses diff --git a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex index 66258b72ac31..e4aa0d7898f8 100644 --- a/apps/explorer/lib/explorer/chain/cache/background_migrations.ex +++ b/apps/explorer/lib/explorer/chain/cache/background_migrations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.BackgroundMigrations do @moduledoc """ Caches the completion status of various background database operations in the Blockscout system. @@ -29,7 +30,7 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :backfill_multichain_search_db_finished, key: :arbitrum_da_records_normalization_finished, key: :sanitize_verified_addresses_finished, - key: :smart_contract_language_finished, + key: :backfill_call_type_enum_finished, key: :heavy_indexes_create_logs_block_hash_index_finished, key: :heavy_indexes_drop_logs_block_number_asc_index_asc_index_finished, key: :heavy_indexes_create_logs_address_hash_block_number_desc_index_desc_index_finished, @@ -55,7 +56,20 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do key: :heavy_indexes_drop_transactions_to_address_hash_with_pending_index_finished, key: :heavy_indexes_create_logs_deposits_withdrawals_index_finished, key: :heavy_indexes_create_addresses_transactions_count_desc_partial_index_finished, - key: :heavy_indexes_create_addresses_transactions_count_asc_coin_balance_desc_hash_partial_index_finished + key: :heavy_indexes_create_addresses_transactions_count_asc_coin_balance_desc_hash_partial_index_finished, + key: :heavy_indexes_drop_token_instances_token_id_index_finished, + key: :heavy_indexes_drop_internal_transactions_created_contract_address_hash_partial_index_finished, + key: :heavy_indexes_create_tokens_name_partial_fts_index_finished, + key: :heavy_indexes_create_idx_tokens_ord_mcap_fiat_holder_name_finished, + key: :heavy_indexes_create_idx_tokens_ord_fiat_holder_name_finished, + key: :heavy_indexes_create_idx_tokens_ord_holder_name_finished, + key: :heavy_indexes_update_internal_transactions_primary_key_finished, + key: :empty_internal_transactions_data_finished, + key: :heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished, + key: :heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished, + key: :heavy_indexes_create_addresses_hash_contract_code_not_null_index_finished, + key: :heavy_indexes_create_address_ids_internal_transactions_indexes_finished, + key: :fill_internal_transactions_address_ids_finished @dialyzer :no_match @@ -64,168 +78,180 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do AddressTokenBalanceTokenType, ArbitrumDaRecordsNormalization, BackfillMultichainSearchDB, + EmptyInternalTransactionsData, + FillInternalTransactionsAddressIds, SanitizeDuplicatedLogIndexLogs, - SmartContractLanguage, TokenTransferTokenType, TransactionsDenormalization } alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateAddressesHashContractCodeNotNullIndex, CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, CreateAddressesTransactionsCountDescPartialIndex, CreateAddressesVerifiedFetchedCoinBalanceDescHashIndex, CreateAddressesVerifiedHashIndex, CreateAddressesVerifiedTransactionsCountDescHashIndex, CreateArbitrumBatchL2BlocksUnconfirmedBlocksIndex, + CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, CreateInternalTransactionsBlockNumberDescTransactionIndexDescIndexDescIndex, CreateLogsAddressHashBlockNumberDescIndexDescIndex, CreateLogsAddressHashFirstTopicBlockNumberIndexIndex, CreateLogsBlockHashIndex, CreateLogsDepositsWithdrawalsIndex, CreateSmartContractsLanguageIndex, + CreateTokensNamePartialFtsIndex, + CreateTokensOrdFiatHolderNameIndex, + CreateTokensOrdHolderNameIndex, + CreateTokensOrdMcapFiatHolderNameIndex, + CreateTransactionsCreatedContractAddressHashWPendingIndex, + DropInternalTransactionsCreatedContractAddressHashPartialIndex, DropInternalTransactionsFromAddressHashIndex, DropLogsAddressHashIndex, DropLogsAddressHashTransactionHashIndex, DropLogsBlockNumberAscIndexAscIndex, DropLogsIndexIndex, + DropTokenInstancesTokenIdIndex, DropTokenTransfersBlockNumberAscLogIndexAscIndex, DropTokenTransfersBlockNumberIndex, DropTokenTransfersFromAddressHashTransactionHashIndex, DropTokenTransfersToAddressHashTransactionHashIndex, DropTokenTransfersTokenContractAddressHashTransactionHashIndex, DropTransactionsCreatedContractAddressHashWithPendingIndex, + DropTransactionsCreatedContractAddressHashWithPendingIndexA, DropTransactionsFromAddressHashWithPendingIndex, - DropTransactionsToAddressHashWithPendingIndex + DropTransactionsToAddressHashWithPendingIndex, + UpdateInternalTransactionsPrimaryKey } defp handle_fallback(:transactions_denormalization_finished) do - start_migration_status_task( + set_and_return_migration_status( TransactionsDenormalization, &set_transactions_denormalization_finished/1 ) end defp handle_fallback(:tb_token_type_finished) do - start_migration_status_task( + set_and_return_migration_status( AddressTokenBalanceTokenType, &set_tb_token_type_finished/1 ) end defp handle_fallback(:ctb_token_type_finished) do - start_migration_status_task( + set_and_return_migration_status( AddressCurrentTokenBalanceTokenType, &set_ctb_token_type_finished/1 ) end defp handle_fallback(:tt_denormalization_finished) do - start_migration_status_task( + set_and_return_migration_status( TokenTransferTokenType, &set_tt_denormalization_finished/1 ) end defp handle_fallback(:sanitize_duplicated_log_index_logs_finished) do - start_migration_status_task( + set_and_return_migration_status( SanitizeDuplicatedLogIndexLogs, &set_sanitize_duplicated_log_index_logs_finished/1 ) end defp handle_fallback(:backfill_multichain_search_db_finished) do - start_migration_status_task( + set_and_return_migration_status( BackfillMultichainSearchDB, &set_backfill_multichain_search_db_finished/1 ) end defp handle_fallback(:heavy_indexes_create_logs_block_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateLogsBlockHashIndex, &set_heavy_indexes_create_logs_block_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_logs_block_number_asc_index_asc_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropLogsBlockNumberAscIndexAscIndex, &set_heavy_indexes_drop_logs_block_number_asc_index_asc_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_logs_address_hash_block_number_desc_index_desc_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateLogsAddressHashBlockNumberDescIndexDescIndex, &set_heavy_indexes_create_logs_address_hash_block_number_desc_index_desc_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_logs_address_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropLogsAddressHashIndex, &set_heavy_indexes_drop_logs_address_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_logs_address_hash_transaction_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropLogsAddressHashTransactionHashIndex, &set_heavy_indexes_drop_logs_address_hash_transaction_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_logs_index_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropLogsIndexIndex, &set_heavy_indexes_drop_logs_index_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_logs_address_hash_first_topic_block_number_index_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateLogsAddressHashFirstTopicBlockNumberIndexIndex, &set_heavy_indexes_create_logs_address_hash_first_topic_block_number_index_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_token_transfers_block_number_asc_log_index_asc_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropTokenTransfersBlockNumberAscLogIndexAscIndex, &set_heavy_indexes_drop_token_transfers_block_number_asc_log_index_asc_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_token_transfers_from_address_hash_transaction_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropTokenTransfersFromAddressHashTransactionHashIndex, &set_heavy_indexes_drop_token_transfers_from_address_hash_transaction_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_token_transfers_to_address_hash_transaction_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropTokenTransfersToAddressHashTransactionHashIndex, &set_heavy_indexes_drop_token_transfers_to_address_hash_transaction_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_token_transfers_token_contract_address_hash_transaction_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropTokenTransfersTokenContractAddressHashTransactionHashIndex, &set_heavy_indexes_drop_token_transfers_token_contract_address_hash_transaction_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_token_transfers_block_number_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropTokenTransfersBlockNumberIndex, &set_heavy_indexes_drop_token_transfers_block_number_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_internal_transactions_from_address_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( DropInternalTransactionsFromAddressHashIndex, &set_heavy_indexes_drop_internal_transactions_from_address_hash_index_finished/1 ) @@ -234,91 +260,84 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do defp handle_fallback( :heavy_indexes_create_internal_transactions_block_number_desc_transaction_index_desc_index_desc_index_finished ) do - start_migration_status_task( + set_and_return_migration_status( CreateInternalTransactionsBlockNumberDescTransactionIndexDescIndexDescIndex, &set_heavy_indexes_create_internal_transactions_block_number_desc_transaction_index_desc_index_desc_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_addresses_verified_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateAddressesVerifiedHashIndex, &set_heavy_indexes_create_addresses_verified_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_addresses_verified_transactions_count_desc_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateAddressesVerifiedTransactionsCountDescHashIndex, &set_heavy_indexes_create_addresses_verified_transactions_count_desc_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_addresses_verified_fetched_coin_balance_desc_hash_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateAddressesVerifiedFetchedCoinBalanceDescHashIndex, &set_heavy_indexes_create_addresses_verified_fetched_coin_balance_desc_hash_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_smart_contracts_language_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateSmartContractsLanguageIndex, &set_heavy_indexes_create_smart_contracts_language_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index) do - start_migration_status_task( + set_and_return_migration_status( DropTransactionsCreatedContractAddressHashWithPendingIndex, &set_heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_transactions_from_address_hash_with_pending_index) do - start_migration_status_task( + set_and_return_migration_status( DropTransactionsFromAddressHashWithPendingIndex, &set_heavy_indexes_drop_transactions_from_address_hash_with_pending_index_finished/1 ) end defp handle_fallback(:heavy_indexes_drop_transactions_to_address_hash_with_pending_index) do - start_migration_status_task( + set_and_return_migration_status( DropTransactionsToAddressHashWithPendingIndex, &set_heavy_indexes_drop_transactions_to_address_hash_with_pending_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_logs_deposits_withdrawals_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateLogsDepositsWithdrawalsIndex, &set_heavy_indexes_create_logs_deposits_withdrawals_index_finished/1 ) end defp handle_fallback(:arbitrum_da_records_normalization_finished) do - start_migration_status_task( + set_and_return_migration_status( ArbitrumDaRecordsNormalization, &set_arbitrum_da_records_normalization_finished/1 ) end - defp handle_fallback(:smart_contract_language_finished) do - start_migration_status_task( - SmartContractLanguage, - &set_smart_contract_language_finished/1 - ) - end - defp handle_fallback(:heavy_indexes_create_arbitrum_batch_l2_blocks_unconfirmed_blocks_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateArbitrumBatchL2BlocksUnconfirmedBlocksIndex, &set_heavy_indexes_create_arbitrum_batch_l2_blocks_unconfirmed_blocks_index_finished/1 ) end defp handle_fallback(:heavy_indexes_create_addresses_transactions_count_desc_partial_index_finished) do - start_migration_status_task( + set_and_return_migration_status( CreateAddressesTransactionsCountDescPartialIndex, &set_heavy_indexes_create_addresses_transactions_count_desc_partial_index_finished/1 ) @@ -327,21 +346,112 @@ defmodule Explorer.Chain.Cache.BackgroundMigrations do defp handle_fallback( :heavy_indexes_create_addresses_transactions_count_asc_coin_balance_desc_hash_partial_index_finished ) do - start_migration_status_task( + set_and_return_migration_status( CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex, &set_heavy_indexes_create_addresses_transactions_count_asc_coin_balance_desc_hash_partial_index_finished/1 ) end + defp handle_fallback(:heavy_indexes_drop_token_instances_token_id_index_finished) do + set_and_return_migration_status( + DropTokenInstancesTokenIdIndex, + &set_heavy_indexes_drop_token_instances_token_id_index_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_drop_internal_transactions_created_contract_address_hash_partial_index_finished) do + set_and_return_migration_status( + DropInternalTransactionsCreatedContractAddressHashPartialIndex, + &set_heavy_indexes_drop_internal_transactions_created_contract_address_hash_partial_index_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_tokens_name_partial_fts_index_finished) do + set_and_return_migration_status( + CreateTokensNamePartialFtsIndex, + &set_heavy_indexes_create_tokens_name_partial_fts_index_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_idx_tokens_ord_mcap_fiat_holder_name_finished) do + set_and_return_migration_status( + CreateTokensOrdMcapFiatHolderNameIndex, + &set_heavy_indexes_create_idx_tokens_ord_mcap_fiat_holder_name_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_idx_tokens_ord_fiat_holder_name_finished) do + set_and_return_migration_status( + CreateTokensOrdFiatHolderNameIndex, + &set_heavy_indexes_create_idx_tokens_ord_fiat_holder_name_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_idx_tokens_ord_holder_name_finished) do + set_and_return_migration_status( + CreateTokensOrdHolderNameIndex, + &set_heavy_indexes_create_idx_tokens_ord_holder_name_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_update_internal_transactions_primary_key_finished) do + set_and_return_migration_status( + UpdateInternalTransactionsPrimaryKey, + &set_heavy_indexes_update_internal_transactions_primary_key_finished/1 + ) + end + + defp handle_fallback(:empty_internal_transactions_data_finished) do + set_and_return_migration_status( + EmptyInternalTransactionsData, + &set_empty_internal_transactions_data_finished/1 + ) + end + + defp handle_fallback(:fill_internal_transactions_address_ids_finished) do + set_and_return_migration_status( + FillInternalTransactionsAddressIds, + &set_fill_internal_transactions_address_ids_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished) do + set_and_return_migration_status( + CreateTransactionsCreatedContractAddressHashWPendingIndex, + &set_heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished) do + set_and_return_migration_status( + DropTransactionsCreatedContractAddressHashWithPendingIndexA, + &set_heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_addresses_hash_contract_code_not_null_index_finished) do + set_and_return_migration_status( + CreateAddressesHashContractCodeNotNullIndex, + &set_heavy_indexes_create_addresses_hash_contract_code_not_null_index_finished/1 + ) + end + + defp handle_fallback(:heavy_indexes_create_address_ids_internal_transactions_indexes_finished) do + set_and_return_migration_status( + CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, + &set_heavy_indexes_create_address_ids_internal_transactions_indexes_finished/1 + ) + end + defp handle_fallback(:sanitize_verified_addresses_finished) do {:return, false} end - defp start_migration_status_task(migration_module, status_setter) do - Task.start_link(fn -> - status_setter.(migration_module.migration_finished?()) - end) + defp set_and_return_migration_status(migration_module, status_setter) do + status = migration_module.migration_finished?() - {:return, false} + status_setter.(status) + + {:return, status} end end diff --git a/apps/explorer/lib/explorer/chain/cache/block_number.ex b/apps/explorer/lib/explorer/chain/cache/block_number.ex index 0c1aeb722d76..d37b9f6d5883 100644 --- a/apps/explorer/lib/explorer/chain/cache/block_number.ex +++ b/apps/explorer/lib/explorer/chain/cache/block_number.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.BlockNumber do @moduledoc """ Cache for max and min block numbers. @@ -11,9 +12,10 @@ defmodule Explorer.Chain.Cache.BlockNumber do ttl_check_interval: Application.get_env(:explorer, __MODULE__)[:ttl_check_interval], global_ttl: Application.get_env(:explorer, __MODULE__)[:global_ttl] - alias Explorer.Chain + alias Explorer.Chain.Block - def handle_update(_key, nil, value), do: {:ok, value} + def handle_update(:min, nil, value), do: {:ok, min(fetch_from_db(:min), value)} + def handle_update(:max, nil, value), do: {:ok, max(fetch_from_db(:max), value)} def handle_update(:min, old_value, new_value), do: {:ok, min(new_value, old_value)} @@ -51,8 +53,8 @@ defmodule Explorer.Chain.Cache.BlockNumber do @spec fetch_from_db(:min | :max) :: non_neg_integer() defp fetch_from_db(key) do case key do - :min -> Chain.fetch_min_block_number() - :max -> Chain.fetch_max_block_number() + :min -> Block.fetch_min_block_number() + :max -> Block.fetch_max_block_number() end end end diff --git a/apps/explorer/lib/explorer/chain/cache/blocks.ex b/apps/explorer/lib/explorer/chain/cache/blocks.ex index ca968fb56d45..a35eef21f8b8 100644 --- a/apps/explorer/lib/explorer/chain/cache/blocks.ex +++ b/apps/explorer/lib/explorer/chain/cache/blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Blocks do @moduledoc """ Caches the last imported blocks @@ -38,4 +39,31 @@ defmodule Explorer.Chain.Cache.Blocks do end def drop_nonconsensus(number) when not is_nil(number), do: drop_nonconsensus([number]) + + @doc """ + Prepares a block for cache update by aggregating transaction metrics and clearing the transactions list. + + This function ensures that all transaction-related statistics are calculated + and stored in the block's virtual fields before removing the transactions + list. This optimization allows the block to retain computed aggregate data + while eliminating the need to store redundant transaction references that + are maintained separately in the database. + + The function first triggers transaction aggregation to compute metrics such + as transaction counts, total fees, burnt fees, and priority fees. After + aggregation is complete, the transactions list is cleared to reduce memory + usage. + + ## Parameters + - `block`: A Block struct that may contain transactions to be aggregated + + ## Returns + - Block struct with aggregated transaction metrics and an empty transactions list + """ + @spec sanitize_before_update(Block.t()) :: Block.t() + def sanitize_before_update(block) do + block = Block.aggregate_transactions(block) + + %{block | transactions: []} + end end diff --git a/apps/explorer/lib/explorer/chain/cache/celo_core_contracts.ex b/apps/explorer/lib/explorer/chain/cache/celo_core_contracts.ex index d822a5fae709..9a20a5936007 100644 --- a/apps/explorer/lib/explorer/chain/cache/celo_core_contracts.ex +++ b/apps/explorer/lib/explorer/chain/cache/celo_core_contracts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.CeloCoreContracts do @moduledoc """ Cache for Celo core contract addresses. diff --git a/apps/explorer/lib/explorer/chain/cache/celo_epochs.ex b/apps/explorer/lib/explorer/chain/cache/celo_epochs.ex new file mode 100644 index 000000000000..cbfe207f99b8 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/celo_epochs.ex @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.CeloEpochs do + @moduledoc """ + Cache for efficiently mapping block numbers to epoch numbers in Celo blockchain. + + This implementation uses: + 1. A direct mathematical calculation for pre-migration epochs + 2. An ordered cache of post-migration epochs for efficient lookups + """ + + use Explorer.Chain.OrderedCache, + name: :celo_epochs_cache, + ttl_check_interval: :timer.minutes(1), + global_ttl: :timer.minutes(5), + # Adjust based on expected number of post-migration epochs + max_size: 256 + + @type element :: %{ + number: non_neg_integer(), + start_block_number: non_neg_integer(), + end_block_number: non_neg_integer() | nil + } + + @type id :: non_neg_integer() + + import Ecto.Query, only: [select: 3] + + alias Explorer.Chain + alias Explorer.Chain.Block + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Celo.{Epoch, Helper} + + @impl Explorer.Chain.OrderedCache + def element_to_id(epoch) when is_map(epoch), do: epoch.number + + @doc """ + Gets the epoch number for a given block number. Uses mathematical formula for + pre-migration blocks and cached data for post-migration blocks. + """ + def block_number_to_epoch_number(block_number) do + block_number + |> Helper.pre_migration_block_number?() + |> if do + # For pre-migration blocks, use the mathematical formula + Helper.block_number_to_epoch_number(block_number) + else + # For post-migration blocks, use the ordered cache + fetch_post_migration_epoch_number(block_number) + end + end + + @doc """ + Retrieves the epoch number of the last fetched block. + """ + @spec last_block_epoch_number() :: Block.block_number() + def last_block_epoch_number do + BlockNumber.get_max() |> block_number_to_epoch_number() + end + + @spec fetch_post_migration_epoch_number(non_neg_integer()) :: non_neg_integer() | nil + defp fetch_post_migration_epoch_number(block_number) do + with {:cache, nil} <- {:cache, fetch_epoch_from_cache(block_number)}, + {:db, nil} <- {:db, fetch_epoch_from_db(block_number)} do + nil + else + {source, epoch} -> + # If the epoch is found in the database, update the cache + if source == :db do + update(epoch) + end + + epoch.number + end + end + + @spec fetch_epoch_from_cache(non_neg_integer()) :: element() | nil + defp fetch_epoch_from_cache(block_number) do + Enum.find(all(), fn + %{end_block_number: nil} = epoch -> + epoch.start_block_number <= block_number + + epoch -> + epoch.start_block_number <= block_number and + block_number <= epoch.end_block_number + end) + end + + @spec fetch_epoch_from_db(non_neg_integer()) :: element() | nil + defp fetch_epoch_from_db(block_number) do + block_number + |> Epoch.block_number_to_epoch_query() + |> select([e], %{ + number: e.number, + start_block_number: e.start_block_number, + end_block_number: e.end_block_number + }) + |> Chain.select_repo(api?: true).one() + end +end diff --git a/apps/explorer/lib/explorer/chain/cache/chain_id.ex b/apps/explorer/lib/explorer/chain/cache/chain_id.ex index dd4636b0592a..67cbfecaad8e 100644 --- a/apps/explorer/lib/explorer/chain/cache/chain_id.ex +++ b/apps/explorer/lib/explorer/chain/cache/chain_id.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.ChainId do @moduledoc """ Caches the blockchain's chain ID to reduce repeated JSON-RPC calls. @@ -5,6 +6,8 @@ defmodule Explorer.Chain.Cache.ChainId do The chain ID is fetched from the node using `eth_chainId` JSON-RPC call when the cache is empty. This helps improve performance by avoiding repeated RPC calls for this frequently needed value. + + If the chain ID cannot be got from the RPC, the `CHAIN_ID` env variable is used. """ require Logger @@ -20,10 +23,22 @@ defmodule Explorer.Chain.Cache.ChainId do {:error, reason} -> Logger.debug([ - "Couldn't fetch eth_chainId, reason: #{inspect(reason)}" + "Couldn't fetch eth_chainId. CHAIN_ID env will be used instead. Reason: #{inspect(reason)}" ]) - {:return, nil} + return = + case Application.get_env(:block_scout_web, :chain_id) do + nil -> + nil + + chain_id -> + chain_id + |> to_string() + |> String.trim() + |> String.to_integer() + end + + {:return, return} end end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex index d7c99643b103..f6ee0c32bcb2 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_tabs_elements_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressTabsElementsCount do @moduledoc """ Cache for tabs counters on address @@ -19,6 +20,7 @@ defmodule Explorer.Chain.Cache.Counters.AddressTabsElementsCount do | :logs | :withdrawals | :internal_transactions + | :beacon_deposits @typep response_status :: :limit_value | :stale | :up_to_date @spec get_counter(counter_type, String.t()) :: {DateTime.t(), non_neg_integer(), response_status} | nil diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex index 1a4cb51c033f..225b4922886a 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_token_transfers_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressTokenTransfersCount do @moduledoc """ Caches Address token transfers count. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_tokens_usd_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_tokens_usd_sum.ex index 6138737e5048..b929b91938a9 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_tokens_usd_sum.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_tokens_usd_sum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressTokensUsdSum do @moduledoc """ Caches Address tokens USD value. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex index 2611d7840818..7ff8aece4e1d 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressTransactionsCount do @moduledoc """ Caches Address transactions count. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/address_gas_usage_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex similarity index 98% rename from apps/explorer/lib/explorer/chain/cache/counters/address_gas_usage_sum.ex rename to apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex index 295f949e69d9..fa9bda0ee3eb 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/address_gas_usage_sum.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/address_transactions_gas_usage_sum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressTransactionsGasUsageSum do @moduledoc """ Caches Address transactions gas usage count. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum.ex index 6bbf742342b9..3dc029b2b838 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressesCoinBalanceSum do @moduledoc """ Cache for address sum. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum_minus_burnt.ex b/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum_minus_burnt.ex index 418c41947abb..189922efc898 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum_minus_burnt.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/addresses_coin_balance_sum_minus_burnt.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressesCoinBalanceSumMinusBurnt do @moduledoc """ Cache for address sum minus burnt number. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/addresses_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/addresses_count.ex index 6e58591b4c16..a731bb4192e1 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/addresses_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/addresses_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressesCount do @moduledoc """ Caches the number of all addresses. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/average_block_time.ex b/apps/explorer/lib/explorer/chain/cache/counters/average_block_time.ex index 7d6e715c7cce..a8acfb00ebad 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/average_block_time.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/average_block_time.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AverageBlockTime do @moduledoc """ Caches the average block time in milliseconds. @@ -11,7 +12,6 @@ defmodule Explorer.Chain.Cache.Counters.AverageBlockTime do alias Explorer.Repo alias Timex.Duration - @num_of_blocks 100 @offset 100 @doc """ @@ -73,10 +73,13 @@ defmodule Explorer.Chain.Cache.Counters.AverageBlockTime do first_block_from_config = RangesHelper.get_min_block_number_from_range_string(Application.get_env(:indexer, :block_ranges)) + num_of_blocks = + Application.get_env(:explorer, __MODULE__)[:num_of_blocks] + base_query = from(block in Block, where: block.number > ^first_block_from_config, - limit: ^@num_of_blocks, + limit: ^num_of_blocks, offset: ^@offset, order_by: [desc: block.number], select: {block.number, block.timestamp} @@ -96,7 +99,14 @@ defmodule Explorer.Chain.Cache.Counters.AverageBlockTime do timestamps = raw_timestamps - |> Enum.sort_by(fn {_, timestamp} -> timestamp end, &Timex.after?/2) + |> Enum.sort(fn + {number_a, timestamp_a}, {number_b, timestamp_b} -> + case Timex.compare(timestamp_a, timestamp_b) do + 1 -> true + -1 -> false + 0 -> number_a > number_b + end + end) |> Enum.map(fn {number, timestamp} -> {number, DateTime.to_unix(timestamp, :millisecond)} end) diff --git a/apps/explorer/lib/explorer/chain/cache/counters/blackfort/validators_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/blackfort/validators_count.ex index 8c2ad1f76f39..d2f5e70bd0ec 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/blackfort/validators_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/blackfort/validators_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Blackfort.ValidatorsCount do @moduledoc """ Counts and store counters of validators blackfort. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/block_burnt_fee_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/block_burnt_fee_count.ex index 4bf6ee239553..2e7be46ae4fb 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/block_burnt_fee_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/block_burnt_fee_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.BlockBurntFeeCount do @moduledoc """ Caches Block Burnt Fee counter. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/block_priority_fee_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/block_priority_fee_count.ex index 54c323f5ec2a..5872d6ab09ff 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/block_priority_fee_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/block_priority_fee_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.BlockPriorityFeeCount do @moduledoc """ Caches Block Priority Fee counter. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/blocks_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/blocks_count.ex index 73968157a01c..8967cfc26301 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/blocks_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/blocks_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.BlocksCount do @moduledoc """ Cache for total blocks count. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/contracts_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/contracts_count.ex index ca6f08f48d2b..cc872cadf956 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/contracts_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/contracts_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.ContractsCount do @moduledoc """ Caches the number of contracts. @@ -15,8 +16,11 @@ defmodule Explorer.Chain.Cache.Counters.ContractsCount do enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]], update_interval_in_milliseconds: [:explorer, [__MODULE__, :update_interval_in_milliseconds]] - alias Explorer.Chain + import Ecto.Query, only: [from: 2] + + alias Explorer.Chain.Address alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Repo @counter_type "contracts_count" @@ -69,7 +73,7 @@ defmodule Explorer.Chain.Cache.Counters.ContractsCount do Consolidates the info by populating the `last_fetched_counters` table with the current database information. """ def consolidate do - all_counter = Chain.count_contracts() + all_counter = count_contracts() params = %{ counter_type: @counter_type, @@ -92,4 +96,15 @@ defmodule Explorer.Chain.Cache.Counters.ContractsCount do `config :explorer, Explorer.Chain.Cache.Counters.ContractsCount, enable_consolidation: false` """ def enable_consolidation?, do: @enable_consolidation + + defp count_contracts do + query = + from(address in Address, + select: address, + where: not is_nil(address.contract_code) + ) + + query + |> Repo.aggregate(:count, timeout: :infinity) + end end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex index bdb1f6d91a75..3c5d4e194d48 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/gas_usage_sum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.GasUsageSum do @moduledoc """ Cache for total gas usage. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/helper.ex b/apps/explorer/lib/explorer/chain/cache/counters/helper.ex index 846b7714c140..9a74b74b78d6 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/helper.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/helper.ex @@ -1,9 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Helper do @moduledoc """ A helper for caching modules """ alias Explorer.Chain + alias Explorer.Chain.Block alias Explorer.Chain.Cache.Counters.LastFetchedCounter @block_number_threshold_1 10_000 @@ -119,7 +121,7 @@ defmodule Explorer.Chain.Cache.Counters.Helper do def evaluate_count(cache_key, nil, estimated_count_fun) do cached_value_from_db = cache_key - |> LastFetchedCounter.get() + |> LastFetchedCounter.get(api?: true) |> case do nil -> 0 value -> Decimal.to_integer(value) @@ -177,7 +179,7 @@ defmodule Explorer.Chain.Cache.Counters.Helper do @spec ttl(atom, String.t()) :: non_neg_integer() def ttl(module, management_variable) do min_blockchain_block_number = Application.get_env(:indexer, :first_block) - max_block_number = Chain.fetch_max_block_number() + max_block_number = Block.fetch_max_block_number() blocks_amount = max_block_number - min_blockchain_block_number global_ttl_from_var = Application.get_env(:explorer, module)[:global_ttl] @@ -263,4 +265,21 @@ defmodule Explorer.Chain.Cache.Counters.Helper do if is_nil(count), do: 0, else: max(count, 0) end + + @doc """ + Returns the estimated count of pending transaction operations. + + This function retrieves the estimated count from the "pending_transaction_operations" cache. + If the count is `nil`, it returns `0`. Otherwise, it ensures the count is non-negative + by returning the maximum of the count and `0`. + + ## Returns + - `integer`: The estimated count of pending transaction operations, or `0` if the count is `nil`. + """ + @spec estimated_pending_transaction_operations_count() :: non_neg_integer() + def estimated_pending_transaction_operations_count do + count = estimated_count_from("pending_transaction_operations") + + if is_nil(count), do: 0, else: max(count, 0) + end end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/helper/average_block_time_duration_format.ex b/apps/explorer/lib/explorer/chain/cache/counters/helper/average_block_time_duration_format.ex index 9ed023af4199..6b2f9a47ba81 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/helper/average_block_time_duration_format.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/helper/average_block_time_duration_format.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Helper.AverageBlockTimeDurationFormat do @moduledoc """ A `Timex.Format.Duration.Formatter` that renders the most significant unit out to one decimal point. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/last_fetched_counter.ex b/apps/explorer/lib/explorer/chain/cache/counters/last_fetched_counter.ex index 1cc64a7b3fad..53908a907be7 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/last_fetched_counter.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/last_fetched_counter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.LastFetchedCounter do @moduledoc """ Stores last fetched counters. @@ -76,6 +77,28 @@ defmodule Explorer.Chain.Cache.Counters.LastFetchedCounter do ) end + @doc """ + Deletes a record of the given type. + + ## Parameters + - `type`: The type of the counter to delete. + + ## Returns + - `true` if the record was successfully deleted. + - `false` if the record is not found. + """ + @spec delete(binary()) :: boolean() + def delete(type) do + query = + from(counter in __MODULE__, + where: counter.counter_type == ^type + ) + + {count, _} = Repo.delete_all(query) + + count > 0 + end + @doc """ Fetches the last fetched counter value for the given `type`. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/new_contracts_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/new_contracts_count.ex index 690939e094db..760198d06857 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/new_contracts_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/new_contracts_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.NewContractsCount do @moduledoc """ Caches the number of new contracts (created in last 24 hours). @@ -15,8 +16,11 @@ defmodule Explorer.Chain.Cache.Counters.NewContractsCount do enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]], update_interval_in_milliseconds: [:explorer, [__MODULE__, :update_interval_in_milliseconds]] - alias Explorer.Chain + import Ecto.Query, only: [from: 2] + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.Transaction + alias Explorer.Repo @counter_type "new_contracts_count" @@ -70,7 +74,7 @@ defmodule Explorer.Chain.Cache.Counters.NewContractsCount do Consolidates the info by populating the `last_fetched_counters` table with the current database information. """ def consolidate do - new_all_counter = Chain.count_new_contracts() + new_all_counter = count_new_contracts() params = %{ counter_type: @counter_type, @@ -93,4 +97,20 @@ defmodule Explorer.Chain.Cache.Counters.NewContractsCount do `config :explorer, Explorer.Chain.Cache.Counters.NewContractsCount, enable_consolidation: false` """ def enable_consolidation?, do: @enable_consolidation + + defp count_new_contracts do + query = + from(transaction in Transaction, + select: transaction, + where: + transaction.status == ^:ok and + fragment( + "NOW() - ? at time zone 'UTC' <= interval '24 hours'", + transaction.created_contract_code_indexed_at + ) + ) + + query + |> Repo.aggregate(:count, timeout: :infinity) + end end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/new_pending_transactions_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/new_pending_transactions_count.ex index a711c851d4fc..5d7073cf2db2 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/new_pending_transactions_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/new_pending_transactions_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.NewPendingTransactionsCount do @moduledoc """ Caches number of pending transactions for last 30 minutes. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/new_verified_contracts_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/new_verified_contracts_count.ex index e4313d6fac32..47102bf02ae6 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/new_verified_contracts_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/new_verified_contracts_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.NewVerifiedContractsCount do @moduledoc """ Caches the number of new verified contracts (verified in last 24 hours). @@ -15,8 +16,11 @@ defmodule Explorer.Chain.Cache.Counters.NewVerifiedContractsCount do enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]], update_interval_in_milliseconds: [:explorer, [__MODULE__, :update_interval_in_milliseconds]] - alias Explorer.Chain alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.SmartContract + alias Explorer.Repo + + import Ecto.Query, only: [from: 2] @counter_type "new_verified_contracts_count" @@ -70,7 +74,7 @@ defmodule Explorer.Chain.Cache.Counters.NewVerifiedContractsCount do Consolidates the info by populating the `last_fetched_counters` table with the current database information. """ def consolidate do - new_verified_counter = Chain.count_new_verified_contracts() + new_verified_counter = count_new_verified_contracts() params = %{ counter_type: @counter_type, @@ -93,4 +97,14 @@ defmodule Explorer.Chain.Cache.Counters.NewVerifiedContractsCount do `config :explorer, Explorer.Chain.Cache.Counters.NewVerifiedContractsCount, enable_consolidation: false` """ def enable_consolidation?, do: @enable_consolidation + + defp count_new_verified_contracts do + query = + from(contract in SmartContract, + where: fragment("NOW() - ? <= interval '24 hours'", contract.inserted_at) + ) + + query + |> Repo.aggregate(:count, timeout: :infinity) + end end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/optimism/last_output_root_size_counter.ex b/apps/explorer/lib/explorer/chain/cache/counters/optimism/last_output_root_size_counter.ex index 8017bc4f6b9d..6cc926b085a2 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/optimism/last_output_root_size_counter.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/optimism/last_output_root_size_counter.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCount do @moduledoc """ Caches number of transactions in last output root. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/pending_block_operation_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/pending_block_operation_count.ex index db043acfd741..d180a545f01b 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/pending_block_operation_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/pending_block_operation_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.PendingBlockOperationCount do @moduledoc """ Cache for estimated `pending_block_operations` count. @@ -13,10 +14,11 @@ defmodule Explorer.Chain.Cache.Counters.PendingBlockOperationCount do require Logger + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Cache.Counters.Helper, as: CacheCountersHelper alias Explorer.Chain.Cache.Counters.LastFetchedCounter alias Explorer.Chain.PendingBlockOperation - alias Explorer.Repo @cache_key "pending_block_operations_count" @@ -46,7 +48,10 @@ defmodule Explorer.Chain.Cache.Counters.PendingBlockOperationCount do {:ok, task} = Task.start_link(fn -> try do - result = Repo.aggregate(PendingBlockOperation, :count, timeout: :infinity) + min_blockchain_trace_block_number = + RangesHelper.get_min_block_number_from_range_string(Application.get_env(:indexer, :trace_block_ranges)) + + result = PendingBlockOperation.blocks_count_in_range(min_blockchain_trace_block_number, BlockNumber.get_max()) params = %{ counter_type: @cache_key, @@ -55,7 +60,10 @@ defmodule Explorer.Chain.Cache.Counters.PendingBlockOperationCount do LastFetchedCounter.upsert(params) - set_count(%ConCache.Item{ttl: CacheCountersHelper.ttl(__MODULE__, "CACHE_PBO_COUNT_PERIOD"), value: result}) + set_count(%ConCache.Item{ + ttl: CacheCountersHelper.ttl(__MODULE__, "CACHE_PENDING_OPERATIONS_COUNT_PERIOD"), + value: result + }) rescue e -> Logger.debug([ diff --git a/apps/explorer/lib/explorer/chain/cache/counters/pending_transaction_operation_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/pending_transaction_operation_count.ex new file mode 100644 index 000000000000..f0c8c15dad50 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/counters/pending_transaction_operation_count.ex @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.Counters.PendingTransactionOperationCount do + @moduledoc """ + Cache for estimated `pending_transaction_operations` count. + """ + + use Explorer.Chain.MapCache, + name: :pending_transaction_operations_count, + key: :count, + key: :async_task, + global_ttl: Application.get_env(:explorer, __MODULE__)[:global_ttl], + ttl_check_interval: :timer.seconds(1), + callback: &async_task_on_deletion(&1) + + require Logger + + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.Counters.Helper, as: CacheCountersHelper + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.PendingTransactionOperation + + @cache_key "pending_transaction_operations_count" + + @doc """ + Gets count of `t:Explorer.Chain.PendingTransactionOperation.t/0`. + + """ + @spec get() :: non_neg_integer() + def get do + cached_value_from_ets = __MODULE__.get_count() + + CacheCountersHelper.evaluate_count( + @cache_key, + cached_value_from_ets, + :estimated_pending_transaction_operations_count + ) + end + + defp handle_fallback(:count) do + # This will get the task PID if one exists, check if it's running and launch + # a new task if task doesn't exist or it's not running. + # See next `handle_fallback` definition + safe_get_async_task() + + {:return, nil} + end + + defp handle_fallback(:async_task) do + # If this gets called it means an async task was requested, but none exists + # so a new one needs to be launched + {:ok, task} = + Task.start_link(fn -> + try do + min_blockchain_trace_block_number = + RangesHelper.get_min_block_number_from_range_string(Application.get_env(:indexer, :trace_block_ranges)) + + result = + PendingTransactionOperation.blocks_count_in_range(min_blockchain_trace_block_number, BlockNumber.get_max()) + + params = %{ + counter_type: @cache_key, + value: result + } + + LastFetchedCounter.upsert(params) + + set_count(%ConCache.Item{ + ttl: CacheCountersHelper.ttl(__MODULE__, "CACHE_PENDING_OPERATIONS_COUNT_PERIOD"), + value: result + }) + rescue + e -> + Logger.debug([ + "Couldn't update pending_transaction_operations count: ", + Exception.format(:error, e, __STACKTRACE__) + ]) + end + + set_async_task(nil) + end) + + {:update, task} + end + + # By setting this as a `callback` an async task will be started each time the + # `count` expires (unless there is one already running) + defp async_task_on_deletion({:delete, _, :count}), do: safe_get_async_task() + + defp async_task_on_deletion(_data), do: nil +end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/rootstock/locked_btc_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/rootstock/locked_btc_count.ex index f6187dc0a1c3..6add07c1df0e 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/rootstock/locked_btc_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/rootstock/locked_btc_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Rootstock.LockedBTCCount do @moduledoc """ Caches the number of BTC locked in 2WP on Rootstock chain. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/shibarium/deposits_and_withdrawals_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/shibarium/deposits_and_withdrawals_count.ex index 6bcb441e8a34..3a883b1f542e 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/shibarium/deposits_and_withdrawals_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/shibarium/deposits_and_withdrawals_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Shibarium.DepositsAndWithdrawalsCount do @moduledoc """ Caches the number of deposits and withdrawals for Shibarium Bridge. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/stability/validators_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/stability/validators_count.ex index ea9b43a464e6..403d733dd306 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/stability/validators_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/stability/validators_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Stability.ValidatorsCount do @moduledoc """ Counts and store counters of validators stability. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex index 6d861d3dbc28..75860cef8d66 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/token_holders_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.TokenHoldersCount do @moduledoc """ Caches Token holders count. @@ -7,7 +8,7 @@ defmodule Explorer.Chain.Cache.Counters.TokenHoldersCount do alias Explorer.Chain.Address.CurrentTokenBalance alias Explorer.Chain.Cache.Counters.Helper - alias Explorer.Chain.Token + alias Explorer.Chain.{Hash, Token} @api_true [api?: true] @cache_name :token_holders_count @@ -42,9 +43,7 @@ defmodule Explorer.Chain.Cache.Counters.TokenHoldersCount do def fetch(address_hash) do if cache_expired?(address_hash) do - Task.start_link(fn -> - update_cache(address_hash) - end) + update_cache(address_hash) end fetch_count_from_cache(address_hash) @@ -71,6 +70,10 @@ defmodule Explorer.Chain.Cache.Counters.TokenHoldersCount do put_into_db_cache(address_hash, new_data) end + @doc """ + Fetches the token holders count from the cache or database. + """ + @spec fetch_count_from_cache(Hash.Address.t()) :: integer() def fetch_count_from_cache(address_hash) do address_hash_string = to_string(address_hash) key = "hash_#{address_hash_string}" @@ -78,19 +81,19 @@ defmodule Explorer.Chain.Cache.Counters.TokenHoldersCount do Helper.fetch_from_ets_cache(@cache_name, key) || fetch_from_db_cache(address_hash) end - def fetch_updated_at_from_cache(address_hash, cache_name) do + defp fetch_updated_at_from_cache(address_hash, cache_name) do address_hash_string = to_string(address_hash) key = "hash_#{address_hash_string}_#{@ets_last_update_key}" Helper.fetch_from_ets_cache(cache_name, key) end - def fetch_from_db_cache(address_hash) do + defp fetch_from_db_cache(address_hash) do token = Token.get_by_contract_address_hash(address_hash, @api_true) - token.holder_count || 0 + (token && token.holder_count) || 0 end - def put_into_db_cache(address_hash, count) do + defp put_into_db_cache(address_hash, count) do Token.update_token_holder_count(address_hash, count) end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex index 97db13c3c729..c70d6a38fa86 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/token_transfers_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.TokenTransfersCount do @moduledoc """ Caches Token transfers counter. @@ -7,9 +8,11 @@ defmodule Explorer.Chain.Cache.Counters.TokenTransfersCount do alias Explorer.Chain alias Explorer.Chain.Cache.Counters.Helper + alias Explorer.Chain.{Hash, Token} + @api_true [api?: true] @cache_name :token_transfers_counter - @last_update_key "last_update" + @ets_last_update_key "last_update" @spec start_link(term()) :: GenServer.on_start() def start_link(_) do @@ -43,16 +46,14 @@ defmodule Explorer.Chain.Cache.Counters.TokenTransfersCount do update_cache(address_hash) end - address_hash_string = to_string(address_hash) - fetch_from_cache("hash_#{address_hash_string}") + fetch_count_from_cache(address_hash) end def cache_name, do: @cache_name defp cache_expired?(address_hash) do cache_period = Application.get_env(:explorer, __MODULE__)[:cache_period] - address_hash_string = to_string(address_hash) - updated_at = fetch_from_cache("hash_#{address_hash_string}_#{@last_update_key}") + updated_at = fetch_updated_at_from_cache(address_hash, @cache_name) cond do is_nil(updated_at) -> true @@ -63,13 +64,37 @@ defmodule Explorer.Chain.Cache.Counters.TokenTransfersCount do defp update_cache(address_hash) do address_hash_string = to_string(address_hash) - Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@last_update_key}", Helper.current_time()) new_data = Chain.count_token_transfers_from_token_hash(address_hash) Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}", new_data) + Helper.put_into_ets_cache(@cache_name, "hash_#{address_hash_string}_#{@ets_last_update_key}", Helper.current_time()) + put_into_db_cache(address_hash, new_data) + end + + @doc """ + Fetches the token transfers count from the cache or database. + """ + @spec fetch_count_from_cache(Hash.Address.t()) :: integer() + def fetch_count_from_cache(address_hash) do + address_hash_string = to_string(address_hash) + key = "hash_#{address_hash_string}" + + Helper.fetch_from_ets_cache(@cache_name, key) || fetch_from_db_cache(address_hash) + end + + defp fetch_updated_at_from_cache(address_hash, cache_name) do + address_hash_string = to_string(address_hash) + key = "hash_#{address_hash_string}_#{@ets_last_update_key}" + + Helper.fetch_from_ets_cache(cache_name, key) + end + + defp fetch_from_db_cache(address_hash) do + token = Token.get_by_contract_address_hash(address_hash, @api_true) + (token && token.transfer_count) || 0 end - defp fetch_from_cache(key) do - Helper.fetch_from_ets_cache(@cache_name, key) + defp put_into_db_cache(address_hash, count) do + Token.update_token_transfer_count(address_hash, count) end defp enable_consolidation?, do: @enable_consolidation diff --git a/apps/explorer/lib/explorer/chain/cache/counters/transactions_24h_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/transactions_24h_count.ex index 3c137f7f9d68..ffedf8f03866 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/transactions_24h_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/transactions_24h_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Transactions24hCount do @moduledoc """ Caches number of transactions for last 24 hours, sum of transaction fees for last 24 hours and average transaction fee for last 24 hours counters. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex index 4f1038d7c9e6..3553ce2c5e93 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/transactions_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.TransactionsCount do @moduledoc """ Cache for total transactions count. diff --git a/apps/explorer/lib/explorer/chain/cache/counters/verified_contracts_count.ex b/apps/explorer/lib/explorer/chain/cache/counters/verified_contracts_count.ex index 0989d21286fe..ab93c5a47302 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/verified_contracts_count.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/verified_contracts_count.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.VerifiedContractsCount do @moduledoc """ Caches the number of verified contracts. @@ -15,8 +16,9 @@ defmodule Explorer.Chain.Cache.Counters.VerifiedContractsCount do enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]], update_interval_in_milliseconds: [:explorer, [__MODULE__, :update_interval_in_milliseconds]] - alias Explorer.Chain alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.SmartContract + alias Explorer.Repo @counter_type "verified_contracts_count" @@ -69,7 +71,7 @@ defmodule Explorer.Chain.Cache.Counters.VerifiedContractsCount do Consolidates the info by populating the `last_fetched_counters` table with the current database information. """ def consolidate do - verified_counter = Chain.count_verified_contracts() + verified_counter = count_verified_contracts() params = %{ counter_type: @counter_type, @@ -92,4 +94,8 @@ defmodule Explorer.Chain.Cache.Counters.VerifiedContractsCount do `config :explorer, Explorer.Chain.Cache.Counters.VerifiedContractsCount, enable_consolidation: false` """ def enable_consolidation?, do: @enable_consolidation + + defp count_verified_contracts do + Repo.aggregate(SmartContract, :count, timeout: :infinity) + end end diff --git a/apps/explorer/lib/explorer/chain/cache/counters/withdrawals_sum.ex b/apps/explorer/lib/explorer/chain/cache/counters/withdrawals_sum.ex index d3d3443e26ee..52168be712c6 100644 --- a/apps/explorer/lib/explorer/chain/cache/counters/withdrawals_sum.ex +++ b/apps/explorer/lib/explorer/chain/cache/counters/withdrawals_sum.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.WithdrawalsSum do @moduledoc """ Caches the sum of all withdrawals. @@ -11,9 +12,9 @@ defmodule Explorer.Chain.Cache.Counters.WithdrawalsSum do enable_consolidation: [:explorer, [__MODULE__, :enable_consolidation]], update_interval_in_milliseconds: [:explorer, [__MODULE__, :update_interval_in_milliseconds]] - alias Explorer.Chain alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Explorer.Chain.Wei + alias Explorer.Chain.{Wei, Withdrawal} + alias Explorer.Repo @counter_type "withdrawals_sum" @@ -66,7 +67,7 @@ defmodule Explorer.Chain.Cache.Counters.WithdrawalsSum do Consolidates the info by populating the `last_fetched_counters` table with the current database information. """ def consolidate do - withdrawals_sum = Chain.sum_withdrawals() + withdrawals_sum = sum_withdrawals() params = %{ counter_type: @counter_type, @@ -89,4 +90,8 @@ defmodule Explorer.Chain.Cache.Counters.WithdrawalsSum do `config :explorer, #{__MODULE__}, enable_consolidation: false` """ def enable_consolidation?, do: @enable_consolidation + + defp sum_withdrawals do + Repo.aggregate(Withdrawal, :sum, :amount, timeout: :infinity) + end end diff --git a/apps/explorer/lib/explorer/chain/cache/gas_price_oracle.ex b/apps/explorer/lib/explorer/chain/cache/gas_price_oracle.ex index 11f28389e1a5..b25c0cacad79 100644 --- a/apps/explorer/lib/explorer/chain/cache/gas_price_oracle.ex +++ b/apps/explorer/lib/explorer/chain/cache/gas_price_oracle.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.GasPriceOracle do @moduledoc """ Cache for gas price oracle (safelow/average/fast gas prices). @@ -5,9 +6,13 @@ defmodule Explorer.Chain.Cache.GasPriceOracle do require Logger + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import Ecto.Query, only: [ - from: 2 + from: 2, + where: 3 ] alias Explorer.Chain.{Block, Wei} @@ -98,6 +103,7 @@ defmodule Explorer.Chain.Cache.GasPriceOracle do from( block in Block, left_join: transaction in assoc(block, :transactions), + as: :transaction, where: block.consensus == true, where: is_nil(transaction.gas_price) or transaction.gas_price > ^0, where: transaction.block_number > ^from_block_query, @@ -168,7 +174,11 @@ defmodule Explorer.Chain.Cache.GasPriceOracle do } ) - new_acc = fee_query |> Repo.all(timeout: :infinity) |> merge_gas_prices(acc, from_block_actual) + new_acc = + fee_query + |> filter_unknown_transaction_types(chain_identity()) + |> Repo.all(timeout: :infinity) + |> merge_gas_prices(acc, from_block_actual) gas_prices = new_acc |> process_fee_data_from_db() @@ -179,6 +189,14 @@ defmodule Explorer.Chain.Cache.GasPriceOracle do {{:error, error}, get_gas_prices_acc()} end + defp filter_unknown_transaction_types(query, {:optimism, :celo}) do + query |> where([transaction: t], is_nil(t.type) or t.type != 123) + end + + defp filter_unknown_transaction_types(query, _chain_type) do + query + end + defp merge_gas_prices(new, acc, from_block), do: Enum.take_while(new ++ acc, &(&1.block_number > from_block)) defp process_fee_data_from_db([]) do diff --git a/apps/explorer/lib/explorer/chain/cache/latest_l1_block_number.ex b/apps/explorer/lib/explorer/chain/cache/latest_l1_block_number.ex index d7d675b0eef3..1b9bbb102a8d 100644 --- a/apps/explorer/lib/explorer/chain/cache/latest_l1_block_number.ex +++ b/apps/explorer/lib/explorer/chain/cache/latest_l1_block_number.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.LatestL1BlockNumber do @moduledoc """ Caches latest L1 block number. @@ -11,5 +12,7 @@ defmodule Explorer.Chain.Cache.LatestL1BlockNumber do @dialyzer :no_match + defp handle_fallback(:update), do: {:update, nil} + defp handle_fallback(_key), do: {:return, nil} end diff --git a/apps/explorer/lib/explorer/chain/cache/min_missing_block.ex b/apps/explorer/lib/explorer/chain/cache/min_missing_block.ex index 362a13586190..fc031c9b3f58 100644 --- a/apps/explorer/lib/explorer/chain/cache/min_missing_block.ex +++ b/apps/explorer/lib/explorer/chain/cache/min_missing_block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.MinMissingBlockNumber do @moduledoc """ Caches min missing block number (break in the chain). diff --git a/apps/explorer/lib/explorer/chain/cache/optimism_finalization_period.ex b/apps/explorer/lib/explorer/chain/cache/optimism_finalization_period.ex index 7ce8d834368f..b1433382fa95 100644 --- a/apps/explorer/lib/explorer/chain/cache/optimism_finalization_period.ex +++ b/apps/explorer/lib/explorer/chain/cache/optimism_finalization_period.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.OptimismFinalizationPeriod do @moduledoc """ Caches Optimism Finalization period. @@ -22,7 +23,7 @@ defmodule Explorer.Chain.Cache.OptimismFinalizationPeriod do # call FINALIZATION_PERIOD_SECONDS() public getter of L2OutputOracle contract on L1 request = Contract.eth_call_request("0xf4daa291", output_oracle, 0, nil, nil) - case json_rpc(request, json_rpc_named_arguments(optimism_l1_rpc)) do + case json_rpc(request, Indexer.Helper.json_rpc_named_arguments(optimism_l1_rpc)) do {:ok, value} -> {:update, quantity_to_integer(value)} @@ -36,19 +37,4 @@ defmodule Explorer.Chain.Cache.OptimismFinalizationPeriod do end defp handle_fallback(_key), do: {:return, nil} - - defp json_rpc_named_arguments(optimism_l1_rpc) do - [ - transport: EthereumJSONRPC.HTTP, - transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, - urls: [optimism_l1_rpc], - http_options: [ - recv_timeout: :timer.minutes(10), - timeout: :timer.minutes(10), - hackney: [pool: :ethereum_jsonrpc] - ] - ] - ] - end end diff --git a/apps/explorer/lib/explorer/chain/cache/state_changes.ex b/apps/explorer/lib/explorer/chain/cache/state_changes.ex index 5c9abc41f54e..7c00961420dd 100644 --- a/apps/explorer/lib/explorer/chain/cache/state_changes.ex +++ b/apps/explorer/lib/explorer/chain/cache/state_changes.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.StateChanges do @moduledoc """ Caches the transaction state changes for pagination diff --git a/apps/explorer/lib/explorer/chain/cache/transaction_action_tokens_data.ex b/apps/explorer/lib/explorer/chain/cache/transaction_action_tokens_data.ex deleted file mode 100644 index 976bfbae4a35..000000000000 --- a/apps/explorer/lib/explorer/chain/cache/transaction_action_tokens_data.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule Explorer.Chain.Cache.TransactionActionTokensData do - @moduledoc """ - Caches tokens data for Indexer.Transform.TransactionActions. - """ - use GenServer - - @cache_name :transaction_actions_tokens_data_cache - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - create_cache_table() - {:ok, %{}} - end - - def create_cache_table do - if :ets.whereis(@cache_name) == :undefined do - :ets.new(@cache_name, [ - :set, - :named_table, - :public, - read_concurrency: true, - write_concurrency: true - ]) - end - end - - def fetch_from_cache(address) do - with info when info != :undefined <- :ets.info(@cache_name), - [{_, value}] <- :ets.lookup(@cache_name, address) do - value - else - _ -> %{symbol: nil, decimals: nil} - end - end - - def put_to_cache(address, data) do - if not :ets.member(@cache_name, address) do - # we need to add a new item to the cache, but don't exceed the limit - cache_size = :ets.info(@cache_name, :size) - - how_many_to_remove = cache_size - get_max_token_cache_size() + 1 - - range = Range.new(1, how_many_to_remove, 1) - - for _step <- range do - :ets.delete(@cache_name, :ets.first(@cache_name)) - end - end - - :ets.insert(@cache_name, {address, data}) - end - - defp get_max_token_cache_size do - Application.get_env(:explorer, __MODULE__)[:max_cache_size] - end -end diff --git a/apps/explorer/lib/explorer/chain/cache/transaction_action_uniswap_pools.ex b/apps/explorer/lib/explorer/chain/cache/transaction_action_uniswap_pools.ex deleted file mode 100644 index 4e7719768f2f..000000000000 --- a/apps/explorer/lib/explorer/chain/cache/transaction_action_uniswap_pools.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Explorer.Chain.Cache.TransactionActionUniswapPools do - @moduledoc """ - Caches Uniswap pools for Indexer.Transform.TransactionActions. - """ - use GenServer - - @cache_name :transaction_actions_uniswap_pools_cache - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - @impl true - def init(_args) do - create_cache_table() - {:ok, %{}} - end - - def create_cache_table do - if :ets.whereis(@cache_name) == :undefined do - :ets.new(@cache_name, [ - :set, - :named_table, - :public, - read_concurrency: true, - write_concurrency: true - ]) - end - end - - def fetch_from_cache(pool_address) do - with info when info != :undefined <- :ets.info(@cache_name), - [{_, value}] <- :ets.lookup(@cache_name, pool_address) do - value - else - _ -> nil - end - end - - def put_to_cache(address, value) do - :ets.insert(@cache_name, {address, value}) - end -end diff --git a/apps/explorer/lib/explorer/chain/cache/transactions.ex b/apps/explorer/lib/explorer/chain/cache/transactions.ex index e9b8a40a9efe..f5778213e768 100644 --- a/apps/explorer/lib/explorer/chain/cache/transactions.ex +++ b/apps/explorer/lib/explorer/chain/cache/transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Transactions do @moduledoc """ Caches the latest imported transactions @@ -12,10 +13,7 @@ defmodule Explorer.Chain.Cache.Transactions do :block, created_contract_address: :names, from_address: :names, - to_address: :names, - token_transfers: :token, - token_transfers: :from_address, - token_transfers: :to_address + to_address: :names ], ttl_check_interval: Application.get_env(:explorer, __MODULE__)[:ttl_check_interval], global_ttl: Application.get_env(:explorer, __MODULE__)[:global_ttl] diff --git a/apps/explorer/lib/explorer/chain/cache/transactions_api_v2.ex b/apps/explorer/lib/explorer/chain/cache/transactions_api_v2.ex deleted file mode 100644 index 0a5cf715c1bc..000000000000 --- a/apps/explorer/lib/explorer/chain/cache/transactions_api_v2.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Explorer.Chain.Cache.TransactionsApiV2 do - @moduledoc """ - Caches the latest imported transactions - """ - - alias Explorer.Chain.Transaction - - use Explorer.Chain.OrderedCache, - name: :transactions_api_v2, - max_size: 51, - preloads: [ - :block, - created_contract_address: :names, - from_address: :names, - to_address: :names - ], - ttl_check_interval: Application.get_env(:explorer, __MODULE__)[:ttl_check_interval], - global_ttl: Application.get_env(:explorer, __MODULE__)[:global_ttl] - - @type element :: Transaction.t() - - @type id :: {non_neg_integer(), non_neg_integer()} - - def element_to_id(%Transaction{block_number: block_number, index: index}) do - {block_number, index} - end -end diff --git a/apps/explorer/lib/explorer/chain/cache/uncles.ex b/apps/explorer/lib/explorer/chain/cache/uncles.ex index 9afe0bf71b3d..ea96db8ee192 100644 --- a/apps/explorer/lib/explorer/chain/cache/uncles.ex +++ b/apps/explorer/lib/explorer/chain/cache/uncles.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Uncles do @moduledoc """ Caches the last known uncles diff --git a/apps/explorer/lib/explorer/chain/celo/account.ex b/apps/explorer/lib/explorer/chain/celo/account.ex new file mode 100644 index 000000000000..bc35654509cc --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/account.ex @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.Account do + @moduledoc """ + Represents a Celo blockchain account with associated metadata, and locked gold + amounts. + + Celo accounts can be regular accounts, validators, or validator groups, each + with different roles in the network governance and consensus mechanisms. + """ + + require Logger + + use Explorer.Schema + + alias Explorer.Chain.{Address, Hash, Wei} + + @required_attrs ~w(address_hash)a + @optional_attrs [ + :type, + :name, + :metadata_url, + :nonvoting_locked_celo, + :locked_celo, + :vote_signer_address_hash, + :validator_signer_address_hash, + :attestation_signer_address_hash + ] + + @allowed_attrs @required_attrs ++ @optional_attrs + + @typedoc """ + * `address_hash` - the hash of the account address + * `type` - account type: regular, validator, or validator group + * `name` - human-readable name of the account + * `metadata_url` - URL to additional account metadata + * `locked_celo` - total amount of CELO locked by this account + * `nonvoting_locked_celo` - amount of locked CELO that is not used for voting + * `vote_signer_address_hash` – Address authorized to vote in governance and + validator elections on behalf of this account. + * `validator_signer_address_hash` – Address authorized to manage a validator + or validator group and sign consensus messages for this account. + * `attestation_signer_address_hash` – Address whose key this account uses to + sign attestations on the Attestations contract. + """ + @primary_key false + typed_schema "celo_accounts" do + field(:type, Ecto.Enum, + values: [:regular, :validator, :group], + default: :regular + ) + + field(:name, :string) + field(:metadata_url, :string) + field(:nonvoting_locked_celo, Wei) + field(:locked_celo, Wei) + + belongs_to( + :address, + Address, + primary_key: true, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address, + null: false + ) + + belongs_to( + :vote_signer_address, + Address, + foreign_key: :vote_signer_address_hash, + references: :hash, + type: Hash.Address, + null: true + ) + + belongs_to( + :validator_signer_address, + Address, + foreign_key: :validator_signer_address_hash, + references: :hash, + type: Hash.Address, + null: true + ) + + belongs_to( + :attestation_signer_address, + Address, + foreign_key: :attestation_signer_address_hash, + references: :hash, + type: Hash.Address, + null: true + ) + + timestamps() + end + + @doc """ + Creates a changeset for a Celo account with the given attributes. + + ## Parameters + - `celo_account`: The Celo account struct to update + - `attrs`: A map of attributes to cast and validate + + ## Returns + - An Ecto changeset with validation results + """ + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = celo_account, attrs) do + celo_account + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + end +end diff --git a/apps/explorer/lib/explorer/chain/celo/aggregated_election_reward.ex b/apps/explorer/lib/explorer/chain/celo/aggregated_election_reward.ex new file mode 100644 index 000000000000..e887b55e520e --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/aggregated_election_reward.ex @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.AggregatedElectionReward do + @moduledoc """ + Schema for aggregated election rewards in the Celo blockchain. + """ + + use Explorer.Schema + + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] + + alias Explorer.Chain + alias Explorer.Chain.{Token, Wei} + + alias Explorer.Chain.Celo.{ElectionReward, Epoch} + + @required_attrs ~w(epoch_number type sum count)a + + @primary_key false + typed_schema "celo_aggregated_election_rewards" do + field(:sum, Wei, null: false) + field(:count, :integer, null: false) + + field( + :type, + Ecto.Enum, + values: ElectionReward.types(), + null: false, + primary_key: true + ) + + belongs_to(:epoch, Epoch, + primary_key: true, + foreign_key: :epoch_number, + references: :number, + type: :integer + ) + + timestamps() + end + + @spec changeset(__MODULE__.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = rewards, attrs) do + rewards + |> cast(attrs, @required_attrs) + |> validate_required(@required_attrs) + |> foreign_key_constraint(:epoch_number) + end + + @doc """ + Retrieves aggregated election rewards by epoch number. + + ## Parameters + - `epoch_number` (`integer()`): The epoch number to aggregate election rewards + for. + - `options` (`Keyword.t()`): Optional parameters for fetching data. + + ## Returns + - A map of aggregated election rewards by type. + + ## Examples + + iex> epoch_number = 1 + iex> Explorer.Chain.Celo.AggregatedElectionReward.epoch_number_to_rewards_aggregated_by_type(epoch_number) + %{ + voter: %{total: %Explorer.Chain.Wei{value: #Decimal<2500>}, count: 2, token: %Explorer.Chain.Token{}}, + validator: %{total: %Explorer.Chain.Wei{value: #Decimal<0>}, count: 0, token: %Explorer.Chain.Token{}}, + group: %{total: %Explorer.Chain.Wei{value: #Decimal<0>}, count: 0, token: %Explorer.Chain.Token{}}, + delegated_payment: %{total: %Explorer.Chain.Wei{value: #Decimal<0>}, count: 0, token: %Explorer.Chain.Token{}} + } + """ + @spec epoch_number_to_rewards_aggregated_by_type(integer(), Keyword.t()) :: + %{atom() => %{total: Wei.t(), count: integer(), token: Token.t() | nil}} + def epoch_number_to_rewards_aggregated_by_type(epoch_number, options \\ []) do + reward_type_to_aggregated_rewards = + __MODULE__ + |> where([r], r.epoch_number == ^epoch_number) + |> Chain.select_repo(options).all() + |> Map.new(&{&1.type, %{total: &1.sum, count: &1.count}}) + + reward_type_to_token = election_reward_tokens_by_type() + zero = %Wei{value: Decimal.new(0)} + + ElectionReward.types() + |> Map.new(&{&1, %{total: zero, count: 0}}) + |> Map.merge(reward_type_to_aggregated_rewards) + |> Map.new(fn {type, aggregated_reward} -> + token = reward_type_to_token[type] + aggregated_reward_with_token = Map.put(aggregated_reward, :token, token) + {type, aggregated_reward_with_token} + end) + end + + # Retrieves the token for each type of election reward. + # + # ## Parameters + # - `options` (`Keyword.t()`): Optional parameters for fetching data. + # + # ## Returns + # - `%{atom() => Token.t() | nil}`: A map of reward types to token. + # + # ## Examples + # + # iex> epoch_number = %Hash.Full{ + # ...> byte_count: 32, + # ...> bytes: <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: big-integer-size(32)-unit(8)>> + # ...> } + # iex> Explorer.Chain.Celo.ElectionReward.election_reward_token_addresses_by_type(epoch_number) + # %{voter_reward: %Token{}, ...} + @spec election_reward_tokens_by_type :: %{atom() => Token.t() | nil} + defp election_reward_tokens_by_type do + reward_type_to_token_address_hash = ElectionReward.reward_type_to_token_address_hash() + + tokens = + reward_type_to_token_address_hash + |> Map.values() + |> Token.get_by_contract_address_hashes( + api?: true, + necessity_by_association: %{ + reputation_association() => :optional + } + ) + + reward_type_to_token_address_hash + |> Map.new(fn {type, address_hash} -> + token = Enum.find(tokens, &(&1.contract_address_hash == address_hash)) + {type, token} + end) + end +end diff --git a/apps/explorer/lib/explorer/chain/celo/election_reward.ex b/apps/explorer/lib/explorer/chain/celo/election_reward.ex index 6b69d02e2b37..9ca5e89507c7 100644 --- a/apps/explorer/lib/explorer/chain/celo/election_reward.ex +++ b/apps/explorer/lib/explorer/chain/celo/election_reward.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Celo.ElectionReward do @moduledoc """ Represents the rewards distributed in an epoch election. Each reward has a @@ -30,15 +31,24 @@ defmodule Explorer.Chain.Celo.ElectionReward do import Explorer.PagingOptions, only: [default_paging_options: 0] import Ecto.Query, only: [from: 2, where: 3] - import Explorer.Helper, only: [safe_parse_non_negative_integer: 1] + alias Explorer.{Chain, Helper, SortingHelper} + alias Explorer.Chain.{Address, Address.Reputation, Celo.Epoch, Hash, Token, Wei} alias Explorer.Chain.Cache.CeloCoreContracts - alias Explorer.{Chain, PagingOptions} - alias Explorer.Chain.{Address, Block, Hash, Token, Wei} @type type :: :voter | :validator | :group | :delegated_payment @types_enum ~w(voter validator group delegated_payment)a + # Legacy URL forms that differ from `to_string(atom)`. + # The URL path uses "delegated-payment" (hyphen), but the canonical atom + # is :delegated_payment (underscore). OpenApiSpex.Plug.CastAndValidate + # matches enum values via `to_string(atom) == binary`, so + # "delegated-payment" does not match :delegated_payment. Including the + # hyphenated string in the enum lets CastAndValidate accept both forms + # during a migration period, after which the hyphenated form can be + # removed. + @legacy_type_url_strings ["delegated-payment"] + @reward_type_url_string_to_atom %{ "voter" => :voter, "validator" => :validator, @@ -60,7 +70,7 @@ defmodule Explorer.Chain.Celo.ElectionReward do :delegated_payment => :usd_token } - @required_attrs ~w(amount type block_hash account_address_hash associated_account_address_hash)a + @required_attrs ~w(amount type epoch_number account_address_hash associated_account_address_hash)a @primary_key false typed_schema "celo_election_rewards" do @@ -74,14 +84,11 @@ defmodule Explorer.Chain.Celo.ElectionReward do primary_key: true ) - belongs_to( - :block, - Block, + belongs_to(:epoch, Epoch, primary_key: true, - foreign_key: :block_hash, - references: :hash, - type: Hash.Full, - null: false + foreign_key: :epoch_number, + references: :number, + type: :integer ) belongs_to( @@ -128,6 +135,21 @@ defmodule Explorer.Chain.Celo.ElectionReward do @spec types() :: [type] def types, do: @types_enum + @doc """ + Returns the list of election reward types extended with legacy hyphenated + URL strings (e.g. `"delegated-payment"`). + + Intended for use as the `enum` in OpenApiSpex schemas so that + `CastAndValidate` accepts both the canonical atom forms (`voter`, + `validator`, `group`, `delegated_payment`) and the legacy hyphenated URL + form (`delegated-payment`). + + Once the migration period ends and `"delegated-payment"` is no longer + accepted, replace usages with `types/0` and remove `@legacy_type_url_strings`. + """ + @spec type_enum_with_legacy() :: [type | String.t()] + def type_enum_with_legacy, do: @types_enum ++ @legacy_type_url_strings + @doc """ Converts a reward type url string to its corresponding atom. @@ -151,99 +173,143 @@ defmodule Explorer.Chain.Celo.ElectionReward do end @doc """ - Returns a map of reward type atoms to their corresponding token atoms. + Converts a reward type string to its corresponding atom. + + ## Parameters + - `type_string` (`String.t()`): The string representation of the reward type. ## Returns - - A map where the keys are reward type atoms and the values are token atoms. + - `{:ok, type}` if the string is valid, `:error` otherwise. ## Examples - iex> ElectionReward.reward_type_atom_to_token_atom() - %{voter: :celo_token, validator: :usd_token, group: :usd_token, delegated_payment: :usd_token} - """ - @spec reward_type_atom_to_token_atom() :: %{type => atom()} - def reward_type_atom_to_token_atom, do: @reward_type_atom_to_token_atom - - @doc """ - Builds a query to aggregate rewards by type for a given block hash. - - ## Parameters - - `block_hash` (`Hash.Full.t()`): The block hash to filter rewards. + iex> ElectionReward.type_from_string("voter") + {:ok, :voter} - ## Returns - - An Ecto query. + iex> ElectionReward.type_from_string("invalid") + :error """ - @spec block_hash_to_aggregated_rewards_by_type_query(Hash.Full.t()) :: Ecto.Query.t() - def block_hash_to_aggregated_rewards_by_type_query(block_hash) do - from( - r in __MODULE__, - where: r.block_hash == ^block_hash, - select: {r.type, sum(r.amount), count(r)}, - group_by: r.type - ) + @spec type_from_string(String.t()) :: {:ok, type} | :error + def type_from_string(type_string) do + Map.fetch(@reward_type_string_to_atom, type_string) end @doc """ - Builds a query to get rewards by type for a given block hash. + Retrieves election rewards by epoch number and reward type. ## Parameters - - `block_hash` (`Hash.Full.t()`): The block hash to filter rewards. - - `reward_type` (`type`): The type of reward to filter. + - `epoch_number` (`Hash.t()`): The epoch number to search for election rewards. + - `reward_type` (`ElectionReward.type()`): The type of reward to filter. + - `options` (`Keyword.t()`): Optional parameters for fetching data. ## Returns - - An Ecto query. + - `[ElectionReward.t()]`: A list of election rewards filtered by epoch number + and reward type. + """ - @spec block_hash_to_rewards_by_type_query(Hash.Full.t(), type) :: Ecto.Query.t() - def block_hash_to_rewards_by_type_query(block_hash, reward_type) do - from( - r in __MODULE__, - where: r.block_hash == ^block_hash and r.type == ^reward_type, - select: r, - order_by: [ - desc: :amount, - asc: :account_address_hash, - asc: :associated_account_address_hash - ] - ) + @spec epoch_number_and_type_to_rewards(integer(), type(), Keyword.t()) :: [__MODULE__.t()] + def epoch_number_and_type_to_rewards(epoch_number, reward_type, options \\ []) + when reward_type in @types_enum do + default_sorting = [ + desc: :amount, + asc: :account_address_hash, + asc: :associated_account_address_hash + ] + + paging_options = Keyword.get(options, :paging_options, default_paging_options()) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + sorting_options = Keyword.get(options, :sorting, []) + + __MODULE__ + |> where([r], r.epoch_number == ^epoch_number) + |> where([r], r.type == ^reward_type) + |> SortingHelper.apply_sorting(sorting_options, default_sorting) + |> SortingHelper.page_with_sorting(paging_options, sorting_options, default_sorting) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all() end - @doc """ - Builds a query to get rewards by account address hash. - """ - @spec address_hash_to_rewards_query(Hash.Address.t()) :: Ecto.Query.t() def address_hash_to_rewards_query(address_hash) do - from( - r in __MODULE__, - where: r.account_address_hash == ^address_hash, - select: r - ) + __MODULE__ + |> where([r], r.account_address_hash == ^address_hash) end @doc """ - Builds a query to get ordered rewards by account address hash. + Retrieves election rewards associated with a given address hash. ## Parameters - - `address_hash` (`Hash.Address.t()`): The account address hash to filter + - `address_hash` (`Hash.Address.t()`): The address hash to search for election rewards. + - `options` (`Keyword.t()`): Optional parameters for fetching data. ## Returns - - An Ecto query. + - `[ElectionReward.t()]`: A list of election rewards associated with the + address hash. + + ## Examples + + iex> address_hash = %Hash.Address{ + ...> byte_count: 20, + ...> bytes: <<0x1d1f7f0e1441c37e28b89e0b5e1edbbd34d77649 :: size(160)>> + ...> } + iex> Explorer.Chain.Celo.ElectionReward.address_hash_to_rewards(address_hash) + [%ElectionReward{}, ...] """ - @spec address_hash_to_ordered_rewards_query(Hash.Address.t()) :: Ecto.Query.t() - def address_hash_to_ordered_rewards_query(address_hash) do - from( - r in __MODULE__, - join: b in assoc(r, :block), - as: :block, - preload: [block: b], - where: r.account_address_hash == ^address_hash, - select: r, - order_by: [ - desc: b.number, - desc: r.amount, - asc: r.associated_account_address_hash, - asc: r.type - ] + @spec address_hash_to_rewards(Hash.Address.t(), Keyword.t()) :: [__MODULE__.t()] + def address_hash_to_rewards(address_hash, options \\ []) do + default_sorting = [ + desc: :epoch_number, + asc: :type, + desc: :amount, + asc: :associated_account_address_hash + ] + + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, default_paging_options()) + sorting_options = Keyword.get(options, :sorting, []) + from_epoch = Keyword.get(options, :from_epoch) + to_epoch = Keyword.get(options, :to_epoch) + timeout = Keyword.get(options, :timeout) + + address_hash + |> address_hash_to_rewards_query() + |> where_epoch_number_in_period(from_epoch, to_epoch) + |> join_token() + |> SortingHelper.apply_sorting(sorting_options, default_sorting) + |> SortingHelper.page_with_sorting(paging_options, sorting_options, default_sorting) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all(Helper.maybe_timeout(timeout)) + |> with_loaded_token_reputations() + end + + @doc """ + Retrieves a mapping of reward types to their corresponding token address + hashes. + ## Returns + - `%{type => Hash.Address.t()}`: A map of reward types to + token address hashes. + """ + @spec reward_type_to_token_address_hash :: %{type => Hash.Address.t()} + def reward_type_to_token_address_hash do + Map.new( + @reward_type_atom_to_token_atom, + fn {type, token_atom} -> + addresses = + token_atom + |> CeloCoreContracts.get_address_updates() + |> case do + {:ok, addresses} -> addresses + _ -> [] + end + |> Enum.map(fn %{"address" => address_hash_string} -> + {:ok, address_hash} = Hash.Address.cast(address_hash_string) + address_hash + end) + + # This match should never fail + [address] = addresses + {type, address} + end ) end @@ -258,31 +324,7 @@ defmodule Explorer.Chain.Celo.ElectionReward do """ @spec join_token(Ecto.Query.t()) :: Ecto.Query.t() def join_token(query) do - # This match should never fail - %{ - voter: [voter_token_address_hash], - validator: [validator_token_address_hash], - group: [group_token_address_hash], - delegated_payment: [delegated_payment_token_address_hash] - } = - Map.new( - @reward_type_atom_to_token_atom, - fn {type, token_atom} -> - addresses = - token_atom - |> CeloCoreContracts.get_address_updates() - |> case do - {:ok, addresses} -> addresses - _ -> [] - end - |> Enum.map(fn %{"address" => address_hash_string} -> - {:ok, address_hash} = Hash.Address.cast(address_hash_string) - address_hash - end) - - {type, addresses} - end - ) + reward_type_to_token_address_hash = reward_type_to_token_address_hash() from( r in query, @@ -301,258 +343,65 @@ defmodule Explorer.Chain.Celo.ElectionReward do """, r.type, ^"voter", - ^voter_token_address_hash.bytes, + ^reward_type_to_token_address_hash.voter.bytes, ^"validator", - ^validator_token_address_hash.bytes, + ^reward_type_to_token_address_hash.validator.bytes, ^"group", - ^group_token_address_hash.bytes, + ^reward_type_to_token_address_hash.group.bytes, ^"delegated_payment", - ^delegated_payment_token_address_hash.bytes + ^reward_type_to_token_address_hash.delegated_payment.bytes ), select_merge: %{token: t} ) end - @doc """ - Makes Explorer.PagingOptions map for election rewards. - """ - @spec block_paging_options(map()) :: [Chain.paging_options()] - def block_paging_options(params) do - with %{ - "amount" => amount_string, - "account_address_hash" => account_address_hash_string, - "associated_account_address_hash" => associated_account_address_hash_string - } - when is_binary(amount_string) and - is_binary(account_address_hash_string) and - is_binary(associated_account_address_hash_string) <- params, - {amount, ""} <- Decimal.parse(amount_string), - {:ok, account_address_hash} <- Hash.Address.cast(account_address_hash_string), - {:ok, associated_account_address_hash} <- - Hash.Address.cast(associated_account_address_hash_string) do - [ - paging_options: %{ - default_paging_options() - | key: {amount, account_address_hash, associated_account_address_hash} - } - ] - else - _ -> - [paging_options: default_paging_options()] - end - end - - @doc """ - Makes Explorer.PagingOptions map for election rewards. - """ - @spec address_paging_options(map()) :: [Chain.paging_options()] - def address_paging_options(params) do - with %{ - "block_number" => block_number_string, - "amount" => amount_string, - "associated_account_address_hash" => associated_account_address_hash_string, - "type" => type_string - } - when is_binary(block_number_string) and - is_binary(amount_string) and - is_binary(associated_account_address_hash_string) and - is_binary(type_string) <- params, - {:ok, block_number} <- safe_parse_non_negative_integer(block_number_string), - {amount, ""} <- Decimal.parse(amount_string), - {:ok, associated_account_address_hash} <- - Hash.Address.cast(associated_account_address_hash_string), - {:ok, type} <- Map.fetch(@reward_type_string_to_atom, type_string) do - [ - paging_options: %{ - default_paging_options() - | key: {block_number, amount, associated_account_address_hash, type} - } - ] - else - _ -> - [paging_options: default_paging_options()] - end + # Ensures each reward token has its reputation association loaded to avoid JSON encoding errors. + @spec with_loaded_token_reputations([__MODULE__.t()]) :: [__MODULE__.t()] + defp with_loaded_token_reputations(rewards) when is_list(rewards) do + tokens = + rewards + |> Enum.map(& &1.token) + |> Enum.reject(&is_nil/1) + + hash_to_reputation = + tokens + |> Enum.map(& &1.contract_address_hash) + |> Reputation.preload_reputation() + |> Map.new() + + address_hash_to_token = + Enum.reduce(tokens, %{}, fn token, acc -> + reputation = Map.get(hash_to_reputation, token.contract_address_hash) + Map.put(acc, token.contract_address_hash, Map.put(token, :reputation, reputation)) + end) + + Enum.map(rewards, fn r -> + case r.token do + nil -> r + %{contract_address_hash: h} -> %{r | token: Map.get(address_hash_to_token, h)} + end + end) end - @doc """ - Paginates the given query based on the provided `PagingOptions`. - - ## Parameters - - `query` (`Ecto.Query.t()`): The query to paginate. - - `paging_options` (`PagingOptions.t()`): The pagination options. - - ## Returns - - An Ecto query with pagination applied. - """ - def paginate(query, %PagingOptions{key: nil}), do: query - - def paginate(query, %PagingOptions{key: {0 = _amount, account_address_hash, associated_account_address_hash}}) do - where( - query, - [reward], - reward.amount == 0 and - (reward.account_address_hash > ^account_address_hash or - (reward.account_address_hash == ^account_address_hash and - reward.associated_account_address_hash > ^associated_account_address_hash)) - ) - end - - def paginate(query, %PagingOptions{key: {amount, account_address_hash, associated_account_address_hash}}) do - where( - query, - [reward], - reward.amount < ^amount or - (reward.amount == ^amount and - reward.account_address_hash > ^account_address_hash) or - (reward.amount == ^amount and - reward.account_address_hash == ^account_address_hash and - reward.associated_account_address_hash > ^associated_account_address_hash) - ) - end - - def paginate(query, %PagingOptions{key: {0 = _block_number, 0 = _amount, associated_account_address_hash, type}}) do - where( - query, - [reward, block], - block.number == 0 and reward.amount == 0 and - (reward.associated_account_address_hash > ^associated_account_address_hash or - (reward.associated_account_address_hash == ^associated_account_address_hash and - reward.type > ^type)) - ) - end - - def paginate(query, %PagingOptions{key: {0 = _block_number, amount, associated_account_address_hash, type}}) do - where( - query, - [reward, block], - block.number == 0 and - (reward.amount < ^amount or - (reward.amount == ^amount and - reward.associated_account_address_hash > ^associated_account_address_hash) or - (reward.amount == ^amount and - reward.associated_account_address_hash == ^associated_account_address_hash and - reward.type > ^type)) - ) - end - - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - def paginate(query, %PagingOptions{key: {block_number, 0 = _amount, associated_account_address_hash, type}}) do - where( - query, - [reward, block], - block.number < ^block_number or - (block.number == ^block_number and - reward.amount == 0 and - reward.associated_account_address_hash > ^associated_account_address_hash) or - (block.number == ^block_number and - reward.amount == 0 and - reward.associated_account_address_hash == ^associated_account_address_hash and - reward.type > ^type) - ) - end - - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - def paginate(query, %PagingOptions{key: {block_number, amount, associated_account_address_hash, type}}) do - where( - query, - [reward, block], - block.number < ^block_number or - (block.number == ^block_number and - reward.amount < ^amount) or - (block.number == ^block_number and - reward.amount == ^amount and - reward.associated_account_address_hash > ^associated_account_address_hash) or - (block.number == ^block_number and - reward.amount == ^amount and - reward.associated_account_address_hash == ^associated_account_address_hash and - reward.type > ^type) - ) - end - - @doc """ - Converts an `ElectionReward` struct to paging parameters on the block view. - - ## Parameters - - `reward` (`%__MODULE__{}`): The election reward struct. - - ## Returns - - A map representing the block paging parameters. - - ## Examples - - iex> ElectionReward.to_block_paging_params(%ElectionReward{amount: 1000, account_address_hash: "0x123", associated_account_address_hash: "0x456"}) - %{"amount" => 1000, "account_address_hash" => "0x123", "associated_account_address_hash" => "0x456"} - """ - def to_block_paging_params(%__MODULE__{ - amount: amount, - account_address_hash: account_address_hash, - associated_account_address_hash: associated_account_address_hash - }) do - %{ - "amount" => amount, - "account_address_hash" => account_address_hash, - "associated_account_address_hash" => associated_account_address_hash - } - end - - @doc """ - Converts an `ElectionReward` struct to paging parameters on the address view. - - ## Parameters - - `reward` (`%__MODULE__{}`): The election reward struct. - - ## Returns - - A map representing the address paging parameters. - - ## Examples - - iex> ElectionReward.to_address_paging_params(%ElectionReward{block_number: 1, amount: 1000, associated_account_address_hash: "0x456", type: :voter}) - %{"block_number" => 1, "amount" => 1000, "associated_account_address_hash" => "0x456", "type" => :voter} - """ - def to_address_paging_params(%__MODULE__{ - block: %Block{number: block_number}, - amount: amount, - associated_account_address_hash: associated_account_address_hash, - type: type - }) do - %{ - "block_number" => block_number, - "amount" => amount, - "associated_account_address_hash" => associated_account_address_hash, - "type" => type - } - end - - @doc """ - Custom filter for `ElectionReward`, inspired by - `Chain.where_block_number_in_period/3`. - - TODO: Consider reusing `Chain.where_block_number_in_period/3`. This would - require storing or making `merge_select` of `block_number`. - """ - @spec where_block_number_in_period( + @spec where_epoch_number_in_period( Ecto.Query.t(), String.t() | integer() | nil, String.t() | integer() | nil ) :: Ecto.Query.t() - def where_block_number_in_period(base_query, from_block, to_block) - when is_nil(from_block) and not is_nil(to_block), - do: where(base_query, [_, block], block.number <= ^to_block) + defp where_epoch_number_in_period(base_query, nil, nil), + do: base_query - def where_block_number_in_period(base_query, from_block, to_block) - when not is_nil(from_block) and is_nil(to_block), - do: where(base_query, [_, block], block.number > ^from_block) + defp where_epoch_number_in_period(base_query, nil, to_epoch), + do: where(base_query, [reward], reward.epoch_number <= ^to_epoch) - def where_block_number_in_period(base_query, from_block, to_block) - when is_nil(from_block) and is_nil(to_block), - do: base_query + defp where_epoch_number_in_period(base_query, from_epoch, nil), + do: where(base_query, [reward], reward.epoch_number >= ^from_epoch) - def where_block_number_in_period(base_query, from_block, to_block), + defp where_epoch_number_in_period(base_query, from_epoch, to_epoch), do: where( base_query, - [_, block], - block.number > ^from_block and - block.number <= ^to_block + [reward], + reward.epoch_number >= ^from_epoch and reward.epoch_number <= ^to_epoch ) end diff --git a/apps/explorer/lib/explorer/chain/celo/epoch.ex b/apps/explorer/lib/explorer/chain/celo/epoch.ex new file mode 100644 index 000000000000..f4a20da66d41 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/epoch.ex @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.Epoch do + @moduledoc """ + Schema for Celo blockchain epochs. + """ + + use Explorer.Schema + + import Ecto.Query, only: [from: 2, where: 2] + + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, + only: [proxy_implementations_association: 0] + + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] + + alias Explorer.{Chain, Hash, QueryHelper, Repo, SortingHelper} + + alias Explorer.Chain.{ + Block, + Celo.ElectionReward, + Celo.EpochReward, + Hash, + TokenTransfer + } + + @required_attrs ~w(number)a + @optional_attrs ~w(fetched? start_block_number end_block_number start_processing_block_hash end_processing_block_hash)a + + @default_paging_options Chain.default_paging_options() + + @typedoc """ + * `number` - The epoch number. + * `fetched?` - Indicates whether the epoch has been fetched. + * `start_block_number` - The starting block number of the epoch. + * `end_block_number` - The ending block number of the epoch. + * `start_processing_block_hash` - The hash of the block where the epoch + processing starts. + * `end_processing_block_hash` - The hash of the block where the epoch + processing ends. + """ + @primary_key false + typed_schema "celo_epochs" do + field(:number, :integer, primary_key: true) + + field(:fetched?, :boolean, + source: :is_fetched, + default: false, + null: false + ) + + field(:start_block_number, :integer) + field(:end_block_number, :integer) + + belongs_to(:start_processing_block, Block, + foreign_key: :start_processing_block_hash, + references: :hash, + type: Hash.Full + ) + + belongs_to(:end_processing_block, Block, + foreign_key: :end_processing_block_hash, + references: :hash, + type: Hash.Full + ) + + has_one(:distribution, EpochReward, + foreign_key: :epoch_number, + references: :number + ) + + has_many(:election_reward, ElectionReward, + foreign_key: :epoch_number, + references: :number + ) + + timestamps() + end + + @spec changeset( + __MODULE__.t(), + :invalid | %{optional(:__struct__) => none, optional(atom | binary) => any} + ) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = vote, attrs \\ %{}) do + vote + |> cast(attrs, @required_attrs ++ @optional_attrs) + |> validate_required(@required_attrs) + |> foreign_key_constraint(:start_processing_block_hash) + |> foreign_key_constraint(:end_processing_block_hash) + end + + @doc """ + Returns a stream of epochs with unfetched rewards. + """ + @spec stream_unfetched_epochs( + initial :: accumulator, + reducer :: (entry :: term(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_unfetched_epochs(initial, reducer, limited? \\ false) + when is_function(reducer, 2) do + query = + from( + epoch in __MODULE__, + join: start_processing_block in assoc(epoch, :start_processing_block), + join: end_processing_block in assoc(epoch, :end_processing_block), + where: + epoch.fetched? == false and + start_processing_block.consensus == true and + end_processing_block.consensus == true, + order_by: [desc: epoch.number] + ) + + query + |> Chain.add_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Retrieves all epochs that have been marked as fetched. + + ## Parameters + - `options` (`Keyword.t()`): Options for filtering and ordering the epochs. + - `:paging_options` - pagination parameters + - `:necessity_by_association` - associations that need to be loaded + - `:sorting` - sorting parameters + + ## Returns + - `list(__MODULE__.t())`: A list of fetched epochs. + + ## Examples + + iex> Explorer.Chain.Celo.Epoch.fetched_epochs([]) + [%Explorer.Chain.Celo.Epoch{number: 42, fetched?: true, ...}, ...] + + iex> Explorer.Chain.Celo.Epoch.fetched_epochs(sorting: [asc: :number], paging_options: %{page_size: 10}) + [%Explorer.Chain.Celo.Epoch{number: 1, fetched?: true, ...}, ...] + """ + @spec all(Keyword.t()) :: [__MODULE__.t()] + def all(options) do + default_sorting = [desc: :number] + + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + sorting_options = Keyword.get(options, :sorting, []) + + __MODULE__ + |> SortingHelper.apply_sorting(sorting_options, default_sorting) + |> SortingHelper.page_with_sorting(paging_options, sorting_options, default_sorting) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all() + |> load_all_distribution_token_transfers(options) + end + + defp load_all_distribution_token_transfers(epochs, options) do + block_hash_log_index_pairs = + epochs + |> Enum.flat_map(fn + %__MODULE__{ + end_processing_block_hash: block_hash, + distribution: %EpochReward{} = distribution + } -> + {:ok, binary} = Hash.Full.dump(block_hash) + + [ + {binary, distribution.reserve_bolster_transfer_log_index}, + {binary, distribution.community_transfer_log_index}, + {binary, distribution.carbon_offsetting_transfer_log_index} + ] + |> Enum.reject(fn {_, log_index} -> is_nil(log_index) end) + + _ -> + [] + end) + + token_transfers_query = + from( + tt in TokenTransfer.only_consensus_transfers_query(), + where: ^QueryHelper.tuple_in([:block_hash, :log_index], block_hash_log_index_pairs), + select: {{tt.block_hash, tt.log_index}, tt}, + preload: [token: ^reputation_association()] + ) + + token_transfers_map = + token_transfers_query + |> Chain.select_repo(options).all() + |> Map.new() + + Enum.map(epochs, &populate_epoch_token_transfers(&1, token_transfers_map)) + end + + # Populate a single epoch with its token transfers from the fetched map + defp populate_epoch_token_transfers( + %__MODULE__{ + end_processing_block_hash: block_hash, + distribution: %EpochReward{} = distribution + } = epoch, + token_transfers_map + ) do + updated_distribution = + %EpochReward{ + distribution + | reserve_bolster_transfer: + token_transfers_map[ + { + block_hash, + distribution.reserve_bolster_transfer_log_index + } + ], + community_transfer: + token_transfers_map[ + { + block_hash, + distribution.community_transfer_log_index + } + ], + carbon_offsetting_transfer: + token_transfers_map[ + { + block_hash, + distribution.carbon_offsetting_transfer_log_index + } + ] + } + + %__MODULE__{epoch | distribution: updated_distribution} + end + + defp populate_epoch_token_transfers(epoch, _token_transfers_map), do: epoch + + @doc """ + Returns a query to find an epoch by its number. + + ## Parameters + - `number` (`integer()`): The epoch number to search for. + + ## Returns + - `Ecto.Query.t()`: The query to find the epoch. + + ## Examples + + iex> Repo.one(epoch_by_number_query(42)) + %Epoch{number: 42, start_block_number: 123400, end_block_number: 123799} + + iex> Repo.one(epoch_by_number_query(999999)) + nil + """ + @spec epoch_by_number_query(integer()) :: Ecto.Query.t() + def epoch_by_number_query(number) do + __MODULE__ + |> where(number: ^number) + end + + @doc """ + Retrieves an epoch by its number. This function fetches the epoch from the + database and preloads its associated data based on the provided options. It + always preloads distribution token transfers. + + ## Parameters + - `number` (`integer()`): The epoch number to search for. + - `options` (`Keyword.t()`): Options for filtering and ordering the epochs. + - `:necessity_by_association` - associations that need to be loaded + - `:sorting` - sorting parameters + ## Returns + - `{:ok, __MODULE__.t()}`: The epoch struct if found. + - `{:error, :not_found}`: If the epoch is not found. + + ## Examples + iex> Explorer.Chain.Celo.Epoch.from_number(42, []) + {:ok, %Epoch{number: 42, start_block_number: 123400, end_block_number: 123799}} + + iex> Explorer.Chain.Celo.Epoch.from_number(999999, []) + {:error, :not_found} + """ + @spec from_number(integer(), Keyword.t()) :: + {:ok, __MODULE__.t()} | {:error, :not_found} + def from_number(number, options) when is_integer(number) and is_list(options) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + number + |> epoch_by_number_query() + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).one() + |> with_loaded_distribution_token_transfers(options) + |> case do + nil -> + {:error, :not_found} + + epoch -> + {:ok, epoch} + end + end + + # Loads the token transfers for the epoch with preloaded epoch reward. + # + # This function retrieves token transfers related to the specified epoch reward + # by knowing the index of the log of the token transfer and populates the + # virtual fields in the `EpochReward` struct. We manually preload token + # transfers since Ecto does not support automatically preloading objects by + # composite key (i.e., `log_index` and `block_hash`). + # + # ## Parameters + # - `epoch_reward` (`EpochReward.t()`): The epoch reward struct. + # - `options` (`Keyword.t()`): Optional parameters for selecting the repository. + # + # ## Returns + # - `EpochReward.t()`: The epoch reward struct with the token transfers loaded. + # + # ## Example + # + # iex> epoch_reward = %Explorer.Chain.Celo.EpochReward{block_hash: "some_hash", reserve_bolster_transfer_log_index: 1} + # iex> Explorer.Chain.Celo.EpochReward.load_token_transfers(epoch_reward) + # %Explorer.Chain.Celo.EpochReward{ + # block_hash: "some_hash", + # reserve_bolster_transfer_log_index: 1, + # reserve_bolster_transfer: %Explorer.Chain.TokenTransfer{log_index: 1, ...} + # } + # + @spec with_loaded_distribution_token_transfers(__MODULE__.t() | nil, api?: boolean()) :: + __MODULE__.t() | nil + defp with_loaded_distribution_token_transfers( + %__MODULE__{ + end_processing_block_hash: block_hash, + distribution: %EpochReward{ + reserve_bolster_transfer_log_index: reserve_bolster_transfer_log_index, + community_transfer_log_index: community_transfer_log_index, + carbon_offsetting_transfer_log_index: carbon_offsetting_transfer_log_index + } + } = epoch, + options + ) do + virtual_field_to_log_index = [ + reserve_bolster_transfer: reserve_bolster_transfer_log_index, + community_transfer: community_transfer_log_index, + carbon_offsetting_transfer: carbon_offsetting_transfer_log_index + ] + + log_indexes = + virtual_field_to_log_index + |> Enum.map(fn {_, index} -> index end) + |> Enum.reject(&is_nil/1) + + query = + from( + tt in TokenTransfer.only_consensus_transfers_query(), + where: tt.log_index in ^log_indexes and tt.block_hash == ^block_hash, + select: {tt.log_index, tt}, + preload: [ + [token: ^reputation_association()], + [ + from_address: [ + :scam_badge, + :names, + :smart_contract, + ^proxy_implementations_association() + ] + ], + [ + to_address: [ + :scam_badge, + :names, + :smart_contract, + ^proxy_implementations_association() + ] + ] + ] + ) + + log_index_to_token_transfer = + query + |> Chain.select_repo(options).all() + |> Map.new() + + with_token_transfers = + Enum.reduce(virtual_field_to_log_index, epoch.distribution, fn + {field, log_index}, acc -> + token_transfer = Map.get(log_index_to_token_transfer, log_index) + Map.put(acc, field, token_transfer) + end) + + %__MODULE__{epoch | distribution: with_token_transfers} + end + + defp with_loaded_distribution_token_transfers(epoch, _options), do: epoch + + @doc """ + Returns a query to find an epoch containing the given block number. + + When multiple epochs with nil end_block_number match the block number, the one + with the maximum start_block_number is selected. + + ## Parameters + - `block_number` (`non_neg_integer()`): The block number to search for. + + ## Returns + - `Ecto.Query.t()`: The query to find the epoch. + + ## Examples + + iex> Repo.one(block_number_to_epoch_query(123456)) + %Epoch{number: 42, start_block_number: 123400, end_block_number: 123799} + + iex> Repo.one(block_number_to_epoch_query(123800)) + %Epoch{number: 43, start_block_number: 123800, end_block_number: nil} + + iex> Repo.one(block_number_to_epoch_query(999999)) + nil + """ + @spec block_number_to_epoch_query(non_neg_integer()) :: Ecto.Query.t() + def block_number_to_epoch_query(block_number) do + from(e in __MODULE__, + # First, find epochs where the block_number falls within a defined range + # OR it's an open-ended epoch with start_block_number <= block_number + where: + e.start_block_number <= ^block_number and + (is_nil(e.end_block_number) or + e.end_block_number >= ^block_number), + # Order by end_block_number nulls last (closed epochs first) + # Then by start_block_number descending (most recent open epoch) + order_by: [asc_nulls_last: e.end_block_number, desc: e.start_block_number], + # Limit to just one result + limit: 1 + ) + end + + @doc """ + Converts a block number range to epoch number range by finding epochs whose + end block numbers fall within the specified block range. + + ## Parameters + - `from_block` (`integer()`): The starting block number. + - `to_block` (`integer()`): The ending block number. + - `options` (`Keyword.t()`): Options for selecting the repository. + + ## Returns + - `{integer(), integer()} | nil`: A tuple containing the minimum and maximum + epoch numbers or nil. + + ## Examples + + iex> Explorer.Chain.Celo.Epoch.block_range_to_epoch_range(123400, 125000) + {42, 43} + + iex> Explorer.Chain.Celo.Epoch.block_range_to_epoch_range(999999, 1000000) + nil + """ + @spec block_range_to_epoch_range(integer(), integer(), Keyword.t()) :: {integer(), integer()} | nil + def block_range_to_epoch_range(from_block, to_block, options \\ []) do + query = + from(e in __MODULE__, + where: e.end_block_number >= ^from_block and e.end_block_number < ^to_block, + select: {min(e.number), max(e.number)} + ) + + case Chain.select_repo(options).one(query) do + {nil, nil} -> nil + res -> res + end + end +end diff --git a/apps/explorer/lib/explorer/chain/celo/epoch_reward.ex b/apps/explorer/lib/explorer/chain/celo/epoch_reward.ex index 85ed60fb1fa5..dff4de3e660d 100644 --- a/apps/explorer/lib/explorer/chain/celo/epoch_reward.ex +++ b/apps/explorer/lib/explorer/chain/celo/epoch_reward.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Celo.EpochReward do @moduledoc """ Represents the distributions in the Celo epoch. Each log index points to a @@ -6,34 +7,29 @@ defmodule Explorer.Chain.Celo.EpochReward do """ use Explorer.Schema - import Ecto.Query, only: [from: 2] - import Explorer.Chain, only: [select_repo: 1] - import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] + alias Explorer.Chain.{Celo.Epoch, TokenTransfer} - alias Explorer.Chain.Celo.EpochReward - alias Explorer.Chain.{Block, Hash, TokenTransfer} - - @required_attrs ~w(block_hash)a + @required_attrs ~w(epoch_number)a @optional_attrs ~w(reserve_bolster_transfer_log_index community_transfer_log_index carbon_offsetting_transfer_log_index)a @allowed_attrs @required_attrs ++ @optional_attrs @primary_key false typed_schema "celo_epoch_rewards" do field(:reserve_bolster_transfer_log_index, :integer) + # field(:reserve_bolster_transfer_value, :integer) field(:community_transfer_log_index, :integer) + # field(:community_transfer_value, :integer) field(:carbon_offsetting_transfer_log_index, :integer) + # field(:carbon_offsetting_transfer_value, :integer) field(:reserve_bolster_transfer, :any, virtual: true) :: TokenTransfer.t() | nil field(:community_transfer, :any, virtual: true) :: TokenTransfer.t() | nil field(:carbon_offsetting_transfer, :any, virtual: true) :: TokenTransfer.t() | nil - belongs_to( - :block, - Block, + belongs_to(:epoch, Epoch, primary_key: true, - foreign_key: :block_hash, - references: :hash, - type: Hash.Full, - null: false + foreign_key: :epoch_number, + references: :number, + type: :integer ) timestamps() @@ -47,72 +43,4 @@ defmodule Explorer.Chain.Celo.EpochReward do |> foreign_key_constraint(:block_hash) |> unique_constraint(:block_hash) end - - @doc """ - Loads the token transfers for the given epoch reward. - - This function retrieves token transfers related to the specified epoch reward - by knowing the index of the log of the token transfer and populates the - virtual fields in the `EpochReward` struct. We manually preload token - transfers since Ecto does not support automatically preloading objects by - composite key (i.e., `log_index` and `block_hash`). - - ## Parameters - - `epoch_reward` (`EpochReward.t()`): The epoch reward struct. - - `options` (`Keyword.t()`): Optional parameters for selecting the repository. - - ## Returns - - `EpochReward.t()`: The epoch reward struct with the token transfers loaded. - - ## Example - - iex> epoch_reward = %Explorer.Chain.Celo.EpochReward{block_hash: "some_hash", reserve_bolster_transfer_log_index: 1} - iex> Explorer.Chain.Celo.EpochReward.load_token_transfers(epoch_reward) - %Explorer.Chain.Celo.EpochReward{ - block_hash: "some_hash", - reserve_bolster_transfer_log_index: 1, - reserve_bolster_transfer: %Explorer.Chain.TokenTransfer{log_index: 1, ...} - } - """ - - @spec load_token_transfers(EpochReward.t()) :: EpochReward.t() - def load_token_transfers( - %EpochReward{ - reserve_bolster_transfer_log_index: reserve_bolster_transfer_log_index, - community_transfer_log_index: community_transfer_log_index, - carbon_offsetting_transfer_log_index: carbon_offsetting_transfer_log_index - } = epoch_reward, - options \\ [] - ) do - virtual_field_to_log_index = [ - reserve_bolster_transfer: reserve_bolster_transfer_log_index, - community_transfer: community_transfer_log_index, - carbon_offsetting_transfer: carbon_offsetting_transfer_log_index - ] - - log_indexes = - virtual_field_to_log_index - |> Enum.map(&elem(&1, 1)) - |> Enum.reject(&is_nil/1) - - query = - from( - tt in TokenTransfer.only_consensus_transfers_query(), - where: tt.log_index in ^log_indexes and tt.block_hash == ^epoch_reward.block_hash, - select: {tt.log_index, tt}, - preload: [ - :token, - [from_address: [:scam_badge, :names, :smart_contract, ^proxy_implementations_association()]], - [to_address: [:scam_badge, :names, :smart_contract, ^proxy_implementations_association()]] - ] - ) - - log_index_to_token_transfer = query |> select_repo(options).all() |> Map.new() - - Enum.reduce(virtual_field_to_log_index, epoch_reward, fn - {field, log_index}, acc -> - token_transfer = Map.get(log_index_to_token_transfer, log_index) - Map.put(acc, field, token_transfer) - end) - end end diff --git a/apps/explorer/lib/explorer/chain/celo/helper.ex b/apps/explorer/lib/explorer/chain/celo/helper.ex index e85d2be760ac..e75aa3835add 100644 --- a/apps/explorer/lib/explorer/chain/celo/helper.ex +++ b/apps/explorer/lib/explorer/chain/celo/helper.ex @@ -1,55 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Celo.Helper do @moduledoc """ Common helper functions for Celo. """ - import Explorer.Chain.Cache.CeloCoreContracts, only: [atom_to_contract_name: 0] - alias Explorer.Chain.Block @blocks_per_epoch 17_280 - @core_contract_atoms atom_to_contract_name() |> Map.keys() @doc """ - Returns the number of blocks per epoch in the Celo network. - """ - @spec blocks_per_epoch() :: non_neg_integer() - def blocks_per_epoch, do: @blocks_per_epoch - - defguard is_epoch_block_number(block_number) - when is_integer(block_number) and - block_number > 0 and - rem(block_number, @blocks_per_epoch) == 0 + Checks if a block number belongs to a block that finalized an L1-era epoch. - defguard is_core_contract_atom(atom) - when atom in @core_contract_atoms - - @doc """ - Validates if a block number is an epoch block number. - - ## Parameters - - `block_number` (`Block.block_number()`): The block number to validate. - - ## Returns - - `:ok` if the block number is an epoch block number. - - `{:error, :not_found}` if the block number is not an epoch block number. - - ## Examples - - iex> Explorer.Chain.Celo.Helper.validate_epoch_block_number(17280) - :ok - - iex> Explorer.Chain.Celo.Helper.validate_epoch_block_number(17281) - {:error, :not_found} - """ - @spec validate_epoch_block_number(Block.block_number()) :: :ok | {:error, :not_found} - def validate_epoch_block_number(block_number) when is_epoch_block_number(block_number), - do: :ok - - def validate_epoch_block_number(_block_number), do: {:error, :not_found} - - @doc """ - Checks if a block number belongs to a block that finalized an epoch. + This function should only be used for pre-L2 migration blocks, as the concept + of epoch blocks no longer exists after L2 migration. ## Parameters - `block_number` (`Block.block_number()`): The block number to check. @@ -67,7 +30,12 @@ defmodule Explorer.Chain.Celo.Helper do false """ @spec epoch_block_number?(block_number :: Block.block_number()) :: boolean() - def epoch_block_number?(block_number) when is_epoch_block_number(block_number), do: true + def epoch_block_number?(block_number) + when is_integer(block_number) and + block_number > 0 and + rem(block_number, @blocks_per_epoch) == 0, + do: true + def epoch_block_number?(_), do: false @doc """ @@ -81,15 +49,49 @@ defmodule Explorer.Chain.Celo.Helper do ## Examples - iex> Explorer.Chain.Celo.Helper.block_number_to_epoch_number(17280) + iex> Explorer.Chain.Celo.Helper.block_number_to_epoch_number(17279) 1 + iex> Explorer.Chain.Celo.Helper.block_number_to_epoch_number(17280) + 2 + iex> Explorer.Chain.Celo.Helper.block_number_to_epoch_number(17281) 2 """ @spec block_number_to_epoch_number(block_number :: Block.block_number()) :: non_neg_integer() def block_number_to_epoch_number(block_number) when is_integer(block_number) do - (block_number / @blocks_per_epoch) |> Float.ceil() |> trunc() + (block_number / @blocks_per_epoch) |> Float.floor() |> trunc() |> Kernel.+(1) + end + + @doc """ + Converts an epoch number to a block range for L1-era epochs. + + This function should only be used for pre-L2 migration epochs, as epoch block + ranges are deterministic only in L1 era. + + ## Parameters + - `epoch_number` (`non_neg_integer()`): The epoch number to convert. + + ## Returns + - `{Block.block_number(), Block.block_number()}`: A tuple containing the start + and end block numbers of the epoch. + + ## Examples + + iex> Explorer.Chain.Celo.Helper.epoch_number_to_block_range(1) + {0, 17279} + + iex> Explorer.Chain.Celo.Helper.epoch_number_to_block_range(2) + {17280, 34559} + """ + @spec epoch_number_to_block_range(epoch_number :: non_neg_integer()) :: + {Block.block_number(), Block.block_number()} + def epoch_number_to_block_range(epoch_number) + when is_integer(epoch_number) and epoch_number > 0 do + start_block = (epoch_number - 1) * @blocks_per_epoch + end_block = epoch_number * @blocks_per_epoch - 1 + + {start_block, end_block} end @doc """ @@ -111,12 +113,27 @@ defmodule Explorer.Chain.Celo.Helper do @doc """ Checks if a block with given number appeared prior to Celo L2 migration. """ - @spec premigration_block_number?(Block.block_number()) :: boolean() - def premigration_block_number?(block_number) do + @spec pre_migration_block_number?(Block.block_number()) :: boolean() + def pre_migration_block_number?(block_number) do + l2_migration_block_number = Application.get_env(:explorer, :celo)[:l2_migration_block] + + if l2_migration_block_number do + block_number < l2_migration_block_number + else + true + end + end + + @doc """ + Checks if an epoch number is prior to Celo L2 migration. + """ + @spec pre_migration_epoch_number?(non_neg_integer()) :: boolean() + def pre_migration_epoch_number?(epoch_number) do l2_migration_block_number = Application.get_env(:explorer, :celo)[:l2_migration_block] if l2_migration_block_number do - block_number <= l2_migration_block_number + l2_migration_epoch_number = l2_migration_block_number |> block_number_to_epoch_number() + epoch_number < l2_migration_epoch_number else true end diff --git a/apps/explorer/lib/explorer/chain/celo/legacy/accounts.ex b/apps/explorer/lib/explorer/chain/celo/legacy/accounts.ex new file mode 100644 index 000000000000..04fd64d744d1 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/legacy/accounts.ex @@ -0,0 +1,211 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.Legacy.Accounts do + @moduledoc """ + Helper functions for transforming data for Celo accounts. + + TODO: this implementation is ported from the celo's fork of blockscout and + could be improved in the future. + """ + + require Logger + + alias ABI.TypeDecoder + alias Explorer.Chain.Celo.Legacy.Events + + @max_name_length 30 + + @doc """ + Returns a list of account addresses given a list of logs. + """ + def parse(logs) do + logs = + logs + |> Enum.map( + &Map.merge(&1, %{ + first_topic: to_string(&1.first_topic), + second_topic: to_string(&1.second_topic), + third_topic: to_string(&1.third_topic), + fourth_topic: to_string(&1.fourth_topic), + data: to_string(&1.data) + }) + ) + + %{ + # Add special items for voter epoch rewards + accounts: get_addresses(logs, Events.account_events()), + # Adding a group to updated validators means to update all members of the group + validators: + get_addresses(logs, Events.validator_events()) ++ + get_addresses(logs, Events.membership_events()) ++ + get_addresses(logs, Events.membership_events(), fn a -> a.third_topic end), + account_names: get_names(logs), + validator_groups: + get_addresses(logs, Events.validator_group_events()) ++ + get_addresses(logs, Events.vote_events(), fn a -> a.third_topic end), + withdrawals: [get_withdrawal_events(logs, Events.gold_withdrawn())], + unlocked: [get_withdrawal_events(logs, Events.gold_unlocked())], + signers: get_signers(logs, Events.signer_events()), + voters: get_voters(logs, Events.voter_events()), + attestations_fulfilled: get_addresses(logs, [Events.attestation_completed_event()], fn a -> a.fourth_topic end), + attestations_requested: + get_addresses(logs, [Events.attestation_issuer_selected_event()], fn a -> a.fourth_topic end), + wallets: get_wallets(logs) + } + end + + def get_names(logs) do + logs + |> Enum.filter(fn log -> log.first_topic == Events.account_name_event() end) + |> Enum.reduce([], fn log, names -> do_parse_name(log, names) end) + |> Enum.filter(fn %{name: name} -> String.length(name) > 0 end) + end + + defp get_addresses(logs, topics, get_topic \\ fn a -> a.second_topic end) do + logs + |> Enum.filter(fn log -> Enum.member?(topics, log.first_topic) end) + |> Enum.reduce([], fn log, accounts -> do_parse(log, accounts, get_topic) end) + end + + defp get_withdrawal_events(logs, topics) do + logs + |> Enum.filter(fn log -> Enum.member?(topics, log.first_topic) end) + |> Enum.reduce([], fn log, accounts -> do_parse_withdrawal_events(log, accounts, fn a -> a.second_topic end) end) + end + + defp get_signers(logs, topics) do + logs + |> Enum.filter(fn log -> Enum.member?(topics, log.first_topic) end) + |> Enum.reduce([], fn log, accounts -> do_parse_signers(log, accounts) end) + end + + def get_wallets(logs) do + logs + |> Enum.filter(fn log -> log.first_topic == Events.account_wallet_address_set_event() end) + |> Enum.reduce([], fn log, wallets -> do_parse_wallets(log, wallets) end) + end + + def get_voters(logs, topics) do + logs + |> Enum.filter(fn log -> Enum.member?(topics, log.first_topic) end) + |> Enum.reduce([], fn log, accounts -> do_parse_voters(log, accounts) end) + end + + defp do_parse(log, accounts, get_topic) do + account_address_hash = parse_params(log, get_topic) + entry = %{address_hash: account_address_hash, block_number: log.block_number} + + if Enum.any?(accounts, &(&1.address_hash == account_address_hash)) do + accounts + else + [entry | accounts] + end + rescue + _ in [FunctionClauseError, MatchError] -> + Logger.error(fn -> "Unknown account event format: #{inspect(log)}" end) + accounts + end + + defp do_parse_signers(log, accounts) do + signer_pair = parse_signer_params(log) + [signer_pair | accounts] + rescue + _ in [FunctionClauseError, MatchError] -> + Logger.error(fn -> "Unknown signer authorization event format: #{inspect(log)}" end) + accounts + end + + defp do_parse_wallets(log, wallets) do + wallet = parse_wallet_params(log) + [wallet | wallets] + rescue + _ in [FunctionClauseError, MatchError] -> + Logger.error(fn -> "Unknown account wallet address set event format: #{inspect(log)}" end) + wallets + end + + defp do_parse_voters(log, accounts) do + pair = parse_voter_params(log) + [pair | accounts] + rescue + _ in [FunctionClauseError, MatchError] -> + Logger.error(fn -> "Unknown voting event format: #{inspect(log)}" end) + accounts + end + + defp do_parse_name(log, names) do + [name] = decode_data(log.data, [:string]) + + entry = %{ + name: String.slice(name, 0, @max_name_length), + address_hash: truncate_address_hash(log.second_topic), + primary: true + } + + [entry | names] + rescue + _ in [FunctionClauseError, MatchError] -> + Logger.error(fn -> "Unknown account name event format: #{inspect(log)}" end) + names + end + + defp do_parse_withdrawal_events(log, accounts, get_topic) do + account_address = parse_params(log, get_topic) + + # GoldUnlocked has 2 unindexed parameters which end up in the data field, while the rest of the withdrawal events + # only 1. Each of these parameters are of length 64 plus 2 for the 0x. + if String.length(log.data) > 66 do + [amount, available] = decode_data(log.data, [{:uint, 256}, {:uint, 256}]) + %{address: account_address, amount: amount, available: available} + else + [amount] = decode_data(log.data, [{:uint, 256}]) + %{address: account_address, amount: amount} + end + rescue + _ in [FunctionClauseError, MatchError] -> + Logger.error(fn -> "Unknown account event format: #{inspect(log)}" end) + accounts + end + + defp parse_params(log, get_topic) do + truncate_address_hash(get_topic.(log)) + end + + defp parse_signer_params(log) do + address = truncate_address_hash(log.second_topic) + [signer] = decode_data(log.data, [:address]) + %{address: address, signer: signer} + end + + defp parse_wallet_params(log) do + account = truncate_address_hash(log.second_topic) + wallet = truncate_address_hash(log.data) + + %{ + account_address_hash: account, + wallet_address_hash: wallet, + block_number: log.block_number + } + end + + defp parse_voter_params(log) do + voter_address = truncate_address_hash(log.second_topic) + group_address = truncate_address_hash(log.third_topic) + %{group_address: group_address, voter_address: voter_address} + end + + defp truncate_address_hash(nil), do: "0x0000000000000000000000000000000000000000" + + defp truncate_address_hash("0x000000000000000000000000" <> truncated_hash) do + "0x#{truncated_hash}" + end + + defp decode_data("0x", types) do + for _ <- types, do: nil + end + + defp decode_data("0x" <> encoded_data, types) do + encoded_data + |> Base.decode16!(case: :mixed) + |> TypeDecoder.decode_raw(types) + end +end diff --git a/apps/explorer/lib/explorer/chain/celo/legacy/events.ex b/apps/explorer/lib/explorer/chain/celo/legacy/events.ex new file mode 100644 index 000000000000..9f54269c0490 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/legacy/events.ex @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.Legacy.Events do + @moduledoc """ + Helper methods and event groups which define Celo contract event topics + """ + + @account_created "0x805996f252884581e2f74cf3d2b03564d5ec26ccc90850ae12653dc1b72d1fa2" + @account_data_encryption_key_set "0x43fdefe0a824cb0e3bbaf9c4bc97669187996136fe9282382baf10787f0d808d" + @account_name_set "0xa6e2c5a23bb917ba0a584c4b250257ddad698685829b66a8813c004b39934fe4" + @account_url_set "0x0b5629fec5b6b5a1c2cfe0de7495111627a8cf297dced72e0669527425d3f01b" + @account_wallet_address_set "0xf81d74398fd47e35c36b714019df15f200f623dde569b5b531d6a0b4da5c5f26" + @attestation_completed "0x414ff2c18c092697c4b8de49f515ac44f8bebc19b24553cf58ace913a6ac639d" + @attestation_issuer_selected "0xaf7f470b643316cf44c1f2898328a075e7602945b4f8584f48ba4ad2d8a2ea9d" + @attestation_signer_authorized "0x9dfbc5a621c3e2d0d83beee687a17dfc796bbce2118793e5e254409bb265ca0b" + @gold_locked "0x0f0f2fc5b4c987a49e1663ce2c2d65de12f3b701ff02b4d09461421e63e609e7" + @gold_unlocked "0xb1a3aef2a332070da206ad1868a5e327f5aa5144e00e9a7b40717c153158a588" + @gold_withdrawn "0x292d39ba701489b7f640c83806d3eeabe0a32c9f0a61b49e95612ebad42211cd" + @oracle_reported "0x7cebb17173a9ed273d2b7538f64395c0ebf352ff743f1cf8ce66b437a6144213" + @validator_affiliated "0x91ef92227057e201e406c3451698dd780fe7672ad74328591c88d281af31581d" + @validator_deaffiliated "0x71815121f0622b31a3e7270eb28acb9fd10825ff418c9a18591f617bb8a31a6c" + @validator_epoch_payment_distributed "0x6f5937add2ec38a0fa4959bccd86e3fcc2aafb706cd3e6c0565f87a7b36b9975" + @validator_group_active_vote_revoked "0xae7458f8697a680da6be36406ea0b8f40164915ac9cc40c0dad05a2ff6e8c6a8" + @validator_group_commission_updated "0x815d292dbc1a08dfb3103aabb6611233dd2393903e57bdf4c5b3db91198a826c" + @validator_group_deregistered "0xae7e034b0748a10a219b46074b20977a9170bf4027b156c797093773619a8669" + @validator_group_epoch_rewards_distributed "0x91ba34d62474c14d6c623cd322f4256666c7a45b7fdaa3378e009d39dfcec2a7" + @validator_group_member_added "0xbdf7e616a6943f81e07a7984c9d4c00197dc2f481486ce4ffa6af52a113974ad" + @validator_group_member_removed "0xc7666a52a66ff601ff7c0d4d6efddc9ac20a34792f6aa003d1804c9d4d5baa57" + @validator_group_member_reordered "0x38819cc49a343985b478d72f531a35b15384c398dd80fd191a14662170f895c6" + @validator_group_pending_vote_revoked "0x148075455e24d5cf538793db3e917a157cbadac69dd6a304186daf11b23f76fe" + @validator_group_registered "0xbf4b45570f1907a94775f8449817051a492a676918e38108bb762e991e6b58dc" + @validator_group_vote_activated "0x45aac85f38083b18efe2d441a65b9c1ae177c78307cb5a5d4aec8f7dbcaeabfe" + @validator_group_vote_cast "0xd3532f70444893db82221041edb4dc26c94593aeb364b0b14dfc77d5ee905152" + @validator_registered "0xd09501348473474a20c772c79c653e1fd7e8b437e418fe235d277d2c88853251" + @validator_score_updated "0xedf9f87e50e10c533bf3ae7f5a7894ae66c23e6cbbe8773d7765d20ad6f995e9" + @validator_signer_authorized "0x16e382723fb40543364faf68863212ba253a099607bf6d3a5b47e50a8bf94943" + @vote_signer_authorized "0xaab5f8a189373aaa290f42ae65ea5d7971b732366ca5bf66556e76263944af28" + @voter_rewards "0x91ba34d62474c14d6c623cd322f4256666c7a45b7fdaa3378e009d39dfcec2a7" + + @doc """ + Events for updating account + """ + def account_events, + do: [ + @gold_withdrawn, + @gold_unlocked, + @gold_locked, + @account_created, + @account_wallet_address_set, + @account_url_set, + @account_name_set, + @account_data_encryption_key_set, + @validator_group_pending_vote_revoked, + @validator_group_active_vote_revoked, + @validator_group_vote_activated, + @validator_group_vote_cast, + @validator_group_epoch_rewards_distributed, + @validator_epoch_payment_distributed, + @validator_registered, + @validator_group_registered + ] + + @doc """ + Events for updating validator + """ + def validator_events, + do: [ + @validator_registered, + @validator_affiliated, + @validator_deaffiliated, + @validator_score_updated, + @validator_signer_authorized, + @validator_epoch_payment_distributed + ] + + def membership_events, + do: [ + @validator_group_member_added, + @validator_group_member_removed, + @validator_group_member_reordered + ] + + @doc """ + Events for updating validator group + """ + def validator_group_events, + do: [ + @validator_group_epoch_rewards_distributed, + @validator_group_commission_updated, + @validator_group_registered, + @validator_group_deregistered + ] + + def validator_group_voter_reward_events, + do: [ + @validator_group_epoch_rewards_distributed + ] + + def vote_events, + do: [ + @validator_group_pending_vote_revoked, + @validator_group_active_vote_revoked, + @validator_group_vote_cast + ] + + @doc """ + Events for notifications + """ + def withdrawal_events, + do: [ + @gold_withdrawn, + @gold_unlocked, + @gold_locked + ] + + def gold_unlocked, + do: [ + @gold_unlocked + ] + + def gold_withdrawn, + do: [ + @gold_withdrawn + ] + + def signer_events, + do: [ + @validator_signer_authorized, + @vote_signer_authorized, + @attestation_signer_authorized + ] + + @doc """ + Events for updating voter + """ + def voter_events, + do: [ + @validator_group_active_vote_revoked, + @validator_group_pending_vote_revoked, + @validator_group_vote_activated, + @validator_group_vote_cast + ] + + def distributed_events, + do: [ + @voter_rewards + ] + + def attestation_issuer_selected_event, + do: @attestation_issuer_selected + + def attestation_completed_event, + do: @attestation_completed + + def oracle_reported_event, + do: @oracle_reported + + def account_name_event, + do: @account_name_set + + def account_wallet_address_set_event, + do: @account_wallet_address_set +end diff --git a/apps/explorer/lib/explorer/chain/celo/pending_account_operation.ex b/apps/explorer/lib/explorer/chain/celo/pending_account_operation.ex new file mode 100644 index 000000000000..9f95b78e7547 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/pending_account_operation.ex @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.PendingAccountOperation do + @moduledoc """ + Tracks an address that is pending for fetching of celo account info. + """ + + use Explorer.Schema + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Address, Hash} + + @required_attrs ~w(address_hash)a + @allowed_attrs @required_attrs + + @typedoc """ + * `address_hash` - the hash of the address that is pending to be fetched. + """ + @primary_key false + typed_schema "celo_pending_account_operations" do + belongs_to(:address, Address, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address, + primary_key: true + ) + + timestamps() + end + + @spec changeset( + t(), + :invalid | %{optional(:__struct__) => none(), optional(atom() | binary()) => any()} + ) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = pending_ops, attrs) do + pending_ops + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + |> foreign_key_constraint(:address_hash, name: :celo_pending_account_operations_address_hash_fkey) + |> unique_constraint(:address_hash, name: :celo_pending_account_operations_pkey) + end + + @doc """ + Returns a stream of pending operations. + """ + @spec stream( + initial :: accumulator, + reducer :: (entry :: term(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream(initial, reducer, limited? \\ false) + when is_function(reducer, 2) do + __MODULE__ + |> order_by([op], desc: op.address_hash) + |> Chain.add_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Deletes pending operations by address hashes. + """ + @spec delete_by_address_hashes([Hash.Address.t()]) :: {integer, nil | [term()]} + def delete_by_address_hashes(address_hashes) do + query = + from(p in __MODULE__, + where: p.address_hash in ^address_hashes + ) + + Repo.delete_all(query) + end +end diff --git a/apps/explorer/lib/explorer/chain/celo/pending_epoch_block_operation.ex b/apps/explorer/lib/explorer/chain/celo/pending_epoch_block_operation.ex deleted file mode 100644 index 458360d6d9d9..000000000000 --- a/apps/explorer/lib/explorer/chain/celo/pending_epoch_block_operation.ex +++ /dev/null @@ -1,87 +0,0 @@ -defmodule Explorer.Chain.Celo.PendingEpochBlockOperation do - @moduledoc """ - Tracks an epoch block that has pending operation. - """ - - use Explorer.Schema - - import Explorer.Chain, only: [add_fetcher_limit: 2] - alias Explorer.Chain.{Block, Hash} - alias Explorer.Repo - - @required_attrs ~w(block_hash)a - - @typedoc """ - * `block_hash` - the hash of the block that has pending epoch operations. - """ - @primary_key false - typed_schema "celo_pending_epoch_block_operations" do - belongs_to(:block, Block, - foreign_key: :block_hash, - primary_key: true, - references: :hash, - type: Hash.Full, - null: false - ) - - timestamps() - end - - def changeset(%__MODULE__{} = pending_ops, attrs) do - pending_ops - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs) - |> foreign_key_constraint(:block_hash) - |> unique_constraint(:block_hash, name: :pending_epoch_block_operations_pkey) - end - - @doc """ - Returns a stream of all blocks prior to Celo L2 migration with unfetched - epochs, using the `celo_pending_epoch_block_operations` table. - - iex> unfetched = insert(:block, number: 1 * blocks_per_epoch()) - iex> insert(:celo_pending_epoch_block_operation, block: unfetched) - iex> {:ok, blocks} = PendingEpochBlockOperation.stream_premigration_epoch_blocks_with_unfetched_rewards( - ...> [], - ...> fn block, acc -> - ...> [block | acc] - ...> end - ...> ) - iex> [{unfetched.number, unfetched.hash}] == blocks - true - """ - @spec stream_premigration_epoch_blocks_with_unfetched_rewards( - initial :: accumulator, - reducer :: (entry :: term(), accumulator -> accumulator), - limited? :: boolean() - ) :: {:ok, accumulator} - when accumulator: term() - def stream_premigration_epoch_blocks_with_unfetched_rewards(initial, reducer, limited? \\ false) - when is_function(reducer, 2) do - query = - from( - op in __MODULE__, - join: block in assoc(op, :block), - select: %{block_number: block.number, block_hash: block.hash}, - where: block.consensus == true, - order_by: [desc: block.number] - ) - - query - |> maybe_filter_premigration_epoch_blocks() - |> add_fetcher_limit(limited?) - |> Repo.stream_reduce(initial, reducer) - end - - @spec maybe_filter_premigration_epoch_blocks(Ecto.Query.t()) :: Ecto.Query.t() - defp maybe_filter_premigration_epoch_blocks(query) do - l2_migration_block_number = Application.get_env(:explorer, :celo)[:l2_migration_block] - - if l2_migration_block_number do - query - |> where([op, block], block.number <= ^l2_migration_block_number) - else - query - end - end -end diff --git a/apps/explorer/lib/explorer/chain/celo/reader.ex b/apps/explorer/lib/explorer/chain/celo/reader.ex deleted file mode 100644 index 8a76aed80e23..000000000000 --- a/apps/explorer/lib/explorer/chain/celo/reader.ex +++ /dev/null @@ -1,213 +0,0 @@ -defmodule Explorer.Chain.Celo.Reader do - @moduledoc """ - Read functions for Celo modules. - """ - - import Ecto.Query, only: [limit: 2] - - alias Explorer.Chain - alias Explorer.Chain.Block - alias Explorer.Chain.Cache.{Blocks, CeloCoreContracts} - alias Explorer.Chain.Celo.{ElectionReward, Helper} - alias Explorer.Chain.{Hash, Token, Wei} - - @election_reward_types ElectionReward.types() - @default_paging_options Chain.default_paging_options() - - @doc """ - Retrieves election rewards associated with a given address hash. - - ## Parameters - - `address_hash` (`Hash.Address.t()`): The address hash to search for election - rewards. - - `options` (`Keyword.t()`): Optional parameters for fetching data. - - ## Returns - - `[ElectionReward.t()]`: A list of election rewards associated with the - address hash. - - ## Examples - - iex> address_hash = %Hash.Address{ - ...> byte_count: 20, - ...> bytes: <<0x1d1f7f0e1441c37e28b89e0b5e1edbbd34d77649 :: size(160)>> - ...> } - iex> Explorer.Chain.Celo.Reader.address_hash_to_election_rewards(address_hash) - [%ElectionReward{}, ...] - """ - @spec address_hash_to_election_rewards( - Hash.Address.t(), - Keyword.t() - ) :: [ElectionReward.t()] - def address_hash_to_election_rewards(address_hash, options \\ []) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - from_block = Chain.from_block(options) - to_block = Chain.to_block(options) - - address_hash - |> ElectionReward.address_hash_to_ordered_rewards_query() - |> ElectionReward.where_block_number_in_period(from_block, to_block) - |> ElectionReward.join_token() - |> ElectionReward.paginate(paging_options) - |> limit(^paging_options.page_size) - |> Chain.join_associations(necessity_by_association) - |> Chain.select_repo(options).all() - end - - @doc """ - Retrieves election rewards by block hash and reward type. - - ## Parameters - - `block_hash` (`Hash.t()`): The block hash to search for election rewards. - - `reward_type` (`ElectionReward.type()`): The type of reward to filter. - - `options` (`Keyword.t()`): Optional parameters for fetching data. - - ## Returns - - `[ElectionReward.t()]`: A list of election rewards filtered by block hash - and reward type. - - ## Examples - - iex> block_hash = %Hash.Full{ - ...> byte_count: 32, - ...> bytes: <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: big-integer-size(32)-unit(8)>> - ...> } - iex> Explorer.Chain.Celo.Reader.block_hash_to_election_rewards_by_type(block_hash, :voter_reward) - [%ElectionReward{}, ...] - """ - @spec block_hash_to_election_rewards_by_type( - Hash.t(), - ElectionReward.type(), - Keyword.t() - ) :: [ElectionReward.t()] - def block_hash_to_election_rewards_by_type(block_hash, reward_type, options \\ []) - when reward_type in @election_reward_types do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, @default_paging_options) - - block_hash - |> ElectionReward.block_hash_to_rewards_by_type_query(reward_type) - |> ElectionReward.paginate(paging_options) - |> limit(^paging_options.page_size) - |> Chain.join_associations(necessity_by_association) - |> Chain.select_repo(options).all() - end - - @doc """ - Retrieves aggregated election rewards by block hash. - - ## Parameters - - `block_hash` (`Hash.Full.t()`): The block hash to aggregate election - rewards. - - `options` (`Keyword.t()`): Optional parameters for fetching data. - - ## Returns - - `%{atom() => Wei.t() | nil}`: A map of aggregated election rewards by type. - - ## Examples - - iex> block_hash = %Hash.Full{ - ...> byte_count: 32, - ...> bytes: <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: big-integer-size(32)-unit(8)>> - ...> } - iex> Explorer.Chain.Celo.Reader.block_hash_to_aggregated_election_rewards_by_type(block_hash) - %{voter_reward: %{total: %Decimal{}, count: 2}, ...} - """ - @spec block_hash_to_aggregated_election_rewards_by_type( - Hash.Full.t(), - Keyword.t() - ) :: %{atom() => Wei.t() | nil} - def block_hash_to_aggregated_election_rewards_by_type(block_hash, options \\ []) do - reward_type_to_token = - block_hash_to_election_reward_token_addresses_by_type( - block_hash, - options - ) - - reward_type_to_aggregated_rewards = - block_hash - |> ElectionReward.block_hash_to_aggregated_rewards_by_type_query() - |> Chain.select_repo(options).all() - |> Map.new(fn {type, total, count} -> - {type, %{total: total, count: count}} - end) - - ElectionReward.types() - |> Map.new(&{&1, %{total: Decimal.new(0), count: 0}}) - |> Map.merge(reward_type_to_aggregated_rewards) - |> Map.new(fn {type, aggregated_reward} -> - token = Map.get(reward_type_to_token, type) - aggregated_reward_with_token = Map.put(aggregated_reward, :token, token) - {type, aggregated_reward_with_token} - end) - end - - # Retrieves the token for each type of election reward on the given block. - # - # ## Parameters - # - `block_hash` (`Hash.Full.t()`): The block hash to search for token - # addresses. - # - `options` (`Keyword.t()`): Optional parameters for fetching data. - # - # ## Returns - # - `%{atom() => Token.t() | nil}`: A map of reward types to token. - # - # ## Examples - # - # iex> block_hash = %Hash.Full{ - # ...> byte_count: 32, - # ...> bytes: <<0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b :: big-integer-size(32)-unit(8)>> - # ...> } - # iex> Explorer.Chain.Celo.Reader.block_hash_to_election_reward_token_addresses_by_type(block_hash) - # %{voter_reward: %Token{}, ...} - @spec block_hash_to_election_reward_token_addresses_by_type( - Hash.Full.t(), - Keyword.t() - ) :: %{atom() => Token.t() | nil} - defp block_hash_to_election_reward_token_addresses_by_type(block_hash, options) do - contract_address_hash_to_atom = - ElectionReward.reward_type_atom_to_token_atom() - |> Map.values() - |> Map.new(fn token_atom -> - {:ok, contract_address_hash} = CeloCoreContracts.get_address(token_atom, block_hash) - {contract_address_hash, token_atom} - end) - - token_atom_to_token = - contract_address_hash_to_atom - |> Map.keys() - |> Token.get_by_contract_address_hashes(options) - |> Map.new(fn token -> - hash = to_string(token.contract_address_hash) - atom = contract_address_hash_to_atom[hash] - {atom, token} - end) - - ElectionReward.reward_type_atom_to_token_atom() - |> Map.new(fn {reward_type_atom, token_atom} -> - {reward_type_atom, Map.get(token_atom_to_token, token_atom)} - end) - end - - @doc """ - Retrieves the epoch number of the last fetched block. - """ - @spec last_block_epoch_number(Keyword.t()) :: Block.block_number() | nil - def last_block_epoch_number(options \\ []) do - block_number = - 1 - |> Blocks.atomic_take_enough() - |> case do - [%Block{number: number}] -> {:ok, number} - nil -> Chain.max_consensus_block_number(options) - end - |> case do - {:ok, number} -> number - _ -> nil - end - - block_number && Helper.block_number_to_epoch_number(block_number) - end -end diff --git a/apps/explorer/lib/explorer/chain/celo/reader/epoch_manager.ex b/apps/explorer/lib/explorer/chain/celo/reader/epoch_manager.ex new file mode 100644 index 000000000000..cdd1063aa54a --- /dev/null +++ b/apps/explorer/lib/explorer/chain/celo/reader/epoch_manager.ex @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Celo.Reader.EpochManager do + @moduledoc """ + Functions for interacting with the Celo Epoch Manager contract. + """ + + use Utils.RuntimeEnvHelper, + epoch_manager_contract_address_hash: [ + :explorer, + [:celo, :epoch_manager_contract_address] + ] + + @get_first_block_at_epoch_abi [ + %{ + "inputs" => [%{"type" => "uint256"}], + "name" => "getFirstBlockAtEpoch", + "outputs" => [%{"type" => "uint256"}], + "stateMutability" => "view", + "type" => "function" + } + ] + + @get_last_block_at_epoch_abi [ + %{ + "inputs" => [%{"type" => "uint256"}], + "name" => "getLastBlockAtEpoch", + "outputs" => [%{"type" => "uint256"}], + "stateMutability" => "view", + "type" => "function" + } + ] + + alias Explorer.Helper + alias Explorer.SmartContract.Reader + + @doc """ + Retrieves the first block number in a given epoch. + + ## Parameters + + * `block_number` - The epoch number to retrieve epoch for. + + ## Returns + + * `{:ok, number}` - The first block number in the epoch. + * `:error` - If the request fails. + """ + @spec fetch_first_block_at_epoch(non_neg_integer()) :: + {:ok, non_neg_integer()} | :error + def fetch_first_block_at_epoch(block_number) do + method_id = Helper.abi_to_method_id(@get_first_block_at_epoch_abi) + + epoch_manager_contract_address_hash() + |> Reader.query_contract( + @get_first_block_at_epoch_abi, + %{method_id => [block_number]}, + false + ) + |> case do + %{^method_id => {:ok, [number]}} -> {:ok, number} + _ -> :error + end + end + + @doc """ + Retrieves the last block number in a given epoch. + + ## Parameters + + * `block_number` - The epoch number to retrieve epoch for. + + ## Returns + + * `{:ok, number}` - The last block number in the epoch. + * `:error` - If the request fails. + """ + @spec fetch_last_block_at_epoch(non_neg_integer()) :: + {:ok, non_neg_integer()} | :error + def fetch_last_block_at_epoch(block_number) do + method_id = Helper.abi_to_method_id(@get_last_block_at_epoch_abi) + + epoch_manager_contract_address_hash() + |> Reader.query_contract( + @get_last_block_at_epoch_abi, + %{method_id => [block_number]}, + false + ) + |> case do + %{^method_id => {:ok, [number]}} -> {:ok, number} + _ -> :error + end + end +end diff --git a/apps/explorer/lib/explorer/chain/celo/validator_group_vote.ex b/apps/explorer/lib/explorer/chain/celo/validator_group_vote.ex index 221b1c62c0b8..ba5c74f7293e 100644 --- a/apps/explorer/lib/explorer/chain/celo/validator_group_vote.ex +++ b/apps/explorer/lib/explorer/chain/celo/validator_group_vote.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Celo.ValidatorGroupVote do @moduledoc """ Represents the information about a vote for a validator group made by an @@ -9,7 +10,7 @@ defmodule Explorer.Chain.Celo.ValidatorGroupVote do alias Explorer.Chain.{Address, Block, Hash, Transaction} @types_enum ~w(activated revoked)a - @required_attrs ~w(account_address_hash group_address_hash type transaction_hash block_hash block_number)a + @required_attrs ~w(account_address_hash group_address_hash type transaction_hash block_hash block_number log_index)a @typedoc """ * `account_address_hash` - the address of the account that made the vote. @@ -42,6 +43,7 @@ defmodule Explorer.Chain.Celo.ValidatorGroupVote do ) field(:block_number, :integer, null: false) + field(:log_index, :integer, primary_key: true, null: false) belongs_to(:block, Block, foreign_key: :block_hash, diff --git a/apps/explorer/lib/explorer/chain/contract_method.ex b/apps/explorer/lib/explorer/chain/contract_method.ex index 021e62d4f7d9..156d46cb1fc2 100644 --- a/apps/explorer/lib/explorer/chain/contract_method.ex +++ b/apps/explorer/lib/explorer/chain/contract_method.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ContractMethod do @moduledoc """ The representation of an individual item from the ABI of a verified smart contract. @@ -8,8 +9,8 @@ defmodule Explorer.Chain.ContractMethod do import Ecto.Query, only: [from: 2] use Explorer.Schema - alias Explorer.Chain.{Hash, MethodIdentifier, SmartContract} alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Data, Hash, MethodIdentifier, SmartContract} typed_schema "contract_methods" do field(:identifier, MethodIdentifier) @@ -35,7 +36,7 @@ defmodule Explorer.Chain.ContractMethod do end end) - unless Enum.empty?(errors) do + if !Enum.empty?(errors) do Logger.error(fn -> ["Error parsing some abi elements at ", Hash.to_iodata(address_hash), ": ", Enum.intersperse(errors, "\n")] end) @@ -44,7 +45,10 @@ defmodule Explorer.Chain.ContractMethod do # Enforce ContractMethod ShareLocks order (see docs: sharelocks.md) ordered_successes = Enum.sort_by(successes, &{&1.identifier, &1.abi}) - Repo.insert_all(__MODULE__, ordered_successes, on_conflict: :nothing, conflict_target: [:identifier, :abi]) + Repo.insert_all(__MODULE__, ordered_successes, + on_conflict: :nothing, + conflict_target: {:unsafe_fragment, ~s<(identifier, md5(abi::text))>} + ) end def import_all do @@ -67,7 +71,7 @@ defmodule Explorer.Chain.ContractMethod do @doc """ Query that finds limited number of contract methods by selector id """ - @spec find_contract_method_query(binary(), integer()) :: Ecto.Query.t() + @spec find_contract_method_query(binary() | Data.t(), integer()) :: Ecto.Query.t() def find_contract_method_query(method_id, limit) do from( contract_method in __MODULE__, @@ -137,8 +141,10 @@ defmodule Explorer.Chain.ContractMethod do # we always take only the first 4 bytes of the hash. <> = selector.method_id + {:ok, method_id} = MethodIdentifier.cast(first_four_bytes) + %{ - identifier: first_four_bytes, + identifier: method_id, abi: element, type: Atom.to_string(selector.type), inserted_at: now, diff --git a/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex b/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex index 1d667bde15b9..2871fe4f999c 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/address/logs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CsvExport.Address.Logs do @moduledoc """ Exports logs to a csv file. @@ -5,11 +6,11 @@ defmodule Explorer.Chain.CsvExport.Address.Logs do alias Explorer.Chain alias Explorer.Chain.{Address, Hash} - alias Explorer.Chain.CsvExport.Helper + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} @spec export(Hash.Address.t(), String.t(), String.t(), Keyword.t(), String.t() | nil, String.t() | nil) :: Enumerable.t() - def export(address_hash, from_period, to_period, _options, _filter_type \\ nil, filter_value \\ nil) do + def export(address_hash, from_period, to_period, _options, _filter_type, filter_value) do {from_block, to_block} = Helper.block_from_period(from_period, to_period) address_hash @@ -25,6 +26,7 @@ defmodule Explorer.Chain.CsvExport.Address.Logs do |> Keyword.put(:from_block, from_block) |> Keyword.put(:to_block, to_block) |> Keyword.put(:topic, filter_value) + |> Keyword.put(:timeout, AsyncHelper.db_timeout()) Chain.address_to_logs(address_hash, true, options) end diff --git a/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex b/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex index b347cc3e9b09..bb647d74336b 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/address/token_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do @moduledoc """ Exports token transfers to a csv file. @@ -11,14 +12,14 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do where: 3 ] - alias Explorer.{PagingOptions, Repo} alias Explorer.Chain.{Address, DenormalizationHelper, Hash, TokenTransfer, Transaction} - alias Explorer.Chain.CsvExport.Helper + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} alias Explorer.Helper, as: ExplorerHelper + alias Explorer.{PagingOptions, Repo} @spec export(Hash.Address.t(), String.t(), String.t(), Keyword.t(), String.t() | nil, String.t() | nil) :: Enumerable.t() - def export(address_hash, from_period, to_period, options, filter_type \\ nil, filter_value \\ nil) do + def export(address_hash, from_period, to_period, options, filter_type, filter_value) do {from_block, to_block} = Helper.block_from_period(from_period, to_period) paging_options = %PagingOptions{Helper.paging_options() | asc_order: true} @@ -45,6 +46,7 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do |> Keyword.put(:to_block, to_block) |> Keyword.put(:filter_type, filter_type) |> Keyword.put(:filter_value, filter_value) + |> Keyword.put(:timeout, AsyncHelper.db_timeout()) address_hash_to_token_transfers_including_contract(address_hash, options) end @@ -111,6 +113,7 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do @spec address_hash_to_token_transfers_including_contract(Hash.Address.t(), Keyword.t()) :: [TokenTransfer.t()] def address_hash_to_token_transfers_including_contract(address_hash, options \\ []) do paging_options = Keyword.get(options, :paging_options, Helper.default_paging_options()) + timeout = Keyword.get(options, :timeout) case paging_options do %PagingOptions{key: {0, 0}} -> @@ -132,7 +135,7 @@ defmodule Explorer.Chain.CsvExport.Address.TokenTransfers do |> handle_token_transfer_paging_options(paging_options) |> preload(^DenormalizationHelper.extend_transaction_preload([:transaction])) |> preload(:token) - |> Repo.all() + |> Repo.replica().all(ExplorerHelper.maybe_timeout(timeout)) end end diff --git a/apps/explorer/lib/explorer/chain/csv_export/address/transactions.ex b/apps/explorer/lib/explorer/chain/csv_export/address/transactions.ex index 657b792ac4c5..756b8032bb19 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/address/transactions.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/address/transactions.ex @@ -1,16 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CsvExport.Address.Transactions do @moduledoc """ Exports transactions to a csv file. """ + alias Explorer.Chain.{Address, DenormalizationHelper, Hash, Transaction, Wei} + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} alias Explorer.Market alias Explorer.Market.MarketHistory - alias Explorer.Chain.{Address, DenormalizationHelper, Hash, Transaction, Wei} - alias Explorer.Chain.CsvExport.Helper @spec export(Hash.Address.t(), String.t(), String.t(), Keyword.t(), String.t() | nil, String.t() | nil) :: Enumerable.t() - def export(address_hash, from_period, to_period, _options, filter_type \\ nil, filter_value \\ nil) do + def export(address_hash, from_period, to_period, _options, filter_type, filter_value) do {from_block, to_block} = Helper.block_from_period(from_period, to_period) exchange_rate = Market.get_coin_exchange_rate() @@ -33,6 +34,7 @@ defmodule Explorer.Chain.CsvExport.Address.Transactions do |> Keyword.put(:paging_options, paging_options) |> Keyword.put(:from_block, from_block) |> Keyword.put(:to_block, to_block) + |> Keyword.put(:timeout, AsyncHelper.db_timeout()) |> (&if(Helper.valid_filter?(filter_type, filter_value, "transactions"), do: &1 |> Keyword.put(:direction, String.to_atom(filter_value)), else: &1 diff --git a/apps/explorer/lib/explorer/chain/csv_export/advanced_filter.ex b/apps/explorer/lib/explorer/chain/csv_export/advanced_filter.ex new file mode 100644 index 000000000000..a882e70baca9 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/advanced_filter.ex @@ -0,0 +1,211 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.AdvancedFilter do + @moduledoc """ + Module responsible for exporting advanced filters to CSV. + """ + + alias Explorer.Chain.Address + alias Explorer.Chain.{AdvancedFilter, MethodIdentifier, TokenTransfer} + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} + alias Explorer.Market + alias Explorer.Market.MarketHistory + + @spec export(Keyword.t()) :: + Enumerable.t() + def export(full_options) do + full_options + |> Keyword.put(:timeout, AsyncHelper.db_timeout()) + |> AdvancedFilter.list() + |> to_csv_format() + |> Helper.dump_to_stream() + end + + @doc """ + Converts a list or stream of advanced filter entities to an appropriate CSV-streaming format. + + Builds the CSV's header row and a stream of rows corresponding to each advanced filter, + resolving historical market data for each row's date. + + ## Parameters + + - advanced_filters: An enumerable of advanced filter structs with `timestamp` and related fields. + + ## Returns + + - A stream where the first row is the header, followed by CSV data rows for each advanced filter. + """ + @spec to_csv_format(Enumerable.t()) :: Enumerable.t() + def to_csv_format(advanced_filters) do + exchange_rate = Market.get_coin_exchange_rate() + + date_to_prices = + Enum.reduce(advanced_filters, %{}, fn af, acc -> + date = DateTime.to_date(af.timestamp) + + if Map.has_key?(acc, date) do + acc + else + market_history = MarketHistory.price_at_date(date) + + Map.put( + acc, + date, + {market_history && market_history.opening_price, market_history && market_history.closing_price} + ) + end + end) + + row_names = [ + "TxHash", + "Type", + "MethodId", + "UtcTimestamp", + "FromAddress", + "ToAddress", + "CreatedContractAddress", + "Value", + "TokenContractAddressHash", + "TokenDecimals", + "TokenSymbol", + "TokenValue", + "TokenID", + "BlockNumber", + "Fee", + "CurrentPrice", + "TxDateOpeningPrice", + "TxDateClosingPrice" + ] + + af_lists = + advanced_filters + |> Stream.map(fn advanced_filter -> + method_id = + case advanced_filter.input do + %{bytes: <>} -> + {:ok, method_id} = MethodIdentifier.cast(method_id) + to_string(method_id) + + _ -> + nil + end + + {opening_price, closing_price} = date_to_prices[DateTime.to_date(advanced_filter.timestamp)] + + prepare_advanced_filter_csv_row(advanced_filter, exchange_rate, opening_price, closing_price, method_id) + end) + + Stream.concat([row_names], af_lists) + end + + defp prepare_advanced_filter_csv_row( + %AdvancedFilter{created_from: :token_transfer} = advanced_filter, + _exchange_rate, + _opening_price, + _closing_price, + method_id + ) do + token_transfer_total = prepare_token_transfer_total(advanced_filter.token_transfer) + + [ + to_string(advanced_filter.hash), + advanced_filter.type, + method_id, + advanced_filter.timestamp, + Address.checksum(advanced_filter.from_address_hash), + Address.checksum(advanced_filter.to_address_hash), + Address.checksum(advanced_filter.created_contract_address_hash), + decimal_to_string(advanced_filter.value, :normal), + Address.checksum(advanced_filter.token_transfer.token.contract_address_hash), + decimal_to_string(token_transfer_total["decimals"], :normal), + advanced_filter.token_transfer.token.symbol, + case token_transfer_total["decimals"] do + nil -> + decimal_to_string(token_transfer_total["value"], :xsd) + + decimals -> + token_transfer_total["value"] && + token_transfer_total["value"] + |> Decimal.div(Integer.pow(10, Decimal.to_integer(decimals))) + |> decimal_to_string(:xsd) + end, + token_transfer_total["token_id"], + advanced_filter.block_number, + decimal_to_string(advanced_filter.fee, :normal), + nil, + nil, + nil + ] + end + + defp prepare_advanced_filter_csv_row( + advanced_filter, + exchange_rate, + opening_price, + closing_price, + method_id + ) do + [ + to_string(advanced_filter.hash), + advanced_filter.type, + method_id, + advanced_filter.timestamp, + Address.checksum(advanced_filter.from_address_hash), + Address.checksum(advanced_filter.to_address_hash), + Address.checksum(advanced_filter.created_contract_address_hash), + decimal_to_string(advanced_filter.value, :normal), + nil, + nil, + nil, + nil, + nil, + advanced_filter.block_number, + decimal_to_string(advanced_filter.fee, :normal), + decimal_to_string(exchange_rate.fiat_value, :xsd), + decimal_to_string(opening_price, :xsd), + decimal_to_string(closing_price, :xsd) + ] + end + + defp decimal_to_string(nil, _), do: nil + defp decimal_to_string(decimal, type), do: Decimal.to_string(decimal, type) + + # NOTE: duplicate of BlockScoutWeb.API.V2.TokenTransferView.prepare_token_transfer_total/1 but without the token_instance + defp prepare_token_transfer_total(token_transfer) do + case TokenTransfer.token_transfer_amount_for_api(token_transfer) do + {:ok, :erc721_instance} -> + token_transfer_map_erc721(token_transfer) + + {:ok, :erc1155_erc404_instance, value, decimals} -> + token_transfer_map_erc1155_single(token_transfer, value, decimals) + + {:ok, :erc1155_erc404_instance, values, token_ids, decimals} -> + token_transfer_map_erc1155_multi(values, token_ids, decimals) + + {:ok, value, decimals} -> + %{"value" => value, "decimals" => decimals} + + _ -> + nil + end + end + + defp token_transfer_map_erc721(token_transfer) do + %{"token_id" => token_transfer.token_ids && List.first(token_transfer.token_ids)} + end + + defp token_transfer_map_erc1155_single(token_transfer, value, decimals) do + %{ + "token_id" => token_transfer.token_ids && List.first(token_transfer.token_ids), + "value" => value, + "decimals" => decimals + } + end + + defp token_transfer_map_erc1155_multi(values, token_ids, decimals) do + %{ + "token_id" => token_ids && List.first(token_ids), + "value" => values && List.first(values), + "decimals" => decimals + } + end +end diff --git a/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex b/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex new file mode 100644 index 000000000000..4c8b9ceba185 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/async_helper.ex @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.AsyncHelper do + @moduledoc """ + Async CSV export helper functions. + """ + + alias Explorer.Chain.CsvExport.Request, as: CsvExportRequest + alias Explorer.HttpClient + alias Tesla.Multipart + + require Logger + + @doc """ + Uploads a file to Gokapi. + + Streams the file in chunks, uploads each chunk via the Gokapi chunk API, then completes the upload. The local file is removed after upload (success or failure). + + ## Parameters + + - `file_path`: Path to the local file to upload. + - `filename`: Filename to use for the uploaded file on Gokapi. + - `uuid`: Upload session identifier for the chunked upload. + + ## Returns + + - `{:ok, file_id, expires_at}` on success, where `file_id` is the Gokapi file ID and `expires_at` is the expiration timestamp of the file. + - `{:error, reason}` on failure (e.g. chunk upload or complete request error). + """ + # sobelow_skip ["Traversal.FileModule"] + @spec upload_file(String.t(), String.t(), String.t()) :: {:ok, String.t(), DateTime.t()} | {:error, any()} + def upload_file(file_path, filename, uuid) do + file_size = File.stat!(file_path).size + chunk_size = chunk_size() + + result = + file_path + |> File.stream!(chunk_size) + |> Stream.with_index() + |> Enum.reduce_while(:ok, fn {chunk, index}, _acc -> + case upload_chunk(chunk, uuid, file_size, index * chunk_size) do + :ok -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end) + + case result do + :ok -> complete_upload(uuid, filename, file_size) + error -> error + end + after + File.rm(file_path) + end + + @doc """ + Actualizes a CSV export request. If the file exists on the gokapi server, returns the request. If the file does not exist, deletes the request and returns nil. If there is an error, logs the error and returns the request. + + ## Parameters + + - `request`: The CSV export request to actualize. + + ## Returns + + - The actualized CSV export request or nil if the request was deleted. + """ + @spec actualize_csv_export_request(CsvExportRequest.t() | nil) :: CsvExportRequest.t() | nil + def actualize_csv_export_request(%CsvExportRequest{file_id: nil} = request), do: request + + def actualize_csv_export_request(%CsvExportRequest{} = request) do + case file_exists?(request.file_id) do + {:ok, true} -> + request + + {:ok, false} -> + CsvExportRequest.delete(request.id) + nil + + error -> + Logger.error("Failed to check if file exists: #{inspect(error)}") + request + end + end + + def actualize_csv_export_request(nil) do + nil + end + + @spec upload_chunk(binary(), String.t(), integer(), integer()) :: :ok | {:error, any()} + defp upload_chunk(chunk, uuid, filesize, offset) do + multipart = + Multipart.new() + |> Multipart.add_file_content(chunk, "chunk", name: "file") + |> Multipart.add_field("uuid", uuid) + |> Multipart.add_field("filesize", to_string(filesize)) + |> Multipart.add_field("offset", to_string(offset)) + + body = multipart |> Multipart.body() |> Enum.to_list() |> IO.iodata_to_binary() + + case HttpClient.post( + gokapi_chunk_upload_url(), + body, + [api_key_header(), {"Content-Type", "multipart/form-data; boundary=#{multipart.boundary}"}], + timeout_options() + ) do + {:ok, %{status_code: 200}} -> :ok + {:ok, resp} -> {:error, {:unexpected_status, resp.status_code}} + {:error, reason} -> {:error, reason} + end + end + + @spec complete_upload(String.t(), String.t(), integer(), String.t()) :: + {:ok, String.t(), DateTime.t()} | {:error, any()} + defp complete_upload(uuid, filename, filesize, content_type \\ "application/csv") do + result = + HttpClient.post( + gokapi_chunk_complete_url(), + nil, + [ + api_key_header(), + {"uuid", uuid}, + {"filename", filename}, + {"filesize", to_string(filesize)}, + {"contenttype", content_type}, + {"allowedDownloads", to_string(gokapi_upload_allowed_downloads())}, + {"expiryDays", to_string(gokapi_upload_expiry_days())}, + {"nonblocking", "false"} + ], + timeout_options() + ) + + with {:ok, %{status_code: 200, body: body}} <- result, + {:ok, %{"FileInfo" => %{"Id" => file_id, "ExpireAt" => expires_at_unix_timestamp}}} <- Jason.decode(body), + {:ok, expires_at} <- DateTime.from_unix(expires_at_unix_timestamp) do + {:ok, file_id, expires_at} + else + error -> {:error, error} + end + end + + @spec file_exists?(String.t()) :: {:ok, boolean()} | {:error, any()} + defp file_exists?(file_id) do + case HttpClient.get(gokapi_file_metadata_url(file_id), [api_key_header()], timeout_options()) do + {:ok, %{status_code: 200}} -> + {:ok, true} + + {:ok, %{status_code: 404}} -> + {:ok, false} + + error -> + {:error, error} + end + end + + # sobelow_skip ["Traversal.FileModule"] + @spec stream_to_temp_file(Enumerable.t(), String.t()) :: String.t() + def stream_to_temp_file(stream, uuid) do + tmp_dir = tmp_dir() + file_path = Path.join(tmp_dir, "csv_export_#{uuid}.csv") + File.mkdir_p!(tmp_dir) + + File.open!(file_path, [:write, :binary], fn file -> + Enum.each(stream, &write_chunk(file, &1)) + end) + + file_path + end + + defp write_chunk(file, chunk) do + case :file.write(file, chunk) do + :ok -> :ok + {:error, reason} -> raise "Failed to write CSV chunk: #{inspect(reason)}" + end + end + + @spec max_pending_tasks_per_ip() :: integer() + def max_pending_tasks_per_ip do + csv_export_config()[:max_pending_tasks_per_ip] + end + + @spec db_timeout() :: non_neg_integer() + def db_timeout do + csv_export_config()[:db_timeout] + end + + @spec csv_export_config() :: list() + defp csv_export_config do + Application.get_env(:explorer, Explorer.Chain.CsvExport) + end + + @spec chunk_size() :: integer() + defp chunk_size do + csv_export_config()[:chunk_size] + end + + @spec tmp_dir() :: String.t() + defp tmp_dir do + csv_export_config()[:tmp_dir] + end + + @spec gokapi_url() :: String.t() + defp gokapi_url do + "#{csv_export_config()[:gokapi_url]}/api" + end + + @spec gokapi_api_key() :: String.t() + defp gokapi_api_key do + csv_export_config()[:gokapi_api_key] + end + + @spec gokapi_upload_expiry_days() :: integer() + defp gokapi_upload_expiry_days do + csv_export_config()[:gokapi_upload_expiry_days] + end + + @spec gokapi_upload_allowed_downloads() :: integer() + defp gokapi_upload_allowed_downloads do + csv_export_config()[:gokapi_upload_allowed_downloads] + end + + @spec gokapi_chunk_upload_url() :: String.t() + defp gokapi_chunk_upload_url do + "#{gokapi_chunk_url()}/add" + end + + @spec gokapi_chunk_complete_url() :: String.t() + defp gokapi_chunk_complete_url do + "#{gokapi_chunk_url()}/complete" + end + + @spec gokapi_chunk_url() :: String.t() + defp gokapi_chunk_url do + "#{gokapi_url()}/chunk" + end + + @spec gokapi_file_metadata_url(String.t()) :: String.t() + defp gokapi_file_metadata_url(file_id) do + "#{gokapi_url()}/files/list/#{file_id}" + end + + @spec api_key_header() :: {String.t(), String.t()} + defp api_key_header do + {"apikey", gokapi_api_key()} + end + + defp timeout_options do + timeout = csv_export_config()[:gokapi_timeout] + [timeout: timeout, recv_timeout: timeout] + end +end diff --git a/apps/explorer/lib/explorer/chain/csv_export/celo/election_rewards.ex b/apps/explorer/lib/explorer/chain/csv_export/celo/election_rewards.ex index 61613dbc6a7d..65112563a7a9 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/celo/election_rewards.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/celo/election_rewards.ex @@ -1,28 +1,41 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CsvExport.Address.Celo.ElectionRewards do @moduledoc """ Exports Celo election rewards to a csv file. """ - import Explorer.Chain.Celo.Helper, - only: [ - block_number_to_epoch_number: 1 - ] - - alias Explorer.Chain.Celo.Reader - alias Explorer.Chain.CsvExport.Helper + alias Explorer.Chain.Celo.{ElectionReward, Epoch} + alias Explorer.Chain.CsvExport.{AsyncHelper, Helper} alias Explorer.Chain.{Hash, Wei} @spec export(Hash.Address.t(), String.t() | nil, String.t() | nil, Keyword.t(), any(), any()) :: Enumerable.t() def export(address_hash, from_period, to_period, _options, _filter_type, _filter_value) do {from_block, to_block} = Helper.block_from_period(from_period, to_period) + epoch_range = Epoch.block_range_to_epoch_range(from_block, to_block) options = [ + necessity_by_association: %{ + :account_address => :optional, + :associated_account_address => :optional, + [epoch: [:end_processing_block]] => :optional + }, paging_options: Helper.paging_options(), - from_block: from_block, - to_block: to_block + api?: true, + timeout: AsyncHelper.db_timeout() ] - address_hash - |> Reader.address_hash_to_election_rewards(options) + epoch_range + |> case do + nil -> + [] + + {from_epoch, to_epoch} -> + full_options = + options + |> Keyword.put(:from_epoch, from_epoch) + |> Keyword.put(:to_epoch, to_epoch) + + address_hash |> ElectionReward.address_hash_to_rewards(full_options) + end |> to_csv_format() |> Helper.dump_to_stream() end @@ -54,13 +67,15 @@ defmodule Explorer.Chain.CsvExport.Address.Celo.ElectionRewards do rows = election_rewards |> Stream.map(fn reward -> + block = reward.epoch.end_processing_block + [ # EpochNumber - reward.block.number |> block_number_to_epoch_number(), + reward.epoch_number, # BlockNumber - reward.block.number, + block.number, # TimestampUTC - reward.block.timestamp, + block.timestamp, # EpochTxType Map.get(reward_type_to_human_readable, reward.type, "N/A"), # ValidatorAddress diff --git a/apps/explorer/lib/explorer/chain/csv_export/helper.ex b/apps/explorer/lib/explorer/chain/csv_export/helper.ex index a9c1af498815..72a5183adad9 100644 --- a/apps/explorer/lib/explorer/chain/csv_export/helper.ex +++ b/apps/explorer/lib/explorer/chain/csv_export/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CsvExport.Helper do @moduledoc """ CSV export helper functions. @@ -160,6 +161,7 @@ defmodule Explorer.Chain.CsvExport.Helper do "address" -> filter_value in supported_address_filter_values() + # todo: topic filter is unused, consider removing "topic" -> case Hash.cast(filter_value) do {:ok, _} -> true @@ -170,4 +172,9 @@ defmodule Explorer.Chain.CsvExport.Helper do true end end + + @spec async_enabled?() :: boolean() + def async_enabled? do + Application.get_env(:explorer, Explorer.Chain.CsvExport)[:async?] + end end diff --git a/apps/explorer/lib/explorer/chain/csv_export/request.ex b/apps/explorer/lib/explorer/chain/csv_export/request.ex new file mode 100644 index 000000000000..133d9312441c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/request.ex @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.Request do + @moduledoc """ + Represents an asynchronous CSV export request. + + When asynchronous CSV export is enabled (`CSV_EXPORT_ASYNC_ENABLED`), the API + enqueues work as an Oban job (`Explorer.Chain.CsvExport.Worker`) instead of + streaming the CSV in the request. This schema tracks the request lifecycle; + the primary key UUID is the `request_id` clients use to poll for status. + """ + + use Explorer.Schema + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.CsvExport.{AsyncHelper, Worker} + + @primary_key false + typed_schema "csv_export_requests" do + field(:id, Ecto.UUID, primary_key: true, autogenerate: true) + field(:remote_ip_hash, :binary, null: false) + field(:file_id, :string) + field(:status, Ecto.Enum, values: [:pending, :completed, :failed], default: :pending) + field(:expires_at, :utc_datetime, null: true) + + timestamps() + end + + @required_attrs ~w(remote_ip_hash)a + @optional_attrs ~w(file_id status expires_at)a + + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = request, attrs \\ %{}) do + request + |> cast(attrs, @required_attrs ++ @optional_attrs) + |> validate_required(@required_attrs) + end + + @doc """ + Creates a new async CSV export request for the given remote IP address. + + The IP is hashed with SHA-256 before storage. Returns `{:ok, request}` on success, + or `{:error, :too_many_pending_requests}` if the IP already has `max_pending_tasks` + requests with `file_id` still `nil`. + """ + @spec create(String.t(), map()) :: {:ok, t()} | {:error, any()} + def create(remote_ip, args) do + remote_ip_hash = hash_ip(remote_ip) + max_pending = AsyncHelper.max_pending_tasks_per_ip() + + Repo.transact(fn -> + pending_count = + __MODULE__ + |> where([r], r.remote_ip_hash == ^remote_ip_hash and r.status == :pending) + |> select([r], count(r.id)) + |> Repo.one() + + with {:too_many_pending_requests, false} <- {:too_many_pending_requests, pending_count >= max_pending}, + {:ok, request} <- + %__MODULE__{remote_ip_hash: remote_ip_hash} + |> changeset() + |> Repo.insert(), + {:ok, _job} <- + args + |> Map.put(:request_id, request.id) + |> Worker.new() + |> Oban.insert() do + {:ok, request} + else + {:too_many_pending_requests, true} -> Repo.rollback(:too_many_pending_requests) + {:error, error} -> Repo.rollback(error) + end + end) + end + + @doc """ + Updates the file_id and expires_at for a given request_id. + + ## Parameters + + - `request_id`: The ID of the request to update. + - `file_id`: The ID of the file to update. + + ## Returns + - The number of rows updated and the result of the update. + + ## Examples + + ```elixir + iex> update_file_id_and_expires_at("123e4567-e89b-12d3-a456-426614174000", "123e4567-e89b-12d3-a456-426614174000", DateTime.from_unix(1716393600)) + {1, nil} + ``` + """ + @spec update_file_id_and_expires_at(Ecto.UUID.t(), String.t(), DateTime.t()) :: {non_neg_integer(), nil} + def update_file_id_and_expires_at(request_id, file_id, expires_at) do + __MODULE__ + |> where([r], r.id == ^request_id) + |> Repo.update_all( + set: [file_id: file_id, expires_at: expires_at, status: :completed, updated_at: DateTime.utc_now()] + ) + end + + @doc """ + Marks a request as failed. + + Called when the Oban job exhausts all attempts without successfully completing. + """ + @spec mark_failed(Ecto.UUID.t()) :: {non_neg_integer(), nil} + def mark_failed(request_id) do + __MODULE__ + |> where([r], r.id == ^request_id) + |> Repo.update_all(set: [status: :failed, updated_at: DateTime.utc_now()]) + end + + @doc """ + Gets a request by its UUID. + + ## Parameters + + - `uuid`: The UUID of the request to get. + - `options`: The options to pass to the repository. + + ## Returns + - The request or nil if no request is found. + + ## Examples + + ```elixir + iex> get_by_uuid("123e4567-e89b-12d3-a456-426614174000") + %Explorer.Chain.CsvExport.Request{id: "123e4567-e89b-12d3-a456-426614174000"} + ``` + """ + @spec get_by_uuid(Ecto.UUID.t(), [Chain.api?()]) :: __MODULE__.t() | nil + def get_by_uuid(uuid, options \\ []) do + Chain.select_repo(options).get(__MODULE__, uuid) + end + + @doc """ + Deletes a request by its ID. + + ## Parameters + + - `request_id`: The ID of the request to delete. + + ## Returns + - The number of rows deleted and the result of the delete. + + ## Examples + + ```elixir + iex> delete("123e4567-e89b-12d3-a456-426614174000") + {1, nil} + ``` + """ + @spec delete(Ecto.UUID.t()) :: {non_neg_integer(), nil} + def delete(request_id) do + __MODULE__ + |> where([r], r.id == ^request_id) + |> Repo.delete_all() + end + + defp hash_ip(ip) do + :crypto.hash(:sha256, ip) + end +end diff --git a/apps/explorer/lib/explorer/chain/csv_export/requests_sanitizer.ex b/apps/explorer/lib/explorer/chain/csv_export/requests_sanitizer.ex new file mode 100644 index 000000000000..118f7cff428c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/requests_sanitizer.ex @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.RequestsSanitizer do + @moduledoc """ + Oban cron worker that periodically deletes expired CSV export requests. + + Removes completed requests (file_id IS NOT NULL) whose updated_at + is older than the configured Gokapi upload expiry period. + """ + use Oban.Worker, queue: :csv_export_sanitize, max_attempts: 3 + + import Ecto.Query + + alias Explorer.Chain.CsvExport.Request + alias Explorer.Repo + + require Logger + + @impl Oban.Worker + def perform(_job) do + expiry_days = csv_export_config()[:gokapi_upload_expiry_days] + + {count, _} = + Request + |> where([r], not is_nil(r.file_id)) + |> where([r], r.updated_at < ago(^expiry_days, "day")) + |> Repo.delete_all() + + Logger.info("Deleted #{count} expired CSV export requests") + + :ok + end + + defp csv_export_config, do: Application.get_env(:explorer, Explorer.Chain.CsvExport) +end diff --git a/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex b/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex new file mode 100644 index 000000000000..98a7a8055ebf --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/token/holders.ex @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.Token.Holders do + @moduledoc """ + Exports token holders to a csv file. + """ + + alias Explorer.Chain + alias Explorer.Chain.{Address, CurrencyHelper, Hash, Token} + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.CsvExport.AsyncHelper + alias Explorer.Chain.CsvExport.Helper, as: CsvHelper + + @spec export(Hash.Address.t(), any(), any(), any(), any(), any()) :: + Enumerable.t() + def export( + token_address_hash, + _from_period, + _to_period, + _options, + _filter_type, + _filter_value + ) do + {:ok, token} = Chain.token_from_address_hash(token_address_hash, api?: true) + + token_address_hash + |> fetch_token_holders() + |> to_csv_format(token) + |> CsvHelper.dump_to_stream() + end + + defp fetch_token_holders(address_hash) do + Chain.fetch_token_holders_from_token_hash_for_csv(address_hash, + paging_options: CsvHelper.paging_options(), + api?: true, + timeout: AsyncHelper.db_timeout() + ) + end + + @doc """ + Converts CurrentTokenBalances to CSV format. Used in `BlockScoutWeb.API.V2.CsvExportController.export_token_holders/2` + """ + @spec to_csv_format([CurrentTokenBalance.t()], Token.t()) :: Enumerable.t() + def to_csv_format(holders, token) do + row_names = [ + "HolderAddress", + "Balance" + ] + + holders_list = + holders + |> Stream.map(fn ctb -> + [ + Address.checksum(ctb.address_hash), + ctb.value |> CurrencyHelper.divide_decimals(token.decimals) |> Decimal.to_string(:xsd) + ] + end) + + Stream.concat([row_names], holders_list) + end +end diff --git a/apps/explorer/lib/explorer/chain/csv_export/worker.ex b/apps/explorer/lib/explorer/chain/csv_export/worker.ex new file mode 100644 index 000000000000..60508311d261 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/csv_export/worker.ex @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.Worker do + @moduledoc """ + Oban worker for asynchronous CSV export jobs. + + Processes export requests in the background when the requested period exceeds + the async threshold, streaming results to a temp file and uploading to storage. + """ + use Oban.Worker, queue: :csv_export, max_attempts: 1 + + alias Explorer.Chain + alias Explorer.Chain.CsvExport.{AdvancedFilter, AsyncHelper, Request} + + require Logger + + @impl Oban.Worker + def perform( + %Job{ + args: %{ + "request_id" => request_id, + "advanced_filters_params" => advanced_filters_params + } + } = job + ) do + run_with_failure_handling(job, request_id, fn -> + filename = "advanced_filters_#{request_id}.csv" + + advanced_filters_params + |> Base.decode64!() + |> :erlang.binary_to_term([:safe]) + |> AdvancedFilter.export() + |> AsyncHelper.stream_to_temp_file(request_id) + |> AsyncHelper.upload_file(filename, request_id) + |> process_upload_result(request_id) + end) + end + + @impl Oban.Worker + def perform( + %Job{ + args: + %{ + "request_id" => request_id, + "address_hash" => address_hash_string, + "from_period" => from_period, + "to_period" => to_period, + "show_scam_tokens?" => show_scam_tokens?, + "module" => module + } = args + } = job + ) do + run_with_failure_handling(job, request_id, fn -> + csv_export_module = String.to_existing_atom(module) + filename = "#{address_hash_string}_#{from_period}_#{to_period}.csv" + + {:ok, address_hash} = Chain.string_to_address_hash(address_hash_string) + + address_hash + |> csv_export_module.export( + from_period, + to_period, + [show_scam_tokens?: show_scam_tokens?], + args["filter_type"], + args["filter_value"] + ) + |> AsyncHelper.stream_to_temp_file(request_id) + |> AsyncHelper.upload_file(filename, request_id) + |> process_upload_result(request_id) + end) + end + + defp run_with_failure_handling(%Job{attempt: attempt, max_attempts: max_attempts}, request_id, fun) do + case fun.() do + :ok -> + :ok + + {:error, _reason} = error -> + if attempt >= max_attempts do + Request.mark_failed(request_id) + end + + error + end + rescue + exception -> + if attempt >= max_attempts do + Request.mark_failed(request_id) + end + + reraise exception, __STACKTRACE__ + end + + defp process_upload_result(result, request_id) do + case result do + {:ok, file_id, expires_at} -> + case Request.update_file_id_and_expires_at(request_id, file_id, expires_at) do + {0, _} -> + Logger.warning( + "CSV export request #{request_id} was deleted before file_id could be set. Uploaded file #{file_id} may be orphaned." + ) + + {_count, _} -> + :ok + end + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/apps/explorer/lib/explorer/chain/currency_helper.ex b/apps/explorer/lib/explorer/chain/currency_helper.ex index 45815ccfe5a8..094baaaa2473 100644 --- a/apps/explorer/lib/explorer/chain/currency_helper.ex +++ b/apps/explorer/lib/explorer/chain/currency_helper.ex @@ -1,9 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CurrencyHelper do @moduledoc """ Helper functions for interacting with `t:BlockScoutWeb.ExchangeRates.USD.t/0` values. """ - @spec divide_decimals(Decimal.t(), Decimal.t() | nil) :: Decimal.t() + @spec divide_decimals(Decimal.t() | nil, Decimal.t() | nil) :: Decimal.t() + def divide_decimals(nil, _decimals) do + Decimal.new(0) + end + def divide_decimals(value, nil) do value end diff --git a/apps/explorer/lib/explorer/chain/data.ex b/apps/explorer/lib/explorer/chain/data.ex index a1019b416c32..c04939351ecd 100644 --- a/apps/explorer/lib/explorer/chain/data.ex +++ b/apps/explorer/lib/explorer/chain/data.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Data do @moduledoc """ The Elixir-native representation of `t:EthereumJSONRPC.data/0`. diff --git a/apps/explorer/lib/explorer/chain/decoding_helper.ex b/apps/explorer/lib/explorer/chain/decoding_helper.ex index e35a8a68c40b..5c83aedc97cb 100644 --- a/apps/explorer/lib/explorer/chain/decoding_helper.ex +++ b/apps/explorer/lib/explorer/chain/decoding_helper.ex @@ -1,9 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.DecodingHelper do @moduledoc """ Data decoding functions """ alias ABI.FunctionSelector - alias Explorer.Helper, as: ExplorerHelper alias Explorer.Chain.{Address, Hash} require Logger @@ -55,7 +55,7 @@ defmodule Explorer.Chain.DecodingHelper do end defp base_value_json(_, {:dynamic, value}) do - ExplorerHelper.add_0x_prefix(value) + "0x" <> Base.encode16(value, case: :lower) end defp base_value_json(:address, value) do @@ -66,7 +66,7 @@ defmodule Explorer.Chain.DecodingHelper do end defp base_value_json(:bytes, value) do - ExplorerHelper.add_0x_prefix(value) + "0x" <> Base.encode16(value, case: :lower) end defp base_value_json(_, value), do: to_string(value) diff --git a/apps/explorer/lib/explorer/chain/denormalization_helper.ex b/apps/explorer/lib/explorer/chain/denormalization_helper.ex index 2af7759d1c3b..91f36c70940c 100644 --- a/apps/explorer/lib/explorer/chain/denormalization_helper.ex +++ b/apps/explorer/lib/explorer/chain/denormalization_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.DenormalizationHelper do @moduledoc """ Helper functions for dynamic logic based on denormalization migration completeness @@ -14,20 +15,6 @@ defmodule Explorer.Chain.DenormalizationHelper do end end - @spec extend_transaction_block_necessity(keyword(), :optional | :required) :: keyword() - def extend_transaction_block_necessity(opts, necessity \\ :optional) do - if transactions_denormalization_finished?() do - opts - else - Keyword.update( - opts, - :necessity_by_association, - %{[transaction: :block] => necessity}, - &(&1 |> Map.delete(:transaction) |> Map.put([transaction: :block], necessity)) - ) - end - end - @spec extend_transaction_preload(list()) :: list() def extend_transaction_preload(preloads) do if transactions_denormalization_finished?() do diff --git a/apps/explorer/lib/explorer/chain/events/db_sender.ex b/apps/explorer/lib/explorer/chain/events/db_sender.ex index 186beebec13b..b0170e1bde43 100644 --- a/apps/explorer/lib/explorer/chain/events/db_sender.ex +++ b/apps/explorer/lib/explorer/chain/events/db_sender.ex @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.DBSender do @moduledoc """ Sends events to Postgres. """ - alias Explorer.Repo + alias Explorer.Repo.EventNotifications, as: EventNotificationsRepo alias Explorer.Utility.EventNotification def send_data(event_type) do @@ -25,12 +26,12 @@ defmodule Explorer.Chain.Events.DBSender do end defp send_notify(payload) do - Repo.query!("select pg_notify('chain_event', $1::text);", [payload]) + EventNotificationsRepo.query!("select pg_notify('chain_event', $1::text);", [payload]) end defp save_event_notification(event_data) do event_data |> EventNotification.new_changeset() - |> Repo.insert() + |> EventNotificationsRepo.insert() end end diff --git a/apps/explorer/lib/explorer/chain/events/listener.ex b/apps/explorer/lib/explorer/chain/events/listener.ex index 4a771d3afdd5..3519d5d08887 100644 --- a/apps/explorer/lib/explorer/chain/events/listener.ex +++ b/apps/explorer/lib/explorer/chain/events/listener.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.Listener do @moduledoc """ Listens and publishes events from PG @@ -5,8 +6,8 @@ defmodule Explorer.Chain.Events.Listener do use GenServer - alias Explorer.Repo alias Explorer.Repo.ConfigHelper + alias Explorer.Repo.EventNotifications, as: EventNotificationsRepo alias Explorer.Utility.EventNotification alias Postgrex.Notifications @@ -17,7 +18,7 @@ defmodule Explorer.Chain.Events.Listener do def init(channel) do {:ok, pid} = :explorer - |> Application.get_env(Explorer.Repo) + |> Application.get_env(EventNotificationsRepo) |> Keyword.merge(listener_db_parameters()) |> Notifications.start_link() @@ -40,7 +41,7 @@ defmodule Explorer.Chain.Events.Listener do defp expand_payload(payload) do case Integer.parse(payload) do - {event_notification_id, ""} -> fetch_and_delete_event_notification(event_notification_id) + {event_notification_id, ""} -> fetch_event_notification(event_notification_id) _ -> payload end end @@ -68,25 +69,18 @@ defmodule Explorer.Chain.Events.Listener do end) end - defp fetch_and_delete_event_notification(id) do - case Repo.get(EventNotification, id) do + defp fetch_event_notification(id) do + case EventNotificationsRepo.get(EventNotification, id) do nil -> nil - %{data: data} = notification -> - try do - Repo.delete(notification) - rescue - Ecto.StaleEntryError -> nil - end - + %{data: data} -> data end end defp listener_db_parameters do - listener_db_url = Application.get_env(:explorer, Repo)[:listener_url] || Application.get_env(:explorer, Repo)[:url] - + listener_db_url = Application.get_env(:explorer, EventNotificationsRepo)[:url] ConfigHelper.extract_parameters(listener_db_url) end end diff --git a/apps/explorer/lib/explorer/chain/events/publisher.ex b/apps/explorer/lib/explorer/chain/events/publisher.ex index 97e052e0da6e..6672eac7d792 100644 --- a/apps/explorer/lib/explorer/chain/events/publisher.ex +++ b/apps/explorer/lib/explorer/chain/events/publisher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.Publisher do @moduledoc """ Publishes events related to the Chain context. @@ -7,7 +8,7 @@ defmodule Explorer.Chain.Events.Publisher do @common_allowed_events ~w(addresses address_coin_balances address_token_balances address_current_token_balances blocks block_rewards internal_transactions last_block_number token_transfers transactions contract_verification_result - token_total_supply changed_bytecode fetched_bytecode fetched_token_instance_metadata + token_total_supply changed_bytecode fetched_bytecode fetched_token_instance_metadata not_fetched_token_instance_metadata smart_contract_was_verified zkevm_confirmed_batches eth_bytecode_db_lookup_started smart_contract_was_not_verified)a diff --git a/apps/explorer/lib/explorer/chain/events/simple_sender.ex b/apps/explorer/lib/explorer/chain/events/simple_sender.ex index f2a8da3e06a3..10cc3508d092 100644 --- a/apps/explorer/lib/explorer/chain/events/simple_sender.ex +++ b/apps/explorer/lib/explorer/chain/events/simple_sender.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.SimpleSender do @moduledoc """ Publishes events through Registry without intermediate levels. diff --git a/apps/explorer/lib/explorer/chain/events/subscriber.ex b/apps/explorer/lib/explorer/chain/events/subscriber.ex index 7f4133cd5428..f26a98072c55 100644 --- a/apps/explorer/lib/explorer/chain/events/subscriber.ex +++ b/apps/explorer/lib/explorer/chain/events/subscriber.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.Subscriber do @moduledoc """ Subscribes to events related to the Chain context. @@ -7,7 +8,7 @@ defmodule Explorer.Chain.Events.Subscriber do @common_allowed_broadcast_events ~w(addresses address_coin_balances address_token_balances address_current_token_balances blocks block_rewards internal_transactions last_block_number token_transfers transactions contract_verification_result - token_total_supply changed_bytecode fetched_bytecode fetched_token_instance_metadata + token_total_supply changed_bytecode fetched_bytecode fetched_token_instance_metadata not_fetched_token_instance_metadata smart_contract_was_verified zkevm_confirmed_batches eth_bytecode_db_lookup_started smart_contract_was_not_verified)a diff --git a/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist.ex b/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist.ex index 64a628ead0b3..e89a1c4ddb37 100644 --- a/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist.ex +++ b/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Fetcher.AddressesBlacklist do @moduledoc """ General fetcher for addresses blacklist diff --git a/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex b/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex index 140f37e3c8b8..5136fd5c4592 100644 --- a/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex +++ b/apps/explorer/lib/explorer/chain/fetcher/addresses_blacklist/blockaid.ex @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Fetcher.AddressesBlacklist.Blockaid do @moduledoc """ Fetcher for addresses blacklist from blockaid provider """ - alias Explorer.Chain + alias Explorer.{Chain, HttpClient} alias Explorer.Chain.Fetcher.AddressesBlacklist @behaviour AddressesBlacklist @@ -12,8 +13,8 @@ defmodule Explorer.Chain.Fetcher.AddressesBlacklist.Blockaid do @impl AddressesBlacklist def fetch_addresses_blacklist do - case HTTPoison.get(AddressesBlacklist.url(), [], recv_timeout: @timeout, timeout: @timeout) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case HttpClient.get(AddressesBlacklist.url(), [], recv_timeout: @timeout, timeout: @timeout) do + {:ok, %{status_code: 200, body: body}} -> body |> Jason.decode() |> parse_blacklist() diff --git a/apps/explorer/lib/explorer/chain/fetcher/check_bytecode_matching_on_demand.ex b/apps/explorer/lib/explorer/chain/fetcher/check_bytecode_matching_on_demand.ex index 594c14296e15..45d7c9119c61 100644 --- a/apps/explorer/lib/explorer/chain/fetcher/check_bytecode_matching_on_demand.ex +++ b/apps/explorer/lib/explorer/chain/fetcher/check_bytecode_matching_on_demand.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Fetcher.CheckBytecodeMatchingOnDemand do @moduledoc """ On demand checker if bytecode written in BlockScout's DB equals to bytecode stored on node (only for verified contracts) diff --git a/apps/explorer/lib/explorer/chain/fetcher/fetch_validator_info_on_demand.ex b/apps/explorer/lib/explorer/chain/fetcher/fetch_validator_info_on_demand.ex index a081a5b521e8..4a1b5d81d463 100644 --- a/apps/explorer/lib/explorer/chain/fetcher/fetch_validator_info_on_demand.ex +++ b/apps/explorer/lib/explorer/chain/fetcher/fetch_validator_info_on_demand.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Fetcher.FetchValidatorInfoOnDemand do @moduledoc """ On demand fetcher info about validator diff --git a/apps/explorer/lib/explorer/chain/fetcher/look_up_smart_contract_sources_on_demand.ex b/apps/explorer/lib/explorer/chain/fetcher/look_up_smart_contract_sources_on_demand.ex index b16731ae86f9..807c3460aef9 100644 --- a/apps/explorer/lib/explorer/chain/fetcher/look_up_smart_contract_sources_on_demand.ex +++ b/apps/explorer/lib/explorer/chain/fetcher/look_up_smart_contract_sources_on_demand.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand do @moduledoc """ On demand fetcher sources for unverified smart contract from @@ -26,11 +27,12 @@ defmodule Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand do alias Explorer.Chain.{Address, Data, SmartContract} alias Explorer.Chain.Events.Publisher alias Explorer.SmartContract.EthBytecodeDBInterface + alias Explorer.SmartContract.Geas.Publisher, as: GeasPublisher alias Explorer.SmartContract.Solidity.Publisher, as: SolidityPublisher alias Explorer.SmartContract.Vyper.Publisher, as: VyperPublisher alias Explorer.Utility.RateLimiter - import Explorer.SmartContract.Helper, only: [prepare_bytecode_for_microservice: 3, contract_creation_input: 1] + import Explorer.SmartContract.Helper, only: [prepare_bytecode_for_microservice: 3, fetch_data_for_verification: 2] @cache_name :smart_contracts_sources_fetching @@ -92,12 +94,13 @@ defmodule Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand do defp fetch_sources(address_hash_string, address_contract_code, only_full?) do Publisher.broadcast(%{eth_bytecode_db_lookup_started: [address_hash_string]}, :on_demand) - creation_transaction_input = contract_creation_input(address_hash_string) + {creation_transaction_input, deployed_bytecode, verifier_metadata} = + fetch_data_for_verification(address_hash_string, Data.to_string(address_contract_code)) with {:ok, %{"sourceType" => type, "matchType" => match_type} = source} <- %{} - |> prepare_bytecode_for_microservice(creation_transaction_input, Data.to_string(address_contract_code)) - |> EthBytecodeDBInterface.search_contract(address_hash_string), + |> prepare_bytecode_for_microservice(creation_transaction_input, deployed_bytecode) + |> EthBytecodeDBInterface.search_contract(address_hash_string, verifier_metadata), :ok <- check_match_type(match_type, only_full?), {:ok, _} <- process_contract_source(type, source, address_hash_string) do Publisher.broadcast(%{smart_contract_was_verified: [address_hash_string]}, :on_demand) @@ -196,6 +199,10 @@ defmodule Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand do SolidityPublisher.process_rust_verifier_response(source, address_hash_string, %{}, true, true, true) end + def process_contract_source("GEAS", source, address_hash_string) do + GeasPublisher.process_rust_verifier_response(source, address_hash_string, %{}, true, true, true) + end + def process_contract_source(_, _source, _address_hash), do: false defp check_match_type("PARTIAL", true), do: :full_match_required diff --git a/apps/explorer/lib/explorer/chain/fhe/fhe_contract_checker.ex b/apps/explorer/lib/explorer/chain/fhe/fhe_contract_checker.ex new file mode 100644 index 000000000000..237666894be0 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/fhe/fhe_contract_checker.ex @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FheContractChecker do + @moduledoc """ + Helper module to check if a contract is a Confidential/FHE contract. + Uses the confidentialProtocolId() function to determine if a contract uses FHE. + """ + require Logger + + import Ecto.Query, only: [from: 2] + import Explorer.Chain, only: [select_repo: 1] + import EthereumJSONRPC, only: [json_rpc: 2] + + alias EthereumJSONRPC.Contract + alias Explorer.Chain.{Address, Hash} + alias Explorer.Repo + alias Explorer.Tags.{AddressTag, AddressToTag} + + @confidential_protocol_id_selector "0x8927b030" + @fhe_tag_label "fhe" + @fhe_tag_display_name "FHE" + + @doc """ + Checks if a contract is FHE and saves the result as a tag in the database. + """ + @spec check_and_save_fhe_status(Hash.Address.t() | nil, Keyword.t()) :: :ok | :empty | :error | :already_checked + def check_and_save_fhe_status(address_hash, options \\ []) + + def check_and_save_fhe_status(address_hash, options) when not is_nil(address_hash) do + address = Address.get(address_hash, options) + + cond do + not Address.smart_contract?(address) or is_nil(address.contract_code) -> :empty + already_checked?(address_hash, options) -> :already_checked + true -> do_check_and_save_fhe_status(address_hash, options) + end + end + + def check_and_save_fhe_status(_, _), do: :empty + + defp do_check_and_save_fhe_status(address_hash, options) do + case fhe_contract?(address_hash) do + {:ok, true} -> save_fhe_tag(address_hash, options) + {:ok, false} -> :ok + end + end + + @doc """ + Checks if a contract is a Confidential/FHE contract by calling confidentialProtocolId() + """ + @spec fhe_contract?(Hash.Address.t()) :: {:ok, boolean()} + def fhe_contract?(%Hash{byte_count: 20} = address_hash) do + address_string = Hash.to_string(address_hash) + + request = Contract.eth_call_request(@confidential_protocol_id_selector, address_string, 0, nil, nil) + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + case json_rpc(request, json_rpc_named_arguments) do + {:ok, result} when is_binary(result) -> + is_fhe = decode_uint256(result) != 0 + {:ok, is_fhe} + + {:ok, [%{result: result}]} when is_binary(result) -> + is_fhe = decode_uint256(result) != 0 + {:ok, is_fhe} + + {:ok, _other} -> + {:ok, false} + + {:error, _reason} -> + # Treat RPC error as false to avoid crashing/retrying loop, or return error? + {:ok, false} + end + end + + @spec fhe_contract?(binary()) :: {:ok, boolean()} | {:error, :invalid_hash} + def fhe_contract?(address_hash_string) when is_binary(address_hash_string) do + case Hash.Address.cast(address_hash_string) do + {:ok, address_hash} -> + fhe_contract?(address_hash) + + _ -> + {:error, :invalid_hash} + end + end + + @doc """ + Checks if an address has already been tagged as FHE. + """ + @spec already_checked?(Hash.Address.t(), Keyword.t()) :: boolean() + def already_checked?(address_hash, options) do + tag_id = AddressTag.get_id_by_label(@fhe_tag_label) + + if tag_id do + repo = select_repo(options) + str_hash = Hash.to_string(address_hash) + + query = + from(att in AddressToTag, + where: att.tag_id == ^tag_id and att.address_hash == ^str_hash + ) + + repo.exists?(query) + else + false + end + end + + defp save_fhe_tag(address_hash, _options) do + ensure_fhe_tag_exists() + + case AddressTag.get_id_by_label(@fhe_tag_label) do + nil -> :error + tag_id -> insert_tag_mapping(address_hash, tag_id) + end + end + + defp insert_tag_mapping(address_hash, tag_id) do + params = %{ + address_hash: address_hash, + tag_id: tag_id, + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + } + + # We use Repo.insert_all with on_conflict: :nothing to be safe and idempotent + Repo.insert_all(AddressToTag, [params], on_conflict: :nothing, conflict_target: [:address_hash, :tag_id]) + :ok + rescue + e -> + Logger.error("Failed to insert FHE tag mapping: #{inspect(e)}") + :error + end + + defp ensure_fhe_tag_exists do + case AddressTag.set(@fhe_tag_label, @fhe_tag_display_name) do + {:ok, _} -> + :ok + + {:error, + %Ecto.Changeset{errors: [label: {_, [constraint: :unique, constraint_name: "address_tags_label_index"]}]}} -> + :ok + + _ -> + :error + end + end + + defp decode_uint256("0x" <> hex), do: decode_hex(hex) + defp decode_uint256(hex) when is_binary(hex), do: decode_hex(hex) + + defp decode_hex(""), do: 0 + + defp decode_hex(hex) do + hex + |> String.trim_leading("0") + |> case do + "" -> 0 + val -> String.to_integer(val, 16) + end + end +end diff --git a/apps/explorer/lib/explorer/chain/fhe/fhe_operation.ex b/apps/explorer/lib/explorer/chain/fhe/fhe_operation.ex new file mode 100644 index 000000000000..7ba102c4aa7d --- /dev/null +++ b/apps/explorer/lib/explorer/chain/fhe/fhe_operation.ex @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FheOperation do + @moduledoc """ + Represents a single FHE (Fully Homomorphic Encryption) operation within a transaction. + FHE operations are parsed from transaction logs during block indexing. + """ + + use Explorer.Schema + + import Ecto.Query, only: [from: 2] + + alias Explorer.Chain.{Block, Hash, Transaction} + alias Explorer.Repo + + @primary_key false + typed_schema "fhe_operations" do + # Composite primary key + field(:log_index, :integer, primary_key: true, null: false) + + # Foreign keys + belongs_to(:transaction, Transaction, + foreign_key: :transaction_hash, + primary_key: true, + references: :hash, + type: Hash.Full, + null: false + ) + + belongs_to(:block, Block, + foreign_key: :block_hash, + references: :hash, + type: Hash.Full, + null: false + ) + + # Operation details + field(:operation, :string, null: false) + field(:operation_type, :string, null: false) + field(:fhe_type, :string, null: false) + field(:is_scalar, :boolean, null: false) + + # HCU metrics + field(:hcu_cost, :integer, null: false) + field(:hcu_depth, :integer, null: false) + + # Addresses and handles + field(:caller, Hash.Address) + field(:result_handle, :binary, null: false) + field(:input_handles, :map) + + # Metadata + field(:block_number, :integer, null: false) + + timestamps() + end + + @required_attrs ~w(transaction_hash log_index block_hash block_number operation operation_type fhe_type is_scalar hcu_cost hcu_depth result_handle)a + @optional_attrs ~w(caller input_handles)a + + @doc false + def changeset(fhe_operation, attrs) do + fhe_operation + |> cast(attrs, @required_attrs ++ @optional_attrs) + |> validate_required(@required_attrs) + end + + @doc """ + Returns all FHE operations for a given transaction hash, ordered by log_index. + """ + @spec by_transaction_hash(Hash.Full.t()) :: [t()] + def by_transaction_hash(transaction_hash) do + query = + from( + op in __MODULE__, + where: op.transaction_hash == ^transaction_hash, + order_by: [asc: op.log_index] + ) + + Repo.all(query) + end + + @doc """ + Returns transaction-level metrics for a given transaction. + Returns a map with operation_count, total_hcu, and max_depth_hcu. + """ + @spec transaction_metrics(Hash.Full.t()) :: %{ + operation_count: non_neg_integer(), + total_hcu: non_neg_integer(), + max_depth_hcu: non_neg_integer() + } + def transaction_metrics(transaction_hash) do + query = + from( + op in __MODULE__, + where: op.transaction_hash == ^transaction_hash, + select: %{ + operation_count: count(op.log_index), + total_hcu: coalesce(sum(op.hcu_cost), 0), + max_depth_hcu: coalesce(max(op.hcu_depth), 0) + } + ) + + Repo.one(query) || %{operation_count: 0, total_hcu: 0, max_depth_hcu: 0} + end +end diff --git a/apps/explorer/lib/explorer/chain/fhe/fhe_operator_prices.ex b/apps/explorer/lib/explorer/chain/fhe/fhe_operator_prices.ex new file mode 100644 index 000000000000..ad65fbdb08bf --- /dev/null +++ b/apps/explorer/lib/explorer/chain/fhe/fhe_operator_prices.ex @@ -0,0 +1,583 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FheOperatorPrices do + @moduledoc """ + HCU (Homomorphic Computation Units) price data for FHE operations. + Ported from https://github.com/zama-ai/fhevm/blob/main/library-solidity/codegen/src/operatorsPrices.ts + """ + + @operator_prices %{ + "fheAdd" => %{ + scalar: %{ + "Uint8" => 84_000, + "Uint16" => 93_000, + "Uint32" => 95_000, + "Uint64" => 133_000, + "Uint128" => 172_000 + }, + non_scalar: %{ + "Uint8" => 88_000, + "Uint16" => 93_000, + "Uint32" => 125_000, + "Uint64" => 162_000, + "Uint128" => 259_000 + } + }, + "fheSub" => %{ + scalar: %{ + "Uint8" => 84_000, + "Uint16" => 93_000, + "Uint32" => 95_000, + "Uint64" => 133_000, + "Uint128" => 172_000 + }, + non_scalar: %{ + "Uint8" => 91_000, + "Uint16" => 93_000, + "Uint32" => 125_000, + "Uint64" => 162_000, + "Uint128" => 260_000 + } + }, + "fheMul" => %{ + scalar: %{ + "Uint8" => 122_000, + "Uint16" => 193_000, + "Uint32" => 265_000, + "Uint64" => 365_000, + "Uint128" => 696_000 + }, + non_scalar: %{ + "Uint8" => 150_000, + "Uint16" => 222_000, + "Uint32" => 328_000, + "Uint64" => 596_000, + "Uint128" => 1_686_000 + } + }, + "fheDiv" => %{ + scalar: %{ + "Uint8" => 210_000, + "Uint16" => 302_000, + "Uint32" => 438_000, + "Uint64" => 715_000, + "Uint128" => 1_225_000 + } + }, + "fheRem" => %{ + scalar: %{ + "Uint8" => 440_000, + "Uint16" => 580_000, + "Uint32" => 792_000, + "Uint64" => 1_153_000, + "Uint128" => 1_943_000 + } + }, + "fheBitAnd" => %{ + scalar: %{ + "Bool" => 22_000, + "Uint8" => 31_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 38_000 + }, + non_scalar: %{ + "Bool" => 25_000, + "Uint8" => 31_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 38_000 + } + }, + "fheBitOr" => %{ + scalar: %{ + "Bool" => 22_000, + "Uint8" => 30_000, + "Uint16" => 30_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 38_000 + }, + non_scalar: %{ + "Bool" => 24_000, + "Uint8" => 30_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 38_000 + } + }, + "fheBitXor" => %{ + scalar: %{ + "Bool" => 22_000, + "Uint8" => 31_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 39_000 + }, + non_scalar: %{ + "Bool" => 22_000, + "Uint8" => 31_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 39_000 + } + }, + "fheShl" => %{ + scalar: %{ + "Uint8" => 32_000, + "Uint16" => 32_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 39_000 + }, + non_scalar: %{ + "Uint8" => 92_000, + "Uint16" => 125_000, + "Uint32" => 162_000, + "Uint64" => 208_000, + "Uint128" => 272_000, + "Uint256" => 378_000 + } + }, + "fheShr" => %{ + scalar: %{ + "Uint8" => 32_000, + "Uint16" => 32_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 38_000 + }, + non_scalar: %{ + "Uint8" => 91_000, + "Uint16" => 123_000, + "Uint32" => 163_000, + "Uint64" => 209_000, + "Uint128" => 272_000, + "Uint256" => 369_000 + } + }, + "fheRotl" => %{ + scalar: %{ + "Uint8" => 31_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 38_000 + }, + non_scalar: %{ + "Uint8" => 91_000, + "Uint16" => 125_000, + "Uint32" => 163_000, + "Uint64" => 209_000, + "Uint128" => 278_000, + "Uint256" => 378_000 + } + }, + "fheRotr" => %{ + scalar: %{ + "Uint8" => 31_000, + "Uint16" => 31_000, + "Uint32" => 32_000, + "Uint64" => 34_000, + "Uint128" => 37_000, + "Uint256" => 40_000 + }, + non_scalar: %{ + "Uint8" => 93_000, + "Uint16" => 125_000, + "Uint32" => 160_000, + "Uint64" => 209_000, + "Uint128" => 283_000, + "Uint256" => 375_000 + } + }, + "fheEq" => %{ + scalar: %{ + "Bool" => 25_000, + "Uint8" => 55_000, + "Uint16" => 55_000, + "Uint32" => 82_000, + "Uint64" => 83_000, + "Uint128" => 117_000, + "Uint160" => 117_000, + "Uint256" => 118_000 + }, + non_scalar: %{ + "Bool" => 26_000, + "Uint8" => 55_000, + "Uint16" => 83_000, + "Uint32" => 86_000, + "Uint64" => 120_000, + "Uint128" => 122_000, + "Uint160" => 137_000, + "Uint256" => 152_000 + } + }, + "fheNe" => %{ + scalar: %{ + "Bool" => 23_000, + "Uint8" => 55_000, + "Uint16" => 55_000, + "Uint32" => 83_000, + "Uint64" => 84_000, + "Uint128" => 117_000, + "Uint160" => 117_000, + "Uint256" => 117_000 + }, + non_scalar: %{ + "Bool" => 23_000, + "Uint8" => 55_000, + "Uint16" => 83_000, + "Uint32" => 85_000, + "Uint64" => 118_000, + "Uint128" => 122_000, + "Uint160" => 136_000, + "Uint256" => 150_000 + } + }, + "fheGe" => %{ + scalar: %{ + "Uint8" => 52_000, + "Uint16" => 55_000, + "Uint32" => 84_000, + "Uint64" => 116_000, + "Uint128" => 149_000 + }, + non_scalar: %{ + "Uint8" => 63_000, + "Uint16" => 84_000, + "Uint32" => 118_000, + "Uint64" => 152_000, + "Uint128" => 210_000 + } + }, + "fheGt" => %{ + scalar: %{ + "Uint8" => 52_000, + "Uint16" => 55_000, + "Uint32" => 84_000, + "Uint64" => 117_000, + "Uint128" => 150_000 + }, + non_scalar: %{ + "Uint8" => 59_000, + "Uint16" => 84_000, + "Uint32" => 118_000, + "Uint64" => 152_000, + "Uint128" => 218_000 + } + }, + "fheLe" => %{ + scalar: %{ + "Uint8" => 58_000, + "Uint16" => 58_000, + "Uint32" => 84_000, + "Uint64" => 119_000, + "Uint128" => 150_000 + }, + non_scalar: %{ + "Uint8" => 58_000, + "Uint16" => 83_000, + "Uint32" => 117_000, + "Uint64" => 149_000, + "Uint128" => 218_000 + } + }, + "fheLt" => %{ + scalar: %{ + "Uint8" => 52_000, + "Uint16" => 58_000, + "Uint32" => 83_000, + "Uint64" => 118_000, + "Uint128" => 149_000 + }, + non_scalar: %{ + "Uint8" => 59_000, + "Uint16" => 84_000, + "Uint32" => 117_000, + "Uint64" => 146_000, + "Uint128" => 215_000 + } + }, + "fheMin" => %{ + scalar: %{ + "Uint8" => 84_000, + "Uint16" => 88_000, + "Uint32" => 117_000, + "Uint64" => 150_000, + "Uint128" => 186_000 + }, + non_scalar: %{ + "Uint8" => 119_000, + "Uint16" => 146_000, + "Uint32" => 182_000, + "Uint64" => 219_000, + "Uint128" => 289_000 + } + }, + "fheMax" => %{ + scalar: %{ + "Uint8" => 89_000, + "Uint16" => 89_000, + "Uint32" => 117_000, + "Uint64" => 149_000, + "Uint128" => 180_000 + }, + non_scalar: %{ + "Uint8" => 121_000, + "Uint16" => 145_000, + "Uint32" => 180_000, + "Uint64" => 218_000, + "Uint128" => 290_000 + } + }, + "fheNeg" => %{ + types: %{ + "Uint8" => 79_000, + "Uint16" => 93_000, + "Uint32" => 95_000, + "Uint64" => 131_000, + "Uint128" => 168_000, + "Uint256" => 269_000 + } + }, + "fheNot" => %{ + types: %{ + "Bool" => 2, + "Uint8" => 9, + "Uint16" => 16, + "Uint32" => 32, + "Uint64" => 63, + "Uint128" => 130, + "Uint256" => 130 + } + }, + "cast" => %{ + types: %{ + "Bool" => 32, + "Uint8" => 32, + "Uint16" => 32, + "Uint32" => 32, + "Uint64" => 32, + "Uint128" => 32, + "Uint256" => 32 + } + }, + "trivialEncrypt" => %{ + types: %{ + "Bool" => 32, + "Uint8" => 32, + "Uint16" => 32, + "Uint32" => 32, + "Uint64" => 32, + "Uint128" => 32, + "Uint160" => 32, + "Uint256" => 32 + } + }, + "ifThenElse" => %{ + types: %{ + "Bool" => 55_000, + "Uint8" => 55_000, + "Uint16" => 55_000, + "Uint32" => 55_000, + "Uint64" => 55_000, + "Uint128" => 57_000, + "Uint160" => 83_000, + "Uint256" => 108_000 + } + }, + "fheRand" => %{ + types: %{ + "Bool" => 19_000, + "Uint8" => 23_000, + "Uint16" => 23_000, + "Uint32" => 24_000, + "Uint64" => 24_000, + "Uint128" => 25_000, + "Uint256" => 30_000 + } + }, + "fheRandBounded" => %{ + types: %{ + "Uint8" => 23_000, + "Uint16" => 23_000, + "Uint32" => 24_000, + "Uint64" => 24_000, + "Uint128" => 25_000, + "Uint256" => 30_000 + } + } + } + + @type_mapping %{ + # Mapping matches TypeScript ALL_FHE_TYPE_INFOS from fhevm/library-solidity/codegen/src/fheTypeInfos.ts + 0 => "Bool", + 1 => "Uint4", + 2 => "Uint8", + 3 => "Uint16", + 4 => "Uint32", + 5 => "Uint64", + 6 => "Uint128", + 7 => "Uint160", + 8 => "Uint256", + 9 => "Uint512", + 10 => "Uint1024", + 11 => "Uint2048", + 12 => "Uint2", + 13 => "Uint6", + 14 => "Uint10", + 15 => "Uint12", + 16 => "Uint14", + 17 => "Int2", + 18 => "Int4", + 19 => "Int6", + 20 => "Int8", + 21 => "Int10", + 22 => "Int12", + 23 => "Int14", + 24 => "Int16", + 25 => "Int32", + 26 => "Int64", + 27 => "Int128", + 28 => "Int160", + 29 => "Int256", + 30 => "AsciiString", + 31 => "Int512", + 32 => "Int1024", + 33 => "Int2048", + 34 => "Uint24", + 35 => "Uint40", + 36 => "Uint48", + 37 => "Uint56", + 38 => "Uint72", + 39 => "Uint80", + 40 => "Uint88", + 41 => "Uint96", + 42 => "Uint104", + 43 => "Uint112", + 44 => "Uint120", + 45 => "Uint136", + 46 => "Uint144", + 47 => "Uint152", + 48 => "Uint168", + 49 => "Uint176", + 50 => "Uint184", + 51 => "Uint192", + 52 => "Uint200", + 53 => "Uint208", + 54 => "Uint216", + 55 => "Uint224", + 56 => "Uint232", + 57 => "Uint240", + 58 => "Uint248", + 59 => "Int24", + 60 => "Int40", + 61 => "Int48", + 62 => "Int56", + 63 => "Int72", + 64 => "Int80", + 65 => "Int88", + 66 => "Int96", + 67 => "Int104", + 68 => "Int112", + 69 => "Int120", + 70 => "Int136", + 71 => "Int144", + 72 => "Int152", + 73 => "Int168", + 74 => "Int176", + 75 => "Int184", + 76 => "Int192", + 77 => "Int200", + 78 => "Int208", + 79 => "Int216", + 80 => "Int224", + 81 => "Int232", + 82 => "Int240", + 83 => "Int248" + } + + @doc """ + Returns the HCU price for an FHE operation. + + Looks up the operation in the price table by operation name, FHE type, and + scalar flag. Returns 0 if not found. + + ## Parameters + - `operation_name` - Operation name (e.g. "fheAdd", "fheSub"). + - `fhe_type` - FHE type string (e.g. "Uint8", "Uint16"). + - `is_scalar` - Whether the operation is scalar (default false). + + ## Returns + - `non_neg_integer()` - HCU cost, or 0 if unknown. + """ + @spec get_price(String.t() | atom(), String.t() | atom(), boolean()) :: non_neg_integer() + def get_price(operation_name, fhe_type, is_scalar \\ false) do + case @operator_prices[operation_name] do + %{scalar: scalar_prices, non_scalar: non_scalar_prices} -> + prices = if is_scalar, do: scalar_prices, else: non_scalar_prices + Map.get(prices, fhe_type, 0) + + %{scalar: scalar_prices} -> + Map.get(scalar_prices, fhe_type, 0) + + %{types: type_prices} -> + Map.get(type_prices, fhe_type, 0) + + _ -> + 0 + end + end + + @doc """ + Converts a type index to its FHE type name string. + + ## Parameters + - `type_index` - Integer type index (e.g. 1 for Uint8). + + ## Returns + - `String.t()` - Type name (e.g. "Uint8"), or "Unknown" if not found. + """ + @spec get_type_name(integer()) :: String.t() + def get_type_name(type_index) when is_integer(type_index) do + Map.get(@type_mapping, type_index, "Unknown") + end + + @doc """ + Returns the full operator price map. + + ## Parameters + - None. + + ## Returns + - `map()` - Map of operation names to price structures (scalar/non_scalar). + """ + @spec all_prices() :: map() + def all_prices, do: @operator_prices + + @doc """ + Returns the mapping from type index to type name. + + ## Parameters + - None. + + ## Returns + - `%{integer() => String.t()}` - Map of type index to type name string. + """ + @spec type_mapping() :: %{integer() => String.t()} + def type_mapping, do: @type_mapping +end diff --git a/apps/explorer/lib/explorer/chain/fhe/parser.ex b/apps/explorer/lib/explorer/chain/fhe/parser.ex new file mode 100644 index 000000000000..a747b91890cf --- /dev/null +++ b/apps/explorer/lib/explorer/chain/fhe/parser.ex @@ -0,0 +1,477 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Fhe.Parser do + @moduledoc """ + Logic for parsing FHE operations from transaction logs. + """ + + require Logger + + alias Explorer.Chain.{FheOperatorPrices, Hash, Log} + alias Explorer.Helper + + # FHE Event Signatures (Keccak-256 hashes) + @fhe_add_event "0xdb9050d65240431621d61d6f94b970e63f53a67a5766614ee6e5c5bbd41c8e2e" + @fhe_sub_event "0xeb6d37bd271abe1395b21d6d78f3487d6584862872c29ffd3f90736ee99b7393" + @fhe_mul_event "0x215346a4f9f975e6d5484e290bd4e53ca14453a9d282ebd3ccedb2a0f171753d" + @fhe_div_event "0x3bab2ee0e2f90f4690c6a87bf63cf1a6b626086e95f231860b152966e8dabbf7" + @fhe_rem_event "0x0e691cd0bf8c4e9308e4ced1bb9c964117dc5c5bb9b9ab5bdfebf2c9b13a897c" + @fhe_bit_and_event "0xe42486b0ccdbef81a2075c48c8e515c079aea73c8b82429997c72a2fe1bf4fef" + @fhe_bit_or_event "0x56df279bbfb03d9ed097bbe2f28d520ca0c1161206327926e98664d70d2c24c4" + @fhe_bit_xor_event "0x4d32284bd3193ecaa44e1ceca32f41c5d6c32803a92e07967dd3ee4229721582" + @fhe_shl_event "0xe84282aaebcca698443e39a2a948a345d0d2ebc654af5cb657a2d7e8053bf6cb" + @fhe_shr_event "0x324220bfc9cb158b492991c03c309cd86e5345cac45aacae2092ddabe31fa3d8" + @fhe_rotl_event "0xeb0e4f8dc74058194d0602425fe602f955c222200f7f10c6fe67992f7b24c7e9" + @fhe_rotr_event "0xc148675905d07ad5496f8ef4d8195c907503f3ec12fd10ed5f21240abc693634" + @fhe_eq_event "0xb3d5c664ec86575818e8d75ff25c5f867250df8954088549c41c848cd10e76cb" + @fhe_ne_event "0x6960c1e88f61c352dba34d1bbf6753e302795264d5d8ae82f7983c7004651e5d" + @fhe_ge_event "0x38c3a63c4230de5b741f494ffb54e3087104030279bc7bccee8ad9ad31712b21" + @fhe_gt_event "0xc9ff8f0d18a3f766ce5de3de216076050140e4fc2652f5e0e745f6fc836cda8b" + @fhe_le_event "0xdef2e704a077284a07f3d0b436db88f5d981b69f58ab7c1ae623252718a6de01" + @fhe_lt_event "0x0d483b100d8c73b208984ec697caa3091521ee5525ce69edcf97d7e395d3d059" + @fhe_min_event "0xc11d62b13c360a83082487064be1ec0878b2f0be4f012bf59f89e128063d47ff" + @fhe_max_event "0xfd7c9208f956bf0c6ab76a667f04361245ad3e0a2d0eff92eb827acfcca68ea9" + @fhe_neg_event "0x8c664d3c3ca583fc5803b8a91c49644bbd9550bfa87967c73ad1de83027768c0" + @fhe_not_event "0x55aff4cc7a3d160c83f1f15b818011ede841a0b4597fb14dcd3603df3a11e5e0" + @trivial_encrypt_event "0x063ccd1bba45151d91f6a418065047a3d048d058a922535747bb2b575a01d236" + @cast_event "0x31ccae6a2f8e3ced1692f77c8f668133e4afdaaa35afe844ff4659a6c27e627f" + @fhe_if_then_else_event "0x60be9d61aad849facc28c38b048cb5c4be3420b8fa2233e08cfa06be1b6d1c3e" + @fhe_rand_event "0x0c8aca6017003326051e19913ef02631f24b801125e1fa8a1d812e868319fda6" + @fhe_rand_bounded_event "0x5222d96b836727a1d6fe1ee9aef27f9bb507bd41794defa376ff6c648aaf8ff1" + + @binary_operations [ + @fhe_add_event, + @fhe_sub_event, + @fhe_mul_event, + @fhe_div_event, + @fhe_rem_event, + @fhe_bit_and_event, + @fhe_bit_or_event, + @fhe_bit_xor_event, + @fhe_shl_event, + @fhe_shr_event, + @fhe_rotl_event, + @fhe_rotr_event, + @fhe_eq_event, + @fhe_ne_event, + @fhe_ge_event, + @fhe_gt_event, + @fhe_le_event, + @fhe_lt_event, + @fhe_min_event, + @fhe_max_event + ] + + @unary_operations [@fhe_neg_event, @fhe_not_event] + + @all_fhe_events @binary_operations ++ + @unary_operations ++ + [ + @trivial_encrypt_event, + @cast_event, + @fhe_if_then_else_event, + @fhe_rand_event, + @fhe_rand_bounded_event + ] + + @event_names %{ + @fhe_add_event => "FheAdd", + @fhe_sub_event => "FheSub", + @fhe_mul_event => "FheMul", + @fhe_div_event => "FheDiv", + @fhe_rem_event => "FheRem", + @fhe_bit_and_event => "FheBitAnd", + @fhe_bit_or_event => "FheBitOr", + @fhe_bit_xor_event => "FheBitXor", + @fhe_shl_event => "FheShl", + @fhe_shr_event => "FheShr", + @fhe_rotl_event => "FheRotl", + @fhe_rotr_event => "FheRotr", + @fhe_eq_event => "FheEq", + @fhe_ne_event => "FheNe", + @fhe_ge_event => "FheGe", + @fhe_gt_event => "FheGt", + @fhe_le_event => "FheLe", + @fhe_lt_event => "FheLt", + @fhe_min_event => "FheMin", + @fhe_max_event => "FheMax", + @fhe_neg_event => "FheNeg", + @fhe_not_event => "FheNot", + @trivial_encrypt_event => "TrivialEncrypt", + @cast_event => "Cast", + @fhe_if_then_else_event => "FheIfThenElse", + @fhe_rand_event => "FheRand", + @fhe_rand_bounded_event => "FheRandBounded" + } + + @doc """ + Returns the list of all FHE event topic signatures. + + ## Parameters + - None. + + ## Returns + - List of binary event topic hashes (Keccak-256 of event signatures). + """ + def all_fhe_events, do: @all_fhe_events + + @doc """ + Maps an event topic hash to its human-readable event name. + + ## Parameters + - `topic` - Event topic (binary or Hash struct). + + ## Returns + - `String.t()` - Event name (e.g. "FheAdd"), or "Unknown" if not found. + """ + def get_event_name(topic) do + normalized_topic = String.downcase(to_string(topic)) + Map.get(@event_names, normalized_topic, "Unknown") + end + + @doc """ + Extracts the caller address from an indexed topic (32-byte padded address). + + Parses the last 20 bytes of a 32-byte topic as an Ethereum address. Returns nil + for invalid or too-short input. + + ## Parameters + - `topic` - Topic value (binary, Hash struct, or string). + + ## Returns + - `String.t()` - "0x" prefixed hex address, or nil if extraction fails. + """ + def extract_caller(nil), do: nil + + def extract_caller(topic) when is_binary(topic) and byte_size(topic) < 32, do: nil + + def extract_caller(topic) do + case topic do + %Hash{} = hash -> + if byte_size(hash.bytes) == 32 do + <<_::binary-size(12), address_bytes::binary-size(20)>> = hash.bytes + "0x" <> Base.encode16(address_bytes, case: :lower) + else + nil + end + + binary when is_binary(binary) -> + if byte_size(binary) >= 32 do + <<_::binary-size(12), address_bytes::binary-size(20)>> = binary + "0x" <> Base.encode16(address_bytes, case: :lower) + else + nil + end + + _ -> + extract_caller_from_topic_str(topic) + end + end + + defp extract_caller_from_topic_str(topic) do + topic_str = topic |> to_string() |> String.downcase() + + cond do + String.starts_with?(topic_str, "0x") -> "0x" <> String.slice(topic_str, -40, 40) + String.length(topic_str) >= 40 -> "0x" <> String.slice(topic_str, -40, 40) + true -> topic_str + end + end + + @doc """ + Decodes FHE event log data based on event type. + + Parses the `data` field of a log according to the event's ABI structure + (binary ops: lhs, rhs, scalar_byte, result; unary ops: ct, result; etc.). + + ## Parameters + - `log` - Log struct with `:data` field. + - `event_name` - Event name string (e.g. "FheAdd", "FheNeg"). + + ## Returns + - Map with decoded fields (e.g. `%{lhs: _, rhs: _, result: _}`). + """ + def decode_event_data(%{data: data} = _log, event_name) + when event_name in [ + "FheAdd", + "FheSub", + "FheMul", + "FheDiv", + "FheRem", + "FheBitAnd", + "FheBitOr", + "FheBitXor", + "FheShl", + "FheShr", + "FheRotl", + "FheRotr", + "FheEq", + "FheNe", + "FheGe", + "FheGt", + "FheLe", + "FheLt", + "FheMin", + "FheMax" + ] do + # Binary operations: (bytes32 lhs, bytes32 rhs, bytes1 scalarByte, bytes32 result) + [lhs, rhs, scalar_byte, result] = Helper.decode_data(data, [{:bytes, 32}, {:bytes, 32}, {:bytes, 1}, {:bytes, 32}]) + + %{ + lhs: lhs, + rhs: rhs, + scalar_byte: scalar_byte, + result: result + } + end + + def decode_event_data(%{data: data} = _log, event_name) when event_name in ["FheNeg", "FheNot"] do + # Unary operations: (bytes32 ct, bytes32 result) + [ct, result] = Helper.decode_data(data, [{:bytes, 32}, {:bytes, 32}]) + + %{ + ct: ct, + result: result + } + end + + def decode_event_data(%{data: data} = _log, "TrivialEncrypt") do + # TrivialEncrypt(address indexed caller, uint256 pt, uint8 toType, bytes32 result) + [pt, to_type, result] = Helper.decode_data(data, [{:uint, 256}, {:uint, 8}, {:bytes, 32}]) + + %{ + plaintext: pt, + to_type: to_type, + result: result + } + end + + def decode_event_data(%{data: data} = _log, "Cast") do + # Cast(address indexed caller, bytes32 ct, uint8 toType, bytes32 result) + [ct, to_type, result] = Helper.decode_data(data, [{:bytes, 32}, {:uint, 8}, {:bytes, 32}]) + + %{ + ct: ct, + to_type: to_type, + result: result + } + end + + def decode_event_data(%{data: data} = _log, "FheIfThenElse") do + # FheIfThenElse(address indexed caller, bytes32 control, bytes32 ifTrue, bytes32 ifFalse, bytes32 result) + [control, if_true, if_false, result] = + Helper.decode_data(data, [{:bytes, 32}, {:bytes, 32}, {:bytes, 32}, {:bytes, 32}]) + + %{ + control: control, + if_true: if_true, + if_false: if_false, + result: result + } + end + + def decode_event_data(%{data: data} = _log, "FheRand") do + # FheRand(address indexed caller, uint8 randType, bytes16 seed, bytes32 result) + [rand_type, seed, result] = Helper.decode_data(data, [{:uint, 8}, {:bytes, 16}, {:bytes, 32}]) + + %{ + rand_type: rand_type, + seed: seed, + result: result + } + end + + def decode_event_data(%{data: data} = _log, "FheRandBounded") do + # FheRandBounded(address indexed caller, uint256 upperBound, uint8 randType, bytes16 seed, bytes32 result) + [upper_bound, rand_type, seed, result] = + Helper.decode_data(data, [{:uint, 256}, {:uint, 8}, {:bytes, 16}, {:bytes, 32}]) + + %{ + upper_bound: upper_bound, + rand_type: rand_type, + seed: seed, + result: result + } + end + + def decode_event_data(%Log{} = _log, _event_name) do + %{result: <<0::256>>} + end + + @doc """ + Type extraction rules (matching TypeScript implementation): + - FheAdd, FheSub, FheMul, FheDiv, FheRem, FheBitAnd, FheBitOr, FheBitXor, + FheShl, FheShr, FheRotl, FheRotr, FheMin, FheMax, FheIfThenElse: from result handle + - FheEq, FheNe, FheGe, FheGt, FheLe, FheLt: from LHS handle (not result!) + - Cast, FheNot, FheNeg: from input handle (ct) + - TrivialEncrypt: from toType parameter + - FheRand: from randType parameter + - FheRandBounded: from randType parameter + """ + def extract_fhe_type(operation_data, event_name) do + case event_name do + event_name when event_name in ["FheEq", "FheNe", "FheGe", "FheGt", "FheLe", "FheLt"] -> + extract_fhe_type_from_handle(operation_data[:lhs], &extract_fhe_type_from_result/1) + + event_name when event_name in ["Cast", "FheNot", "FheNeg"] -> + extract_fhe_type_from_handle(operation_data[:ct], &extract_fhe_type_from_result/1) + + "TrivialEncrypt" -> + extract_fhe_type_from_param(operation_data[:to_type]) + + "FheRand" -> + extract_fhe_type_from_param(operation_data[:rand_type]) + + "FheRandBounded" -> + extract_fhe_type_from_param(operation_data[:rand_type]) + + _ -> + extract_fhe_type_from_handle(operation_data[:result], &extract_fhe_type_from_result/1) + end + end + + defp extract_fhe_type_from_handle(nil, _extractor), do: "Unknown" + + defp extract_fhe_type_from_handle(handle, extractor), do: extractor.(handle) + + defp extract_fhe_type_from_param(nil), do: "Unknown" + + defp extract_fhe_type_from_param(param), do: FheOperatorPrices.get_type_name(param) + + defp extract_fhe_type_from_result(result) when is_binary(result) do + result_size = byte_size(result) + + if result_size >= 32 do + # Extract byte 30 (0-indexed, second to last byte) + <<_prefix::binary-size(30), type_byte::8, _suffix::binary-size(1)>> = result + FheOperatorPrices.get_type_name(type_byte) + else + "Unknown" + end + end + + defp extract_fhe_type_from_result(_), do: "Unknown" + + @doc """ + Extract inputs based on operation type. + """ + def extract_inputs(%{lhs: lhs, rhs: rhs}, _event_name) when not is_nil(lhs) do + %{ + lhs: Base.encode16(lhs, case: :lower), + rhs: Base.encode16(rhs, case: :lower) + } + end + + def extract_inputs(%{ct: ct}, _event_name) when not is_nil(ct) do + %{ct: Base.encode16(ct, case: :lower)} + end + + def extract_inputs(%{control: control, if_true: if_true, if_false: if_false}, "FheIfThenElse") do + %{ + control: Base.encode16(control, case: :lower), + if_true: Base.encode16(if_true, case: :lower), + if_false: Base.encode16(if_false, case: :lower) + } + end + + def extract_inputs(%{plaintext: pt}, "TrivialEncrypt") do + %{plaintext: pt} + end + + def extract_inputs(_, _), do: %{} + + @doc """ + Calculate HCU cost for an operation. + """ + def calculate_hcu_cost(event_name, fhe_type, is_scalar) do + operation_key = event_name_to_operation_key(event_name) + FheOperatorPrices.get_price(operation_key, fhe_type, is_scalar) + end + + def event_name_to_operation_key("FheAdd"), do: "fheAdd" + def event_name_to_operation_key("FheSub"), do: "fheSub" + def event_name_to_operation_key("FheMul"), do: "fheMul" + def event_name_to_operation_key("FheDiv"), do: "fheDiv" + def event_name_to_operation_key("FheRem"), do: "fheRem" + def event_name_to_operation_key("FheBitAnd"), do: "fheBitAnd" + def event_name_to_operation_key("FheBitOr"), do: "fheBitOr" + def event_name_to_operation_key("FheBitXor"), do: "fheBitXor" + def event_name_to_operation_key("FheShl"), do: "fheShl" + def event_name_to_operation_key("FheShr"), do: "fheShr" + def event_name_to_operation_key("FheRotl"), do: "fheRotl" + def event_name_to_operation_key("FheRotr"), do: "fheRotr" + def event_name_to_operation_key("FheEq"), do: "fheEq" + def event_name_to_operation_key("FheNe"), do: "fheNe" + def event_name_to_operation_key("FheGe"), do: "fheGe" + def event_name_to_operation_key("FheGt"), do: "fheGt" + def event_name_to_operation_key("FheLe"), do: "fheLe" + def event_name_to_operation_key("FheLt"), do: "fheLt" + def event_name_to_operation_key("FheMin"), do: "fheMin" + def event_name_to_operation_key("FheMax"), do: "fheMax" + def event_name_to_operation_key("FheNeg"), do: "fheNeg" + def event_name_to_operation_key("FheNot"), do: "fheNot" + def event_name_to_operation_key("TrivialEncrypt"), do: "trivialEncrypt" + def event_name_to_operation_key("Cast"), do: "cast" + def event_name_to_operation_key("FheIfThenElse"), do: "ifThenElse" + def event_name_to_operation_key("FheRand"), do: "fheRand" + def event_name_to_operation_key("FheRandBounded"), do: "fheRandBounded" + def event_name_to_operation_key(_), do: "unknown" + + @doc """ + Get operation category from event name. + """ + def get_operation_type(event_name) do + cond do + event_name in ["FheAdd", "FheSub", "FheMul", "FheDiv", "FheRem"] -> "arithmetic" + event_name in ["FheBitAnd", "FheBitOr", "FheBitXor", "FheShl", "FheShr", "FheRotl", "FheRotr"] -> "bitwise" + event_name in ["FheEq", "FheNe", "FheGe", "FheGt", "FheLe", "FheLt", "FheMin", "FheMax"] -> "comparison" + event_name in ["FheNeg", "FheNot"] -> "unary" + event_name in ["FheIfThenElse"] -> "control" + event_name in ["TrivialEncrypt", "Cast"] -> "encryption" + event_name in ["FheRand", "FheRandBounded"] -> "random" + true -> "other" + end + end + + @doc """ + Build HCU depth map tracking cumulative HCU for each handle. + """ + def build_hcu_depth_map(operations) do + Enum.reduce(operations, %{}, fn op, acc -> + result_handle = if is_binary(op.result), do: Base.encode16(op.result, case: :lower), else: nil + + if is_nil(result_handle) do + acc + else + depth = calculate_op_depth(op, acc) + Map.put(acc, result_handle, depth) + end + end) + end + + defp calculate_op_depth(op, acc) do + case op.inputs do + %{lhs: lhs, rhs: rhs} -> + lhs_depth = Map.get(acc, lhs, 0) + + if Map.get(op, :is_scalar, false) do + lhs_depth + op.hcu_cost + else + rhs_depth = Map.get(acc, rhs, 0) + max(lhs_depth, rhs_depth) + op.hcu_cost + end + + %{control: control, if_true: if_true, if_false: if_false} -> + control_depth = Map.get(acc, control, 0) + true_depth = Map.get(acc, if_true, 0) + false_depth = Map.get(acc, if_false, 0) + max(control_depth, max(true_depth, false_depth)) + op.hcu_cost + + %{ct: ct} -> + ct_depth = Map.get(acc, ct, 0) + ct_depth + op.hcu_cost + + _ -> + op.hcu_cost + end + end +end diff --git a/apps/explorer/lib/explorer/chain/filecoin/id.ex b/apps/explorer/lib/explorer/chain/filecoin/id.ex index 8c73460f6e90..823c51510ccd 100644 --- a/apps/explorer/lib/explorer/chain/filecoin/id.ex +++ b/apps/explorer/lib/explorer/chain/filecoin/id.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Filecoin.IDAddress do @moduledoc """ Handles Filecoin ID addresses, wrapping the `NativeAddress` type. diff --git a/apps/explorer/lib/explorer/chain/filecoin/native_address.ex b/apps/explorer/lib/explorer/chain/filecoin/native_address.ex index 205a1107e04f..d35261c29bd2 100644 --- a/apps/explorer/lib/explorer/chain/filecoin/native_address.ex +++ b/apps/explorer/lib/explorer/chain/filecoin/native_address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Filecoin.NativeAddress do @moduledoc """ Handles Filecoin addresses by parsing, validating, and converting them to and diff --git a/apps/explorer/lib/explorer/chain/filecoin/pending_address_operation.ex b/apps/explorer/lib/explorer/chain/filecoin/pending_address_operation.ex index 6db214a8bd6e..6f53b43f43e3 100644 --- a/apps/explorer/lib/explorer/chain/filecoin/pending_address_operation.ex +++ b/apps/explorer/lib/explorer/chain/filecoin/pending_address_operation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Filecoin.PendingAddressOperation do @moduledoc """ Tracks an address that is pending for fetching of filecoin address info. diff --git a/apps/explorer/lib/explorer/chain/gas.ex b/apps/explorer/lib/explorer/chain/gas.ex index 06eae13e1a14..a8f374c94b28 100644 --- a/apps/explorer/lib/explorer/chain/gas.ex +++ b/apps/explorer/lib/explorer/chain/gas.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Gas do @moduledoc """ A measurement roughly equivalent to computational steps. Every operation has a gas expenditure; for most operations diff --git a/apps/explorer/lib/explorer/chain/hash.ex b/apps/explorer/lib/explorer/chain/hash.ex index 255861c0d697..b868a5828506 100644 --- a/apps/explorer/lib/explorer/chain/hash.ex +++ b/apps/explorer/lib/explorer/chain/hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash do @moduledoc """ A [KECCAK-256](https://en.wikipedia.org/wiki/SHA-3) hash. diff --git a/apps/explorer/lib/explorer/chain/hash/address.ex b/apps/explorer/lib/explorer/chain/hash/address.ex index f37db26dee9d..41057caa0b37 100644 --- a/apps/explorer/lib/explorer/chain/hash/address.ex +++ b/apps/explorer/lib/explorer/chain/hash/address.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash.Address do @moduledoc """ The address (40 (hex) characters / 160 bits / 20 bytes) is derived from the public key (128 (hex) characters / diff --git a/apps/explorer/lib/explorer/chain/hash/full.ex b/apps/explorer/lib/explorer/chain/hash/full.ex index db2a586bd770..441339c48988 100644 --- a/apps/explorer/lib/explorer/chain/hash/full.ex +++ b/apps/explorer/lib/explorer/chain/hash/full.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash.Full do @moduledoc """ A 32-byte [KECCAK-256](https://en.wikipedia.org/wiki/SHA-3) hash. diff --git a/apps/explorer/lib/explorer/chain/hash/nonce.ex b/apps/explorer/lib/explorer/chain/hash/nonce.ex index f8be6c276270..3eb26317ce6d 100644 --- a/apps/explorer/lib/explorer/chain/hash/nonce.ex +++ b/apps/explorer/lib/explorer/chain/hash/nonce.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash.Nonce do @moduledoc """ The nonce (16 (hex) characters / 128 bits / 8 bytes) is derived from the Proof-of-Work. diff --git a/apps/explorer/lib/explorer/chain/health/helper.ex b/apps/explorer/lib/explorer/chain/health/helper.ex index 82d4e44de5e9..f2d68a22a2b8 100644 --- a/apps/explorer/lib/explorer/chain/health/helper.ex +++ b/apps/explorer/lib/explorer/chain/health/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Health.Helper do @moduledoc """ Helper functions for /api/health endpoints @@ -188,27 +189,56 @@ defmodule Explorer.Chain.Health.Helper do blocks_indexing_delay_threshold = Application.get_env(:explorer, Explorer.Chain.Health.Monitor)[:healthy_blocks_period] - with true <- last_block_db_delay > blocks_indexing_delay_threshold, - {:empty_health_latest_block_number_from_node, false} <- - {:empty_health_latest_block_number_from_node, is_nil(health_status.health_latest_block_number_from_node)}, - true <- - Decimal.compare( - Decimal.sub( - health_status.health_latest_block_number_from_node, - health_status.health_latest_block_number_from_db - ), - Decimal.new(@max_blocks_gap_between_node_and_db) - ) == :gt do - no_new_block_status(last_block_db_delay) - else - {:empty_health_latest_block_number_from_node, true} -> no_new_block_status(last_block_db_delay) - _ -> true + db_freshness_result = + with true <- last_block_db_delay > blocks_indexing_delay_threshold, + {:empty_health_latest_block_number_from_node, false} <- + {:empty_health_latest_block_number_from_node, is_nil(health_status.health_latest_block_number_from_node)}, + {:empty_health_latest_block_number_from_db, false} <- + {:empty_health_latest_block_number_from_db, is_nil(health_status.health_latest_block_number_from_db)}, + true <- + Decimal.compare( + Decimal.sub( + health_status.health_latest_block_number_from_node, + health_status.health_latest_block_number_from_db + ), + Decimal.new(@max_blocks_gap_between_node_and_db) + ) == :gt do + no_new_block_status(last_block_db_delay) + else + {:empty_health_latest_block_number_from_node, true} -> no_new_block_status(last_block_db_delay) + {:empty_health_latest_block_number_from_db, true} -> true + _ -> true + end + + case db_freshness_result do + true -> check_cache_lag(health_status, blocks_indexing_delay_threshold) + error -> error end else {false, @no_items_error_code, "There are no blocks in the DB."} end end + defp check_cache_lag(health_status, threshold) do + cache_ts_raw = health_status[:health_latest_block_timestamp_from_cache] + db_ts_raw = health_status[:health_latest_block_timestamp_from_db] + + if is_nil(cache_ts_raw) or is_nil(db_ts_raw) do + true + else + {:ok, cache_ts} = DateTime.from_unix(Decimal.to_integer(cache_ts_raw)) + {:ok, db_ts} = DateTime.from_unix(Decimal.to_integer(db_ts_raw)) + cache_lag_ms = DateTime.diff(db_ts, cache_ts, :millisecond) + + if cache_lag_ms > threshold do + {false, @no_new_items_error_code, + "Cache block is lagging behind DB block by #{round(cache_lag_ms / 1_000 / 60)} mins. Check the cache update mechanism."} + else + true + end + end + end + defp no_new_block_status(last_block_db_delay) do {false, @no_new_items_error_code, "There are no new blocks in the DB for the last #{round(last_block_db_delay / 1_000 / 60)} mins. Check the healthiness of the JSON RPC archive node or the DB."} diff --git a/apps/explorer/lib/explorer/chain/health/monitor.ex b/apps/explorer/lib/explorer/chain/health/monitor.ex index 5cdb25080989..c48ac0e89003 100644 --- a/apps/explorer/lib/explorer/chain/health/monitor.ex +++ b/apps/explorer/lib/explorer/chain/health/monitor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Health.Monitor do @moduledoc """ This module provides functionality for monitoring of the application health. @@ -11,7 +12,6 @@ defmodule Explorer.Chain.Health.Monitor do alias Explorer.Chain.Cache.Counters.LastFetchedCounter alias Explorer.Chain.Health.Helper, as: HealthHelper alias Explorer.Chain.Optimism.Reader, as: OptimismReader - alias Explorer.Chain.PolygonZkevm.Reader, as: PolygonZkevmReader alias Explorer.Chain.Scroll.Reader, as: ScrollReader alias Explorer.Chain.ZkSync.Reader, as: ZkSyncReader alias Explorer.Repo @@ -80,9 +80,6 @@ defmodule Explorer.Chain.Health.Monitor do :optimism -> get_latest_batch_info_from_module(OptimismReader) - :polygon_zkevm -> - get_latest_batch_info_from_module(PolygonZkevmReader) - :scroll -> get_latest_batch_info_from_module(ScrollReader) diff --git a/apps/explorer/lib/explorer/chain/import.ex b/apps/explorer/lib/explorer/chain/import.ex index 32291e0cb754..7f0f1cd60b77 100644 --- a/apps/explorer/lib/explorer/chain/import.ex +++ b/apps/explorer/lib/explorer/chain/import.ex @@ -1,12 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import do @moduledoc """ Bulk importing of data into `Explorer.Repo` """ - alias Ecto.Changeset + alias Ecto.{Changeset, Multi} alias Explorer.Account.Notify - alias Explorer.Chain.Events.Publisher alias Explorer.Chain.{Block, Import} + alias Explorer.Chain.Events.Publisher + alias Explorer.Chain.Import.Stage alias Explorer.Repo require Logger @@ -16,6 +18,7 @@ defmodule Explorer.Chain.Import do Import.Stage.Blocks ], [ + Import.Stage.Addresses, Import.Stage.Main ], [ @@ -25,6 +28,9 @@ defmodule Explorer.Chain.Import do Import.Stage.Logs, Import.Stage.InternalTransactions, Import.Stage.ChainTypeSpecific + ], + [ + Import.Stage.Stats ] ] @@ -70,7 +76,7 @@ defmodule Explorer.Chain.Import do @type all_result :: {:ok, %{unquote_splicing(quoted_runner_imported)}} - | {:error, [Changeset.t()] | :timeout | :insert_to_multichain_search_db_failed} + | {:error, [Changeset.t()] | :timeout} | {:error, step :: Ecto.Multi.name(), failed_value :: any(), changes_so_far :: %{optional(Ecto.Multi.name()) => any()}} @@ -147,6 +153,39 @@ defmodule Explorer.Chain.Import do end end + @doc """ + Prepares a bulk import transaction without executing it. + + This function follows the same validation steps as `all/1` but instead of executing the transaction, + it returns the prepared `Ecto.Multi` struct. This allows the caller to compose the transaction with + additional operations before executing it. + + ## Parameters + + - `runners`: List of runner modules to prepare the multi for + - `options`: The import options map (same structure as in `all/1`) + + ## Returns + + - `{:ok, multi}` - The prepared transaction that can be executed later + - `{:error, [Changeset.t()]}` - Validation errors for the provided options + - `{:error, {:unknown_options, map()}}` - Unknown options were provided + """ + @spec all_single_multi([module()], all_options()) :: + {:ok, Ecto.Multi.t()} + | {:error, [Changeset.t()]} + | {:error, {:unknown_options, map()}} + def all_single_multi(runners, options) do + with {:ok, runner_options_pairs} <- validate_options(options), + {:ok, valid_runner_option_pairs} <- validate_runner_options_pairs(runner_options_pairs), + {:ok, runner_to_changes_list} <- runner_to_changes_list(valid_runner_option_pairs) do + timestamps = timestamps() + full_options = Map.put(options, :timestamps, timestamps) + {multi, _remaining_runner_to_changes_list} = Stage.single_multi(runners, runner_to_changes_list, full_options) + {:ok, multi} + end + end + defp configured_runners do # in order so that foreign keys are inserted before being referenced Enum.flat_map(@stages, fn stage_batch -> @@ -177,7 +216,15 @@ defmodule Explorer.Chain.Import do changeset_function_name = Map.get(options, :with, :changeset) struct = ecto_schema_module.__struct__() + prepare_data_function = + if Map.has_key?(Enum.into(runner.__info__(:functions), %{}), :prepare_data) do + &runner.prepare_data(&1) + else + & &1 + end + params + |> prepare_data_function.() |> Stream.map(&apply(ecto_schema_module, changeset_function_name, [struct, &1])) |> Enum.reduce({:ok, []}, fn changeset = %Changeset{valid?: false}, {:ok, _} -> @@ -308,7 +355,7 @@ defmodule Explorer.Chain.Import do end) end) - unless Enum.empty?(final_runner_to_changes_list) do + if !Enum.empty?(final_runner_to_changes_list) do raise ArgumentError, "No stages consumed the following runners: #{final_runner_to_changes_list |> Map.keys() |> inspect()}" end @@ -348,12 +395,12 @@ defmodule Explorer.Chain.Import do {:ok, result} error -> - set_refetch_needed_for_partially_imported_blocks(options) + handle_partially_imported_blocks(options) error end rescue exception -> - set_refetch_needed_for_partially_imported_blocks(options) + handle_partially_imported_blocks(options) reraise exception, __STACKTRACE__ end @@ -389,15 +436,31 @@ defmodule Explorer.Chain.Import do end defp import_transaction(multi, options) when is_map(options) do - Repo.logged_transaction(multi, timeout: Map.get(options, :timeout, @transaction_timeout)) + timeout = Map.get(options, :timeout, @transaction_timeout) + + multi + |> add_statement_timeout(timeout) + |> Repo.logged_transaction(timeout: timeout) rescue exception -> {:exception, exception, __STACKTRACE__} end + defp add_statement_timeout(multi, timeout) when is_integer(timeout) do + prefix_multi = + Multi.run(Multi.new(), :set_statement_timeout, fn repo, _ -> + repo.query!("SET LOCAL statement_timeout = #{timeout}") + {:ok, :done} + end) + + Multi.prepend(multi, prefix_multi) + end + + defp add_statement_timeout(multi, _timeout), do: multi + defp handle_task_results(task_results, acc_changes) do Enum.reduce_while(task_results, {:ok, acc_changes}, fn task_result, {:ok, acc_changes_inner} -> case task_result do - {:ok, {:ok, changes}} -> {:cont, {:ok, Map.merge(acc_changes_inner, changes)}} + {:ok, {:ok, changes}} -> {:cont, {:ok, merge_task_result(acc_changes_inner, changes)}} {:ok, {:exception, exception, stacktrace}} -> reraise exception, stacktrace {:ok, error} -> {:halt, error} {:exit, reason} -> {:halt, reason} @@ -406,14 +469,22 @@ defmodule Explorer.Chain.Import do end) end - defp set_refetch_needed_for_partially_imported_blocks(%{blocks: %{params: blocks_params}}) do - block_numbers = Enum.map(blocks_params, & &1.number) + defp merge_task_result(changes_acc, changes) do + Map.merge(changes_acc, changes, fn + _k, v1, v2 when is_list(v1) and is_list(v2) -> v1 ++ v2 + _k, _v1, v2 -> v2 + end) + end + + defp handle_partially_imported_blocks(%{blocks: %{params: blocks_params}}) do + block_numbers = blocks_params |> Enum.map(& &1.number) |> Enum.uniq() Block.set_refetch_needed(block_numbers) + Import.Runner.Blocks.process_blocks_consensus(blocks_params) Logger.warning("Set refetch_needed for partially imported block because of error: #{inspect(block_numbers)}") end - defp set_refetch_needed_for_partially_imported_blocks(_options), do: :ok + defp handle_partially_imported_blocks(_options), do: :ok @spec timestamps() :: timestamps def timestamps do diff --git a/apps/explorer/lib/explorer/chain/import/runner.ex b/apps/explorer/lib/explorer/chain/import/runner.ex index d8a8ff20f222..bebab143479c 100644 --- a/apps/explorer/lib/explorer/chain/import/runner.ex +++ b/apps/explorer/lib/explorer/chain/import/runner.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner do @moduledoc """ Behaviour used by `Explorer.Chain.Import.all/1` to import data into separate tables. @@ -58,5 +59,10 @@ defmodule Explorer.Chain.Import.Runner do """ @callback runner_specific_options() :: [atom()] - @optional_callbacks runner_specific_options: 0 + @doc """ + The optional function to prepare data for passing it to the changeset. + """ + @callback prepare_data(changes_list) :: changes_list + + @optional_callbacks runner_specific_options: 0, prepare_data: 1 end diff --git a/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances.ex b/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances.ex index 62b0cfa9b2e5..5ee64071bb49 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Address.CoinBalances do @moduledoc """ Bulk imports `t:Explorer.Chain.Address.CoinBalance.t/0`. @@ -94,7 +95,7 @@ defmodule Explorer.Chain.Import.Runner.Address.CoinBalances do timestamps: timestamps ) - {:ok, Enum.map(ordered_changes_list, &Map.take(&1, ~w(address_hash block_number)a))} + {:ok, Enum.map(ordered_changes_list, &Map.take(&1, ~w(address_hash block_number value)a))} end def default_on_conflict do diff --git a/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances_daily.ex b/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances_daily.ex index e610ed3c81d9..a597044ccc33 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances_daily.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/address/coin_balances_daily.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Address.CoinBalancesDaily do @moduledoc """ Bulk imports `t:Explorer.Chain.Address.CoinBalancesDaily.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex b/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex index 7780bc301776..e2d69283ed4a 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/address/current_token_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do @moduledoc """ Bulk imports `t:Explorer.Chain.Address.CurrentTokenBalance.t/0`. @@ -231,7 +232,7 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do filtered_ctbs = Enum.filter(changes_list, fn ctb -> existing_ctb = existing_ctb_map[{ctb[:address_hash], ctb[:token_contract_address_hash], ctb[:token_id]}] - should_update?(ctb, existing_ctb) + should_update?(Map.put_new(ctb, :value_fetched_at, nil), existing_ctb) end) {:ok, filtered_ctbs} @@ -239,43 +240,64 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do defp select_existing_current_token_balances(_repo, [], _with_token_id?), do: [] - defp select_existing_current_token_balances(repo, params, with_token_id?) do - params - |> existing_ctb_query(with_token_id?) - |> repo.all() - end - - defp existing_ctb_query(params, false) do + defp select_existing_current_token_balances(repo, params, false) do ids = params |> Enum.map(&{&1.address_hash.bytes, &1.token_contract_address_hash.bytes}) |> Enum.uniq() - from( - ctb in CurrentTokenBalance, - where: is_nil(ctb.token_id), - where: ^QueryHelper.tuple_in([:address_hash, :token_contract_address_hash], ids) - ) + existing_ctb_query = + from( + ctb in CurrentTokenBalance, + where: is_nil(ctb.token_id), + where: ^QueryHelper.tuple_in([:address_hash, :token_contract_address_hash], ids) + ) + + repo.all(existing_ctb_query) end - defp existing_ctb_query(params, true) do - ids = - params - |> Enum.map(&{&1.address_hash.bytes, &1.token_contract_address_hash.bytes, &1.token_id}) - |> Enum.uniq() + defp select_existing_current_token_balances(repo, params, true) do + ids = Enum.map(params, &[&1.address_hash.bytes, &1.token_contract_address_hash.bytes, &1.token_id]) + + placeholders = + ids + |> Enum.with_index(1) + |> Enum.map_join(",", fn {_, i} -> + # The value 3 corresponds to the number of parameters in each group within the WHERE clause. + # If this number changes, make sure to update it accordingly. For example, placeholders for + # an array of ids [[1, 2, 3], [4, 5, 6]] would be formatted as: ($1, $2, $3),($4, $5, $6)". + "($#{3 * i - 2}, $#{3 * i - 1}, $#{3 * i})" + end) - from( - ctb in CurrentTokenBalance, - where: ^QueryHelper.tuple_in([:address_hash, :token_contract_address_hash, :token_id], ids) - ) + # Using raw SQL here is needed to be able to add the `COALESCE` statement + # which is needed to force `fetched_current_token_balances` full index usage + existing_ctb_query = + """ + SELECT address_hash, token_contract_address_hash, token_id, block_number, value, value_fetched_at + FROM address_current_token_balances + WHERE (address_hash, token_contract_address_hash, COALESCE(token_id, -1)) IN (#{placeholders}) + """ + + query_params = List.flatten(ids) + + existing_ctb_query + |> repo.query!(query_params) + |> Map.get(:rows, []) + |> Enum.map(fn [address_hash, token_contract_address_hash, token_id, block_number, value, value_fetched_at] -> + %{ + address_hash: address_hash, + token_contract_address_hash: token_contract_address_hash, + token_id: token_id, + block_number: block_number, + value: value, + value_fetched_at: value_fetched_at + } + end) end # ctb does not exist defp should_update?(_new_ctb, nil), do: true - # new ctb has no value - defp should_update?(%{value_fetched_at: nil}, _existing_ctb), do: false - # new ctb is newer defp should_update?(%{block_number: new_ctb_block_number}, %{block_number: existing_ctb_block_number}) when new_ctb_block_number > existing_ctb_block_number, @@ -284,7 +306,7 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do # new ctb is the same height or older defp should_update?(new_ctb, existing_ctb) do existing_ctb.block_number == new_ctb.block_number and not is_nil(Map.get(new_ctb, :value)) and - (is_nil(existing_ctb.value_fetched_at) or existing_ctb.value_fetched_at < new_ctb.value_fetched_at) + (is_nil(existing_ctb.value_fetched_at) or Timex.before?(existing_ctb.value_fetched_at, new_ctb.value_fetched_at)) end @spec insert(Repo.t(), [map()], %{ @@ -355,21 +377,23 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalances do update: [ set: [ block_number: fragment("EXCLUDED.block_number"), - value: fragment("EXCLUDED.value"), + value: fragment("COALESCE(EXCLUDED.value, ?)", current_token_balance.value), value_fetched_at: fragment("EXCLUDED.value_fetched_at"), old_value: current_token_balance.value, token_type: fragment("EXCLUDED.token_type"), + refetch_after: fragment("EXCLUDED.refetch_after"), + retries_count: fragment("EXCLUDED.retries_count"), inserted_at: fragment("LEAST(EXCLUDED.inserted_at, ?)", current_token_balance.inserted_at), updated_at: fragment("GREATEST(EXCLUDED.updated_at, ?)", current_token_balance.updated_at) ] ], where: - fragment("EXCLUDED.value_fetched_at IS NOT NULL") and - (fragment("? < EXCLUDED.block_number", current_token_balance.block_number) or - (fragment("? = EXCLUDED.block_number", current_token_balance.block_number) and - fragment("EXCLUDED.value IS NOT NULL") and - (is_nil(current_token_balance.value_fetched_at) or - fragment("? < EXCLUDED.value_fetched_at", current_token_balance.value_fetched_at)))) + fragment("? < EXCLUDED.block_number", current_token_balance.block_number) or + (fragment("? = EXCLUDED.block_number", current_token_balance.block_number) and + fragment("EXCLUDED.value_fetched_at IS NOT NULL") and + fragment("EXCLUDED.value IS NOT NULL") and + (is_nil(current_token_balance.value_fetched_at) or + fragment("? < EXCLUDED.value_fetched_at", current_token_balance.value_fetched_at))) ) end diff --git a/apps/explorer/lib/explorer/chain/import/runner/address/token_balances.ex b/apps/explorer/lib/explorer/chain/import/runner/address/token_balances.ex index 932b6cc748f1..bd2046768ca6 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/address/token_balances.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/address/token_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Address.TokenBalances do @moduledoc """ Bulk imports `t:Explorer.Chain.Address.TokenBalance.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/addresses.ex b/apps/explorer/lib/explorer/chain/import/runner/addresses.ex index 0c01af5bc917..b8239b915849 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/addresses.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/addresses.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Addresses do @moduledoc """ Bulk imports `t:Explorer.Chain.Address.t/0`. @@ -7,9 +8,9 @@ defmodule Explorer.Chain.Import.Runner.Addresses do import Explorer.Chain.Import.Runner.Helper, only: [chain_type_dependent_import: 3] alias Ecto.{Multi, Repo} + alias Explorer.Chain.{Address, Import, Transaction} alias Explorer.Chain.Filecoin.PendingAddressOperation, as: FilecoinPendingAddressOperation alias Explorer.Chain.Import.Runner - alias Explorer.Chain.{Address, Hash, Import, Transaction} alias Explorer.Prometheus.Instrumenter require Ecto.Query @@ -73,7 +74,7 @@ defmodule Explorer.Chain.Import.Runner.Addresses do multi |> Multi.run(:filter_addresses, fn repo, _ -> Instrumenter.block_import_stage_runner( - fn -> filter_addresses(repo, ordered_changes_list) end, + fn -> filter_addresses(repo, ordered_changes_list, options[option_key()][:fields_to_update]) end, :addresses, :addresses, :filter_addresses @@ -120,10 +121,13 @@ defmodule Explorer.Chain.Import.Runner.Addresses do @impl Import.Runner def timeout, do: @timeout + @impl Import.Runner + def runner_specific_options, do: [:fields_to_update] + ## Private Functions - @spec filter_addresses(Repo.t(), [map()]) :: {:ok, {[map()], map()}} - defp filter_addresses(repo, changes_list) do + @spec filter_addresses(Repo.t(), [map()], [atom()] | nil) :: {:ok, {[map()], map()}} + defp filter_addresses(repo, changes_list, fields_to_update) do hashes = Enum.map(changes_list, & &1.hash) existing_addresses_query = @@ -142,7 +146,7 @@ defmodule Explorer.Chain.Import.Runner.Addresses do |> Enum.reduce([], fn address, acc -> existing_address = existing_addresses_map[address.hash] - if should_update?(address, existing_address) do + if should_update?(address, existing_address, fields_to_update) do [address | acc] else acc @@ -153,9 +157,10 @@ defmodule Explorer.Chain.Import.Runner.Addresses do {:ok, {filtered_addresses, existing_addresses_map}} end - defp should_update?(new_address, existing_address) do - is_nil(existing_address) or - (not is_nil(new_address[:contract_code]) and new_address[:contract_code] != existing_address.contract_code) or + defp should_update?(_new_address, nil, _fields_to_replace), do: true + + defp should_update?(new_address, existing_address, nil) do + (not is_nil(new_address[:contract_code]) and new_address[:contract_code] != existing_address.contract_code) or (not is_nil(new_address[:fetched_coin_balance_block_number]) and (is_nil(existing_address.fetched_coin_balance_block_number) or new_address[:fetched_coin_balance_block_number] >= existing_address.fetched_coin_balance_block_number)) or @@ -163,7 +168,12 @@ defmodule Explorer.Chain.Import.Runner.Addresses do (is_nil(existing_address.nonce) or new_address[:nonce] > existing_address.nonce)) end - @spec insert(Repo.t(), [%{hash: Hash.Address.t()}], %{ + defp should_update?(new_address, existing_address, fields_to_replace) do + fields_to_replace + |> Enum.any?(fn field -> Map.get(existing_address, field) != Map.get(new_address, field) end) + end + + @spec insert(Repo.t(), [map()], %{ optional(:on_conflict) => Import.Runner.on_conflict(), required(:timeout) => timeout, required(:timestamps) => Import.timestamps() @@ -232,7 +242,7 @@ defmodule Explorer.Chain.Import.Runner.Addresses do # where any of `set`s would make a change # This is so that tuples are only generated when a change would occur where: - fragment("COALESCE(?, EXCLUDED.contract_code) IS DISTINCT FROM ?", address.contract_code, address.contract_code) or + fragment("COALESCE(EXCLUDED.contract_code, ?) IS DISTINCT FROM ?", address.contract_code, address.contract_code) or fragment( "EXCLUDED.fetched_coin_balance_block_number IS NOT NULL AND (? IS NULL OR EXCLUDED.fetched_coin_balance_block_number >= ?)", address.fetched_coin_balance_block_number, diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_blocks.ex index 2b97c22e75f1..c5f26081565a 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.BatchBlocks do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.BatchBlock. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_to_da_blobs.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_to_da_blobs.ex index 752b94d4da6c..296256c44185 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_to_da_blobs.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_to_da_blobs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.BatchToDaBlobs do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.BatchToDaBlob. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_transactions.ex index 17cda3fb909a..333283517073 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/batch_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.BatchTransactions do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.BatchTransaction. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/da_multi_purpose_records.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/da_multi_purpose_records.ex index ca8ef10c95ee..4eb647f7ef83 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/da_multi_purpose_records.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/da_multi_purpose_records.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.DaMultiPurposeRecords do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.DaMultiPurposeRecord. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_batches.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_batches.ex index ddca21b5de95..f6ea269fb34f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_batches.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_batches.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.L1Batches do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.L1Batch. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_executions.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_executions.ex index e597ba55f0e2..4e6d105a7d0c 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_executions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/l1_executions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.L1Executions do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.L1Execution. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/lifecycle_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/lifecycle_transactions.ex index b6ab03b73cee..d0d23dc26786 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/lifecycle_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/lifecycle_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.LifecycleTransactions do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.LifecycleTransaction. diff --git a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/messages.ex b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/messages.ex index 9aef34064ffa..7c8629495838 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/arbitrum/messages.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/arbitrum/messages.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Arbitrum.Messages do @moduledoc """ Bulk imports of Explorer.Chain.Arbitrum.Message. @@ -42,7 +43,7 @@ defmodule Explorer.Chain.Import.Runner.Arbitrum.Messages do |> Map.put_new(:timeout, @timeout) |> Map.put(:timestamps, timestamps) - Multi.run(multi, :insert_arbitrum_messages, fn repo, _ -> + Multi.run(multi, CrosslevelMessage.insert_result_key(), fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> insert(repo, changes_list, insert_options) end, :block_referencing, diff --git a/apps/explorer/lib/explorer/chain/import/runner/beacon/blob_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/beacon/blob_transactions.ex index e3f61616cd19..1cac0aaeab1f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/beacon/blob_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/beacon/blob_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Beacon.BlobTransactions do @moduledoc """ Bulk imports `t:Explorer.Chain.Beacon.BlobTransaction.t/0`. @@ -7,8 +8,8 @@ defmodule Explorer.Chain.Import.Runner.Beacon.BlobTransactions do import Ecto.Query, only: [from: 2] - alias Explorer.Chain.Beacon.BlobTransaction alias Ecto.{Multi, Repo} + alias Explorer.Chain.Beacon.BlobTransaction alias Explorer.Chain.{Hash, Import} alias Explorer.Prometheus.Instrumenter diff --git a/apps/explorer/lib/explorer/chain/import/runner/block/rewards.ex b/apps/explorer/lib/explorer/chain/import/runner/block/rewards.ex index cf13ae1b2278..f3140ab0a69d 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/block/rewards.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/block/rewards.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Block.Rewards do @moduledoc """ Bulk imports `t:Explorer.Chain.Block.Reward.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/block/second_degree_relations.ex b/apps/explorer/lib/explorer/chain/import/runner/block/second_degree_relations.ex index 0383e681d31d..4061835998a7 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/block/second_degree_relations.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/block/second_degree_relations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Block.SecondDegreeRelations do @moduledoc """ Bulk imports `t:Explorer.Chain.Block.SecondDegreeRelation.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex index 4972461ff1e2..2c2a5bbf618b 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Blocks do @moduledoc """ Bulk imports `t:Explorer.Chain.Block.t/0`. @@ -5,8 +6,9 @@ defmodule Explorer.Chain.Import.Runner.Blocks do require Ecto.Query - import Ecto.Query, only: [from: 2, where: 3, subquery: 1] - import Explorer.Chain.Import.Runner.Helper, only: [chain_type_dependent_import: 3] + import Ecto.Query, only: [dynamic: 1, dynamic: 2, from: 2, where: 3, subquery: 1] + import Explorer.Chain.Import.Runner.Helper, only: [chain_identity_dependent_import: 3] + import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] alias Ecto.{Changeset, Multi, Repo} @@ -18,23 +20,29 @@ defmodule Explorer.Chain.Import.Runner.Blocks do BlockNumberHelper, DenormalizationHelper, Import, - PendingBlockOperation, PendingOperationsHelper, + SmartContract, Token, Token.Instance, TokenTransfer, Transaction } - alias Explorer.Chain.Celo.Helper, as: CeloHelper - alias Explorer.Chain.Celo.PendingEpochBlockOperation, as: CeloPendingEpochBlockOperation - alias Explorer.Chain.Block.Reward + alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Import.Runner alias Explorer.Chain.Import.Runner.Address.CurrentTokenBalances alias Explorer.Chain.Import.Runner.{Addresses, TokenInstances, Tokens} + alias Explorer.Chain.InternalTransaction.DeleteQueue, as: InternalTransactionDeleteQueue + alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer alias Explorer.Prometheus.Instrumenter - alias Explorer.Utility.MissingRangesManipulator + alias Explorer.Repo, as: ExplorerRepo + alias Explorer.Utility.MissingBlockRange + + alias Explorer.Chain.Celo.AggregatedElectionReward, as: CeloAggregatedElectionReward + alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward + alias Explorer.Chain.Celo.Epoch, as: CeloEpoch + alias Explorer.Chain.Celo.EpochReward, as: CeloEpochReward @behaviour Runner @@ -68,11 +76,9 @@ defmodule Explorer.Chain.Import.Runner.Blocks do hashes = Enum.map(changes_list, & &1.hash) - consensus_block_numbers = consensus_block_numbers(changes_list) - # Enforce ShareLocks tables order (see docs: sharelocks.md) run_func = fn repo -> - {:ok, nonconsensus_items} = lose_consensus(repo, hashes, consensus_block_numbers, changes_list, insert_options) + {:ok, nonconsensus_items} = process_blocks_consensus(changes_list, repo, insert_options) {:ok, RangesHelper.filter_by_height_range(nonconsensus_items, fn {number, _hash} -> @@ -100,16 +106,6 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :blocks ) end) - |> Multi.run(:new_pending_block_operations, fn repo, %{blocks: blocks} -> - Instrumenter.block_import_stage_runner( - fn -> - new_pending_block_operations(repo, blocks, insert_options) - end, - :address_referencing, - :blocks, - :new_pending_block_operations - ) - end) |> Multi.run(:uncle_fetched_block_second_degree_relations, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> @@ -163,29 +159,34 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :derive_transaction_forks ) end) - |> Multi.run(:delete_address_coin_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> + |> Multi.run(:delete_address_token_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> Instrumenter.block_import_stage_runner( - fn -> delete_address_coin_balances(repo, non_consensus_blocks, insert_options) end, + fn -> delete_address_token_balances(repo, non_consensus_blocks, insert_options) end, :address_referencing, :blocks, - :delete_address_coin_balances + :delete_address_token_balances ) end) - |> Multi.run(:derive_address_fetched_coin_balances, fn repo, - %{delete_address_coin_balances: delete_address_coin_balances} -> + |> Multi.run(:select_address_current_token_balances_for_delete, fn repo, %{lose_consensus: non_consensus_blocks} -> Instrumenter.block_import_stage_runner( - fn -> derive_address_fetched_coin_balances(repo, delete_address_coin_balances, insert_options) end, + fn -> select_address_current_token_balances_for_delete(repo, non_consensus_blocks, insert_options) end, :address_referencing, :blocks, - :derive_address_fetched_coin_balances + :select_address_current_token_balances_for_delete ) end) - |> Multi.run(:delete_address_token_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> + |> Multi.run(:derive_address_current_token_balances, fn repo, + %{ + select_address_current_token_balances_for_delete: + address_current_token_balances_for_delete + } -> Instrumenter.block_import_stage_runner( - fn -> delete_address_token_balances(repo, non_consensus_blocks, insert_options) end, + fn -> + derive_address_current_token_balances(repo, address_current_token_balances_for_delete, insert_options) + end, :address_referencing, :blocks, - :delete_address_token_balances + :derive_address_current_token_balances ) end) |> Multi.run(:delete_address_current_token_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> @@ -196,16 +197,43 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :delete_address_current_token_balances ) end) - |> Multi.run(:derive_address_current_token_balances, fn repo, - %{ - delete_address_current_token_balances: - deleted_address_current_token_balances - } -> + |> Multi.run(:insert_derived_address_current_token_balances, fn repo, + %{ + derive_address_current_token_balances: + derived_address_current_token_balances + } -> Instrumenter.block_import_stage_runner( - fn -> derive_address_current_token_balances(repo, deleted_address_current_token_balances, insert_options) end, + fn -> + insert_derived_address_current_token_balances(repo, derived_address_current_token_balances, insert_options) + end, :address_referencing, :blocks, - :derive_address_current_token_balances + :insert_derived_address_current_token_balances + ) + end) + |> Multi.run(:delete_address_coin_balances, fn repo, %{lose_consensus: non_consensus_blocks} -> + Instrumenter.block_import_stage_runner( + fn -> delete_address_coin_balances(repo, non_consensus_blocks, insert_options) end, + :address_referencing, + :blocks, + :delete_address_coin_balances + ) + end) + |> Multi.run(:derive_address_fetched_coin_balances, fn repo, + %{delete_address_coin_balances: delete_address_coin_balances} -> + Instrumenter.block_import_stage_runner( + fn -> derive_address_fetched_coin_balances(repo, delete_address_coin_balances, insert_options) end, + :address_referencing, + :blocks, + :derive_address_fetched_coin_balances + ) + end) + |> Multi.run(:save_internal_transactions_for_delete, fn repo, %{lose_consensus: non_consensus_blocks} -> + Instrumenter.block_import_stage_runner( + fn -> save_internal_transactions_for_delete(repo, non_consensus_blocks, insert_options) end, + :address_referencing, + :blocks, + :save_internal_transactions_for_delete ) end) |> Multi.run(:update_token_instances_owner, fn repo, %{derive_transaction_forks: transactions} -> @@ -219,7 +247,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do |> Multi.run(:blocks_update_token_holder_counts, fn repo, %{ delete_address_current_token_balances: deleted, - derive_address_current_token_balances: inserted + insert_derived_address_current_token_balances: inserted } -> Instrumenter.block_import_stage_runner( fn -> @@ -231,18 +259,20 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :blocks_update_token_holder_counts ) end) - |> chain_type_dependent_import( - :celo, - &Multi.run(&1, :celo_pending_epoch_block_operations, fn repo, %{blocks: blocks} -> - Instrumenter.block_import_stage_runner( - fn -> - celo_pending_epoch_block_operations(repo, blocks, insert_options) - end, - :address_referencing, - :blocks, - :celo_pending_epoch_block_operations - ) - end) + |> chain_identity_dependent_import( + {:optimism, :celo}, + &Multi.run( + &1, + :celo_delete_epoch_rewards, + fn repo, %{lose_consensus: non_consensus_blocks} -> + Instrumenter.block_import_stage_runner( + fn -> celo_delete_epoch_rewards(repo, non_consensus_blocks, insert_options) end, + :address_referencing, + :blocks, + :celo_delete_epoch_rewards + ) + end + ) ) end @@ -289,6 +319,10 @@ defmodule Explorer.Chain.Import.Runner.Blocks do {_num, transactions} = repo.update_all(update_query, [], timeout: timeout) + transactions + |> Enum.map(& &1.hash) + |> PendingOperationsHelper.delete_related_transaction_operations() + {:ok, transactions} rescue postgrex_error in Postgrex.Error -> @@ -359,50 +393,178 @@ defmodule Explorer.Chain.Import.Runner.Blocks do ) end - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp default_on_conflict do - from( - block in Block, - update: [ - set: [ - consensus: fragment("EXCLUDED.consensus"), - difficulty: fragment("EXCLUDED.difficulty"), - gas_limit: fragment("EXCLUDED.gas_limit"), - gas_used: fragment("EXCLUDED.gas_used"), - miner_hash: fragment("EXCLUDED.miner_hash"), - nonce: fragment("EXCLUDED.nonce"), - number: fragment("EXCLUDED.number"), - parent_hash: fragment("EXCLUDED.parent_hash"), - size: fragment("EXCLUDED.size"), - timestamp: fragment("EXCLUDED.timestamp"), - total_difficulty: fragment("EXCLUDED.total_difficulty"), - refetch_needed: fragment("EXCLUDED.refetch_needed"), - # Don't update `hash` as it is used for the conflict target - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", block.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", block.updated_at) - ] + chain_type = Application.get_env(:explorer, :chain_type) + + base_fields = [ + consensus: dynamic(fragment("EXCLUDED.consensus")), + difficulty: dynamic(fragment("EXCLUDED.difficulty")), + gas_limit: dynamic(fragment("EXCLUDED.gas_limit")), + gas_used: dynamic(fragment("EXCLUDED.gas_used")), + miner_hash: dynamic(fragment("EXCLUDED.miner_hash")), + nonce: dynamic(fragment("EXCLUDED.nonce")), + number: dynamic(fragment("EXCLUDED.number")), + parent_hash: dynamic(fragment("EXCLUDED.parent_hash")), + size: dynamic(fragment("EXCLUDED.size")), + timestamp: dynamic(fragment("EXCLUDED.timestamp")), + total_difficulty: dynamic(fragment("EXCLUDED.total_difficulty")), + refetch_needed: dynamic(fragment("EXCLUDED.refetch_needed")), + base_fee_per_gas: dynamic(fragment("EXCLUDED.base_fee_per_gas")), + is_empty: dynamic(fragment("EXCLUDED.is_empty")), + # Don't update `hash` as it is used for the conflict target + inserted_at: dynamic([block], fragment("LEAST(?, EXCLUDED.inserted_at)", block.inserted_at)), + updated_at: dynamic([block], fragment("GREATEST(?, EXCLUDED.updated_at)", block.updated_at)) + ] + + base_condition = + dynamic( + [block], + fragment( + "(EXCLUDED.consensus, EXCLUDED.difficulty, EXCLUDED.gas_limit, EXCLUDED.gas_used, EXCLUDED.miner_hash, EXCLUDED.nonce, EXCLUDED.number, EXCLUDED.parent_hash, EXCLUDED.size, EXCLUDED.timestamp, EXCLUDED.total_difficulty, EXCLUDED.refetch_needed, EXCLUDED.base_fee_per_gas, EXCLUDED.is_empty) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + block.consensus, + block.difficulty, + block.gas_limit, + block.gas_used, + block.miner_hash, + block.nonce, + block.number, + block.parent_hash, + block.size, + block.timestamp, + block.total_difficulty, + block.refetch_needed, + block.base_fee_per_gas, + block.is_empty + ) + ) + + case on_conflict_chain_type_extension(chain_type) do + {chain_type_fields, chain_type_condition} -> + base_with_chain_type_fields = Keyword.merge(base_fields, chain_type_fields) + base_with_chain_type_condition = dynamic(^base_condition or ^chain_type_condition) + + from( + block in Block, + update: ^[set: base_with_chain_type_fields], + where: ^base_with_chain_type_condition + ) + + _ -> + from( + block in Block, + update: ^[set: base_fields], + where: ^base_condition + ) + end + end + + defp on_conflict_chain_type_extension(:ethereum) do + { + [ + blob_gas_used: dynamic(fragment("EXCLUDED.blob_gas_used")), + excess_blob_gas: dynamic(fragment("EXCLUDED.excess_blob_gas")) ], - where: - fragment("EXCLUDED.consensus <> ?", block.consensus) or fragment("EXCLUDED.difficulty <> ?", block.difficulty) or - fragment("EXCLUDED.gas_limit <> ?", block.gas_limit) or fragment("EXCLUDED.gas_used <> ?", block.gas_used) or - fragment("EXCLUDED.miner_hash <> ?", block.miner_hash) or fragment("EXCLUDED.nonce <> ?", block.nonce) or - fragment("EXCLUDED.number <> ?", block.number) or fragment("EXCLUDED.parent_hash <> ?", block.parent_hash) or - fragment("EXCLUDED.size <> ?", block.size) or fragment("EXCLUDED.timestamp <> ?", block.timestamp) or - fragment("EXCLUDED.total_difficulty <> ?", block.total_difficulty) or - fragment("EXCLUDED.refetch_needed <> ?", block.refetch_needed) - ) + dynamic( + [block], + fragment( + "(EXCLUDED.blob_gas_used, EXCLUDED.excess_blob_gas) IS DISTINCT FROM (?, ?)", + block.blob_gas_used, + block.excess_blob_gas + ) + ) + } end - defp consensus_block_numbers(blocks_changes) when is_list(blocks_changes) do + defp on_conflict_chain_type_extension(:rsk) do + { + [ + bitcoin_merged_mining_header: dynamic(fragment("EXCLUDED.bitcoin_merged_mining_header")), + bitcoin_merged_mining_coinbase_transaction: + dynamic(fragment("EXCLUDED.bitcoin_merged_mining_coinbase_transaction")), + bitcoin_merged_mining_merkle_proof: dynamic(fragment("EXCLUDED.bitcoin_merged_mining_merkle_proof")), + hash_for_merged_mining: dynamic(fragment("EXCLUDED.hash_for_merged_mining")), + minimum_gas_price: dynamic(fragment("EXCLUDED.minimum_gas_price")) + ], + dynamic( + [block], + fragment( + "(EXCLUDED.bitcoin_merged_mining_header, EXCLUDED.bitcoin_merged_mining_coinbase_transaction, EXCLUDED.bitcoin_merged_mining_merkle_proof, EXCLUDED.hash_for_merged_mining, EXCLUDED.minimum_gas_price) IS DISTINCT FROM (?, ?, ?, ?, ?)", + block.bitcoin_merged_mining_header, + block.bitcoin_merged_mining_coinbase_transaction, + block.bitcoin_merged_mining_merkle_proof, + block.hash_for_merged_mining, + block.minimum_gas_price + ) + ) + } + end + + defp on_conflict_chain_type_extension(:arbitrum) do + { + [ + send_count: dynamic(fragment("EXCLUDED.send_count")), + send_root: dynamic(fragment("EXCLUDED.send_root")), + l1_block_number: dynamic(fragment("EXCLUDED.l1_block_number")) + ], + dynamic( + [block], + fragment( + "(EXCLUDED.send_count, EXCLUDED.send_root, EXCLUDED.l1_block_number) IS DISTINCT FROM (?, ?, ?)", + block.send_count, + block.send_root, + block.l1_block_number + ) + ) + } + end + + defp on_conflict_chain_type_extension(:zilliqa) do + { + [ + zilliqa_view: dynamic(fragment("EXCLUDED.zilliqa_view")) + ], + dynamic( + [block], + fragment( + "EXCLUDED.zilliqa_view IS DISTINCT FROM ?", + block.zilliqa_view + ) + ) + } + end + + defp on_conflict_chain_type_extension(_), do: nil + + defp consensus_block_identifiers(blocks_changes) when is_list(blocks_changes) do blocks_changes |> Enum.filter(& &1.consensus) - |> Enum.map(& &1.number) + |> Enum.reduce({[], []}, fn block_change, {numbers, hashes} -> + {[block_change.number | numbers], [block_change.hash | hashes]} + end) end - def lose_consensus(repo, hashes, consensus_block_numbers, changes_list, %{ - timeout: timeout, - timestamps: %{updated_at: updated_at} - }) do + # Handles block consensus loss. + # + # ## Parameters + # - `repo`: The `t:Explorer.Repo.t/0` or db replica instance. + # - `changes_list`: List of block changes to process. + # - `_opts`: The options containing timeout and `updated_at` timestamp for db operations. + # + # ## Returns + # - `{:ok, removed_consensus_blocks}` tuple with the list of `{block_number, block_hash}` + # tuples for the blocks that lost consensus. + # - `{:error, %{exception: postgrex_error}}` in case of database error. + defp lose_consensus( + repo, + changes_list, + %{ + timeout: timeout, + timestamps: %{updated_at: updated_at} + } = _opts + ) do + hashes = Enum.map(changes_list, & &1.hash) + {consensus_block_numbers, consensus_hashes} = consensus_block_identifiers(changes_list) + acquire_query = from( block in where_invalid_neighbor(changes_list), @@ -415,7 +577,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do lock: "FOR NO KEY UPDATE" ) - {_, removed_consensus_block_hashes} = + {_, removed_consensus_blocks} = repo.update_all( from( block in Block, @@ -429,13 +591,28 @@ defmodule Explorer.Chain.Import.Runner.Blocks do timeout: timeout ) + removed_consensus_block_numbers = + removed_consensus_blocks + |> Enum.map(fn {number, _hash} -> number end) + + maximum_block_number = BlockNumber.get_max() + + minimum_recent_block_number = + removed_consensus_block_numbers + |> Enum.filter(fn n -> n >= maximum_block_number - 64 end) + |> Enum.min(fn -> nil end) + + if minimum_recent_block_number do + GenServer.cast(Indexer.Fetcher.Beacon.Deposit, {:lost_consensus, minimum_recent_block_number}) + end + repo.update_all( from( transaction in Transaction, join: s in subquery(acquire_query), on: transaction.block_hash == s.hash, # we don't want to remove consensus from blocks that will be upserted - where: transaction.block_hash not in ^hashes + where: transaction.block_hash not in ^consensus_hashes ), [set: [block_consensus: false, updated_at: updated_at]], timeout: timeout @@ -445,49 +622,91 @@ defmodule Explorer.Chain.Import.Runner.Blocks do from( token_transfer in TokenTransfer, join: s in subquery(acquire_query), - on: token_transfer.block_number == s.number, + on: token_transfer.block_number == s.number and token_transfer.block_hash == s.hash, # we don't want to remove consensus from blocks that will be upserted - where: token_transfer.block_hash not in ^hashes + where: token_transfer.block_hash not in ^consensus_hashes ), [set: [block_consensus: false, updated_at: updated_at]], timeout: timeout ) - removed_consensus_block_hashes - |> Enum.map(fn {number, _hash} -> number end) + # Query to find addresses created in lost consensus blocks + created_contract_addresses_query = + from( + t in Transaction, + join: s in subquery(acquire_query), + on: t.block_hash == s.hash, + # we don't want to remove contract code from blocks that will be upserted + where: t.block_hash not in ^consensus_hashes, + where: not is_nil(t.created_contract_address_hash), + select: t.created_contract_address_hash + ) + + # Delete smart contracts for addresses created in lost consensus blocks + repo.delete_all( + from( + sc in SmartContract, + where: sc.address_hash in subquery(created_contract_addresses_query) + ), + timeout: timeout + ) + + # Clear contract code from addresses created in lost consensus blocks + repo.update_all( + from( + address in Address, + where: address.hash in subquery(created_contract_addresses_query) + ), + [set: [contract_code: nil, updated_at: updated_at]], + timeout: timeout + ) + + if Application.get_env(:explorer, :chain_type) == :zilliqa do + repo.delete_all( + from( + zrc2_token_transfer in Zrc2TokenTransfer, + join: s in subquery(acquire_query), + on: zrc2_token_transfer.block_number == s.number and zrc2_token_transfer.block_hash == s.hash, + where: zrc2_token_transfer.block_hash not in ^consensus_hashes + ), + timeout: timeout + ) + end + + removed_consensus_block_numbers |> Enum.reject(&Enum.member?(consensus_block_numbers, &1)) - |> MissingRangesManipulator.add_ranges_by_block_numbers() + |> MissingBlockRange.add_ranges_by_block_numbers() - {:ok, removed_consensus_block_hashes} + {:ok, removed_consensus_blocks} rescue postgrex_error in Postgrex.Error -> - {:error, %{exception: postgrex_error, consensus_block_numbers: consensus_block_numbers}} + {:error, %{exception: postgrex_error}} end - defp new_pending_block_operations(repo, inserted_blocks, %{timeout: timeout, timestamps: timestamps}) do - case PendingOperationsHelper.pending_operations_type() do - "blocks" -> - sorted_pending_ops = - inserted_blocks - |> RangesHelper.filter_by_height_range(&RangesHelper.traceable_block_number?(&1.number)) - |> Enum.filter(& &1.consensus) - |> Enum.map(&%{block_hash: &1.hash, block_number: &1.number}) - |> Enum.sort() - - Import.insert_changes_list( - repo, - sorted_pending_ops, - conflict_target: :block_hash, - on_conflict: :nothing, - for: PendingBlockOperation, - returning: true, - timeout: timeout, - timestamps: timestamps - ) + @doc """ + Processes consensus for blocks that failed to import completely. - _other_type -> - {:ok, []} - end + This function handles the consistency updates needed when a block import fails, + ensuring that the chain's consensus state remains valid. + + ## Parameters + - `blocks_changes`: List of block changes to process + + ## Returns + - `{:ok, removed_consensus_blocks}`: List of tuples {number, hash} for blocks that lost consensus + - `{:error, reason}`: The error encountered during processing + """ + @spec process_blocks_consensus([map()], module(), map() | nil) :: + {:ok, [{non_neg_integer(), binary()}]} | {:error, map()} + def process_blocks_consensus(blocks_changes, repo \\ ExplorerRepo, insert_options \\ nil) do + opts = + insert_options || + %{ + timeout: @timeout, + timestamps: %{updated_at: DateTime.utc_now()} + } + + lose_consensus(repo, blocks_changes, opts) end defp delete_address_coin_balances(_repo, [], _options), do: {:ok, []} @@ -570,7 +789,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do ordered_query = from(tb in Address.TokenBalance, where: tb.block_number in ^non_consensus_block_numbers, - select: map(tb, [:address_hash, :token_contract_address_hash, :token_id, :block_number]), + select: select_ctid(tb), # Enforce TokenBalance ShareLocks order (see docs: sharelocks.md) order_by: [ tb.token_contract_address_hash, @@ -585,14 +804,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do from(tb in Address.TokenBalance, select: map(tb, [:address_hash, :token_contract_address_hash, :block_number]), inner_join: ordered_address_token_balance in subquery(ordered_query), - on: - ordered_address_token_balance.address_hash == tb.address_hash and - ordered_address_token_balance.token_contract_address_hash == - tb.token_contract_address_hash and - ((is_nil(ordered_address_token_balance.token_id) and is_nil(tb.token_id)) or - (ordered_address_token_balance.token_id == tb.token_id and - not is_nil(ordered_address_token_balance.token_id) and not is_nil(tb.token_id))) and - ordered_address_token_balance.block_number == tb.block_number + on: join_on_ctid(tb, ordered_address_token_balance) ) try do @@ -605,6 +817,26 @@ defmodule Explorer.Chain.Import.Runner.Blocks do end end + defp select_address_current_token_balances_for_delete(_, [], _), do: {:ok, []} + + defp select_address_current_token_balances_for_delete(repo, non_consensus_blocks, %{timeout: timeout}) do + non_consensus_block_numbers = Enum.map(non_consensus_blocks, fn {number, _hash} -> number end) + + query = + from(ctb in Address.CurrentTokenBalance, + select: + map(ctb, [ + :address_hash, + :token_contract_address_hash, + :token_id, + :value + ]), + where: ctb.block_number in ^non_consensus_block_numbers + ) + + {:ok, repo.all(query, timeout: timeout)} + end + defp delete_address_current_token_balances(_, [], _), do: {:ok, []} defp delete_address_current_token_balances(repo, non_consensus_blocks, %{timeout: timeout}) do @@ -613,7 +845,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do ordered_query = from(ctb in Address.CurrentTokenBalance, where: ctb.block_number in ^non_consensus_block_numbers, - select: map(ctb, [:address_hash, :token_contract_address_hash, :token_id]), + select: select_ctid(ctb), # Enforce CurrentTokenBalance ShareLocks order (see docs: sharelocks.md) order_by: [ ctb.token_contract_address_hash, @@ -636,12 +868,7 @@ defmodule Explorer.Chain.Import.Runner.Blocks do :value ]), inner_join: ordered_address_current_token_balance in subquery(ordered_query), - on: - ordered_address_current_token_balance.address_hash == ctb.address_hash and - ordered_address_current_token_balance.token_contract_address_hash == ctb.token_contract_address_hash and - ((is_nil(ordered_address_current_token_balance.token_id) and is_nil(ctb.token_id)) or - (ordered_address_current_token_balance.token_id == ctb.token_id and - not is_nil(ordered_address_current_token_balance.token_id) and not is_nil(ctb.token_id))) + on: join_on_ctid(ctb, ordered_address_current_token_balance) ) try do @@ -659,46 +886,42 @@ defmodule Explorer.Chain.Import.Runner.Blocks do defp derive_address_current_token_balances( repo, deleted_address_current_token_balances, - %{timeout: timeout} = options + %{timeout: timeout} ) when is_list(deleted_address_current_token_balances) do - final_query = derive_address_current_token_balances_grouped_query(deleted_address_current_token_balances) - - new_current_token_balance_query = - from(new_current_token_balance in subquery(final_query), - inner_join: tb in Address.TokenBalance, - on: - tb.address_hash == new_current_token_balance.address_hash and - tb.token_contract_address_hash == new_current_token_balance.token_contract_address_hash and - ((is_nil(tb.token_id) and is_nil(new_current_token_balance.token_id)) or - (tb.token_id == new_current_token_balance.token_id and - not is_nil(tb.token_id) and not is_nil(new_current_token_balance.token_id))) and - tb.block_number == new_current_token_balance.block_number, + base_query = + from( + tb in Address.TokenBalance, select: %{ - address_hash: new_current_token_balance.address_hash, - token_contract_address_hash: new_current_token_balance.token_contract_address_hash, - token_id: new_current_token_balance.token_id, + address_hash: tb.address_hash, + token_contract_address_hash: tb.token_contract_address_hash, + token_id: tb.token_id, + block_number: tb.block_number, token_type: tb.token_type, - block_number: new_current_token_balance.block_number, value: tb.value, - value_fetched_at: tb.value_fetched_at, - inserted_at: over(min(tb.inserted_at), :w), - updated_at: over(max(tb.updated_at), :w) + value_fetched_at: tb.value_fetched_at }, - windows: [ - w: [partition_by: [tb.address_hash, tb.token_contract_address_hash, tb.token_id]] - ] + distinct: [tb.address_hash, tb.token_contract_address_hash, fragment("COALESCE(?, -1::numeric)", tb.token_id)], + order_by: [desc: tb.block_number] ) - current_token_balance = - new_current_token_balance_query - |> repo.all() + query = + derive_address_current_token_balances_to_deleted_entries_only_query( + base_query, + deleted_address_current_token_balances + ) + {:ok, repo.all(query, timeout: timeout)} + end + + defp insert_derived_address_current_token_balances(_, [], _), do: {:ok, []} + + defp insert_derived_address_current_token_balances(repo, current_token_balances, %{timeout: timeout} = options) do timestamps = Import.timestamps() result = CurrentTokenBalances.insert_changes_list_with_and_without_token_id( - current_token_balance, + current_token_balances, repo, timestamps, timeout, @@ -711,6 +934,29 @@ defmodule Explorer.Chain.Import.Runner.Blocks do {:ok, derived_address_current_token_balances} end + defp save_internal_transactions_for_delete(_, [], _), do: {:ok, []} + + defp save_internal_transactions_for_delete(repo, non_consensus_blocks, %{timeout: timeout, timestamps: timestamps}) do + insert_params = + non_consensus_blocks + |> Enum.map(fn {number, _hash} -> + Map.put(timestamps, :block_number, number) + end) + |> Enum.uniq() + + {_total, result} = + repo.insert_all( + InternalTransactionDeleteQueue, + insert_params, + conflict_target: [:block_number], + on_conflict: {:replace, [:updated_at]}, + returning: [:block_number], + timeout: timeout + ) + + {:ok, Enum.map(result, & &1.block_number)} + end + defp update_token_instances_owner(_, [], _), do: {:ok, []} defp update_token_instances_owner(repo, forked_transaction_hashes, options) do @@ -889,41 +1135,29 @@ defmodule Explorer.Chain.Import.Runner.Blocks do ) end - defp derive_address_current_token_balances_grouped_query(deleted_address_current_token_balances) do - initial_query = - from(tb in Address.TokenBalance, - select: %{ - address_hash: tb.address_hash, - token_contract_address_hash: tb.token_contract_address_hash, - token_id: tb.token_id, - block_number: max(tb.block_number) - }, - group_by: [tb.address_hash, tb.token_contract_address_hash, tb.token_id] - ) + defp derive_address_current_token_balances_to_deleted_entries_only_query( + base_query, + deleted_address_current_token_balances + ) do + Enum.reduce( + deleted_address_current_token_balances, + base_query, + fn address_current_token_balance, accumulated_query -> + %{ + address_hash: address_hash, + token_contract_address_hash: token_contract_address_hash, + token_id: token_id + } = address_current_token_balance - Enum.reduce(deleted_address_current_token_balances, initial_query, fn %{ - address_hash: address_hash, - token_contract_address_hash: - token_contract_address_hash, - token_id: token_id - }, - acc_query -> - if token_id do - from(tb in acc_query, - or_where: - tb.address_hash == ^address_hash and - tb.token_contract_address_hash == ^token_contract_address_hash and - tb.token_id == ^token_id - ) - else - from(tb in acc_query, + from( + tb in accumulated_query, or_where: tb.address_hash == ^address_hash and tb.token_contract_address_hash == ^token_contract_address_hash and - is_nil(tb.token_id) + fragment("coalesce(?.\"token_id\", -1::numeric) = coalesce(?::numeric, -1::numeric)", tb, ^token_id) ) end - end) + ) end # `block_rewards` are linked to `blocks.hash`, but fetched by `blocks.number`, so when a block with the same number is @@ -1040,23 +1274,56 @@ defmodule Explorer.Chain.Import.Runner.Blocks do where(invalid_neighbors_query, [block], block.consensus) end - defp celo_pending_epoch_block_operations(repo, inserted_blocks, %{timeout: timeout, timestamps: timestamps}) do - ordered_epoch_blocks = - inserted_blocks - |> Enum.filter(fn block -> CeloHelper.epoch_block_number?(block.number) && block.consensus end) - |> Enum.map(&%{block_hash: &1.hash}) - |> Enum.sort_by(& &1.block_hash) - |> Enum.dedup_by(& &1.block_hash) + defp celo_delete_epoch_rewards(_repo, [], _options), do: {:ok, []} - Import.insert_changes_list( - repo, - ordered_epoch_blocks, - conflict_target: :block_hash, - on_conflict: :nothing, - for: CeloPendingEpochBlockOperation, - returning: true, - timeout: timeout, - timestamps: timestamps - ) + defp celo_delete_epoch_rewards(repo, non_consensus_blocks, %{timeout: timeout}) do + non_consensus_block_hashes = Enum.map(non_consensus_blocks, fn {_number, hash} -> hash end) + + ordered_query = + from(epoch in CeloEpoch, + where: + epoch.start_processing_block_hash in ^non_consensus_block_hashes or + epoch.end_processing_block_hash in ^non_consensus_block_hashes, + # Enforce Epoch ShareLocks order (see docs: sharelocks.md) + order_by: epoch.number, + lock: "FOR UPDATE" + ) + + repo.all(ordered_query, timeout: timeout) + + epoch_rewards_query = + from(reward in CeloEpochReward, + join: epoch in subquery(ordered_query), + on: reward.epoch_number == epoch.number + ) + + election_rewards_query = + from(reward in CeloElectionReward, + join: epoch in subquery(ordered_query), + on: reward.epoch_number == epoch.number + ) + + aggregated_election_rewards_query = + from(reward in CeloAggregatedElectionReward, + join: epoch in subquery(ordered_query), + on: reward.epoch_number == epoch.number + ) + + repo.delete_all(epoch_rewards_query, timeout: timeout) + repo.delete_all(election_rewards_query, timeout: timeout) + repo.delete_all(aggregated_election_rewards_query, timeout: timeout) + + {_count, updated_epochs} = + repo.update_all( + from(epoch in CeloEpoch, + join: ordered_epoch in subquery(ordered_query), + on: epoch.number == ordered_epoch.number, + select: epoch + ), + [set: [fetched?: false]], + timeout: timeout + ) + + {:ok, updated_epochs} end end diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/accounts.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/accounts.ex new file mode 100644 index 000000000000..f627b7cb2ce7 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/accounts.ex @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Celo.Accounts do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Celo.Account.t/0`. + """ + + require Ecto.Query + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Celo.Account + alias Explorer.Chain.Import + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [Account.t()] + + @impl Import.Runner + def ecto_schema_module, do: Account + + @impl Import.Runner + def option_key, do: :celo_accounts + + @impl Import.Runner + @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + @spec run(Multi.t(), list(), map()) :: Multi.t() + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :insert_celo_accounts, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :address_referencing, + :celo_accounts, + :celo_accounts + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [Account.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = _options) when is_list(changes_list) do + # Enforce Celo.Epoch.Account ShareLocks order (see docs: sharelock.md) + ordered_changes_list = + changes_list + |> Enum.uniq_by(& &1.address_hash) + |> Enum.sort_by(& &1.address_hash) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + for: Account, + returning: true, + timeout: timeout, + timestamps: timestamps, + conflict_target: :address_hash, + on_conflict: :replace_all + ) + + {:ok, inserted} + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/aggregated_election_rewards.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/aggregated_election_rewards.ex new file mode 100644 index 000000000000..87aa7dfba16f --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/aggregated_election_rewards.ex @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Celo.AggregatedElectionRewards do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Celo.AggregatedElectionReward.t/0`. + """ + + require Ecto.Query + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Celo.AggregatedElectionReward + alias Explorer.Chain.Import + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [AggregatedElectionReward.t()] + + @impl Import.Runner + def ecto_schema_module, do: AggregatedElectionReward + + @impl Import.Runner + def option_key, do: :celo_aggregated_election_rewards + + @impl Import.Runner + @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + @spec run(Multi.t(), list(), map()) :: Multi.t() + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + multi + |> Multi.run(:insert_celo_aggregated_election_rewards, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :celo_aggregated_election_rewards, + :celo_aggregated_election_rewards + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [AggregatedElectionReward.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = _options) when is_list(changes_list) do + # Enforce Celo.AggregatedElectionReward ShareLocks order (see docs: sharelock.md) + ordered_changes_list = Enum.sort_by(changes_list, &{&1.type, &1.epoch_number}) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + for: AggregatedElectionReward, + returning: true, + timeout: timeout, + timestamps: timestamps, + conflict_target: [:type, :epoch_number], + on_conflict: :replace_all + ) + + {:ok, inserted} + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/election_rewards.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/election_rewards.ex index b7ff06f78157..ad4ee909ac53 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/celo/election_rewards.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/election_rewards.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Celo.ElectionRewards do @moduledoc """ Bulk imports `t:Explorer.Chain.Celo.ElectionReward.t/0`. @@ -64,8 +65,8 @@ defmodule Explorer.Chain.Import.Runner.Celo.ElectionRewards do Enum.sort_by( changes_list, &{ - &1.block_hash, &1.type, + &1.epoch_number, &1.account_address_hash, &1.associated_account_address_hash } @@ -80,8 +81,8 @@ defmodule Explorer.Chain.Import.Runner.Celo.ElectionRewards do timeout: timeout, timestamps: timestamps, conflict_target: [ - :block_hash, :type, + :epoch_number, :account_address_hash, :associated_account_address_hash ], diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/epoch_rewards.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/epoch_rewards.ex index 7c7cd5752e68..6b36be2eb2d2 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/celo/epoch_rewards.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/epoch_rewards.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Celo.EpochRewards do @moduledoc """ Bulk imports `t:Explorer.Chain.Celo.EpochReward.t/0`. @@ -6,12 +7,10 @@ defmodule Explorer.Chain.Import.Runner.Celo.EpochRewards do require Ecto.Query alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Celo.{EpochReward, PendingEpochBlockOperation} + alias Explorer.Chain.Celo.EpochReward alias Explorer.Chain.Import alias Explorer.Prometheus.Instrumenter - import Ecto.Query - @behaviour Import.Runner # milliseconds @@ -45,14 +44,6 @@ defmodule Explorer.Chain.Import.Runner.Celo.EpochRewards do |> Map.put(:timestamps, timestamps) multi - |> Multi.run(:acquire_pending_epoch_block_operations, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> acquire_pending_epoch_block_operations(repo, changes_list) end, - :block_pending, - :celo_epoch_rewards, - :acquire_pending_epoch_block_operations - ) - end) |> Multi.run(:insert_celo_epoch_rewards, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> insert(repo, changes_list, insert_options) end, @@ -61,20 +52,6 @@ defmodule Explorer.Chain.Import.Runner.Celo.EpochRewards do :celo_epoch_rewards ) end) - |> Multi.run( - :delete_pending_epoch_block_operations, - fn repo, - %{ - acquire_pending_epoch_block_operations: pending_block_hashes - } -> - Instrumenter.block_import_stage_runner( - fn -> delete_pending_epoch_block_operations(repo, pending_block_hashes) end, - :block_pending, - :celo_epoch_rewards, - :delete_pending_epoch_block_operations - ) - end - ) end @impl Import.Runner @@ -85,7 +62,7 @@ defmodule Explorer.Chain.Import.Runner.Celo.EpochRewards do | {:error, [Changeset.t()]} def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = _options) when is_list(changes_list) do # Enforce Celo.EpochReward ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.block_hash) + ordered_changes_list = Enum.sort_by(changes_list, & &1.epoch_number) {:ok, inserted} = Import.insert_changes_list( @@ -95,45 +72,10 @@ defmodule Explorer.Chain.Import.Runner.Celo.EpochRewards do returning: true, timeout: timeout, timestamps: timestamps, - conflict_target: :block_hash, + conflict_target: :epoch_number, on_conflict: :replace_all ) {:ok, inserted} end - - def acquire_pending_epoch_block_operations(repo, changes_list) do - block_hashes = Enum.map(changes_list, & &1.block_hash) - - query = - from( - pending_ops in PendingEpochBlockOperation, - where: pending_ops.block_hash in ^block_hashes, - select: pending_ops.block_hash, - # Enforce PendingBlockOperation ShareLocks order (see docs: sharelocks.md) - order_by: [asc: pending_ops.block_hash], - lock: "FOR UPDATE" - ) - - {:ok, repo.all(query)} - end - - def delete_pending_epoch_block_operations(repo, block_hashes) do - delete_query = - from( - pending_ops in PendingEpochBlockOperation, - where: pending_ops.block_hash in ^block_hashes - ) - - try do - # ShareLocks order already enforced by - # `acquire_pending_epoch_block_operations` (see docs: sharelocks.md) - {_count, deleted} = repo.delete_all(delete_query, []) - - {:ok, deleted} - rescue - postgrex_error in Postgrex.Error -> - {:error, %{exception: postgrex_error, pending_hashes: block_hashes}} - end - end end diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/epochs.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/epochs.ex new file mode 100644 index 000000000000..1ea3b158a474 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/epochs.ex @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Celo.Epochs do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Celo.Epoch.t/0`. + """ + + require Ecto.Query + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Celo.Epoch + alias Explorer.Chain.Import + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [Epoch.t()] + + @impl Import.Runner + def ecto_schema_module, do: Epoch + + @impl Import.Runner + def option_key, do: :celo_epochs + + @impl Import.Runner + @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + @spec run(Multi.t(), list(), map()) :: Multi.t() + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :celo_epochs, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :celo_epochs, + :celo_epochs + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [Epoch.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = _options) when is_list(changes_list) do + # Enforce Celo.Epoch ShareLocks order (see docs: sharelock.md) + ordered_changes_list = Enum.sort_by(changes_list, & &1.number) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + for: Epoch, + returning: true, + timeout: timeout, + timestamps: timestamps, + conflict_target: [:number], + on_conflict: default_on_conflict() + ) + + {:ok, inserted} + end + + defp default_on_conflict do + from(epoch in Epoch, + update: [ + set: [ + start_block_number: fragment("COALESCE(EXCLUDED.start_block_number, ?)", epoch.start_block_number), + end_block_number: fragment("COALESCE(EXCLUDED.end_block_number, ?)", epoch.end_block_number), + start_processing_block_hash: + fragment("COALESCE(EXCLUDED.start_processing_block_hash, ?)", epoch.start_processing_block_hash), + end_processing_block_hash: + fragment("COALESCE(EXCLUDED.end_processing_block_hash, ?)", epoch.end_processing_block_hash), + fetched?: fragment("EXCLUDED.is_fetched"), + inserted_at: fragment("LEAST(EXCLUDED.inserted_at, ?)", epoch.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", epoch.updated_at) + ] + ], + where: + fragment("EXCLUDED.start_block_number IS DISTINCT FROM ?", epoch.start_block_number) or + fragment("EXCLUDED.end_block_number IS DISTINCT FROM ?", epoch.end_block_number) or + fragment("EXCLUDED.start_processing_block_hash IS DISTINCT FROM ?", epoch.start_processing_block_hash) or + fragment("EXCLUDED.end_processing_block_hash IS DISTINCT FROM ?", epoch.end_processing_block_hash) or + fragment("EXCLUDED.is_fetched IS DISTINCT FROM ?", epoch.fetched?) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/pending_account_operations.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/pending_account_operations.ex new file mode 100644 index 000000000000..47d369564ffa --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/pending_account_operations.ex @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Celo.PendingAccountOperations do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Celo.PendingAccountOperation.t/0`. + """ + + require Ecto.Query + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Celo.PendingAccountOperation + alias Explorer.Chain.Import + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [PendingAccountOperation.t()] + + @impl Import.Runner + def ecto_schema_module, do: PendingAccountOperation + + @impl Import.Runner + def option_key, do: :celo_pending_account_operations + + @impl Import.Runner + @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + @spec run(Multi.t(), list(), map()) :: Multi.t() + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :celo_pending_account_operations, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :address_referencing, + :celo_pending_account_operations, + :celo_pending_account_operations + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [PendingAccountOperation.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = _options) when is_list(changes_list) do + # Enforce Celo.Epoch.Account ShareLocks order (see docs: sharelock.md) + ordered_changes_list = + changes_list + |> Enum.uniq_by(& &1.address_hash) + |> Enum.sort_by(& &1.address_hash) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + for: PendingAccountOperation, + returning: true, + timeout: timeout, + timestamps: timestamps, + conflict_target: :address_hash, + on_conflict: :nothing + ) + + {:ok, inserted} + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/celo/validator_group_votes.ex b/apps/explorer/lib/explorer/chain/import/runner/celo/validator_group_votes.ex index 10cd12c5b30d..4349e3843879 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/celo/validator_group_votes.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/celo/validator_group_votes.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Celo.ValidatorGroupVotes do @moduledoc """ Bulk imports `t:Explorer.Chain.Celo.ValidatorGroupVote.t/0`. @@ -65,6 +66,7 @@ defmodule Explorer.Chain.Import.Runner.Celo.ValidatorGroupVotes do changes_list, &{ &1.transaction_hash, + &1.log_index, &1.account_address_hash, &1.group_address_hash } @@ -80,6 +82,7 @@ defmodule Explorer.Chain.Import.Runner.Celo.ValidatorGroupVotes do timestamps: timestamps, conflict_target: [ :transaction_hash, + :log_index, :account_address_hash, :group_address_hash ], diff --git a/apps/explorer/lib/explorer/chain/import/runner/fhe_operations.ex b/apps/explorer/lib/explorer/chain/import/runner/fhe_operations.ex new file mode 100644 index 000000000000..80c6555de452 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/fhe_operations.ex @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.FheOperations do + @moduledoc """ + Bulk imports FHE operations parsed from transaction logs. + + This runner handles the database insertion of FHE operations that were parsed + during block indexing. It follows the standard Blockscout import runner pattern + with proper conflict resolution and error handling. + + Also checks and sets FHE contract tags. + """ + + require Ecto.Query + require Logger + + alias Ecto.{Changeset, Multi} + alias Explorer.Chain.FheContractChecker + alias Explorer.Chain.{FheOperation, Import, Transaction} + alias Explorer.Repo + + import Ecto.Query, only: [from: 2] + + @behaviour Import.Runner + + @timeout 60_000 + + @type imported :: [FheOperation.t()] + + # Required by Import.Runner behaviour + @impl Import.Runner + def ecto_schema_module, do: FheOperation + + @impl Import.Runner + def option_key, do: :fhe_operations + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.put_new(:timeout, timeout()) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :insert_fhe_operations, fn repo, _ -> + insert(repo, changes_list, insert_options) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Ecto.Repo.t(), [map()], %{ + required(:timeout) => timeout(), + required(:timestamps) => Import.timestamps() + }) :: {:ok, [FheOperation.t()]} | {:error, [Changeset.t()]} + defp insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps}) when is_list(changes_list) do + # Return early if no FHE operations to insert + if Enum.empty?(changes_list) do + {:ok, []} + else + # Order by transaction_hash and log_index for deterministic insertion + ordered_changes_list = + changes_list + |> Enum.sort_by(&{&1.transaction_hash, &1.log_index}) + + # Insert with conflict resolution + # If the same operation exists (same transaction_hash + log_index), replace it + case Import.insert_changes_list( + repo, + ordered_changes_list, + conflict_target: [:transaction_hash, :log_index], + on_conflict: :replace_all, + for: FheOperation, + returning: true, + timeout: timeout, + timestamps: timestamps + ) do + {:ok, inserted} -> + tag_contracts_from_fhe_operations(ordered_changes_list) + update_transaction_fhe_counts(repo, ordered_changes_list) + {:ok, inserted} + end + end + end + + # Tags contracts that were called in transactions with FHE operations + defp tag_contracts_from_fhe_operations(fhe_operations) when is_list(fhe_operations) do + contract_addresses = get_all_contract_addresses_from_fhe_operations(fhe_operations) + + Enum.each(contract_addresses, fn address_hash -> + FheContractChecker.check_and_save_fhe_status(address_hash, []) + end) + + :ok + end + + # Gets all unique contract addresses from FHE operations: + # 1. Caller addresses from FHE operation logs (contracts that called FHE operations) + # 2. Transaction to_addresses (contracts that were called in transactions with FHE operations) + defp get_all_contract_addresses_from_fhe_operations(fhe_operations) do + # Get caller addresses from FHE operation logs + caller_addresses = + fhe_operations + |> Enum.map(&Map.get(&1, :caller)) + |> Enum.filter(&(not is_nil(&1))) + |> Enum.uniq() + + # Get transaction to_addresses from transactions with FHE operations + transaction_hashes = + fhe_operations + |> Enum.map(& &1.transaction_hash) + |> Enum.uniq() + + query = + from( + t in Transaction, + where: t.hash in ^transaction_hashes, + where: t.block_consensus == true, + where: not is_nil(t.to_address_hash), + select: t.to_address_hash, + distinct: true + ) + + to_addresses = Repo.all(query) + + # Combine and deduplicate + (caller_addresses ++ to_addresses) |> Enum.uniq() + end + + # Updates fhe_operations_count on transactions table for precomputed list API performance + defp update_transaction_fhe_counts(repo, fhe_operations) when is_list(fhe_operations) do + counts_by_hash = + fhe_operations + |> Enum.group_by(& &1.transaction_hash, & &1.log_index) + |> Enum.map(fn {hash, log_indices} -> {hash, length(log_indices)} end) + + if counts_by_hash != [] do + Enum.each(counts_by_hash, fn {transaction_hash, count} -> + repo.update_all( + from(t in Transaction, where: t.hash == ^transaction_hash), + set: [fhe_operations_count: count] + ) + end) + end + + :ok + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/helper.ex b/apps/explorer/lib/explorer/chain/import/runner/helper.ex index 6fd5f53e85e5..242408e5819a 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/helper.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Helper do @moduledoc """ Provides utility functions for the chain import runners. @@ -19,4 +20,21 @@ defmodule Explorer.Chain.Import.Runner.Helper do multi end end + + @doc """ + Executes the import function if the configured chain identity matches the + specified `chain_identity`. + """ + @spec chain_identity_dependent_import( + Ecto.Multi.t(), + chain_identity :: atom() | tuple(), + (Ecto.Multi.t() -> Ecto.Multi.t()) + ) :: Ecto.Multi.t() + def chain_identity_dependent_import(multi, chain_identity, multi_run) do + if Application.get_env(:explorer, :chain_identity) == chain_identity do + multi_run.(multi) + else + multi + end + end end diff --git a/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex index 771afc5ac26f..75a9a2c09f2c 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/internal_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.InternalTransactions do @moduledoc """ Bulk imports `t:Explorer.Chain.InternalTransactions.t/0`. @@ -11,21 +12,22 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.Chain.{ + Address, Block, - Hash, Import, InternalTransaction, - PendingBlockOperation, PendingOperationsHelper, PendingTransactionOperation, - Transaction + Transaction, + TransactionError } alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Import.Runner + alias Explorer.Migrator.DeleteZeroValueInternalTransactions alias Explorer.Prometheus.Instrumenter alias Explorer.Repo, as: ExplorerRepo - alias Explorer.Utility.MissingRangesManipulator + alias Explorer.Utility.{AddressIdToAddressHash, MissingBlockRange} import Ecto.Query @@ -45,8 +47,8 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do @impl Runner def imported_table_row do %{ - value_type: "[%{index: non_neg_integer(), transaction_hash: Explorer.Chain.Hash.t()}]", - value_description: "List of maps of the `t:Explorer.Chain.InternalTransaction.t/0` `index` and `transaction_hash`" + value_type: "[InternalTransaction.t()]", + value_description: "List of maps of the `t:Explorer.Chain.InternalTransaction.t/0`" } end @@ -78,7 +80,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do end) |> Multi.run(:acquire_pending_internal_transactions, fn repo, %{acquire_blocks: block_hashes} -> Instrumenter.block_import_stage_runner( - fn -> acquire_pending_internal_transactions(repo, block_hashes) end, + fn -> acquire_pending_internal_transactions(repo, block_hashes, changes_list) end, :block_pending, :internal_transactions, :acquire_pending_internal_transactions @@ -149,11 +151,11 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do |> Multi.run(:internal_transactions, fn repo, %{ maybe_shrink_internal_transactions_params: - shrink_internal_transactions_params + maybe_shrink_internal_transactions_params } -> Instrumenter.block_import_stage_runner( fn -> - insert(repo, shrink_internal_transactions_params, insert_options) + insert(repo, maybe_shrink_internal_transactions_params, insert_options) end, :block_pending, :internal_transactions, @@ -192,6 +194,24 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do :update_pending_blocks_status ) end) + |> Multi.run(:empty_selfdestructed_contracts_bytecode, fn repo, + %{ + valid_internal_transactions: valid_internal_transactions + } -> + Instrumenter.block_import_stage_runner( + fn -> empty_selfdestructed_contracts_bytecode(repo, valid_internal_transactions, timestamps) end, + :block_pending, + :internal_transactions, + :empty_selfdestructed_contracts_bytecode + ) + end) + end + + @impl Runner + def prepare_data(changes_list) do + changes_list + |> maybe_reject_zero_value() + |> adjust_insert_params() end def run_insert_only(changes_list, %{timestamps: timestamps} = options) when is_map(options) do @@ -208,8 +228,11 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do # Enforce ShareLocks tables order (see docs: sharelocks.md) with {:ok, data} <- Multi.new() - |> Multi.run(:internal_transactions, fn repo, _ -> - insert(repo, internal_transactions_params, insert_options) + |> Multi.run(:prepare_data, fn _, _ -> + {:ok, prepare_data(internal_transactions_params)} + end) + |> Multi.run(:internal_transactions, fn repo, %{prepare_data: prepared_data} -> + insert(repo, prepared_data, insert_options) end) |> ExplorerRepo.transaction() do Publisher.broadcast(data, :on_demand) @@ -224,19 +247,24 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do required(:timeout) => timeout, required(:timestamps) => Import.timestamps() }) :: - {:ok, [%{index: non_neg_integer, transaction_hash: Hash.t()}]} + {:ok, [InternalTransaction.t()]} | {:error, [Changeset.t()]} defp insert(repo, valid_internal_transactions, %{timeout: timeout, timestamps: timestamps} = options) when is_list(valid_internal_transactions) do on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - ordered_changes_list = Enum.sort_by(valid_internal_transactions, &{&1.transaction_hash, &1.index}) + ordered_changes_list = + valid_internal_transactions + |> Enum.map(fn internal_transaction -> + Map.put(internal_transaction, :trace_address, nil) + end) + |> Enum.sort_by(&{&1.transaction_index, &1.index}) {:ok, internal_transactions} = Import.insert_changes_list( repo, ordered_changes_list, - conflict_target: [:block_hash, :block_index], + conflict_target: [:block_number, :transaction_index, :index], for: InternalTransaction, on_conflict: on_conflict, returning: true, @@ -252,49 +280,43 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do internal_transaction in InternalTransaction, update: [ set: [ - block_number: fragment("EXCLUDED.block_number"), call_type: fragment("EXCLUDED.call_type"), - created_contract_address_hash: fragment("EXCLUDED.created_contract_address_hash"), + call_type_enum: fragment("EXCLUDED.call_type_enum"), + created_contract_address_id: fragment("EXCLUDED.created_contract_address_id"), created_contract_code: fragment("EXCLUDED.created_contract_code"), - error: fragment("EXCLUDED.error"), - from_address_hash: fragment("EXCLUDED.from_address_hash"), + error_id: fragment("EXCLUDED.error_id"), + from_address_id: fragment("EXCLUDED.from_address_id"), gas: fragment("EXCLUDED.gas"), gas_used: fragment("EXCLUDED.gas_used"), - index: fragment("EXCLUDED.index"), init: fragment("EXCLUDED.init"), input: fragment("EXCLUDED.input"), output: fragment("EXCLUDED.output"), - to_address_hash: fragment("EXCLUDED.to_address_hash"), - trace_address: fragment("EXCLUDED.trace_address"), - transaction_hash: fragment("EXCLUDED.transaction_hash"), - transaction_index: fragment("EXCLUDED.transaction_index"), + to_address_id: fragment("EXCLUDED.to_address_id"), type: fragment("EXCLUDED.type"), value: fragment("EXCLUDED.value"), inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", internal_transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", internal_transaction.updated_at) - # Don't update `block_hash` as it is used for the conflict target - # Don't update `block_index` as it is used for the conflict target + # Don't update `block_number` as it is used for the conflict target + # Don't update `transaction_index` as it is used for the conflict target + # Don't update `index` as it is used for the conflict target ] ], # `IS DISTINCT FROM` is used because it allows `NULL` to be equal to itself where: fragment( - "(EXCLUDED.transaction_hash, EXCLUDED.index, EXCLUDED.call_type, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code, EXCLUDED.error, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_used, EXCLUDED.init, EXCLUDED.input, EXCLUDED.output, EXCLUDED.to_address_hash, EXCLUDED.trace_address, EXCLUDED.transaction_index, EXCLUDED.type, EXCLUDED.value) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - internal_transaction.transaction_hash, - internal_transaction.index, + "(EXCLUDED.call_type, EXCLUDED.call_type_enum, EXCLUDED.created_contract_address_id, EXCLUDED.created_contract_code, EXCLUDED.error_id, EXCLUDED.from_address_id, EXCLUDED.gas, EXCLUDED.gas_used, EXCLUDED.init, EXCLUDED.input, EXCLUDED.output, EXCLUDED.to_address_id, EXCLUDED.type, EXCLUDED.value) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", internal_transaction.call_type, - internal_transaction.created_contract_address_hash, + internal_transaction.call_type_enum, + internal_transaction.created_contract_address_id, internal_transaction.created_contract_code, - internal_transaction.error, - internal_transaction.from_address_hash, + internal_transaction.error_id, + internal_transaction.from_address_id, internal_transaction.gas, internal_transaction.gas_used, internal_transaction.init, internal_transaction.input, internal_transaction.output, - internal_transaction.to_address_hash, - internal_transaction.trace_address, - internal_transaction.transaction_index, + internal_transaction.to_address_id, internal_transaction.type, internal_transaction.value ) @@ -320,27 +342,32 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do {:ok, repo.all(query)} end - defp acquire_pending_internal_transactions(repo, block_hashes) do + defp acquire_pending_internal_transactions(repo, block_hashes, changes_list) do case PendingOperationsHelper.pending_operations_type() do "blocks" -> query = - from( - pending_ops in PendingBlockOperation, - where: pending_ops.block_hash in ^block_hashes, - select: pending_ops.block_hash, - # Enforce PendingBlockOperation ShareLocks order (see docs: sharelocks.md) - order_by: [asc: pending_ops.block_hash], - lock: "FOR UPDATE" - ) + block_hashes + |> PendingOperationsHelper.block_hash_in_query() + |> select([pbo], pbo.block_hash) + |> order_by([pbo], asc: pbo.block_hash) + |> lock("FOR UPDATE") {:ok, {:block_hashes, repo.all(query)}} "transactions" -> + transaction_hashes = + changes_list + |> Enum.reject(&is_nil(Map.get(&1, :transaction_index))) + |> Enum.map(&{&1.block_number, &1.transaction_index}) + |> Enum.uniq() + |> Transaction.by_block_number_index_query() + |> repo.all() + |> Enum.map(& &1.hash) + query = from( pending_ops in PendingTransactionOperation, - join: transaction in assoc(pending_ops, :transaction), - where: transaction.block_hash in ^block_hashes, + where: pending_ops.transaction_hash in ^transaction_hashes, select: pending_ops.transaction_hash, # Enforce PendingTransactionOperation ShareLocks order (see docs: sharelocks.md) order_by: [asc: pending_ops.transaction_hash], @@ -362,7 +389,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do from( t in Transaction, where: ^dynamic_condition, - select: map(t, [:hash, :block_hash, :block_number, :cumulative_gas_used, :status]), + select: map(t, [:hash, :block_hash, :block_number, :cumulative_gas_used, :status, :index]), # Enforce Transaction ShareLocks order (see docs: sharelocks.md) order_by: [asc: t.hash], lock: "FOR NO KEY UPDATE" @@ -375,7 +402,6 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do # Finds all mismatches between transactions and internal transactions # for a block number: # - there are no internal transactions for some transactions - # - there are internal transactions with a different block number than their transactions # Returns block numbers where any of these issues is found # Note: the case "# - there are no transactions for some internal transactions" was removed because it caused the issue https://github.com/blockscout/blockscout/issues/3367 @@ -387,23 +413,17 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do # the case "# - there are no internal transactions for some transactions" is removed since # there are may be non-traceable transactions - transactions_tuples = MapSet.new(transactions, &{&1.hash, &1.block_number}) - - internal_transactions_tuples = MapSet.new(internal_transactions_params, &{&1.transaction_hash, &1.block_number}) - - all_tuples = MapSet.union(transactions_tuples, internal_transactions_tuples) - invalid_block_numbers = if allow_non_traceable_transactions?() do - Enum.reduce(internal_transactions_tuples, [], fn {transaction_hash, block_number}, acc -> - # credo:disable-for-next-line - case Enum.find(transactions_tuples, fn {t_hash, _block_number} -> t_hash == transaction_hash end) do - nil -> acc - {_t_hash, ^block_number} -> acc - _ -> [block_number | acc] - end - end) + [] else + transactions_tuples = MapSet.new(transactions, &{&1.index, &1.block_number}) + + internal_transactions_tuples = + MapSet.new(internal_transactions_params, &{&1.transaction_index, &1.block_number}) + + all_tuples = MapSet.union(transactions_tuples, internal_transactions_tuples) + all_tuples |> MapSet.difference(internal_transactions_tuples) |> MapSet.new(fn {_hash, block_number} -> block_number end) @@ -440,32 +460,67 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do defp compose_entry_wrapper(item, blocks_map) do case item do {block_number, entries} -> - compose_entry(entries, blocks_map, block_number) + compose_entry(entries, block_number, blocks_map) _ -> [] end end - defp compose_entry(entries, blocks_map, block_number) do + defp compose_entry(entries, block_number, blocks_map) do if Map.has_key?(blocks_map, block_number) do - block_hash = Map.fetch!(blocks_map, block_number) - entries - |> Enum.sort_by( - &{(Map.has_key?(&1, :transaction_index) && &1.transaction_index) || &1.transaction_hash, &1.index} - ) - |> Enum.with_index() - |> Enum.map(fn {entry, index} -> - entry - |> Map.put(:block_hash, block_hash) - |> Map.put(:block_index, index) - end) else [] end end + defp adjust_insert_params(internal_transactions_params) do + error_to_error_id_map = + internal_transactions_params + |> Enum.map(&sanitize_error/1) + |> Enum.map(&Map.get(&1, :error)) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> TransactionError.find_or_create_multiple() + + address_hash_to_address_id_map = + internal_transactions_params + |> Enum.flat_map(fn params -> + params + |> Map.take([:from_address_hash, :to_address_hash, :created_contract_address_hash]) + |> Map.values() + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> AddressIdToAddressHash.find_or_create_multiple() + + Enum.map(internal_transactions_params, fn params -> + params + |> sanitize_error() + |> put_error_id(error_to_error_id_map) + |> put_address_ids(address_hash_to_address_id_map) + |> shift_created_contract_address_id() + end) + end + + defp put_error_id(entry, error_to_error_id_map) do + entry + |> Map.delete(:error) + |> Map.put(:error_id, Map.get(entry, :error_id) || error_to_error_id_map[Map.get(entry, :error)]) + end + + defp put_address_ids(entry, address_hash_to_address_id_map) do + entry + |> Map.drop([:from_address_hash, :to_address_hash, :created_contract_address_hash]) + |> Map.merge(%{ + from_address_id: address_hash_to_address_id_map[String.downcase(to_string(entry[:from_address_hash]))], + to_address_id: address_hash_to_address_id_map[String.downcase(to_string(entry[:to_address_hash]))], + created_contract_address_id: + address_hash_to_address_id_map[String.downcase(to_string(entry[:created_contract_address_hash]))] + }) + end + defp valid_internal_transactions_without_first_trace(valid_internal_transactions) do json_rpc_named_arguments = Application.fetch_env!(:indexer, :json_rpc_named_arguments) variant = Keyword.fetch!(json_rpc_named_arguments, :variant) @@ -499,6 +554,58 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do end end + defp maybe_reject_zero_value(internal_transactions) do + with true <- Application.get_env(:explorer, DeleteZeroValueInternalTransactions)[:enabled], + border_number when is_integer(border_number) <- DeleteZeroValueInternalTransactions.border_number() do + Enum.reject(internal_transactions, fn + %{type: type, block_number: block_number} = internal_transaction -> + # credo:disable-for-lines:2 Credo.Check.Refactor.Nesting + value = + case Map.get(internal_transaction, :value) do + %{value: decimal_value} -> decimal_value + integer_value when is_integer(integer_value) -> integer_value + nil -> 0 + end + + block_number <= border_number and type in [:call, "call"] and Decimal.eq?(value, 0) + + _ -> + false + end) + else + _ -> internal_transactions + end + end + + defp sanitize_error(entry) do + error = Map.get(entry, :error) + + sanitized_error = + if is_binary(error) and not String.printable?(error) do + error + |> inspect(binaries: :as_strings) + |> String.trim("\"") + else + error + end + + Map.put(entry, :error, sanitized_error) + end + + # Shifts the `created_contract_address_id` value to `to_address_id` when applicable. + + # This function handles the migration of contract creation data by copying the + # `created_contract_address_id` to `to_address_id` field when: + # - `created_contract_address_id` is present (not nil) + # - `to_address_id` is nil + @spec shift_created_contract_address_id(map()) :: map() + defp shift_created_contract_address_id(entry) do + case {Map.get(entry, :created_contract_address_id), Map.get(entry, :to_address_id)} do + {id, nil} when not is_nil(id) -> Map.put(entry, :to_address_id, id) + _ -> entry + end + end + def defer_internal_transactions_primary_key(repo) do # Allows internal_transactions primary key to not be checked during the # DB transactions and instead be checked only at the end of it. @@ -516,6 +623,8 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do if valid_internal_transactions_count == 0 do {:ok, nil} else + block_number_index_to_hash_map = Map.new(transactions, &{{&1.block_number, &1.index}, &1.hash}) + params = valid_internal_transactions |> Enum.filter(fn internal_transaction -> @@ -523,24 +632,32 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do end) |> Enum.map(fn trace -> %{ - block_hash: Map.get(trace, :block_hash), block_number: Map.get(trace, :block_number), gas_used: Map.get(trace, :gas_used), - transaction_hash: Map.get(trace, :transaction_hash), - created_contract_address_hash: Map.get(trace, :created_contract_address_hash), - error: Map.get(trace, :error), - status: if(is_nil(Map.get(trace, :error)), do: :ok, else: :error) + transaction_hash: + Map.fetch!(block_number_index_to_hash_map, {trace[:block_number], trace[:transaction_index]}), + created_contract_address_hash: + AddressIdToAddressHash.id_to_hash(Map.get(trace, :created_contract_address_id)), + error: TransactionError.id_to_error(Map.get(trace, :error_id)), + status: if(is_nil(Map.get(trace, :error_id)), do: :ok, else: :error) } end) - |> Enum.filter(fn transaction_hash -> transaction_hash != nil end) transaction_hashes = valid_internal_transactions - |> MapSet.new(& &1.transaction_hash) + |> MapSet.new(&{&1.block_number, &1.transaction_index}) |> MapSet.to_list() + |> then(fn block_numbers_indexes -> Map.take(block_number_index_to_hash_map, block_numbers_indexes) end) + |> Map.values() json_rpc_named_arguments = Application.fetch_env!(:indexer, :json_rpc_named_arguments) + valid_internal_transactions_with_hashes = + Enum.map(valid_internal_transactions, fn it -> + transaction_hash = Map.fetch!(block_number_index_to_hash_map, {it[:block_number], it[:transaction_index]}) + Map.put(it, :transaction_hash, transaction_hash) + end) + result = Enum.reduce_while(params, 0, fn first_trace, transaction_hashes_iterator -> transaction_hash = Map.get(first_trace, :transaction_hash) @@ -550,7 +667,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do update_transactions_inner_wrapper( transaction_from_db, repo, - valid_internal_transactions, + valid_internal_transactions_with_hashes, transaction_hash, json_rpc_named_arguments, transaction_hashes, @@ -639,7 +756,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do defp get_trivial_transaction_hashes_with_error_in_internal_transaction(internal_transactions) do internal_transactions |> Enum.filter(fn internal_transaction -> - internal_transaction[:index] != 0 && !is_nil(internal_transaction[:error]) + internal_transaction[:index] != 0 && !is_nil(internal_transaction[:error_id]) end) |> Enum.map(fn internal_transaction -> internal_transaction[:transaction_hash] end) |> MapSet.new() @@ -740,7 +857,6 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do default_set |> put_status_in_update_set(first_trace, transaction_from_db) |> put_error_in_update_set(first_trace, transaction_from_db, transaction_receipt_from_node) - |> Keyword.put_new(:block_hash, first_trace.block_hash) |> Keyword.put_new(:block_number, first_trace.block_number) |> Keyword.put_new(:index, transaction_receipt_from_node && transaction_receipt_from_node.transaction_index) |> Keyword.put_new( @@ -801,7 +917,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do try do {_num, result} = repo.update_all(update_block_query, []) - MissingRangesManipulator.add_ranges_by_block_numbers(invalid_block_numbers) + MissingBlockRange.add_ranges_by_block_numbers(invalid_block_numbers) Logger.debug(fn -> [ @@ -829,10 +945,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do |> MapSet.difference(MapSet.new(invalid_block_hashes)) |> MapSet.to_list() - from( - pending_ops in PendingBlockOperation, - where: pending_ops.block_hash in ^valid_block_hashes - ) + PendingOperationsHelper.block_hash_in_query(valid_block_hashes) {:transaction_hashes, transaction_hashes} -> from( @@ -852,13 +965,67 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactions do end end + defp empty_selfdestructed_contracts_bytecode(repo, valid_internal_transactions, timestamps) do + # Find all selfdestruct internal transactions + selfdestruct_addresses = + valid_internal_transactions + |> Enum.filter(&(&1.type == :selfdestruct)) + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.from_address_id}) + |> MapSet.new() + + # Find all create/create2 internal transactions in the same transactions + created_addresses = + valid_internal_transactions + |> Enum.filter(&(&1.type in [:create, :create2])) + |> Enum.map(&{&1.block_number, &1.transaction_index, Map.get(&1, :created_contract_address_id)}) + |> Enum.reject(fn {_block_number, _tx_index, address_id} -> is_nil(address_id) end) + |> MapSet.new() + + # Filter to find addresses that were selfdestructed but NOT created in the same transaction + addresses_to_empty = + selfdestruct_addresses + |> Enum.reject(fn {block_number, tx_index, address_id} -> + MapSet.member?(created_addresses, {block_number, tx_index, address_id}) + end) + |> Enum.map(fn {_block_number, _tx_index, address_id} -> address_id end) + |> Enum.uniq() + |> AddressIdToAddressHash.ids_to_hashes() + + if Enum.empty?(addresses_to_empty) do + {:ok, []} + else + # Update the addresses to have empty contract_code + empty_contract_code = %Explorer.Chain.Data{bytes: <<>>} + + update_query = + from( + address in Address, + where: address.hash in ^addresses_to_empty, + update: [set: [contract_code: ^empty_contract_code, updated_at: ^timestamps.updated_at]] + ) + + {count, _} = repo.update_all(update_query, []) + + Logger.info( + "Emptied contract_code for #{count} selfdestructed contracts: #{inspect(addresses_to_empty, limit: :infinity)}" + ) + + {:ok, count} + end + end + defp traceable_blocks_dynamic_query do if RangesHelper.trace_ranges_present?() do block_ranges = RangesHelper.get_trace_block_ranges() Enum.reduce(block_ranges, dynamic([_], false), fn - _from.._to//_ = range, acc -> dynamic([block], ^acc or block.number in ^range) - num_to_latest, acc -> dynamic([block], ^acc or block.number >= ^num_to_latest) + _from.._to//_ = range, acc -> + lower = min(range.first, range.last) + upper = max(range.first, range.last) + dynamic([block], ^acc or (block.number >= ^lower and block.number <= ^upper)) + + num_to_latest, acc -> + dynamic([block], ^acc or block.number >= ^num_to_latest) end) else dynamic([_], true) diff --git a/apps/explorer/lib/explorer/chain/import/runner/logs.ex b/apps/explorer/lib/explorer/chain/import/runner/logs.ex index cb8466f2b2ac..f41af369600e 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/logs.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/logs.ex @@ -1,8 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Logs do @moduledoc """ Bulk imports `t:Explorer.Chain.Log.t/0`. """ + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + require Ecto.Query alias Ecto.{Changeset, Multi, Repo} @@ -65,16 +69,19 @@ defmodule Explorer.Chain.Import.Runner.Logs do on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) # Enforce Log ShareLocks order (see docs: sharelocks.md) - ordered_changes_list = - case Application.get_env(:explorer, :chain_type) do - :celo -> Enum.sort_by(changes_list, &{&1.block_hash, &1.index}) - _ -> Enum.sort_by(changes_list, &{&1.transaction_hash, &1.block_hash, &1.index}) - end - - conflict_target = - case Application.get_env(:explorer, :chain_type) do - :celo -> [:index, :block_hash] - _ -> [:transaction_hash, :index, :block_hash] + {ordered_changes_list, conflict_target} = + case chain_identity() do + {:optimism, :celo} -> + { + Enum.sort_by(changes_list, &{&1.block_hash, &1.index}), + [:index, :block_hash] + } + + _ -> + { + Enum.sort_by(changes_list, &{&1.transaction_hash, &1.block_hash, &1.index}), + [:transaction_hash, :index, :block_hash] + } end {:ok, _} = @@ -91,8 +98,8 @@ defmodule Explorer.Chain.Import.Runner.Logs do end defp default_on_conflict do - case Application.get_env(:explorer, :chain_type) do - :celo -> + case chain_identity() do + {:optimism, :celo} -> from( log in Log, update: [ diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/deposits.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/deposits.ex index 0422a625f35a..29abd107d73d 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/deposits.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/deposits.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.Deposits do @moduledoc """ Bulk imports `t:Explorer.Chain.Deposit.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/dispute_games.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/dispute_games.ex index 9b84c6af6607..d6b68f3ec559 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/dispute_games.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/dispute_games.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.DisputeGames do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.DisputeGame.t/0`. @@ -86,7 +87,7 @@ defmodule Explorer.Chain.Import.Runner.Optimism.DisputeGames do set: [ # don't update `index` as it is a primary key and used for the conflict target game_type: fragment("EXCLUDED.game_type"), - address: fragment("EXCLUDED.address"), + address_hash: fragment("EXCLUDED.address_hash"), extra_data: fragment("EXCLUDED.extra_data"), created_at: fragment("EXCLUDED.created_at"), resolved_at: fragment("EXCLUDED.resolved_at"), @@ -97,9 +98,9 @@ defmodule Explorer.Chain.Import.Runner.Optimism.DisputeGames do ], where: fragment( - "(EXCLUDED.game_type, EXCLUDED.address, EXCLUDED.extra_data, EXCLUDED.created_at, EXCLUDED.resolved_at, EXCLUDED.status) IS DISTINCT FROM (?, ?, ?, ?, ?, ?)", + "(EXCLUDED.game_type, EXCLUDED.address_hash, EXCLUDED.extra_data, EXCLUDED.created_at, EXCLUDED.resolved_at, EXCLUDED.status) IS DISTINCT FROM (?, ?, ?, ?, ?, ?)", game.game_type, - game.address, + game.address_hash, game.extra_data, game.created_at, game.resolved_at, diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/eip1559_config_updates.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/eip1559_config_updates.ex index 65ad40ee00dd..41806657b7d4 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/eip1559_config_updates.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/eip1559_config_updates.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.EIP1559ConfigUpdates do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.EIP1559ConfigUpdate.t/0`. @@ -88,16 +89,18 @@ defmodule Explorer.Chain.Import.Runner.Optimism.EIP1559ConfigUpdates do l2_block_hash: fragment("EXCLUDED.l2_block_hash"), base_fee_max_change_denominator: fragment("EXCLUDED.base_fee_max_change_denominator"), elasticity_multiplier: fragment("EXCLUDED.elasticity_multiplier"), + min_base_fee: fragment("EXCLUDED.min_base_fee"), inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", update.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", update.updated_at) ] ], where: fragment( - "(EXCLUDED.l2_block_hash, EXCLUDED.base_fee_max_change_denominator, EXCLUDED.elasticity_multiplier) IS DISTINCT FROM (?, ?, ?)", + "(EXCLUDED.l2_block_hash, EXCLUDED.base_fee_max_change_denominator, EXCLUDED.elasticity_multiplier, EXCLUDED.min_base_fee) IS DISTINCT FROM (?, ?, ?, ?)", update.l2_block_hash, update.base_fee_max_change_denominator, - update.elasticity_multiplier + update.elasticity_multiplier, + update.min_base_fee ) ) end diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequence_blobs.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequence_blobs.ex index 56a2d6dcb13d..4aaeb132abef 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequence_blobs.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequence_blobs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.FrameSequenceBlobs do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.FrameSequenceBlob.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequences.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequences.ex index bf45f5354d71..4d3112654408 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequences.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/frame_sequences.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.FrameSequences do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.FrameSequence.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/interop_messages.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/interop_messages.ex index 165ebf9ece04..7074e4b5d62f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/interop_messages.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/interop_messages.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.InteropMessages do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.InteropMessage.t/0`. @@ -96,13 +97,21 @@ defmodule Explorer.Chain.Import.Runner.Optimism.InteropMessages do fragment("COALESCE(EXCLUDED.relay_transaction_hash, ?)", message.relay_transaction_hash), payload: fragment("COALESCE(EXCLUDED.payload, ?)", message.payload), failed: fragment("COALESCE(EXCLUDED.failed, ?)", message.failed), + transfer_token_address_hash: + fragment("COALESCE(EXCLUDED.transfer_token_address_hash, ?)", message.transfer_token_address_hash), + transfer_from_address_hash: + fragment("COALESCE(EXCLUDED.transfer_from_address_hash, ?)", message.transfer_from_address_hash), + transfer_to_address_hash: + fragment("COALESCE(EXCLUDED.transfer_to_address_hash, ?)", message.transfer_to_address_hash), + transfer_amount: fragment("COALESCE(EXCLUDED.transfer_amount, ?)", message.transfer_amount), + sent_to_multichain: fragment("COALESCE(EXCLUDED.sent_to_multichain, ?)", message.sent_to_multichain), inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", message.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", message.updated_at) ] ], where: fragment( - "(EXCLUDED.sender_address_hash, EXCLUDED.target_address_hash, EXCLUDED.init_transaction_hash, EXCLUDED.block_number, EXCLUDED.timestamp, EXCLUDED.relay_chain_id, EXCLUDED.relay_transaction_hash, EXCLUDED.payload, EXCLUDED.failed) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.sender_address_hash, EXCLUDED.target_address_hash, EXCLUDED.init_transaction_hash, EXCLUDED.block_number, EXCLUDED.timestamp, EXCLUDED.relay_chain_id, EXCLUDED.relay_transaction_hash, EXCLUDED.payload, EXCLUDED.failed, EXCLUDED.transfer_token_address_hash, EXCLUDED.transfer_from_address_hash, EXCLUDED.transfer_to_address_hash, EXCLUDED.transfer_amount) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", message.sender_address_hash, message.target_address_hash, message.init_transaction_hash, @@ -111,7 +120,11 @@ defmodule Explorer.Chain.Import.Runner.Optimism.InteropMessages do message.relay_chain_id, message.relay_transaction_hash, message.payload, - message.failed + message.failed, + message.transfer_token_address_hash, + message.transfer_from_address_hash, + message.transfer_to_address_hash, + message.transfer_amount ) ) end diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/output_roots.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/output_roots.ex index d0027bc483f6..bdd45a048ce7 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/output_roots.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/output_roots.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.OutputRoots do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.OutputRoot.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/transaction_batches.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/transaction_batches.ex index ae846119fd34..aca25624738f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/transaction_batches.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/transaction_batches.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.TransactionBatches do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.TransactionBatch.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawal_events.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawal_events.ex index 1816db039549..1347344003ae 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawal_events.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawal_events.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.WithdrawalEvents do @moduledoc """ Bulk imports `t:Explorer.Chain.Optimism.WithdrawalEvent.t/0`. @@ -90,16 +91,18 @@ defmodule Explorer.Chain.Import.Runner.Optimism.WithdrawalEvents do l1_timestamp: fragment("EXCLUDED.l1_timestamp"), l1_block_number: fragment("EXCLUDED.l1_block_number"), game_index: fragment("EXCLUDED.game_index"), + game_address_hash: fragment("EXCLUDED.game_address_hash"), inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", we.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", we.updated_at) ] ], where: fragment( - "(EXCLUDED.l1_timestamp, EXCLUDED.l1_block_number, EXCLUDED.game_index) IS DISTINCT FROM (?, ?, ?)", + "(EXCLUDED.l1_timestamp, EXCLUDED.l1_block_number, EXCLUDED.game_index, EXCLUDED.game_address_hash) IS DISTINCT FROM (?, ?, ?, ?)", we.l1_timestamp, we.l1_block_number, - we.game_index + we.game_index, + we.game_address_hash ) ) end diff --git a/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawals.ex b/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawals.ex index 450c97b07017..193e642207fc 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawals.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/optimism/withdrawals.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Optimism.Withdrawals do @moduledoc """ Bulk imports `t:Explorer.Chain.OptimismWithdrawal.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/deposit_executes.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/deposit_executes.ex deleted file mode 100644 index bc20a12567c2..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/deposit_executes.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonEdge.DepositExecutes do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonEdge.DepositExecute.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonEdge.DepositExecute - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [DepositExecute.t()] - - @impl Import.Runner - def ecto_schema_module, do: DepositExecute - - @impl Import.Runner - def option_key, do: :polygon_edge_deposit_executes - - @impl Import.Runner - @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - @spec run(Multi.t(), list(), map()) :: Multi.t() - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_edge_deposit_executes, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_edge_deposit_executes, - :polygon_edge_deposit_executes - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [DepositExecute.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonEdge.DepositExecute ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.msg_id) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: :msg_id, - on_conflict: on_conflict, - for: DepositExecute, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - de in DepositExecute, - update: [ - set: [ - # Don't update `msg_id` as it is a primary key and used for the conflict target - l2_transaction_hash: fragment("EXCLUDED.l2_transaction_hash"), - l2_block_number: fragment("EXCLUDED.l2_block_number"), - success: fragment("EXCLUDED.success"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", de.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", de.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.l2_transaction_hash, EXCLUDED.l2_block_number, EXCLUDED.success) IS DISTINCT FROM (?, ?, ?)", - de.l2_transaction_hash, - de.l2_block_number, - de.success - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/deposits.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/deposits.ex deleted file mode 100644 index b4893f86b67d..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/deposits.ex +++ /dev/null @@ -1,110 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonEdge.Deposits do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonEdge.Deposit.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonEdge.Deposit - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [Deposit.t()] - - @impl Import.Runner - def ecto_schema_module, do: Deposit - - @impl Import.Runner - def option_key, do: :polygon_edge_deposits - - @impl Import.Runner - @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - @spec run(Multi.t(), list(), map()) :: Multi.t() - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_edge_deposits, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_edge_deposits, - :polygon_edge_deposits - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [Deposit.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonEdge.Deposit ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.msg_id) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: :msg_id, - on_conflict: on_conflict, - for: Deposit, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - d in Deposit, - update: [ - set: [ - # Don't update `msg_id` as it is a primary key and used for the conflict target - from: fragment("EXCLUDED.from"), - to: fragment("EXCLUDED.to"), - l1_transaction_hash: fragment("EXCLUDED.l1_transaction_hash"), - l1_timestamp: fragment("EXCLUDED.l1_timestamp"), - l1_block_number: fragment("EXCLUDED.l1_block_number"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", d.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", d.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.from, EXCLUDED.to, EXCLUDED.l1_transaction_hash, EXCLUDED.l1_timestamp, EXCLUDED.l1_block_number) IS DISTINCT FROM (?, ?, ?, ?, ?)", - d.from, - d.to, - d.l1_transaction_hash, - d.l1_timestamp, - d.l1_block_number - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/withdrawal_exits.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/withdrawal_exits.ex deleted file mode 100644 index 16c13eadbf1f..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/withdrawal_exits.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonEdge.WithdrawalExits do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonEdge.WithdrawalExit.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonEdge.WithdrawalExit - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [WithdrawalExit.t()] - - @impl Import.Runner - def ecto_schema_module, do: WithdrawalExit - - @impl Import.Runner - def option_key, do: :polygon_edge_withdrawal_exits - - @impl Import.Runner - @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - @spec run(Multi.t(), list(), map()) :: Multi.t() - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_edge_withdrawal_exits, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_edge_withdrawal_exits, - :polygon_edge_withdrawal_exits - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [WithdrawalExit.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonEdge.WithdrawalExit ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.msg_id) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: :msg_id, - on_conflict: on_conflict, - for: WithdrawalExit, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - we in WithdrawalExit, - update: [ - set: [ - # Don't update `msg_id` as it is a primary key and used for the conflict target - l1_transaction_hash: fragment("EXCLUDED.l1_transaction_hash"), - l1_block_number: fragment("EXCLUDED.l1_block_number"), - success: fragment("EXCLUDED.success"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", we.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", we.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.l1_transaction_hash, EXCLUDED.l1_block_number, EXCLUDED.success) IS DISTINCT FROM (?, ?, ?)", - we.l1_transaction_hash, - we.l1_block_number, - we.success - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/withdrawals.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/withdrawals.ex deleted file mode 100644 index e728e2cba495..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_edge/withdrawals.ex +++ /dev/null @@ -1,108 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonEdge.Withdrawals do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonEdge.Withdrawal.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonEdge.Withdrawal - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [Withdrawal.t()] - - @impl Import.Runner - def ecto_schema_module, do: Withdrawal - - @impl Import.Runner - def option_key, do: :polygon_edge_withdrawals - - @impl Import.Runner - @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - @spec run(Multi.t(), list(), map()) :: Multi.t() - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_edge_withdrawals, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_edge_withdrawals, - :polygon_edge_withdrawals - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [Withdrawal.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonEdge.Withdrawal ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.msg_id) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: :msg_id, - on_conflict: on_conflict, - for: Withdrawal, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - w in Withdrawal, - update: [ - set: [ - # Don't update `msg_id` as it is a primary key and used for the conflict target - from: fragment("EXCLUDED.from"), - to: fragment("EXCLUDED.to"), - l2_transaction_hash: fragment("EXCLUDED.l2_transaction_hash"), - l2_block_number: fragment("EXCLUDED.l2_block_number"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", w.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", w.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.from, EXCLUDED.to, EXCLUDED.l2_transaction_hash, EXCLUDED.l2_block_number) IS DISTINCT FROM (?, ?, ?, ?)", - w.from, - w.to, - w.l2_transaction_hash, - w.l2_block_number - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/bridge_l1_tokens.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/bridge_l1_tokens.ex deleted file mode 100644 index 03ed1bd5783c..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/bridge_l1_tokens.ex +++ /dev/null @@ -1,101 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonZkevm.BridgeL1Tokens do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonZkevm.BridgeL1Token.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonZkevm.BridgeL1Token - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [BridgeL1Token.t()] - - @impl Import.Runner - def ecto_schema_module, do: BridgeL1Token - - @impl Import.Runner - def option_key, do: :polygon_zkevm_bridge_l1_tokens - - @impl Import.Runner - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_zkevm_bridge_l1_tokens, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_zkevm_bridge_l1_tokens, - :polygon_zkevm_bridge_l1_tokens - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [BridgeL1Token.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce BridgeL1Token ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, &{&1.address}) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: :address, - on_conflict: on_conflict, - for: BridgeL1Token, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - t in BridgeL1Token, - update: [ - set: [ - decimals: fragment("EXCLUDED.decimals"), - symbol: fragment("EXCLUDED.symbol"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", t.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", t.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.decimals, EXCLUDED.symbol) IS DISTINCT FROM (?, ?)", - t.decimals, - t.symbol - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/bridge_operations.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/bridge_operations.ex deleted file mode 100644 index 6cd724fe70cb..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/bridge_operations.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonZkevm.BridgeOperations do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonZkevm.Bridge.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonZkevm.Bridge, as: PolygonZkevmBridge - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [PolygonZkevmBridge.t()] - - @impl Import.Runner - def ecto_schema_module, do: PolygonZkevmBridge - - @impl Import.Runner - def option_key, do: :polygon_zkevm_bridge_operations - - @impl Import.Runner - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_zkevm_bridge_operations, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_zkevm_bridge_operations, - :polygon_zkevm_bridge_operations - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [PolygonZkevmBridge.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonZkevmBridge ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, &{&1.type, &1.index}) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: [:type, :index], - on_conflict: on_conflict, - for: PolygonZkevmBridge, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - op in PolygonZkevmBridge, - update: [ - set: [ - # Don't update `type` as it is part of the composite primary key and used for the conflict target - # Don't update `index` as it is part of the composite primary key and used for the conflict target - l1_transaction_hash: fragment("COALESCE(EXCLUDED.l1_transaction_hash, ?)", op.l1_transaction_hash), - l2_transaction_hash: fragment("COALESCE(EXCLUDED.l2_transaction_hash, ?)", op.l2_transaction_hash), - l1_token_id: fragment("COALESCE(EXCLUDED.l1_token_id, ?)", op.l1_token_id), - l1_token_address: fragment("COALESCE(EXCLUDED.l1_token_address, ?)", op.l1_token_address), - l2_token_address: fragment("COALESCE(EXCLUDED.l2_token_address, ?)", op.l2_token_address), - amount: fragment("EXCLUDED.amount"), - block_number: fragment("COALESCE(EXCLUDED.block_number, ?)", op.block_number), - block_timestamp: fragment("COALESCE(EXCLUDED.block_timestamp, ?)", op.block_timestamp), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", op.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", op.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.l1_transaction_hash, EXCLUDED.l2_transaction_hash, EXCLUDED.l1_token_id, EXCLUDED.l1_token_address, EXCLUDED.l2_token_address, EXCLUDED.amount, EXCLUDED.block_number, EXCLUDED.block_timestamp) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?)", - op.l1_transaction_hash, - op.l2_transaction_hash, - op.l1_token_id, - op.l1_token_address, - op.l2_token_address, - op.amount, - op.block_number, - op.block_timestamp - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/lifecycle_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/lifecycle_transactions.ex deleted file mode 100644 index a7260a787c01..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/lifecycle_transactions.ex +++ /dev/null @@ -1,103 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonZkevm.LifecycleTransactions do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonZkevm.LifecycleTransaction.t/0`. - """ - - require Ecto.Query - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonZkevm.LifecycleTransaction - alias Explorer.Prometheus.Instrumenter - - import Ecto.Query, only: [from: 2] - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [LifecycleTransaction.t()] - - @impl Import.Runner - def ecto_schema_module, do: LifecycleTransaction - - @impl Import.Runner - def option_key, do: :polygon_zkevm_lifecycle_transactions - - @impl Import.Runner - @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - @spec run(Multi.t(), list(), map()) :: Multi.t() - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_zkevm_lifecycle_transactions, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_zkevm_lifecycle_transactions, - :polygon_zkevm_lifecycle_transactions - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [LifecycleTransaction.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonZkevm.LifecycleTransaction ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.id) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - for: LifecycleTransaction, - returning: true, - timeout: timeout, - timestamps: timestamps, - conflict_target: :hash, - on_conflict: on_conflict - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - transaction in LifecycleTransaction, - update: [ - set: [ - # don't update `id` as it is a primary key - # don't update `hash` as it is a unique index and used for the conflict target - is_verify: fragment("EXCLUDED.is_verify"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.is_verify) IS DISTINCT FROM (?)", - transaction.is_verify - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/transaction_batches.ex b/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/transaction_batches.ex deleted file mode 100644 index e2b8930b1828..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/transaction_batches.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.PolygonZkevm.TransactionBatches do - @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonZkevm.TransactionBatch.t/0`. - """ - - require Ecto.Query - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.Import - alias Explorer.Chain.PolygonZkevm.TransactionBatch - alias Explorer.Prometheus.Instrumenter - - import Ecto.Query, only: [from: 2] - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [TransactionBatch.t()] - - @impl Import.Runner - def ecto_schema_module, do: TransactionBatch - - @impl Import.Runner - def option_key, do: :polygon_zkevm_transaction_batches - - @impl Import.Runner - @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - @spec run(Multi.t(), list(), map()) :: Multi.t() - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_polygon_zkevm_transaction_batches, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :polygon_zkevm_transaction_batches, - :polygon_zkevm_transaction_batches - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [TransactionBatch.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce PolygonZkevm.TransactionBatch ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.number) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - for: TransactionBatch, - returning: true, - timeout: timeout, - timestamps: timestamps, - conflict_target: :number, - on_conflict: on_conflict - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - tb in TransactionBatch, - update: [ - set: [ - # don't update `number` as it is a primary key and used for the conflict target - timestamp: fragment("EXCLUDED.timestamp"), - l2_transactions_count: fragment("EXCLUDED.l2_transactions_count"), - global_exit_root: fragment("EXCLUDED.global_exit_root"), - acc_input_hash: fragment("EXCLUDED.acc_input_hash"), - state_root: fragment("EXCLUDED.state_root"), - sequence_id: fragment("EXCLUDED.sequence_id"), - verify_id: fragment("EXCLUDED.verify_id"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", tb.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", tb.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.timestamp, EXCLUDED.l2_transactions_count, EXCLUDED.global_exit_root, EXCLUDED.acc_input_hash, EXCLUDED.state_root, EXCLUDED.sequence_id, EXCLUDED.verify_id) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", - tb.timestamp, - tb.l2_transactions_count, - tb.global_exit_root, - tb.acc_input_hash, - tb.state_root, - tb.sequence_id, - tb.verify_id - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/scroll/batch_bundles.ex b/apps/explorer/lib/explorer/chain/import/runner/scroll/batch_bundles.ex index 20633de83635..8434a29c1099 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/scroll/batch_bundles.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/scroll/batch_bundles.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Scroll.BatchBundles do @moduledoc """ Bulk imports `Explorer.Chain.Scroll.BatchBundle`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/scroll/batches.ex b/apps/explorer/lib/explorer/chain/import/runner/scroll/batches.ex index 710e22c9429f..b88ed19084bf 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/scroll/batches.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/scroll/batches.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Scroll.Batches do @moduledoc """ Bulk imports `Explorer.Chain.Scroll.Batch`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/scroll/bridge_operations.ex b/apps/explorer/lib/explorer/chain/import/runner/scroll/bridge_operations.ex index 44c8cc4f7fbc..55a13e50add3 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/scroll/bridge_operations.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/scroll/bridge_operations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Scroll.BridgeOperations do @moduledoc """ Bulk imports `Explorer.Chain.Scroll.Bridge`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/scroll/l1_fee_params.ex b/apps/explorer/lib/explorer/chain/import/runner/scroll/l1_fee_params.ex index 525fd0ffee5e..0b99cb02db19 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/scroll/l1_fee_params.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/scroll/l1_fee_params.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Scroll.L1FeeParams do @moduledoc """ Bulk imports `Explorer.Chain.Scroll.L1FeeParam`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/shibarium/bridge_operations.ex b/apps/explorer/lib/explorer/chain/import/runner/shibarium/bridge_operations.ex index b7cd680ae231..5d8a464e2cb3 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/shibarium/bridge_operations.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/shibarium/bridge_operations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Shibarium.BridgeOperations do @moduledoc """ Bulk imports `t:Explorer.Chain.Shibarium.Bridge.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex b/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex index 2683c87bdcbd..8eb8e371acc1 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.SignedAuthorizations do @moduledoc """ Bulk imports `t:Explorer.Chain.SignedAuthorization.t/0`. @@ -91,20 +92,22 @@ defmodule Explorer.Chain.Import.Runner.SignedAuthorizations do s: fragment("EXCLUDED.s"), v: fragment("EXCLUDED.v"), authority: fragment("EXCLUDED.authority"), + status: fragment("EXCLUDED.status"), inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", authorization.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", authorization.updated_at) ] ], where: fragment( - "(EXCLUDED.chain_id, EXCLUDED.address, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.v, EXCLUDED.authority) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.chain_id, EXCLUDED.address, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.v, EXCLUDED.authority, EXCLUDED.status) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?)", authorization.chain_id, authorization.address, authorization.nonce, authorization.r, authorization.s, authorization.v, - authorization.authority + authorization.authority, + authorization.status ) ) end diff --git a/apps/explorer/lib/explorer/chain/import/runner/stability/validators.ex b/apps/explorer/lib/explorer/chain/import/runner/stability/validators.ex new file mode 100644 index 000000000000..1e5ed19ee752 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/stability/validators.ex @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Stability.Validators do + @moduledoc """ + Bulk updates `t:Explorer.Chain.Stability.Validator.t/0` blocks_validated counters. + """ + + require Ecto.Query + + alias Ecto.{Multi, Repo} + alias Explorer.Chain.{Import, Stability.Validator} + alias Explorer.Prometheus.Instrumenter + + import Ecto.Query, only: [from: 2, where: 3] + + require Logger + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [Validator.t()] + + @impl Import.Runner + def ecto_schema_module, do: Validator + + @impl Import.Runner + def option_key, do: :stability_validators + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s with updated counters" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :stability_validators, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> update_counters(repo, changes_list, insert_options) end, + :block_referencing, + :stability_validators, + :stability_validators + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec update_counters(Repo.t(), [map()], %{ + required(:timeout) => timeout, + required(:timestamps) => Import.timestamps() + }) :: + {:ok, [Validator.t()]} + defp update_counters(repo, changes_list, %{timeout: timeout, timestamps: timestamps}) when is_list(changes_list) do + if changes_list != [] do + # Get all address hashes from the changes + address_hashes = Enum.map(changes_list, & &1.address_hash) + + # Get existing validators that match the address hashes + existing_validators = + Validator + |> where([v], v.address_hash in ^address_hashes) + |> repo.all(timeout: timeout) + + # Update counters for each existing validator + updated_validators = + Enum.reduce(changes_list, [], fn change, acc -> + case Enum.find(existing_validators, &(&1.address_hash == change.address_hash)) do + nil -> + # Validator doesn't exist, log error and skip + Logger.error("Validator with address hash #{to_string(change.address_hash)} not found") + acc + + validator -> + # Update the blocks_validated counter + # credo:disable-for-next-line + case repo.update_all( + from(v in Validator, where: v.address_hash == ^change.address_hash), + [ + inc: [blocks_validated: change.blocks_validated], + set: [updated_at: timestamps.updated_at] + ], + timeout: timeout + ) do + {1, _} -> + # Successfully updated, add to result + updated_validator = %Validator{ + address_hash: change.address_hash, + blocks_validated: validator.blocks_validated + change.blocks_validated + } + + [updated_validator | acc] + + _ -> + # Update failed, log error and skip + Logger.error("Failed to update validator counter for address hash #{to_string(change.address_hash)}") + acc + end + end + end) + + {:ok, updated_validators} + else + {:ok, []} + end + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/stats/hot_smart_contracts.ex b/apps/explorer/lib/explorer/chain/import/runner/stats/hot_smart_contracts.ex new file mode 100644 index 000000000000..b246b5962c73 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/stats/hot_smart_contracts.ex @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Stats.HotSmartContracts do + @moduledoc """ + Bulk imports `t:Explorer.Stats.HotSmartContracts.t/0` rows (hot_smart_contracts_daily). + """ + + require Ecto.Query + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Import + alias Explorer.Prometheus.Instrumenter + alias Explorer.Stats.HotSmartContracts + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [HotSmartContracts.t()] + + @impl Import.Runner + def ecto_schema_module, do: HotSmartContracts + + @impl Import.Runner + def option_key, do: :hot_smart_contracts_daily + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :hot_smart_contracts_daily, fn repo, _ -> + Instrumenter.stats_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :hot_smart_contracts_daily + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{ + optional(:on_conflict) => Import.Runner.on_conflict(), + required(:timeout) => timeout, + required(:timestamps) => Import.timestamps() + }) :: + {:ok, [HotSmartContracts.t()]} + | {:error, [Changeset.t()]} + defp insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + # Enforce HotSmartContracts ShareLocks order (see docs: sharelocks.md) + ordered_changes_list = Enum.sort_by(changes_list, &{&1.date, &1.contract_address_hash}) + + Import.insert_changes_list( + repo, + ordered_changes_list, + conflict_target: [:date, :contract_address_hash], + on_conflict: on_conflict, + for: HotSmartContracts, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + end + + defp default_on_conflict do + :replace_all + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/token_instances.ex b/apps/explorer/lib/explorer/chain/import/runner/token_instances.ex index 0fb38962a755..de9326de5d28 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/token_instances.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/token_instances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.TokenInstances do @moduledoc """ Bulk imports `t:Explorer.Chain.TokenInstances.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/token_transfers.ex b/apps/explorer/lib/explorer/chain/import/runner/token_transfers.ex index acbbf3159207..9523c358f7a1 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/token_transfers.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/token_transfers.ex @@ -1,8 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.TokenTransfers do @moduledoc """ Bulk imports `t:Explorer.Chain.TokenTransfer.t/0`. """ + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + require Ecto.Query import Ecto.Query, only: [from: 2] @@ -61,16 +65,20 @@ defmodule Explorer.Chain.Import.Runner.TokenTransfers do on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) # Enforce TokenTransfer ShareLocks order (see docs: sharelocks.md) - ordered_changes_list = - case Application.get_env(:explorer, :chain_type) do - :celo -> Enum.sort_by(changes_list, &{&1.block_hash, &1.log_index}) - _ -> Enum.sort_by(changes_list, &{&1.transaction_hash, &1.block_hash, &1.log_index}) - end - conflict_target = - case Application.get_env(:explorer, :chain_type) do - :celo -> [:log_index, :block_hash] - _ -> [:transaction_hash, :log_index, :block_hash] + {ordered_changes_list, conflict_target} = + case chain_identity() do + {:optimism, :celo} -> + { + Enum.sort_by(changes_list, &{&1.block_hash, &1.log_index}), + [:log_index, :block_hash] + } + + _ -> + { + Enum.sort_by(changes_list, &{&1.transaction_hash, &1.block_hash, &1.log_index}), + [:transaction_hash, :log_index, :block_hash] + } end {:ok, inserted} = @@ -89,8 +97,8 @@ defmodule Explorer.Chain.Import.Runner.TokenTransfers do end defp default_on_conflict do - case Application.get_env(:explorer, :chain_type) do - :celo -> + case chain_identity() do + {:optimism, :celo} -> from( token_transfer in TokenTransfer, update: [ diff --git a/apps/explorer/lib/explorer/chain/import/runner/tokens.ex b/apps/explorer/lib/explorer/chain/import/runner/tokens.ex index d69b2f4c66db..ef77f04be358 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/tokens.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/tokens.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Tokens do @moduledoc """ Bulk imports `t:Explorer.Chain.Token.t/0`. @@ -143,11 +144,13 @@ defmodule Explorer.Chain.Import.Runner.Tokens do ordered_changes_list = changes_list - # brand new tokens start with no holders + # brand new tokens start with no holders and transfers # set cataloged: nil, if not set before, to get proper COALESCE result # if don't set it, cataloged will default to false (as in DB schema) # and COALESCE in on_conflict will return false - |> Stream.map(fn token -> token |> Map.put_new(:holder_count, 0) |> Map.put_new(:cataloged, nil) end) + |> Stream.map(fn token -> + token |> Map.put_new(:holder_count, 0) |> Map.put_new(:transfer_count, 0) |> Map.put_new(:cataloged, nil) + end) # Enforce Token ShareLocks order (see docs: sharelocks.md) |> Enum.sort_by(& &1.contract_address_hash) @@ -196,7 +199,7 @@ defmodule Explorer.Chain.Import.Runner.Tokens do cataloged: fragment("COALESCE(EXCLUDED.cataloged, ?)", token.cataloged), bridged: fragment("COALESCE(EXCLUDED.bridged, ?)", token.bridged), skip_metadata: fragment("COALESCE(EXCLUDED.skip_metadata, ?)", token.skip_metadata), - # `holder_count` is not updated as a pre-existing token means the `holder_count` is already initialized OR + # `holder_count` and `transfer_count` are not updated as a pre-existing token means these counts are already initialized OR # need to be migrated with `priv/repo/migrations/scripts/update_new_tokens_holder_count_in_batches.sql.exs` # Don't update `contract_address_hash` as it is the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token.inserted_at), @@ -264,21 +267,25 @@ defmodule Explorer.Chain.Import.Runner.Tokens do circulating_market_cap: fragment("COALESCE(EXCLUDED.circulating_market_cap, ?)", token.circulating_market_cap), volume_24h: fragment("COALESCE(EXCLUDED.volume_24h, ?)", token.volume_24h), + circulating_supply: fragment("COALESCE(EXCLUDED.circulating_supply, ?)", token.circulating_supply), icon_url: fragment("COALESCE(?, EXCLUDED.icon_url)", token.icon_url), + decimals: fragment("COALESCE(?, EXCLUDED.decimals)", token.decimals), inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token.updated_at) ] ], where: fragment( - "(EXCLUDED.name, EXCLUDED.symbol, EXCLUDED.type, EXCLUDED.fiat_value, EXCLUDED.circulating_market_cap, EXCLUDED.volume_24h, EXCLUDED.icon_url) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.name, EXCLUDED.symbol, EXCLUDED.type, EXCLUDED.fiat_value, EXCLUDED.circulating_market_cap, EXCLUDED.volume_24h, EXCLUDED.circulating_supply, EXCLUDED.icon_url, EXCLUDED.decimals) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?)", token.name, token.symbol, token.type, token.fiat_value, token.circulating_market_cap, token.volume_24h, - token.icon_url + token.circulating_supply, + token.icon_url, + token.decimals ) ) end @@ -295,10 +302,69 @@ defmodule Explorer.Chain.Import.Runner.Tokens do `:volume_24h` """ @spec market_data_fields_to_update() :: [ - :name | :symbol | :type | :fiat_value | :circulating_market_cap | :volume_24h + :name + | :symbol + | :type + | :fiat_value + | :circulating_market_cap + | :volume_24h + | :circulating_supply + | :decimals ] def market_data_fields_to_update do - [:name, :symbol, :type, :fiat_value, :circulating_market_cap, :volume_24h] + [:name, :symbol, :type, :fiat_value, :circulating_market_cap, :volume_24h, :circulating_supply, :decimals] + end + + @doc """ + Returns an Ecto query that defines the conflict resolution strategy when importing tokens from a token list + """ + @spec token_list_on_conflict() :: Ecto.Query.t() + def token_list_on_conflict do + from( + token in Token, + update: [ + set: [ + name: fragment("COALESCE(?, EXCLUDED.name)", token.name), + symbol: fragment("COALESCE(?, EXCLUDED.symbol)", token.symbol), + decimals: fragment("COALESCE(?, EXCLUDED.decimals)", token.decimals), + icon_url: + fragment( + "CASE WHEN ? THEN ? ELSE COALESCE(EXCLUDED.icon_url, ?) END", + token.is_verified_via_admin_panel, + token.icon_url, + token.icon_url + ), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token.updated_at) + ] + ], + where: + fragment( + """ + (COALESCE(?, EXCLUDED.name), COALESCE(?, EXCLUDED.symbol), COALESCE(?, EXCLUDED.decimals), + CASE WHEN ? THEN ? ELSE COALESCE(EXCLUDED.icon_url, ?) END) + IS DISTINCT FROM (?, ?, ?, ?) + """, + token.name, + token.symbol, + token.decimals, + token.is_verified_via_admin_panel, + token.icon_url, + token.icon_url, + token.name, + token.symbol, + token.decimals, + token.icon_url + ) + ) + end + + @doc """ + Returns a list of token fields that should be updated when importing from a token list. + """ + @spec token_list_fields_to_update() :: [:name | :symbol | :decimals | :icon_url] + def token_list_fields_to_update do + [:name, :symbol, :decimals, :icon_url] end defp should_update?(_new_token, nil, _fields_to_replace), do: true diff --git a/apps/explorer/lib/explorer/chain/import/runner/transaction/forks.ex b/apps/explorer/lib/explorer/chain/import/runner/transaction/forks.ex index 19144b9dca97..fa43a2b3bace 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/transaction/forks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/transaction/forks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Transaction.Forks do @moduledoc """ Bulk imports `t:Explorer.Chain.Transaction.Fork.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/transaction_actions.ex b/apps/explorer/lib/explorer/chain/import/runner/transaction_actions.ex deleted file mode 100644 index a5e54c7763eb..000000000000 --- a/apps/explorer/lib/explorer/chain/import/runner/transaction_actions.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Explorer.Chain.Import.Runner.TransactionActions do - @moduledoc """ - Bulk imports `t:Explorer.Chain.TransactionAction.t/0`. - """ - - require Ecto.Query - - import Ecto.Query, only: [from: 2] - - alias Ecto.{Changeset, Multi, Repo} - alias Explorer.Chain.{Import, TransactionAction} - alias Explorer.Prometheus.Instrumenter - - @behaviour Import.Runner - - # milliseconds - @timeout 60_000 - - @type imported :: [TransactionAction.t()] - - @impl Import.Runner - def ecto_schema_module, do: TransactionAction - - @impl Import.Runner - def option_key, do: :transaction_actions - - @impl Import.Runner - def imported_table_row do - %{ - value_type: "[#{ecto_schema_module()}.t()]", - value_description: "List of `t:#{ecto_schema_module()}.t/0`s" - } - end - - @impl Import.Runner - def run(multi, changes_list, %{timestamps: timestamps} = options) do - insert_options = - options - |> Map.get(option_key(), %{}) - |> Map.take(~w(on_conflict timeout)a) - |> Map.put_new(:timeout, @timeout) - |> Map.put(:timestamps, timestamps) - - Multi.run(multi, :insert_transaction_actions, fn repo, _ -> - Instrumenter.block_import_stage_runner( - fn -> insert(repo, changes_list, insert_options) end, - :block_referencing, - :transaction_actions, - :transaction_actions - ) - end) - end - - @impl Import.Runner - def timeout, do: @timeout - - @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [TransactionAction.t()]} - | {:error, [Changeset.t()]} - def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do - on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) - - # Enforce TransactionAction ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, &{&1.hash, &1.log_index}) - - {:ok, inserted} = - Import.insert_changes_list( - repo, - ordered_changes_list, - conflict_target: [:hash, :log_index], - on_conflict: on_conflict, - for: TransactionAction, - returning: true, - timeout: timeout, - timestamps: timestamps - ) - - {:ok, inserted} - end - - defp default_on_conflict do - from( - action in TransactionAction, - update: [ - set: [ - # Don't update `hash` as it is part of the composite primary key and used for the conflict target - # Don't update `log_index` as it is part of the composite primary key and used for the conflict target - protocol: fragment("EXCLUDED.protocol"), - data: fragment("EXCLUDED.data"), - type: fragment("EXCLUDED.type"), - inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", action.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", action.updated_at) - ] - ], - where: - fragment( - "(EXCLUDED.protocol, EXCLUDED.data, EXCLUDED.type) IS DISTINCT FROM (?, ? ,?)", - action.protocol, - action.data, - action.type - ) - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/import/runner/transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/transactions.ex index 2959f9d55eca..fac92be3485f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/transactions.ex @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Transactions do @moduledoc """ Bulk imports `t:Explorer.Chain.Transaction.t/0`. """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] require Ecto.Query @@ -10,10 +11,20 @@ defmodule Explorer.Chain.Import.Runner.Transactions do alias Ecto.{Multi, Repo} alias EthereumJSONRPC.Utility.RangesHelper - alias Explorer.Chain.{Block, Hash, Import, PendingOperationsHelper, PendingTransactionOperation, Transaction} + + alias Explorer.Chain.{ + Block, + Hash, + Import, + PendingBlockOperation, + PendingOperationsHelper, + PendingTransactionOperation, + Transaction + } + alias Explorer.Chain.Import.Runner.TokenTransfers alias Explorer.Prometheus.Instrumenter - alias Explorer.Utility.MissingRangesManipulator + alias Explorer.Utility.MissingBlockRange @behaviour Import.Runner @@ -66,14 +77,14 @@ defmodule Explorer.Chain.Import.Runner.Transactions do :transactions ) end) - |> Multi.run(:new_pending_transaction_operations, fn repo, %{transactions: transactions} -> + |> Multi.run(:new_pending_operations, fn repo, %{transactions: transactions} -> Instrumenter.block_import_stage_runner( fn -> - new_pending_transaction_operations(repo, transactions, insert_options) + new_pending_operations(repo, transactions, insert_options) end, :block_referencing, :transactions, - :new_pending_transaction_operations + :new_pending_operations ) end) end @@ -105,7 +116,10 @@ defmodule Explorer.Chain.Import.Runner.Transactions do on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) # Enforce Transaction ShareLocks order (see docs: sharelocks.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.hash) + ordered_changes_list = + changes_list + |> Enum.uniq_by(& &1.hash) + |> Enum.sort_by(& &1.hash) Import.insert_changes_list( repo, @@ -119,14 +133,21 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ) end - defp new_pending_transaction_operations(repo, inserted_transactions, %{timeout: timeout, timestamps: timestamps}) do + defp new_pending_operations(repo, inserted_transactions, %{timeout: timeout, timestamps: timestamps}) do + traceable_consensus_transactions = + inserted_transactions + |> RangesHelper.filter_by_height_range(&RangesHelper.traceable_block_number?(&1.block_number)) + |> Transaction.filter_non_traceable_transactions() + + traceable_consensus_block_numbers = Enum.map(traceable_consensus_transactions, & &1.block_number) |> Enum.uniq() + block_numbers_with_priorities = MissingBlockRange.find_priority_by_numbers(traceable_consensus_block_numbers) + case PendingOperationsHelper.pending_operations_type() do "transactions" -> sorted_pending_ops = - inserted_transactions - |> RangesHelper.filter_by_height_range(&RangesHelper.traceable_block_number?(&1.block_number)) + traceable_consensus_transactions |> Enum.reject(&is_nil(&1.block_number)) - |> Enum.map(&%{transaction_hash: &1.hash}) + |> Enum.map(&%{transaction_hash: &1.hash, priority: Map.get(block_numbers_with_priorities, &1.block_number)}) |> Enum.sort() Import.insert_changes_list( @@ -140,14 +161,35 @@ defmodule Explorer.Chain.Import.Runner.Transactions do timestamps: timestamps ) - _other_type -> - {:ok, []} + "blocks" -> + sorted_pending_ops = + traceable_consensus_transactions + |> Enum.reject(&is_nil(&1.block_number)) + |> Enum.map( + &%{ + block_hash: &1.block_hash, + block_number: &1.block_number, + priority: Map.get(block_numbers_with_priorities, &1.block_number) + } + ) + |> Enum.sort() + + Import.insert_changes_list( + repo, + sorted_pending_ops, + conflict_target: :block_hash, + on_conflict: :nothing, + for: PendingBlockOperation, + returning: true, + timeout: timeout, + timestamps: timestamps + ) end end # todo: avoid code duplication - case @chain_type do - :suave -> + case @chain_identity do + {:suave, nil} -> defp default_on_conflict do from( transaction in Transaction, @@ -245,7 +287,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ) end - :optimism -> + {:optimism, nil} -> defp default_on_conflict do from( transaction in Transaction, @@ -284,6 +326,9 @@ defmodule Explorer.Chain.Import.Runner.Transactions do l1_gas_used: fragment("EXCLUDED.l1_gas_used"), l1_transaction_origin: fragment("EXCLUDED.l1_transaction_origin"), l1_block_number: fragment("EXCLUDED.l1_block_number"), + operator_fee_scalar: fragment("EXCLUDED.operator_fee_scalar"), + operator_fee_constant: fragment("EXCLUDED.operator_fee_constant"), + da_footprint_gas_scalar: fragment("EXCLUDED.da_footprint_gas_scalar"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) @@ -291,7 +336,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number, EXCLUDED.operator_fee_scalar, EXCLUDED.operator_fee_constant, EXCLUDED.da_footprint_gas_scalar) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -322,12 +367,15 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.l1_gas_price, transaction.l1_gas_used, transaction.l1_transaction_origin, - transaction.l1_block_number + transaction.l1_block_number, + transaction.operator_fee_scalar, + transaction.operator_fee_constant, + transaction.da_footprint_gas_scalar ) ) end - :arbitrum -> + {:arbitrum, nil} -> defp default_on_conflict do from( transaction in Transaction, @@ -399,7 +447,7 @@ defmodule Explorer.Chain.Import.Runner.Transactions do ) end - :celo -> + {:scroll, nil} -> defp default_on_conflict do from( transaction in Transaction, @@ -432,18 +480,16 @@ defmodule Explorer.Chain.Import.Runner.Transactions do max_priority_fee_per_gas: fragment("EXCLUDED.max_priority_fee_per_gas"), max_fee_per_gas: fragment("EXCLUDED.max_fee_per_gas"), type: fragment("EXCLUDED.type"), + l1_fee: fragment("EXCLUDED.l1_fee"), + queue_index: fragment("EXCLUDED.queue_index"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at), - # Celo custom fields - gas_token_contract_address_hash: fragment("EXCLUDED.gas_token_contract_address_hash"), - gas_fee_recipient_address_hash: fragment("EXCLUDED.gas_fee_recipient_address_hash"), - gateway_fee: fragment("EXCLUDED.gateway_fee") + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) ] ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.gas_token_contract_address_hash, EXCLUDED.gas_fee_recipient_address_hash, EXCLUDED.gateway_fee) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.queue_index) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -469,14 +515,13 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.max_priority_fee_per_gas, transaction.max_fee_per_gas, transaction.type, - transaction.gas_token_contract_address_hash, - transaction.gas_fee_recipient_address_hash, - transaction.gateway_fee + transaction.l1_fee, + transaction.queue_index ) ) end - :scroll -> + {:optimism, :celo} -> defp default_on_conflict do from( transaction in Transaction, @@ -510,15 +555,23 @@ defmodule Explorer.Chain.Import.Runner.Transactions do max_fee_per_gas: fragment("EXCLUDED.max_fee_per_gas"), type: fragment("EXCLUDED.type"), l1_fee: fragment("EXCLUDED.l1_fee"), - queue_index: fragment("EXCLUDED.queue_index"), + l1_fee_scalar: fragment("EXCLUDED.l1_fee_scalar"), + l1_gas_price: fragment("EXCLUDED.l1_gas_price"), + l1_gas_used: fragment("EXCLUDED.l1_gas_used"), + l1_transaction_origin: fragment("EXCLUDED.l1_transaction_origin"), + l1_block_number: fragment("EXCLUDED.l1_block_number"), # Don't update `hash` as it is part of the primary key and used for the conflict target inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", transaction.inserted_at), - updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at) + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", transaction.updated_at), + # Celo custom fields + gas_token_contract_address_hash: fragment("EXCLUDED.gas_token_contract_address_hash"), + gas_fee_recipient_address_hash: fragment("EXCLUDED.gas_fee_recipient_address_hash"), + gateway_fee: fragment("EXCLUDED.gateway_fee") ] ], where: fragment( - "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.queue_index) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "(EXCLUDED.block_hash, EXCLUDED.block_number, EXCLUDED.block_consensus, EXCLUDED.block_timestamp, EXCLUDED.created_contract_address_hash, EXCLUDED.created_contract_code_indexed_at, EXCLUDED.cumulative_gas_used, EXCLUDED.from_address_hash, EXCLUDED.gas, EXCLUDED.gas_price, EXCLUDED.gas_used, EXCLUDED.index, EXCLUDED.input, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.status, EXCLUDED.to_address_hash, EXCLUDED.v, EXCLUDED.value, EXCLUDED.earliest_processing_start, EXCLUDED.revert_reason, EXCLUDED.max_priority_fee_per_gas, EXCLUDED.max_fee_per_gas, EXCLUDED.type, EXCLUDED.l1_fee, EXCLUDED.l1_fee_scalar, EXCLUDED.l1_gas_price, EXCLUDED.l1_gas_used, EXCLUDED.l1_transaction_origin, EXCLUDED.l1_block_number, EXCLUDED.gas_token_contract_address_hash, EXCLUDED.gas_fee_recipient_address_hash, EXCLUDED.gateway_fee) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", transaction.block_hash, transaction.block_number, transaction.block_consensus, @@ -545,7 +598,14 @@ defmodule Explorer.Chain.Import.Runner.Transactions do transaction.max_fee_per_gas, transaction.type, transaction.l1_fee, - transaction.queue_index + transaction.l1_fee_scalar, + transaction.l1_gas_price, + transaction.l1_gas_used, + transaction.l1_transaction_origin, + transaction.l1_block_number, + transaction.gas_token_contract_address_hash, + transaction.gas_fee_recipient_address_hash, + transaction.gateway_fee ) ) end @@ -699,14 +759,16 @@ defmodule Explorer.Chain.Import.Runner.Transactions do timeout: timeout ) - {_, _transactions_result} = + {_, transaction_hashes} = repo.update_all( - from(t in Transaction, join: s in subquery(transactions_query), on: t.hash == s.hash), + from(t in Transaction, join: s in subquery(transactions_query), on: t.hash == s.hash, select: t.hash), [set: transactions_replacements], timeout: timeout ) - MissingRangesManipulator.add_ranges_by_block_numbers(result) + PendingOperationsHelper.delete_related_transaction_operations(transaction_hashes) + + MissingBlockRange.add_ranges_by_block_numbers(result) {:ok, result} rescue diff --git a/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex b/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex index 1c84df614110..4608207165e4 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/withdrawals.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Withdrawals do @moduledoc """ Bulk imports `t:Explorer.Chain.Withdrawal.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/aggregate_quorum_certificates.ex b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/aggregate_quorum_certificates.ex index 42a2b00357a3..a35a79285d16 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/aggregate_quorum_certificates.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/aggregate_quorum_certificates.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Zilliqa.AggregateQuorumCertificates do @moduledoc """ Bulk imports `t:Explorer.Chain.Zilliqa.AggregateQuorumCertificate.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/nested_quorum_certificates.ex b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/nested_quorum_certificates.ex index 78cc04979803..7df0efd1f5c3 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/nested_quorum_certificates.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/nested_quorum_certificates.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Zilliqa.NestedQuorumCertificates do @moduledoc """ Bulk imports `t:Explorer.Chain.Zilliqa.NestedQuorumCertificate.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/quorum_certificates.ex b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/quorum_certificates.ex index e7b876910a8b..d2e42ed9c137 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/quorum_certificates.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/quorum_certificates.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Zilliqa.QuorumCertificates do @moduledoc """ Bulk imports `t:Explorer.Chain.Zilliqa.QuorumCertificate.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/batch_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/zrc2/token_adapters.ex similarity index 67% rename from apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/batch_transactions.ex rename to apps/explorer/lib/explorer/chain/import/runner/zilliqa/zrc2/token_adapters.ex index c330da10e689..6a948349b545 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/polygon_zkevm/batch_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/zrc2/token_adapters.ex @@ -1,13 +1,14 @@ -defmodule Explorer.Chain.Import.Runner.PolygonZkevm.BatchTransactions do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Zilliqa.Zrc2.TokenAdapters do @moduledoc """ - Bulk imports `t:Explorer.Chain.PolygonZkevm.BatchTransaction.t/0`. + Bulk imports `t:Explorer.Chain.Zilliqa.Zrc2.TokenAdapter.t/0`. """ require Ecto.Query alias Ecto.{Changeset, Multi, Repo} alias Explorer.Chain.Import - alias Explorer.Chain.PolygonZkevm.BatchTransaction + alias Explorer.Chain.Zilliqa.Zrc2.TokenAdapter alias Explorer.Prometheus.Instrumenter @behaviour Import.Runner @@ -15,13 +16,13 @@ defmodule Explorer.Chain.Import.Runner.PolygonZkevm.BatchTransactions do # milliseconds @timeout 60_000 - @type imported :: [BatchTransaction.t()] + @type imported :: [TokenAdapter.t()] @impl Import.Runner - def ecto_schema_module, do: BatchTransaction + def ecto_schema_module, do: TokenAdapter @impl Import.Runner - def option_key, do: :polygon_zkevm_batch_transactions + def option_key, do: :zilliqa_zrc2_token_adapters @impl Import.Runner @spec imported_table_row() :: %{:value_description => binary(), :value_type => binary()} @@ -42,12 +43,12 @@ defmodule Explorer.Chain.Import.Runner.PolygonZkevm.BatchTransactions do |> Map.put_new(:timeout, @timeout) |> Map.put(:timestamps, timestamps) - Multi.run(multi, :insert_polygon_zkevm_batch_transactions, fn repo, _ -> + Multi.run(multi, :insert_zilliqa_zrc2_token_adapters, fn repo, _ -> Instrumenter.block_import_stage_runner( fn -> insert(repo, changes_list, insert_options) end, :block_referencing, - :polygon_zkevm_batch_transactions, - :polygon_zkevm_batch_transactions + :zilliqa_zrc2_token_adapters, + :zilliqa_zrc2_token_adapters ) end) end @@ -56,21 +57,21 @@ defmodule Explorer.Chain.Import.Runner.PolygonZkevm.BatchTransactions do def timeout, do: @timeout @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: - {:ok, [BatchTransaction.t()]} + {:ok, [TokenAdapter.t()]} | {:error, [Changeset.t()]} def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = _options) when is_list(changes_list) do - # Enforce PolygonZkevm.BatchTransaction ShareLocks order (see docs: sharelock.md) - ordered_changes_list = Enum.sort_by(changes_list, & &1.hash) + # Enforce TokenAdapter ShareLocks order (see docs: sharelock.md) + ordered_changes_list = Enum.sort_by(changes_list, & &1.adapter_address_hash) {:ok, inserted} = Import.insert_changes_list( repo, ordered_changes_list, - for: BatchTransaction, + for: TokenAdapter, returning: true, timeout: timeout, timestamps: timestamps, - conflict_target: :hash, + conflict_target: :adapter_address_hash, on_conflict: :nothing ) diff --git a/apps/explorer/lib/explorer/chain/import/runner/zilliqa/zrc2/token_transfers.ex b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/zrc2/token_transfers.ex new file mode 100644 index 000000000000..b366f389767a --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/zilliqa/zrc2/token_transfers.ex @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.Zilliqa.Zrc2.TokenTransfers do + @moduledoc """ + Bulk imports `t:Explorer.Chain.Zilliqa.Zrc2.TokenTransfer.t/0`. + """ + + require Ecto.Query + + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.Import + alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [TokenTransfer.t()] + + @impl Import.Runner + def ecto_schema_module, do: TokenTransfer + + @impl Import.Runner + def option_key, do: :zilliqa_zrc2_token_transfers + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :insert_zilliqa_zrc2_token_transfers, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :zilliqa_zrc2_token_transfers, + :zilliqa_zrc2_token_transfers + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{required(:timeout) => timeout(), required(:timestamps) => Import.timestamps()}) :: + {:ok, [TokenTransfer.t()]} + | {:error, [Changeset.t()]} + def insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + + # Enforce TokenTransfer ShareLocks order (see docs: sharelocks.md) + ordered_changes_list = Enum.sort_by(changes_list, &{&1.transaction_hash, &1.block_hash, &1.log_index}) + + {:ok, inserted} = + Import.insert_changes_list( + repo, + ordered_changes_list, + conflict_target: [:transaction_hash, :log_index, :block_hash], + on_conflict: on_conflict, + for: TokenTransfer, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + + {:ok, inserted} + end + + defp default_on_conflict do + from( + token_transfer in TokenTransfer, + update: [ + set: [ + # Don't update `transaction_hash` as it is part of the composite primary key and used for the conflict target. + # Don't update `log_index` as it is part of the composite primary key and used for the conflict target. + # Don't update `block_hash` as it is part of the composite primary key and used for the conflict target. + from_address_hash: fragment("EXCLUDED.from_address_hash"), + to_address_hash: fragment("EXCLUDED.to_address_hash"), + amount: fragment("EXCLUDED.amount"), + zrc2_address_hash: fragment("EXCLUDED.zrc2_address_hash"), + block_number: fragment("EXCLUDED.block_number"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", token_transfer.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", token_transfer.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.from_address_hash, EXCLUDED.to_address_hash, EXCLUDED.amount, EXCLUDED.zrc2_address_hash, EXCLUDED.block_number) IS DISTINCT FROM (?, ?, ?, ?, ?)", + token_transfer.from_address_hash, + token_transfer.to_address_hash, + token_transfer.amount, + token_transfer.zrc2_address_hash, + token_transfer.block_number + ) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_blocks.ex b/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_blocks.ex index 33d075e93588..362a2f685778 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.ZkSync.BatchBlocks do @moduledoc """ Bulk imports `t:Explorer.Chain.ZkSync.BatchBlock.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_transactions.ex index a7dca2298b86..2d8e645b68b0 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zksync/batch_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.ZkSync.BatchTransactions do @moduledoc """ Bulk imports `t:Explorer.Chain.ZkSync.BatchTransaction.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/zksync/lifecycle_transactions.ex b/apps/explorer/lib/explorer/chain/import/runner/zksync/lifecycle_transactions.ex index 7011423601ee..58fd12cbee3f 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zksync/lifecycle_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zksync/lifecycle_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.ZkSync.LifecycleTransactions do @moduledoc """ Bulk imports `t:Explorer.Chain.ZkSync.LifecycleTransaction.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/runner/zksync/transaction_batches.ex b/apps/explorer/lib/explorer/chain/import/runner/zksync/transaction_batches.ex index 11c85fff7601..b03df1708a71 100644 --- a/apps/explorer/lib/explorer/chain/import/runner/zksync/transaction_batches.ex +++ b/apps/explorer/lib/explorer/chain/import/runner/zksync/transaction_batches.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.ZkSync.TransactionBatches do @moduledoc """ Bulk imports `t:Explorer.Chain.ZkSync.TransactionBatch.t/0`. diff --git a/apps/explorer/lib/explorer/chain/import/stage.ex b/apps/explorer/lib/explorer/chain/import/stage.ex index ed000760ca9b..5120d0ee88b0 100644 --- a/apps/explorer/lib/explorer/chain/import/stage.ex +++ b/apps/explorer/lib/explorer/chain/import/stage.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage do @moduledoc """ Behaviour used to chunk `changes_list` into multiple `t:Ecto.Multi.t/0`` that can run in separate transactions to diff --git a/apps/explorer/lib/explorer/chain/import/stage/addresses.ex b/apps/explorer/lib/explorer/chain/import/stage/addresses.ex new file mode 100644 index 000000000000..0786e46108d2 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/stage/addresses.ex @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Stage.Addresses do + @moduledoc """ + Import addresses. + """ + + alias Explorer.Chain.Import.{Runner, Stage} + + @behaviour Stage + + @runners [ + Runner.Addresses + ] + + @impl Stage + def runners, do: @runners + + @impl Stage + def all_runners, do: runners() + + @addresses_chunk_size 200 + + @impl Stage + def multis(runner_to_changes_list, options) do + Stage.chunk_every(runner_to_changes_list, Runner.Addresses, @addresses_chunk_size, options) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/stage/block_transaction_referencing.ex b/apps/explorer/lib/explorer/chain/import/stage/block_transaction_referencing.ex index 8c25a9a05495..5fbbcd9b53f2 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/block_transaction_referencing.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/block_transaction_referencing.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.BlockTransactionReferencing do @moduledoc """ Imports any data that is related to blocks and transactions. @@ -12,9 +13,9 @@ defmodule Explorer.Chain.Import.Stage.BlockTransactionReferencing do Runner.Transaction.Forks, Runner.Block.Rewards, Runner.Block.SecondDegreeRelations, - Runner.TransactionActions, Runner.Withdrawals, - Runner.SignedAuthorizations + Runner.SignedAuthorizations, + Runner.FheOperations ] @impl Stage diff --git a/apps/explorer/lib/explorer/chain/import/stage/blocks.ex b/apps/explorer/lib/explorer/chain/import/stage/blocks.ex index 205acd55453f..5d2e7e87a799 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/blocks.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.Blocks do @moduledoc """ Import blocks. diff --git a/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex b/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex index 1b4d182cc841..63a07a05ba7e 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/chain_type_specific.ex @@ -1,8 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.ChainTypeSpecific do @moduledoc """ Imports any chain type specific tables. """ + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + alias Explorer.Chain.Import.{Runner, Stage} @behaviour Stage @@ -20,19 +25,6 @@ defmodule Explorer.Chain.Import.Stage.ChainTypeSpecific do Runner.Optimism.EIP1559ConfigUpdates, Runner.Optimism.InteropMessages ], - polygon_edge: [ - Runner.PolygonEdge.Deposits, - Runner.PolygonEdge.DepositExecutes, - Runner.PolygonEdge.Withdrawals, - Runner.PolygonEdge.WithdrawalExits - ], - polygon_zkevm: [ - Runner.PolygonZkevm.LifecycleTransactions, - Runner.PolygonZkevm.TransactionBatches, - Runner.PolygonZkevm.BatchTransactions, - Runner.PolygonZkevm.BridgeL1Tokens, - Runner.PolygonZkevm.BridgeOperations - ], zksync: [ Runner.ZkSync.LifecycleTransactions, Runner.ZkSync.TransactionBatches, @@ -61,29 +53,50 @@ defmodule Explorer.Chain.Import.Stage.ChainTypeSpecific do Runner.Scroll.BridgeOperations, Runner.Scroll.L1FeeParams ], - celo: [ - Runner.Celo.ValidatorGroupVotes, - Runner.Celo.ElectionRewards, - Runner.Celo.EpochRewards - ], zilliqa: [ Runner.Zilliqa.AggregateQuorumCertificates, Runner.Zilliqa.NestedQuorumCertificates, - Runner.Zilliqa.QuorumCertificates + Runner.Zilliqa.QuorumCertificates, + Runner.Zilliqa.Zrc2.TokenAdapters, + Runner.Zilliqa.Zrc2.TokenTransfers + ], + stability: [ + Runner.Stability.Validators + ] + } + + @runners_by_chain_identity %{ + {:optimism, :celo} => [ + Runner.Celo.PendingAccountOperations, + Runner.Celo.Accounts, + Runner.Celo.ValidatorGroupVotes, + Runner.Celo.Epochs, + Runner.Celo.ElectionRewards, + Runner.Celo.EpochRewards, + Runner.Celo.AggregatedElectionRewards ] } @impl Stage def runners do - chain_type = Application.get_env(:explorer, :chain_type) - Map.get(@runners_by_chain_type, chain_type, []) + chain_type_runners = Map.get(@runners_by_chain_type, chain_type(), []) + chain_identity_runners = Map.get(@runners_by_chain_identity, chain_identity(), []) + chain_type_runners ++ chain_identity_runners end @impl Stage def all_runners do - @runners_by_chain_type - |> Map.values() - |> Enum.concat() + chain_type_runners = + @runners_by_chain_type + |> Map.values() + |> Enum.concat() + + chain_identity_runners = + @runners_by_chain_identity + |> Map.values() + |> Enum.concat() + + chain_type_runners ++ chain_identity_runners end @impl Stage diff --git a/apps/explorer/lib/explorer/chain/import/stage/internal_transactions.ex b/apps/explorer/lib/explorer/chain/import/stage/internal_transactions.ex index 178c6a1b95a3..29284932ea0f 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/internal_transactions.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/internal_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.InternalTransactions do @moduledoc """ Imports the rest of the data. diff --git a/apps/explorer/lib/explorer/chain/import/stage/logs.ex b/apps/explorer/lib/explorer/chain/import/stage/logs.ex index 739af1583d35..0b879f3b87a0 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/logs.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/logs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.Logs do @moduledoc """ Import logs. diff --git a/apps/explorer/lib/explorer/chain/import/stage/main.ex b/apps/explorer/lib/explorer/chain/import/stage/main.ex index 7df0f93aecf0..9f4c79bcea32 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/main.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/main.ex @@ -1,15 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.Main do @moduledoc """ - Imports main data (addresses, address_coin_balances, address_coin_balances_daily, tokens, transactions). + Imports main data (address_coin_balances, address_coin_balances_daily, tokens, transactions). """ alias Explorer.Chain.Import.{Runner, Stage} @behaviour Stage - @addresses_runner Runner.Addresses - - @rest_runners [ + @runners [ Runner.Tokens, Runner.Address.CoinBalances, Runner.Address.CoinBalancesDaily, @@ -17,21 +16,16 @@ defmodule Explorer.Chain.Import.Stage.Main do ] @impl Stage - def runners, do: [@addresses_runner | @rest_runners] + def runners, do: @runners @impl Stage def all_runners, do: runners() - @addresses_chunk_size 50 - @impl Stage def multis(runner_to_changes_list, options) do - {addresses_multis, remaining_runner_to_changes_list} = - Stage.chunk_every(runner_to_changes_list, Runner.Addresses, @addresses_chunk_size, options) - {final_multi, final_remaining_runner_to_changes_list} = - Stage.single_multi(@rest_runners, remaining_runner_to_changes_list, options) + Stage.single_multi(@runners, runner_to_changes_list, options) - {[final_multi | addresses_multis], final_remaining_runner_to_changes_list} + {[final_multi], final_remaining_runner_to_changes_list} end end diff --git a/apps/explorer/lib/explorer/chain/import/stage/stats.ex b/apps/explorer/lib/explorer/chain/import/stage/stats.ex new file mode 100644 index 000000000000..813aea6835c7 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/stage/stats.ex @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Stage.Stats do + @moduledoc """ + Imports stats data (hot_smart_contracts_daily). + """ + + alias Explorer.Chain.Import.{Runner, Stage} + + @behaviour Stage + + @runners [ + Runner.Stats.HotSmartContracts + ] + + @impl Stage + def runners, do: @runners + + @impl Stage + def all_runners, do: runners() + + @impl Stage + def multis(runner_to_changes_list, options) do + {final_multi, final_remaining_runner_to_changes_list} = + Stage.single_multi(@runners, runner_to_changes_list, options) + + {[final_multi], final_remaining_runner_to_changes_list} + end +end diff --git a/apps/explorer/lib/explorer/chain/import/stage/token_instances.ex b/apps/explorer/lib/explorer/chain/import/stage/token_instances.ex index 785930e6e18d..57eecbedbbef 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/token_instances.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/token_instances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.TokenInstances do @moduledoc """ Import token instances. diff --git a/apps/explorer/lib/explorer/chain/import/stage/token_referencing.ex b/apps/explorer/lib/explorer/chain/import/stage/token_referencing.ex index 18af2e8563b9..7e9d82260ce2 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/token_referencing.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/token_referencing.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Stage.TokenReferencing do @moduledoc """ Imports any data that is related to tokens. @@ -7,22 +8,33 @@ defmodule Explorer.Chain.Import.Stage.TokenReferencing do @behaviour Stage - @runners [ - Runner.Address.TokenBalances, - Runner.Address.CurrentTokenBalances + @ctb_runner Runner.Address.CurrentTokenBalances + + @rest_runners [ + Runner.Address.TokenBalances ] @impl Stage - def runners, do: @runners + def runners, do: [@ctb_runner | @rest_runners] @impl Stage def all_runners, do: runners() + @default_ctb_chunk_size 50 + @impl Stage def multis(runner_to_changes_list, options) do + {ctb_multis, remaining_runner_to_changes_list} = + Stage.chunk_every(runner_to_changes_list, @ctb_runner, ctb_chunk_size(), options) + {final_multi, final_remaining_runner_to_changes_list} = - Stage.single_multi(runners(), runner_to_changes_list, options) + Stage.single_multi(@rest_runners, remaining_runner_to_changes_list, options) + + {[final_multi | ctb_multis], final_remaining_runner_to_changes_list} + end - {[final_multi], final_remaining_runner_to_changes_list} + defp ctb_chunk_size do + Application.get_env(:explorer, :token_balances_import_chunk_size, @default_ctb_chunk_size) + |> max(1) end end diff --git a/apps/explorer/lib/explorer/chain/internal_transaction.ex b/apps/explorer/lib/explorer/chain/internal_transaction.ex index 72ffcfc2ad88..e7a50ee5136c 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction.ex @@ -1,12 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction do @moduledoc "Models internal transactions." use Explorer.Schema - alias Explorer.{Chain, PagingOptions} - alias Explorer.Chain.{Address, Block, Data, Hash, PendingBlockOperation, Transaction, Wei} - alias Explorer.Chain.DenormalizationHelper - alias Explorer.Chain.InternalTransaction.{Action, CallType, Result, Type} + alias Explorer.{Chain, Helper, PagingOptions, QueryHelper, Repo} + + alias Explorer.Chain.{ + Address, + Block, + Data, + Hash, + PendingOperationsHelper, + Transaction, + TransactionError, + Wei + } + + alias Explorer.Chain.Block.Reader.General, as: BlockReaderGeneral + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.InternalTransaction.{CallType, Type} + alias Explorer.Migrator.DeleteZeroValueInternalTransactions + alias Explorer.Utility.AddressIdToAddressHash import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] @@ -20,6 +35,7 @@ defmodule Explorer.Chain.InternalTransaction do * `call_type` - the type of call. `nil` when `type` is not `:call`. * `created_contract_code` - the code of the contract that was created when `type` is `:create`. * `error` - error message when `:call` or `:create` `type` errors + * `error_id` - foreign key for `t:Explorer.Chain.TransactionError.t/0` * `from_address` - the source of the `value` * `from_address_hash` - hash of the source of the `value` * `gas` - the amount of gas allowed @@ -32,160 +48,144 @@ defmodule Explorer.Chain.InternalTransaction do * `to_address_hash` - hash of the sink of the `value` * `trace_address` - list of traces * `transaction` - transaction in which this internal transaction occurred - * `transaction_hash` - foreign key for `transaction` * `transaction_index` - the `t:Explorer.Chain.Transaction.t/0` `index` of `transaction` in `block_number`. * `type` - type of internal transaction * `value` - value of transferred from `from_address` to `to_address` * `block` - block in which this internal transaction occurred - * `block_hash` - foreign key for `block` - * `block_index` - the index of this internal transaction inside the `block` - * `pending_block` - `nil` if `block` has all its internal transactions fetched """ @primary_key false typed_schema "internal_transactions" do # todo: consider using enum: `field(:call_type, Ecto.Enum, values: [:call, :callcode, :delegatecall, :staticcall])` field(:call_type, CallType) + field(:call_type_enum, Ecto.Enum, values: [:call, :callcode, :delegatecall, :staticcall, :invalid]) field(:created_contract_code, Data) - field(:error, :string) + field(:error, :string, virtual: true) + field(:error_id, :integer) field(:gas, :decimal) field(:gas_used, :decimal) field(:index, :integer, primary_key: true, null: false) field(:init, Data) field(:input, Data) field(:output, Data) - field(:trace_address, {:array, :integer}, null: false) + field(:trace_address, {:array, :integer}) # todo: consider using enum field(:type, Type, null: false) - field(:value, Wei, null: false) - field(:block_number, :integer) - field(:transaction_index, :integer) - field(:block_index, :integer, null: false) + field(:value, Wei) + field(:transaction_index, :integer, primary_key: true, null: false) + + field(:transaction, :map, virtual: true) + field(:transaction_hash, Hash.Full, virtual: true) timestamps() + belongs_to(:created_contract_address_mapping, AddressIdToAddressHash, + foreign_key: :created_contract_address_id, + references: :address_id, + type: :integer + ) + + has_one(:created_contract_address, through: [:created_contract_address_mapping, :address]) + + # TODO: remove after migration to address ids is done belongs_to( - :created_contract_address, + :created_contract_address_by_hash, Address, foreign_key: :created_contract_address_hash, references: :hash, type: Hash.Address ) + belongs_to(:from_address_mapping, AddressIdToAddressHash, + foreign_key: :from_address_id, + references: :address_id, + type: :integer + ) + + has_one(:from_address, through: [:from_address_mapping, :address]) + + # TODO: remove after migration to address ids is done belongs_to( - :from_address, + :from_address_by_hash, Address, foreign_key: :from_address_hash, references: :hash, - type: Hash.Address, - null: false + type: Hash.Address + ) + + belongs_to(:to_address_mapping, AddressIdToAddressHash, + foreign_key: :to_address_id, + references: :address_id, + type: :integer ) + has_one(:to_address, through: [:to_address_mapping, :address]) + + # TODO: remove after migration to address ids is done belongs_to( - :to_address, + :to_address_by_hash, Address, foreign_key: :to_address_hash, references: :hash, type: Hash.Address ) - belongs_to(:transaction, Transaction, - foreign_key: :transaction_hash, - primary_key: true, - references: :hash, - type: Hash.Full, - null: false - ) - belongs_to(:block, Block, - foreign_key: :block_hash, - references: :hash, - type: Hash.Full, - null: false - ) - - belongs_to(:pending_block, PendingBlockOperation, - foreign_key: :block_hash, - define_field: false, - references: :block_hash, - type: Hash.Full + foreign_key: :block_number, + references: :number, + primary_key: true, + type: :integer, + where: [consensus: true] ) end @doc """ Validates that the `attrs` are valid. - `:create` type traces generated when a contract is created are valid. `created_contract_address_hash`, - `from_address_hash`, and `transaction_hash` are converted to `t:Explorer.Chain.Hash.t/0`, and `type` is converted to + `:create` type traces generated when a contract is created are valid. `type` is converted to `t:Explorer.Chain.InternalTransaction.Type.t/0` iex> changeset = Explorer.Chain.InternalTransaction.changeset( ...> %Explorer.Chain.InternalTransaction{}, ...> %{ - ...> created_contract_address_hash: "0xffc87239eb0267bc3ca2cd51d12fbf278e02ccb4", + ...> created_contract_address_id: 3, ...> created_contract_code: "0x606060405260043610610062576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630900f01014610067578063445df0ac146100a05780638da5cb5b146100c9578063fdacd5761461011e575b600080fd5b341561007257600080fd5b61009e600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610141565b005b34156100ab57600080fd5b6100b3610224565b6040518082815260200191505060405180910390f35b34156100d457600080fd5b6100dc61022a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561012957600080fd5b61013f600480803590602001909190505061024f565b005b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610220578190508073ffffffffffffffffffffffffffffffffffffffff1663fdacd5766001546040518263ffffffff167c010000000000000000000000000000000000000000000000000000000002815260040180828152602001915050600060405180830381600087803b151561020b57600080fd5b6102c65a03f1151561021c57600080fd5b5050505b5050565b60015481565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614156102ac57806001819055505b505600a165627a7a72305820a9c628775efbfbc17477a472413c01ee9b33881f550c59d21bee9928835c854b0029", - ...> from_address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + ...> from_address_id: 1, ...> gas: 4597044, ...> gas_used: 166651, ...> index: 0, ...> init: "0x6060604052341561000f57600080fd5b336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506102db8061005e6000396000f300606060405260043610610062576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630900f01014610067578063445df0ac146100a05780638da5cb5b146100c9578063fdacd5761461011e575b600080fd5b341561007257600080fd5b61009e600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610141565b005b34156100ab57600080fd5b6100b3610224565b6040518082815260200191505060405180910390f35b34156100d457600080fd5b6100dc61022a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561012957600080fd5b61013f600480803590602001909190505061024f565b005b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610220578190508073ffffffffffffffffffffffffffffffffffffffff1663fdacd5766001546040518263ffffffff167c010000000000000000000000000000000000000000000000000000000002815260040180828152602001915050600060405180830381600087803b151561020b57600080fd5b6102c65a03f1151561021c57600080fd5b5050505b5050565b60015481565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614156102ac57806001819055505b505600a165627a7a72305820a9c628775efbfbc17477a472413c01ee9b33881f550c59d21bee9928835c854b0029", ...> trace_address: [], - ...> transaction_hash: "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", + ...> transaction_index: 0, ...> type: "create", ...> value: 0, - ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0 + ...> block_number: 35 ...> } ...> ) iex> changeset.valid? true - iex> changeset.changes.created_contract_address_hash - %Explorer.Chain.Hash{ - byte_count: 20, - bytes: <<255, 200, 114, 57, 235, 2, 103, 188, 60, 162, 205, 81, 209, 47, 191, 39, 142, 2, 204, 180>> - } - iex> changeset.changes.from_address_hash - %Explorer.Chain.Hash{ - byte_count: 20, - bytes: <<232, 221, 197, 199, 162, 210, 240, 215, 169, 121, 132, 89, 192, 16, 79, 223, 94, 152, 122, 202>> - } - iex> changeset.changes.transaction_hash - %Explorer.Chain.Hash{ - byte_count: 32, - bytes: <<58, 62, 177, 52, 230, 121, 44, 233, 64, 62, 164, 24, 142, 94, 121, 105, 61, 233, 228, 201, 78, 73, 157, - 177, 50, 190, 8, 100, 0, 218, 121, 230>> - } iex> changeset.changes.type :create `:create` type can fail due to a Bad Instruction in the `init`, but these need to be valid, so we can display the - failures. `to_address_hash` is converted to `t:Explorer.Chain.Hash.t/0`. + failures. iex> changeset = Explorer.Chain.InternalTransaction.changeset( ...> %Explorer.Chain.InternalTransaction{}, ...> %{ - ...> error: "Bad instruction", - ...> from_address_hash: "0x78a42d3705fb3c26a4b54737a784bf064f0815fb", + ...> error_id: 1, + ...> from_address_id: 1, ...> gas: 3946728, ...> index: 0, ...> init: "0x4bb278f3", ...> trace_address: [], - ...> transaction_hash: "0x3c624bb4852fb5e35a8f45644cec7a486211f6ba89034768a2b763194f22f97d", ...> type: "create", ...> value: 0, ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0 ...> } iex> ) iex> changeset.valid? true - iex> changeset.changes.from_address_hash - %Explorer.Chain.Hash{ - byte_count: 20, - bytes: <<120, 164, 45, 55, 5, 251, 60, 38, 164, 181, 71, 55, 167, 132, 191, 6, 79, 8, 21, 251>> - } `:call` type traces are generated when a method in a contract is call. @@ -193,16 +193,13 @@ defmodule Explorer.Chain.InternalTransaction do ...> %Explorer.Chain.InternalTransaction{}, ...> %{ ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0, - ...> transaction_hash: "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", ...> index: 0, ...> trace_address: [], ...> call_type: "call", ...> type: "call", - ...> from_address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", - ...> to_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + ...> from_address_id: 1, + ...> to_address_id: 2, ...> gas: 4677320, ...> gas_used: 27770, ...> input: "0x", @@ -219,19 +216,16 @@ defmodule Explorer.Chain.InternalTransaction do ...> %Explorer.Chain.InternalTransaction{}, ...> %{ ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0, - ...> transaction_hash: "0xcd7c15dbbc797722bef6e1d551edfd644fc7f4fb2ccd6a7947b2d1ade9ed140b", ...> index: 0, ...> trace_address: [], ...> type: "call", ...> call_type: "call", - ...> from_address_hash: "0xc9266e6fdf5182dc47d27e0dc32bdff9e4cd2e32", - ...> to_address_hash: "0xfdca0da4158740a93693441b35809b5bb463e527", + ...> from_address_id: 1, + ...> to_address_id: 2, ...> gas: 7578728, ...> input: "0x", - ...> error: "Reverted", + ...> error_id: 1, ...> value: 10000000000000000 ...> } ...> ) @@ -246,21 +240,18 @@ defmodule Explorer.Chain.InternalTransaction do ...> %Explorer.Chain.InternalTransaction{}, ...> %{ ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0, - ...> transaction_hash: "0xcd7c15dbbc797722bef6e1d551edfd644fc7f4fb2ccd6a7947b2d1ade9ed140b", ...> index: 0, ...> trace_address: [], ...> type: "call", ...> call_type: "call", - ...> from_address_hash: "0xc9266e6fdf5182dc47d27e0dc32bdff9e4cd2e32", - ...> to_address_hash: "0xfdca0da4158740a93693441b35809b5bb463e527", + ...> from_address_id: 1, + ...> to_address_id: 2, ...> gas: 7578728, ...> gas_used: 7578727, ...> input: "0x", ...> output: "0x", - ...> error: "Reverted", + ...> error_id: 1, ...> value: 10000000000000000 ...> } ...> ) @@ -273,16 +264,13 @@ defmodule Explorer.Chain.InternalTransaction do ...> %Explorer.Chain.InternalTransaction{}, ...> %{ ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0, - ...> transaction_hash: "0xcd7c15dbbc797722bef6e1d551edfd644fc7f4fb2ccd6a7947b2d1ade9ed140b", ...> index: 0, ...> trace_address: [], ...> type: "call", ...> call_type: "call", - ...> from_address_hash: "0xc9266e6fdf5182dc47d27e0dc32bdff9e4cd2e32", - ...> to_address_hash: "0xfdca0da4158740a93693441b35809b5bb463e527", + ...> from_address_id: 1, + ...> to_address_id: 2, ...> input: "0x", ...> gas: 7578728, ...> value: 10000000000000000 @@ -296,50 +284,44 @@ defmodule Explorer.Chain.InternalTransaction do output: {"can't be blank for successful call", [validation: :required]} ] - For failed `:create`, `created_contract_code`, `created_contract_address_hash`, and `gas_used` are not allowed to be + For failed `:create`, `created_contract_code`, `created_contract_address_id`, and `gas_used` are not allowed to be set because they come from `result` object, which shouldn't be returned from Nethermind. The changeset will be fixed by `validate_create_error_or_result`, therefore the changeset is still valid. iex> changeset = Explorer.Chain.InternalTransaction.changeset( ...> %Explorer.Chain.InternalTransaction{}, ...> %{ - ...> created_contract_address_hash: "0xffc87239eb0267bc3ca2cd51d12fbf278e02ccb4", + ...> created_contract_address_id: 3, ...> created_contract_code: "0x606060405260043610610062576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630900f01014610067578063445df0ac146100a05780638da5cb5b146100c9578063fdacd5761461011e575b600080fd5b341561007257600080fd5b61009e600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610141565b005b34156100ab57600080fd5b6100b3610224565b6040518082815260200191505060405180910390f35b34156100d457600080fd5b6100dc61022a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561012957600080fd5b61013f600480803590602001909190505061024f565b005b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610220578190508073ffffffffffffffffffffffffffffffffffffffff1663fdacd5766001546040518263ffffffff167c010000000000000000000000000000000000000000000000000000000002815260040180828152602001915050600060405180830381600087803b151561020b57600080fd5b6102c65a03f1151561021c57600080fd5b5050505b5050565b60015481565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614156102ac57806001819055505b505600a165627a7a72305820a9c628775efbfbc17477a472413c01ee9b33881f550c59d21bee9928835c854b0029", - ...> error: "Bad instruction", - ...> from_address_hash: "0x78a42d3705fb3c26a4b54737a784bf064f0815fb", + ...> error_id: 1, + ...> from_address_id: 1, ...> gas: 3946728, ...> gas_used: 166651, ...> index: 0, ...> init: "0x4bb278f3", ...> trace_address: [], - ...> transaction_hash: "0x3c624bb4852fb5e35a8f45644cec7a486211f6ba89034768a2b763194f22f97d", ...> type: "create", ...> value: 0, ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0 ...> } iex> ) iex> changeset.valid? true - For successful `:create`, `created_contract_code`, `created_contract_address_hash`, and `gas_used` are required. + For successful `:create`, `created_contract_code`, `created_contract_address_id`, and `gas_used` are required. iex> changeset = Explorer.Chain.InternalTransaction.changeset( ...> %Explorer.Chain.InternalTransaction{}, ...> %{ - ...> from_address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + ...> from_address_id: 1, ...> gas: 4597044, ...> index: 0, ...> init: "0x6060604052341561000f57600080fd5b336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506102db8061005e6000396000f300606060405260043610610062576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680630900f01014610067578063445df0ac146100a05780638da5cb5b146100c9578063fdacd5761461011e575b600080fd5b341561007257600080fd5b61009e600480803573ffffffffffffffffffffffffffffffffffffffff16906020019091905050610141565b005b34156100ab57600080fd5b6100b3610224565b6040518082815260200191505060405180910390f35b34156100d457600080fd5b6100dc61022a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561012957600080fd5b61013f600480803590602001909190505061024f565b005b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415610220578190508073ffffffffffffffffffffffffffffffffffffffff1663fdacd5766001546040518263ffffffff167c010000000000000000000000000000000000000000000000000000000002815260040180828152602001915050600060405180830381600087803b151561020b57600080fd5b6102c65a03f1151561021c57600080fd5b5050505b5050565b60015481565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614156102ac57806001819055505b505600a165627a7a72305820a9c628775efbfbc17477a472413c01ee9b33881f550c59d21bee9928835c854b0029", ...> trace_address: [], - ...> transaction_hash: "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", ...> type: "create", ...> value: 0, ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0 ...> } ...> ) @@ -348,7 +330,7 @@ defmodule Explorer.Chain.InternalTransaction do iex> changeset.errors [ created_contract_code: {"can't be blank for successful create", [validation: :required]}, - created_contract_address_hash: {"can't be blank for successful create", [validation: :required]}, + created_contract_address_id: {"can't be blank for successful create", [validation: :required]}, gas_used: {"can't be blank for successful create", [validation: :required]} ] @@ -357,16 +339,13 @@ defmodule Explorer.Chain.InternalTransaction do iex> changeset = Explorer.Chain.InternalTransaction.changeset( ...> %Explorer.Chain.InternalTransaction{}, ...> %{ - ...> from_address_hash: "0xa7542d78b9a0be6147536887e0065f16182d294b", + ...> from_address_id: 1, ...> index: 1, - ...> to_address_hash: "0x59e2e9ecf133649b1a7efc731162ff09d29ca5a5", + ...> to_address_id: 2, ...> trace_address: [0], - ...> transaction_hash: "0xb012b8c53498c669d87d85ed90f57385848b86d3f44ed14b2784ec685d6fda98", ...> type: "selfdestruct", ...> value: 0, ...> block_number: 35, - ...> block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - ...> block_index: 0, ...> transaction_index: 0 ...> } ...> ) @@ -375,22 +354,23 @@ defmodule Explorer.Chain.InternalTransaction do """ def changeset(%__MODULE__{} = internal_transaction, attrs \\ %{}) do + base_attributes = ~w(block_number transaction_index index type)a + internal_transaction - |> cast(attrs, ~w(type)a) - |> validate_required(~w(type)a) - |> validate_block_required(attrs) + |> cast(attrs, base_attributes) + |> validate_required(base_attributes) |> type_changeset(attrs) end @doc """ Accepts changes without `:type` but with `:block_number`, if `:type` is defined - works like `changeset`, except allowing `:block_hash` and `:block_index` to be undefined. + works like `changeset`, except allowing `:block_number` to be undefined. This is used because the `internal_transactions` runner can derive such values on its own or use empty types to know that a block has no internal transactions. """ def blockless_changeset(%__MODULE__{} = internal_transaction, attrs \\ %{}) do - changeset = cast(internal_transaction, attrs, ~w(type block_number)a) + changeset = cast(internal_transaction, attrs, ~w(type block_number transaction_index index)a) if validate_required(changeset, ~w(type)a).valid? do type_changeset(changeset, attrs) @@ -399,37 +379,31 @@ defmodule Explorer.Chain.InternalTransaction do end end - defp validate_block_required(changeset, attrs) do - changeset - |> cast(attrs, ~w(block_hash block_index)a) - |> validate_required(~w(block_hash block_index)a) - |> foreign_key_constraint(:block_hash) - end - defp type_changeset(changeset, attrs) do type = get_field(changeset, :type) type_changeset(changeset, attrs, type) end - @call_optional_fields ~w(error gas_used output block_number transaction_index)a - @call_required_fields ~w(call_type from_address_hash gas index input to_address_hash trace_address transaction_hash value)a + @call_optional_fields ~w(error_id gas_used output value)a + @call_required_fields ~w(block_number call_type_enum from_address_id gas input to_address_id)a @call_allowed_fields @call_optional_fields ++ @call_required_fields defp type_changeset(changeset, attrs, :call) do changeset - |> cast(attrs, @call_allowed_fields) + |> cast(adjust_call_type(attrs), @call_allowed_fields) |> validate_required(@call_required_fields) # TODO consider removing |> validate_call_error_or_result() - |> check_constraint(:call_type, message: ~S|can't be blank when type is 'call'|, name: :call_has_call_type) + |> check_constraint(:call_type_enum, + message: ~S|can't be blank when type is 'call'|, + name: :call_has_call_type_enum + ) |> check_constraint(:input, message: ~S|can't be blank when type is 'call'|, name: :call_has_input) - |> foreign_key_constraint(:transaction_hash) - |> unique_constraint(:index) end - @create_optional_fields ~w(error created_contract_code created_contract_address_hash gas_used block_number transaction_index)a - @create_required_fields ~w(from_address_hash gas index init trace_address transaction_hash value)a + @create_optional_fields ~w(error_id created_contract_code created_contract_address_id gas_used value)a + @create_required_fields ~w(block_number from_address_id gas init)a @create_allowed_fields @create_optional_fields ++ @create_required_fields defp type_changeset(changeset, attrs, type) when type in [:create, :create2] do @@ -439,34 +413,40 @@ defmodule Explorer.Chain.InternalTransaction do # TODO consider removing |> validate_create_error_or_result() |> check_constraint(:init, message: ~S|can't be blank when type is 'create'|, name: :create_has_init) - |> foreign_key_constraint(:transaction_hash) - |> unique_constraint(:index) end - @selfdestruct_optional_fields ~w(block_number transaction_index)a - @selfdestruct_required_fields ~w(from_address_hash index to_address_hash trace_address transaction_hash type value)a + @selfdestruct_optional_fields ~w(value)a + @selfdestruct_required_fields ~w(block_number from_address_id to_address_id type)a @selfdestruct_allowed_fields @selfdestruct_optional_fields ++ @selfdestruct_required_fields defp type_changeset(changeset, attrs, :selfdestruct) do changeset |> cast(attrs, @selfdestruct_allowed_fields) |> validate_required(@selfdestruct_required_fields) - |> unique_constraint(:index) end - @stop_optional_fields ~w(from_address_hash gas gas_used error)a - @stop_required_fields ~w(block_number transaction_hash transaction_index index type value trace_address)a + @stop_optional_fields ~w(from_address_id gas gas_used error_id value)a + @stop_required_fields ~w(block_number type)a @stop_allowed_fields @stop_optional_fields ++ @stop_required_fields defp type_changeset(changeset, attrs, :stop) do changeset |> cast(attrs, @stop_allowed_fields) |> validate_required(@stop_required_fields) - |> unique_constraint(:index) end defp type_changeset(changeset, _, nil), do: changeset + defp adjust_call_type(%{call_type_enum: _} = attrs), do: attrs + + defp adjust_call_type(%{call_type: call_type} = attrs) when not is_nil(call_type) do + attrs + |> Map.delete(:call_type) + |> Map.put(:call_type_enum, call_type) + end + + defp adjust_call_type(attrs), do: attrs + defp validate_disallowed(changeset, field, named_arguments) when is_atom(field) do case get_field(changeset, field) do nil -> changeset @@ -482,7 +462,7 @@ defmodule Explorer.Chain.InternalTransaction do # Validates that :call `type` changeset either has an `error` or both `gas_used` and `output` defp validate_call_error_or_result(changeset) do - case get_field(changeset, :error) do + case get_field(changeset, :error_id) do nil -> validate_required(changeset, [:gas_used, :output], message: "can't be blank for successful call") @@ -494,19 +474,19 @@ defmodule Explorer.Chain.InternalTransaction do end end - @create_success_fields ~w(created_contract_code created_contract_address_hash gas_used)a + @create_success_fields ~w(created_contract_code created_contract_address_id gas_used)a - # Validates that :create `type` changeset either has an `:error` or both `:created_contract_code` and - # `:created_contract_address_hash` + # Validates that :create `type` changeset either has an `:error_id` or both `:created_contract_code` and + # `:created_contract_address_id` defp validate_create_error_or_result(changeset) do - case get_field(changeset, :error) do + case get_field(changeset, :error_id) do nil -> validate_required(changeset, @create_success_fields, message: "can't be blank for successful create") _ -> changeset |> delete_change(:created_contract_code) - |> delete_change(:created_contract_address_hash) + |> delete_change(:created_contract_address_id) |> delete_change(:gas_used) |> validate_disallowed(@create_success_fields, message: "can't be present for failed create") end @@ -528,183 +508,195 @@ defmodule Explorer.Chain.InternalTransaction do - returns a query considering that the given address_hash can be: to_address_hash, from_address_hash, created_contract_address_hash from internal_transactions' table. """ - def where_address_fields_match(query, address_hash, :to) do - where( - query, - [t], - t.to_address_hash == ^address_hash or - (is_nil(t.to_address_hash) and t.created_contract_address_hash == ^address_hash) - ) - end + def where_address_fields_match(query, address_hash, direction) do + address_id = AddressIdToAddressHash.hash_to_id(address_hash) + + case direction do + :to -> + if BackgroundMigrations.get_empty_internal_transactions_data_finished() do + where_address_match(query, :to_address, address_hash, address_id) + else + where( + query, + [it], + ^to_direction_match_dynamic(address_hash, address_id) + ) + end - def where_address_fields_match(query, address_hash, :from) do - where(query, [t], t.from_address_hash == ^address_hash) - end + :from -> + where_address_match(query, :from_address, address_hash, address_id) - def where_address_fields_match(query, address_hash, :to_address_hash) do - where(query, [it], it.to_address_hash == ^address_hash) - end + :to_address_hash -> + if BackgroundMigrations.get_empty_internal_transactions_data_finished() do + where( + query, + [it], + ^to_address_hash_match_dynamic(address_hash, address_id) + ) + else + where_address_match(query, :to_address, address_hash, address_id) + end + + :from_address_hash -> + where_address_match(query, :from_address, address_hash, address_id) + + :created_contract_address_hash -> + where_address_match(query, :created_contract_address, address_hash, address_id) - def where_address_fields_match(query, address_hash, :from_address_hash) do - where(query, [it], it.from_address_hash == ^address_hash) + _ -> + where( + query, + [it], + ^all_address_fields_match_dynamic(address_hash, address_id) + ) + end end - def where_address_fields_match(query, address_hash, :created_contract_address_hash) do - where(query, [it], it.created_contract_address_hash == ^address_hash) + @doc """ + Adds an address filter for the given internal transaction address field. + + The function accepts either a single address hash or a list of address hashes. + It resolves corresponding address IDs through `Explorer.Chain.AddressIdToAddressHash` + and builds a `where` clause that matches: + + * the migrated `*_address_id` field when mapping entries exist + * the legacy `*_address_hash` field as a fallback for partially migrated rows + + This helper is intended for filtering internal transactions by one of the + logical address roles, such as `:from_address`, `:to_address`, or + `:created_contract_address`. + + ## Parameters + + * `query` - the base query to extend + * `address_field` - the logical internal transaction address field + * `address_hash_or_hashes` - a single address hash or a list of address hashes + + ## Returns + + An `Ecto.Query.t/0` with the address filter applied. + """ + @spec where_address_match( + Ecto.Query.t() | module(), + :from_address | :to_address | :created_contract_address, + Hash.Address.t() | [Hash.Address.t()] + ) :: Ecto.Query.t() + def where_address_match(query, address_field, address_hash_or_hashes) do + address_hashes = List.wrap(address_hash_or_hashes) + address_ids = AddressIdToAddressHash.hashes_to_ids(address_hashes) + + where_address_match(query, address_field, address_hashes, address_ids) end - def where_address_fields_match(query, address_hash, _) do - base_address_where(query, address_hash) + @spec where_address_match( + Ecto.Query.t() | module(), + :from_address | :to_address | :created_contract_address, + Hash.Address.t() | [Hash.Address.t()], + integer() | [integer()] | nil + ) :: Ecto.Query.t() + def where_address_match(query, address_field, address_hash_or_hashes, address_id_or_ids) do + address_hashes = List.wrap(address_hash_or_hashes) + address_ids = List.wrap(address_id_or_ids) + + where(query, [it], ^address_match_dynamic(address_field, address_hashes, address_ids)) end - defp base_address_where(query, address_hash) do - where( - query, + defp to_direction_match_dynamic(address_hash, address_id) do + to_match = address_match_dynamic(:to_address, address_hash, address_id) + created_contract_match = address_match_dynamic(:created_contract_address, address_hash, address_id) + + dynamic( [it], - it.to_address_hash == ^address_hash or it.from_address_hash == ^address_hash or - it.created_contract_address_hash == ^address_hash + (^to_match and is_nil(it.created_contract_address_hash) and is_nil(it.created_contract_address_id)) or + (is_nil(it.to_address_hash) and is_nil(it.to_address_id) and ^created_contract_match) ) end - def where_is_different_from_parent_transaction(query) do - where( - query, + defp to_address_hash_match_dynamic(address_hash, address_id) do + to_match = address_match_dynamic(:to_address, address_hash, address_id) + + dynamic( [it], - (it.type == ^:call and it.index > 0) or it.type != ^:call + ^to_match and is_nil(it.created_contract_address_hash) and is_nil(it.created_contract_address_id) ) end - def where_block_number_is_not_null(query) do - where(query, [t], not is_nil(t.block_number)) - end + defp all_address_fields_match_dynamic(address_hash, address_id) do + to_match = address_match_dynamic(:to_address, address_hash, address_id) + from_match = address_match_dynamic(:from_address, address_hash, address_id) + created_contract_match = address_match_dynamic(:created_contract_address, address_hash, address_id) - @doc """ - Filters out internal_transactions of blocks that are flagged as needing fetching - of internal_transactions - """ - def where_nonpending_block(query \\ nil) do - (query || __MODULE__) - |> where( + dynamic( [it], - fragment("(SELECT block_hash FROM pending_block_operations WHERE block_hash = ? LIMIT 1) IS NULL", it.block_hash) + ^to_match or ^from_match or ^created_contract_match ) end - def internal_transactions_to_raw(internal_transactions) when is_list(internal_transactions) do - internal_transactions - |> Enum.map(&internal_transaction_to_raw/1) - |> add_subtraces() - end - - defp internal_transaction_to_raw(%{type: :call} = transaction) do - %{ - call_type: call_type, - to_address_hash: to_address_hash, - from_address_hash: from_address_hash, - input: input, - gas: gas, - value: value, - trace_address: trace_address - } = transaction - - action = %{ - "callType" => call_type, - "to" => to_address_hash, - "from" => from_address_hash, - "input" => input, - "gas" => gas, - "value" => value - } - - %{ - "type" => "call", - "action" => Action.to_raw(action), - "traceAddress" => trace_address - } - |> put_raw_call_error_or_result(transaction) - end - - defp internal_transaction_to_raw(%{type: type} = transaction) when type in [:create, :create2] do - %{ - from_address_hash: from_address_hash, - gas: gas, - init: init, - trace_address: trace_address, - value: value - } = transaction - - action = %{"from" => from_address_hash, "gas" => gas, "init" => init, "value" => value} - - %{ - "type" => Atom.to_string(type), - "action" => Action.to_raw(action), - "traceAddress" => trace_address - } - |> put_raw_create_error_or_result(transaction) - end - - defp internal_transaction_to_raw(%{type: :selfdestruct} = transaction) do - %{ - to_address_hash: to_address_hash, - from_address_hash: from_address_hash, - trace_address: trace_address, - value: value - } = transaction - - action = %{ - "address" => from_address_hash, - "balance" => value, - "refundAddress" => to_address_hash - } - - %{ - "type" => "suicide", - "action" => Action.to_raw(action), - "traceAddress" => trace_address - } - end - - defp add_subtraces(traces) do - Enum.map(traces, fn trace -> - Map.put(trace, "subtraces", count_subtraces(trace, traces)) - end) - end + defp address_match_dynamic(address_field, address_hash_or_hashes, address_id_or_ids) do + address_hashes = List.wrap(address_hash_or_hashes) + address_hash_field = String.to_existing_atom("#{address_field}_hash") + address_ids = List.wrap(address_id_or_ids) + address_id_field = String.to_existing_atom("#{address_field}_id") - defp count_subtraces(%{"traceAddress" => trace_address}, traces) do - Enum.count(traces, fn %{"traceAddress" => trace_address_candidate} -> - direct_descendant?(trace_address, trace_address_candidate) - end) - end + cond do + not address_ids_indexes_exists?() -> + adjust_address_match_dynamic(address_hash_field, address_hashes) - defp direct_descendant?([], [_]), do: true + address_ids_filled?() -> + adjust_address_match_dynamic(address_id_field, address_ids) - defp direct_descendant?([elem | remaining_left], [elem | remaining_right]), - do: direct_descendant?(remaining_left, remaining_right) + true -> + dynamic( + [it], + field(it, ^address_id_field) in ^address_ids or field(it, ^address_hash_field) in ^address_hashes + ) + end + end - defp direct_descendant?(_, _), do: false + defp adjust_address_match_dynamic(field, [value]), do: dynamic([it], field(it, ^field) == ^value) + defp adjust_address_match_dynamic(field, values), do: dynamic([it], field(it, ^field) in ^values) - defp put_raw_call_error_or_result(raw, %{error: error}) when not is_nil(error) do - Map.put(raw, "error", error) + defp address_ids_indexes_exists? do + BackgroundMigrations.get_heavy_indexes_create_address_ids_internal_transactions_indexes_finished() end - defp put_raw_call_error_or_result(raw, %{gas_used: gas_used, output: output}) do - Map.put(raw, "result", Result.to_raw(%{"gasUsed" => gas_used, "output" => output})) + defp address_ids_filled? do + BackgroundMigrations.get_fill_internal_transactions_address_ids_finished() end - defp put_raw_create_error_or_result(raw, %{error: error}) when not is_nil(error) do - Map.put(raw, "error", error) + def where_is_different_from_parent_transaction(query) do + where( + query, + [it], + (it.type == ^:call and it.index > 0) or it.type != ^:call + ) end - defp put_raw_create_error_or_result(raw, %{ - created_contract_code: code, - created_contract_address_hash: created_contract_address_hash, - gas_used: gas_used - }) do - Map.put( - raw, - "result", - Result.to_raw(%{"gasUsed" => gas_used, "code" => code, "address" => created_contract_address_hash}) - ) + @doc """ + Filters out internal_transactions of blocks or transactions that are flagged as needing fetching + of internal_transactions + """ + @spec where_nonpending_operation(Ecto.Query.t() | module()) :: Ecto.Query.t() + def where_nonpending_operation(query \\ __MODULE__) do + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> + where( + query, + [it], + fragment("(SELECT 1 FROM pending_block_operations WHERE block_number = ? LIMIT 1) IS NULL", it.block_number) + ) + + "transactions" -> + query + |> join_transaction_query() + |> where( + [transaction: t], + fragment( + "(SELECT 1 FROM pending_transaction_operations WHERE transaction_hash = ? LIMIT 1) IS NULL", + t.hash + ) + ) + end end @doc """ @@ -720,7 +712,6 @@ defmodule Explorer.Chain.InternalTransaction do the `index` that is passed. """ - @spec all_transaction_to_internal_transactions(Hash.Full.t(), [ Chain.paging_options() | Chain.necessity_by_association_option() | Chain.api?() ]) :: [ @@ -733,11 +724,14 @@ defmodule Explorer.Chain.InternalTransaction do __MODULE__ |> for_parent_transaction(hash) |> Chain.join_associations(necessity_by_association) - |> where_nonpending_block() - |> Chain.page_internal_transaction(paging_options) + |> where_nonpending_operation() + |> page_internal_transaction(paging_options) |> limit(^paging_options.page_size) |> order_by([internal_transaction], asc: internal_transaction.index) |> Chain.select_repo(options).all() + |> preload_error(options) + |> preload_transaction() + |> preload_addresses(options) end @spec transaction_to_internal_transactions(Hash.Full.t(), [ @@ -753,50 +747,446 @@ defmodule Explorer.Chain.InternalTransaction do __MODULE__ |> for_parent_transaction(hash) |> Chain.join_associations(necessity_by_association) - |> where_transaction_has_multiple_internal_transactions() |> where_is_different_from_parent_transaction() - |> where_nonpending_block() - |> Chain.page_internal_transaction(paging_options) + |> where_nonpending_operation() + |> page_internal_transaction(paging_options) |> limit(^paging_options.page_size) |> order_by([internal_transaction], asc: internal_transaction.index) |> preload(:block) |> Chain.select_repo(options).all() + |> preload_error(options) + |> preload_transaction() + |> preload_addresses(options) end - @spec block_to_internal_transactions(Hash.Full.t(), [ + @spec block_to_internal_transactions(non_neg_integer(), [ Chain.paging_options() | Chain.necessity_by_association_option() | Chain.api?() ]) :: [ __MODULE__.t() ] - def block_to_internal_transactions(hash, options \\ []) when is_list(options) do + def block_to_internal_transactions(block_number, options \\ []) when is_list(options) do necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) paging_options = Keyword.get(options, :paging_options, @default_paging_options) - type_filter = Keyword.get(options, :type) - call_type_filter = Keyword.get(options, :call_type) + type_filter = Keyword.get(options, :type, []) + call_type_filter = Keyword.get(options, :call_type, []) __MODULE__ - |> where([internal_transaction], internal_transaction.block_hash == ^hash) + |> where([internal_transaction], internal_transaction.block_number == ^block_number) |> Chain.join_associations(necessity_by_association) |> where_is_different_from_parent_transaction() - |> where_nonpending_block() + |> where_nonpending_operation() |> page_block_internal_transaction(paging_options) |> filter_by_type(type_filter, call_type_filter) |> filter_by_call_type(call_type_filter) |> limit(^paging_options.page_size) - |> order_by([internal_transaction], asc: internal_transaction.block_index) + |> order_by([internal_transaction], asc: internal_transaction.transaction_index, asc: internal_transaction.index) |> Chain.select_repo(options).all() + |> preload_error(options) + |> preload_transaction() + |> preload_addresses(options) end - defp for_parent_transaction(query, %Hash{byte_count: unquote(Hash.Full.byte_count())} = hash) do - from( - child in query, - inner_join: transaction in assoc(child, :transaction), - where: transaction.hash == ^hash, - where: child.block_hash == transaction.block_hash + @doc """ + `t:Explorer.Chain.InternalTransaction/0`s from the address with the given `hash`. + + This function excludes any internal transactions in the results where the + internal transaction has no siblings within the parent transaction. + + ## Options + + * `:direction` - if specified, will filter internal transactions by address type. If `:to` is specified, only + internal transactions where the "to" address matches will be returned. Likewise, if `:from` is specified, only + internal transactions where the "from" address matches will be returned. If `:direction` is omitted, internal + transactions either to or from the address will be returned. + * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is + `:required`, and the `t:Explorer.Chain.InternalTransaction.t/0` has no associated record for that association, + then the `t:Explorer.Chain.InternalTransaction.t/0` will not be included in the page `entries`. + * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` and + `:key` (a tuple of the lowest/oldest `{block_number, transaction_index, index}`) and. Results will be the internal + transactions older than the `block_number`, `transaction index`, and `index` that are passed. + + """ + @spec address_to_internal_transactions(Hash.Address.t(), [paging_options | Chain.necessity_by_association_option()]) :: + [ + __MODULE__.t() + ] + def address_to_internal_transactions(hash, options \\ []) do + case Keyword.get(options, :paging_options, @default_paging_options) do + %PagingOptions{key: {0, 0, 0}} -> [] + _ -> fetch_from_db_by_address(hash, options) + end + end + + @doc """ + Deduplicates and trims internal transactions based on the page_size specified in paging options. + """ + @spec deduplicate_and_trim_internal_transactions([__MODULE__.t()], PagingOptions.t()) :: [__MODULE__.t()] + def deduplicate_and_trim_internal_transactions(internal_transactions, paging_options) do + internal_transactions + |> Enum.uniq_by(fn internal_transaction -> + {internal_transaction.block_number, internal_transaction.transaction_index, internal_transaction.index} + end) + |> Enum.take(paging_options.page_size) + end + + @doc """ + Determines whether internal transactions for the given block number are present in the database. + + When the DeleteZeroValueInternalTransactions migration is enabled, internal + transactions for blocks older than the border_number may have been removed + from the database. This function returns false for those old blocks, + indicating that the data must be fetched on-demand from RPC instead. + + ## Parameters + - `block_number`: The block number to check, or nil for pending transactions + + ## Returns + - `true` if internal transactions are present in the database + - `false` if internal transactions have been deleted and must be fetched on-demand + """ + @spec present_in_db?(non_neg_integer() | nil) :: boolean() + def present_in_db?(nil), do: true + + def present_in_db?(block_number) do + if Application.get_env(:explorer, DeleteZeroValueInternalTransactions)[:enabled] do + border_number = DeleteZeroValueInternalTransactions.border_number() + is_nil(border_number) or border_number < block_number + else + true + end + end + + @doc """ + Fetches internal transactions from the database for the given address hash with specified options. + + When direction is nil or empty, the function performs a union query across + all three address roles (to_address, from_address, created_contract_address) + and deduplicates the results. When direction is specified, it performs a + single query filtering by the specified address field. + + ## Parameters + - `hash`: The address hash to query internal transactions for + - `options`: Keyword list with the following keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:direction` - filter by address role (:to, :from, :to_address_hash, :from_address_hash, :created_contract_address_hash, or nil for all) + - `:from_block` - starting block number for the query range + - `:to_block` - ending block number for the query range + - `:paging_options` - pagination options including page_size and key + + ## Returns + - List of InternalTransaction structs matching the query, with deduplication applied when direction is nil + """ + @spec fetch_from_db_by_address(Hash.Address.t(), Keyword.t()) :: [__MODULE__.t()] + def fetch_from_db_by_address(hash, options) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + direction = Keyword.get(options, :direction) + timeout = Keyword.get(options, :timeout) + + from_block = Chain.from_block(options) + to_block = Chain.to_block(options) + + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + if direction == nil || direction == "" do + query_to_address_hash_wrapped = + __MODULE__ + |> where_nonpending_operation() + |> where_address_fields_match(hash, :to) + |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) + |> where_is_different_from_parent_transaction() + |> common_where_limit_order(paging_options) + |> Chain.wrapped_union_subquery() + + query_from_address_hash_wrapped = + __MODULE__ + |> where_nonpending_operation() + |> where_address_fields_match(hash, :from_address_hash) + |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) + |> where_is_different_from_parent_transaction() + |> common_where_limit_order(paging_options) + |> Chain.wrapped_union_subquery() + + query_to_address_hash_wrapped + |> union_all(^query_from_address_hash_wrapped) + |> Chain.wrapped_union_subquery() + |> common_where_and_order(paging_options) + |> preload(:block) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all(Helper.maybe_timeout(timeout)) + |> deduplicate_and_trim_internal_transactions(paging_options) + |> preload_error(options) + |> preload_transaction() + |> preload_addresses(options) + else + __MODULE__ + |> where_nonpending_operation() + |> where_address_fields_match(hash, direction) + |> BlockReaderGeneral.where_block_number_in_period(from_block, to_block) + |> where_is_different_from_parent_transaction() + |> common_where_limit_order(paging_options) + |> preload(:block) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all(Helper.maybe_timeout(timeout)) + |> preload_error(options) + |> preload_transaction() + |> preload_addresses(options) + end + end + + @doc """ + Joins the parent transaction to an internal transaction query by + `block_number` and `transaction_index`. + + The join uses an inner join against `Explorer.Chain.Transaction` and matches + each internal transaction with its parent transaction where: + - `internal_transactions.block_number == transactions.block_number` + - `internal_transactions.transaction_index == transactions.index` + - `transactions.block_consensus == true` + + The joined binding is added under the named binding `:transaction`. If the + query already contains that named binding, it is reused and no duplicate join + is added. + + ## Parameters + - `query`: The Ecto query to join the parent transaction into + + ## Returns + - Query with named `:transaction` binding available for further filtering, + selection, or preloading + """ + @spec join_transaction_query(Ecto.Query.t() | module()) :: Ecto.Query.t() + def join_transaction_query(query) do + with_named_binding(query, :transaction, fn query, binding -> + join(query, :inner, [it], t in Transaction, + on: it.block_number == t.block_number and it.transaction_index == t.index and t.block_consensus == true, + as: ^binding + ) + end) + end + + @doc """ + Joins an internal transaction query with the address mapping and resolved + address for the given address field. + + This helper supports both migrated and partially migrated data: + + - it first left joins the `AddressIdToAddressHash` mapping using the + corresponding `*_address_id` field + - it then left joins `Explorer.Chain.Address` using the mapped hash + - if no mapping is found, it falls back to joining by the legacy + `*_address_hash` field + + This allows downstream query code to reference the joined address through + `as/1`, for example `as(:created_contract_address).hash`. + + ## Parameters + + - `query`: The base query to extend + - `address_field`: The logical address field to join. Expected values + include `:from_address`, `:to_address`, or `:created_contract_address` + + ## Returns + + An `Ecto.Query.t/0` with the mapping and address joins added. + """ + @spec join_address_query(Ecto.Query.t() | module(), :from_address | :to_address | :created_contract_address, atom()) :: + Ecto.Query.t() + def join_address_query(query, address_field, join_type \\ :left) do + mapping_binding = String.to_existing_atom("#{address_field}_mapping") + address_binding = address_field + address_id_field = String.to_existing_atom("#{address_field}_id") + address_hash_field = String.to_existing_atom("#{address_field}_hash") + + query + |> with_named_binding(mapping_binding, fn query, binding -> + join(query, join_type, [it], m in AddressIdToAddressHash, + as: ^binding, + on: field(it, ^address_id_field) == m.address_id + ) + end) + |> with_named_binding(address_binding, fn query, binding -> + join(query, join_type, [it], a in Address, + as: ^binding, + on: + a.hash == as(^mapping_binding).address_hash or + (is_nil(as(^mapping_binding).address_hash) and a.hash == field(it, ^address_hash_field)) + ) + end) + end + + @doc """ + Joins an internal transaction query with the address mapping table for the + given address field. + + The helper joins `Explorer.Chain.AddressIdToAddressHash` using the + corresponding `*_address_id` field and exposes the join under the named + binding `:"address_field_mapping"`. + + This is useful when callers need access to the resolved mapping row, while the + main address filtering logic can stay on `internal_transactions.*_address_id` + with a fallback to the legacy `*_address_hash` fields. + + ## Parameters + + - `query`: The base query to extend + - `address_field`: The logical address field to join. Expected values + include `:from_address`, `:to_address`, or `:created_contract_address` + - `join_type`: Ecto join type, defaults to `:left` + + ## Returns + + An `Ecto.Query.t/0` with the mapping join added. + """ + @spec join_address_mapping_query( + Ecto.Query.t() | module(), + :from_address | :to_address | :created_contract_address, + atom() + ) :: + Ecto.Query.t() + def join_address_mapping_query(query, address_field, join_type \\ :left) do + mapping_binding = String.to_existing_atom("#{address_field}_mapping") + address_id_field = String.to_existing_atom("#{address_field}_id") + + with_named_binding(query, mapping_binding, fn query, binding -> + join(query, join_type, [it], m in AddressIdToAddressHash, + as: ^binding, + on: field(it, ^address_id_field) == m.address_id + ) + end) + end + + defp common_where_limit_order(query, paging_options) do + query + |> common_where_and_order(paging_options) + |> limit(^paging_options.page_size) + end + + defp common_where_and_order(query, paging_options) do + query + |> page_internal_transaction(paging_options, %{index_internal_transaction_desc_order: true}) + |> order_by( + [it], + desc: it.block_number, + desc: it.transaction_index, + desc: it.index ) end + defp page_internal_transaction(_, _, _ \\ %{index_internal_transaction_desc_order: false}) + + defp page_internal_transaction(query, %PagingOptions{key: nil}, _), do: query + + defp page_internal_transaction(query, %PagingOptions{key: {block_number, transaction_index, index}}, %{ + index_internal_transaction_desc_order: desc_order + }) do + hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, index, desc_order) + end + + defp page_internal_transaction(query, %PagingOptions{key: {0}}, %{index_internal_transaction_desc_order: desc_order}) do + if desc_order do + query + else + where(query, [internal_transaction], internal_transaction.index > 0) + end + end + + defp page_internal_transaction(query, %PagingOptions{key: {index}}, %{ + index_internal_transaction_desc_order: desc_order + }) do + if desc_order do + where(query, [internal_transaction], internal_transaction.index < ^index) + else + where(query, [internal_transaction], internal_transaction.index > ^index) + end + end + + defp hardcoded_where_for_page_internal_transaction(query, 0, 0, index, false), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number == 0 and + internal_transaction.transaction_index == 0 and internal_transaction.index > ^index + ) + + defp hardcoded_where_for_page_internal_transaction(query, block_number, 0, index, false), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number < ^block_number or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index == 0 and internal_transaction.index > ^index) + ) + + defp hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, index, false), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number < ^block_number or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index < ^transaction_index) or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index == ^transaction_index and internal_transaction.index > ^index) + ) + + defp hardcoded_where_for_page_internal_transaction(query, 0, 0, index, true), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number == 0 and + internal_transaction.transaction_index == 0 and internal_transaction.index < ^index + ) + + defp hardcoded_where_for_page_internal_transaction(query, block_number, 0, 0, true), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number < ^block_number + ) + + defp hardcoded_where_for_page_internal_transaction(query, block_number, 0, index, true), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number < ^block_number or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index == 0 and internal_transaction.index < ^index) + ) + + defp hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, 0, true), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number < ^block_number or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index < ^transaction_index) + ) + + defp hardcoded_where_for_page_internal_transaction(query, block_number, transaction_index, index, true), + do: + where( + query, + [internal_transaction], + internal_transaction.block_number < ^block_number or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index < ^transaction_index) or + (internal_transaction.block_number == ^block_number and + internal_transaction.transaction_index == ^transaction_index and internal_transaction.index < ^index) + ) + + defp for_parent_transaction(query, %Hash{byte_count: unquote(Hash.Full.byte_count())} = hash) do + query + |> join_transaction_query() + |> where(as(:transaction).hash == ^hash) + end + # filter by `type` is automatically ignored if `call_type_filter` is not empty, # as applying both filter simultaneously have no sense defp filter_by_type(query, _, [_ | _]), do: query @@ -811,59 +1201,44 @@ defmodule Explorer.Chain.InternalTransaction do defp filter_by_call_type(query, call_types) do query - |> where([internal_transaction], internal_transaction.call_type in ^call_types) - end - - @doc """ - Ensures the following conditions are true: - - * excludes internal transactions of type call with no siblings in the - transaction - * includes internal transactions of type create, reward, or selfdestruct - even when they are alone in the parent transaction - - """ - @spec where_transaction_has_multiple_internal_transactions(Ecto.Query.t()) :: Ecto.Query.t() - def where_transaction_has_multiple_internal_transactions(query) do - where( - query, - [internal_transaction, transaction], - internal_transaction.type != ^:call or - fragment( - """ - EXISTS (SELECT sibling.* - FROM internal_transactions AS sibling - WHERE sibling.transaction_hash = ? AND sibling.index != ? - ) - """, - transaction.hash, - internal_transaction.index - ) + |> where( + [internal_transaction], + internal_transaction.call_type_enum in ^call_types or internal_transaction.call_type in ^call_types ) end @doc """ Returns the ordered paginated list of internal transactions (consensus blocks only) from the DB with address, block preloads + + ## Options + * `:exclude_origin_internal_transaction` - when `true`, filters out the origin sender transaction (index 0 with type :call) + * `:paging_options` - a `t:Explorer.PagingOptions.t/0` for pagination + * `:transaction_hash` - optional transaction hash to filter by + * `:api?` - whether this is an API request """ @spec fetch([paging_options | api?]) :: [] def fetch(options) do paging_options = Keyword.get(options, :paging_options, @default_paging_options) + exclude_zero_index_internal_transaction = Keyword.get(options, :exclude_origin_internal_transaction, false) case paging_options do %PagingOptions{key: {0, 0}} -> [] _ -> - preloads = - DenormalizationHelper.extend_transaction_preload([ - :block, - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]], - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] - ]) + preload_options = + Keyword.merge(options, + address_preloads: [ + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + created_contract_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] + ) __MODULE__ - |> where_nonpending_block() - |> Chain.page_internal_transaction(paging_options, %{index_internal_transaction_desc_order: true}) + |> where_nonpending_operation() + |> maybe_filter_origin_transaction(exclude_zero_index_internal_transaction) + |> page_internal_transaction(paging_options, %{index_internal_transaction_desc_order: true}) |> where_internal_transactions_by_transaction_hash(Keyword.get(options, :transaction_hash)) |> order_by([internal_transaction], desc: internal_transaction.block_number, @@ -871,26 +1246,425 @@ defmodule Explorer.Chain.InternalTransaction do desc: internal_transaction.index ) |> limit(^paging_options.page_size) - |> preload(^preloads) + |> preload(:block) |> Chain.select_repo(options).all() + |> preload_error(options) + |> preload_transaction() + |> preload_addresses(preload_options) end end - defp page_block_internal_transaction(query, %PagingOptions{key: %{block_index: block_index}}) do + defp page_block_internal_transaction(query, %PagingOptions{key: %{transaction_index: transaction_index, index: index}}) do query - |> where([internal_transaction], internal_transaction.block_index > ^block_index) + |> where( + [internal_transaction], + (internal_transaction.transaction_index == ^transaction_index and internal_transaction.index > ^index) or + internal_transaction.transaction_index > ^transaction_index + ) end defp page_block_internal_transaction(query, _), do: query - def internal_transaction_to_block_paging_options(%__MODULE__{block_index: block_index}) do - %{"block_index" => block_index} + def internal_transaction_to_block_paging_options(%__MODULE__{transaction_index: transaction_index, index: index}) do + %{"transaction_index" => transaction_index, "index" => index} end defp where_internal_transactions_by_transaction_hash(query, nil), do: query defp where_internal_transactions_by_transaction_hash(query, transaction_hash) do query - |> where([internal_transaction], internal_transaction.transaction_hash == ^transaction_hash) + |> join_transaction_query() + |> where(as(:transaction).hash == ^transaction_hash) + end + + defp maybe_filter_origin_transaction(query, true), do: where_is_different_from_parent_transaction(query) + defp maybe_filter_origin_transaction(query, false), do: query + + @doc """ + Conditionally filters internal transactions to include or exclude zero-value transfers. + + When `include_zero` is `true`, the query remains unchanged and will return all + internal transactions regardless of their value. When `include_zero` is `false`, + the query is modified to exclude internal transactions where the transferred + value is zero, returning only transactions with positive Wei values. + + ## Parameters + - `query`: An Ecto query for internal transactions + - `include_zero`: Whether to include zero-value internal transactions + + ## Returns + - Modified Ecto query that either includes or excludes zero-value transfers + """ + @spec include_zero_value(Ecto.Query.t(), boolean()) :: Ecto.Query.t() + def include_zero_value(query, true), do: query + + def include_zero_value(query, false) do + where( + query, + [internal_transaction], + (internal_transaction.type == :call and internal_transaction.value > ^0) or internal_transaction.type != :call + ) + end + + @doc """ + Builds an `Ecto.Query` to fetch internal transactions by {block_number, transaction_index} pairs + """ + @spec by_block_number_transaction_index_query([{non_neg_integer(), non_neg_integer()}]) :: Ecto.Query.t() + def by_block_number_transaction_index_query(block_number_transaction_index_pairs) do + from( + t in __MODULE__, + where: ^QueryHelper.tuple_in([:block_number, :transaction_index], block_number_transaction_index_pairs) + ) + end + + @doc """ + Fetches and formats the first internal transaction trace for the given transaction parameters from the EthereumJSONRPC. + + This function retrieves the first trace by delegating to + EthereumJSONRPC.fetch_first_trace and then formats the result into a + structure suitable for insertion into the database, including type + conversions and block index calculation. + + ## Parameters + - `transactions_params`: List of transaction parameter maps containing + block_hash, block_number, hash_data, and transaction_index + - `json_rpc_named_arguments`: Named arguments for the JSON RPC call + + ## Returns + - `{:ok, [formatted_trace]}` if the trace is successfully fetched and + formatted + - `{:error, reason}` if there's an error fetching the trace + - `:ignore` if the trace should be ignored + """ + @spec fetch_first_trace(list(map()), keyword()) :: {:ok, [map()]} | {:error, term()} | :ignore + def fetch_first_trace(transactions_params, json_rpc_named_arguments) do + case EthereumJSONRPC.fetch_first_trace(transactions_params, json_rpc_named_arguments) do + {:ok, [%{first_trace: first_trace, block_hash: block_hash, block_number: block_number}]} -> + format_transaction_first_trace(first_trace, block_hash, block_number) + + {:error, error} -> + {:error, error} + + :ignore -> + :ignore + end + end + + defp format_transaction_first_trace(first_trace, block_hash, block_number) do + {:ok, to_address_hash} = + if Map.has_key?(first_trace, :to_address_hash) do + Chain.string_to_address_hash(first_trace.to_address_hash) + else + {:ok, nil} + end + + {:ok, from_address_hash} = Chain.string_to_address_hash(first_trace.from_address_hash) + + {:ok, created_contract_address_hash} = + if Map.has_key?(first_trace, :created_contract_address_hash) do + Chain.string_to_address_hash(first_trace.created_contract_address_hash) + else + {:ok, nil} + end + + {:ok, transaction_hash} = Chain.string_to_full_hash(first_trace.transaction_hash) + + {:ok, call_type} = + if Map.has_key?(first_trace, :call_type) do + CallType.load(first_trace.call_type) + else + {:ok, nil} + end + + {:ok, type} = Type.load(first_trace.type) + + {:ok, input} = + if Map.has_key?(first_trace, :input) do + Data.cast(first_trace.input) + else + {:ok, nil} + end + + {:ok, output} = + if Map.has_key?(first_trace, :output) do + Data.cast(first_trace.output) + else + {:ok, nil} + end + + {:ok, created_contract_code} = + if Map.has_key?(first_trace, :created_contract_code) do + Data.cast(first_trace.created_contract_code) + else + {:ok, nil} + end + + {:ok, init} = + if Map.has_key?(first_trace, :init) do + Data.cast(first_trace.init) + else + {:ok, nil} + end + + value = + if is_nil(first_trace.value), do: %Wei{value: Decimal.new("0")}, else: %Wei{value: Decimal.new(first_trace.value)} + + first_trace_formatted = + first_trace + |> Map.merge(%{ + block_hash: block_hash, + block_number: block_number, + call_type: call_type, + to_address_hash: to_address_hash, + created_contract_address_hash: created_contract_address_hash, + from_address_hash: from_address_hash, + input: input, + output: output, + created_contract_code: created_contract_code, + init: init, + transaction_hash: transaction_hash, + type: type, + value: value + }) + + {:ok, [first_trace_formatted]} + end + + @doc """ + Finds internal transactions by {block_number, transaction_index} pairs + """ + @spec get_by_block_number_transaction_index([{non_neg_integer(), non_neg_integer()}]) :: [__MODULE__.t()] + def get_by_block_number_transaction_index(block_number_transaction_index_pairs) do + block_number_transaction_index_pairs + |> by_block_number_transaction_index_query() + |> Repo.all() + end + + @spec call_type(map()) :: atom() | nil + def call_type(%{call_type: call_type, call_type_enum: call_type_enum}) do + if BackgroundMigrations.get_empty_internal_transactions_data_finished() do + call_type_enum + else + call_type_enum || call_type + end + end + + @doc """ + Fills internal transaction `error` field based on `error_id` + """ + @spec preload_error(__MODULE__.t() | [__MODULE__.t()], Keyword.t()) :: __MODULE__.t() | [__MODULE__.t()] + def preload_error(internal_transactions, options \\ [api?: true]) + + def preload_error(internal_transactions, options) when is_list(internal_transactions) do + error_ids = + internal_transactions + |> Enum.map(& &1.error_id) + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + + error_id_to_error_map = + if error_ids == [] do + %{} + else + TransactionError + |> where([te], te.id in ^error_ids) + |> select([te], {te.id, te.message}) + |> Chain.select_repo(options).all() + |> Map.new() + end + + Enum.map(internal_transactions, &Map.put(&1, :error, Map.get(&1, :error) || error_id_to_error_map[&1.error_id])) + end + + def preload_error(internal_transaction, options) do + [internal_transaction] + |> preload_error(options) + |> List.first() + end + + @doc """ + Preloads parent transactions for internal transaction records. + + When a list of internal transactions is provided and `transactions` is `nil`, + the function fetches the corresponding parent transactions by + `{block_number, transaction_index}` and attaches each transaction to the + `:transaction` field of the matching internal transaction. + + It also ensures that `:transaction_hash` is populated from the loaded parent + transaction when available, while preserving the existing value if no parent + transaction is found. + + ## Parameters + - `internal_transactions`: A single internal transaction, a list of internal + transactions, or `nil` + - `repo`: The repo used to fetch parent transactions when they are not passed + explicitly + - `transactions`: Optional preloaded parent transactions to reuse instead of + querying the database + + ## Returns + - `nil` when `internal_transactions` is `nil` + - A list of internal transactions with the `:transaction` field populated + - A single internal transaction with the `:transaction` field populated + """ + @spec preload_transaction(__MODULE__.t() | [__MODULE__.t()] | nil, module(), [Transaction.t()] | nil) :: + __MODULE__.t() | [__MODULE__.t()] | nil + def preload_transaction(internal_transactions, repo \\ Repo, transactions \\ nil) + + def preload_transaction(nil, _repo, _transactions), do: nil + + def preload_transaction(internal_transactions, repo, existing_transactions) when is_list(internal_transactions) do + transactions = + case existing_transactions do + nil -> + internal_transactions + |> Enum.map(&{&1.block_number, &1.transaction_index}) + |> Enum.uniq() + |> Transaction.by_block_number_index_query() + |> repo.all() + + transactions -> + transactions + end + + block_number_index_to_transaction_map = Map.new(transactions, &{{&1.block_number, &1.index}, &1}) + + Enum.map(internal_transactions, fn it -> + transaction = block_number_index_to_transaction_map[{it.block_number, it.transaction_index}] + + Map.merge(it, %{ + transaction: transaction, + transaction_hash: (transaction && transaction.hash) || it.transaction_hash + }) + end) + end + + def preload_transaction(internal_transaction, repo, transactions) do + [internal_transaction] + |> preload_transaction(repo, transactions) + |> List.first() + end + + @default_address_preloads [from_address: [], to_address: [], created_contract_address: []] + + @doc """ + Preloads address associations for the given internal transaction or list of + internal transactions. + + After preloading, the function normalizes the result so that + `:from_address`, `:to_address`, and `:created_contract_address` are populated + consistently, and the corresponding `*_address_hash` fields are aligned with + the resolved addresses. + + ## Parameters + + - `internal_transactions`: An `Explorer.Chain.InternalTransaction.t/0`, a + list of internal transactions, `[]`, or `nil` + - `options`: Keyword options. Supports `:address_preloads` to specify nested + preloads for `:from_address`, `:to_address`, and + `:created_contract_address` + - `repo`: The repo module used for preloading. When omitted, it is resolved + via `Explorer.Chain.select_repo/1` + + ## Returns + + - A list of internal transactions with addresses preloaded when the input is + a list + - A single internal transaction with addresses preloaded when the input is a + single struct + """ + @spec preload_addresses([__MODULE__.t()] | __MODULE__.t() | nil, Keyword.t(), module() | nil) :: + [__MODULE__.t()] | __MODULE__.t() | nil + def preload_addresses(internal_transactions, options \\ [], repo \\ nil) + + def preload_addresses([], _options, _repo), do: [] + def preload_addresses(nil, _options, _repo), do: nil + + def preload_addresses(internal_transactions, options, repo) when is_list(internal_transactions) do + preloads = Keyword.merge(@default_address_preloads, Keyword.get(options, :address_preloads, [])) + repo = repo || Chain.select_repo(options) + + indexed_transactions = Enum.with_index(internal_transactions) + + {migrated_indexed, not_migrated_indexed} = + Enum.split_with( + indexed_transactions, + fn {it, _idx} -> + is_nil(it.from_address_hash) and is_nil(it.to_address_hash) and is_nil(it.created_contract_address_hash) + end + ) + + migrated = Enum.map(migrated_indexed, &elem(&1, 0)) + not_migrated = Enum.map(not_migrated_indexed, &elem(&1, 0)) + + not_migrated_preloaded = preload_addresses_for_not_migrated_internal_transactions(not_migrated, preloads, repo) + migrated_preloaded = preload_addresses_for_migrated_internal_transactions(migrated, preloads, repo) + + migrated_with_idx = Enum.zip(migrated_preloaded, Enum.map(migrated_indexed, &elem(&1, 1))) + not_migrated_with_idx = Enum.zip(not_migrated_preloaded, Enum.map(not_migrated_indexed, &elem(&1, 1))) + + (migrated_with_idx ++ not_migrated_with_idx) + |> Enum.sort_by(&elem(&1, 1)) + |> Enum.map(&elem(&1, 0)) + end + + def preload_addresses(internal_transaction, options, repo) do + [internal_transaction] + |> preload_addresses(options, repo) + |> List.first() + end + + defp preload_addresses_for_not_migrated_internal_transactions([], _preloads, _repo), do: [] + + defp preload_addresses_for_not_migrated_internal_transactions(internal_transactions, preloads, repo) do + unified_preloads = + preloads + |> List.wrap() + |> Enum.map(fn + preload when is_atom(preload) -> {String.to_existing_atom("#{preload}_by_hash"), []} + {preload, fields} -> {String.to_existing_atom("#{preload}_by_hash"), fields} + end) + + internal_transactions + |> repo.preload(unified_preloads) + |> Enum.map(fn internal_transaction -> + Enum.reduce( + [ + {:from_address_by_hash, :from_address_hash, :from_address}, + {:to_address_by_hash, :to_address_hash, :to_address}, + {:created_contract_address_by_hash, :created_contract_address_hash, :created_contract_address} + ], + internal_transaction, + fn {source_field, hash_field, address_field}, acc -> + corresponding_address = Map.get(acc, source_field) + + Map.merge(acc, %{ + hash_field => (corresponding_address && corresponding_address.hash) || Map.get(acc, hash_field), + address_field => corresponding_address + }) + end + ) + end) + end + + defp preload_addresses_for_migrated_internal_transactions([], _preloads, _repo), do: [] + + defp preload_addresses_for_migrated_internal_transactions(internal_transactions, preloads, repo) do + internal_transactions + |> repo.preload(preloads) + |> Enum.map(fn internal_transaction -> + Enum.reduce( + [ + {:from_address_hash, :from_address}, + {:to_address_hash, :to_address}, + {:created_contract_address_hash, :created_contract_address} + ], + internal_transaction, + fn {hash_field, address_field}, acc -> + corresponding_address = Map.get(acc, address_field) + Map.put(acc, hash_field, corresponding_address && corresponding_address.hash) + end + ) + end) end end diff --git a/apps/explorer/lib/explorer/chain/internal_transaction/action.ex b/apps/explorer/lib/explorer/chain/internal_transaction/action.ex index d3311f34879e..79b7206d687f 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction/action.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction/action.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction.Action do @moduledoc """ The action that was performed in a `t:EthereumJSONRPC.Nethermind.Trace.t/0` diff --git a/apps/explorer/lib/explorer/chain/internal_transaction/call_type.ex b/apps/explorer/lib/explorer/chain/internal_transaction/call_type.ex index 63e6dba64d2c..7bb4c7523aa3 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction/call_type.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction/call_type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction.CallType do @moduledoc """ Internal transaction types diff --git a/apps/explorer/lib/explorer/chain/internal_transaction/delete_queue.ex b/apps/explorer/lib/explorer/chain/internal_transaction/delete_queue.ex new file mode 100644 index 000000000000..660e30c0fc81 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/internal_transaction/delete_queue.ex @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.InternalTransaction.DeleteQueue do + @moduledoc """ + Stores numbers for blocks, whose internal transactions should be deleted and refetched + """ + + use Explorer.Schema + import Ecto.Query + alias Explorer.Chain.{Block, Import} + alias Explorer.Repo + + @primary_key false + typed_schema "internal_transactions_delete_queue" do + field(:block_number, :integer, primary_key: true) + + timestamps() + end + + @doc """ + Streams block numbers from the delete queue that are older than the threshold and reduces them using the provided reducer function. + + ## Parameters + - `initial`: The initial accumulator value + - `reducer`: A 2-arity function that processes each block number and returns an updated accumulator + - `threshold`: Time threshold in milliseconds (default: 600_000 ms / 10 minutes). Only entries older than this are streamed + + ## Returns + - `{:ok, accumulator}`: The final accumulator after processing all entries + """ + @spec stream_data( + initial :: accumulator, + reducer :: (entry :: integer(), accumulator -> accumulator), + threshold :: non_neg_integer() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_data(initial, reducer, threshold \\ 600_000) when is_function(reducer, 2) do + __MODULE__ + |> join(:inner, [dq], b in Block, on: dq.block_number == b.number and b.refetch_needed == false) + |> where([dq], dq.updated_at < ago(^threshold, "millisecond")) + |> order_by([dq], desc: :block_number) + |> select([dq], dq.block_number) + |> distinct(true) + |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Inserts block numbers into the internal transactions delete queue. + + This function builds queue entries for the given block numbers, adds shared + insert and update timestamps, and performs a bulk insert. Existing entries are + ignored because conflicts on the primary key are handled with `:nothing`. + + ## Parameters + + - `block_numbers`: A list of block numbers to enqueue for internal transaction deletion and refetch. + + ## Returns + + - The result of `Repo.safe_insert_all/3`. + + ## Examples + + iex> batch_insert([100, 101, 102]) + {3, nil} + + iex> batch_insert([100, 100]) + {1, nil} + + """ + @spec batch_insert([integer()]) :: {non_neg_integer(), nil | [term()]} + def batch_insert(block_numbers) do + timestamps = Import.timestamps() + params = Enum.map(block_numbers, &Map.put(timestamps, :block_number, &1)) + + Repo.safe_insert_all(__MODULE__, params, timeout: :infinity, on_conflict: :nothing) + end +end diff --git a/apps/explorer/lib/explorer/chain/internal_transaction/result.ex b/apps/explorer/lib/explorer/chain/internal_transaction/result.ex index 505f54568630..60080125c36b 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction/result.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction/result.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction.Result do @moduledoc """ The result of performing the `t:EthereumJSONRPC.Nethermind.Action.t/0` in a `t:EthereumJSONRPC.Nethermind.Trace.t/0`. diff --git a/apps/explorer/lib/explorer/chain/internal_transaction/type.ex b/apps/explorer/lib/explorer/chain/internal_transaction/type.ex index 0be77999e52c..9a06ccc1a4a9 100644 --- a/apps/explorer/lib/explorer/chain/internal_transaction/type.ex +++ b/apps/explorer/lib/explorer/chain/internal_transaction/type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction.Type do @moduledoc """ Internal transaction types diff --git a/apps/explorer/lib/explorer/chain/log.ex b/apps/explorer/lib/explorer/chain/log.ex index 49402eddcc58..2e9a17231b2d 100644 --- a/apps/explorer/lib/explorer/chain/log.ex +++ b/apps/explorer/lib/explorer/chain/log.ex @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Log.Schema do @moduledoc false - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] alias Explorer.Chain.{ Address, @@ -18,10 +20,10 @@ defmodule Explorer.Chain.Log.Schema do # However, on Celo, logs may exist where `transaction_hash` equals block_hash. # In these instances, we set `transaction_hash` to `nil`. This action, though, # violates the primary key constraint. To resolve this issue, we've excluded - # `transaction_hash` from the composite primary key when dealing with `:celo` - # chain type. - @transaction_field (case @chain_type do - :celo -> + # `transaction_hash` from the composite primary key when dealing with + # `:optimism-celo` chain type. + @transaction_field (case @chain_identity do + {:optimism, :celo} -> quote do [ belongs_to(:transaction, Transaction, @@ -80,20 +82,19 @@ defmodule Explorer.Chain.Log do @moduledoc "Captures a Web3 log entry generated by a transaction" use Explorer.Schema - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] require Explorer.Chain.Log.Schema require Logger alias ABI.{Event, FunctionSelector} alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Address, ContractMethod, Hash, Log, TokenTransfer, Transaction} - alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.{Block, ContractMethod, Hash, Log, TokenTransfer, Transaction} alias Explorer.SmartContract.SigProviderInterface @required_attrs ~w(address_hash data block_hash index)a - |> (&(case @chain_type do - :celo -> + |> (&(case @chain_identity do + {:optimism, :celo} -> &1 _ -> @@ -101,8 +102,8 @@ defmodule Explorer.Chain.Log do end)).() @optional_attrs ~w(first_topic second_topic third_topic fourth_topic block_number)a - |> (&(case @chain_type do - :celo -> + |> (&(case @chain_identity do + {:optimism, :celo} -> [:transaction_hash | &1] _ -> @@ -167,36 +168,25 @@ defmodule Explorer.Chain.Log do @doc """ Decode transaction log data. """ - @spec decode(Log.t(), Transaction.t(), any(), boolean(), boolean(), map(), map()) :: + @spec decode(Log.t(), Transaction.t(), any(), boolean(), boolean(), list(), map()) :: {{:ok, String.t(), String.t(), map()} | {:error, :could_not_decode} | {:error, atom(), list()} - | {{:error, :contract_not_verified | :try_with_sig_provider, [any()]}, any()}, map(), map()} + | {{:error, :contract_not_verified | :try_with_sig_provider, [any()]}, any()}, map()} def decode( log, transaction, db_options, skip_sig_provider?, decoding_from_list?, - full_abi \\ nil, - full_abi_per_address_hash_acc \\ %{}, + full_abi, events_acc \\ %{} ) do - {full_abi, full_abi_per_address_hash_acc} = - if decoding_from_list? do - {full_abi, full_abi_per_address_hash_acc} - else - full_abi_per_address_hash_acc = - accumulate_abi_by_address_hash(full_abi_per_address_hash_acc, log.address_hash, db_options) - - {full_abi_per_address_hash_acc[log.address_hash], full_abi_per_address_hash_acc} - end - with {:no_abi, false} <- {:no_abi, is_nil(full_abi)}, {:ok, selector, mapping} <- find_and_decode(full_abi, log, transaction.hash), identifier <- Base.encode16(selector.method_id, case: :lower), text <- function_call(selector.function, mapping) do - {{:ok, identifier, text, mapping}, full_abi_per_address_hash_acc, events_acc} + {{:ok, identifier, text, mapping}, events_acc} else {:error, _} = error -> handle_method_decode_error( @@ -206,7 +196,6 @@ defmodule Explorer.Chain.Log do skip_sig_provider?, decoding_from_list?, db_options, - full_abi_per_address_hash_acc, events_acc ) @@ -218,7 +207,6 @@ defmodule Explorer.Chain.Log do skip_sig_provider?, decoding_from_list?, db_options, - full_abi_per_address_hash_acc, events_acc ) end @@ -231,7 +219,6 @@ defmodule Explorer.Chain.Log do skip_sig_provider?, decoding_from_list?, db_options, - full_abi_per_address_hash_acc, events_acc ) do case error do @@ -240,114 +227,14 @@ defmodule Explorer.Chain.Log do find_method_candidates(log, transaction, db_options, events_acc), {true, events_acc} <- {is_list(candidates), events_acc}, {false, events_acc} <- {Enum.empty?(candidates), events_acc} do - {{:error, :contract_not_verified, candidates}, full_abi_per_address_hash_acc, events_acc} + {{:error, :contract_not_verified, candidates}, events_acc} else {_, events_acc} -> - result = - if decoding_from_list? do - mark_events_to_decode_later_via_sig_provider_in_batch(log, transaction.hash) - else - decode_event_via_sig_provider(log, transaction.hash, skip_sig_provider?) - end - - {result, full_abi_per_address_hash_acc, events_acc} + handle_unverified_method(log, transaction, decoding_from_list?, skip_sig_provider?, events_acc) end end end - # Accumulates the ABI (Application Binary Interface) by the given address hash. - - # ## Parameters - - # - `acc` (map): The accumulator map where the ABIs are stored. - # - `address_hash` (binary): The address hash for which the ABI needs to be accumulated. - # - `db_options` (keyword list): Database options to be used for querying the contract address. - - # ## Returns - - # - `map`: The updated accumulator map with the ABI for the given address hash. - - # If the address hash is `nil` or already exists in the accumulator, it returns the accumulator as is. - # If the contract address is found, it combines the proxy implementation ABI and stores it in the accumulator. - # If the contract address is not found, it stores `nil` in the accumulator for the given address hash. - @spec accumulate_abi_by_address_hash(map(), Hash.t(), Keyword.t()) :: map() - defp accumulate_abi_by_address_hash(acc, address_hash, db_options) do - address_options = - [ - necessity_by_association: %{ - :smart_contract => :optional - } - ] - |> Keyword.merge(db_options) - - if !is_nil(address_hash) && Map.has_key?(acc, address_hash) do - acc - else - case Chain.find_contract_address(address_hash, address_options) do - {:ok, %{smart_contract: smart_contract}} -> - full_abi = Proxy.combine_proxy_implementation_abi(smart_contract, db_options) - Map.put(acc, address_hash, full_abi) - - _ -> - Map.put(acc, address_hash, nil) - end - end - end - - @doc """ - Accumulates the ABI (Application Binary Interface) by the given list of address hashes. - - ## Parameters - - - `acc` (map): The accumulator map where the ABIs are stored. - - `address_hash` (binary): The address hash for which the ABI needs to be accumulated. - - `db_options` (keyword list): Database options to be used for querying the contract address. - - ## Returns - - - `map`: The updated accumulator map with the ABI for the given address hash. - - If the address hash is `nil` or already exists in the accumulator, it returns the accumulator as is. - If the contract address is found, it combines the proxy implementation ABI and stores it in the accumulator. - If the contract address is not found, it stores `nil` in the accumulator for the given address hash. - """ - @spec accumulate_abi_by_address_hashes(map(), [Hash.t()], Keyword.t()) :: map() - def accumulate_abi_by_address_hashes(address_hash_abi_map, address_hashes, db_options) do - address_options = - [ - necessity_by_association: %{ - :smart_contract => :optional - } - ] - |> Keyword.merge(db_options) - - address_hashes_without_abi = - address_hashes - |> Enum.filter(fn address_hash -> - is_nil(address_hash_abi_map[address_hash]) - end) - - if Enum.empty?(address_hashes_without_abi) do - address_hash_abi_map - else - case Address.find_contract_addresses(address_hashes_without_abi, address_options) do - {:ok, addresses} when is_list(addresses) -> - update_address_hash_abi_map_with_implementations_abi(address_hash_abi_map, addresses, db_options) - - _ -> - %{} - end - end - end - - defp update_address_hash_abi_map_with_implementations_abi(address_hash_abi_map, addresses, db_options) do - addresses - |> Enum.reduce(address_hash_abi_map, fn %{smart_contract: smart_contract} = address, acc -> - full_abi = Proxy.combine_proxy_implementation_abi(smart_contract, db_options) - Map.put(acc, address.hash, full_abi) - end) - end - defp find_method_candidates(log, transaction, options, events_acc) do if is_nil(log.first_topic) do {{:error, :could_not_decode}, events_acc} @@ -363,6 +250,17 @@ defmodule Explorer.Chain.Log do end end + defp handle_unverified_method(log, transaction, decoding_from_list?, skip_sig_provider?, events_acc) do + result = + if decoding_from_list? do + mark_events_to_decode_later_via_sig_provider_in_batch(log, transaction.hash) + else + decode_event_via_sig_provider(log, transaction.hash, skip_sig_provider?) + end + + {result, events_acc} + end + defp find_method_candidates_from_db(method_id, log, transaction, options) do event_candidates = method_id @@ -499,6 +397,7 @@ defmodule Explorer.Chain.Log do ) do with true <- SigProviderInterface.enabled?(), false <- skip_sig_provider?, + false <- is_nil(log.first_topic), {:ok, result} <- SigProviderInterface.decode_event( [ @@ -566,25 +465,32 @@ defmodule Explorer.Chain.Log do :log => log, :transaction_hash => transaction_hash }}, %{"abi" => abi}} -> - abi_first_item = abi |> List.first() + decode_sig_provider_batch_item(index, abi, log, transaction_hash) + end) + else + _ -> + input + |> Enum.map(fn {index, _} -> {index, {:error, :could_not_decode}} end) + end + end - if is_map(abi_first_item) do - abi = [abi_first_item |> Map.put("type", "event")] + defp decode_sig_provider_batch_item(index, abi, log, transaction_hash) do + abi_first_item = List.first(abi) - {:ok, selector, mapping} = find_and_decode(abi, log, transaction_hash) + if is_map(abi_first_item) do + normalized_abi = [Map.put(abi_first_item, "type", "event")] + case find_and_decode(normalized_abi, log, transaction_hash) do + {:ok, selector, mapping} -> identifier = Base.encode16(selector.method_id, case: :lower) text = function_call(selector.function, mapping) - {index, {:error, :contract_not_verified, [{:ok, identifier, text, mapping}]}} - else + + {:error, _} -> {index, {:error, :could_not_decode}} - end - end) + end else - _ -> - input - |> Enum.map(fn {index, _} -> {index, {:error, :could_not_decode}} end) + {index, {:error, :could_not_decode}} end end @@ -691,4 +597,42 @@ defmodule Explorer.Chain.Log do log.first_topic not in [^TokenTransfer.weth_deposit_signature(), ^TokenTransfer.weth_withdrawal_signature()] ) end + + @doc """ + Returns the first (min) known consensus block number found in the `logs` table. + + ## Returns + - An integer block number if found. + - `nil` if not found. + """ + @spec first_known_block_number() :: non_neg_integer() | nil + def first_known_block_number do + Repo.aggregate(get_known_block_number_query(), :min, :block_number) + end + + @doc """ + Returns the last (max) known consensus block number found in the `logs` table. + + ## Returns + - An integer block number if found. + - `nil` if not found. + """ + @spec last_known_block_number() :: non_neg_integer() | nil + def last_known_block_number do + Repo.aggregate(get_known_block_number_query(), :max, :block_number) + end + + # Returns a query to get all logs with consensus blocks. + # + # ## Returns + # - A query to be used by other Repo functions. + @spec get_known_block_number_query() :: Ecto.Query.t() + defp get_known_block_number_query do + from( + log in __MODULE__, + inner_join: block in Block, + on: block.hash == log.block_hash and block.consensus == true, + select: log + ) + end end diff --git a/apps/explorer/lib/explorer/chain/map_cache.ex b/apps/explorer/lib/explorer/chain/map_cache.ex index fbe38db6bbca..7aa96a894ffc 100644 --- a/apps/explorer/lib/explorer/chain/map_cache.ex +++ b/apps/explorer/lib/explorer/chain/map_cache.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.MapCache do @moduledoc """ Behaviour for a map-like cache of elements. @@ -44,6 +45,12 @@ defmodule Explorer.Chain.MapCache do - `{:return, value}` that will cause the value to be returned but not stored This allows to define of a default value or perform some actions. By default it will simply `{:return, nil}` + + ## Distributed writes + + In split API/indexer deployments, `set/2` and `update/2` use `do_raw/2`: the write runs on + the local `ConCache` first, then indexer nodes multicast the same operation to other cluster + nodes via `:erpc` (`propagate: false` on receivers). Reads are not distributed. """ @type key :: atom() @@ -113,6 +120,7 @@ defmodule Explorer.Chain.MapCache do # credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks quote do + require Logger alias Explorer.Chain.MapCache @behaviour MapCache @@ -155,7 +163,7 @@ defmodule Explorer.Chain.MapCache do @impl MapCache def set(key, value) do - ConCache.put(cache_name(), key, value) + do_raw(fn -> ConCache.put(cache_name(), key, value) end) end @impl MapCache @@ -168,7 +176,31 @@ defmodule Explorer.Chain.MapCache do @impl MapCache def update(key, value) do - ConCache.update(cache_name(), key, fn old_val -> handle_update(key, old_val, value) end) + do_raw(fn -> ConCache.update(cache_name(), key, fn old_val -> handle_update(key, old_val, value) end) end) + end + + @doc """ + Applies a ConCache write locally, then propagates it from indexer nodes. + + With the default `propagate: true`, the given zero-arity function runs on this node first. + When `Explorer.mode/0` is `:indexer`, the same function is multicast to `Node.list/0` with + `propagate: false` so API nodes apply the write without re-propagating. + """ + def do_raw(function, propagate \\ true) do + function.() + + case Explorer.mode() do + :indexer -> + if propagate do + Node.list() |> :erpc.multicast(__MODULE__, :do_raw, [function, false]) + else + Logger.error("[#{__MODULE__}] Indexer got unexpected propagation call to do_raw/2") + :ok + end + + _ -> + :ok + end end ### Autogenerated named functions diff --git a/apps/explorer/lib/explorer/chain/method_identifier.ex b/apps/explorer/lib/explorer/chain/method_identifier.ex index e6097b59d941..5ebe3e50a76d 100644 --- a/apps/explorer/lib/explorer/chain/method_identifier.ex +++ b/apps/explorer/lib/explorer/chain/method_identifier.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.MethodIdentifier do @moduledoc """ The first four bytes of the [KECCAK-256](https://en.wikipedia.org/wiki/SHA-3) hash of a contract method or event. @@ -7,7 +8,9 @@ defmodule Explorer.Chain.MethodIdentifier do use Ecto.Type - @type t :: binary + alias Explorer.Chain.Data + + @type t :: Data.t() @impl true def type, do: :integer @@ -15,12 +18,16 @@ defmodule Explorer.Chain.MethodIdentifier do @impl true @spec load(integer) :: {:ok, t()} def load(value) do - {:ok, <>} + {:ok, %Data{bytes: <>}} end @impl true @spec cast(binary) :: {:ok, t()} | :error def cast(<<_::binary-size(4)>> = identifier) do + {:ok, %Data{bytes: identifier}} + end + + def cast(%Data{bytes: <<_::binary-size(4)>>} = identifier) do {:ok, identifier} end @@ -28,7 +35,7 @@ defmodule Explorer.Chain.MethodIdentifier do @impl true @spec dump(t()) :: {:ok, integer} | :error - def dump(<>) do + def dump(%Data{bytes: <>}) do {:ok, num} end diff --git a/apps/explorer/lib/explorer/chain/metrics.ex b/apps/explorer/lib/explorer/chain/metrics/public_metrics.ex similarity index 86% rename from apps/explorer/lib/explorer/chain/metrics.ex rename to apps/explorer/lib/explorer/chain/metrics/public_metrics.ex index b90e925e42d0..dd4adef608cc 100644 --- a/apps/explorer/lib/explorer/chain/metrics.ex +++ b/apps/explorer/lib/explorer/chain/metrics/public_metrics.ex @@ -1,4 +1,5 @@ -defmodule Explorer.Chain.Metrics do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Metrics.PublicMetrics do @moduledoc """ Module responsible for periodically setting current chain metrics. """ @@ -7,7 +8,7 @@ defmodule Explorer.Chain.Metrics do import Explorer.Chain, only: [select_repo: 1] - alias Explorer.Chain.Metrics.Queries + alias Explorer.Chain.Metrics.Queries.PublicChainMetrics, as: PublicChainMetricsQueries alias Explorer.Prometheus.Instrumenter @interval :timer.hours(1) @@ -69,12 +70,12 @@ defmodule Explorer.Chain.Metrics do defp set_handler_metric(metric) do func = String.to_atom(to_string(metric) <> "_query") - transactions_count = - Queries + items_count = + PublicChainMetricsQueries |> apply(func, []) |> select_repo(@options).one() - apply(Instrumenter, metric, [transactions_count]) + apply(Instrumenter, metric, [items_count]) end defp schedule_next_run do diff --git a/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex b/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex new file mode 100644 index 000000000000..aa60db88895f --- /dev/null +++ b/apps/explorer/lib/explorer/chain/metrics/queries/indexer_metrics.ex @@ -0,0 +1,438 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Metrics.Queries.IndexerMetrics do + @moduledoc """ + Module for DB queries to get indexer health metrics + """ + + import Ecto.Query + alias Ecto.Adapters.SQL + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain.Address.TokenBalance + alias Explorer.Chain.MultichainSearchDb.BalancesExportQueue, as: MultichainSearchDbBalancesExportQueue + alias Explorer.Chain.MultichainSearchDb.CountersExportQueue, as: MultichainSearchDbCountersExportQueue + alias Explorer.Chain.MultichainSearchDb.MainExportQueue, as: MultichainSearchDbMainExportQueue + alias Explorer.Chain.MultichainSearchDb.TokenInfoExportQueue, as: MultichainSearchDbTokenInfoExportQueue + alias Explorer.Chain.{PendingBlockOperation, PendingOperationsHelper, PendingTransactionOperation} + alias Explorer.Chain.Token.Instance + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + + @doc """ + Query to get the number of missing block numbers in the DB + """ + # sobelow_skip ["SQL"] + @spec missing_blocks_count() :: integer() + def missing_blocks_count do + block_ranges = RangesHelper.get_block_ranges() + + if block_ranges == [] do + 0 + else + {sql_parts, params} = + Enum.reduce(block_ranges, {[], []}, fn + first..last//_, {parts, acc_params} -> + from = min(first, last) + to = max(first, last) + param_index_from = length(acc_params) + 1 + param_index_to = length(acc_params) + 2 + + part = """ + SELECT COUNT(*) AS missing_count + FROM generate_series($#{param_index_from}::bigint, $#{param_index_to}::bigint) AS num(number) + WHERE NOT EXISTS ( + SELECT 1 FROM blocks b WHERE b.number = num.number AND b.consensus + ) + """ + + {[part | parts], [to, from | acc_params]} + + start_from, {parts, acc_params} -> + param_index = length(acc_params) + 1 + + part = """ + SELECT COUNT(*) AS missing_count + FROM generate_series( + $#{param_index}::bigint, + (SELECT COALESCE(MAX(number), $#{param_index}) FROM blocks)::bigint + ) AS num(number) + WHERE NOT EXISTS ( + SELECT 1 FROM blocks b WHERE b.number = num.number AND b.consensus + ) + """ + + {[part | parts], [start_from | acc_params]} + end) + + sql_string = + sql_parts + |> Enum.reverse() + |> Enum.join("\n UNION ALL\n ") + |> then(&"SELECT SUM(missing_count) AS missing_blocks_count FROM (\n #{&1}\n) AS counts(missing_count)") + + case SQL.query(Repo, sql_string, Enum.reverse(params), timeout: :infinity) do + {:ok, %Postgrex.Result{command: :select, columns: ["missing_blocks_count"], rows: [[missing_blocks_count]]}} -> + normalize_missing_blocks_count(missing_blocks_count) + + _ -> + 0 + end + end + end + + defp normalize_missing_blocks_count(nil), do: 0 + defp normalize_missing_blocks_count(%Decimal{} = value), do: Decimal.to_integer(value) + defp normalize_missing_blocks_count(value), do: value + + @doc """ + Query to get the number of missing internal transactions in the DB + """ + @spec missing_internal_transactions_count() :: integer() + def missing_internal_transactions_count do + trace_first_block = Application.get_env(:indexer, :trace_first_block) + + case PendingOperationsHelper.pending_operations_type() do + "blocks" -> + PendingBlockOperation + |> maybe_filter_by_trace_first_block(trace_first_block) + |> Repo.aggregate(:count, :block_hash, timeout: :infinity) + + "transactions" -> + Repo.aggregate(PendingTransactionOperation, :count, :transaction_hash, timeout: :infinity) + end + end + + defp maybe_filter_by_trace_first_block(query, trace_first_block) + when is_integer(trace_first_block) and trace_first_block > 0 do + where(query, [pbo], pbo.block_number >= ^trace_first_block) + end + + defp maybe_filter_by_trace_first_block(query, _trace_first_block), do: query + + @doc """ + Query to get the count of current token balances with missing values + """ + # sobelow_skip ["SQL"] + @spec missing_current_token_balances_count() :: integer() + def missing_current_token_balances_count do + block_ranges = RangesHelper.get_block_ranges() + + if block_ranges == [] do + 0 + else + {range_conditions, params} = + Enum.reduce(block_ranges, {[], []}, fn + first..last//_, {conditions, acc_params} -> + from = min(first, last) + to = max(first, last) + param_index_from = length(acc_params) + 1 + param_index_to = length(acc_params) + 2 + + condition = + "(ctb.block_number >= $#{param_index_from}::bigint AND ctb.block_number <= $#{param_index_to}::bigint)" + + {[condition | conditions], [to, from | acc_params]} + + start_from, {conditions, acc_params} -> + param_index = length(acc_params) + 1 + condition = "ctb.block_number >= $#{param_index}::bigint" + {[condition | conditions], [start_from | acc_params]} + end) + + range_filter = + range_conditions + |> Enum.reverse() + |> Enum.join(" OR ") + + sql_string = """ + SELECT COUNT(1) as missing_current_token_balances_count + FROM address_current_token_balances ctb + WHERE (ctb.value_fetched_at is NULL OR ctb.value is NULL) + AND ctb.token_type != 'ERC-7984' + AND (ctb.refetch_after IS NULL OR ctb.refetch_after < NOW()) + AND NOT EXISTS (SELECT 1 FROM missing_balance_of_tokens bmt WHERE bmt.token_contract_address_hash = ctb.token_contract_address_hash) + AND (#{range_filter}); + """ + + case SQL.query(Repo, sql_string, Enum.reverse(params), timeout: :infinity) do + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["missing_current_token_balances_count"], + rows: [[missing_current_token_balances_count]] + }} -> + missing_current_token_balances_count + + _ -> + 0 + end + end + end + + @doc """ + Query to get the count of archival token balances with missing values + """ + @spec missing_archival_token_balances_count() :: integer() + def missing_archival_token_balances_count do + if archival_token_balances_fetcher_disabled?() do + 0 + else + query = + from( + token_balance in TokenBalance, + where: is_nil(token_balance.value_fetched_at), + where: token_balance.token_type != "ERC-7984" + ) + + query + |> Repo.aggregate(:count, :id, timeout: :infinity) + end + end + + defp archival_token_balances_fetcher_disabled? do + :indexer + |> Application.get_env(Indexer.Fetcher.TokenBalance.Historical.Supervisor, []) + |> Keyword.get(:disabled?) + end + + @doc """ + Query to get the count of token instances with failed metadata fetches + """ + @spec failed_token_instances_metadata_count() :: integer() + def failed_token_instances_metadata_count do + query = + from( + token_instance in Instance, + where: not is_nil(token_instance.error) + ) + + query + |> Repo.aggregate(:count, :token_id, timeout: :infinity) + end + + @doc """ + Query to get the count of unfetched token instances + """ + # sobelow_skip ["SQL"] + @spec unfetched_token_instances_count() :: integer() + def unfetched_token_instances_count do + sql_string = + """ + SELECT COUNT(1) AS unfetched_token_instances_count + FROM ( + SELECT DISTINCT ON (s0."contract_address_hash", s0."token_id") s0."contract_address_hash", s0."token_id" + FROM ( + SELECT ss0."token_contract_address_hash" AS "contract_address_hash", ss0."token_id" AS "token_id" + FROM ( + SELECT sst0."token_contract_address_hash" AS "token_contract_address_hash", unnest(sst0."token_ids") AS "token_id" + FROM "token_transfers" AS sst0) AS ss0 INNER JOIN ( + SELECT sst0."contract_address_hash" AS "contract_address_hash" + FROM "tokens" AS sst0 + WHERE ((sst0."type" = 'ERC-1155') OR (sst0."type" = 'ERC-721')) + ) AS ss1 ON ss1."contract_address_hash" = ss0."token_contract_address_hash" + LEFT OUTER JOIN "token_instances" AS st2 ON (ss0."token_contract_address_hash" = st2."token_contract_address_hash") + AND (ss0."token_id" = st2."token_id") + WHERE (st2."token_id" IS NULL) + ) AS s0 + ) AS a; + """ + + case SQL.query(Repo, sql_string, [], timeout: :infinity) do + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["unfetched_token_instances_count"], + rows: [[unfetched_token_instances_count]] + }} -> + unfetched_token_instances_count + + _ -> + 0 + end + end + + @doc """ + Query to get the count of token instances not uploaded to CDN + """ + # sobelow_skip ["SQL"] + @spec token_instances_not_uploaded_to_cdn_count() :: integer() + def token_instances_not_uploaded_to_cdn_count do + sql_string = + """ + SELECT COUNT(1) AS token_instances_not_uploaded_to_cdn_count + FROM token_instances WHERE metadata IS NOT NULL AND thumbnails IS NULL AND cdn_upload_error IS NULL; + """ + + case SQL.query(Repo, sql_string, [], timeout: :infinity) do + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["token_instances_not_uploaded_to_cdn_count"], + rows: [[token_instances_not_uploaded_to_cdn_count]] + }} -> + token_instances_not_uploaded_to_cdn_count + + _ -> + 0 + end + end + + @doc """ + Query to get the count of multichain_search_db_export_balances_queue entries + """ + @spec multichain_search_db_export_balances_queue_count() :: integer() + def multichain_search_db_export_balances_queue_count do + if multichain_search_enabled?() and not multichain_search_balances_export_queue_disabled?() do + Repo.aggregate(MultichainSearchDbBalancesExportQueue, :count, :id, timeout: :infinity) + else + 0 + end + end + + @doc """ + Query to get the count of multichain_search_db_export_counters_queue entries + """ + @spec multichain_search_db_export_counters_queue_count() :: integer() + def multichain_search_db_export_counters_queue_count do + if multichain_search_enabled?() and not multichain_search_counters_export_queue_disabled?() do + Repo.aggregate(MultichainSearchDbCountersExportQueue, :count, :timestamp, timeout: :infinity) + else + 0 + end + end + + @doc """ + Query to get the count of multichain_search_db_export_token_info_queue entries + """ + @spec multichain_search_db_export_token_info_queue_count() :: integer() + def multichain_search_db_export_token_info_queue_count do + if multichain_search_enabled?() and not multichain_search_token_info_export_queue_disabled?() do + Repo.aggregate(MultichainSearchDbTokenInfoExportQueue, :count, :address_hash, timeout: :infinity) + else + 0 + end + end + + @doc """ + Query to get the count of multichain_search_db_main_export_queue entries + """ + @spec multichain_search_db_main_export_queue_count() :: integer() + def multichain_search_db_main_export_queue_count do + if multichain_search_enabled?() and not multichain_search_main_export_queue_disabled?() do + Repo.aggregate(MultichainSearchDbMainExportQueue, :count, :hash, timeout: :infinity) + else + 0 + end + end + + defp multichain_search_enabled? do + MultichainSearch.enabled?() + end + + defp multichain_search_main_export_queue_disabled? do + :indexer + |> Application.get_env(Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor, []) + |> Keyword.get(:disabled?) == true + end + + defp multichain_search_balances_export_queue_disabled? do + :indexer + |> Application.get_env(Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor, []) + |> Keyword.get(:disabled?) == true + end + + defp multichain_search_token_info_export_queue_disabled? do + :indexer + |> Application.get_env(Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor, []) + |> Keyword.get(:disabled?) == true + end + + defp multichain_search_counters_export_queue_disabled? do + :indexer + |> Application.get_env(Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor, []) + |> Keyword.get(:disabled?) == true + end + + @doc """ + Query to get percentile distribution of realtime token balances indexing delay. + Returns a map of percentile label to delay in nanoseconds, e.g. %{"p20" => 1500000000}. + Nanoseconds are expected by the corresponding gauge: its `_seconds` name suffix makes + prometheus infer `duration_unit: :seconds`, converting stored values to seconds at scrape time. + """ + # sobelow_skip ["SQL"] + @spec erc20_token_balances_realtime_indexing_delay_percentiles() :: map() + def erc20_token_balances_realtime_indexing_delay_percentiles do + sql = """ + WITH base AS ( + SELECT (EXTRACT(EPOCH FROM actb.value_fetched_at - b.timestamp) * 1e9)::bigint AS delay_nanoseconds + FROM token_transfers tt + JOIN blocks b + ON tt.block_hash = b.hash + LEFT JOIN address_current_token_balances actb + ON tt.to_address_hash = actb.address_hash + AND actb.token_contract_address_hash = tt.token_contract_address_hash + AND actb.block_number = tt.block_number + WHERE tt.token_type = 'ERC-20' + AND tt.block_consensus = true + AND actb.value_fetched_at IS NOT NULL + ORDER BY tt.block_number DESC + LIMIT 1000 + ) + SELECT + percentile_cont(0.20) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p20, + percentile_cont(0.40) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p40, + percentile_cont(0.60) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p60, + percentile_cont(0.80) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p80, + percentile_cont(0.90) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p90, + percentile_cont(0.95) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p95, + percentile_cont(0.99) WITHIN GROUP (ORDER BY delay_nanoseconds) AS p99 + FROM base + """ + + case SQL.query(Repo, sql, [], timeout: :infinity) do + {:ok, %Postgrex.Result{columns: columns, rows: [row]}} -> + columns |> Enum.zip(row) |> Map.new() + + _ -> + %{} + end + end + + @doc """ + Query to get percentile distribution of realtime blocks indexing delay. + Returns a map of percentile label to delay in nanoseconds, e.g. %{"p20" => 500000000}. + Nanoseconds are expected by the corresponding gauge: its `_seconds` name suffix makes + prometheus infer `duration_unit: :seconds`, converting stored values to seconds at scrape time. + """ + # sobelow_skip ["SQL"] + @spec blocks_realtime_indexing_delay_percentiles() :: map() + def blocks_realtime_indexing_delay_percentiles do + sql = """ + WITH stats AS ( + SELECT percentile_cont( + ARRAY[0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 0.99] + ) WITHIN GROUP (ORDER BY delay_nanoseconds) AS vals + FROM ( + SELECT (EXTRACT(EPOCH FROM inserted_at - timestamp) * 1e9)::bigint AS delay_nanoseconds + FROM blocks + WHERE timestamp AT TIME ZONE 'UTC' >= NOW() AT TIME ZONE 'UTC' - INTERVAL '5 minutes' + AND consensus = true + ORDER BY number DESC + ) s + ) + SELECT t.percentile, t.delay_nanoseconds + FROM stats + CROSS JOIN LATERAL unnest( + ARRAY[20, 30, 40, 50, 60, 70, 80, 90, 99], + stats.vals + ) AS t(percentile, delay_nanoseconds) + """ + + case SQL.query(Repo, sql, [], timeout: :infinity) do + {:ok, %Postgrex.Result{rows: rows}} -> + Map.new(rows, fn [pct, val] -> {"p#{pct}", val} end) + + _ -> + %{} + end + end +end diff --git a/apps/explorer/lib/explorer/chain/metrics/queries.ex b/apps/explorer/lib/explorer/chain/metrics/queries/public_chain_metrics.ex similarity index 88% rename from apps/explorer/lib/explorer/chain/metrics/queries.ex rename to apps/explorer/lib/explorer/chain/metrics/queries/public_chain_metrics.ex index 927f762ce428..f22ef190d40a 100644 --- a/apps/explorer/lib/explorer/chain/metrics/queries.ex +++ b/apps/explorer/lib/explorer/chain/metrics/queries/public_chain_metrics.ex @@ -1,4 +1,5 @@ -defmodule Explorer.Chain.Metrics.Queries do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Metrics.Queries.PublicChainMetrics do @moduledoc """ Module for DB queries to get chain metrics exposed at /public-metrics endpoint """ @@ -196,7 +197,10 @@ defmodule Explorer.Chain.Metrics.Queries do internal_transactions_query = if DenormalizationHelper.transactions_denormalization_finished?() do InternalTransaction - |> join(:inner, [it], transaction in assoc(it, :transaction)) + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) |> where([it, transaction], transaction.block_timestamp >= ago(^update_period_hours(), "hour")) |> where([it, transaction], transaction.block_consensus == true) |> where([it, transaction], transaction.status == ^1) @@ -204,16 +208,19 @@ defmodule Explorer.Chain.Metrics.Queries do address_hash: fragment( "UNNEST(ARRAY[?, ?, ?])", - it.from_address_hash, - it.to_address_hash, - it.created_contract_address_hash + coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) ) }) |> wrapped_union_subquery() else InternalTransaction - |> join(:inner, [it], transaction in assoc(it, :transaction)) - |> join(:inner, [transaction], block in assoc(transaction, :block)) + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> join(:inner, [_it, transaction], block in assoc(transaction, :block)) |> where([it, transaction, block], transaction.block_timestamp >= ago(^update_period_hours(), "hour")) |> where([it, transaction, block], block.consensus == true) |> where([it, transaction, block], transaction.status == ^1) @@ -221,9 +228,9 @@ defmodule Explorer.Chain.Metrics.Queries do address_hash: fragment( "UNNEST(ARRAY[?, ?, ?])", - it.from_address_hash, - it.to_address_hash, - it.created_contract_address_hash + coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) ) }) |> wrapped_union_subquery() @@ -268,6 +275,6 @@ defmodule Explorer.Chain.Metrics.Queries do end defp update_period_hours do - Application.get_env(:explorer, Explorer.Chain.Metrics)[:update_period_hours] + Application.get_env(:explorer, Explorer.Chain.Metrics.PublicMetrics)[:update_period_hours] end end diff --git a/apps/explorer/lib/explorer/chain/mud.ex b/apps/explorer/lib/explorer/chain/mud.ex index 9b95b81eb90c..6ba99b7ace15 100644 --- a/apps/explorer/lib/explorer/chain/mud.ex +++ b/apps/explorer/lib/explorer/chain/mud.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Mud do @moduledoc """ Represents a MUD framework database record. @@ -22,14 +23,13 @@ defmodule Explorer.Chain.Mud do Block, Data, Hash, + MethodIdentifier, Mud, Mud.Schema, Mud.Schema.FieldSchema, SmartContract } - alias Explorer.Helper, as: ExplorerHelper - require Logger @store_tables_table_id Base.decode16!("746273746f72650000000000000000005461626c657300000000000000000000", @@ -312,7 +312,10 @@ defmodule Explorer.Chain.Mud do ((system_contract && system_contract.abi) || []) |> ABI.parse_specification() |> Enum.filter(&(&1.type == :function)) - |> Enum.into(%{}, fn selector -> {ExplorerHelper.add_0x_prefix(selector.method_id), selector} end) + |> Enum.into(%{}, fn selector -> + {:ok, method_id} = MethodIdentifier.cast(selector.method_id) + {to_string(method_id), selector} + end) function_selector_signature_records |> Enum.reject(&(&1 == {nil, nil})) @@ -359,13 +362,13 @@ defmodule Explorer.Chain.Mud do Returns a map of block numbers to timestamps. """ @spec preload_records_timestamps([Mud.t()]) :: %{non_neg_integer() => DateTime.t()} - def preload_records_timestamps(records) do + def preload_records_timestamps(records, options \\ []) do block_numbers = records |> Enum.map(&(&1.block_number |> Decimal.to_integer())) |> Enum.uniq() Block |> where([b], b.number in ^block_numbers) |> select([b], {b.number, b.timestamp}) - |> Repo.all() + |> Chain.select_repo(options).all() |> Enum.into(%{}) end @@ -545,7 +548,7 @@ defmodule Explorer.Chain.Mud do int |> Integer.to_string() _ when type < 96 or type == 196 -> - ExplorerHelper.add_0x_prefix(raw) + %Data{bytes: raw} |> to_string() 96 -> raw == <<1>> diff --git a/apps/explorer/lib/explorer/chain/mud/schema.ex b/apps/explorer/lib/explorer/chain/mud/schema.ex index 42c22359f9ef..84406f24274c 100644 --- a/apps/explorer/lib/explorer/chain/mud/schema.ex +++ b/apps/explorer/lib/explorer/chain/mud/schema.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Mud.Schema do @moduledoc """ Represents a MUD framework database record schema. diff --git a/apps/explorer/lib/explorer/chain/mud/table.ex b/apps/explorer/lib/explorer/chain/mud/table.ex index 3adfc39a6593..2670cabc67e9 100644 --- a/apps/explorer/lib/explorer/chain/mud/table.ex +++ b/apps/explorer/lib/explorer/chain/mud/table.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Mud.Table do @moduledoc """ Represents a decoded MUD framework database table ID. diff --git a/apps/explorer/lib/explorer/chain/multichain_search_db/balances_export_queue.ex b/apps/explorer/lib/explorer/chain/multichain_search_db/balances_export_queue.ex new file mode 100644 index 000000000000..8a5e3fbdebaf --- /dev/null +++ b/apps/explorer/lib/explorer/chain/multichain_search_db/balances_export_queue.ex @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.MultichainSearchDb.BalancesExportQueue do + @moduledoc """ + Tracks token and coin balances, pending for export to the Multichain Service database. + """ + + use Explorer.Schema + import Ecto.Query + alias Ecto.Multi + alias Explorer.Chain.{Hash, Wei} + alias Explorer.Repo + + @required_attrs ~w(address_hash token_contract_address_hash_or_native)a + @optional_attrs ~w(value token_id retries_number)a + @allowed_attrs @optional_attrs ++ @required_attrs + + @primary_key false + typed_schema "multichain_search_db_export_balances_queue" do + field(:id, :integer, primary_key: true, null: false) + field(:address_hash, Hash.Address, null: false) + field(:token_contract_address_hash_or_native, :binary, null: false) + field(:value, Wei) + field(:token_id, :decimal) + field(:retries_number, :integer) + + timestamps() + end + + def changeset(%__MODULE__{} = pending_ops, attrs) do + pending_ops + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + end + + @doc """ + Streams a batch of multichain database balances that need to be retried for export. + + This function selects specific fields from the export records and applies a reducer function to each entry in the stream, accumulating the result. Optionally, the stream can be limited based on the `limited?` flag. + + ## Parameters + + - `initial`: The initial accumulator value. + - `reducer`: A function that takes an entry (as a map) and the current accumulator, returning the updated accumulator. + - `limited?` (optional): A boolean indicating whether to apply a fetch limit to the stream. Defaults to `false`. + + ## Returns + + - `{:ok, accumulator}`: A tuple containing `:ok` and the final accumulator after processing the stream. + """ + @spec stream_multichain_db_balances_batch( + initial :: accumulator, + reducer :: (entry :: map(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_multichain_db_balances_batch(initial, reducer, limited? \\ false) + when is_function(reducer, 2) do + __MODULE__ + |> select([export], %{ + address_hash: export.address_hash, + token_contract_address_hash_or_native: export.token_contract_address_hash_or_native, + value: export.value, + token_id: export.token_id + }) + |> add_balances_queue_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + defp add_balances_queue_fetcher_limit(query, false), do: query + + defp add_balances_queue_fetcher_limit(query, true) do + balances_queue_fetcher_limit = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue)[:init_limit] + + limit(query, ^balances_queue_fetcher_limit) + end + + @doc """ + Returns an Ecto query that defines the default behavior for handling conflicts + when inserting into the `multichain_search_db_export_balances_queue` table. + + On conflict, this query: + - Increments the `retries_number` field by 1 (or sets it to 1 if it was `nil`). + - Sets the `updated_at` field to the greatest value between the current and the excluded `updated_at`. + + This is typically used with `on_conflict: default_on_conflict()` in Ecto insert operations. + """ + @spec default_on_conflict :: Ecto.Query.t() + def default_on_conflict do + from( + multichain_search_db_export_balances_queue in __MODULE__, + update: [ + set: [ + retries_number: fragment("COALESCE(?, 0) + 1", multichain_search_db_export_balances_queue.retries_number), + updated_at: + fragment("GREATEST(?, EXCLUDED.updated_at)", multichain_search_db_export_balances_queue.updated_at) + ] + ] + ) + end + + # sobelow_skip ["DOS.StringToAtom"] + @spec delete_elements_from_queue_by_params([map()]) :: list() + def delete_elements_from_queue_by_params(balances) do + balances + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {balance, ind}, acc -> + balance_address_hash = balance.address_hash + + balance_token_contract_address_hash_or_native_binary = + if byte_size(balance.token_contract_address_hash_or_native) == 6 do + balance.token_contract_address_hash_or_native + else + "0x" <> hex = balance.token_contract_address_hash_or_native + hex |> Base.decode16(case: :lower) |> elem(1) + end + + balance_token_id = balance.token_id + + acc + |> Multi.delete_all( + String.to_atom("delete_#{ind}"), + from( + b in __MODULE__, + where: b.address_hash == ^balance_address_hash, + where: b.token_contract_address_hash_or_native == ^balance_token_contract_address_hash_or_native_binary, + where: + fragment( + "COALESCE(?, -1::numeric) = COALESCE(?::numeric, -1::numeric)", + b.token_id, + ^balance_token_id + ) + ) + ) + end) + end +end diff --git a/apps/explorer/lib/explorer/chain/multichain_search_db/counters_export_queue.ex b/apps/explorer/lib/explorer/chain/multichain_search_db/counters_export_queue.ex new file mode 100644 index 000000000000..3f07a1e32742 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/multichain_search_db/counters_export_queue.ex @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.MultichainSearchDb.CountersExportQueue do + @moduledoc """ + Tracks counters data, pending for export to the Multichain Service database. + """ + + use Explorer.Schema + + import Ecto.Query + + alias Ecto.Multi + alias Explorer.Repo + + @required_attrs ~w(timestamp counter_type data)a + @optional_attrs ~w(retries_number)a + @allowed_attrs @optional_attrs ++ @required_attrs + + @typedoc """ + * `timestamp` - The timestamp of the counters. The counters in `data` are only relevant at the moment of the timestamp. + * `counter_type` - The type of the counters in `data`. Currently only `global` type is implemented which includes the following counters: + - daily transactions number + - total transactions number + - total addresses number + * `data` - The map containing the counters relevant to the timestamp. + * `retries_number` - A number of retries to send the counters to Multichain service. + Equals to `nil` if the counters haven't been sent to the service yet. + """ + @primary_key false + typed_schema "multichain_search_db_export_counters_queue" do + field(:timestamp, :utc_datetime_usec, primary_key: true) + + field(:counter_type, Ecto.Enum, + values: [:global], + null: false, + primary_key: true + ) + + field(:data, :map) + field(:retries_number, :integer) + + timestamps() + end + + @doc """ + Validates that the `attrs` are valid. + """ + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() + def changeset(%__MODULE__{} = queue, attrs) do + queue + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + end + + @doc """ + Streams a batch of multichain database counter entries that need to be retried for export. + + This function selects specific fields from the export records and applies a reducer function + to each entry in the stream, accumulating the result. + Optionally, the stream can be limited based on the `limited?` flag. + + ## Parameters + - `initial`: The initial accumulator value. + - `reducer`: A function that takes an entry (as a map) and the current accumulator, returning the updated accumulator. + - `limited?` (optional): A boolean indicating whether to apply a fetch limit to the stream. Defaults to `false`. + + ## Returns + - `{:ok, accumulator}`: A tuple containing `:ok` and the final accumulator after processing the stream. + """ + @spec stream_multichain_db_counters_batch( + initial :: accumulator, + reducer :: (entry :: map(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_multichain_db_counters_batch(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + __MODULE__ + |> select([export], %{ + timestamp: export.timestamp, + counter_type: export.counter_type, + data: export.data + }) + |> add_queue_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + # Limits the SELECT query if needed. The limit is defined in `INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_INIT_QUERY_LIMIT` env variable. + # + # ## Parameters + # - `query`: The query to add the limit to. + # - `true or false`: If `true`, add the limit. If `false`, leave the query as it is. + # + # ## Returns + # - The modified query with the limit or the source query without changes. + @spec add_queue_fetcher_limit(Ecto.Query.t(), boolean()) :: Ecto.Query.t() + defp add_queue_fetcher_limit(query, false), do: query + + defp add_queue_fetcher_limit(query, true) do + limit = Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue)[:init_limit] + limit(query, ^limit) + end + + @doc """ + Constructs DELETE FROM queries for the counter items to be deleted from the queue. + + ## Parameters + - `queue_items`: A list of items to be deleted from the queue. Each item is identified by its primary key. + + ## Returns + - An `Ecto.Multi` struct containing the delete operations. + """ + @spec delete_query([%{:timestamp => DateTime.t(), :counter_type => atom(), optional(:data) => map()}]) :: Multi.t() + def delete_query(queue_items) do + queue_items + |> Enum.reduce(Multi.new(), fn queue_item, multi_acc -> + Multi.delete_all( + multi_acc, + {queue_item.timestamp, queue_item.counter_type}, + from(q in __MODULE__, + where: q.timestamp == ^queue_item.timestamp and q.counter_type == ^queue_item.counter_type + ) + ) + end) + end + + @doc """ + Returns the current number of items in the queue. + + ## Returns + - The current number of items in the queue. + """ + @spec queue_size() :: non_neg_integer() + def queue_size do + Repo.aggregate(__MODULE__, :count) + end + + @doc """ + Returns an Ecto query that defines the conflict resolution strategy for the + `multichain_search_db_export_counters_queue` table. On conflict, it increments the `retries_number` + (by using the db stored value or 0 if not present) and updates the + `updated_at` field to the greatest value between the current and the new timestamp. + + This is typically used in upsert operations to ensure retry counts are tracked and + timestamps are properly updated. + """ + @spec increase_retries_on_conflict :: Ecto.Query.t() + def increase_retries_on_conflict do + from( + q in __MODULE__, + update: [ + set: [ + retries_number: fragment("COALESCE(?, 0) + 1", q.retries_number), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", q.updated_at) + ] + ] + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/multichain_search_db/main_export_queue.ex b/apps/explorer/lib/explorer/chain/multichain_search_db/main_export_queue.ex new file mode 100644 index 000000000000..dbc282f2daa0 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/multichain_search_db/main_export_queue.ex @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.MultichainSearchDb.MainExportQueue do + @moduledoc """ + Tracks main blockchain data: block, transaction hashes, addresses with the metadata and block ranges, + pending for export to the Multichain Service database. + """ + + use Explorer.Schema + import Ecto.Query + alias Explorer.Chain.Block.Range + alias Explorer.Repo + + @required_attrs ~w(hash hash_type)a + @optional_attrs ~w(block_range retries_number)a + @allowed_attrs @optional_attrs ++ @required_attrs + + @primary_key false + typed_schema "multichain_search_db_main_export_queue" do + field(:hash, :binary, null: false) + + field(:hash_type, Ecto.Enum, + values: [ + :block, + :transaction, + :address + ], + null: false + ) + + field(:retries_number, :integer) + field(:block_range, Range) + + timestamps() + end + + def changeset(%__MODULE__{} = pending_ops, attrs) do + pending_ops + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + end + + @spec stream_multichain_db_data_batch( + initial :: accumulator, + reducer :: (entry :: map(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_multichain_db_data_batch(initial, reducer, limited? \\ false) + when is_function(reducer, 2) do + __MODULE__ + |> select([export], %{ + hash: export.hash, + hash_type: export.hash_type, + block_range: export.block_range + }) + |> order_by([export], fragment("upper(?) DESC", export.block_range)) + |> add_main_queue_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + defp add_main_queue_fetcher_limit(query, false), do: query + + defp add_main_queue_fetcher_limit(query, true) do + main_queue_fetcher_limit = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue)[:init_limit] + + limit(query, ^main_queue_fetcher_limit) + end + + @doc """ + Builds a query to retrieve records from the `Explorer.Chain.MultichainSearchDb.MainExportQueue` module + where the `hash` field matches any of the given `hashes`. + + ## Parameters + + - `hashes`: A list of hash values to filter the records by. + + ## Returns + + - An Ecto query that can be executed to fetch the matching records. + """ + @spec by_hashes_query([binary()]) :: Ecto.Query.t() + def by_hashes_query(hashes) do + # todo: In order to prevent deadlocks consider ordering by `hash` + # and rewrite logic to acquire items first with locking + # then deleting them. + __MODULE__ + |> where([export], export.hash in ^hashes) + end + + @doc """ + Returns an Ecto query that defines the default behavior for handling conflicts + when inserting into the `multichain_search_db_main_export_queue` table. + + On conflict, this query: + - Increments the `retries_number` field by 1 (or sets it to 1 if it was `nil`). + - Sets the `updated_at` field to the greatest value between the current and the excluded `updated_at`. + + This is typically used with `on_conflict` options in Ecto insert operations. + """ + @spec default_on_conflict :: Ecto.Query.t() + def default_on_conflict do + from( + multichain_search_db_main_export_queue in __MODULE__, + update: [ + set: [ + retries_number: fragment("COALESCE(?, 0) + 1", multichain_search_db_main_export_queue.retries_number), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", multichain_search_db_main_export_queue.updated_at) + ] + ] + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/multichain_search_db/token_info_export_queue.ex b/apps/explorer/lib/explorer/chain/multichain_search_db/token_info_export_queue.ex new file mode 100644 index 000000000000..58ad65d42dda --- /dev/null +++ b/apps/explorer/lib/explorer/chain/multichain_search_db/token_info_export_queue.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.MultichainSearchDb.TokenInfoExportQueue do + @moduledoc """ + Tracks token data, pending for export to the Multichain Service database. + """ + + use Explorer.Schema + + import Ecto.Query + + alias Explorer.Chain.Hash + alias Explorer.Repo + + @required_attrs ~w(address_hash data_type data)a + @optional_attrs ~w(retries_number)a + @allowed_attrs @optional_attrs ++ @required_attrs + + @primary_key false + typed_schema "multichain_search_db_export_token_info_queue" do + field(:address_hash, Hash.Address, null: false, primary_key: true) + + field(:data_type, Ecto.Enum, + values: [ + :metadata, + :total_supply, + :counters, + :market_data + ], + null: false, + primary_key: true + ) + + field(:data, :map) + field(:retries_number, :integer) + + timestamps() + end + + def changeset(%__MODULE__{} = queue, attrs) do + queue + |> cast(attrs, @allowed_attrs) + |> validate_required(@required_attrs) + end + + @doc """ + Streams a batch of multichain database token info entries that need to be retried for export. + + This function selects specific fields from the export records and applies a reducer function to each entry in the stream, accumulating the result. Optionally, the stream can be limited based on the `limited?` flag. + + ## Parameters + - `initial`: The initial accumulator value. + - `reducer`: A function that takes an entry (as a map) and the current accumulator, returning the updated accumulator. + - `limited?` (optional): A boolean indicating whether to apply a fetch limit to the stream. Defaults to `false`. + + ## Returns + - `{:ok, accumulator}`: A tuple containing `:ok` and the final accumulator after processing the stream. + """ + @spec stream_multichain_db_token_info_batch( + initial :: accumulator, + reducer :: (entry :: map(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_multichain_db_token_info_batch(initial, reducer, limited? \\ false) when is_function(reducer, 2) do + __MODULE__ + |> select([export], %{ + address_hash: export.address_hash, + data_type: export.data_type, + data: export.data + }) + |> add_queue_fetcher_limit(limited?) + |> Repo.stream_reduce(initial, reducer) + end + + defp add_queue_fetcher_limit(query, false), do: query + + defp add_queue_fetcher_limit(query, true) do + limit = Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue)[:init_limit] + limit(query, ^limit) + end + + @doc """ + Constructs query for DELETE FROM query for the token info item to be deleted from the queue. + + ## Parameters + - `queue_item`: An item to be deleted from the queue. The item is identified by its primary key. + + ## Returns + - An `Ecto.Query` struct containing the delete operation. + """ + @spec delete_query(%{:address_hash => Hash.Address.t() | binary(), :data_type => atom(), optional(:data) => map()}) :: + Ecto.Query.t() + def delete_query(queue_item) do + from(q in __MODULE__, + where: q.address_hash == ^queue_item.address_hash and q.data_type == ^queue_item.data_type + ) + end + + @doc """ + Returns the current number of items in the queue. + + ## Returns + - The current number of items in the queue. + """ + @spec queue_size() :: non_neg_integer() + def queue_size do + Repo.aggregate(__MODULE__, :count) + end + + @doc """ + Returns an Ecto query that defines the conflict resolution strategy for the + `multichain_search_db_export_token_info_queue` table. On conflict, it increments the `retries_number` + (by using the db stored value or 0 if not present) and updates the + `updated_at` field to the greatest value between the current and the new timestamp. + + This is typically used in upsert operations to ensure retry counts are tracked and + timestamps are properly updated. + """ + @spec increase_retries_on_conflict :: Ecto.Query.t() + def increase_retries_on_conflict do + from( + q in __MODULE__, + update: [ + set: [ + retries_number: fragment("COALESCE(?, 0) + 1", q.retries_number), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", q.updated_at) + ] + ] + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/neon/linked_solana_transactions.ex b/apps/explorer/lib/explorer/chain/neon/linked_solana_transactions.ex index 9a0548b5b4c1..93fddee15c1f 100644 --- a/apps/explorer/lib/explorer/chain/neon/linked_solana_transactions.ex +++ b/apps/explorer/lib/explorer/chain/neon/linked_solana_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Neon.LinkedSolanaTransactions do @moduledoc """ A relation table between a regular EVM transaction and multiple Solana transactions diff --git a/apps/explorer/lib/explorer/chain/null_round_height.ex b/apps/explorer/lib/explorer/chain/null_round_height.ex index d89b763dcd11..0325f2a52071 100644 --- a/apps/explorer/lib/explorer/chain/null_round_height.ex +++ b/apps/explorer/lib/explorer/chain/null_round_height.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.NullRoundHeight do @moduledoc """ Manages and tracks null round heights in the Filecoin blockchain. diff --git a/apps/explorer/lib/explorer/chain/optimism/deposit.ex b/apps/explorer/lib/explorer/chain/optimism/deposit.ex index 021c4a11d463..79048471193c 100644 --- a/apps/explorer/lib/explorer/chain/optimism/deposit.ex +++ b/apps/explorer/lib/explorer/chain/optimism/deposit.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.Deposit do @moduledoc "Models a deposit for Optimism." use Explorer.Schema - import Explorer.Chain, only: [default_paging_options: 0, join_association: 3, select_repo: 1] + import Explorer.Chain, only: [default_paging_options: 0, select_repo: 1] alias Explorer.Chain.{Hash, Transaction} alias Explorer.PagingOptions @@ -82,18 +83,50 @@ defmodule Explorer.Chain.Optimism.Deposit do _ -> base_query = - from(d in __MODULE__, + from( + d in __MODULE__, + inner_join: t in Transaction, + on: t.hash == d.l2_transaction_hash and t.status == :ok, + select: %{ + l1_block_number: d.l1_block_number, + l1_block_timestamp: d.l1_block_timestamp, + l1_transaction_hash: d.l1_transaction_hash, + l1_transaction_origin: d.l1_transaction_origin, + l2_transaction_hash: d.l2_transaction_hash, + l2_transaction_gas_limit: t.gas + }, order_by: [desc: d.l1_block_number, desc: d.l2_transaction_hash] ) base_query - |> join_association(:l2_transaction, :required) |> page_deposits(paging_options) |> limit(^paging_options.page_size) |> select_repo(options).all(timeout: :infinity) end end + @doc """ + Returns total number of displayed deposits. + + ## Parameters + - `options`: A keyword list of options: + - `:api?` - Whether the function is being called from an API context. + + ## Returns + - A total number of deposits. + """ + @spec count(list()) :: non_neg_integer() + def count(options \\ []) do + query = + from( + d in __MODULE__, + inner_join: t in Transaction, + on: t.hash == d.l2_transaction_hash and t.status == :ok + ) + + select_repo(options).aggregate(query, :count) + end + defp page_deposits(query, %PagingOptions{key: nil}), do: query defp page_deposits(query, %PagingOptions{key: {block_number, l2_transaction_hash}}) do diff --git a/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex b/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex index 4ad8318b9f9b..25666afb6775 100644 --- a/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex +++ b/apps/explorer/lib/explorer/chain/optimism/dispute_game.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.DisputeGame do @moduledoc "Models a dispute game for Optimism." @@ -6,16 +7,22 @@ defmodule Explorer.Chain.Optimism.DisputeGame do import Ecto.Query import Explorer.Chain, only: [default_paging_options: 0, select_repo: 1] + alias Explorer.Chain.Cache.ChainId alias Explorer.Chain.{Data, Hash} alias Explorer.{PagingOptions, Repo} - @required_attrs ~w(index game_type address created_at)a + @required_attrs ~w(index game_type address_hash created_at)a @optional_attrs ~w(extra_data resolved_at status)a + @chain_id_bob_mainnet 60_808 + @chain_id_bob_sepolia 808_813 + @chain_id_megaeth_mainnet 4326 + @chain_id_megaeth_testnet_v2 6343 + @typedoc """ * `index` - A unique index of the dispute game. * `game_type` - A number encoding a type of the dispute game. - * `address` - The dispute game contract address. + * `address_hash` - The dispute game contract address. * `extra_data` - An extra data of the dispute game (contains L2 block number). Equals to `nil` when the game is written to database but the rest data is not known yet. * `created_at` - UTC timestamp of when the dispute game was created. @@ -28,7 +35,7 @@ defmodule Explorer.Chain.Optimism.DisputeGame do typed_schema "op_dispute_games" do field(:index, :integer, primary_key: true) field(:game_type, :integer) - field(:address, Hash.Address) + field(:address_hash, Hash.Address) field(:extra_data, Data) field(:created_at, :utc_datetime_usec) field(:resolved_at, :utc_datetime_usec) @@ -81,6 +88,36 @@ defmodule Explorer.Chain.Optimism.DisputeGame do |> select_repo(options).all(timeout: :infinity) end + @doc """ + Retrieves L2 block number from the `extraData` field of the dispute game. The L2 block number can be encoded in + different ways depending on the chain. + + ## Parameters + - `extra_data`: The byte sequence of the extra data to retrieve L2 block number from. + + ## Returns + - L2 block number of the dispute game. + """ + @spec l2_block_number_from_extra_data(Data.t() | nil) :: non_neg_integer() + def l2_block_number_from_extra_data(nil), do: 0 + + def l2_block_number_from_extra_data(%Data{bytes: extra_data}) do + first_bits = + if ChainId.get_id() in [ + @chain_id_bob_mainnet, + @chain_id_bob_sepolia, + @chain_id_megaeth_mainnet, + @chain_id_megaeth_testnet_v2 + ] do + 64 + else + 256 + end + + <> = extra_data + l2_block_number + end + defp page_dispute_games(query, %PagingOptions{key: nil}), do: query defp page_dispute_games(query, %PagingOptions{key: {index}}) do diff --git a/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex b/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex index 061273e6b808..e2f7e6d020c3 100644 --- a/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex +++ b/apps/explorer/lib/explorer/chain/optimism/eip1559_config_update.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdate do @moduledoc "Models EIP-1559 config updates for Optimism (introduced by Holocene upgrade)." @@ -7,12 +8,14 @@ defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdate do alias Explorer.Repo @required_attrs ~w(l2_block_number l2_block_hash base_fee_max_change_denominator elasticity_multiplier)a + @optional_attrs ~w(min_base_fee)a @typedoc """ * `l2_block_number` - An L2 block number where the config update was registered. * `l2_block_hash` - An L2 block hash where the config update was registered. * `base_fee_max_change_denominator` - A new value of the denominator. * `elasticity_multiplier` - A new value of the multiplier. + * `min_base_fee` - A minimum base fee introduced in OP Jovian upgrade. """ @primary_key false typed_schema "op_eip1559_config_updates" do @@ -20,6 +23,7 @@ defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdate do field(:l2_block_hash, Hash.Full) field(:base_fee_max_change_denominator, :integer) field(:elasticity_multiplier, :integer) + field(:min_base_fee, :integer) timestamps() end @@ -29,7 +33,7 @@ defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdate do """ def changeset(%__MODULE__{} = updates, attrs \\ %{}) do updates - |> cast(attrs, @required_attrs) + |> cast(attrs, @required_attrs ++ @optional_attrs) |> validate_required(@required_attrs) end @@ -40,14 +44,14 @@ defmodule Explorer.Chain.Optimism.EIP1559ConfigUpdate do - `block_number`: The block number for which we need to read the actual config. ## Returns - - `{denominator, multiplier}` tuple in case the config exists. + - `{denominator, multiplier, min_base_fee}` tuple in case the config exists. - `nil` if the config is unknown. """ - @spec actual_config_for_block(non_neg_integer()) :: {non_neg_integer(), non_neg_integer()} | nil + @spec actual_config_for_block(non_neg_integer()) :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} | nil def actual_config_for_block(block_number) do query = from(u in __MODULE__, - select: {u.base_fee_max_change_denominator, u.elasticity_multiplier}, + select: {u.base_fee_max_change_denominator, u.elasticity_multiplier, u.min_base_fee}, where: u.l2_block_number < ^block_number, order_by: [desc: u.l2_block_number], limit: 1 diff --git a/apps/explorer/lib/explorer/chain/optimism/frame_sequence.ex b/apps/explorer/lib/explorer/chain/optimism/frame_sequence.ex index c44bfc34720a..6ea5fcecb72c 100644 --- a/apps/explorer/lib/explorer/chain/optimism/frame_sequence.ex +++ b/apps/explorer/lib/explorer/chain/optimism/frame_sequence.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.FrameSequence do @moduledoc """ Models a frame sequence for Optimism. @@ -78,7 +79,7 @@ defmodule Explorer.Chain.Optimism.FrameSequence do frame_sequence_id = select_repo(options).one(query) if not is_nil(frame_sequence_id) do - batch_by_internal_id(frame_sequence_id, options) + batch_by_number(frame_sequence_id, options) end end @@ -87,7 +88,7 @@ defmodule Explorer.Chain.Optimism.FrameSequence do op_frame_sequence_blobs DB tables by the internal id of the batch. ## Parameters - - `internal_id`: Batch'es internal id. + - `number`: Batch'es number. - `options`: A keyword list of options that may include whether to use a replica database and/or whether to include blobs (true by default). @@ -95,33 +96,33 @@ defmodule Explorer.Chain.Optimism.FrameSequence do - A map with info about L1 batch having the specified id. - nil if the batch is not found. """ - @spec batch_by_internal_id(non_neg_integer(), list()) :: map() | nil - def batch_by_internal_id(internal_id, options \\ []) do + @spec batch_by_number(non_neg_integer(), list()) :: map() | nil + def batch_by_number(number, options \\ []) do query = from(fs in __MODULE__, - where: fs.id == ^internal_id and fs.view_ready == true + where: fs.id == ^number and fs.view_ready == true ) batch = select_repo(options).one(query) if not is_nil(batch) do - l2_block_number_from = TransactionBatch.edge_l2_block_number(internal_id, :min) - l2_block_number_to = TransactionBatch.edge_l2_block_number(internal_id, :max) - transaction_count = Transaction.transaction_count_for_block_range(l2_block_number_from..l2_block_number_to) + l2_block_number_from = TransactionBatch.edge_l2_block_number(number, :min, options) + l2_block_number_to = TransactionBatch.edge_l2_block_number(number, :max, options) + transactions_count = Transaction.transaction_count_for_block_range(l2_block_number_from..l2_block_number_to) {batch_data_container, blobs} = if Keyword.get(options, :include_blobs?, true) do - FrameSequenceBlob.list(internal_id, options) + FrameSequenceBlob.list(number, options) else {nil, []} end result = prepare_base_info_for_batch( - internal_id, + number, l2_block_number_from, l2_block_number_to, - transaction_count, + transactions_count, batch_data_container, batch ) @@ -141,11 +142,12 @@ defmodule Explorer.Chain.Optimism.FrameSequence do includes basic batch information. ## Parameters - - `internal_id`: The internal ID of the batch. + - `number`: Number of the batch. - `l2_block_number_from`: Start L2 block number of the batch block range. - `l2_block_number_to`: End L2 block number of the batch block range. - - `transaction_count`: The L2 transaction count included into the blocks of the range. - - `batch_data_container`: Designates where the batch info is stored: :in_blob4844, :in_celestia, or :in_calldata. + - `transactions_count`: The L2 transaction count included into the blocks of the range. + - `batch_data_container`: Designates where the batch info is stored: + :in_blob4844, :in_celestia, :in_eigenda, :in_alt_da, or :in_calldata. Can be `nil` if the container is unknown. - `batch`: Either an `Explorer.Chain.Optimism.FrameSequence` entry or a map with the corresponding fields. @@ -158,44 +160,32 @@ defmodule Explorer.Chain.Optimism.FrameSequence do non_neg_integer(), non_neg_integer(), non_neg_integer(), - :in_blob4844 | :in_celestia | :in_calldata | nil, + :in_blob4844 | :in_celestia | :in_eigenda | :in_alt_da | :in_calldata | nil, __MODULE__.t() | %{:l1_timestamp => DateTime.t(), :l1_transaction_hashes => list(), optional(any()) => any()} ) :: %{ :number => non_neg_integer(), - :internal_id => non_neg_integer(), :l1_timestamp => DateTime.t(), :l2_start_block_number => non_neg_integer(), - :l2_block_start => non_neg_integer(), :l2_end_block_number => non_neg_integer(), - :l2_block_end => non_neg_integer(), :transactions_count => non_neg_integer(), - :transaction_count => non_neg_integer(), :l1_transaction_hashes => list(), - :batch_data_container => :in_blob4844 | :in_celestia | :in_calldata | nil + :batch_data_container => :in_blob4844 | :in_celestia | :in_eigenda | :in_alt_da | :in_calldata | nil } def prepare_base_info_for_batch( - internal_id, + number, l2_block_number_from, l2_block_number_to, - transaction_count, + transactions_count, batch_data_container, batch ) do %{ - :number => internal_id, - # todo: "internal_id" should be removed in favour `number` property with the next release after 8.0.0 - :internal_id => internal_id, + :number => number, :l1_timestamp => batch.l1_timestamp, :l2_start_block_number => l2_block_number_from, - # todo: It should be removed in favour `l2_start_block_number` property with the next release after 8.0.0 - :l2_block_start => l2_block_number_from, :l2_end_block_number => l2_block_number_to, - # todo: It should be removed in favour `l2_end_block_number` property with the next release after 8.0.0 - :l2_block_end => l2_block_number_to, - :transactions_count => transaction_count, - # todo: It should be removed in favour `transactions_count` property with the next release after 8.0.0 - :transaction_count => transaction_count, + :transactions_count => transactions_count, :l1_transaction_hashes => batch.l1_transaction_hashes, :batch_data_container => batch_data_container } diff --git a/apps/explorer/lib/explorer/chain/optimism/frame_sequence_blob.ex b/apps/explorer/lib/explorer/chain/optimism/frame_sequence_blob.ex index 001c3c2f318d..c0ca09875c4c 100644 --- a/apps/explorer/lib/explorer/chain/optimism/frame_sequence_blob.ex +++ b/apps/explorer/lib/explorer/chain/optimism/frame_sequence_blob.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.FrameSequenceBlob do @moduledoc """ Models a blob related to Optimism frame sequence. @@ -7,6 +8,7 @@ defmodule Explorer.Chain.Optimism.FrameSequenceBlob do Migrations: - Explorer.Repo.Optimism.Migrations.AddCelestiaBlobMetadata + - Explorer.Repo.Optimism.Migrations.AddAltDABlobs """ use Explorer.Schema @@ -20,7 +22,7 @@ defmodule Explorer.Chain.Optimism.FrameSequenceBlob do @typedoc """ * `key` - A unique id (key) of the blob. - * `type` - A type of the blob (`celestia` or `eip4844`). + * `type` - A type of the blob (`celestia`, `eip4844`, `alt_da`, or `eigenda`). * `metadata` - A map containing metadata of the blob. * `l1_transaction_hash` - The corresponding L1 transaction hash which point to the blob. * `l1_timestamp` - The timestamp of the L1 transaction. @@ -30,7 +32,7 @@ defmodule Explorer.Chain.Optimism.FrameSequenceBlob do @primary_key {:id, :integer, autogenerate: false} typed_schema "op_frame_sequence_blobs" do field(:key, :binary) - field(:type, Ecto.Enum, values: [:celestia, :eip4844]) + field(:type, Ecto.Enum, values: [:celestia, :eip4844, :alt_da, :eigenda]) field(:metadata, :map) field(:l1_transaction_hash, Hash.Full) field(:l1_timestamp, :utc_datetime_usec) @@ -59,10 +61,11 @@ defmodule Explorer.Chain.Optimism.FrameSequenceBlob do - `options`: A keyword list of options that may include whether to use a replica database. ## Returns - - A tuple {type, blobs} where `type` can be one of: `in_blob4844`, `in_celestia`, `in_calldata`. + - A tuple {type, blobs} where `type` can be one of: `in_blob4844`, `in_celestia`, `in_eigenda`, `in_alt_da`, `in_calldata`. The `blobs` in the list of blobs related to the specified frame sequence id sorted by an entity id. """ - @spec list(non_neg_integer(), list()) :: {:in_blob4844 | :in_celestia | :in_calldata, [map()]} + @spec list(non_neg_integer(), list()) :: + {:in_blob4844 | :in_celestia | :in_eigenda | :in_alt_da | :in_calldata, [map()]} def list(frame_sequence_id, options \\ []) do repo = select_repo(options) @@ -102,6 +105,28 @@ defmodule Explorer.Chain.Optimism.FrameSequenceBlob do } end) + eigenda_blobs = + blobs + |> Enum.filter(fn b -> b.type == :eigenda end) + |> Enum.map(fn b -> + %{ + "cert" => b.metadata["cert"], + "l1_transaction_hash" => b.l1_transaction_hash, + "l1_timestamp" => b.l1_timestamp + } + end) + + alt_da_blobs = + blobs + |> Enum.filter(fn b -> b.type == :alt_da end) + |> Enum.map(fn b -> + %{ + "commitment" => b.metadata["commitment"], + "l1_transaction_hash" => b.l1_transaction_hash, + "l1_timestamp" => b.l1_timestamp + } + end) + cond do not Enum.empty?(eip4844_blobs) -> {:in_blob4844, eip4844_blobs} @@ -109,6 +134,12 @@ defmodule Explorer.Chain.Optimism.FrameSequenceBlob do not Enum.empty?(celestia_blobs) -> {:in_celestia, celestia_blobs} + not Enum.empty?(eigenda_blobs) -> + {:in_eigenda, eigenda_blobs} + + not Enum.empty?(alt_da_blobs) -> + {:in_alt_da, alt_da_blobs} + true -> {:in_calldata, []} end diff --git a/apps/explorer/lib/explorer/chain/optimism/interop_message.ex b/apps/explorer/lib/explorer/chain/optimism/interop_message.ex index 4b6461579406..a22c3d22b3b9 100644 --- a/apps/explorer/lib/explorer/chain/optimism/interop_message.ex +++ b/apps/explorer/lib/explorer/chain/optimism/interop_message.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.InteropMessage do @moduledoc "Models interop message for Optimism." @@ -6,14 +7,14 @@ defmodule Explorer.Chain.Optimism.InteropMessage do require Logger import Explorer.Chain, only: [default_paging_options: 0, select_repo: 1] - import Explorer.Helper, only: [add_0x_prefix: 1] - alias Explorer.Chain.Hash + alias ABI.{FunctionSelector, TypeDecoder} + alias Explorer.Chain.{Data, Hash} alias Explorer.{PagingOptions, Repo} alias Indexer.Fetcher.Optimism.Interop.MessageQueue, as: InteropMessageQueue @required_attrs ~w(nonce init_chain_id relay_chain_id)a - @optional_attrs ~w(sender_address_hash target_address_hash init_transaction_hash block_number timestamp relay_transaction_hash payload failed)a + @optional_attrs ~w(sender_address_hash target_address_hash init_transaction_hash block_number timestamp relay_transaction_hash payload failed transfer_token_address_hash transfer_from_address_hash transfer_to_address_hash transfer_amount sent_to_multichain)a @interop_instance_api_url_to_public_key_cache :interop_instance_api_url_to_public_key_cache @interop_chain_id_to_instance_info_cache :interop_chain_id_to_instance_info_cache @@ -29,6 +30,11 @@ defmodule Explorer.Chain.Optimism.InteropMessage do * `relay_transaction_hash` - Transaction hash (on the target chain) associated with the message relay transaction. Can be `nil` (when relay transaction is not indexed yet). * `payload` - Message payload to call target with. Can be `nil` (when SentMessage event is not indexed yet). * `failed` - Fail status of the relay transaction. Can be `nil` (when relay transaction is not indexed yet). + * `transfer_token_address_hash` - Address of SuperchainERC20 token transferred within this message. Can be `nil` (if this is ETH transfer or not transfer operation at all). + * `transfer_from_address_hash` - The cross-chain transfer `from` address. Can be `nil` (if this is not transfer operation). + * `transfer_to_address_hash` - The cross-chain transfer `to` address. Can be `nil` (if this is not transfer operation). + * `transfer_amount` - The cross-chain transfer amount. Can be `nil` (if this is not transfer operation). + * `sent_to_multichain` - Equals to `true` if message details are sent to multichain service. Defaults to `nil`. """ @primary_key false typed_schema "op_interop_messages" do @@ -41,8 +47,13 @@ defmodule Explorer.Chain.Optimism.InteropMessage do field(:timestamp, :utc_datetime_usec) field(:relay_chain_id, :integer) field(:relay_transaction_hash, Hash.Full) - field(:payload, :binary) + field(:payload, Data) field(:failed, :boolean) + field(:transfer_token_address_hash, Hash.Address) + field(:transfer_from_address_hash, Hash.Address) + field(:transfer_to_address_hash, Hash.Address) + field(:transfer_amount, :decimal) + field(:sent_to_multichain, :boolean) timestamps() end @@ -124,30 +135,112 @@ defmodule Explorer.Chain.Optimism.InteropMessage do end end + @doc """ + Retrieves some statistics for the list of the last incomplete messages: min block number, max block number, and message count. + An incomplete message is the message for which an init transaction or relay transaction is unknown. + The selection is limited by a minimum block number (set to zero when the stats is needed for all messages). + + ## Parameters + - `current_chain_id`: The current chain ID to make correct query to the database. + - `start_block_number`: The block number starting from which the messages should be considered. + + ## Returns + - A map with `min`, `max`, and `count` fields. + - `%{min: nil, max: nil, count: 0}` map if there are no messages. + """ + @spec get_incomplete_messages_stats(non_neg_integer(), non_neg_integer()) :: + %{min: non_neg_integer() | nil, max: non_neg_integer() | nil, count: non_neg_integer()} + def get_incomplete_messages_stats(current_chain_id, start_block_number) + when is_integer(current_chain_id) and is_integer(start_block_number) do + Repo.one( + from( + m in __MODULE__, + select: %{min: min(m.block_number), max: max(m.block_number), count: fragment("COUNT(*)")}, + where: + ((is_nil(m.relay_transaction_hash) and m.init_chain_id == ^current_chain_id) or + (is_nil(m.init_transaction_hash) and m.relay_chain_id == ^current_chain_id)) and + m.block_number >= ^start_block_number + ) + ) + end + @doc """ Returns a list of incomplete messages from the `op_interop_messages` table. An incomplete message is the message for which an init transaction or relay transaction is unknown. - The selection is limited by a min block number. + The selection is limited by a block range. ## Parameters - `current_chain_id`: The current chain ID to make correct query to the database. - `min_block_number`: The block number starting from which the messages should be considered. + - `max_block_number`: The max block number before which (including) the messages should be considered. + - `limit`: Max number of retrieved items. + - `offset`: An offset within SQL query to retrieve items from. ## Returns - A list of the incomplete messages. Returns an empty list if they are not found. """ - @spec get_incomplete_messages(non_neg_integer(), non_neg_integer()) :: list() - def get_incomplete_messages(current_chain_id, min_block_number) do + @spec get_incomplete_messages( + non_neg_integer(), + non_neg_integer() | nil, + non_neg_integer() | nil, + non_neg_integer(), + non_neg_integer() + ) :: list() + def get_incomplete_messages(_current_chain_id, nil, nil, _limit, _offset), do: [] + + def get_incomplete_messages(current_chain_id, min_block_number, max_block_number, limit, offset) do Repo.all( from(m in __MODULE__, where: ((is_nil(m.relay_transaction_hash) and m.init_chain_id == ^current_chain_id) or (is_nil(m.init_transaction_hash) and m.relay_chain_id == ^current_chain_id)) and - m.block_number >= ^min_block_number + m.block_number >= ^min_block_number and m.block_number <= ^max_block_number, + order_by: [asc: m.nonce, asc: m.init_chain_id], + limit: ^limit, + offset: ^offset ) ) end + @doc """ + Retrieves messages to be exported to the multichain service. + + ## Parameters + - `current_chain_id`: The current chain ID to make correct query to the database. + - `limit`: The max number of retrieved items at once. + """ + @spec get_messages_for_multichain_export(non_neg_integer(), non_neg_integer()) :: list() + def get_messages_for_multichain_export(current_chain_id, limit) do + Repo.all( + from(m in __MODULE__, + where: + ((not is_nil(m.init_transaction_hash) and m.init_chain_id == ^current_chain_id) or + (not is_nil(m.relay_transaction_hash) and m.relay_chain_id == ^current_chain_id)) and + (is_nil(m.sent_to_multichain) or m.sent_to_multichain == false), + order_by: [desc: m.block_number], + limit: ^limit + ) + ) + end + + @doc """ + Retrieves message fields by its primary key (`init_chain_id` and `nonce`). + + ## Parameters + - `init_chain_id`: The chain ID of the init transaction. + - `nonce`: The message nonce. + """ + @spec get_message(non_neg_integer(), non_neg_integer()) :: __MODULE__.t() | nil + def get_message(init_chain_id, nonce) do + query = + from(m in __MODULE__, + where: m.init_chain_id == ^init_chain_id and m.nonce == ^nonce + ) + + query + |> Repo.one() + end + @doc """ Returns relay transaction hash and failure status from the `op_interop_messages` table for the given `init_chain_id` and `nonce`. @@ -245,6 +338,53 @@ defmodule Explorer.Chain.Optimism.InteropMessage do end end + @doc """ + Decodes message payload to get cross-chain transfer details (such as token address, from, to addresses, and amount). + If the message doesn't encode cross-chain transfer, the function returns nils. + + ## Parameters + - `payload`: The payload to decode. + + ## Returns + - A list consisting of the following elements: `[token_address, from_address, to_address, amount]`. + - A list with nils if the message doesn't encode a cross-chain transfer: `[nil, nil, nil, nil]`. + """ + @spec decode_payload(binary() | nil) :: list() + def decode_payload(payload) do + case payload do + # relayERC20(address _token, address _from, address _to, uint256 _amount) + <<0x7C, 0xFD, 0x6D, 0xBC>> <> encoded_params -> + TypeDecoder.decode( + encoded_params, + %FunctionSelector{ + function: "relayERC20", + types: [ + :address, + :address, + :address, + {:uint, 256} + ] + } + ) + + # relayETH(address _from, address _to, uint256 _amount) + <<0x4F, 0x0E, 0xDC, 0xC9>> <> encoded_params -> + encoded_params + |> TypeDecoder.decode(%FunctionSelector{ + function: "relayETH", + types: [ + :address, + :address, + {:uint, 256} + ] + }) + |> List.insert_at(0, nil) + + _ -> + List.duplicate(nil, 4) + end + end + # Extends a query for listing interop messages with their filtering. # Filter conditions are applied with `and` relation. # @@ -557,31 +697,28 @@ defmodule Explorer.Chain.Optimism.InteropMessage do end @doc """ - Finds a message by transaction hash and prepares to display the message details on transaction page. + Finds messages by transaction hash and prepares to display the message details on transaction page. Used by `BlockScoutWeb.API.V2.OptimismView.add_optimism_fields` function. ## Parameters - - `transaction_hash`: The transaction hash we need to find the corresponding message for. + - `transaction_hash`: The transaction hash we need to find the corresponding messages for. ## Returns - - A map with message details ready to be displayed on transaction page. - - `nil` if the message not found. + - A list with maps containing message details ready to be displayed on transaction page. The list can be empty. """ - @spec message_by_transaction(Hash.t()) :: map() | nil - def message_by_transaction(transaction_hash) do + @spec messages_by_transaction(Hash.t()) :: [map()] + def messages_by_transaction(transaction_hash) do query = from( m in __MODULE__, - where: m.init_transaction_hash == ^transaction_hash or m.relay_transaction_hash == ^transaction_hash, - limit: 1 + where: m.init_transaction_hash == ^transaction_hash or m.relay_transaction_hash == ^transaction_hash ) - message = - query - |> Repo.replica().one() - |> extend_with_status() + query + |> Repo.replica().all() + |> Enum.map(fn msg -> + message = extend_with_status(msg) - if not is_nil(message) do chain_info = if message.init_transaction_hash == transaction_hash do %{ @@ -597,19 +734,41 @@ defmodule Explorer.Chain.Optimism.InteropMessage do Map.merge( %{ + "unique_id" => message_unique_id(message), "nonce" => message.nonce, "status" => message.status, "sender_address_hash" => message.sender_address_hash, - # todo: keep next line for compatibility with frontend and remove when new frontend is bound to `sender_address_hash` property - "sender" => message.sender_address_hash, "target_address_hash" => message.target_address_hash, - # todo: keep next line for compatibility with frontend and remove when new frontend is bound to `target_address_hash` property - "target" => message.target_address_hash, - "payload" => add_0x_prefix(message.payload) + "payload" => message.payload }, chain_info ) - end + end) + end + + @doc """ + Constructs message id string for using in URLs on frontend. Concatenates hex representations of the `init_chain_id` + and `nonce` field (both consisting of 8 hex symbols and padded with leading zeroes). + + ## Parameters + - `message`: The message map containing `init_chain_id` and `nonce` keys. + + ## Returns + - The message id. Example for `init_chain_id` = 100 and `nonce` = 4000: "0000006400000FA0" + """ + @spec message_unique_id(map()) :: String.t() + def message_unique_id(%{init_chain_id: init_chain_id, nonce: nonce} = _message) do + init_chain_id_string = + init_chain_id + |> Integer.to_string(16) + |> String.pad_leading(8, "0") + + nonce_string = + nonce + |> Integer.to_string(16) + |> String.pad_leading(8, "0") + + init_chain_id_string <> nonce_string end @doc """ diff --git a/apps/explorer/lib/explorer/chain/optimism/output_root.ex b/apps/explorer/lib/explorer/chain/optimism/output_root.ex index 0d68cb7c3b51..36fce7b48ff8 100644 --- a/apps/explorer/lib/explorer/chain/optimism/output_root.ex +++ b/apps/explorer/lib/explorer/chain/optimism/output_root.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.OutputRoot do @moduledoc "Models an output root for Optimism." diff --git a/apps/explorer/lib/explorer/chain/optimism/reader.ex b/apps/explorer/lib/explorer/chain/optimism/reader.ex index 3dba0355f4f4..221ac207dca7 100644 --- a/apps/explorer/lib/explorer/chain/optimism/reader.ex +++ b/apps/explorer/lib/explorer/chain/optimism/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.Reader do @moduledoc "Contains read functions for Optimism modules." import Ecto.Query, diff --git a/apps/explorer/lib/explorer/chain/optimism/transaction_batch.ex b/apps/explorer/lib/explorer/chain/optimism/transaction_batch.ex index a1853e01169f..0be7645476fc 100644 --- a/apps/explorer/lib/explorer/chain/optimism/transaction_batch.ex +++ b/apps/explorer/lib/explorer/chain/optimism/transaction_batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.TransactionBatch do @moduledoc """ Models a batch of transactions for Optimism. @@ -16,11 +17,11 @@ defmodule Explorer.Chain.Optimism.TransactionBatch do use Explorer.Schema - import Explorer.Chain, only: [default_paging_options: 0, join_association: 3, join_associations: 2, select_repo: 1] + import Explorer.Chain, only: [default_paging_options: 0, join_associations: 2, select_repo: 1] alias Explorer.Chain.Block alias Explorer.Chain.Optimism.FrameSequence - alias Explorer.{PagingOptions, Repo} + alias Explorer.PagingOptions @required_attrs ~w(l2_block_number frame_sequence_id)a @@ -61,30 +62,33 @@ defmodule Explorer.Chain.Optimism.TransactionBatch do ## Parameters - `id`: The ID of the frame sequence for which the edge block number must be returned. - `type`: Can be :min or :max depending on which block number needs to be returned. + - `options`: A keyword list of options that may include whether to use a replica database. ## Returns - The min/max block number or `nil` if the block range is not found. """ - @spec edge_l2_block_number(non_neg_integer(), :min | :max) :: non_neg_integer() | nil - def edge_l2_block_number(id, type) when type == :min and is_integer(id) and id >= 0 do + @spec edge_l2_block_number(non_neg_integer(), :min | :max, list()) :: non_neg_integer() | nil + def edge_l2_block_number(id, type, options \\ []) + + def edge_l2_block_number(id, type, options) when type == :min and is_integer(id) and id >= 0 do query = id |> edge_l2_block_number_query() |> order_by([tb], asc: tb.l2_block_number) - Repo.replica().one(query) + select_repo(options).one(query) end - def edge_l2_block_number(id, type) when type == :max and is_integer(id) and id >= 0 do + def edge_l2_block_number(id, type, options) when type == :max and is_integer(id) and id >= 0 do query = id |> edge_l2_block_number_query() |> order_by([tb], desc: tb.l2_block_number) - Repo.replica().one(query) + select_repo(options).one(query) end - def edge_l2_block_number(_id, _type), do: nil + def edge_l2_block_number(_id, _type, _options), do: nil defp edge_l2_block_number_query(id) do from( @@ -95,48 +99,6 @@ defmodule Explorer.Chain.Optimism.TransactionBatch do ) end - @doc """ - Lists `t:Explorer.Chain.Optimism.TransactionBatch.t/0`'s' in descending order based on l2_block_number. - - ## Parameters - - `options`: A keyword list of options that may include whether to use a replica database, - paging options, and optional L2 block range for which to make the list of items. - - ## Returns - - A list of found entities sorted by `l2_block_number` in descending order. - """ - @spec list(list()) :: [__MODULE__.t()] - def list(options \\ []) do - paging_options = Keyword.get(options, :paging_options, default_paging_options()) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - l2_block_range_start = Keyword.get(options, :l2_block_range_start) - l2_block_range_end = Keyword.get(options, :l2_block_range_end) - - base_query = - if is_nil(l2_block_range_start) or is_nil(l2_block_range_end) do - from(tb in __MODULE__, - order_by: [desc: tb.l2_block_number] - ) - else - from(tb in __MODULE__, - order_by: [desc: tb.l2_block_number], - where: tb.l2_block_number >= ^l2_block_range_start and tb.l2_block_number <= ^l2_block_range_end - ) - end - - base_query - |> join_association(:frame_sequence, :required) - |> page_transaction_batches(paging_options) - |> limit(^paging_options.page_size) - |> select_repo(options).all(timeout: :infinity) - end - end - @doc """ Retrieves a list of rollup blocks included into a specified batch. @@ -274,10 +236,4 @@ defmodule Explorer.Chain.Optimism.TransactionBatch do <> = bytes bytes_before <> <> <> bytes_after end - - defp page_transaction_batches(query, %PagingOptions{key: nil}), do: query - - defp page_transaction_batches(query, %PagingOptions{key: {block_number}}) do - from(tb in query, where: tb.l2_block_number < ^block_number) - end end diff --git a/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex b/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex index cb1f1bec9650..5980f9ed4bbb 100644 --- a/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex +++ b/apps/explorer/lib/explorer/chain/optimism/withdrawal.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.Withdrawal do @moduledoc "Models Optimism withdrawal." @@ -6,7 +7,8 @@ defmodule Explorer.Chain.Optimism.Withdrawal do import Explorer.Chain, only: [default_paging_options: 0, select_repo: 1] alias Explorer.Application.Constants - alias Explorer.Chain.{Block, Hash, Transaction} + alias Explorer.Chain + alias Explorer.Chain.{Block, Hash, Log, Transaction} alias Explorer.Chain.Cache.OptimismFinalizationPeriod alias Explorer.Chain.Optimism.{DisputeGame, OutputRoot, WithdrawalEvent} alias Explorer.{Helper, PagingOptions, Repo} @@ -24,7 +26,13 @@ defmodule Explorer.Chain.Optimism.Withdrawal do @dispute_game_finality_delay_seconds "optimism_dispute_game_finality_delay_seconds" @proof_maturity_delay_seconds "optimism_proof_maturity_delay_seconds" + # 32-byte signature of the event MessagePassed(uint256 indexed nonce, address indexed sender, address indexed target, uint256 value, uint256 gasLimit, bytes data, bytes32 withdrawalHash) + @message_passed_event "0x02a52367d10742d8032712c1bb8e0144ff1ec5ffda1ed7d70bb05a2744955054" + @required_attrs ~w(msg_nonce hash l2_transaction_hash l2_block_number)a + @game_fields ~w(created_at resolved_at status)a + + @api_true [api?: true] @typedoc """ * `msg_nonce` - A nonce of the withdrawal message. @@ -70,6 +78,10 @@ defmodule Explorer.Chain.Optimism.Withdrawal do on: w.l2_block_number == l2_block.number, left_join: we in WithdrawalEvent, on: we.withdrawal_hash == w.hash and we.l1_event_type == :WithdrawalFinalized, + left_join: log in Log, + on: + log.transaction_hash == w.l2_transaction_hash and log.first_topic == ^@message_passed_event and + log.second_topic == fragment("numeric_to_bytea32(msg_nonce)"), select: %{ msg_nonce: w.msg_nonce, hash: w.hash, @@ -77,7 +89,10 @@ defmodule Explorer.Chain.Optimism.Withdrawal do l2_timestamp: l2_block.timestamp, l2_transaction_hash: w.l2_transaction_hash, l1_transaction_hash: we.l1_transaction_hash, - from: l2_transaction.from_address_hash + from: l2_transaction.from_address_hash, + msg_log_sender_address_hash: log.third_topic, + msg_log_target_address_hash: log.fourth_topic, + msg_log_data: log.data } ) @@ -129,9 +144,16 @@ defmodule Explorer.Chain.Optimism.Withdrawal do @doc """ Gets withdrawal statuses for Optimism Withdrawal transaction. For each withdrawal associated with this transaction, - returns the status and the corresponding L1 transaction hash if the status is `Relayed`. + returns the status, the corresponding L1 transaction hash if the status is `Relayed`, + and withdrawal message's data (such as nonce, sender, target, etc.). + + ## Parameters + - `l2_transaction_hash`: The transaction hash associated with the withdrawal. + + ## Returns + - A tuple containing the withdrawal message nonce, the withdrawal status, and a map with other message's data. """ - @spec transaction_statuses(Hash.t()) :: [{non_neg_integer(), String.t(), Hash.t() | nil}] + @spec transaction_statuses(Hash.t()) :: [{non_neg_integer(), String.t(), map()}] def transaction_statuses(l2_transaction_hash) do query = from(w in __MODULE__, @@ -140,11 +162,18 @@ defmodule Explorer.Chain.Optimism.Withdrawal do on: w.l2_block_number == l2_block.number and l2_block.consensus == true, left_join: we in WithdrawalEvent, on: we.withdrawal_hash == w.hash and we.l1_event_type == :WithdrawalFinalized, + left_join: log in Log, + on: + log.transaction_hash == w.l2_transaction_hash and log.first_topic == ^@message_passed_event and + log.second_topic == fragment("numeric_to_bytea32(msg_nonce)"), select: %{ hash: w.hash, l2_block_number: w.l2_block_number, l1_transaction_hash: we.l1_transaction_hash, - msg_nonce: w.msg_nonce + msg_nonce: w.msg_nonce, + msg_log_sender_address_hash: log.third_topic, + msg_log_target_address_hash: log.fourth_topic, + msg_log_data: log.data } ) @@ -157,8 +186,8 @@ defmodule Explorer.Chain.Optimism.Withdrawal do 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF ) - {status, _} = status(w) - {msg_nonce, status, w.l1_transaction_hash} + {status, _} = status(w, nil, @api_true) + {msg_nonce, status, w} end) end @@ -172,9 +201,9 @@ defmodule Explorer.Chain.Optimism.Withdrawal do ## Parameters - `w`: A map with the withdrawal info. - - `respected_games`: A list of games returned by the `respected_games()` function. + - `respected_games`: A list of games returned by the `respected_games(options)` function. Used to avoid duplicated SQL requests when the `status` function - is called in a loop. If `nil`, the `respected_games()` function + is called in a loop. If `nil`, the `respected_games(options)` function is called internally. ## Returns @@ -182,15 +211,15 @@ defmodule Explorer.Chain.Optimism.Withdrawal do `datetime` is the point of time when the challenge period ends. (only for `In challenge period` status). """ - @spec status(map(), list() | nil) :: {String.t(), DateTime.t() | nil} - def status(w, respected_games \\ nil) + @spec status(map(), list() | nil, [Chain.api?()]) :: {String.t(), DateTime.t() | nil} + def status(w, respected_games \\ nil, options \\ []) - def status(w, respected_games) when is_nil(w.l1_transaction_hash) do + def status(w, respected_games, options) when is_nil(w.l1_transaction_hash) do proven_events = proven_events_by_hash(w.hash) respected_games = if is_nil(respected_games) do - respected_games() + respected_games(options) else respected_games end @@ -211,7 +240,7 @@ defmodule Explorer.Chain.Optimism.Withdrawal do end end - def status(_w, _respected_games) do + def status(_w, _respected_games, _options) do {@withdrawal_status_relayed, nil} end @@ -219,8 +248,8 @@ defmodule Explorer.Chain.Optimism.Withdrawal do Returns the list of games which type is equal to the current respected game type received from OptimismPortal contract. """ - @spec respected_games() :: list() - def respected_games do + @spec respected_games([Chain.api?()]) :: list() + def respected_games(options) do case Helper.parse_integer(Constants.get_constant_value("optimism_respected_game_type")) do nil -> [] @@ -233,7 +262,7 @@ defmodule Explorer.Chain.Optimism.Withdrawal do limit: 100 ) - Repo.all(query, timeout: :infinity) + Chain.select_repo(options).all(query, timeout: :infinity) end end @@ -256,7 +285,7 @@ defmodule Explorer.Chain.Optimism.Withdrawal do defp appropriate_games_found(withdrawal_l2_block_number, respected_games) do respected_games |> Enum.any?(fn game -> - [l2_block_number] = Helper.decode_data(game.extra_data, [{:uint, 256}]) + l2_block_number = DisputeGame.l2_block_number_from_extra_data(game.extra_data) withdrawal_l2_block_number <= l2_block_number end) end @@ -274,23 +303,45 @@ defmodule Explorer.Chain.Optimism.Withdrawal do withdrawal_l2_block_number <= last_root_l2_block_number end - defp game_by_index(game_index) do - if not is_nil(game_index) do - Repo.replica().one( - from( - g in DisputeGame, - select: %{created_at: g.created_at, resolved_at: g.resolved_at, status: g.status}, - where: g.index == ^game_index - ) + # Fetches dispute game info from DB by game's contract address hash or game's unique index. + # + # ## Parameters + # - `game_address_hash`: Game's contract address hash. Must be `nil` if unknown. + # - `game_index`: Game unique index. Must be `nil` if unknown. + # + # ## Returns + # - A map with necessary info about the dispute game. + # - `nil` if the dispute game not found in DB or both input parameters are `nil`. + @spec game_by_address_hash_or_index(Hash.t() | nil, non_neg_integer() | nil) :: + %{:created_at => DateTime.t(), :resolved_at => DateTime.t(), :status => non_neg_integer()} | nil + defp game_by_address_hash_or_index(nil, nil), do: nil + + defp game_by_address_hash_or_index(game_address_hash, nil) do + Repo.replica().one( + from( + g in DisputeGame, + select: map(g, ^@game_fields), + where: g.address_hash == ^game_address_hash, + limit: 1 ) - end + ) + end + + defp game_by_address_hash_or_index(_game_address_hash, game_index) when not is_nil(game_index) do + Repo.replica().one( + from( + g in DisputeGame, + select: map(g, ^@game_fields), + where: g.index == ^game_index + ) + ) end # Determines the current withdrawal status by the list of the bound WithdrawalProven events. # # ## Parameters - # - `proven_events`: A list of WithdrawalProven events. Each item is `{l1_timestamp, game_index}` tuple. - # - `respected_games`: A list of games returned by the `respected_games()` function. + # - `proven_events`: A list of WithdrawalProven events. Each item is `{l1_timestamp, game_address_hash, game_index}` tuple. + # - `respected_games`: A list of games returned by the `respected_games(options)` function. # # ## Returns # - `{status, datetime}` tuple where the `status` is the current withdrawal status, @@ -300,15 +351,15 @@ defmodule Explorer.Chain.Optimism.Withdrawal do defp handle_proven_status(proven_events, respected_games) do statuses = proven_events - |> Enum.reduce_while([], fn {l1_timestamp, game_index}, acc -> - game = game_by_index(game_index) + |> Enum.reduce_while([], fn {l1_timestamp, game_address_hash, game_index}, acc -> + game = game_by_address_hash_or_index(game_address_hash, game_index) # credo:disable-for-lines:16 Credo.Check.Refactor.PipeChainStart cond do - is_nil(game_index) and not Enum.empty?(respected_games) -> + is_nil(game) and not Enum.empty?(respected_games) -> # here we cannot exactly determine the status `Waiting a game to resolve` or # `Ready for relay` or `In challenge period` - # as we don't know the game index. In this case we display the `Proven` status + # as we don't know the game index and address. In this case we display the `Proven` status {@withdrawal_status_proven, nil} is_nil(game) or DateTime.compare(l1_timestamp, game.created_at) == :lt -> @@ -349,14 +400,15 @@ defmodule Explorer.Chain.Optimism.Withdrawal do # - `withdrawal_hash`: The withdrawal hash for which the function should return the events. # # ## Returns - # - A list of `{l1_timestamp, game_index}` tuples where `l1_timestamp` is the L1 block timestamp - # when the event appeared, `game_index` is the bound dispute game index (can be `nil`). - @spec proven_events_by_hash(Hash.t()) :: [{DateTime.t(), non_neg_integer()}] + # - A list of `{l1_timestamp, game_address_hash, game_index}` tuples where `l1_timestamp` is the L1 block timestamp + # when the event appeared, `game_address_hash` is the bound dispute game contract address hash (can be `nil`), + # `game_index` is the bound dispute game index (can be `nil`). + @spec proven_events_by_hash(Hash.t()) :: [{DateTime.t(), Hash.t() | nil, non_neg_integer() | nil}] defp proven_events_by_hash(withdrawal_hash) do Repo.replica().all( from( we in WithdrawalEvent, - select: {we.l1_timestamp, we.game_index}, + select: {we.l1_timestamp, we.game_address_hash, we.game_index}, where: we.withdrawal_hash == ^withdrawal_hash and we.l1_event_type == :WithdrawalProven, order_by: [asc: we.l1_block_number] ), @@ -408,4 +460,41 @@ defmodule Explorer.Chain.Optimism.Withdrawal do end end end + + @doc """ + Returns a signature of the `MessagePassed` event emitted by `L2ToL1MessagePasser` contract + in form of 0x-prefixed string. + + ## Returns + - A 0x-prefixed string representing the signature. + """ + @spec message_passed_event() :: String.t() + def message_passed_event, do: @message_passed_event + + @doc """ + Returns `OptimismPortal` contract address. First, the function tries to get the address from the + `INDEXER_OPTIMISM_L1_PORTAL_CONTRACT` env variable. If that's not defined, the address is retrieved + from the database (constants table) where it was saved by other modules. If the address is unknown, + the function returns `nil`. + + ## Returns + - The OptimismPortal contract address in form of 0x-prefixed string. + - `nil` if the address cannot be determined. + """ + @spec portal_contract_address() :: String.t() | nil + def portal_contract_address do + portal_address = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism][:portal] + + if Indexer.Helper.address_correct?(portal_address) do + portal_address + else + Constants.get_constant_value(portal_contract_address_constant(), @api_true) + end + end + + @doc """ + Returns the name of the optimism_portal_contract_address constant. + """ + @spec portal_contract_address_constant() :: binary() + def portal_contract_address_constant, do: "optimism_portal_contract_address" end diff --git a/apps/explorer/lib/explorer/chain/optimism/withdrawal_event.ex b/apps/explorer/lib/explorer/chain/optimism/withdrawal_event.ex index 0ca44f41e58f..3c37578cecab 100644 --- a/apps/explorer/lib/explorer/chain/optimism/withdrawal_event.ex +++ b/apps/explorer/lib/explorer/chain/optimism/withdrawal_event.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Optimism.WithdrawalEvent do @moduledoc "Models Optimism withdrawal event." @@ -6,9 +7,10 @@ defmodule Explorer.Chain.Optimism.WithdrawalEvent do alias Explorer.Chain.Hash @required_attrs ~w(withdrawal_hash l1_event_type l1_timestamp l1_transaction_hash l1_block_number)a - @optional_attrs ~w(game_index)a + @optional_attrs ~w(game_index game_address_hash)a @typedoc """ + Descriptor of the withdrawal event: * `withdrawal_hash` - A withdrawal hash. * `l1_event_type` - A type of withdrawal event: `WithdrawalProven` or `WithdrawalFinalized`. * `l1_timestamp` - A timestamp of when the withdrawal event appeared. @@ -16,6 +18,29 @@ defmodule Explorer.Chain.Optimism.WithdrawalEvent do * `l1_block_number` - An L1 block number of the L1 transaction. * `game_index` - An index of a dispute game (if available in L1 transaction input) when the withdrawal is proven. Equals to `nil` if not available. + * `game_address_hash` - Contract address of a dispute game (if available in L1 transaction input) when + the withdrawal is proven. Equals to `nil` if not available. + """ + @type to_import :: %{ + withdrawal_hash: Hash.t(), + l1_event_type: String.t(), + l1_timestamp: DateTime.t(), + l1_transaction_hash: Hash.t(), + l1_block_number: non_neg_integer(), + game_index: non_neg_integer(), + game_address_hash: Hash.t() + } + + @typedoc """ + * `withdrawal_hash` - A withdrawal hash. + * `l1_event_type` - A type of withdrawal event: `WithdrawalProven` or `WithdrawalFinalized`. + * `l1_timestamp` - A timestamp of when the withdrawal event appeared. + * `l1_transaction_hash` - An hash of L1 transaction that contains the event. + * `l1_block_number` - An L1 block number of the L1 transaction. + * `game_index` - An index of a dispute game (if available in L1 transaction input) when + the withdrawal is proven. Equals to `nil` if not available. + * `game_address_hash` - Contract address of a dispute game (if available in L1 transaction input) when + the withdrawal is proven. Equals to `nil` if not available. """ @primary_key false typed_schema "op_withdrawal_events" do @@ -25,6 +50,7 @@ defmodule Explorer.Chain.Optimism.WithdrawalEvent do field(:l1_transaction_hash, Hash.Full, primary_key: true) field(:l1_block_number, :integer) field(:game_index, :integer) + field(:game_address_hash, Hash.Address) timestamps() end diff --git a/apps/explorer/lib/explorer/chain/ordered_cache.ex b/apps/explorer/lib/explorer/chain/ordered_cache.ex index 631bbf8ab73e..ba37da1a2cb9 100644 --- a/apps/explorer/lib/explorer/chain/ordered_cache.ex +++ b/apps/explorer/lib/explorer/chain/ordered_cache.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.OrderedCache do @moduledoc """ Behaviour for a cache of ordered elements. @@ -35,6 +36,12 @@ defmodule Explorer.Chain.OrderedCache do and `c:element_to_id/1` callbacks. For typechecking purposes it's also recommended to override the `t:element/0` and `t:id/0` type definitions. + + ## Distributed writes + + In split API/indexer deployments, `update/1` uses `do_raw_update/2`: the ids list and + elements are written to the local `ConCache` first, then indexer nodes multicast the prepared + update to other cluster nodes via `:erpc` (`propagate: false` on receivers). """ @type element :: struct() @@ -117,6 +124,14 @@ defmodule Explorer.Chain.OrderedCache do """ @callback atomic_take_enough(integer()) :: [element] | nil + @doc """ + Processes the elements before updating the cache. + This function is called before the `update/1` function and can be used to + modify the elements to be inserted. Can be used to optimize memory usage along + with fetching time. + """ + @callback sanitize_before_update(element) :: element + @doc """ Adds an element, or a list of elements, to the cache. When the cache is full, only the most prevailing elements will be stored, based @@ -143,6 +158,7 @@ defmodule Explorer.Chain.OrderedCache do # credo:disable-for-next-line Credo.Check.Refactor.LongQuoteBlocks quote do + require Logger alias Explorer.Chain.OrderedCache @behaviour OrderedCache @@ -169,6 +185,9 @@ defmodule Explorer.Chain.OrderedCache do @impl OrderedCache def element_to_id(element), do: element + @impl OrderedCache + def sanitize_before_update(element), do: element + ### Straightforward fetching functions @impl OrderedCache @@ -244,21 +263,69 @@ defmodule Explorer.Chain.OrderedCache do def update(elements) when is_nil(elements), do: :ok def update(elements) when is_list(elements) do + case Explorer.mode() do + mode when mode in [:all, :api, :indexer] -> + elements_for_preload = + elements + |> Enum.sort_by(&element_to_id(&1), &prevails?(&1, &2)) + |> Enum.take(max_size()) + + preloaded_elements = + try do + do_preloads(elements_for_preload) + rescue + postgrex_error in Postgrex.Error -> + Logger.error(fn -> + [ + "Error while preloading elements for ordered cache: ", + Exception.format(:error, postgrex_error, __STACKTRACE__) + ] + end) + + elements_for_preload + end + + preloaded_elements + |> Enum.map(&{element_to_id(&1), sanitize_before_update(&1)}) + |> do_raw_update(true) + + _ -> + :ok + end + end + + def update(element), do: update([element]) + + @doc """ + Merges prepared elements into the local ordered cache, then propagates from indexer nodes. + + Always updates the local ids list and element entries first. When `Explorer.mode/0` is + `:indexer` and `propagate` is `true`, multicasts the same prepared elements to `Node.list/0` + with `propagate: false` so API nodes apply the write without re-propagating. + """ + def do_raw_update(prepared_elements, propagate) do ConCache.update(cache_name(), ids_list_key(), fn ids -> updated_list = - elements - |> Enum.sort_by(&element_to_id(&1), &prevails?(&1, &2)) - |> Enum.take(max_size()) - |> do_preloads() - |> Enum.map(&{element_to_id(&1), &1}) + prepared_elements |> merge_and_update(ids || [], max_size()) # ids_list is set to never expire {:ok, %ConCache.Item{value: updated_list, ttl: :infinity}} end) - end - def update(element), do: update([element]) + case Explorer.mode() do + :indexer -> + if propagate do + Node.list() |> :erpc.multicast(__MODULE__, :do_raw_update, [prepared_elements, false]) + else + Logger.error("Indexer got unexpected propagation call to do_raw_update/2") + :ok + end + + _ -> + :ok + end + end defp do_preloads(elements) do if Enum.empty?(preloads()) do @@ -372,7 +439,8 @@ defmodule Explorer.Chain.OrderedCache do max_size: 0, preloads: 0, prevails?: 2, - element_to_id: 1 + element_to_id: 1, + sanitize_before_update: 1 end end end diff --git a/apps/explorer/lib/explorer/chain/pending_block_operation.ex b/apps/explorer/lib/explorer/chain/pending_block_operation.ex index ca205f978fee..e181c189baf2 100644 --- a/apps/explorer/lib/explorer/chain/pending_block_operation.ex +++ b/apps/explorer/lib/explorer/chain/pending_block_operation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.PendingBlockOperation do @moduledoc """ Tracks a block that has pending operations. @@ -28,6 +29,8 @@ defmodule Explorer.Chain.PendingBlockOperation do type: Hash.Full, null: false ) + + field(:priority, :integer) end def changeset(%__MODULE__{} = pending_ops, attrs) do @@ -55,7 +58,7 @@ defmodule Explorer.Chain.PendingBlockOperation do |> where([pbo], pbo.block_number >= ^from_block_number) |> where([pbo], pbo.block_number <= ^to_block_number) |> select([pbo], count(pbo.block_number)) - |> Repo.one() + |> Repo.one(timeout: :infinity) end @doc """ @@ -80,7 +83,7 @@ defmodule Explorer.Chain.PendingBlockOperation do limited? :: boolean() ) :: {:ok, accumulator} when accumulator: term() - def stream_blocks_with_unfetched_internal_transactions(initial, reducer, limited? \\ false) + def stream_blocks_with_unfetched_internal_transactions(initial, reducer, limited? \\ false, with_priority? \\ false) when is_function(reducer, 2) do direction = Application.get_env(:indexer, :internal_transactions_fetch_order) @@ -93,7 +96,16 @@ defmodule Explorer.Chain.PendingBlockOperation do ) query + |> maybe_add_priority_filter(with_priority?) |> add_fetcher_limit(limited?) |> Repo.stream_reduce(initial, reducer) end + + defp maybe_add_priority_filter(query, false), do: query + + defp maybe_add_priority_filter(query, true) do + from(pbo in query, + where: not is_nil(pbo.priority) + ) + end end diff --git a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex index 6d7d1b415a86..268f76039e34 100644 --- a/apps/explorer/lib/explorer/chain/pending_operations_helper.ex +++ b/apps/explorer/lib/explorer/chain/pending_operations_helper.ex @@ -1,26 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.PendingOperationsHelper do @moduledoc false import Ecto.Query - alias Explorer.Chain.{PendingBlockOperation, PendingTransactionOperation, Transaction} - alias Explorer.Repo + alias Explorer.Chain.{Block, Hash, PendingBlockOperation, PendingTransactionOperation, Transaction} + alias Explorer.{Helper, Repo} - @transactions_batch_size 1000 - @blocks_batch_size 10 + defp transactions_batch_size, + do: + Application.get_env(:explorer, Explorer.Chain.PendingOperationsHelper)[:transactions_batch_size] || + 1000 + + defp blocks_batch_size, + do: Application.get_env(:explorer, Explorer.Chain.PendingOperationsHelper)[:blocks_batch_size] || 10 def pending_operations_type do - # TODO: bring back this condition after the migration of internal transactions PK to [:block_hash, :transaction_index, :index] - # if Application.get_env(:explorer, :json_rpc_named_arguments)[:variant] == EthereumJSONRPC.Geth and - # not Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth)[:block_traceable?], - # do: "transactions", - # else: "blocks" - - if Application.get_env(:explorer, :non_existing_variable, false) do - "transactions" - else - "blocks" - end + if Application.get_env(:explorer, :json_rpc_named_arguments)[:variant] == EthereumJSONRPC.Geth and + !Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth)[:block_traceable?], + do: "transactions", + else: "blocks" + end + + @doc """ + Deletes all entities from `PendingTransactionOperation` related to provided `transaction_hashes`. + """ + @spec delete_related_transaction_operations([Hash.Full.t()]) :: {non_neg_integer(), nil} + def delete_related_transaction_operations(transaction_hashes) do + pending_operations_query = + from( + pto in PendingTransactionOperation, + where: pto.transaction_hash in ^transaction_hashes, + order_by: [asc: :transaction_hash], + lock: "FOR UPDATE" + ) + + Repo.delete_all( + from( + pto in PendingTransactionOperation, + join: s in subquery(pending_operations_query), + on: pto.transaction_hash == s.transaction_hash + ) + ) end def actual_entity do @@ -55,12 +76,14 @@ defmodule Explorer.Chain.PendingOperationsHelper do end defp from_transactions_to_blocks_function do + batch_size = transactions_batch_size() + pbo_params_query = from( pto in PendingTransactionOperation, join: t in assoc(pto, :transaction), - select: %{block_hash: t.block_hash, block_number: t.block_number}, - limit: @transactions_batch_size + select: %{block_hash: t.block_hash, block_number: t.block_number, priority: pto.priority}, + limit: ^batch_size ) case Repo.all(pbo_params_query) do @@ -68,7 +91,8 @@ defmodule Explorer.Chain.PendingOperationsHelper do :finish pbo_params -> - Repo.insert_all(PendingBlockOperation, add_timestamps(pbo_params), on_conflict: :nothing) + filtered_pbo_params = Enum.reject(pbo_params, &is_nil(&1.block_hash)) + Repo.insert_all(PendingBlockOperation, Helper.add_timestamps(filtered_pbo_params), on_conflict: :nothing) block_numbers_to_delete = Enum.map(pbo_params, & &1.block_number) @@ -76,7 +100,7 @@ defmodule Explorer.Chain.PendingOperationsHelper do from( pto in PendingTransactionOperation, join: t in assoc(pto, :transaction), - where: t.block_number in ^block_numbers_to_delete + where: is_nil(t.block_number) or t.block_number in ^block_numbers_to_delete ) Repo.delete_all(delete_query) @@ -86,9 +110,13 @@ defmodule Explorer.Chain.PendingOperationsHelper do end defp from_blocks_to_transactions_function do + from_blocks_to_transactions_function(blocks_batch_size()) + end + + defp from_blocks_to_transactions_function(blocks_batch_size) do pbo_block_numbers_query = PendingBlockOperation - |> limit(@blocks_batch_size) + |> limit(^blocks_batch_size) |> select([pbo], pbo.block_number) case Repo.all(pbo_block_numbers_query) do @@ -99,23 +127,202 @@ defmodule Explorer.Chain.PendingOperationsHelper do pto_params = Transaction |> where([t], t.block_number in ^pbo_block_numbers) - |> select([t], %{transaction_hash: t.hash}) + |> select([t], %{hash: t.hash, type: t.type}) |> Repo.all() - |> add_timestamps() + |> Transaction.filter_non_traceable_transactions() + |> Enum.map(&%{transaction_hash: &1.hash}) + |> Helper.add_timestamps() - Repo.insert_all(PendingTransactionOperation, pto_params, on_conflict: :nothing) + case insert_pending_transaction_operations(pto_params) do + :ok -> + delete_pending_block_operations(pbo_block_numbers) - PendingBlockOperation - |> where([pbo], pbo.block_number in ^pbo_block_numbers) - |> Repo.delete_all() + :continue - :continue + {:error, :too_many_parameters} when blocks_batch_size > 1 -> + from_blocks_to_transactions_function(max(div(blocks_batch_size, 2), 1)) + + {:error, :too_many_parameters} -> + Repo.safe_insert_all(PendingTransactionOperation, pto_params, on_conflict: :nothing) + delete_pending_block_operations(pbo_block_numbers) + + :continue + end + end + end + + defp insert_pending_transaction_operations([]), do: :ok + + defp insert_pending_transaction_operations(pto_params) do + Repo.insert_all(PendingTransactionOperation, pto_params, on_conflict: :nothing) + :ok + rescue + error in Postgrex.QueryError -> + if too_many_parameters_error?(error) do + {:error, :too_many_parameters} + else + reraise error, __STACKTRACE__ + end + end + + defp delete_pending_block_operations(pbo_block_numbers) do + PendingBlockOperation + |> where([pbo], pbo.block_number in ^pbo_block_numbers) + |> Repo.delete_all() + end + + defp too_many_parameters_error?(%Postgrex.QueryError{message: message}) when is_binary(message) do + Regex.match?(~r/postgresql protocol can not handle \d+ parameters, the maximum is \d+/i, message) + end + + defp too_many_parameters_error?(_), do: false + + @doc """ + Generates a query to find pending block operations that match any of the given block hashes. + + ## Parameters + + - `block_hashes`: A list of block hashes to filter the pending block operations. + + ## Returns + + - An Ecto query that can be executed to retrieve the matching pending block operations. + """ + @spec block_hash_in_query([Hash.Full.t()]) :: Ecto.Query.t() + def block_hash_in_query(block_hashes) do + from( + pending_ops in PendingBlockOperation, + where: pending_ops.block_hash in ^block_hashes + ) + end + + @doc """ + Checks if a block with the given hash is pending. + A block is considered pending if there exists a corresponding entry in the `PendingBlockOperation` table. + """ + @spec block_pending?(Hash.Full.t()) :: boolean() + def block_pending?(block_hash) do + [block_hash] + |> block_hash_in_query() + |> Repo.exists?() + end + + # Generates a query to find pending block operations within a specified range of block numbers. + @spec block_range_in_query(non_neg_integer(), non_neg_integer()) :: Ecto.Query.t() + defp block_range_in_query(min_block_number, max_block_number) + when is_integer(min_block_number) and is_integer(max_block_number) do + from( + pending_ops in PendingBlockOperation, + where: pending_ops.block_number >= ^min_block_number and pending_ops.block_number <= ^max_block_number + ) + end + + defp block_range_in_query(min_block_number, max_block_number) + when is_nil(min_block_number) and is_nil(max_block_number) do + from(pending_ops in PendingBlockOperation) + end + + defp block_range_in_query(min_block_number, max_block_number) when is_nil(min_block_number) do + from( + pending_ops in PendingBlockOperation, + where: pending_ops.block_number <= ^max_block_number + ) + end + + defp block_range_in_query(min_block_number, max_block_number) when is_nil(max_block_number) do + from( + pending_ops in PendingBlockOperation, + where: pending_ops.block_number >= ^min_block_number + ) + end + + @doc """ + Checks if there are any pending blocks within the specified range of block numbers. + A block is considered pending if there exists a corresponding entry in the `PendingBlockOperation` + table. + """ + @spec blocks_pending?(non_neg_integer() | nil, non_neg_integer() | nil) :: boolean() + def blocks_pending?(min_block_number, max_block_number) do + min_block_number + |> block_range_in_query(max_block_number) + |> Repo.exists?() + end + + @doc """ + Inserts pending operations for the given block numbers. + """ + @spec insert_pending_operations([integer()], integer() | nil) :: {[integer()], [Explorer.Chain.Transaction.t()]} + def insert_pending_operations(block_numbers, priority \\ nil) do + case pending_operations_type() do + "transactions" -> + default_on_conflict = default_pto_on_conflict() + transactions = Transaction.get_transactions_of_block_numbers(block_numbers) + + pto_params = + transactions + |> Transaction.filter_non_traceable_transactions() + |> Enum.map(&%{transaction_hash: &1.hash, priority: priority}) + |> Helper.add_timestamps() + + Repo.insert_all(PendingTransactionOperation, pto_params, + on_conflict: default_on_conflict, + conflict_target: [:transaction_hash] + ) + + {[], transactions} + + "blocks" -> + default_on_conflict = default_pbo_on_conflict() + + pbo_params = + Block + |> where([b], b.number in ^block_numbers) + |> where([b], b.consensus == true) + |> select([b], %{block_hash: b.hash, block_number: b.number}) + |> Repo.all() + |> add_priority(priority) + |> Helper.add_timestamps() + + {_total, inserted} = + Repo.insert_all(PendingBlockOperation, pbo_params, + on_conflict: default_on_conflict, + conflict_target: [:block_hash], + returning: [:block_number] + ) + + {Enum.map(inserted, & &1.block_number), []} end end - defp add_timestamps(params) do - now = DateTime.utc_now() + defp default_pbo_on_conflict do + from( + pending_block_operation in PendingBlockOperation, + update: [ + set: [ + priority: fragment("EXCLUDED.priority"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", pending_block_operation.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", pending_block_operation.updated_at) + ] + ], + where: is_nil(pending_block_operation.priority) and fragment("EXCLUDED.priority IS NOT NULL") + ) + end + + defp default_pto_on_conflict do + from( + pending_transaction_operation in PendingTransactionOperation, + update: [ + set: [ + priority: fragment("EXCLUDED.priority"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", pending_transaction_operation.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", pending_transaction_operation.updated_at) + ] + ], + where: is_nil(pending_transaction_operation.priority) and fragment("EXCLUDED.priority IS NOT NULL") + ) + end - Enum.map(params, &Map.merge(&1, %{inserted_at: now, updated_at: now})) + defp add_priority(params, priority) do + Enum.map(params, &Map.merge(&1, %{priority: priority})) end end diff --git a/apps/explorer/lib/explorer/chain/pending_transaction_operation.ex b/apps/explorer/lib/explorer/chain/pending_transaction_operation.ex index dd84f1170830..fa5d295ea025 100644 --- a/apps/explorer/lib/explorer/chain/pending_transaction_operation.ex +++ b/apps/explorer/lib/explorer/chain/pending_transaction_operation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.PendingTransactionOperation do @moduledoc """ Tracks a transaction that has pending operations. @@ -26,6 +27,8 @@ defmodule Explorer.Chain.PendingTransactionOperation do type: Hash.Full, null: false ) + + field(:priority, :integer) end def changeset(%__MODULE__{} = pending_ops, attrs) do @@ -69,10 +72,17 @@ defmodule Explorer.Chain.PendingTransactionOperation do """ @spec stream_transactions_with_unfetched_internal_transactions( initial :: accumulator, - reducer :: (entry :: term(), accumulator -> accumulator) + reducer :: (entry :: term(), accumulator -> accumulator), + limited? :: boolean(), + with_priority? :: boolean() ) :: {:ok, accumulator} when accumulator: term() - def stream_transactions_with_unfetched_internal_transactions(initial, reducer, limited? \\ false) + def stream_transactions_with_unfetched_internal_transactions( + initial, + reducer, + limited? \\ false, + with_priority? \\ false + ) when is_function(reducer, 2) do direction = Application.get_env(:indexer, :internal_transactions_fetch_order) @@ -80,12 +90,21 @@ defmodule Explorer.Chain.PendingTransactionOperation do from( po in __MODULE__, join: t in assoc(po, :transaction), - select: %{block_number: t.block_number, hash: t.hash, index: t.index}, + select: %{block_number: t.block_number, hash: t.hash, index: t.index, type: t.type}, order_by: [{^direction, t.block_number}] ) query + |> maybe_add_priority_filter(with_priority?) |> add_fetcher_limit(limited?) |> Repo.stream_reduce(initial, reducer) end + + defp maybe_add_priority_filter(query, false), do: query + + defp maybe_add_priority_filter(query, true) do + from(pto in query, + where: not is_nil(pto.priority) + ) + end end diff --git a/apps/explorer/lib/explorer/chain/polygon_edge/deposit.ex b/apps/explorer/lib/explorer/chain/polygon_edge/deposit.ex deleted file mode 100644 index a18900868876..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_edge/deposit.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule Explorer.Chain.PolygonEdge.Deposit do - @moduledoc "Models Polygon Edge deposit." - - use Explorer.Schema - - alias Explorer.Chain.{ - Block, - Hash - } - - @optional_attrs ~w(from to l1_transaction_hash l1_timestamp)a - - @required_attrs ~w(msg_id l1_block_number)a - - @allowed_attrs @optional_attrs ++ @required_attrs - - @typedoc """ - * `msg_id` - id of the message - * `from` - source address of the message - * `to` - target address of the message - * `l1_transaction_hash` - hash of the L1 transaction containing the corresponding StateSynced event - * `l1_timestamp` - timestamp of the L1 transaction block - * `l1_block_number` - block number of the L1 transaction - """ - @primary_key false - typed_schema "polygon_edge_deposits" do - field(:msg_id, :integer, primary_key: true, null: false) - field(:from, Hash.Address) - field(:to, Hash.Address) - field(:l1_transaction_hash, Hash.Full) - field(:l1_timestamp, :utc_datetime_usec) - field(:l1_block_number, :integer) :: Block.block_number() - - timestamps() - end - - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = module, attrs \\ %{}) do - module - |> cast(attrs, @allowed_attrs) - |> validate_required(@required_attrs) - |> unique_constraint(:msg_id) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_edge/deposit_execute.ex b/apps/explorer/lib/explorer/chain/polygon_edge/deposit_execute.ex deleted file mode 100644 index 7e2ed2c64e5e..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_edge/deposit_execute.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Explorer.Chain.PolygonEdge.DepositExecute do - @moduledoc "Models Polygon Edge deposit execute." - - use Explorer.Schema - - alias Explorer.Chain.Hash - - @required_attrs ~w(msg_id l2_transaction_hash l2_block_number success)a - - @typedoc """ - * `msg_id` - id of the message - * `l2_transaction_hash` - hash of the L2 transaction containing the corresponding StateSyncResult event - * `l2_block_number` - block number of the L2 transaction - * `success` - a status of onStateReceive internal call (namely internal deposit transaction) - """ - @primary_key false - typed_schema "polygon_edge_deposit_executes" do - field(:msg_id, :integer, primary_key: true, null: false) - field(:l2_transaction_hash, Hash.Full, null: false) - field(:l2_block_number, :integer, null: false) - field(:success, :boolean, null: false) - - timestamps() - end - - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = module, attrs \\ %{}) do - module - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs) - |> unique_constraint(:msg_id) - |> unique_constraint(:l2_transaction_hash) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_edge/reader.ex b/apps/explorer/lib/explorer/chain/polygon_edge/reader.ex deleted file mode 100644 index 7f27ccf6048e..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_edge/reader.ex +++ /dev/null @@ -1,153 +0,0 @@ -defmodule Explorer.Chain.PolygonEdge.Reader do - @moduledoc "Contains read functions for Polygon Edge modules." - - import Ecto.Query, - only: [ - from: 2, - limit: 2 - ] - - import Explorer.Chain, only: [default_paging_options: 0, select_repo: 1] - - alias Explorer.{PagingOptions, Repo} - alias Explorer.Chain.PolygonEdge.{Deposit, DepositExecute, Withdrawal, WithdrawalExit} - alias Explorer.Chain.{Block, Hash} - - @spec deposits(list()) :: list() - def deposits(options \\ []) do - paging_options = Keyword.get(options, :paging_options, default_paging_options()) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - base_query = - from( - de in DepositExecute, - inner_join: d in Deposit, - on: d.msg_id == de.msg_id and not is_nil(d.l1_timestamp), - select: %{ - msg_id: de.msg_id, - from: d.from, - to: d.to, - l1_transaction_hash: d.l1_transaction_hash, - l1_timestamp: d.l1_timestamp, - success: de.success, - l2_transaction_hash: de.l2_transaction_hash - }, - order_by: [desc: de.msg_id] - ) - - base_query - |> page_deposits_or_withdrawals(paging_options) - |> limit(^paging_options.page_size) - |> select_repo(options).all() - end - end - - @spec deposits_count(list()) :: term() | nil - def deposits_count(options \\ []) do - query = - from( - de in DepositExecute, - inner_join: d in Deposit, - on: d.msg_id == de.msg_id and not is_nil(d.l1_timestamp) - ) - - select_repo(options).aggregate(query, :count, timeout: :infinity) - end - - @spec withdrawals(list()) :: list() - def withdrawals(options \\ []) do - paging_options = Keyword.get(options, :paging_options, default_paging_options()) - - case paging_options do - %PagingOptions{key: 0} -> - [] - - _ -> - base_query = - from( - w in Withdrawal, - left_join: we in WithdrawalExit, - on: we.msg_id == w.msg_id, - left_join: b in Block, - on: b.number == w.l2_block_number and b.consensus == true, - select: %{ - msg_id: w.msg_id, - from: w.from, - to: w.to, - l2_transaction_hash: w.l2_transaction_hash, - l2_timestamp: b.timestamp, - success: we.success, - l1_transaction_hash: we.l1_transaction_hash - }, - where: not is_nil(w.from), - order_by: [desc: w.msg_id] - ) - - base_query - |> page_deposits_or_withdrawals(paging_options) - |> limit(^paging_options.page_size) - |> select_repo(options).all() - end - end - - @spec withdrawals_count(list()) :: term() | nil - def withdrawals_count(options \\ []) do - query = - from( - w in Withdrawal, - where: not is_nil(w.from) - ) - - select_repo(options).aggregate(query, :count, timeout: :infinity) - end - - @spec deposit_by_transaction_hash(Hash.t()) :: Ecto.Schema.t() | term() | nil - def deposit_by_transaction_hash(hash) do - query = - from( - de in DepositExecute, - inner_join: d in Deposit, - on: d.msg_id == de.msg_id and not is_nil(d.from), - select: %{ - msg_id: de.msg_id, - from: d.from, - to: d.to, - success: de.success, - l1_transaction_hash: d.l1_transaction_hash - }, - where: de.l2_transaction_hash == ^hash - ) - - Repo.replica().one(query) - end - - @spec withdrawal_by_transaction_hash(Hash.t()) :: Ecto.Schema.t() | term() | nil - def withdrawal_by_transaction_hash(hash) do - query = - from( - w in Withdrawal, - left_join: we in WithdrawalExit, - on: we.msg_id == w.msg_id, - select: %{ - msg_id: w.msg_id, - from: w.from, - to: w.to, - success: we.success, - l1_transaction_hash: we.l1_transaction_hash - }, - where: w.l2_transaction_hash == ^hash and not is_nil(w.from) - ) - - Repo.replica().one(query) - end - - defp page_deposits_or_withdrawals(query, %PagingOptions{key: nil}), do: query - - defp page_deposits_or_withdrawals(query, %PagingOptions{key: {msg_id}}) do - from(item in query, where: item.msg_id < ^msg_id) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_edge/withdrawal.ex b/apps/explorer/lib/explorer/chain/polygon_edge/withdrawal.ex deleted file mode 100644 index 22b50b1ffa13..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_edge/withdrawal.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule Explorer.Chain.PolygonEdge.Withdrawal do - @moduledoc "Models Polygon Edge withdrawal." - - use Explorer.Schema - - alias Explorer.Chain.{ - Address, - Block, - Hash, - Transaction - } - - @optional_attrs ~w(from to)a - - @required_attrs ~w(msg_id l2_transaction_hash l2_block_number)a - - @allowed_attrs @optional_attrs ++ @required_attrs - - @typedoc """ - * `msg_id` - id of the message - * `from` - source address of the message - * `to` - target address of the message - * `l2_transaction_hash` - hash of the L2 transaction containing the corresponding L2StateSynced event - * `l2_block_number` - block number of the L2 transaction - """ - @primary_key false - typed_schema "polygon_edge_withdrawals" do - field(:msg_id, :integer, primary_key: true, null: false) - - belongs_to(:from_address, Address, foreign_key: :from, references: :hash, type: Hash.Address) - belongs_to(:to_address, Address, foreign_key: :to, references: :hash, type: Hash.Address) - - belongs_to(:l2_transaction, Transaction, - foreign_key: :l2_transaction_hash, - references: :hash, - type: Hash.Full, - null: false - ) - - belongs_to(:l2_block, Block, foreign_key: :l2_block_number, references: :number, type: :integer, null: false) - - timestamps() - end - - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = module, attrs \\ %{}) do - module - |> cast(attrs, @allowed_attrs) - |> validate_required(@required_attrs) - |> unique_constraint(:msg_id) - |> unique_constraint(:l2_transaction_hash) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_edge/withdrawal_exit.ex b/apps/explorer/lib/explorer/chain/polygon_edge/withdrawal_exit.ex deleted file mode 100644 index 959d3bfc68a3..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_edge/withdrawal_exit.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Explorer.Chain.PolygonEdge.WithdrawalExit do - @moduledoc "Models Polygon Edge withdrawal exit." - - use Explorer.Schema - - alias Explorer.Chain.{Block, Hash} - - @required_attrs ~w(msg_id l1_transaction_hash l1_block_number success)a - - @typedoc """ - * `msg_id` - id of the message - * `l1_transaction_hash` - hash of the L1 transaction containing the corresponding ExitProcessed event - * `l1_block_number` - block number of the L1 transaction - * `success` - a status of onL2StateReceive internal call (namely internal withdrawal transaction) - """ - @primary_key false - typed_schema "polygon_edge_withdrawal_exits" do - field(:msg_id, :integer, primary_key: true, null: false) - field(:l1_transaction_hash, Hash.Full, null: false) - field(:l1_block_number, :integer) :: Block.block_number() - field(:success, :boolean, null: false) - - timestamps() - end - - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = module, attrs \\ %{}) do - module - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs) - |> unique_constraint(:msg_id) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_zkevm/batch_transaction.ex b/apps/explorer/lib/explorer/chain/polygon_zkevm/batch_transaction.ex deleted file mode 100644 index 18d8a775a1b3..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_zkevm/batch_transaction.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Explorer.Chain.PolygonZkevm.BatchTransaction do - @moduledoc "Models a list of transactions related to a batch for zkEVM." - - use Explorer.Schema - - alias Explorer.Chain.{Hash, Transaction} - alias Explorer.Chain.PolygonZkevm.TransactionBatch - - @required_attrs ~w(batch_number hash)a - - @primary_key false - typed_schema "polygon_zkevm_batch_l2_transactions" do - belongs_to(:batch, TransactionBatch, foreign_key: :batch_number, references: :number, type: :integer, null: false) - - belongs_to(:l2_transaction, Transaction, - foreign_key: :hash, - primary_key: true, - references: :hash, - type: Hash.Full, - null: false - ) - - timestamps(null: false) - end - - @doc """ - Validates that the `attrs` are valid. - """ - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = transactions, attrs \\ %{}) do - transactions - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs) - |> foreign_key_constraint(:batch_number) - |> unique_constraint(:hash) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_zkevm/bridge.ex b/apps/explorer/lib/explorer/chain/polygon_zkevm/bridge.ex deleted file mode 100644 index c0623f8fbf52..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_zkevm/bridge.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule Explorer.Chain.PolygonZkevm.Bridge do - @moduledoc "Models a bridge operation for Polygon zkEVM." - - use Explorer.Schema - - alias Explorer.Chain.{Block, Hash, Token} - alias Explorer.Chain.PolygonZkevm.BridgeL1Token - - @optional_attrs ~w(l1_transaction_hash l2_transaction_hash l1_token_id l2_token_address block_number block_timestamp)a - - @required_attrs ~w(type index amount)a - - @type t :: %__MODULE__{ - type: String.t(), - index: non_neg_integer(), - l1_transaction_hash: Hash.t() | nil, - l2_transaction_hash: Hash.t() | nil, - l1_token: %Ecto.Association.NotLoaded{} | BridgeL1Token.t() | nil, - l1_token_id: non_neg_integer() | nil, - l1_token_address: Hash.Address.t() | nil, - l2_token: %Ecto.Association.NotLoaded{} | Token.t() | nil, - l2_token_address: Hash.Address.t() | nil, - amount: Decimal.t(), - block_number: Block.block_number() | nil, - block_timestamp: DateTime.t() | nil - } - - @primary_key false - schema "polygon_zkevm_bridge" do - field(:type, Ecto.Enum, values: [:deposit, :withdrawal], primary_key: true) - field(:index, :integer, primary_key: true) - field(:l1_transaction_hash, Hash.Full) - field(:l2_transaction_hash, Hash.Full) - belongs_to(:l1_token, BridgeL1Token, foreign_key: :l1_token_id, references: :id, type: :integer) - field(:l1_token_address, Hash.Address) - belongs_to(:l2_token, Token, foreign_key: :l2_token_address, references: :contract_address_hash, type: Hash.Address) - field(:amount, :decimal) - field(:block_number, :integer) - field(:block_timestamp, :utc_datetime_usec) - - timestamps() - end - - @doc """ - Checks that the `attrs` are valid. - """ - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = operations, attrs \\ %{}) do - operations - |> cast(attrs, @required_attrs ++ @optional_attrs) - |> validate_required(@required_attrs) - |> unique_constraint([:type, :index]) - |> foreign_key_constraint(:l1_token_id) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_zkevm/bridge_l1_token.ex b/apps/explorer/lib/explorer/chain/polygon_zkevm/bridge_l1_token.ex deleted file mode 100644 index c3187c28ea40..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_zkevm/bridge_l1_token.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Explorer.Chain.PolygonZkevm.BridgeL1Token do - @moduledoc "Models a bridge token on L1 for Polygon zkEVM." - - use Explorer.Schema - - alias Explorer.Chain.Hash - - @optional_attrs ~w(decimals symbol)a - - @required_attrs ~w(address)a - - @type t :: %__MODULE__{ - address: Hash.Address.t(), - decimals: non_neg_integer() | nil, - symbol: String.t() | nil - } - - @primary_key {:id, :id, autogenerate: true} - schema "polygon_zkevm_bridge_l1_tokens" do - field(:address, Hash.Address) - field(:decimals, :integer) - field(:symbol, :string) - - timestamps() - end - - @doc """ - Checks that the `attrs` are valid. - """ - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = tokens, attrs \\ %{}) do - tokens - |> cast(attrs, @required_attrs ++ @optional_attrs) - |> validate_required(@required_attrs) - |> unique_constraint(:id) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_zkevm/lifecycle_transaction.ex b/apps/explorer/lib/explorer/chain/polygon_zkevm/lifecycle_transaction.ex deleted file mode 100644 index e1ec0a7abc30..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_zkevm/lifecycle_transaction.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Explorer.Chain.PolygonZkevm.LifecycleTransaction do - @moduledoc "Models an L1 lifecycle transaction for zkEVM." - - use Explorer.Schema - - alias Explorer.Chain.Hash - alias Explorer.Chain.PolygonZkevm.TransactionBatch - - @required_attrs ~w(id hash is_verify)a - - @primary_key false - typed_schema "polygon_zkevm_lifecycle_l1_transactions" do - field(:id, :integer, primary_key: true, null: false) - field(:hash, Hash.Full, null: false) - field(:is_verify, :boolean, null: false) - - has_many(:sequenced_batches, TransactionBatch, foreign_key: :sequence_id, references: :id) - has_many(:verified_batches, TransactionBatch, foreign_key: :verify_id, references: :id) - - timestamps() - end - - @doc """ - Validates that the `attrs` are valid. - """ - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = transaction, attrs \\ %{}) do - transaction - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs) - |> unique_constraint(:id) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_zkevm/reader.ex b/apps/explorer/lib/explorer/chain/polygon_zkevm/reader.ex deleted file mode 100644 index fc4527cd522f..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_zkevm/reader.ex +++ /dev/null @@ -1,394 +0,0 @@ -defmodule Explorer.Chain.PolygonZkevm.Reader do - @moduledoc "Contains read functions for zkevm modules." - - import Ecto.Query, - only: [ - from: 2, - limit: 2, - order_by: 2, - where: 2, - where: 3 - ] - - import Explorer.Chain, only: [select_repo: 1] - - alias Explorer.Chain.PolygonZkevm.{BatchTransaction, Bridge, BridgeL1Token, LifecycleTransaction, TransactionBatch} - alias Explorer.{Chain, PagingOptions, Repo} - alias Explorer.Prometheus.Instrumenter - alias Indexer.Helper - - @doc """ - Reads a batch by its number from database. - If the number is :latest, gets the latest batch from `polygon_zkevm_transaction_batches` table. - Returns {:error, :not_found} in case the batch is not found. - """ - @spec batch(non_neg_integer() | :latest, list()) :: {:ok, map()} | {:error, :not_found} - def batch(number, options \\ []) - - def batch(:latest, options) when is_list(options) do - TransactionBatch - |> order_by(desc: :number) - |> limit(1) - |> select_repo(options).one() - |> case do - nil -> {:error, :not_found} - batch -> {:ok, batch} - end - end - - def batch(number, options) when is_list(options) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - - TransactionBatch - |> where(number: ^number) - |> Chain.join_associations(necessity_by_association) - |> select_repo(options).one() - |> case do - nil -> {:error, :not_found} - batch -> {:ok, batch} - end - end - - @doc """ - Reads a list of batches from `polygon_zkevm_transaction_batches` table. - """ - @spec batches(list()) :: list() - def batches(options \\ []) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - - base_query = - from(tb in TransactionBatch, - order_by: [desc: tb.number] - ) - - query = - if Keyword.get(options, :confirmed?, false) do - base_query - |> Chain.join_associations(necessity_by_association) - |> where([tb], not is_nil(tb.sequence_id) and tb.sequence_id > 0) - |> limit(10) - else - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - base_query - |> Chain.join_associations(necessity_by_association) - |> page_batches(paging_options) - |> limit(^paging_options.page_size) - end - end - - select_repo(options).all(query) - end - - @doc """ - Reads a list of L2 transaction hashes from `polygon_zkevm_batch_l2_transactions` table. - """ - @spec batch_transactions(non_neg_integer(), list()) :: list() - def batch_transactions(batch_number, options \\ []) do - query = from(bts in BatchTransaction, where: bts.batch_number == ^batch_number) - - select_repo(options).all(query) - end - - @doc """ - Tries to read L1 token data (address, symbol, decimals) for the given addresses - from the database. If the data for an address is not found in Explorer.Chain.PolygonZkevm.BridgeL1Token, - the address is returned in the list inside the tuple (the second item of the tuple). - The first item of the returned tuple contains `L1 token address -> L1 token data` map. - """ - @spec get_token_data_from_db(list()) :: {map(), list()} - def get_token_data_from_db(token_addresses) do - # try to read token symbols and decimals from the database - query = - from( - t in BridgeL1Token, - where: t.address in ^token_addresses, - select: {t.address, t.decimals, t.symbol} - ) - - token_data = - query - |> Repo.all() - |> Enum.reduce(%{}, fn {address, decimals, symbol}, acc -> - token_address = Helper.address_hash_to_string(address, true) - Map.put(acc, token_address, %{symbol: symbol, decimals: decimals}) - end) - - token_addresses_for_rpc = - token_addresses - |> Enum.reject(fn address -> - Map.has_key?(token_data, Helper.address_hash_to_string(address, true)) - end) - - {token_data, token_addresses_for_rpc} - end - - @doc """ - Gets last known L1 item (deposit) from polygon_zkevm_bridge table. - Returns block number and L1 transaction hash bound to that deposit. - If not found, returns zero block number and nil as the transaction hash. - """ - @spec last_l1_item() :: {non_neg_integer(), binary() | nil} - def last_l1_item do - query = - from(b in Bridge, - select: {b.block_number, b.l1_transaction_hash}, - where: b.type == :deposit and not is_nil(b.block_number), - order_by: [desc: b.index], - limit: 1 - ) - - query - |> Repo.one() - |> Kernel.||({0, nil}) - end - - @doc """ - Gets last known L2 item (withdrawal) from polygon_zkevm_bridge table. - Returns block number and L2 transaction hash bound to that withdrawal. - If not found, returns zero block number and nil as the transaction hash. - """ - @spec last_l2_item() :: {non_neg_integer(), binary() | nil} - def last_l2_item do - query = - from(b in Bridge, - select: {b.block_number, b.l2_transaction_hash}, - where: b.type == :withdrawal and not is_nil(b.block_number), - order_by: [desc: b.index], - limit: 1 - ) - - query - |> Repo.one() - |> Kernel.||({0, nil}) - end - - @doc """ - Gets the number of the latest batch with defined verify_id from `polygon_zkevm_transaction_batches` table. - Returns 0 if not found. - """ - @spec last_verified_batch_number() :: non_neg_integer() - def last_verified_batch_number do - query = - from(tb in TransactionBatch, - select: tb.number, - where: not is_nil(tb.verify_id), - order_by: [desc: tb.number], - limit: 1 - ) - - query - |> Repo.one() - |> Kernel.||(0) - end - - @doc """ - Reads a list of L1 transactions by their hashes from `polygon_zkevm_lifecycle_l1_transactions` table. - """ - @spec lifecycle_transactions(list()) :: list() - def lifecycle_transactions(l1_transaction_hashes) do - query = - from( - lt in LifecycleTransaction, - select: {lt.hash, lt.id}, - where: lt.hash in ^l1_transaction_hashes - ) - - Repo.all(query, timeout: :infinity) - end - - @doc """ - Determines ID of the future lifecycle transaction by reading `polygon_zkevm_lifecycle_l1_transactions` table. - """ - @spec next_id() :: non_neg_integer() - def next_id do - query = - from(lt in LifecycleTransaction, - select: lt.id, - order_by: [desc: lt.id], - limit: 1 - ) - - last_id = - query - |> Repo.one() - |> Kernel.||(0) - - last_id + 1 - end - - @doc """ - Builds `L1 token address -> L1 token id` map for the given token addresses. - The info is taken from Explorer.Chain.PolygonZkevm.BridgeL1Token. - If an address is not in the table, it won't be in the resulting map. - """ - @spec token_addresses_to_ids_from_db(list()) :: map() - def token_addresses_to_ids_from_db(addresses) do - query = from(t in BridgeL1Token, select: {t.address, t.id}, where: t.address in ^addresses) - - query - |> Repo.all(timeout: :infinity) - |> Enum.reduce(%{}, fn {address, id}, acc -> - Map.put(acc, Helper.address_hash_to_string(address), id) - end) - end - - @doc """ - Retrieves a list of Polygon zkEVM deposits (completed and unclaimed) - sorted in descending order of the index. - """ - @spec deposits(list()) :: list() - def deposits(options \\ []) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - base_query = - from( - b in Bridge, - left_join: t1 in assoc(b, :l1_token), - left_join: t2 in assoc(b, :l2_token), - where: b.type == :deposit and not is_nil(b.l1_transaction_hash), - preload: [l1_token: t1, l2_token: t2], - order_by: [desc: b.index] - ) - - base_query - |> page_deposits_or_withdrawals(paging_options) - |> limit(^paging_options.page_size) - |> select_repo(options).all() - end - end - - @doc """ - Returns a total number of Polygon zkEVM deposits (completed and unclaimed). - """ - @spec deposits_count(list()) :: term() | nil - def deposits_count(options \\ []) do - query = - from( - b in Bridge, - where: b.type == :deposit and not is_nil(b.l1_transaction_hash) - ) - - select_repo(options).aggregate(query, :count, timeout: :infinity) - end - - @doc """ - Retrieves a list of Polygon zkEVM withdrawals (completed and unclaimed) - sorted in descending order of the index. - """ - @spec withdrawals(list()) :: list() - def withdrawals(options \\ []) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - - case paging_options do - %PagingOptions{key: {0}} -> - [] - - _ -> - base_query = - from( - b in Bridge, - left_join: t1 in assoc(b, :l1_token), - left_join: t2 in assoc(b, :l2_token), - where: b.type == :withdrawal and not is_nil(b.l2_transaction_hash), - preload: [l1_token: t1, l2_token: t2], - order_by: [desc: b.index] - ) - - base_query - |> page_deposits_or_withdrawals(paging_options) - |> limit(^paging_options.page_size) - |> select_repo(options).all() - end - end - - @doc """ - Returns a total number of Polygon zkEVM withdrawals (completed and unclaimed). - """ - @spec withdrawals_count(list()) :: term() | nil - def withdrawals_count(options \\ []) do - query = - from( - b in Bridge, - where: b.type == :withdrawal and not is_nil(b.l2_transaction_hash) - ) - - select_repo(options).aggregate(query, :count, timeout: :infinity) - end - - @doc """ - Filters token decimals value (cannot be greater than 0xFF). - """ - @spec sanitize_decimals(non_neg_integer()) :: non_neg_integer() - def sanitize_decimals(decimals) do - if decimals > 0xFF do - 0 - else - decimals - end - end - - @doc """ - Filters token symbol (cannot be longer than 16 characters). - """ - @spec sanitize_symbol(String.t()) :: String.t() - def sanitize_symbol(symbol) do - String.slice(symbol, 0, 16) - end - - defp page_batches(query, %PagingOptions{key: nil}), do: query - - defp page_batches(query, %PagingOptions{key: {number}}) do - from(tb in query, where: tb.number < ^number) - end - - defp page_deposits_or_withdrawals(query, %PagingOptions{key: nil}), do: query - - defp page_deposits_or_withdrawals(query, %PagingOptions{key: {index}}) do - from(b in query, where: b.index < ^index) - end - - @doc """ - Gets information about the latest finalized batch and calculates average time between finalized batches, in seconds. - - ## Parameters - - `options`: A keyword list of options that may include whether to use a replica database. - - ## Returns - - If at least two batches exist: - `{:ok, %{latest_batch_number: integer, latest_batch_timestamp: DateTime.t(), average_batch_time: integer}}` - where: - * latest_batch_number - id of the latest batch in the database. - * latest_batch_timestamp - when the latest batch was committed to L1. - * average_batch_time - average number of seconds between batches for the last 100 batches. - - - If less than two batches exist: `{:error, :not_found}`. - """ - @spec get_latest_batch_info(keyword()) :: {:ok, map()} | {:error, :not_found} - def get_latest_batch_info(options \\ []) do - query = - from(tb in TransactionBatch, - where: not is_nil(tb.timestamp), - order_by: [desc: tb.number], - limit: 100, - select: %{ - number: tb.number, - timestamp: tb.timestamp - } - ) - - items = select_repo(options).all(query) - - Instrumenter.prepare_batch_metric(items) - end -end diff --git a/apps/explorer/lib/explorer/chain/polygon_zkevm/transaction_batch.ex b/apps/explorer/lib/explorer/chain/polygon_zkevm/transaction_batch.ex deleted file mode 100644 index 92ca1dd21527..000000000000 --- a/apps/explorer/lib/explorer/chain/polygon_zkevm/transaction_batch.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule Explorer.Chain.PolygonZkevm.TransactionBatch do - @moduledoc "Models a batch of transactions for zkEVM." - - use Explorer.Schema - - alias Explorer.Chain.Hash - alias Explorer.Chain.PolygonZkevm.{BatchTransaction, LifecycleTransaction} - - @optional_attrs ~w(timestamp sequence_id verify_id)a - - @required_attrs ~w(number l2_transactions_count global_exit_root acc_input_hash state_root)a - - @primary_key false - typed_schema "polygon_zkevm_transaction_batches" do - field(:number, :integer, primary_key: true, null: false) - field(:timestamp, :utc_datetime_usec) - field(:l2_transactions_count, :integer) - field(:global_exit_root, Hash.Full) - field(:acc_input_hash, Hash.Full) - field(:state_root, Hash.Full) - - belongs_to(:sequence_transaction, LifecycleTransaction, - foreign_key: :sequence_id, - references: :id, - type: :integer - ) - - belongs_to(:verify_transaction, LifecycleTransaction, foreign_key: :verify_id, references: :id, type: :integer) - - has_many(:l2_transactions, BatchTransaction, foreign_key: :batch_number, references: :number) - - timestamps() - end - - @doc """ - Validates that the `attrs` are valid. - """ - @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() - def changeset(%__MODULE__{} = batches, attrs \\ %{}) do - batches - |> cast(attrs, @required_attrs ++ @optional_attrs) - |> validate_required(@required_attrs) - |> foreign_key_constraint(:sequence_id) - |> foreign_key_constraint(:verify_id) - |> unique_constraint(:number) - end -end diff --git a/apps/explorer/lib/explorer/chain/rollup_reorg_monitor_queue.ex b/apps/explorer/lib/explorer/chain/rollup_reorg_monitor_queue.ex deleted file mode 100644 index 7a7365984eda..000000000000 --- a/apps/explorer/lib/explorer/chain/rollup_reorg_monitor_queue.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule Explorer.Chain.RollupReorgMonitorQueue do - @moduledoc """ - A module containing (encapsulating) the reorg monitor queue and functions to manage it. - Mostly used by the `Indexer.Fetcher.RollupL1ReorgMonitor` module. - """ - - alias Explorer.BoundQueue - - @doc """ - Pops the number of reorg block from the front of the queue for the specified rollup module. - - ## Parameters - - `module`: The module for which the block number is popped from the queue. - - ## Returns - - The popped block number. - - `nil` if the reorg queue is empty. - """ - @spec reorg_block_pop(module()) :: non_neg_integer() | nil - def reorg_block_pop(module) do - table_name = reorg_table_name(module) - - case BoundQueue.pop_front(reorg_queue_get(table_name)) do - {:ok, {block_number, updated_queue}} -> - :ets.insert(table_name, {:queue, updated_queue}) - block_number - - {:error, :empty} -> - nil - end - end - - @doc """ - Pushes the number of reorg block to the back of the queue for the specified rollup module. - - ## Parameters - - `block_number`: The reorg block number. - - `module`: The module for which the block number is pushed to the queue. - - ## Returns - - Nothing is returned. - """ - @spec reorg_block_push(non_neg_integer(), module()) :: any() - def reorg_block_push(block_number, module) do - table_name = reorg_table_name(module) - {:ok, updated_queue} = BoundQueue.push_back(reorg_queue_get(table_name), block_number) - :ets.insert(table_name, {:queue, updated_queue}) - end - - # Reads a block number queue instance from the ETS table associated with the queue. - # The table name depends on the module name and formed by the `reorg_table_name` function. - # - # ## Parameters - # - `table_name`: The ETS table name of the queue. - # - # ## Returns - # - `BoundQueue` instance for the queue. The queue may be empty (then %BoundQueue{} is returned). - @spec reorg_queue_get(atom()) :: BoundQueue.t(any()) - defp reorg_queue_get(table_name) do - if :ets.whereis(table_name) == :undefined do - :ets.new(table_name, [ - :set, - :named_table, - :public, - read_concurrency: true, - write_concurrency: true - ]) - end - - with info when info != :undefined <- :ets.info(table_name), - [{_, value}] <- :ets.lookup(table_name, :queue) do - value - else - _ -> %BoundQueue{} - end - end - - # Forms an ETS table name for the block number queue for the given module name. - # - # ## Parameters - # - `module`: The module name (instance) for which the ETS table name should be formed. - # - # ## Returns - # - An atom defining the table name. - # - # sobelow_skip ["DOS.BinToAtom"] - @spec reorg_table_name(module()) :: atom() - defp reorg_table_name(module) do - :"#{module}#{:_reorgs}" - end -end diff --git a/apps/explorer/lib/explorer/chain/scroll/batch.ex b/apps/explorer/lib/explorer/chain/scroll/batch.ex index af560fe4f105..a6397b0edf71 100644 --- a/apps/explorer/lib/explorer/chain/scroll/batch.ex +++ b/apps/explorer/lib/explorer/chain/scroll/batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Scroll.Batch do @moduledoc """ Models a batch for Scroll. @@ -21,7 +22,7 @@ defmodule Explorer.Chain.Scroll.Batch do @required_attrs ~w(number commit_transaction_hash commit_block_number commit_timestamp container)a @zstd_magic_number <<0x28, 0xB5, 0x2F, 0xFD>> - @codec_version 7 + @codec_min_version 7 @typedoc """ Descriptor of the batch: @@ -103,7 +104,7 @@ defmodule Explorer.Chain.Scroll.Batch do <> = blob_data_raw # ensure we have correct version, blob envelope size doesn't exceed the raw data size, and compression flag is correct - with {:version_is_supported, true} <- {:version_is_supported, version == @codec_version}, + with {:version_is_supported, true} <- {:version_is_supported, version >= @codec_min_version}, {:size_is_correct, true} <- {:size_is_correct, blob_payload_size + 5 <= byte_size(blob_data_raw)}, {:compression_flag_is_correct, true} <- {:compression_flag_is_correct, is_compressed in [0, 1]}, @@ -113,7 +114,7 @@ defmodule Explorer.Chain.Scroll.Batch do decompressed else {:version_is_supported, false} -> - Logger.error("Codec version #{version} is not supported. Expected: #{@codec_version}.") + Logger.error("Codec version #{version} is not supported. Expected: >=#{@codec_min_version}") nil {:size_is_correct, false} -> diff --git a/apps/explorer/lib/explorer/chain/scroll/batch_bundle.ex b/apps/explorer/lib/explorer/chain/scroll/batch_bundle.ex index 5a36bf72e359..f05ab5b21bc5 100644 --- a/apps/explorer/lib/explorer/chain/scroll/batch_bundle.ex +++ b/apps/explorer/lib/explorer/chain/scroll/batch_bundle.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Scroll.BatchBundle do @moduledoc """ Models a batch bundle for Scroll. diff --git a/apps/explorer/lib/explorer/chain/scroll/bridge.ex b/apps/explorer/lib/explorer/chain/scroll/bridge.ex index 6cfd280e0b7f..1c16f472dd79 100644 --- a/apps/explorer/lib/explorer/chain/scroll/bridge.ex +++ b/apps/explorer/lib/explorer/chain/scroll/bridge.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Scroll.Bridge do @moduledoc """ Models a bridge operation for Scroll. diff --git a/apps/explorer/lib/explorer/chain/scroll/l1_fee_param.ex b/apps/explorer/lib/explorer/chain/scroll/l1_fee_param.ex index a9f55ae28750..be0d63e2d43f 100644 --- a/apps/explorer/lib/explorer/chain/scroll/l1_fee_param.ex +++ b/apps/explorer/lib/explorer/chain/scroll/l1_fee_param.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Scroll.L1FeeParam do @moduledoc """ Models an L1 fee parameter for Scroll. diff --git a/apps/explorer/lib/explorer/chain/scroll/reader.ex b/apps/explorer/lib/explorer/chain/scroll/reader.ex index a53487debdd1..361cbe674111 100644 --- a/apps/explorer/lib/explorer/chain/scroll/reader.ex +++ b/apps/explorer/lib/explorer/chain/scroll/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Scroll.Reader do @moduledoc "Contains read functions for Scroll modules." @@ -14,9 +15,9 @@ defmodule Explorer.Chain.Scroll.Reader do import Explorer.Chain, only: [select_repo: 1] - alias Explorer.Chain.Scroll.{Batch, BatchBundle, Bridge, L1FeeParam} alias Explorer.{Chain, PagingOptions, Repo} alias Explorer.Chain.{Block, Transaction} + alias Explorer.Chain.Scroll.{Batch, BatchBundle, Bridge, L1FeeParam} alias Explorer.Prometheus.Instrumenter @doc """ @@ -300,8 +301,7 @@ defmodule Explorer.Chain.Scroll.Reader do base_query, [p], p.name == ^name and - (p.block_number < ^transaction.block_number or - (p.block_number == ^transaction.block_number and p.transaction_index < ^transaction.index)) + {p.block_number, p.transaction_index} < {^transaction.block_number, ^transaction.index} ) end diff --git a/apps/explorer/lib/explorer/chain/search.ex b/apps/explorer/lib/explorer/chain/search.ex index cfdacb6e997e..7e6e719b2f91 100644 --- a/apps/explorer/lib/explorer/chain/search.ex +++ b/apps/explorer/lib/explorer/chain/search.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Search do @moduledoc """ Search-related functions @@ -29,7 +30,7 @@ defmodule Explorer.Chain.Search do UserOperation } - alias Explorer.MicroserviceInterfaces.Metadata + alias Explorer.MicroserviceInterfaces.{Metadata, TACOperationLifecycle} use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] @@ -52,108 +53,121 @@ defmodule Explorer.Chain.Search do @label_sorting [{:asc, :display_name, :address_tag}, {:desc, :inserted_at, :address_to_tag}] + defp search_result(nil, _paging_options, _query_string, _options), do: {[], nil} + + defp search_result({:address_hash, address_hash}, paging_options, query_string, options) do + tac_operation_task = Task.async(fn -> search_tac_operations(query_string, paging_options) end) + + addresses_and_tokens = address_hash_search_if_first_page(paging_options, address_hash, options) + + %{items: tac_operation_results, next_page_params: tac_operation_next_page_params} = + await_task_with_paging(tac_operation_task) + + trim_list_and_prepare_next_page_params( + addresses_and_tokens ++ tac_operation_results, + paging_options, + query_string, + %{}, + !is_nil(tac_operation_next_page_params) + ) + end + + defp search_result({:ton_address, _ton_address}, paging_options, query_string, _options) do + tac_operation_task = Task.async(fn -> search_tac_operations(query_string, paging_options) end) + + %{items: tac_operation_results, next_page_params: tac_operation_next_page_params} = + await_task_with_paging(tac_operation_task) + + trim_list_and_prepare_next_page_params( + tac_operation_results, + paging_options, + query_string, + %{}, + !is_nil(tac_operation_next_page_params) + ) + end + + if @chain_type == :filecoin do + defp search_result({:filecoin, filecoin_address}, _paging_options, _query_string, options) do + {filecoin_address + |> address_by_filecoin_id_or_robust() + |> select_repo(options).all(), nil} + end + end + + defp search_result({:full_hash, full_hash}, paging_options, query_string, options) do + tac_operation_task = Task.async(fn -> search_tac_operations(query_string, paging_options) end) + results = full_hash_search_if_first_page(paging_options, full_hash, options) + + %{items: tac_operation_results, next_page_params: tac_operation_next_page_params} = + await_task_with_paging(tac_operation_task) + + trim_list_and_prepare_next_page_params( + results ++ tac_operation_results, + paging_options, + query_string, + %{}, + !is_nil(tac_operation_next_page_params) + ) + end + + defp search_result({:number, block_number}, _paging_options, _query_string, options) do + {block_number + |> search_block_by_number_query() + |> select_repo(options).all(), nil} + end + + defp search_result([{:number, block_number}, {:text, prepared_term}], paging_options, query_string, options) do + prepared_term + |> search_by_string(paging_options, [], options) + |> union_all(^search_block_by_number_query(block_number)) + |> order_and_page_text_search_result(paging_options) + |> select_repo(options).all() + |> trim_list_and_prepare_next_page_params(paging_options, query_string, %{}, false) + end + + defp search_result({:text, prepared_term}, paging_options, query_string, options) do + ens_task = run_ens_task_if_first_page(paging_options, query_string, options) + + %{items: metadata_tags, next_page_params: metadata_next_page_params} = + maybe_fetch_metadata_tags( + query_string, + parse_possible_nil(paging_options.key[:metadata_tag]["metadata_next_page_params"]), + ExplorerHelper.parse_boolean(paging_options.key[:metadata_tag]["end_of_tags"]) + ) + + paginated_metadata_tags = page_metadata_tags(metadata_tags, paging_options) + + items = + prepared_term + |> search_by_string(paging_options, paginated_metadata_tags, options) + |> order_and_page_text_search_result(paging_options) + |> select_repo(options).all() + + ens_result = (ens_task && await_task(ens_task)) || [] + + (ens_result ++ items) + |> trim_list_and_prepare_next_page_params( + paging_options, + query_string, + %{ + metadata_next_page_params: metadata_next_page_params + }, + !is_nil(metadata_next_page_params) + ) + end + @doc """ Search function used in web interface and API v2. Returns paginated search results """ @spec joint_search(PagingOptions.t(), binary(), [Chain.api?() | Chain.show_scam_tokens?()]) :: {list(), map() | nil} - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def joint_search(paging_options, query_string, options \\ []) do query_string = String.trim(query_string) {search_results, next_page_params} = query_string |> prepare_search_query(prepare_search_term(query_string)) - |> case do - nil -> - {[], nil} - - {:address_hash, address_hash} -> - {address_hash - |> search_token_by_address_hash_query() - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) - |> union_all( - ^(address_hash - |> search_address_by_address_hash_query()) - ) - |> select_repo(options).all(), nil} - - {:filecoin, filecoin_address} -> - {filecoin_address - |> address_by_filecoin_id_or_robust() - |> select_repo(options).all(), nil} - - {:full_hash, full_hash} -> - transaction_block_query = - full_hash - |> search_transaction_query() - |> union_all(^search_block_by_hash_query(full_hash)) - - transaction_block_op_query = - if UserOperation.enabled?() do - user_operation_query = search_user_operation_query(full_hash) - - transaction_block_query - |> union_all(^user_operation_query) - else - transaction_block_query - end - - result_query = - if Application.get_env(:explorer, :chain_type) == :ethereum do - blob_query = search_blob_query(full_hash) - - transaction_block_op_query - |> union_all(^blob_query) - else - transaction_block_op_query - end - - {result_query - |> select_repo(options).all(), nil} - - {:number, block_number} -> - {block_number - |> search_block_by_number_query() - |> select_repo(options).all(), nil} - - [{:number, block_number}, {:text, prepared_term}] -> - prepared_term - |> search_by_string(paging_options, [], options) - |> union_all(^search_block_by_number_query(block_number)) - |> order_and_page_text_search_result(paging_options) - |> select_repo(options).all() - |> trim_list_and_prepare_next_page_params(paging_options, query_string, %{}, false) - - {:text, prepared_term} -> - ens_task = run_ens_task_if_first_page(paging_options, query_string, options) - - %{items: metadata_tags, next_page_params: metadata_next_page_params} = - maybe_fetch_metadata_tags( - query_string, - parse_possible_nil(paging_options.key["metadata_tag"]["metadata_next_page_params"]), - ExplorerHelper.parse_boolean(paging_options.key["metadata_tag"]["end_of_tags"]) - ) - - paginated_metadata_tags = page_metadata_tags(metadata_tags, paging_options) - - items = - prepared_term - |> search_by_string(paging_options, paginated_metadata_tags, options) - |> order_and_page_text_search_result(paging_options) - |> select_repo(options).all() - - ens_result = (ens_task && await_ens_task(ens_task)) || [] - - (ens_result ++ items) - |> trim_list_and_prepare_next_page_params( - paging_options, - query_string, - %{ - metadata_next_page_params: metadata_next_page_params - }, - !is_nil(metadata_next_page_params) - ) - end + |> search_result(paging_options, query_string, options) prepared_results = search_results @@ -166,6 +180,49 @@ defmodule Explorer.Chain.Search do {prepared_results, next_page_params} end + defp address_hash_search_if_first_page(%PagingOptions{key: nil}, address_hash, options) do + address_hash + |> search_token_by_address_hash_query(options) + |> union_all( + ^(address_hash + |> search_address_by_address_hash_query()) + ) + |> select_repo(options).all() + end + + defp address_hash_search_if_first_page(_, _address_hash, _options), do: [] + + defp full_hash_search_if_first_page(%PagingOptions{key: nil}, full_hash, options) do + transaction_block_query = + full_hash + |> search_transaction_query() + |> union_all(^search_block_by_hash_query(full_hash)) + + transaction_block_op_query = + if UserOperation.enabled?() do + user_operation_query = search_user_operation_query(full_hash) + + transaction_block_query + |> union_all(^user_operation_query) + else + transaction_block_query + end + + result_query = + if Application.get_env(:explorer, :chain_type) == :ethereum do + blob_query = search_blob_query(full_hash) + + transaction_block_op_query + |> union_all(^blob_query) + else + transaction_block_op_query + end + + select_repo(options).all(result_query) + end + + defp full_hash_search_if_first_page(_, _full_hash, _options), do: [] + defp order_and_page_text_search_result(query, paging_options) do query |> subquery() @@ -186,7 +243,7 @@ defmodule Explorer.Chain.Search do defp maybe_fetch_metadata_tags(query_string, next_page_params, false) do metadata_task = Task.async(fn -> Metadata.search_tags_by_name(query_string, next_page_params) end) - await_metadata_task(metadata_task) + await_task_with_paging(metadata_task) end defp maybe_fetch_metadata_tags(_query_string, _next_page_params, true) do @@ -194,7 +251,7 @@ defmodule Explorer.Chain.Search do end defp page_metadata_tags(tags, paging_options) do - case (paging_options.key || %{})["metadata_tag"] do + case (paging_options.key || %{})[:metadata_tag] do %{"addresses_index" => addresses_index} -> Enum.drop(tags, ExplorerHelper.parse_integer(addresses_index)) @@ -203,49 +260,65 @@ defmodule Explorer.Chain.Search do end end - @spec prepare_search_query(binary(), {:some, binary()} | :none) :: + defp prepare_results(query) do + %{ + address_hash_result: Chain.string_to_address_hash(query), + ton_address_result: Ton.parse_address(query), + filecoin_address_result: maybe_parse_filecoin_address(query), + full_hash_result: Chain.string_to_full_hash(query), + non_negative_integer_result: ExplorerHelper.safe_parse_non_negative_integer(query), + query_length: String.length(query) + } + end + + @type base_search_results :: {:address_hash, Hash.Address.t()} - | {:filecoin, any()} + | {:ton_address, Ton.Address.t()} | {:full_hash, Hash.t()} | {:number, non_neg_integer()} | [{:number, non_neg_integer()}, {:text, binary()}] | {:text, binary()} | nil - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defp prepare_search_query(query, {:some, prepared_term}) do - address_hash_result = Chain.string_to_address_hash(query) - filecoin_address_result = maybe_parse_filecoin_address(query) - full_hash_result = Chain.string_to_transaction_hash(query) - non_negative_integer_result = ExplorerHelper.safe_parse_non_negative_integer(query) - query_length = String.length(query) - cond do - match?({:ok, _hash}, address_hash_result) -> - {:ok, hash} = address_hash_result - {:address_hash, hash} + case @chain_type do + :filecoin -> + @type search_results :: base_search_results() | {:filecoin, any()} - match?({:ok, _address}, filecoin_address_result) -> - {:ok, filecoin_address} = filecoin_address_result - {:filecoin, filecoin_address} + _ -> + @type search_results :: base_search_results() + end - match?({:ok, _hash}, full_hash_result) -> - {:ok, hash} = full_hash_result - {:full_hash, hash} + @spec match_search_result(map(), binary()) :: search_results() + defp match_search_result(%{address_hash_result: {:ok, address_hash}}, _prepared_term), + do: {:address_hash, address_hash} - match?({:ok, _block_number}, non_negative_integer_result) and query_length < @min_query_length -> - {:ok, block_number} = non_negative_integer_result - {:number, block_number} + defp match_search_result(%{ton_address_result: {:ok, ton_address}}, _prepared_term), do: {:ton_address, ton_address} - match?({:ok, _block_number}, non_negative_integer_result) and query_length >= @min_query_length -> - {:ok, block_number} = non_negative_integer_result - [{:number, block_number}, {:text, prepared_term}] + if @chain_type == :filecoin do + defp match_search_result(%{filecoin_address_result: {:ok, filecoin_address}}, _prepared_term), + do: {:filecoin, filecoin_address} + end - query_length >= @min_query_length -> - {:text, prepared_term} + defp match_search_result(%{full_hash_result: {:ok, hash}}, _prepared_term), do: {:full_hash, hash} - true -> - nil - end + defp match_search_result(%{non_negative_integer_result: {:ok, block_number}, query_length: ql}, _prepared_term) + when ql < @min_query_length, + do: {:number, block_number} + + defp match_search_result(%{non_negative_integer_result: {:ok, block_number}, query_length: ql}, prepared_term) + when ql >= @min_query_length, + do: [{:number, block_number}, {:text, prepared_term}] + + defp match_search_result(%{query_length: ql}, prepared_term) when ql >= @min_query_length, + do: {:text, prepared_term} + + defp match_search_result(_, _), do: nil + + @spec prepare_search_query(binary(), {:some, binary()} | :none) :: search_results() + defp prepare_search_query(query, {:some, prepared_term}) do + results = prepare_results(query) + + match_search_result(results, prepared_term) end defp prepare_search_query(_query, _) do @@ -255,18 +328,15 @@ defmodule Explorer.Chain.Search do defp search_by_string(term, paging_options, metadata_tags, options) do tokens_query_certified = term - |> search_token_query_certified(paging_options) - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) + |> search_token_query_certified(paging_options, options) tokens_query_not_certified = term - |> search_token_query_not_certified(paging_options) - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) + |> search_token_query_not_certified(paging_options, options) metadata_tags_addresses_query = join_metadata_tags_with_addresses(metadata_tags, options) - contracts_query = - term |> search_contract_query(paging_options) |> ExplorerHelper.maybe_hide_scam_addresses(:address_hash, options) + contracts_query = term |> search_contract_query(paging_options, options) labels_query = search_label_query(term, paging_options) @@ -285,6 +355,75 @@ defmodule Explorer.Chain.Search do defp run_ens_task_if_first_page(_, _query_string, _options), do: nil + @spec balanced_unpaginated_search_result( + search_results(), + PagingOptions.t(), + binary(), + [Chain.api?() | Chain.show_scam_tokens?()] + ) :: list() + defp balanced_unpaginated_search_result(nil, _paging_options, _query_string, _options), do: [] + + defp balanced_unpaginated_search_result({:address_hash, address_hash}, paging_options, query_string, options) do + tac_operation_task = Task.async(fn -> search_tac_operations(query_string, paging_options) end) + + addresses_and_tokens = address_hash_search_if_first_page(paging_options, address_hash, options) + + %{items: tac_operation_results} = await_task_with_paging(tac_operation_task) + + [addresses_and_tokens, tac_operation_results] + end + + defp balanced_unpaginated_search_result({:ton_address, _ton_address}, paging_options, query_string, _options) do + tac_operation_task = Task.async(fn -> search_tac_operations(query_string, paging_options) end) + + %{items: tac_operation_results} = + await_task_with_paging(tac_operation_task) + + [tac_operation_results] + end + + if @chain_type == :filecoin do + defp balanced_unpaginated_search_result({:filecoin, filecoin_address}, _paging_options, _query_string, options) do + [ + filecoin_address + |> address_by_filecoin_id_or_robust() + |> select_repo(options).all() + ] + end + end + + defp balanced_unpaginated_search_result({:full_hash, full_hash}, paging_options, query_string, options) do + tac_operation_task = Task.async(fn -> search_tac_operations(query_string, paging_options) end) + results = full_hash_search_if_first_page(paging_options, full_hash, options) + %{items: tac_operation_results} = await_task_with_paging(tac_operation_task) + + [results, tac_operation_results] + end + + defp balanced_unpaginated_search_result({:number, block_number}, _paging_options, _query_string, options) do + [ + block_number + |> search_block_by_number_query() + |> select_repo(options).all() + ] + end + + defp balanced_unpaginated_search_result( + [{:number, block_number}, {:text, prepared_term}], + paging_options, + _query_string, + options + ) do + [ + block_number |> search_block_by_number_query() |> select_repo(options).all() + | search_by_string_balanced(prepared_term, paging_options, options, nil) + ] + end + + defp balanced_unpaginated_search_result({:text, prepared_term}, paging_options, query_string, options) do + search_by_string_balanced(prepared_term, paging_options, options, query_string) + end + @doc """ Search function. Differences from joint_search/4: 1. Returns all the found categories (amount of results up to `paging_options.page_size`). @@ -295,7 +434,6 @@ defmodule Explorer.Chain.Search do `balanced_unpaginated_search` function is used at api/v2/search/quick endpoint. """ @spec balanced_unpaginated_search(PagingOptions.t(), binary(), [Chain.api?() | Chain.show_scam_tokens?()]) :: list - # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def balanced_unpaginated_search(paging_options, query_string, options \\ []) do query_string = String.trim(query_string) ens_task = Task.async(fn -> search_ens_name(query_string, options) end) @@ -303,78 +441,9 @@ defmodule Explorer.Chain.Search do results = query_string |> prepare_search_query(prepare_search_term(query_string)) - |> case do - nil -> - [] - - {:address_hash, address_hash} -> - [ - address_hash - |> search_token_by_address_hash_query() - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) - |> union_all( - ^(address_hash - |> search_address_by_address_hash_query()) - ) - |> select_repo(options).all() - ] - - {:filecoin, filecoin_address} -> - [ - filecoin_address - |> address_by_filecoin_id_or_robust() - |> select_repo(options).all() - ] - - {:full_hash, full_hash} -> - transaction_block_query = - full_hash - |> search_transaction_query() - |> union_all(^search_block_by_hash_query(full_hash)) - - transaction_block_op_query = - if UserOperation.enabled?() do - user_operation_query = search_user_operation_query(full_hash) - - transaction_block_query - |> union_all(^user_operation_query) - else - transaction_block_query - end - - result_query = - if Application.get_env(:explorer, :chain_type) == :ethereum do - blob_query = search_blob_query(full_hash) - - transaction_block_op_query - |> union_all(^blob_query) - else - transaction_block_op_query - end - - [ - result_query - |> select_repo(options).all() - ] - - {:number, block_number} -> - [ - block_number - |> search_block_by_number_query() - |> select_repo(options).all() - ] - - [{:number, block_number}, {:text, prepared_term}] -> - [ - block_number |> search_block_by_number_query() |> select_repo(options).all() - | search_by_string_balanced(prepared_term, paging_options, options, nil) - ] - - {:text, prepared_term} -> - search_by_string_balanced(prepared_term, paging_options, options, query_string) - end + |> balanced_unpaginated_search_result(paging_options, query_string, options) - ens_result = await_ens_task(ens_task) + ens_result = await_task(ens_task) non_empty_lists = [ @@ -403,23 +472,20 @@ defmodule Explorer.Chain.Search do tokens_results = (term - |> search_token_query_certified(paging_options) - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) + |> search_token_query_certified(paging_options, options) |> select_repo(options).all()) ++ (term - |> search_token_query_not_certified(paging_options) - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) + |> search_token_query_not_certified(paging_options, options) |> select_repo(options).all()) contracts_results = term - |> search_contract_query(paging_options) - |> ExplorerHelper.maybe_hide_scam_addresses(:address_hash, options) + |> search_contract_query(paging_options, options) |> select_repo(options).all() labels_results = term |> search_label_query(paging_options) |> select_repo(options).all() - %{items: metadata_tags} = (metadata_task && await_metadata_task(metadata_task)) || %{items: []} + %{items: metadata_tags} = (metadata_task && await_task_with_paging(metadata_task)) || %{items: []} metadata_tags_addresses = metadata_tags @@ -430,8 +496,8 @@ defmodule Explorer.Chain.Search do [tokens_results, contracts_results, labels_results, metadata_tags_addresses] end - defp await_ens_task(ens_task) do - case Task.yield(ens_task, 5000) || Task.shutdown(ens_task) do + defp await_task(task) do + case Task.yield(task, 5000) || Task.shutdown(task) do {:ok, result} -> result @@ -440,8 +506,8 @@ defmodule Explorer.Chain.Search do end end - defp await_metadata_task(metadata_task) do - case Task.yield(metadata_task, 5000) || Task.shutdown(metadata_task) do + defp await_task_with_paging(task) do + case Task.yield(task, 5000) || Task.shutdown(task) do {:ok, {:ok, result}} -> result @@ -472,6 +538,7 @@ defmodule Explorer.Chain.Search do |> Map.put(:name, dynamic([address_tag: at], at.display_name)) |> Map.put(:inserted_at, dynamic([address_to_tag: att], att.inserted_at)) |> Map.put(:verified, dynamic([smart_contract: smart_contract], not is_nil(smart_contract))) + |> Map.put(:is_smart_contract_address, dynamic([address: address], not is_nil(address.contract_code))) |> Map.put(:priority, 3) inner_query = @@ -489,6 +556,9 @@ defmodule Explorer.Chain.Search do left_join: smart_contract in SmartContract, as: :smart_contract, on: att.address_hash == smart_contract.address_hash, + left_join: address in Address, + as: :address, + on: att.address_hash == address.hash, select: ^label_search_fields ) @@ -497,44 +567,51 @@ defmodule Explorer.Chain.Search do |> page_search_results(paging_options, "label") end - defp search_token_query_not_certified(term, paging_options) do + defp search_token_query_not_certified(term, paging_options, options) do term - |> search_token_by_symbol_or_name_query(paging_options) + |> search_token_by_symbol_or_name_query(paging_options, options) |> where([smart_contract: smart_contract], is_nil(smart_contract.certified) or not smart_contract.certified) end - defp search_token_query_certified(term, paging_options) do + defp search_token_query_certified(term, paging_options, options) do term - |> search_token_by_symbol_or_name_query(paging_options) + |> search_token_by_symbol_or_name_query(paging_options, options) |> where([smart_contract: smart_contract], smart_contract.certified) end - defp search_token_by_symbol_or_name_query(term, paging_options) do + defp search_token_by_symbol_or_name_query(term, paging_options, options) do base_query = from(token in Token, as: :token, left_join: smart_contract in SmartContract, as: :smart_contract, - on: token.contract_address_hash == smart_contract.address_hash, - where: fragment("to_tsvector('english', ? || ' ' || ?) @@ to_tsquery(?)", token.symbol, token.name, ^term), - select: ^token_search_fields() + on: token.contract_address_hash == smart_contract.address_hash ) - base_query |> apply_sorting([], @token_sorting) |> page_search_results(paging_options, "token") + base_query + |> Token.apply_fts_filter(term) + |> apply_sorting([], @token_sorting) + |> page_search_results(paging_options, "token") + |> ExplorerHelper.maybe_hide_scam_addresses_for_search(:contract_address_hash, options) + |> select(^(token_search_fields() |> add_reputation_to_search_fields(options))) end - defp search_token_by_address_hash_query(address_hash) do - from(token in Token, - as: :token, - left_join: smart_contract in SmartContract, - as: :smart_contract, - on: token.contract_address_hash == smart_contract.address_hash, - where: token.contract_address_hash == ^address_hash, - select: ^token_search_fields() - ) + defp search_token_by_address_hash_query(address_hash, options) do + query = + from(token in Token, + as: :token, + left_join: smart_contract in SmartContract, + as: :smart_contract, + on: token.contract_address_hash == smart_contract.address_hash, + where: token.contract_address_hash == ^address_hash + ) + + query + |> ExplorerHelper.maybe_hide_scam_addresses_for_search(:contract_address_hash, options) + |> select(^(token_search_fields() |> add_reputation_to_search_fields(options))) end - defp search_contract_query(term, paging_options) do + defp search_contract_query(term, paging_options, options) do contract_search_fields = search_fields() |> Map.put(:address_hash, dynamic([smart_contract: smart_contract], smart_contract.address_hash)) @@ -543,16 +620,19 @@ defmodule Explorer.Chain.Search do |> Map.put(:inserted_at, dynamic([smart_contract: smart_contract], smart_contract.inserted_at)) |> Map.put(:certified, dynamic([smart_contract: smart_contract], smart_contract.certified)) |> Map.put(:verified, true) + |> Map.put(:is_smart_contract_address, true) |> Map.put(:priority, 0) + |> add_reputation_to_search_fields(options) base_query = from(smart_contract in SmartContract, as: :smart_contract, - where: fragment("to_tsvector('english', ?) @@ to_tsquery(?)", smart_contract.name, ^term), - select: ^contract_search_fields + where: fragment("to_tsvector('english', ?) @@ to_tsquery(?)", smart_contract.name, ^term) ) base_query + |> ExplorerHelper.maybe_hide_scam_addresses_for_search(:address_hash, options) + |> select(^contract_search_fields) |> apply_sorting([], @contract_sorting) |> page_search_results(paging_options, "contract") end @@ -566,6 +646,7 @@ defmodule Explorer.Chain.Search do |> Map.put(:inserted_at, dynamic([address: address], address.inserted_at)) |> Map.put(:verified, dynamic([address: address], address.verified)) |> Map.put(:certified, dynamic([smart_contract: smart_contract], smart_contract.certified)) + |> Map.put(:is_smart_contract_address, dynamic([address: address], not is_nil(address.contract_code))) base_address_query() |> where([address: address], address.hash == ^address_hash) @@ -716,15 +797,15 @@ defmodule Explorer.Chain.Search do as: :metadata_tag, on: address.hash == tag.address_hash ) - |> select(^metadata_tags_search_fields()) - |> ExplorerHelper.maybe_hide_scam_addresses(:hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_for_search(:hash, options) + |> select(^(metadata_tags_search_fields() |> add_reputation_to_search_fields(options))) end defp page_search_results( query, %PagingOptions{ key: %{ - "label" => %{ + label: %{ "name" => name, "inserted_at" => inserted_at } @@ -751,7 +832,7 @@ defmodule Explorer.Chain.Search do query, %PagingOptions{ key: %{ - "contract" => %{ + contract: %{ "certified" => certified, "name" => name, "inserted_at" => inserted_at @@ -784,13 +865,11 @@ defmodule Explorer.Chain.Search do query, %PagingOptions{ key: %{ - "token" => %{ + token: %{ "circulating_market_cap" => circulating_market_cap, "fiat_value" => fiat_value, "is_verified_via_admin_panel" => is_verified_via_admin_panel, - "holders_count" => holder_count, - # todo: It should be removed in favour `holders_count` property with the next release after 8.0.0 - "holder_count" => holder_count, + "holder_count" => holders_count, "name" => name, "inserted_at" => inserted_at } @@ -806,7 +885,7 @@ defmodule Explorer.Chain.Search do circulating_market_cap: parse_possible_nil(circulating_market_cap), fiat_value: parse_possible_nil(fiat_value), is_verified_via_admin_panel: parse_possible_nil(is_verified_via_admin_panel), - holder_count: parse_possible_nil(holder_count), + holder_count: parse_possible_nil(holders_count), name: name, inserted_at: inserted_at }, @@ -890,6 +969,11 @@ defmodule Explorer.Chain.Search do defp search_ens_name(search_query, options) do case search_ens_name_in_bens(search_query) do + {ens_result, nil} -> + [ + merge_address_search_result_with_ens_info(nil, ens_result) + ] + {ens_result, address_hash} -> [ address_hash @@ -903,24 +987,57 @@ defmodule Explorer.Chain.Search do end end + defp search_tac_operations(search_query, paging_options) do + case paging_options do + %PagingOptions{key: %{tac_operation: nil}} -> {:ok, %{items: [], next_page_params: nil}} + %PagingOptions{key: %{tac_operation: page_params}} -> do_search_tac_operations(search_query, page_params) + _ -> do_search_tac_operations(search_query, nil) + end + end + + defp do_search_tac_operations(search_query, page_params) do + case TACOperationLifecycle.get_operations_by_id_or_sender_or_transaction_hash(search_query, page_params) do + {:ok, %{items: operations, next_page_params: next_page_params}} -> + {:ok, + %{ + items: + Enum.map(operations, fn operation -> + search_fields() + |> Map.merge(%{ + type: "tac_operation", + tac_operation: operation, + address_hash: nil, + timestamp: nil + }) + end), + next_page_params: next_page_params + }} + + error -> + error + end + end + @doc """ Try to resolve ENS domain via BENS """ @spec search_ens_name_in_bens(binary()) :: nil | {%{ - address_hash: binary(), + address_hash: binary() | nil, expiry_date: any(), name: any(), names_count: non_neg_integer(), - protocol: any() - }, Hash.Address.t()} + protocol: any(), + protocol_dapp_url: binary() | nil, + protocol_dapp_logo: binary() | nil + }, Hash.Address.t() | nil} def search_ens_name_in_bens(search_query) do trimmed_query = String.trim(search_query) with true <- Regex.match?(~r/\w+\.\w+/, trimmed_query), - %{address_hash: address_hash_string} = result <- ens_domain_name_lookup(search_query), - {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string) do + %{address_hash: address_hash_string_or_nil} = result <- ens_domain_name_lookup(search_query), + address_hash <- address_hash_string_or_nil && Chain.string_to_address_hash_or_nil(address_hash_string_or_nil) do {result, address_hash} else _ -> @@ -928,6 +1045,15 @@ defmodule Explorer.Chain.Search do end end + defp merge_address_search_result_with_ens_info(nil, ens_info) do + search_fields() + |> Map.put(:address_hash, nil) + |> Map.put(:type, "ens_domain") + |> Map.put(:ens_info, ens_info) + |> Map.put(:timestamp, nil) + |> Map.put(:priority, 4) + end + defp merge_address_search_result_with_ens_info([], ens_info) do search_fields() |> Map.put(:address_hash, ens_info[:address_hash]) @@ -970,7 +1096,9 @@ defmodule Explorer.Chain.Search do is_verified_via_admin_panel: nil, order: 0, metadata: dynamic(type(^nil, :map)), - addresses_index: 0 + addresses_index: 0, + reputation: "ok", + is_smart_contract_address: nil } end @@ -990,6 +1118,7 @@ defmodule Explorer.Chain.Search do |> Map.put(:is_verified_via_admin_panel, dynamic([token: token], token.is_verified_via_admin_panel)) |> Map.put(:verified, dynamic([smart_contract: smart_contract], not is_nil(smart_contract))) |> Map.put(:certified, dynamic([smart_contract: smart_contract], smart_contract.certified)) + |> Map.put(:is_smart_contract_address, true) |> Map.put(:priority, 2) end @@ -1002,25 +1131,54 @@ defmodule Explorer.Chain.Search do |> Map.put(:order, dynamic([metadata_tag: tag], tag.id)) |> Map.put(:addresses_index, dynamic([metadata_tag: tag], tag.addresses_index)) |> Map.put(:verified, dynamic([address: address], address.verified)) + |> Map.put(:is_smart_contract_address, dynamic([address: address], not is_nil(address.contract_code))) |> Map.put(:priority, 1) end - @paginated_types ["label", "contract", "token", "metadata_tag"] + defp add_reputation_to_search_fields(search_fields, options) do + if ExplorerHelper.force_show_scam_addresses?(options) do + search_fields + |> Map.put( + :reputation, + dynamic([sabm: sabm], fragment("CASE WHEN ? THEN ? ELSE ? END", is_nil(sabm.address_hash), "ok", "scam")) + ) + else + search_fields + end + end + + @paginated_types [ + "label", + "contract", + "token", + "metadata_tag", + "tac_operation", + :label, + :contract, + :token, + :metadata_tag, + :tac_operation + ] defp trim_list_and_prepare_next_page_params( items, %PagingOptions{page_size: page_size, key: prev_options}, query, metadata_tags_params, - metadata_tags_next_page? + microservice_task_next_page? ) - when length(items) > page_size - 1 or metadata_tags_next_page? do - {items, [first_element_of_the_next_page | _]} = Enum.split(items, page_size - 1) + when length(items) > page_size - 1 or microservice_task_next_page? do + {items, first_element_of_the_next_page} = + case Enum.split(items, page_size - 1) do + {items, [first_element_of_the_next_page | _]} -> {items, first_element_of_the_next_page} + {items, []} -> {items, nil} + end + prev_options = prev_options || %{} base_params = Map.merge( - %{"next_page_params_type" => "search", "q" => query}, + %{next_page_params_type: "search", q: query}, prev_options ) @@ -1070,8 +1228,8 @@ defmodule Explorer.Chain.Search do inserted_at_datetime = DateTime.to_iso8601(inserted_at) %{ - "name" => name, - "inserted_at" => inserted_at_datetime + name: name, + inserted_at: inserted_at_datetime } end @@ -1080,7 +1238,7 @@ defmodule Explorer.Chain.Search do circulating_market_cap: circulating_market_cap, exchange_rate: exchange_rate, is_verified_via_admin_panel: is_verified_via_admin_panel, - holder_count: holder_count, + holder_count: holders_count, name: name, inserted_at: inserted_at, type: "token" @@ -1090,14 +1248,12 @@ defmodule Explorer.Chain.Search do inserted_at_datetime = DateTime.to_iso8601(inserted_at) %{ - "circulating_market_cap" => circulating_market_cap, - "fiat_value" => exchange_rate, - "is_verified_via_admin_panel" => is_verified_via_admin_panel, - "holders_count" => holder_count, - # todo: It should be removed in favour `holders_count` property with the next release after 8.0.0 - "holder_count" => holder_count, - "name" => name, - "inserted_at" => inserted_at_datetime + circulating_market_cap: circulating_market_cap, + fiat_value: exchange_rate, + is_verified_via_admin_panel: is_verified_via_admin_panel, + holder_count: holders_count, + name: name, + inserted_at: inserted_at_datetime } end @@ -1113,9 +1269,9 @@ defmodule Explorer.Chain.Search do inserted_at_datetime = DateTime.to_iso8601(inserted_at) %{ - "certified" => certified, - "name" => name, - "inserted_at" => inserted_at_datetime + certified: certified, + name: name, + inserted_at: inserted_at_datetime } end @@ -1138,14 +1294,14 @@ defmodule Explorer.Chain.Search do ) %{ - "address_hash" => address_hash, - "metadata_next_page_params" => + address_hash: address_hash, + metadata_next_page_params: if(metadata_tag?(first_element_of_the_next_page), do: metadata_tag_to_paging_params(first_element_of_the_next_page), else: metadata_next_page_params ), - "end_of_tags" => is_nil(next_page_params), - "addresses_index" => + end_of_tags: is_nil(next_page_params), + addresses_index: if(metadata_tag?(first_element_of_the_next_page) && first_element_of_the_next_page[:metadata]["slug"] == slug, do: addresses_index + 1, else: 0 @@ -1153,8 +1309,15 @@ defmodule Explorer.Chain.Search do } end + defp paging_params( + %{type: "tac_operation", tac_operation: %{"timestamp" => timestamp}}, + _ + ) do + %{page_token: timestamp |> DateTime.from_iso8601() |> elem(1) |> DateTime.to_unix()} + end + defp metadata_tag_to_paging_params(%{metadata: metadata}) do - %{"page_token" => "#{metadata["ordinal"]},#{metadata["slug"]},#{metadata["tagType"]}", "page_size" => 50} + %{page_token: "#{metadata["ordinal"]},#{metadata["slug"]},#{metadata["tagType"]}", page_size: 50} end defp metadata_tag?(%{type: "metadata_tag"}), do: true @@ -1172,7 +1335,7 @@ defmodule Explorer.Chain.Search do A keyword list with paging options, where key is the map with the parsed paging options. """ @spec parse_paging_options(map()) :: [paging_options: PagingOptions.t()] - def parse_paging_options(%{"next_page_params_type" => "search"} = paging_params) do + def parse_paging_options(%{next_page_params_type: "search"} = paging_params) do key = Enum.reduce(@paginated_types, %{}, fn type, acc -> if Map.has_key?(paging_params, type) do @@ -1253,6 +1416,7 @@ defmodule Explorer.Chain.Search do |> Map.put(:inserted_at, dynamic([address: address], address.inserted_at)) |> Map.put(:verified, dynamic([address: address], address.verified)) |> Map.put(:certified, dynamic([smart_contract: smart_contract], smart_contract.certified)) + |> Map.put(:is_smart_contract_address, dynamic([address: address], not is_nil(address.contract_code))) base_address_query() |> join( diff --git a/apps/explorer/lib/explorer/chain/shibarium/bridge.ex b/apps/explorer/lib/explorer/chain/shibarium/bridge.ex index 374bf2ae8d37..9609048d26e3 100644 --- a/apps/explorer/lib/explorer/chain/shibarium/bridge.ex +++ b/apps/explorer/lib/explorer/chain/shibarium/bridge.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Shibarium.Bridge do @moduledoc "Models Shibarium Bridge operation." diff --git a/apps/explorer/lib/explorer/chain/shibarium/reader.ex b/apps/explorer/lib/explorer/chain/shibarium/reader.ex index 59700c757549..9ed9a7f69526 100644 --- a/apps/explorer/lib/explorer/chain/shibarium/reader.ex +++ b/apps/explorer/lib/explorer/chain/shibarium/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Shibarium.Reader do @moduledoc "Contains read functions for Shibarium modules." diff --git a/apps/explorer/lib/explorer/chain/signed_authorization.ex b/apps/explorer/lib/explorer/chain/signed_authorization.ex index b8ca690bed91..6a82d460f46e 100644 --- a/apps/explorer/lib/explorer/chain/signed_authorization.ex +++ b/apps/explorer/lib/explorer/chain/signed_authorization.ex @@ -1,11 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SignedAuthorization do @moduledoc "Models a transaction extension with authorization tuples from eip7702 set code transactions." use Explorer.Schema - alias Explorer.Chain.{Hash, Transaction} + alias Explorer.Chain.{Cache.ChainId, Data, Hash, Transaction} - @optional_attrs ~w(authority)a + @optional_attrs ~w(authority status)a @required_attrs ~w(transaction_hash index chain_id address nonce r s v)a @typedoc """ @@ -19,17 +20,19 @@ defmodule Explorer.Chain.SignedAuthorization do * `r` - the 'r' component of the signature. * `s` - the 's' component of the signature. * `authority` - the signer of the authorization. + * `status` - the status of the authorization. """ - @type to_import :: %__MODULE__{ + @type to_import :: %{ transaction_hash: binary(), index: non_neg_integer(), - chain_id: non_neg_integer(), + chain_id: non_neg_integer() | Decimal.t(), address: binary(), - nonce: non_neg_integer(), + nonce: non_neg_integer() | Decimal.t(), r: non_neg_integer(), s: non_neg_integer(), v: non_neg_integer(), - authority: binary() | nil + authority: binary() | nil, + status: :ok | :invalid_chain_id | :invalid_signature | :invalid_nonce | nil } @typedoc """ @@ -42,6 +45,7 @@ defmodule Explorer.Chain.SignedAuthorization do * `r` - the 'r' component of the signature. * `s` - the 's' component of the signature. * `authority` - the signer of the authorization. + * `status` - the validity status of the authorization. * `inserted_at` - timestamp indicating when the signed authorization was created. * `updated_at` - timestamp indicating when the signed authorization was last updated. * `transaction` - an instance of `Explorer.Chain.Transaction` referenced by `transaction_hash`. @@ -49,13 +53,14 @@ defmodule Explorer.Chain.SignedAuthorization do @primary_key false typed_schema "signed_authorizations" do field(:index, :integer, primary_key: true, null: false) - field(:chain_id, :integer, null: false) + field(:chain_id, :decimal, null: false) field(:address, Hash.Address, null: false) field(:nonce, :decimal, null: false) field(:r, :decimal, null: false) field(:s, :decimal, null: false) field(:v, :integer, null: false) field(:authority, Hash.Address, null: true) + field(:status, Ecto.Enum, values: [:ok, :invalid_chain_id, :invalid_signature, :invalid_nonce], null: true) belongs_to(:transaction, Transaction, foreign_key: :transaction_hash, @@ -67,6 +72,19 @@ defmodule Explorer.Chain.SignedAuthorization do timestamps() end + @local_fields [:__meta__, :inserted_at, :updated_at] + + @doc """ + Returns a map representation of the signed authorization. + """ + @spec to_map(__MODULE__.t()) :: map() + def to_map(%__MODULE__{} = struct) do + association_fields = struct.__struct__.__schema__(:associations) + waste_fields = association_fields ++ @local_fields + + struct |> Map.from_struct() |> Map.drop(waste_fields) + end + @doc """ Validates that the `attrs` are valid. """ @@ -77,4 +95,62 @@ defmodule Explorer.Chain.SignedAuthorization do |> validate_required(@required_attrs) |> foreign_key_constraint(:transaction_hash) end + + @doc """ + Converts a `SignedAuthorization.t()` into a map of import params that can be + fed into `Chain.import/1`. It sets synthetic contract code for EIP-7702 proxies + and updates the nonce. + """ + @spec to_address_params(Ecto.Schema.t()) :: %{ + hash: Hash.Address.t(), + contract_code: Data.t() | nil, + nonce: non_neg_integer() + } + def to_address_params(%__MODULE__{} = struct) do + code = + if struct.address.bytes == <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>> do + nil + else + %Data{bytes: <<239, 1, 0>> <> struct.address.bytes} + end + + %{hash: struct.authority, contract_code: code, nonce: struct.nonce |> Decimal.to_integer()} + end + + @doc """ + Does basic validation on a `SignedAuthorization.t()` according to EIP-7702, with + the exception of verifying current authority nonce, which requires calling + `eth_getTransactionCount` JSON-RPC method. + + Authority nonce validity is verified in async `Indexer.Fetcher.SignedAuthorizationStatus` fetcher. + + ## Returns + - `:ok` if the signed authorization is valid and we should proceed with nonce validation. + - `:invalid_chain_id` if the signed authorization is for another chain ID. + - `:invalid_signature` if the signed authorization has an invalid signature. + - `:invalid_nonce` if the signed authorization has an invalid nonce. + - `nil` if the signed authorization status is unknown due to unknown chain ID. + """ + @spec basic_validate(Ecto.Schema.t() | to_import()) :: + :ok | :invalid_chain_id | :invalid_signature | :invalid_nonce | nil + def basic_validate(%{} = struct) do + chain_id = ChainId.get_id() + + cond do + not Decimal.eq?(struct.chain_id, 0) and !is_nil(chain_id) and not Decimal.eq?(struct.chain_id, chain_id) -> + :invalid_chain_id + + not Decimal.eq?(struct.chain_id, 0) and is_nil(chain_id) -> + nil + + struct.nonce |> Decimal.gte?(2 ** 64 - 1) -> + :invalid_nonce + + is_nil(struct.authority) -> + :invalid_signature + + true -> + :ok + end + end end diff --git a/apps/explorer/lib/explorer/chain/signed_authorization/reader.ex b/apps/explorer/lib/explorer/chain/signed_authorization/reader.ex new file mode 100644 index 000000000000..50acb7b6bd1c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/signed_authorization/reader.ex @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SignedAuthorization.Reader do + @moduledoc """ + Reads signed authorization data from the database. + """ + + import Ecto.Query, only: [from: 2] + + alias Explorer.Chain.{Block, Hash, SignedAuthorization} + alias Explorer.Repo + + @doc """ + Returns a stream of `Block.t()` for signed authorizations with missing statuses. + + ## Parameters + + - `initial`: The initial accumulator value for the stream. + - `reducer`: A function that processes each entry in the stream, receiving + the entry and the current accumulator, and returning a new accumulator. + + ## Returns + + - `{:ok, accumulator}`: The final accumulator value after streaming through + the block numbers. + """ + @spec stream_blocks_to_refetch_signed_authorizations_statuses( + initial :: accumulator, + reducer :: (entry :: term(), accumulator -> accumulator) + ) :: {:ok, accumulator} + when accumulator: term() + def stream_blocks_to_refetch_signed_authorizations_statuses(initial, reducer) when is_function(reducer, 2) do + query = + from( + b in Block, + join: t in assoc(b, :transactions), + join: s in assoc(t, :signed_authorizations), + where: is_nil(s.status) and b.consensus == true and b.refetch_needed == false, + distinct: true, + select: %{ + block_hash: b.hash, + block_number: b.number + } + ) + + query |> Repo.stream_reduce(initial, reducer) + end + + @doc """ + Returns latest successful authorizations for a list of addresses. + + ## Parameters + + - `address_hashes`: The list of `authority` addresses to fetch authorizations for. + + ## Returns + + - `[%{authority: Hash.Address.t(), nonce: non_neg_integer()}]`: The list of latest + successful authorizations and their nonces for the given addresses, if there are any. + """ + @spec address_hashes_to_latest_authorizations([Hash.Address.t()]) :: [ + %{authority: Hash.Address.t(), nonce: non_neg_integer()} + ] + def address_hashes_to_latest_authorizations(address_hashes) do + query = + from( + s in SignedAuthorization, + where: s.authority in ^address_hashes and s.status == :ok, + select: %{authority: s.authority, nonce: max(s.nonce)}, + group_by: s.authority + ) + + Repo.all(query) + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract.ex b/apps/explorer/lib/explorer/chain/smart_contract.ex index 02f918a5e967..6279cfbc7a1c 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Schema do @moduledoc """ Models smart-contract. @@ -8,6 +9,7 @@ defmodule Explorer.Chain.SmartContract.Schema do alias Explorer.Chain.{ Address, + Address.Reputation, Hash, SmartContractAdditionalSource } @@ -50,13 +52,11 @@ defmodule Explorer.Chain.SmartContract.Schema do field(:verified_via_verifier_alliance, :boolean) field(:partially_verified, :boolean) field(:file_path, :string) - field(:is_vyper_contract, :boolean) field(:is_changed_bytecode, :boolean, default: false) field(:bytecode_checked_at, :utc_datetime_usec, default: DateTime.add(DateTime.utc_now(), -86400, :second)) field(:contract_code_md5, :string, null: false) field(:compiler_settings, :map) field(:autodetect_constructor_args, :boolean, virtual: true) - field(:is_yul, :boolean, virtual: true) field(:metadata_from_verified_bytecode_twin, :boolean, virtual: true) field(:verified_bytecode_twin_address_hash, Hash.Address, virtual: true) field(:license_type, Ecto.Enum, values: @license_enum, default: :none) @@ -78,6 +78,8 @@ defmodule Explorer.Chain.SmartContract.Schema do foreign_key: :address_hash ) + has_one(:reputation, Reputation, foreign_key: :address_hash, references: :address_hash) + timestamps() unquote_splicing(@chain_type_fields) @@ -119,9 +121,7 @@ defmodule Explorer.Chain.SmartContract do } alias Explorer.Chain.Address.Name, as: AddressName - - alias Explorer.Chain.Cache.BackgroundMigrations - alias Explorer.Chain.SmartContract.{LegacyHelper, Proxy} + alias Explorer.Chain.SmartContract.{Proxy, VerifiedContractAddressesQuery} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Helper, as: ExplorerHelper alias Explorer.SmartContract.Helper @@ -139,7 +139,7 @@ defmodule Explorer.Chain.SmartContract do end) @required_attrs @default_required_attrs ++ @chain_type_required_attrs - @optional_common_attrs ~w(name contract_source_code evm_version optimization_runs constructor_arguments verified_via_sourcify verified_via_eth_bytecode_db verified_via_verifier_alliance partially_verified file_path is_vyper_contract is_changed_bytecode bytecode_checked_at autodetect_constructor_args license_type certified is_blueprint)a + @optional_common_attrs ~w(name contract_source_code evm_version optimization_runs constructor_arguments verified_via_sourcify verified_via_eth_bytecode_db verified_via_verifier_alliance partially_verified file_path is_changed_bytecode bytecode_checked_at autodetect_constructor_args license_type certified is_blueprint)a @optional_changeset_attrs ~w(abi compiler_settings)a @optional_invalid_contract_changeset_attrs ~w(autodetect_constructor_args)a @@ -189,23 +189,33 @@ defmodule Explorer.Chain.SmartContract do } ] - @default_languages ~w(solidity vyper yul)a + @default_languages [ + solidity: 1, + vyper: 2, + yul: 3, + geas: 5 + ] + @chain_type_languages (case @chain_type do :arbitrum -> - ~w(stylus_rust)a + [stylus_rust: 4] :zilliqa -> - ~w(scilla)a + [scilla: 4] _ -> - ~w()a + [] end) - @languages @default_languages ++ @chain_type_languages - @languages_enum @languages |> Enum.with_index(1) - @language_string_to_atom @languages |> Map.new(&{to_string(&1), &1}) + @languages_enum @default_languages ++ @chain_type_languages + @language_atoms @languages_enum + |> Enum.map(&elem(&1, 0)) + @language_strings @language_atoms + |> Enum.map(&to_string(&1)) + @language_string_to_atom @language_atoms + |> Map.new(&{to_string(&1), &1}) - @type base_language :: :solidity | :vyper | :yul + @type base_language :: :solidity | :vyper | :yul | :geas case @chain_type do :arbitrum -> @@ -219,7 +229,15 @@ defmodule Explorer.Chain.SmartContract do end @doc """ - Returns list of languages supported by the database schema. + Returns list of languages (as strings) supported by the database schema. + """ + @spec language_strings() :: [String.t()] + def language_strings do + @language_strings + end + + @doc """ + Returns list of languages as map(string to atom) supported by the database schema. """ @spec language_string_to_atom() :: %{String.t() => atom()} def language_string_to_atom do @@ -227,7 +245,7 @@ defmodule Explorer.Chain.SmartContract do end @doc """ - Returns burn address hash + Returns burn address hash """ @spec burn_address_hash_string() :: EthereumJSONRPC.address() def burn_address_hash_string do @@ -235,15 +253,13 @@ defmodule Explorer.Chain.SmartContract do end @doc """ - Returns dead address hash + Returns dead address hash """ @spec dead_address_hash_string() :: EthereumJSONRPC.address() def dead_address_hash_string do @dead_address_hash_string end - @default_sorting [asc: :hash] - @typedoc """ The name of a parameter to a function or event. """ @@ -467,19 +483,15 @@ defmodule Explorer.Chain.SmartContract do * `verified_via_eth_bytecode_db` - whether contract automatically verified via eth-bytecode-db or not. * `verified_via_verifier_alliance` - whether contract automatically verified via Verifier Alliance or not. * `partially_verified` - whether contract verified using partial matched source code or not. - * `is_vyper_contract` - boolean flag, determines if contract is Vyper or not * `file_path` - show the filename or path to the file of the contract source file * `is_changed_bytecode` - boolean flag, determines if contract's bytecode was modified * `bytecode_checked_at` - timestamp of the last check of contract's bytecode matching (DB and BlockChain) * `contract_code_md5` - md5(`t:Explorer.Chain.Address.t/0` `contract_code`) * `compiler_settings` - raw compilation parameters * `autodetect_constructor_args` - field was added for storing user's choice - * `is_yul` - field was added for storing user's choice * `certified` - boolean flag, which can be set for set of smart-contracts via runtime env variable to prioritize those smart-contracts in the search. * `is_blueprint` - boolean flag, determines if contract is ERC-5202 compatible blueprint contract or not. - * `language` - Specifies the programming language of this smart contract. Do - not access this field directly, use - `Explorer.Chain.SmartContract.language/1` instead. + * `language` - Specifies the programming language of this smart contract. """ Explorer.Chain.SmartContract.Schema.generate() @@ -574,7 +586,6 @@ defmodule Explorer.Chain.SmartContract do %__MODULE__{} |> changeset(Map.from_struct(twin_contract)) |> Changeset.put_change(:autodetect_constructor_args, true) - |> Changeset.put_change(:is_yul, false) |> Changeset.force_change(:address_hash, Changeset.get_field(changeset, :address_hash)) end @@ -592,7 +603,6 @@ defmodule Explorer.Chain.SmartContract do |> Changeset.put_change(:compiler_version, "latest") |> Changeset.put_change(:contract_source_code, "") |> Changeset.put_change(:autodetect_constructor_args, true) - |> Changeset.put_change(:is_yul, false) |> (&if(Application.get_env(:explorer, :chain_type) == :zksync, do: Changeset.put_change(&1, :zk_compiler_version, "latest"), else: &1 @@ -600,7 +610,7 @@ defmodule Explorer.Chain.SmartContract do end def merge_twin_vyper_contract_with_changeset( - %__MODULE__{is_vyper_contract: true} = twin_contract, + %__MODULE__{language: :vyper} = twin_contract, %Changeset{} = changeset ) do %__MODULE__{} @@ -608,11 +618,7 @@ defmodule Explorer.Chain.SmartContract do |> Changeset.force_change(:address_hash, Changeset.get_field(changeset, :address_hash)) end - def merge_twin_vyper_contract_with_changeset(%__MODULE__{is_vyper_contract: false}, %Changeset{} = changeset) do - merge_twin_vyper_contract_with_changeset(nil, changeset) - end - - def merge_twin_vyper_contract_with_changeset(%__MODULE__{is_vyper_contract: nil}, %Changeset{} = changeset) do + def merge_twin_vyper_contract_with_changeset(%__MODULE__{}, %Changeset{} = changeset) do merge_twin_vyper_contract_with_changeset(nil, changeset) end @@ -685,15 +691,13 @@ defmodule Explorer.Chain.SmartContract do %{init: Data.to_string(input), transaction: transaction} end else - creation_int_transaction_query = - from( - itx in InternalTransaction, - join: t in assoc(itx, :transaction), - where: itx.created_contract_address_hash == ^address_hash, - where: t.status == ^1 - ) + creation_int_transaction_query = Address.creation_internal_transaction_query(address_hash) - internal_transaction = creation_int_transaction_query |> Repo.one() + internal_transaction = + creation_int_transaction_query + |> Repo.one() + |> InternalTransaction.preload_transaction() + |> InternalTransaction.preload_addresses() case internal_transaction do %{init: init} -> @@ -808,7 +812,7 @@ defmodule Explorer.Chain.SmartContract do contract_code_md5 = target_address.contract_code.bytes - |> Helper.contract_code_md5() + |> Helper.md5() verified_bytecode_twin_contract_query = from( @@ -962,14 +966,11 @@ defmodule Explorer.Chain.SmartContract do |> __MODULE__.changeset(attrs) |> Changeset.put_change(:external_libraries, external_libraries) - # Prepares changesets for additional sources associated with the contract - new_contract_additional_source = %SmartContractAdditionalSource{} - smart_contract_additional_sources_changesets = if secondary_sources do secondary_sources |> Enum.map(fn changeset -> - new_contract_additional_source + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(changeset) end) else @@ -1041,51 +1042,63 @@ defmodule Explorer.Chain.SmartContract do def update_smart_contract(attrs \\ %{}, external_libraries \\ [], secondary_sources \\ []) do address_hash = Map.get(attrs, :address_hash) - # Removes all additional sources associated with the contract - query_sources = - from( - source in SmartContractAdditionalSource, - where: source.address_hash == ^address_hash - ) - - _delete_sources = Repo.delete_all(query_sources) - - # Retrieve the existing smart contract - smart_contract = address_hash_to_smart_contract(address_hash) + # Prepares the queries to clear the primary flag for the contract address in + # Explorer.Chain.Address.Name if any (enforce ShareLocks tables order (see + # docs: sharelocks.md)) and updated the contract details. + insert_contract_query = + Multi.new() + |> Multi.run(:acquire_smart_contract, fn repo, _ -> + # Get smart contract with lock INSIDE the transaction + smart_contract = + address_hash + |> get_by_address_hash_query() + |> lock("FOR UPDATE") + |> repo.one() - # Updates existing changeset and extends it with external libraries. - # As part of changeset preparation and verification, contract methods are - # updated as so if new ABI does not contain some of previous methods, they - # are still kept in the database - smart_contract_changeset = - smart_contract - |> __MODULE__.changeset(attrs) - |> Changeset.put_change(:external_libraries, external_libraries) + {:ok, smart_contract} + end) + |> Multi.run(:delete_sources, fn repo, _ -> + {count, _} = + repo.delete_all( + from( + source in SmartContractAdditionalSource, + where: source.address_hash == ^address_hash + ) + ) - # Prepares changesets for additional sources associated with the contract - new_contract_additional_source = %SmartContractAdditionalSource{} + {:ok, count} + end) + |> Multi.run(:clear_primary_address_names, fn repo, _ -> + AddressName.clear_primary_address_names(repo, address_hash) + end) + |> Multi.run( + :smart_contract, + fn repo, %{acquire_smart_contract: smart_contract} -> + smart_contract + |> __MODULE__.changeset(attrs) + |> Changeset.put_change(:external_libraries, external_libraries) + |> repo.update() + end + ) + |> Multi.run( + :set_primary_address_name, + fn repo, %{smart_contract: %SmartContract{name: name}} -> + result = AddressName.create_primary_address_name(repo, name, address_hash) + {:ok, result} + end + ) smart_contract_additional_sources_changesets = if secondary_sources do secondary_sources |> Enum.map(fn changeset -> - new_contract_additional_source + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(changeset) end) else [] end - # Prepares the queries to clear the primary flag for the contract address in - # Explorer.Chain.Address.Name if any (enforce ShareLocks tables order (see - # docs: sharelocks.md)) and updated the contract details. - insert_contract_query = - Multi.new() - |> Multi.run(:clear_primary_address_names, fn repo, _ -> - AddressName.clear_primary_address_names(repo, address_hash) - end) - |> Multi.update(:smart_contract, smart_contract_changeset) - # Updates the queries from the previous step with inserting additional sources # of the contract insert_contract_query_with_additional_sources = @@ -1100,9 +1113,6 @@ defmodule Explorer.Chain.SmartContract do insert_contract_query_with_additional_sources |> Repo.transaction() - # Set the primary mark for the contract name - AddressName.create_primary_address_name(Repo, Changeset.get_field(smart_contract_changeset, :name), address_hash) - case insert_result do {:ok, %{smart_contract: smart_contract}} -> {:ok, smart_contract} @@ -1125,7 +1135,8 @@ defmodule Explorer.Chain.SmartContract do @doc """ Converts address hash to smart-contract object with metadata_from_verified_bytecode_twin=true """ - @spec address_hash_to_smart_contract_with_bytecode_twin(Hash.Address.t(), [api?]) :: {__MODULE__.t() | nil, boolean()} + @spec address_hash_to_smart_contract_with_bytecode_twin(Hash.Address.t(), [api?], boolean()) :: + {__MODULE__.t() | nil, boolean()} def address_hash_to_smart_contract_with_bytecode_twin(address_hash, options \\ [], fetch_implementation? \\ true) do current_smart_contract = address_hash_to_smart_contract(address_hash, options) @@ -1145,7 +1156,7 @@ defmodule Explorer.Chain.SmartContract do implementation_address_fetched?: false, refetch_necessity_checked?: false }, - Keyword.put(options, :proxy_without_abi?, true) + options ) {implementation_smart_contract, true} @@ -1409,8 +1420,8 @@ defmodule Explorer.Chain.SmartContract do This function fetches verified smart contracts from the database and applies filtering, searching, sorting, and pagination based on the provided options. - It implements different query strategies depending on whether database - migrations have completed or are still in progress. + It implements different query strategies depending on whether custom sorting + is provided. ## Options @@ -1438,92 +1449,9 @@ defmodule Explorer.Chain.SmartContract do | {:sorting, SortingHelper.sorting_params()} | Chain.api?() | Chain.show_scam_tokens?() - ]) :: [__MODULE__.t()] - def verified_contract_addresses(options \\ []) do - necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - sorting_options = Keyword.get(options, :sorting, []) - - addresses_query = - if background_migrations_finished?() do - verified_addresses_query(options) - else - # Legacy query approach - will be removed in future releases - LegacyHelper.verified_addresses_query(options) - end - - addresses_query - |> ExplorerHelper.maybe_hide_scam_addresses(:hash, options) - |> SortingHelper.apply_sorting(sorting_options, @default_sorting) - |> SortingHelper.page_with_sorting(paging_options, sorting_options, @default_sorting) - |> Chain.join_associations(necessity_by_association) - |> Chain.select_repo(options).all() - end - - @doc """ - Checks if all background migrations are finished. Kept public for mocking in - tests. - """ - @spec background_migrations_finished? :: boolean() - def background_migrations_finished? do - [ - BackgroundMigrations.get_smart_contract_language_finished(), - BackgroundMigrations.get_sanitize_verified_addresses_finished(), - BackgroundMigrations.get_heavy_indexes_create_addresses_verified_hash_index_finished(), - BackgroundMigrations.get_heavy_indexes_create_addresses_verified_transactions_count_desc_hash_index_finished(), - BackgroundMigrations.get_heavy_indexes_create_addresses_verified_fetched_coin_balance_desc_hash_index_finished(), - BackgroundMigrations.get_heavy_indexes_create_smart_contracts_language_index_finished() - ] - |> Enum.all?(& &1) - end - - defp verified_addresses_query(options) do - filter = Keyword.get(options, :filter, nil) - search_string = Keyword.get(options, :search, nil) - - smart_contracts_by_address_hash_query = - from( - contract in __MODULE__, - where: contract.address_hash == parent_as(:address).hash - ) - - smart_contracts_subquery = - smart_contracts_by_address_hash_query - |> filter_contracts(filter) - |> search_contracts(search_string) - |> limit(1) - |> subquery() - - from( - address in Address, - as: :address, - where: address.verified == true, - inner_lateral_join: contract in ^smart_contracts_subquery, - on: true, - select: address, - preload: [smart_contract: contract] - ) - end - - @spec search_contracts(Ecto.Query.t(), String.t() | nil) :: Ecto.Query.t() - defp search_contracts(basic_query, nil), do: basic_query - - defp search_contracts(basic_query, search_string) do - from(contract in basic_query, - where: - ilike(contract.name, ^"%#{search_string}%") or - ilike(fragment("'0x' || encode(?, 'hex')", contract.address_hash), ^"%#{search_string}%") - ) - end - - # Applies filtering to the given query based on a specified contract language. - # If `nil` is provided, no additional filtering is applied. - @spec filter_contracts(Ecto.Query.t(), language() | nil) :: Ecto.Query.t() - defp filter_contracts(basic_query, nil), do: basic_query - - defp filter_contracts(basic_query, language) do - basic_query |> where(language: ^language) - end + ]) :: [Address.t()] + def verified_contract_addresses(options \\ []), + do: VerifiedContractAddressesQuery.list(options) @doc """ Retrieves the constructor arguments for a zkSync smart contract. @@ -1549,42 +1477,6 @@ defmodule Explorer.Chain.SmartContract do end end - @doc """ - Retrieves the smart contract language, taking legacy fields into account for - compatibility. It first tries to retrieve the language from the `language` - field; if not present, it falls back to legacy boolean fields. - - ## TODO - This function is a temporary measure during background migration of the - `language` field and should be removed in the future releases. Afterward, the - language will be retrieved directly from the `language` field. Tracked in - [#11822](https://github.com/blockscout/blockscout/issues/11822). - - ## Parameters - - - `SmartContract.t()`: The smart contract. - - ## Returns - - - `language()`: An atom representing the language of the smart contract. - """ - @spec language(SmartContract.t()) :: language() - def language(smart_contract) do - cond do - not is_nil(smart_contract.language) -> - smart_contract.language - - smart_contract.is_vyper_contract -> - :vyper - - is_nil(smart_contract.abi) -> - :yul - - true -> - :solidity - end - end - @spec format_constructor_arguments(list() | nil, binary() | nil) :: list() | nil def format_constructor_arguments(abi, constructor_arguments) when not is_nil(abi) and not is_nil(constructor_arguments) do diff --git a/apps/explorer/lib/explorer/chain/smart_contract/audit_report.ex b/apps/explorer/lib/explorer/chain/smart_contract/audit_report.ex index 124255b920c9..6cd7a389fdcf 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/audit_report.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/audit_report.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.AuditReport do @moduledoc """ The representation of an audit report for a smart contract. @@ -7,7 +8,8 @@ defmodule Explorer.Chain.SmartContract.AuditReport do alias Explorer.{Chain, Helper, Repo} alias Explorer.Chain.Hash - alias Explorer.ThirdPartyIntegrations.AirTable + alias Explorer.ThirdPartyIntegrations.AirTableAuditReport + alias Utils.ConfigHelper, as: UtilsConfigHelper @max_reports_per_day_for_contract 5 @@ -86,7 +88,7 @@ defmodule Explorer.Chain.SmartContract.AuditReport do end defp valid_url?(field, url) do - if Helper.valid_url?(url) do + if UtilsConfigHelper.valid_url?(url) do [] else [{field, "invalid url"}] @@ -108,7 +110,39 @@ defmodule Explorer.Chain.SmartContract.AuditReport do def create(attrs) do %__MODULE__{} |> changeset(attrs) - |> AirTable.submit() + |> AirTableAuditReport.submit() + |> Repo.insert() + end + + @doc """ + Import a new audit report from admin panel. + """ + @spec import(map()) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()} + def import(%{ + address_hash: address_hash, + audit_report_url: audit_report_url, + audit_publish_date: audit_publish_date, + audit_company_name: audit_company_name + }) do + attrs = %{ + address_hash: address_hash, + audit_report_url: audit_report_url, + audit_publish_date: audit_publish_date, + audit_company_name: audit_company_name, + is_approved: true, + submitter_name: "api", + submitter_email: "api", + is_project_owner: true, + project_name: "api", + project_url: "api" + } + + %__MODULE__{} + |> cast(attrs, @optional_fields ++ @required_fields) + |> unique_constraint([:address_hash, :audit_report_url, :audit_publish_date, :audit_company_name], + message: "the report was submitted before", + name: :audit_report_unique_index + ) |> Repo.insert() end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/external_library.ex b/apps/explorer/lib/explorer/chain/smart_contract/external_library.ex index 62aeb99b7115..b5e9db9bb758 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/external_library.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/external_library.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.ExternalLibrary do @moduledoc """ The representation of an external library that was used for a smart contract. diff --git a/apps/explorer/lib/explorer/chain/smart_contract/legacy_helper.ex b/apps/explorer/lib/explorer/chain/smart_contract/legacy_helper.ex deleted file mode 100644 index 98baffb2d5f5..000000000000 --- a/apps/explorer/lib/explorer/chain/smart_contract/legacy_helper.ex +++ /dev/null @@ -1,111 +0,0 @@ -defmodule Explorer.Chain.SmartContract.LegacyHelper do - @moduledoc """ - Legacy functions for SmartContract verification during database migration. - - This module contains legacy query functions that are used as a fallback during - the migration period when the `language` field is being populated in the - smart_contracts table. These functions maintain compatibility with - pre-migration behavior by checking both the new `language` field and the - legacy boolean flags like `is_vyper_contract`. - - All functions in this module are temporary and will be removed after the - following migrations are complete: - - smart_contract_language background migration - - sanitize_verified_addresses background migration - - heavy_indexes_create_smart_contracts_language_index migration - - Related to issue: https://github.com/blockscout/blockscout/issues/11822 - """ - - import Ecto.Query - alias Explorer.Chain.{Address, SmartContract} - - @doc """ - Legacy query for verified addresses using join-based filtering. - - This approach is less performant than using lateral joins but is required - during the migration period when indexes are being created and language fields - populated. - """ - @spec verified_addresses_query(keyword()) :: Ecto.Query.t() - def verified_addresses_query(options) do - filter = Keyword.get(options, :filter, nil) - search_string = Keyword.get(options, :search, nil) - - addresses_query = - from( - address in Address, - join: contract in SmartContract, - on: address.hash == contract.address_hash, - preload: [:smart_contract] - ) - - addresses_query - |> filter_contracts_for_join(filter) - |> search_contracts_for_join(search_string) - end - - # Applies language filter to the query with legacy compatibility. Works with - # join-based queries where contract is the second binding. - @spec filter_contracts_for_join(Ecto.Query.t(), atom() | nil) :: Ecto.Query.t() - defp filter_contracts_for_join(query, nil), do: query - - defp filter_contracts_for_join(query, language) do - query - |> where([_address, contract], contract.language == ^language) - |> maybe_filter_contracts_on_legacy_fields_for_join(language) - end - - # Conditionally applies legacy filtering based on migration status. - # - # Checks if the smart_contract_language migration is complete before - # deciding whether to apply legacy filtering. - @spec maybe_filter_contracts_on_legacy_fields_for_join(Ecto.Query.t(), atom()) :: Ecto.Query.t() - defp maybe_filter_contracts_on_legacy_fields_for_join(query, language) do - alias Explorer.Chain.Cache.BackgroundMigrations - - if BackgroundMigrations.get_smart_contract_language_finished() do - query - else - apply_legacy_language_filter_for_join(query, language) - end - end - - # Applies language-specific filtering for legacy fields. - # - # This function maintains backward compatibility during migration - # by checking boolean flags that were previously used to determine - # contract language before the dedicated field was introduced. - @spec apply_legacy_language_filter_for_join(Ecto.Query.t(), atom()) :: Ecto.Query.t() - defp apply_legacy_language_filter_for_join(query, :solidity) do - query - |> or_where( - [_address, contract], - not contract.is_vyper_contract and not is_nil(contract.abi) and is_nil(contract.language) - ) - end - - defp apply_legacy_language_filter_for_join(query, :vyper) do - query |> or_where([_address, contract], contract.is_vyper_contract and is_nil(contract.language)) - end - - defp apply_legacy_language_filter_for_join(query, :yul) do - query |> or_where([_address, contract], is_nil(contract.abi) and is_nil(contract.language)) - end - - defp apply_legacy_language_filter_for_join(query, _), do: query - - # Applies search filter to the query with join-based approach. - # - # Searches in both contract name and address hash fields. - @spec search_contracts_for_join(Ecto.Query.t(), String.t() | nil) :: Ecto.Query.t() - defp search_contracts_for_join(query, nil), do: query - - defp search_contracts_for_join(query, search_string) do - from([_address, contract] in query, - where: - ilike(contract.name, ^"%#{search_string}%") or - ilike(fragment("'0x' || encode(?, 'hex')", contract.address_hash), ^"%#{search_string}%") - ) - end -end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex index e31711703d46..1dab607c87bd 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex @@ -1,15 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy do @moduledoc """ Module for proxy smart-contract implementation detection """ use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + require Logger + + import EthereumJSONRPC, only: [id_to_params: 1, json_rpc: 2] + alias EthereumJSONRPC.Contract - alias Explorer.Chain.{Address, Hash, SmartContract} + alias Explorer.Chain.{Address, Data, Hash, SmartContract} alias Explorer.Chain.SmartContract.Proxy alias Explorer.Chain.SmartContract.Proxy.Models.Implementation + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour alias Explorer.Chain.SmartContract.Proxy.{ + BasicImplementationGetter, CloneWithImmutableArguments, EIP1167, EIP1822, @@ -18,61 +25,68 @@ defmodule Explorer.Chain.SmartContract.Proxy do EIP7702, ERC7760, MasterCopy, + MinimalProxy, ResolvedDelegateProxy } - alias Explorer.SmartContract.Helper, as: SmartContractHelper - import Explorer.Chain, only: [ join_associations: 2, - select_repo: 1, - string_to_address_hash: 1 + select_repo: 1 ] - import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [ - is_burn_signature: 1, get_implementation: 2, get_proxy_implementations: 1, - save_implementation_data: 4 + save_implementation_data: 2 ] - # supported signatures: - # 5c60da1b = keccak256(implementation()) - @implementation_signature "5c60da1b" - # aaf10f42 = keccak256(getImplementation()) - @get_implementation_signature "aaf10f42" - # bb82aa5e = keccak256(comptrollerImplementation()) Compound protocol proxy pattern - @comptroller_implementation_signature "bb82aa5e" - - @typep options :: [{:api?, true | false}, {:proxy_without_abi?, true | false}] + @proxy_resolvers [ + # bytecode-matching proxy types + {EIP1167, :eip1167}, + {EIP7702, :eip7702}, + {MasterCopy, :master_copy}, + {ERC7760, :erc7760}, + {CloneWithImmutableArguments, :clone_with_immutable_arguments}, + {ResolvedDelegateProxy, :resolved_delegate_proxy}, + {MinimalProxy, :minimal_proxy}, + + # generic proxy types + {EIP1967, :eip1967}, + {EIP1822, :eip1822}, + {EIP1967, :eip1967_beacon}, + {EIP2535, :eip2535}, + {EIP1967, :eip1967_oz}, + {BasicImplementationGetter, :basic_implementation}, + {BasicImplementationGetter, :basic_get_implementation}, + {BasicImplementationGetter, :comptroller} + ] + + @zero_address_hash_string "0x0000000000000000000000000000000000000000" + @zero_bytes32_string "0x0000000000000000000000000000000000000000000000000000000000000000" + + @type options :: [{:api?, true | false}] + + @spec zero_hex_string?(any()) :: boolean() + defp zero_hex_string?(term), do: term in ["0x", "0x0", @zero_address_hash_string, @zero_bytes32_string] @doc """ Fetches into DB proxy contract implementation's address and name from different proxy patterns """ - @spec fetch_implementation_address_hash(Hash.Address.t(), list(), options) :: - Implementation.t() | :empty | :error - def fetch_implementation_address_hash(proxy_address_hash, proxy_abi, options) + @spec fetch_implementation_address_hash(Hash.Address.t() | nil, options()) :: Implementation.t() | :empty | :error + def fetch_implementation_address_hash(proxy_address_hash, options) when not is_nil(proxy_address_hash) do - %{implementation_address_hash_strings: implementation_address_hash_strings, proxy_type: proxy_type} = - try_to_get_implementation_from_known_proxy_patterns( - proxy_address_hash, - proxy_abi, - options[:proxy_without_abi?] - ) + proxy_address = Address.get(proxy_address_hash, options) - save_implementation_data( - implementation_address_hash_strings, - proxy_address_hash, - proxy_type, - options - ) + case try_to_get_implementation_from_known_proxy_patterns(proxy_address) do + :empty -> :empty + :error -> :error + proxy_implementations -> save_implementation_data(proxy_implementations, options) + end end - def fetch_implementation_address_hash(_, _, _) do + def fetch_implementation_address_hash(_, _) do :empty end @@ -81,69 +95,17 @@ defmodule Explorer.Chain.SmartContract.Proxy do """ @spec proxy_contract?(SmartContract.t(), Keyword.t()) :: boolean() def proxy_contract?(smart_contract, options \\ []) do - {:ok, burn_address_hash} = string_to_address_hash(SmartContract.burn_address_hash_string()) proxy_implementations = get_proxy_implementations(smart_contract.address_hash) - with false <- is_nil(proxy_implementations), - false <- Enum.empty?(proxy_implementations.address_hashes), - implementation_address_hash = Enum.at(proxy_implementations.address_hashes, 0), - false <- implementation_address_hash.bytes == burn_address_hash.bytes do + if !is_nil(proxy_implementations) and !Enum.empty?(proxy_implementations.address_hashes) do true else - _ -> - implementation = get_implementation(smart_contract, options) - - with false <- is_nil(implementation), - false <- Enum.empty?(implementation.address_hashes) do - has_not_burn_address_hash?(implementation.address_hashes, burn_address_hash) - else - _ -> - false - end - end - end - - @spec has_not_burn_address_hash?([Hash.Address.t()], Hash.Address.t()) :: boolean() - defp has_not_burn_address_hash?(address_hashes, burn_address_hash) do - address_hashes - |> Enum.reduce_while(false, fn implementation_address_hash, acc -> - if implementation_address_hash.bytes == burn_address_hash.bytes, do: {:cont, acc}, else: {:halt, true} - end) - end - - @doc """ - Decodes and formats an address output from a smart contract ABI. - - This function handles various input formats and edge cases when decoding - address outputs from smart contract function calls or events. - - ## Parameters - - `address`: The address output to decode. Can be `nil`, `"0x"`, a binary string, or `:error`. - - ## Returns - - `nil` if the input is `nil`. - - The burn address hash string if the input is `"0x"`. - - A formatted address string if the input is a valid binary string. - - `:error` if the input is `:error`. - - `nil` for any other input type. - """ - @spec abi_decode_address_output(any()) :: nil | :error | binary() - def abi_decode_address_output(nil), do: nil - - def abi_decode_address_output("0x"), do: SmartContract.burn_address_hash_string() + implementation = get_implementation(smart_contract, options) - def abi_decode_address_output(address) when is_binary(address) do - if String.length(address) > 42 do - "0x" <> String.slice(address, -40, 40) - else - address + !is_nil(implementation) and !Enum.empty?(implementation.address_hashes) end end - def abi_decode_address_output(:error), do: :error - - def abi_decode_address_output(_), do: nil - @doc """ Gets implementation ABI for given proxy smart-contract """ @@ -165,271 +127,243 @@ defmodule Explorer.Chain.SmartContract.Proxy do def get_implementation_abi_from_proxy(_, _), do: [] @doc """ - Gets implementation from proxy contract's specific storage - """ - @spec get_implementation_from_storage(Hash.Address.t(), String.t(), any()) :: String.t() | :error | nil - def get_implementation_from_storage(proxy_address_hash, storage_slot, json_rpc_named_arguments) do - case Contract.eth_get_storage_at_request( - proxy_address_hash, - storage_slot, - nil, - json_rpc_named_arguments - ) do - {:ok, empty_address_hash_string} - when is_burn_signature(empty_address_hash_string) -> - nil - - {:ok, "0x" <> storage_value} -> - extract_address_hex_from_storage_pointer(storage_value) - - {:error, _error} -> - :error + Tries to get implementation address from known proxy patterns - _ -> - nil - end - end + ## Parameters + - `proxy_address`: The address to try to detect implementations for. - @doc """ - Tries to get implementation address from known proxy patterns + ## Returns + - Pre-filled `proxy_implementations` if the result should be saved to the database. + - `:error` if the implementation detection failed, nothing should be saved to the database. + - `:empty` if the address is empty or not a smart contract, nothing should be saved to the database. """ - @spec try_to_get_implementation_from_known_proxy_patterns(Hash.Address.t(), list() | nil, bool()) :: - %{implementation_address_hash_strings: [String.t()] | :error, proxy_type: atom()} - - def try_to_get_implementation_from_known_proxy_patterns(proxy_address_hash, proxy_abi, proxy_without_abi?) - when not is_nil(proxy_abi) or proxy_without_abi? == true do - functions = - [ - :get_implementation_address_hash_string_eip1167, - :get_implementation_address_hash_string_clones_with_immutable_arguments, - :get_implementation_address_hash_string_eip7702, - :get_implementation_address_hash_string_eip1967, - :get_implementation_address_hash_string_eip1822, - :get_implementation_address_hash_string_eip2535, - :get_implementation_address_hash_string_erc7760, - :get_implementation_address_hash_string_resolved_delegate_proxy - ] - - %{implementation_address_hash_strings: implementation_address_hash_strings, proxy_type: proxy_type} = - functions - |> Enum.reduce_while(nil, fn fun, _acc -> - %{ - implementation_address_hash_strings: implementation_address_hash_strings, - proxy_type: _proxy_type - } = result = apply(__MODULE__, fun, [proxy_address_hash]) - - case implementation_address_hash_strings do - [] -> {:cont, result} - :error -> {:halt, result} - _ -> {:halt, result} + @spec try_to_get_implementation_from_known_proxy_patterns(Address.t()) :: + %{ + proxy_address_hash: Hash.Address.t(), + address_hashes: [Hash.Address.t()], + proxy_type: atom() | nil, + conflicting_proxy_types: [atom()] | nil, + conflicting_address_hashes: [[Hash.Address.t()]] | nil + } + | :error + | :empty + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def try_to_get_implementation_from_known_proxy_patterns(proxy_address) do + with true <- Address.smart_contract?(proxy_address), + # first, we try to immediately resolve proxy types by matching bytecodes, + # while collecting fetch requirements for all other proxy types + resolvers_and_requirements when is_list(resolvers_and_requirements) <- + Enum.reduce_while(@proxy_resolvers, [], fn {module, proxy_type}, acc -> + case module.quick_resolve_implementations(proxy_address, proxy_type) do + {:ok, address_hashes} -> + filtered_address_hashes = address_hashes |> Enum.reject(&(&1 == proxy_address.hash)) + + if Enum.empty?(filtered_address_hashes) do + {:halt, + %{ + proxy_address_hash: proxy_address.hash, + address_hashes: [] + }} + else + {:halt, + %{ + proxy_address_hash: proxy_address.hash, + address_hashes: filtered_address_hashes, + proxy_type: proxy_type + }} + end + + {:cont, requirements} -> + {:cont, [{{module, proxy_type}, requirements} | acc]} + + :error -> + Logger.error( + "Failed to quick resolve implementations for proxy address #{proxy_address.hash} and proxy type #{proxy_type}" + ) + + {:halt, :error} + + _ -> + {:cont, acc} + end + end), + # didn't match any known bytecode pattern, proceed with fetching required values + {:ok, resolvers_and_fetched_values} <- + resolvers_and_requirements + |> Enum.reverse() + |> prefetch_values(proxy_address.hash), + generic_results when is_list(generic_results) <- + Enum.reduce_while(resolvers_and_fetched_values, [], fn {{module, proxy_type}, values}, acc -> + case module.resolve_implementations(proxy_address, proxy_type, values) do + {:ok, implementation_address_hashes} -> + filtered_address_hashes = implementation_address_hashes |> Enum.reject(&(&1 == proxy_address.hash)) + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if Enum.empty?(filtered_address_hashes) do + {:cont, acc} + else + {:cont, [{proxy_type, filtered_address_hashes} | acc]} + end + + :error -> + Logger.error( + "Failed to resolve implementations for proxy address #{proxy_address.hash} and proxy type #{proxy_type}" + ) + + {:halt, :error} + + _ -> + {:cont, acc} + end + end) do + {address_hashes, proxy_type, conflicting_proxy_types, conflicting_address_hashes} = + case Enum.reverse(generic_results) do + [] -> + {[], nil, nil, nil} + + [{proxy_type, address_hashes}] -> + {address_hashes, proxy_type, nil, nil} + + [{proxy_type, address_hashes} | rest] -> + address_hashes_sorted = address_hashes |> Enum.sort() + + if Enum.all?(rest, &(&1 |> elem(1) |> Enum.sort() == address_hashes_sorted)) do + {address_hashes, proxy_type, nil, nil} + else + {conflicting_proxy_types, conflicting_address_hashes} = Enum.unzip(rest) + {address_hashes, proxy_type, conflicting_proxy_types, conflicting_address_hashes} + end end - end) - - cond do - implementation_address_hash_strings == :error -> - fallback_proxy_detection(proxy_address_hash, proxy_abi, implementation_address_hash_strings_fallback(:error)) - implementation_address_hash_strings == [] || - implementation_address_hash_strings == [burn_address_hash_string()] -> - fallback_proxy_detection(proxy_address_hash, proxy_abi, implementation_address_hash_strings_fallback(nil)) - - true -> - %{implementation_address_hash_strings: implementation_address_hash_strings, proxy_type: proxy_type} + %{ + proxy_address_hash: proxy_address.hash, + address_hashes: address_hashes, + proxy_type: proxy_type, + conflicting_proxy_types: conflicting_proxy_types, + conflicting_address_hashes: conflicting_address_hashes + } + else + %{proxy_type: _} = result -> result + :error -> :error + _ -> :empty end end - def try_to_get_implementation_from_known_proxy_patterns(proxy_address_hash, proxy_abi, _proxy_without_abi?) do - fallback_proxy_detection(proxy_address_hash, proxy_abi, implementation_address_hash_strings_fallback(nil)) - end - - @spec get_implementation_address_hash_string_eip1167(Hash.Address.t()) :: - %{implementation_address_hash_strings: [String.t() | :error | nil], proxy_type: atom()} - def get_implementation_address_hash_string_eip1167(proxy_address_hash) do - get_implementation_address_hash_string_by_module(EIP1167, :eip1167, proxy_address_hash) - end - - @spec get_implementation_address_hash_string_clones_with_immutable_arguments(Hash.Address.t()) :: - %{implementation_address_hash_strings: [String.t()] | :error, proxy_type: atom()} - def get_implementation_address_hash_string_clones_with_immutable_arguments(proxy_address_hash) do - get_implementation_address_hash_string_by_module( - CloneWithImmutableArguments, - :clone_with_immutable_arguments, - proxy_address_hash - ) - end + @doc """ + Fetches all required eth_getStorageAt and eth_call results for given proxy resolvers and requirements. - @spec get_implementation_address_hash_string_eip7702(Hash.Address.t()) :: - %{implementation_address_hash_strings: [String.t()] | :error, proxy_type: atom()} - def get_implementation_address_hash_string_eip7702(proxy_address_hash) do - get_implementation_address_hash_string_by_module(EIP7702, :eip7702, proxy_address_hash) - end + ## Parameters + - `resolvers_and_requirements`: The list of proxy resolvers and their requirements. + - `address_hash`: The address hash to fetch the values for. - @spec get_implementation_address_hash_string_eip1967(Hash.Address.t()) :: %{ - implementation_address_hash_strings: [String.t() | :error | nil], - proxy_type: atom() - } - def get_implementation_address_hash_string_eip1967(proxy_address_hash) do - get_implementation_address_hash_string_by_module(EIP1967, :eip1967, proxy_address_hash) + ## Returns + - `{:ok, [{any(), ResolverBehaviour.fetched_values()}]}` if all of the values are fetched successfully, + map can contain nil values for failed/reverted eth_call requests. + - `:error` if the prefetching failed. + """ + @spec prefetch_values([{any(), ResolverBehaviour.fetch_requirements()}], Hash.Address.t()) :: + {:ok, [{any(), ResolverBehaviour.fetched_values()}]} | :error + def prefetch_values(resolvers_and_requirements, address_hash) do + with {:ok, fetched_values} <- + resolvers_and_requirements + |> Enum.flat_map(fn {_, reqs} -> Map.values(reqs) end) + |> fetch_values(address_hash), + resolvers_and_fetched_values when is_list(resolvers_and_fetched_values) <- + Enum.reduce_while(resolvers_and_requirements, [], fn {resolver, reqs}, acc -> + reduce_prefetched_resolver_values(resolver, reqs, fetched_values, acc) + end) do + {:ok, Enum.reverse(resolvers_and_fetched_values)} + end end - @spec get_implementation_address_hash_string_eip1822(Hash.Address.t()) :: %{ - implementation_address_hash_strings: [String.t() | :error | nil], - proxy_type: atom() - } - def get_implementation_address_hash_string_eip1822(proxy_address_hash) do - get_implementation_address_hash_string_by_module(EIP1822, :eip1822, proxy_address_hash) - end + defp reduce_prefetched_resolver_values(resolver, reqs, fetched_values, acc) do + values = Enum.into(reqs, %{}, fn {name, req} -> {name, Map.get(fetched_values, req, :error)} end) - @spec get_implementation_address_hash_string_eip2535(Hash.Address.t()) :: %{ - implementation_address_hash_strings: [String.t() | :error | nil], - proxy_type: atom() - } - def get_implementation_address_hash_string_eip2535(proxy_address_hash) do - get_implementation_address_hash_string_by_module(EIP2535, :eip2535, proxy_address_hash) + if Enum.any?(values, &(elem(&1, 1) == :error)) do + {:halt, :error} + else + {:cont, [{resolver, values} | acc]} + end end - @spec get_implementation_address_hash_string_erc7760(Hash.Address.t()) :: %{ - implementation_address_hash_strings: [String.t() | :error | nil], - proxy_type: atom() - } - def get_implementation_address_hash_string_erc7760(proxy_address_hash) do - get_implementation_address_hash_string_by_module(ERC7760, :erc7760, proxy_address_hash) - end + @doc """ + Fetches values for given eth_getStorageAt and eth_call requirements for a given address hash. - @spec get_implementation_address_hash_string_resolved_delegate_proxy(Hash.Address.t()) :: - %{implementation_address_hash_strings: [String.t() | :error | nil], proxy_type: atom()} - def get_implementation_address_hash_string_resolved_delegate_proxy(proxy_address_hash) do - get_implementation_address_hash_string_by_module( - ResolvedDelegateProxy, - :resolved_delegate_proxy, - proxy_address_hash - ) - end + ## Parameters + - `reqs`: The list of eth_getStorageAt and eth_call requirements to fetch the values for. + - `address_hash`: The address hash to fetch the values for. - defp get_implementation_address_hash_string_by_module( - module, - proxy_type, - proxy_address_hash - ) do - implementation_address_hash_strings = module.get_implementation_address_hash_strings(proxy_address_hash, api?: true) - - if implementation_address_hash_strings == [] || - implementation_address_hash_strings == [burn_address_hash_string()] || - implementation_address_hash_strings == :error do - implementation_address_hash_strings_fallback(implementation_address_hash_strings) + ## Returns + - `{:ok, prefetched_values()}` if all of the values are fetched successfully, + map can contain nil values for failed/reverted eth_call requests. + - `:error` if the prefetching failed. + """ + @spec fetch_values([ResolverBehaviour.fetch_requirement()], Hash.Address.t()) :: + {:ok, %{ResolverBehaviour.fetch_requirement() => String.t() | nil}} | :error + def fetch_values(reqs, address_hash) do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + id_to_params = id_to_params(reqs) + + with {:ok, responses} <- + id_to_params + |> Enum.map(fn {index, req} -> encode_request(req, address_hash, index) end) + |> json_rpc(json_rpc_named_arguments), + fetched_values when is_map(fetched_values) <- + Enum.reduce_while(responses, %{}, fn result, acc -> + reduce_fetched_value(result, id_to_params, acc) + end) do + {:ok, fetched_values} else - %{ - implementation_address_hash_strings: implementation_address_hash_strings, - proxy_type: proxy_type - } + _ -> :error end end - defp implementation_address_hash_strings_fallback(implementation_value) do - value = if implementation_value == :error, do: :error, else: [] - - %{implementation_address_hash_strings: value, proxy_type: :unknown} + defp reduce_fetched_value(result, id_to_params, acc) do + with %{id: id} <- result, + {:ok, req} <- Map.fetch(id_to_params, id), + {:ok, value} <- handle_response(req, result) do + {:cont, Map.put(acc, req, value)} + else + _ -> + {:halt, :error} + end end - @spec fallback_proxy_detection(Hash.Address.t(), list() | nil, %{ - implementation_address_hash_strings: [String.t()] | :error, - proxy_type: atom() - }) :: %{ - implementation_address_hash_strings: [String.t()] | :error, - proxy_type: atom() - } - defp fallback_proxy_detection(proxy_address_hash, proxy_abi, fallback_value) do - proxy_type = define_fallback_proxy_type(proxy_abi) - - case proxy_type do - :implementation -> - implementation_address_hash_string = - SmartContractHelper.get_binary_string_from_contract_getter( - @implementation_signature, - to_string(proxy_address_hash), - proxy_abi - ) - - %{ - implementation_address_hash_strings: - implementation_address_hash_string_to_list(implementation_address_hash_string), - proxy_type: :basic_implementation - } - - :get_implementation -> - implementation_address_hash_string = - SmartContractHelper.get_binary_string_from_contract_getter( - @get_implementation_signature, - to_string(proxy_address_hash), - proxy_abi - ) - - %{ - implementation_address_hash_strings: - implementation_address_hash_string_to_list(implementation_address_hash_string), - proxy_type: :basic_get_implementation - } - - :master_copy -> - implementation_address_hash_string = MasterCopy.get_implementation_address_hash_string(proxy_address_hash) - - %{ - implementation_address_hash_strings: - implementation_address_hash_string_to_list(implementation_address_hash_string), - proxy_type: :master_copy - } + @doc """ + Fetches value for the given eth_getStorageAt or eth_call request for a given address hash. - :comptroller -> - implementation_address_hash_string = - SmartContractHelper.get_binary_string_from_contract_getter( - @comptroller_implementation_signature, - proxy_address_hash, - proxy_abi - ) + The eth_call request is allowed to fail/revert, nil will be returned in such case. - %{ - implementation_address_hash_strings: - implementation_address_hash_string_to_list(implementation_address_hash_string), - proxy_type: :comptroller - } + ## Parameters + - `req`: The eth_getStorageAt or eth_call request to fetch the value for. + - `address_hash`: The address hash to fetch the value for. - _ -> - fallback_value - end - end + ## Returns + - `{:ok, String.t() | nil}` if the value is fetched successfully. + - `:error` if the fetch request failed. + """ + @spec fetch_value(ResolverBehaviour.fetch_requirement(), Hash.Address.t()) :: {:ok, String.t() | nil} | :error + def fetch_value(req, address_hash) do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - defp implementation_address_hash_string_to_list(implementation_address_hash_string) do - case implementation_address_hash_string do - :error -> :error - nil -> [] - hash -> [hash] + case req |> encode_request(address_hash, 0) |> json_rpc(json_rpc_named_arguments) do + {:ok, response} -> handle_response(req, %{result: response}) + {:error, error} -> handle_response(req, %{error: error}) end end - defp define_fallback_proxy_type(nil), do: nil - - defp define_fallback_proxy_type(proxy_abi) do - methods_to_proxy_types = %{ - "implementation" => :implementation, - "getImplementation" => :get_implementation, - "comptrollerImplementation" => :comptroller, - "facetAddresses" => :diamond - } - - proxy_abi - |> Enum.reduce_while(nil, fn method, acc -> - cond do - Map.get(method, "name") in Map.keys(methods_to_proxy_types) && Map.get(method, "stateMutability") == "view" -> - {:halt, methods_to_proxy_types[Map.get(method, "name")]} + defp encode_request({:storage, value}, address_hash, index), + do: Contract.eth_get_storage_at_request(to_string(address_hash), value, index) - MasterCopy.pattern?(method) -> - {:halt, :master_copy} + defp encode_request({:call, value}, address_hash, index), + do: Contract.eth_call_request(value, to_string(address_hash), index, nil, nil) - true -> - {:cont, acc} - end - end) - end + defp handle_response({:storage, _}, %{result: result}) when is_binary(result), do: {:ok, result} + defp handle_response({:call, _}, %{result: result}) when is_binary(result), do: {:ok, result} + # TODO: it'll be better to return nil only for the revert-related errors + defp handle_response({:call, _}, %{error: _}), do: {:ok, nil} + defp handle_response(_, _), do: :error @doc """ Returns combined ABI from proxy and implementation smart-contracts @@ -446,13 +380,19 @@ defmodule Explorer.Chain.SmartContract.Proxy do end @doc """ - Decodes 20 bytes address hex from smart-contract storage pointer value + Decodes non-zero address hash from raw smart-contract hex response """ - @spec extract_address_hex_from_storage_pointer(binary()) :: binary() - def extract_address_hex_from_storage_pointer(storage_value) when is_binary(storage_value) do - address_hex = storage_value |> String.slice(-40, 40) |> String.pad_leading(40, ["0"]) - - "0x" <> address_hex + @spec extract_address_hash(String.t() | nil) :: {:ok, Hash.Address.t()} | :error | nil + def extract_address_hash(value) do + with false <- is_nil(value), + false <- zero_hex_string?(value), + {:ok, %Data{bytes: bytes}} <- Data.cast(value), + false <- byte_size(bytes) > 32 do + Hash.Address.cast((<<0::160>> <> bytes) |> binary_slice(-20, 20)) + else + :error -> :error + _ -> nil + end end @doc """ @@ -473,64 +413,42 @@ defmodule Explorer.Chain.SmartContract.Proxy do end @doc """ - Retrieves formatted proxy object based on its implementation addresses and names. + Retrieves formatted proxy implementation objects with addresses and names. ## Parameters - * `implementation_addresses` - A list of implementation addresses for the proxy object. - * `implementation_names` - A list of implementation names for the proxy object. + * `proxy_implementation` - An `Implementation.t()` struct. ## Returns - A list of maps containing information about the proxy object. + A list of maps containing information about the proxy implementations. """ @spec proxy_object_info(Implementation.t() | nil) :: [map()] def proxy_object_info(nil), do: [] def proxy_object_info(proxy_implementation) do - implementations_info = prepare_implementations(proxy_implementation) + implementations_info = prepare_implementations(proxy_implementation.addresses) implementation_addresses = proxy_implementation.address_hashes implementation_names = proxy_implementation.names implementation_addresses |> Enum.zip(implementation_names) - |> Enum.reduce([], fn {address, name}, acc -> - case address do - %Hash{} = address_hash -> - [ - # todo: "address" should be removed in favour `address_hash` property with the next release after 8.0.0 - %{ - "address_hash" => Address.checksum(address_hash), - "address" => Address.checksum(address_hash), - "name" => name - } - |> chain_type_fields(implementations_info) - | acc - ] - - _ -> - with {:ok, address_hash} <- string_to_address_hash(address), - checksummed_address <- Address.checksum(address_hash) do - [ - # todo: "address" should be removed in favour `address_hash` property with the next release after 8.0.0 - %{"address_hash" => checksummed_address, "address" => checksummed_address, "name" => name} - |> chain_type_fields(implementations_info) - | acc - ] - else - _ -> acc - end - end + |> Enum.map(fn {address_hash, name} -> + %{ + "address_hash" => Address.checksum(address_hash), + "name" => name + } + |> chain_type_fields(implementations_info) end) end if @chain_type == :filecoin do - def chain_type_fields(%{"address" => address_hash} = address, implementations_info) do + def chain_type_fields(%{"address_hash" => address_hash} = address, implementations_info) do Map.put(address, "filecoin_robust_address", implementations_info[address_hash]) end - def prepare_implementations(%Implementation{addresses: [_ | _] = addresses}) do + def prepare_implementations(addresses) when is_list(addresses) do Enum.into(addresses, %{}, fn address -> {Address.checksum(address.hash), address.filecoin_robust} end) end @@ -546,4 +464,52 @@ defmodule Explorer.Chain.SmartContract.Proxy do :ignore end end + + @doc """ + Returns conflicting implementations info for a given proxy implementation. + + ## Parameters + + * `proxy_implementation` - An `Implementation.t()` struct. + + ## Returns + + A list of maps containing information about the conflicting proxy implementations, if more than 1 proxy type is present. + + """ + @spec conflicting_implementations_info(Implementation.t() | nil) :: [map()] | nil + def conflicting_implementations_info( + %{ + proxy_type: proxy_type, + conflicting_proxy_types: conflicting_proxy_types, + conflicting_address_hashes: conflicting_address_hashes + } = proxy_implementation + ) + when not is_nil(proxy_type) and is_list(conflicting_proxy_types) and is_list(conflicting_address_hashes) do + implementations_info = prepare_implementations(proxy_implementation.conflicting_addresses) + + conflicting_implementations = + conflicting_proxy_types + |> Enum.zip(conflicting_address_hashes) + |> Enum.map(fn {proxy_type, address_hashes} -> + %{ + "proxy_type" => proxy_type, + "implementations" => + Enum.map( + address_hashes, + &(%{"address_hash" => Address.checksum(&1)} |> chain_type_fields(implementations_info)) + ) + } + end) + + [ + %{ + "proxy_type" => proxy_type, + "implementations" => proxy_object_info(proxy_implementation) + } + | conflicting_implementations + ] + end + + def conflicting_implementations_info(_proxy_implementation), do: nil end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/basic_implementation_getter.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/basic_implementation_getter.ex new file mode 100644 index 000000000000..68340902f00e --- /dev/null +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/basic_implementation_getter.ex @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.Proxy.BasicImplementationGetter do + @moduledoc """ + Module for fetching proxy implementation from public smart-contract method + """ + + alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour + + @behaviour ResolverBehaviour + + # 0x5c60da1b = keccak256(implementation()) + @implementation_signature <<0x5C60DA1B::4-unit(8)>> + # 0xaaf10f42 = keccak256(getImplementation()) + @get_implementation_signature <<0xAAF10F42::4-unit(8)>> + # 0xbb82aa5e = keccak256(comptrollerImplementation()) + @comptroller_implementation_signature <<0xBB82AA5E::4-unit(8)>> + + @impl true + def quick_resolve_implementations(address, proxy_type) do + signature = + case proxy_type do + :basic_implementation -> @implementation_signature + :basic_get_implementation -> @get_implementation_signature + :comptroller -> @comptroller_implementation_signature + _ -> nil + end + + # don't resolve implementations if the bytecode doesn't contain function selector in it + if signature && address.contract_code && :binary.match(address.contract_code.bytes, signature) != :nomatch do + {:cont, + %{ + implementation_getter: {:call, "0x" <> Base.encode16(signature, case: :lower)} + }} + else + nil + end + end + + @impl true + def resolve_implementations(_proxy_address, _proxy_type, prefetched_values) do + with {:ok, value} <- Map.fetch(prefetched_values, :implementation_getter), + {:ok, address_hash} <- Proxy.extract_address_hash(value) do + {:ok, [address_hash]} + else + :error -> :error + _ -> nil + end + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments.ex index 6ccc06c433cc..c7ca7cae1265 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments.ex @@ -1,51 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.CloneWithImmutableArguments do @moduledoc """ - Module for fetching proxy implementation from https://github.com/wighawag/clones-with-immutable-args + Module for fetching proxy implementation from clone contracts with immutable arguments. + Supports both wighawag (https://github.com/wighawag/clones-with-immutable-args) and + solady (https://github.com/Vectorized/solady/blob/main/src/utils/LibClone.sol) variants. """ - alias Explorer.Chain - alias Explorer.Chain.{Address, Hash} - alias Explorer.Chain.SmartContract.Proxy - - @doc """ - Get implementation address hash string following "Clone with immutable arguments" proxy pattern. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] - def get_implementation_address_hash_strings(proxy_address_hash, options \\ []) do - case get_implementation_address_hash_string(proxy_address_hash, options) do - nil -> [] - implementation_address_hash_string -> [implementation_address_hash_string] - end - end - - # Get implementation address hash string following "Clone with immutable arguments" pattern - @spec get_implementation_address_hash_string(Hash.Address.t(), Keyword.t()) :: binary() | nil - defp get_implementation_address_hash_string(proxy_address_hash, options) do - case Chain.select_repo(options).get(Address, proxy_address_hash) do - nil -> - nil - - target_address -> - contract_code = target_address.contract_code - - case contract_code do - %Chain.Data{bytes: contract_code_bytes} -> - contract_bytecode = Base.encode16(contract_code_bytes, case: :lower) - - contract_bytecode |> get_proxy_clone_with_immutable_arguments() |> Proxy.abi_decode_address_output() - - _ -> - nil - end - end - end - - defp get_proxy_clone_with_immutable_arguments(contract_bytecode) do - case contract_bytecode do - "3d3d3d3d363d3d3761" <> - <<_::binary-size(4)>> <> - "603736393661" <> <<_::binary-size(4)>> <> "013d73" <> <> <> _ -> - "0x" <> template_address + alias Explorer.Chain.Hash + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour + + @behaviour ResolverBehaviour + + @impl true + def quick_resolve_implementations(proxy_address, _proxy_type) do + case proxy_address.contract_code && proxy_address.contract_code.bytes do + # wighawag variant: https://github.com/wighawag/clones-with-immutable-args/blob/196f1ecc6485c1bf2d41677fa01d3df4927ff9ce/src/ClonesWithImmutableArgs.sol + <<0x3D3D3D3D363D3D3761::9-unit(8), _::2-bytes, 0x603736393661::6-unit(8), _::2-bytes, 0x013D73::3-unit(8), + template_address::20-bytes, _::binary>> -> + {:ok, template_address_hash} = Hash.Address.cast(template_address) + {:ok, [template_address_hash]} + + # solady variant: https://github.com/Vectorized/solady/blob/main/src/utils/LibClone.sol#L620 + <<0x363D3D373D3D3D363D73::10-unit(8), template_address::20-bytes, 0x5AF43D3D93803E602A57FD5BF3::13-unit(8), + _::binary>> -> + {:ok, template_address_hash} = Hash.Address.cast(template_address) + {:ok, [template_address_hash]} _ -> nil diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1167.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1167.ex index acaf1716cf55..250db408a2ba 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1167.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1167.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.EIP1167 do @moduledoc """ Module for fetching proxy implementation from https://eips.ethereum.org/EIPS/eip-1167 (Minimal Proxy Contract) @@ -6,48 +7,20 @@ defmodule Explorer.Chain.SmartContract.Proxy.EIP1167 do alias Explorer.Chain alias Explorer.Chain.{Address, Hash, SmartContract} alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour - @doc """ - Get implementation address hash string following EIP-1167. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] - def get_implementation_address_hash_strings(proxy_address_hash, options \\ []) do - case get_implementation_address_hash_string(proxy_address_hash, options) do - nil -> [] - implementation_address_hash_string -> [implementation_address_hash_string] - end - end - - # Get implementation address hash string following EIP-1167 - @spec get_implementation_address_hash_string(Hash.Address.t(), Keyword.t()) :: binary() | nil - defp get_implementation_address_hash_string(proxy_address_hash, options) do - case Chain.select_repo(options).get(Address, proxy_address_hash) do - nil -> - nil - - target_address -> - contract_code = target_address.contract_code + @behaviour ResolverBehaviour - case contract_code do - %Chain.Data{bytes: contract_code_bytes} -> - contract_bytecode = Base.encode16(contract_code_bytes, case: :lower) + @impl true + def quick_resolve_implementations(proxy_address, _proxy_type) do + case proxy_address.contract_code && proxy_address.contract_code.bytes do + <<0x363D3D373D3D3D363D73::10-unit(8), template_address::20-bytes, 0x5AF43D82803E903D91602B57FD5BF3::15-unit(8)>> -> + {:ok, template_address_hash} = Hash.Address.cast(template_address) + {:ok, [template_address_hash]} - contract_bytecode |> get_proxy_eip_1167() |> Proxy.abi_decode_address_output() - - _ -> - nil - end - end - end - - defp get_proxy_eip_1167(contract_bytecode) do - case contract_bytecode do - "363d3d373d3d3d363d73" <> <> <> "5af43d82803e903d91602b57fd5bf3" -> - "0x" <> template_address - - # https://medium.com/coinmonks/the-more-minimal-proxy-5756ae08ee48 - "3d3d3d3d363d3d37363d73" <> <> <> "5af43d3d93803e602a57fd5bf3" -> - "0x" <> template_address + <<0x3D3D3D3D363D3D37363D73::11-unit(8), template_address::20-bytes, 0x5AF43D3D93803E602A57FD5BF3::13-unit(8)>> -> + {:ok, template_address_hash} = Hash.Address.cast(template_address) + {:ok, [template_address_hash]} _ -> nil @@ -59,8 +32,11 @@ defmodule Explorer.Chain.SmartContract.Proxy.EIP1167 do """ @spec get_implementation_smart_contract(Hash.Address.t(), Keyword.t()) :: SmartContract.t() | nil def get_implementation_smart_contract(address_hash, options \\ []) do - address_hash - |> get_implementation_address_hash_string(options) - |> Proxy.implementation_to_smart_contract(options) + address = Chain.select_repo(options).get(Address, address_hash) + + case address && quick_resolve_implementations(address, :eip1167) do + {:ok, [address_hash]} -> Proxy.implementation_to_smart_contract(address_hash, options) + _ -> nil + end end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1822.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1822.ex index 0fc51b5644c4..a5df461dce95 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1822.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1822.ex @@ -1,38 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.EIP1822 do @moduledoc """ Module for fetching proxy implementation from https://eips.ethereum.org/EIPS/eip-1822 Universal Upgradeable Proxy Standard (UUPS) """ - alias Explorer.Chain - alias Explorer.Chain.Hash + alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour + + @behaviour ResolverBehaviour # keccak256("PROXIABLE") @storage_slot_proxiable "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7" - @doc """ - Get implementation address hash string following EIP-1822. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] | :error - def get_implementation_address_hash_strings(proxy_address_hash, _options \\ []) do - case get_implementation_address_hash_string(proxy_address_hash) do - nil -> [] + @impl true + def quick_resolve_implementations(_proxy_address, _proxy_type), + do: + {:cont, + %{ + implementation_slot: {:storage, @storage_slot_proxiable} + }} + + @impl true + def resolve_implementations(_proxy_address, _proxy_type, prefetched_values) do + with {:ok, value} <- Map.fetch(prefetched_values, :implementation_slot), + {:ok, address_hash} <- Proxy.extract_address_hash(value) do + {:ok, [address_hash]} + else :error -> :error - implementation_address_hash_string -> [implementation_address_hash_string] + _ -> nil end end - - # Get implementation address hash string following EIP-1822 - @spec get_implementation_address_hash_string(Hash.Address.t()) :: binary() | nil | :error - defp get_implementation_address_hash_string(proxy_address_hash) do - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - proxiable_contract_address_hash_string = - Proxy.get_implementation_from_storage( - proxy_address_hash, - @storage_slot_proxiable, - json_rpc_named_arguments - ) - - Proxy.abi_decode_address_output(proxiable_contract_address_hash_string) - end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1967.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1967.ex index 83950860a438..f7011098bb44 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1967.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_1967.ex @@ -1,15 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.EIP1967 do @moduledoc """ Module for fetching proxy implementation from https://eips.ethereum.org/EIPS/eip-1967 (Proxy Storage Slots) """ - alias Explorer.Chain - alias Explorer.Chain.Hash alias Explorer.Chain.SmartContract.Proxy - alias Explorer.SmartContract.Helper, as: SmartContractHelper + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour - # supported signatures: - # 5c60da1b = keccak256(implementation()) - @implementation_signature "5c60da1b" + @behaviour ResolverBehaviour + + # 0x5c60da1b = keccak256(implementation()) + @implementation_signature "0x5c60da1b" # obtained as bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) @storage_slot_logic_contract_address "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" @@ -21,100 +21,43 @@ defmodule Explorer.Chain.SmartContract.Proxy.EIP1967 do # This is the keccak-256 hash of "org.zeppelinos.proxy.implementation" @storage_slot_openzeppelin_contract_address "0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3" - @implementation_method_abi [ - %{ - "type" => "function", - "stateMutability" => "view", - "outputs" => [%{"type" => "address", "name" => "", "internalType" => "address"}], - "name" => "implementation", - "inputs" => [] - } - ] + @impl true + def quick_resolve_implementations(_proxy_address, proxy_type) do + storage_slot = + case proxy_type do + :eip1967 -> @storage_slot_logic_contract_address + :eip1967_oz -> @storage_slot_openzeppelin_contract_address + :eip1967_beacon -> @storage_slot_beacon_contract_address + _ -> nil + end - @doc """ - Get implementation address hash string following EIP-1967. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] | :error - def get_implementation_address_hash_strings(proxy_address_hash, _options \\ []) do - case get_implementation_address_hash_string(proxy_address_hash) do - nil -> [] - :error -> :error - implementation_address_hash_string -> [implementation_address_hash_string] + if is_nil(storage_slot) do + nil + else + {:cont, + %{ + implementation_slot: {:storage, storage_slot} + }} end end - # Get implementation address hash string following EIP-1967 - @spec get_implementation_address_hash_string(Hash.Address.t()) :: binary() | nil | :error - defp get_implementation_address_hash_string(proxy_address_hash) do - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - implementation_address_hash_string_from_logic_storage_slot = - Proxy.get_implementation_from_storage( - proxy_address_hash, - @storage_slot_logic_contract_address, - json_rpc_named_arguments - ) - - implementation_address_hash_string = - if implementation_address_hash_string_from_logic_storage_slot && - implementation_address_hash_string_from_logic_storage_slot !== :error do - implementation_address_hash_string_from_logic_storage_slot - else - implementation_address_hash_string_from_beacon_storage_slot = - fetch_beacon_proxy_implementation(proxy_address_hash, json_rpc_named_arguments) - - if implementation_address_hash_string_from_beacon_storage_slot && - implementation_address_hash_string_from_beacon_storage_slot !== :error do - implementation_address_hash_string_from_beacon_storage_slot - else - Proxy.get_implementation_from_storage( - proxy_address_hash, - @storage_slot_openzeppelin_contract_address, - json_rpc_named_arguments - ) - end - end - - Proxy.abi_decode_address_output(implementation_address_hash_string) + @impl true + def resolve_implementations(_proxy_address, proxy_type, prefetched_values) do + with {:ok, value} <- Map.fetch(prefetched_values, :implementation_slot), + {:ok, stored_address_hash} <- Proxy.extract_address_hash(value) do + resolve_stored_implementation(proxy_type, stored_address_hash) + else + :error -> :error + _ -> nil + end end - # changes requested by https://github.com/blockscout/blockscout/issues/4770 - # for support BeaconProxy pattern - defp fetch_beacon_proxy_implementation(proxy_address_hash, json_rpc_named_arguments) do - eip1967_beacon_address_hash_string = - Proxy.get_implementation_from_storage( - proxy_address_hash, - @storage_slot_beacon_contract_address, - json_rpc_named_arguments - ) - - case eip1967_beacon_address_hash_string do - :error -> - :error - - nil -> - nil - - _ -> - case @implementation_signature - |> SmartContractHelper.get_binary_string_from_contract_getter( - eip1967_beacon_address_hash_string, - @implementation_method_abi - ) do - <> -> - implementation_address_hash_string - - _ -> - nil - end + defp resolve_stored_implementation(:eip1967_beacon, stored_address_hash) do + with {:ok, value} <- Proxy.fetch_value({:call, @implementation_signature}, stored_address_hash), + {:ok, implementation_address_hash} <- Proxy.extract_address_hash(value) do + {:ok, [implementation_address_hash]} end end - @doc """ - Shares logic storage slot to other modules - """ - @spec storage_slot_logic_contract_address() :: String.t() - def storage_slot_logic_contract_address do - @storage_slot_logic_contract_address - end + defp resolve_stored_implementation(_, stored_address_hash), do: {:ok, [stored_address_hash]} end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_2535.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_2535.ex index c7c754f2fa98..a46cb4d2596f 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_2535.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_2535.ex @@ -1,37 +1,59 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.EIP2535 do @moduledoc """ Module for fetching proxy implementation from https://eips.ethereum.org/EIPS/eip-2535 (Diamond Proxy) """ - # 52ef6b2c = keccak256(facetAddresses()) - @facet_addresses_signature "52ef6b2c" + alias ABI.TypeDecoder + alias Explorer.Chain.{Data, Hash} + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour - alias Explorer.Chain - alias Explorer.Chain.Hash - alias Explorer.SmartContract.Helper, as: SmartContractHelper + @behaviour ResolverBehaviour - @facet_addresses_method_abi [ - %{ - "inputs" => [], - "name" => "facetAddresses", - "outputs" => [%{"internalType" => "address[]", "name" => "facetAddresses_", "type" => "address[]"}], - "stateMutability" => "view", - "type" => "function" - } - ] + # 0x52ef6b2c = keccak256(facetAddresses()) + @facet_addresses_signature "0x52ef6b2c" - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] - def get_implementation_address_hash_strings(proxy_address_hash, _options \\ []) do - case @facet_addresses_signature - |> SmartContractHelper.get_binary_string_from_contract_getter( - to_string(proxy_address_hash), - @facet_addresses_method_abi - ) do - implementation_addresses when is_list(implementation_addresses) -> - implementation_addresses + @max_implementations_number_per_proxy 100 - _ -> - [] + @impl true + def quick_resolve_implementations(_proxy_address, _proxy_type), + do: + {:cont, + %{ + implementation_getter: {:call, @facet_addresses_signature} + }} + + @impl true + def resolve_implementations(_proxy_address, _proxy_type, prefetched_values) do + with {:ok, value} <- Map.fetch(prefetched_values, :implementation_getter), + {:ok, address_hashes} <- extract_address_hashes(value) do + {:ok, address_hashes} + else + :error -> :error + _ -> nil + end + end + + # Decodes unique non-zero address hashes from raw smart-contract hex response + @spec extract_address_hashes(String.t() | nil) :: {:ok, [Hash.Address.t()]} | :error | nil + defp extract_address_hashes(value) do + with false <- is_nil(value), + {:ok, %Data{bytes: bytes}} <- Data.cast(value), + [all_address_hashes] when is_list(all_address_hashes) <- + (try do + TypeDecoder.decode_raw(bytes, [{:array, :address}]) + rescue + _ -> nil + end) do + {:ok, + all_address_hashes + |> Enum.reject(&(&1 == <<0::160>>)) + |> Enum.map(&(&1 |> Hash.Address.cast() |> elem(1))) + |> Enum.take(@max_implementations_number_per_proxy) + |> Enum.uniq()} + else + :error -> :error + _ -> nil end end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex index 7bcbc027c453..30a8839afe77 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex @@ -1,82 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.EIP7702 do @moduledoc """ Module for fetching EOA delegate from https://eips.ethereum.org/EIPS/eip-7702 """ - alias Explorer.Chain - alias Explorer.Chain.{Address, Hash} - alias Explorer.Chain.SmartContract.Proxy - alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Chain.Hash + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour - @doc """ - Get implementation address hash string following EIP-7702. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] - def get_implementation_address_hash_strings(address_hash, options \\ []) do - case get_implementation_address_hash_string(address_hash, options) do - nil -> [] - implementation_address_hash_string -> [implementation_address_hash_string] - end - end - - # Retrieves the delegate address hash string for an EIP-7702 compatible EOA. - - # This function fetches the contract code for the given address and extracts - # the delegate address according to the EIP-7702 specification. + @behaviour ResolverBehaviour - # ## Parameters - # - `address_hash`: The address of the contract to check. - # - `options`: Optional keyword list of options (default: `[]`). + @impl true + def quick_resolve_implementations(proxy_address, _proxy_type \\ :eip7702) do + case proxy_address.contract_code && proxy_address.contract_code.bytes do + <<0xEF0100::3-unit(8), template_address::20-bytes>> -> + {:ok, template_address_hash} = Hash.Address.cast(template_address) + {:ok, [template_address_hash]} - # ## Returns - # - The delegate address in the hex string format if found and successfully decoded. - # - `nil` if the address doesn't exist, has no contract code, or the delegate address - # couldn't be extracted or decoded. - @spec get_implementation_address_hash_string(Hash.Address.t(), [Chain.api?()]) :: binary() | nil - defp get_implementation_address_hash_string(address_hash, options) do - case Chain.select_repo(options).get(Address, address_hash) do - nil -> + _ -> nil - - target_address -> - contract_code = target_address.contract_code - - case contract_code do - %Chain.Data{bytes: contract_code_bytes} -> - contract_code_bytes |> get_delegate_address() |> Proxy.abi_decode_address_output() - - _ -> - nil - end - end - end - - @doc """ - Extracts the EIP-7702 delegate address from the bytecode. - - This function analyzes the given bytecode to identify and extract the delegate - address according to the EIP-7702 specification. - - ## Parameters - - `contract_code_bytes`: The binary representation of the contract bytecode. - - ## Returns - - A string representation of the delegate address prefixed with "0x" if found. - - `nil` if the delegate address is not present in the bytecode. - - ## Examples - iex> get_delegate_address(<<239, 1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20>>) - "0x0102030405060708090a0b0c0d0e0f10111213" - - iex> get_delegate_address(<<1, 2, 3>>) - nil - """ - @spec get_delegate_address(binary()) :: String.t() | nil - def get_delegate_address(contract_code_bytes) do - case contract_code_bytes do - # 0xef0100 <> address - <<239, 1, 0>> <> <> -> ExplorerHelper.add_0x_prefix(address) - _ -> nil end end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/erc_7760.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/erc_7760.ex index ef1ba2abbd0c..0e5dc4d2ec42 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/erc_7760.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/erc_7760.ex @@ -1,100 +1,72 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.ERC7760 do @moduledoc """ Module for fetching proxy implementation from https://github.com/ethereum/ERCs/blob/master/ERCS/erc-7760.md """ - alias Explorer.Chain - alias Explorer.Chain.{Address, Hash} alias Explorer.Chain.SmartContract.Proxy - alias Explorer.Chain.SmartContract.Proxy.EIP1967 - - @uups_basic_variant "363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6038573d6000fd5b3d6000f3" - @uups_l_variant "365814604357363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e603e573d6000fd5b3d6000f35b6020600f3d393d51543d52593df3" - @beacon_basic_variant "363d3d373d3d363d602036600436635c60da1b60e01b36527fa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50545afa5036515af43d6000803e604d573d6000fd5b3d6000f3" - @beacon_l_variant "363d3d373d3d363d602036600436635c60da1b60e01b36527fa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50545afa361460525736515af43d600060013e6052573d6001fd5b3d6001f3" - @transparent_basic_variant_20_left "3d3d3373" - @transparent_basic_variant_20_right "14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b3d356020355560408036111560525736038060403d373d3d355af43d6000803e6052573d6000fd" - @transparent_basic_variant_14_left "3d3d336d" - @transparent_basic_variant_14_right "14605157363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e604c573d6000fd5b3d6000f35b3d3560203555604080361115604c5736038060403d373d3d355af43d6000803e604c573d6000fd" - @transparent_l_variant_20_left "3658146083573d3d3373" - @transparent_l_variant_20_right "14605d57363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6058573d6000fd5b3d6000f35b3d35602035556040360380156058578060403d373d3d355af43d6000803e6058573d6000fd5b602060293d393d51543d52593df3" - @transparent_l_variant_14_left "365814607d573d3d336d" - @transparent_l_variant_14_right "14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b3d35602035556040360380156052578060403d373d3d355af43d6000803e6052573d6000fd5b602060233d393d51543d52593df3" - - @doc """ - Get implementation address hash string following ERC-7760. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t()) :: [binary()] - def get_implementation_address_hash_strings(proxy_address_hash, options \\ []) do - case get_implementation_address_hash_string(proxy_address_hash, options) do - nil -> [] - implementation_address_hash_string -> [implementation_address_hash_string] - end - end - - # Get implementation address hash string following ERC-7760 - @spec get_implementation_address_hash_string(Hash.Address.t(), Keyword.t()) :: binary() | nil - defp get_implementation_address_hash_string(proxy_address_hash, options) do - case Chain.select_repo(options).get(Address, proxy_address_hash) do - nil -> - nil - - target_address -> - contract_code = target_address.contract_code - - case contract_code do - %Chain.Data{bytes: contract_code_bytes} -> - contract_bytecode = Base.encode16(contract_code_bytes, case: :lower) - - contract_bytecode |> get_proxy_erc_7760(proxy_address_hash) - - _ -> - nil - end - end - end - - # credo:disable-for-next-line /Complexity/ - defp get_proxy_erc_7760(contract_bytecode, proxy_address_hash) do - case String.downcase(contract_bytecode) do - @transparent_basic_variant_20_left <> - <<_factory_address::binary-size(40)>> <> @transparent_basic_variant_20_right <> _ -> - fetch_implementation(proxy_address_hash) - - @transparent_basic_variant_14_left <> - <<_factory_address::binary-size(28)>> <> @transparent_basic_variant_14_right <> _ -> - fetch_implementation(proxy_address_hash) - - @transparent_l_variant_20_left <> <<_factory_address::binary-size(40)>> <> @transparent_l_variant_20_right <> _ -> - fetch_implementation(proxy_address_hash) - - @transparent_l_variant_14_left <> <<_factory_address::binary-size(28)>> <> @transparent_l_variant_14_right <> _ -> - fetch_implementation(proxy_address_hash) - - @uups_basic_variant <> _ -> - fetch_implementation(proxy_address_hash) - - @uups_l_variant <> _ -> - fetch_implementation(proxy_address_hash) - - @beacon_basic_variant <> _ -> - fetch_implementation(proxy_address_hash) - - @beacon_l_variant <> _ -> - fetch_implementation(proxy_address_hash) - - _ -> - nil + alias Explorer.Chain.SmartContract.Proxy.{EIP1967, ResolverBehaviour} + + @behaviour ResolverBehaviour + + @transparent_basic_variant_20_left <<0x3D3D3373::4-unit(8)>> + @transparent_basic_variant_20_right <<0x14605757363D3D37363D7F360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC545AF43D6000803E6052573D6000FD5B3D6000F35B3D356020355560408036111560525736038060403D373D3D355AF43D6000803E6052573D6000FD::824>> + @transparent_basic_variant_14_left <<0x3D3D336D::4-unit(8)>> + @transparent_basic_variant_14_right <<0x14605157363D3D37363D7F360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC545AF43D6000803E604C573D6000FD5B3D6000F35B3D3560203555604080361115604C5736038060403D373D3D355AF43D6000803E604C573D6000FD::824>> + @transparent_i_variant_20_left <<0x3658146083573D3D3373::10-unit(8)>> + @transparent_i_variant_20_right <<0x14605D57363D3D37363D7F360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC545AF43D6000803E6058573D6000FD5B3D6000F35B3D35602035556040360380156058578060403D373D3D355AF43D6000803E6058573D6000FD5B602060293D393D51543D52593DF3::928>> + @transparent_i_variant_14_left <<0x365814607D573D3D336D::10-unit(8)>> + @transparent_i_variant_14_right <<0x14605757363D3D37363D7F360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC545AF43D6000803E6052573D6000FD5B3D6000F35B3D35602035556040360380156052578060403D373D3D355AF43D6000803E6052573D6000FD5B602060233D393D51543D52593DF3::928>> + @uups_basic_variant <<0x363D3D373D3D363D7F360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC545AF43D6000803E6038573D6000FD5B3D6000F3::488>> + @uups_i_variant <<0x365814604357363D3D373D3D363D7F360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC545AF43D6000803E603E573D6000FD5B3D6000F35B6020600F3D393D51543D52593DF3::656>> + @beacon_basic_variant <<0x363D3D373D3D363D602036600436635C60DA1B60E01B36527FA3F0AD74E5423AEBFD80D3EF4346578335A9A72AEAEE59FF6CB3582B35133D50545AFA5036515AF43D6000803E604D573D6000FD5B3D6000F3::656>> + @beacon_i_variant <<0x363D3D373D3D363D602036600436635C60DA1B60E01B36527FA3F0AD74E5423AEBFD80D3EF4346578335A9A72AEAEE59FF6CB3582B35133D50545AFA361460525736515AF43D600060013E6052573D6001FD5B3D6001F3::696>> + + @impl true + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + def quick_resolve_implementations(proxy_address, _proxy_type \\ :erc7760) do + eip1967_proxy_type = + case proxy_address.contract_code && proxy_address.contract_code.bytes do + <<@transparent_basic_variant_20_left, _::20-bytes, @transparent_basic_variant_20_right, _::binary>> -> + :eip1967 + + <<@transparent_basic_variant_14_left, _::14-bytes, @transparent_basic_variant_14_right, _::binary>> -> + :eip1967 + + <<@transparent_i_variant_20_left, _::20-bytes, @transparent_i_variant_20_right, _::binary>> -> + :eip1967 + + <<@transparent_i_variant_14_left, _::14-bytes, @transparent_i_variant_14_right, _::binary>> -> + :eip1967 + + @uups_basic_variant <> _ -> + :eip1967 + + @uups_i_variant <> _ -> + :eip1967 + + @beacon_basic_variant <> _ -> + :eip1967_beacon + + @beacon_i_variant <> _ -> + :eip1967_beacon + + _ -> + nil + end + + with false <- is_nil(eip1967_proxy_type), + {:cont, reqs} <- EIP1967.quick_resolve_implementations(proxy_address, eip1967_proxy_type), + resolvers_and_requirements = [{{EIP1967, eip1967_proxy_type}, reqs}], + {:ok, [{_, prefetched_values}]} <- Proxy.prefetch_values(resolvers_and_requirements, proxy_address.hash), + {:ok, address_hashes} <- EIP1967.resolve_implementations(proxy_address, eip1967_proxy_type, prefetched_values) do + {:ok, address_hashes} + else + :error -> :error + # proceed to other proxy types only if bytecode doesn't match + true -> nil + # if bytecode matches but resolution fails, we should halt + _ -> {:ok, []} end end - - defp fetch_implementation(proxy_address_hash) do - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - Proxy.get_implementation_from_storage( - proxy_address_hash, - EIP1967.storage_slot_logic_contract_address(), - json_rpc_named_arguments - ) - end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/master_copy.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/master_copy.ex index f7ef76c0eda4..a2572171b8ad 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/master_copy.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/master_copy.ex @@ -1,68 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.MasterCopy do @moduledoc """ Module for fetching master-copy proxy implementation """ - alias EthereumJSONRPC.Contract - alias Explorer.Chain.Hash alias Explorer.Chain.SmartContract.Proxy - - import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [is_burn_signature: 1] - - @doc """ - Gets implementation address hash string for proxy contract from master-copy pattern - """ - @spec get_implementation_address_hash_string(Hash.Address.t()) :: binary() | nil - def get_implementation_address_hash_string(proxy_address_hash) do - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - master_copy_storage_pointer = "0x0" - - implementation_address = - case Contract.eth_get_storage_at_request( - proxy_address_hash, - master_copy_storage_pointer, - nil, - json_rpc_named_arguments - ) do - {:ok, empty_address} - when is_burn_signature(empty_address) -> - "0x" - - {:ok, "0x" <> storage_value} -> - logic_contract_address = Proxy.extract_address_hex_from_storage_pointer(storage_value) - logic_contract_address - - _ -> - nil - end - - Proxy.abi_decode_address_output(implementation_address) - end - - @doc """ - Checks if the input of the smart-contract follows master-copy (or Safe) proxy pattern before - fetching its implementation from 0x0 storage pointer - """ - @spec pattern?(map()) :: any() - def pattern?(method) do - Map.get(method, "type") == "constructor" && - method - |> Enum.find(fn item -> - case item do - {"inputs", inputs} -> - find_input_by_name(inputs, "_masterCopy") || find_input_by_name(inputs, "_singleton") - - _ -> - false - end - end) - end - - defp find_input_by_name(inputs, name) do - inputs - |> Enum.find(fn input -> - Map.get(input, "name") == name - end) + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour + + @behaviour ResolverBehaviour + + # All known Safe Proxy bytecodes + # Proxy factory addresses are fetched from https://github.com/safe-global/safe-deployments/tree/main/src/assets + # Bytecodes are fetched from contract instances created by those factories + @safe_proxy_v1_0_0 <<0x608060405273FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF600054163660008037600080366000845AF43D6000803E6000811415603D573D6000FD5B3D6000F3FEA165627A7A723058201E7D648B83CFAC072CBCCEFC2FFC62A6999D4A050EE87A721942DE1DA9670DB80029::880>> + @safe_proxy_v1_1_1 <<0x608060405273FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF600054167FA619486E0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000F35B3660008037600080366000845AF43D6000803E60008114156070573D6000FD5B3D6000F3FEA265627A7A72315820D8A00DC4FE6BF675A9D7416FC2D00BB3433362AA8186B750F76C4027269667FF64736F6C634300050E0032::1360>> + @safe_proxy_v1_3_0 <<0x608060405273FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF600054167FA619486E0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000F35B3660008037600080366000845AF43D6000803E60008114156070573D6000FD5B3D6000F3FEA2646970667358221220D1429297349653A4918076D650332DE1A1068C5F3E07C5C82360C277770B955264736F6C63430007060033::1368>> + @safe_proxy_v1_3_0_zksync <<0x000400000000000200000000030100190000006003300270000000360430019700030000004103550002000000010355000000360030019D000100000000001F0000008001000039000000400010043F0000000101200190000000150000C13D000000000100041A00000037021001970000000201000367000000000301043B0000003D0330009C000000590000C13D00000000002004350000003E01000041000000D20001042E0000000001000416000000000110004C000000570000C13D0000000203000367000000400100043D00000000020000310000001F0420018F0000000505200272000000270000613D000000000600001900000005076002100000000008710019000000000773034F000000000707043B00000000007804350000000106600039000000000756004B0000001F0000413D000000000640004C000000360000613D0000000505500210000000000353034F00000000055100190000000304400210000000000605043300000000064601CF000000000646022F000000000303043B0000010004400089000000000343022F00000000034301CF000000000363019F00000000003504350000000003120019000000400030043F000000200220008C000000570000413D00000000010104330000003701100198000000BD0000C13D00000064013000390000003A02000041000000000021043500000044013000390000003B0200004100000000002104350000002401300039000000220200003900000000002104350000003C010000410000000000130435000000040130003900000020020000390000000000210435000000400100043D000000000213004900000084022000390000003603000041000000360420009C0000000002038019000000360410009C000000000103801900000040011002100000006002200210000000000112019F000000D3000104300000000001000019000000D30001043000000000030000310000001F0430018F0000000503300272000000650000613D00000000050000190000000506500210000000000761034F000000000707043B00000000007604350000000105500039000000000635004B0000005E0000413D000000000540004C000000730000613D00000003044002100000000503300210000000000503043300000000054501CF000000000545022F000000000131034F000000000101043B0000010004400089000000000141022F00000000014101CF000000000151019F000000000013043500000000010000310000000003000414000000040420008C000000930000C13D000000030100036700000001020000310000001F0320018F0000000502200272000000840000613D00000000040000190000000505400210000000000651034F000000000606043B00000000006504350000000104400039000000000524004B0000007D0000413D000000000430004C000000BA0000613D00000003033002100000000502200210000000000402043300000000043401CF000000000434022F000000000121034F000000000101043B0000010003300089000000000131022F00000000013101CF000000000141019F0000000000120435000000BA0000013D0000003604000041000000360530009C0000000003048019000000C0033002100000006001100210000000000113001900D100CC0000040F0003000000010355000000000301001900000060043002700000001F0340018F000100360040019D00000036044001970000000504400272000000AA0000613D00000000050000190000000506500210000000000761034F000000000707043B00000000007604350000000105500039000000000645004B000000A30000413D000000000530004C000000B80000613D00000003033002100000000504400210000000000504043300000000053501CF000000000535022F000000000141034F000000000101043B0000010003300089000000000131022F00000000013101CF000000000151019F00000000001404350000000101200190000000C60000613D000000600100003900000001011001FF000000D20001042E000000000200041A0000003802200197000000000112019F000000000010041B0000002001000039000001000010044300000120000004430000003901000041000000D20001042E00000036010000410000000102000031000000360320009C00000000010240190000006001100210000000D300010430000000CF002104250000000102000039000000000001042D0000000002000019000000000001042D000000D100000432000000D20001042E000000D300010430000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFF000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000002000000000000000000000000000000400000010000000000000000006564000000000000000000000000000000000000000000000000000000000000496E76616C69642073696E676C65746F6E20616464726573732070726F76696408C379A000000000000000000000000000000000000000000000000000000000A619486E0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ACBE897875BB4F3E88089713FAB44968F091FDEB912D0AFADD2FE5700E4E0CC6::16640>> + @safe_proxy_v1_4_1 <<0x608060405273FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF600054167FA619486E0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000F35B3660008037600080366000845AF43D6000803E60008114156070573D6000FD5B3D6000F3FEA264697066735822122003D1488EE65E08FA41E58E888A9865554C535F2C77126A82CB4C0F917F31441364736F6C63430007060033::1368>> + @safe_proxy_v1_4_1_zksync <<0x00010000000000020000008004000039000000400040043F000000000301001900000060033002700000002E033001970000000100200190000000100000C13D000000000200041A0000003002200197000000000401043B000000360040009C0000004B0000C13D000000000020043F0000003701000041000000B40001042E0000000002000416000000000002004B000000490000C13D0000001F0530018F0000002F0630019800000080026000390000001C0000613D000000000701034F000000007807043C0000000004840436000000000024004B000000180000C13D000000000005004B000000290000613D000000000161034F0000000304500210000000000502043300000000054501CF000000000545022F000000000101043B0000010004400089000000000141022F00000000014101CF000000000151019F00000000001204350000008001300039000000400010043F000000200030008C000000490000413D000000800200043D0000003002200198000000A30000C13D00000033020000410000000000210435000000E40130003900000034020000410000000000210435000000C40130003900000035020000410000000000210435000000A40130003900000022020000390000000000210435000000840130003900000020020000390000000000210435000000400100043D000000000213004900000104022000390000002E0020009C0000002E0200804100000060022002100000002E0010009C0000002E010080410000004001100210000000000112019F000000B5000104300000000001000019000000B5000104300000001F0530018F0000002F04300198000000540000613D000000000601034F0000000007000019000000006806043C0000000007870436000000000047004B000000500000C13D000000000005004B000000610000613D000000000641034F0000000305500210000000000704043300000000075701CF000000000757022F000000000606043B0000010005500089000000000656022F00000000055601CF000000000575019F00000000005404350000000004000414000000040020008C0000007D0000C13D000000000331034F000000000100003100000038021001980000001F0410018F0000006F0000613D000000000503034F0000000006000019000000005705043C0000000006760436000000000026004B0000006B0000C13D000000000004004B0000009F0000613D000000000323034F0000000304400210000000000502043300000000054501CF000000000545022F000000000303043B0000010004400089000000000343022F00000000034301CF000000000353019F00000000003204350000009F0000013D00000060013002100000002E0040009C0000002E04008041000000C003400210000000000113019F00B300AE0000040F000000000301001900000060033002700000001F0530018F0000002E0030019D0000002F043001980000008F0000613D000000000601034F0000000007000019000000006806043C0000000007870436000000000047004B0000008B0000C13D000000000005004B0000009C0000613D000000000141034F0000000305500210000000000604043300000000065601CF000000000656022F000000000101043B0000010005500089000000000151022F00000000015101CF000000000161019F00000000001404350000002E013001970000000100200190000000AC0000613D0000002E0010009C0000002E010080410000006001100210000000B40001042E000000000100041A0000003101100197000000000121019F000000000010041B0000002001000039000001000010044300000120000004430000003201000041000000B40001042E0000006001100210000000B500010430000000B1002104250000000102000039000000000001042D0000000002000019000000000001042D000000B300000432000000B40001042E000000B5000104300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFF00000000000000000000000000000000000000000000000000000000FFFFFFE0000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000000000000000000000000000000000000000000000020000000000000000000000000000004000000100000000000000000008C379A0000000000000000000000000000000000000000000000000000000006564000000000000000000000000000000000000000000000000000000000000496E76616C69642073696E676C65746F6E20616464726573732070726F766964A619486E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE000000000000000000000000000000000000000000000000000000000000000004D3D6AC1CA9BD3D9AB93E603DE7967DA33803409D7B4500E28F36CE7E4DD5A21::15104>> + @safe_proxy_v1_5_0 <<0x608060405260005463A619486E60003560E01C14156024578060601B606C5260206060F35B3660008037600080366000845AF43D6000803E806040573D6000FD5B3D6000F3FEA2646970667358221220E61834EBD2D8CD909D362BF67C47EF58FD665DF38E6DD036CE65611101D072E964736F6C63430007060033::984>> + + @impl true + def quick_resolve_implementations(proxy_address, _proxy_type) do + with true <- + proxy_address.contract_code && + proxy_address.contract_code.bytes in [ + @safe_proxy_v1_0_0, + @safe_proxy_v1_1_1, + @safe_proxy_v1_3_0, + @safe_proxy_v1_3_0_zksync, + @safe_proxy_v1_4_1, + @safe_proxy_v1_4_1_zksync, + @safe_proxy_v1_5_0 + ], + {:ok, value} <- Proxy.fetch_value({:storage, "0x0"}, proxy_address.hash), + {:ok, address_hash} <- Proxy.extract_address_hash(value) do + {:ok, [address_hash]} + else + :error -> :error + # proceed to other proxy types only if bytecode doesn't match + false -> nil + # if bytecode matches but resolution fails, we should halt + _ -> {:ok, []} + end end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/minimal_proxy.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/minimal_proxy.ex new file mode 100644 index 000000000000..f59be1f6569c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/minimal_proxy.ex @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.Proxy.MinimalProxy do + @max_bytecode_byte_size 100 + + @moduledoc """ + Fetches the implementation address from minimal proxy contracts where the EIP-1167-like + bytecode sequence is embedded in the middle of the contract bytecode rather than at the start. + + The pattern `3D3D3D3D363D3D37363D73` is searched anywhere in the bytecode; the 20 bytes + immediately following it are the implementation address. + + Only bytecode up to #{@max_bytecode_byte_size} bytes is considered. Standard minimal proxies are + well below that size; longer bytecode is treated as a different contract shape and skipped to + avoid false positives from incidental pattern matches in large contracts. + """ + + alias Explorer.Chain.Hash + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour + + @behaviour ResolverBehaviour + + @pattern <<0x3D3D3D3D363D3D37363D73::11-unit(8)>> + + @impl true + def quick_resolve_implementations(proxy_address, _proxy_type) do + case proxy_address.contract_code && proxy_address.contract_code.bytes do + bytes when is_binary(bytes) and byte_size(bytes) <= @max_bytecode_byte_size -> + case :binary.match(bytes, @pattern) do + {pos, len} when byte_size(bytes) >= pos + len + 20 -> + template_address = binary_part(bytes, pos + len, 20) + {:ok, template_address_hash} = Hash.Address.cast(template_address) + {:ok, [template_address_hash]} + + _ -> + nil + end + + _ -> + nil + end + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex index 13fb70519dfd..9700f14ea6a1 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do @moduledoc """ The representation of proxy smart-contract implementation. @@ -11,20 +12,34 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do import Ecto.Query, only: [ from: 2, - select: 3 + select: 3, + where: 3 ] alias Explorer.{Chain, Repo} alias Explorer.Chain.{Address, Hash, SmartContract} alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.SmartContract.Proxy.EIP7702 alias Timex.Duration - @burn_address_hash_string "0x0000000000000000000000000000000000000000" - @burn_address_hash_string_32 "0x0000000000000000000000000000000000000000000000000000000000000000" - @max_implementations_number_per_proxy 100 - - defguard is_burn_signature(term) when term in ["0x", "0x0", @burn_address_hash_string, @burn_address_hash_string_32] + @proxy_types [ + :eip1167, + :eip1967, + :eip1822, + :eip1967_oz, + :eip1967_beacon, + :master_copy, + :basic_implementation, + :basic_get_implementation, + :comptroller, + :eip2535, + :clone_with_immutable_arguments, + :eip7702, + :resolved_delegate_proxy, + :erc7760, + :minimal_proxy + ] @typedoc """ * `proxy_address_hash` - proxy `smart_contract` address hash. @@ -37,31 +52,16 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do field(:proxy_address_hash, Hash.Address, primary_key: true, null: false) # the order matches order of enum values in the DB - field(:proxy_type, Ecto.Enum, - values: [ - :eip1167, - :eip1967, - :eip1822, - # todo: it is obsolete. Consider re-define the custom type in the future to remove this value. - :eip930, - :master_copy, - :basic_implementation, - :basic_get_implementation, - :comptroller, - :eip2535, - :clone_with_immutable_arguments, - :eip7702, - :resolved_delegate_proxy, - :erc7760, - :unknown - ], - null: true - ) + field(:proxy_type, Ecto.Enum, values: @proxy_types, null: true) field(:address_hashes, {:array, Hash.Address}, null: false) field(:names, {:array, :string}, null: false) + field(:conflicting_proxy_types, {:array, Ecto.Enum}, values: @proxy_types, null: true) + field(:conflicting_address_hashes, {:array, {:array, Hash.Address}}, null: true) has_many(:addresses, Address, foreign_key: :hash, references: :address_hashes) + has_many(:smart_contracts, SmartContract, foreign_key: :address_hash, references: :address_hashes) + has_many(:conflicting_addresses, Address, foreign_key: :hash, references: :conflicting_address_hashes) belongs_to( :address, @@ -80,9 +80,15 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do :proxy_address_hash, :proxy_type, :address_hashes, + :names, + :conflicting_proxy_types, + :conflicting_address_hashes + ]) + |> validate_required([ + :proxy_address_hash, + :address_hashes, :names ]) - |> validate_required([:proxy_address_hash, :proxy_type, :address_hashes, :names]) |> unique_constraint([:proxy_address_hash]) end @@ -90,7 +96,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do Returns all implementations for the given smart-contract address hash """ @spec get_proxy_implementations(Hash.Address.t() | nil, Keyword.t()) :: __MODULE__.t() | nil - def get_proxy_implementations(proxy_address_hash, options \\ []) do + def get_proxy_implementations(proxy_address_hash, options \\ [api?: true]) do proxy_address_hash |> get_proxy_implementations_query() |> Chain.select_repo(options).one() @@ -197,8 +203,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do def get_implementation( %{ updated: %SmartContract{ - address_hash: address_hash, - abi: abi + address_hash: address_hash }, implementation_updated_at: implementation_updated_at, implementation_address_fetched?: implementation_address_fetched?, @@ -215,7 +220,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do Task.async(fn -> # Here and only here we fetch implementations for the given address # using requests to the JSON RPC node for known proxy patterns - result = Proxy.fetch_implementation_address_hash(address_hash, abi, options) + result = Proxy.fetch_implementation_address_hash(address_hash, options) callback = Keyword.get(options, :callback, nil) uid = Keyword.get(options, :uid) @@ -308,126 +313,182 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do @doc """ Saves proxy's implementation into the DB """ - @spec save_implementation_data([String.t()], Hash.Address.t(), atom() | nil, Keyword.t()) :: - __MODULE__.t() | :empty | :error - def save_implementation_data(:error, _proxy_address_hash, _proxy_type, _options) do - :error - end + @spec save_implementation_data( + %{ + proxy_address_hash: Hash.Address.t(), + address_hashes: [Hash.Address.t()], + proxy_type: atom() | nil, + conflicting_proxy_types: [atom()] | nil, + conflicting_address_hashes: [[Hash.Address.t()]] | nil + }, + Keyword.t() + ) :: __MODULE__.t() | :error + def save_implementation_data(proxy_implementations, options) do + query = + from( + address in Address, + where: address.hash in ^proxy_implementations.address_hashes, + left_join: s1 in assoc(address, :smart_contract), + left_join: s2 in SmartContract, + on: fragment("md5(?)", address.contract_code) == s2.contract_code_md5 and is_nil(s1), + select: {address.hash, fragment("COALESCE(?, ?)", s1.name, s2.name)} + ) + + implementation_names_map = + query + |> Chain.select_repo(options).all() + |> Enum.into(%{}) - def save_implementation_data(implementation_address_hash_strings, proxy_address_hash, proxy_type, options) - when implementation_address_hash_strings == [] do - upsert_implementations(proxy_address_hash, proxy_type, [], [], options) + implementation_names = proxy_implementations.address_hashes |> Enum.map(&Map.get(implementation_names_map, &1)) - :empty + case upsert_implementations(proxy_implementations |> Map.put(:names, implementation_names)) do + {:ok, result} -> + result + + {:error, error} -> + Logger.error("Error while upserting proxy implementations data into the DB: #{inspect(error)}") + :error + end end - def save_implementation_data( - [empty_implementation_address_hash_string], - proxy_address_hash, - proxy_type, - options - ) - when is_burn_signature(empty_implementation_address_hash_string) do - upsert_implementations(proxy_address_hash, proxy_type, [], [], options) + @spec upsert_implementations(%{ + proxy_address_hash: Hash.Address.t(), + address_hashes: [Hash.Address.t()], + proxy_type: atom() | nil, + conflicting_proxy_types: [atom()] | nil, + conflicting_address_hashes: [[Hash.Address.t()]] | nil, + names: [String.t()] + }) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} + defp upsert_implementations(proxy_implementations) do + %__MODULE__{} + |> changeset(proxy_implementations) + |> Repo.insert(on_conflict: on_conflict(), conflict_target: [:proxy_address_hash], allow_stale: true) + end - :empty + defp on_conflict do + from( + proxy_implementations in __MODULE__, + update: [ + set: [ + proxy_type: fragment("EXCLUDED.proxy_type"), + address_hashes: fragment("EXCLUDED.address_hashes"), + names: fragment("EXCLUDED.names"), + conflicting_proxy_types: fragment("EXCLUDED.conflicting_proxy_types"), + conflicting_address_hashes: fragment("EXCLUDED.conflicting_address_hashes"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", proxy_implementations.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", proxy_implementations.updated_at) + ] + ], + where: + fragment("EXCLUDED.proxy_type <> ?", proxy_implementations.proxy_type) or + fragment("EXCLUDED.address_hashes <> ?", proxy_implementations.address_hashes) or + fragment("EXCLUDED.names <> ?", proxy_implementations.names) or + fragment("EXCLUDED.conflicting_proxy_types IS DISTINCT FROM ?", proxy_implementations.conflicting_proxy_types) or + fragment( + "EXCLUDED.conflicting_address_hashes IS DISTINCT FROM ?", + proxy_implementations.conflicting_address_hashes + ) + ) end - def save_implementation_data( - implementation_address_hash_strings, - proxy_address_hash, - proxy_type, - options - ) do - {implementation_addresses, implementation_names} = - implementation_address_hash_strings - |> Enum.map(fn implementation_address_hash_string -> - with {:ok, implementation_address_hash} <- Chain.string_to_address_hash(implementation_address_hash_string), - {:implementation, {%SmartContract{name: name}, _}} <- { - :implementation, - SmartContract.address_hash_to_smart_contract_with_bytecode_twin(implementation_address_hash, options) - } do - {implementation_address_hash_string, name} - else - :error -> - :error + @doc """ + Deletes all proxy implementations associated with the given proxy address hashes. - {:implementation, _} -> - {implementation_address_hash_string, nil} - end - end) - |> Enum.filter(&(&1 !== :error)) - |> Enum.unzip() + ## Parameters - if Enum.empty?(implementation_addresses) do - :empty - else - case upsert_implementations( - proxy_address_hash, - proxy_type, - implementation_addresses, - implementation_names, - options - ) do - {:ok, result} -> - result + - `address_hashes` (binary): The list of proxy address hashes whose implementations + should be deleted. - {:error, error} -> - Logger.error("Error while upserting proxy implementations data into the DB: #{inspect(error)}") - :error - end - end - end + ## Returns - defp upsert_implementations(proxy_address_hash, proxy_type, implementation_address_hash_strings, names, options) do - proxy = get_proxy_implementations(proxy_address_hash, options) + - `{count, nil}`: A tuple where `count` is the number of records deleted. - if proxy do - update_implementations(proxy, proxy_type, implementation_address_hash_strings, names) - else - insert_implementations(proxy_address_hash, proxy_type, implementation_address_hash_strings, names) - end + This function uses a query to find all proxy implementations matching the + provided `address_hashes` and deletes them from the database. + """ + @spec delete_implementations([Hash.Address.t()]) :: {non_neg_integer(), nil} + def delete_implementations(address_hashes) do + __MODULE__ + |> where([proxy_implementations], proxy_implementations.proxy_address_hash in ^address_hashes) + |> Repo.delete_all() end - @spec insert_implementations(Hash.Address.t(), atom() | nil, [EthereumJSONRPC.hash()], [String.t()]) :: - {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} - defp insert_implementations(proxy_address_hash, proxy_type, implementation_address_hash_strings, names) - when not is_nil(proxy_address_hash) do - sanitized_implementation_address_hash_strings = - sanitize_implementation_address_hash_strings(implementation_address_hash_strings) + @doc """ + Upserts multiple EIP-7702 proxy records for given addresses. + + ## Parameters - changeset = %{ - proxy_address_hash: proxy_address_hash, - proxy_type: proxy_type, - address_hashes: sanitized_implementation_address_hash_strings, - names: names - } + - `addresses`: The list of addresses to upsert EIP-7702 proxy records for. - %__MODULE__{} - |> changeset(changeset) - |> Repo.insert() - end + ## Returns - @spec update_implementations(__MODULE__.t(), atom() | nil, [EthereumJSONRPC.hash()], [String.t()]) :: - {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} - defp update_implementations(proxy, proxy_type, implementation_address_hash_strings, names) do - sanitized_implementation_address_hash_strings = - sanitize_implementation_address_hash_strings(implementation_address_hash_strings) - - proxy - |> changeset(%{ - proxy_type: proxy_type, - address_hashes: sanitized_implementation_address_hash_strings, - names: names - }) - |> Repo.update() + - `{count, nil}`: A tuple where `count` is the number of records updated. + """ + @spec upsert_eip7702_implementations([Address.t()]) :: {non_neg_integer(), nil | []} + def upsert_eip7702_implementations(addresses) do + now = DateTime.utc_now() + + records = + addresses + |> Repo.preload(:smart_contract) + |> Enum.flat_map(fn address -> + case EIP7702.quick_resolve_implementations(address) do + {:ok, [delegate_address_hash]} -> + # credo:disable-for-lines:2 Credo.Check.Refactor.Nesting + name = + case address do + %{smart_contract: %{name: name}} -> name + _ -> nil + end + + [ + %{ + proxy_address_hash: address.hash, + proxy_type: :eip7702, + address_hashes: [delegate_address_hash], + names: [name], + inserted_at: now, + updated_at: now + } + ] + + _ -> + [] + end + end) + + Repo.insert_all(__MODULE__, records, + on_conflict: eip7702_on_conflict(), + conflict_target: [:proxy_address_hash] + ) end - # Cut off implementations per proxy up to @max_implementations_number_per_proxy number - # before insert into the DB to prevent DoS via the verification endpoint of Diamond smart contracts. - @spec sanitize_implementation_address_hash_strings([EthereumJSONRPC.hash()]) :: [EthereumJSONRPC.hash()] - defp sanitize_implementation_address_hash_strings(implementation_address_hash_strings) do - Enum.take(implementation_address_hash_strings, @max_implementations_number_per_proxy) + defp eip7702_on_conflict do + from( + proxy_implementations in __MODULE__, + update: [ + set: [ + address_hashes: fragment("EXCLUDED.address_hashes"), + names: fragment("EXCLUDED.names"), + conflicting_proxy_types: fragment("EXCLUDED.conflicting_proxy_types"), + conflicting_address_hashes: fragment("EXCLUDED.conflicting_address_hashes"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", proxy_implementations.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", proxy_implementations.updated_at) + ] + ], + where: + fragment("EXCLUDED.proxy_type = ?", proxy_implementations.proxy_type) and + (fragment("EXCLUDED.address_hashes <> ?", proxy_implementations.address_hashes) or + fragment("EXCLUDED.names <> ?", proxy_implementations.names) or + fragment( + "EXCLUDED.conflicting_proxy_types IS DISTINCT FROM ?", + proxy_implementations.conflicting_proxy_types + ) or + fragment( + "EXCLUDED.conflicting_address_hashes IS DISTINCT FROM ?", + proxy_implementations.conflicting_address_hashes + )) + ) end @doc """ @@ -439,7 +500,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do def names(proxy_address, options) when not is_nil(proxy_address) do proxy_implementations = get_proxy_implementations(proxy_address.hash, options) - if proxy_implementations && not Enum.empty?(proxy_implementations.names) do + if proxy_implementations do proxy_implementations.names else [] @@ -448,6 +509,43 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do def names(_, _), do: [] + @doc """ + Fetches and associates smart contracts for a nested list of address hashes. + + This function takes a nested list of address hashes (`nested_address_hashes`), queries the database + for smart contracts matching the flattened list of address hashes, and then maps the results back + to the original nested structure. Each address hash is associated with its corresponding smart + contract, if found. + + ## Parameters + + - `nested_address_hashes`: A nested list of address hashes (e.g., `[[hash1, hash2], [hash3]]`). + + ## Returns + + - A list of tuples where each tuple contains: + - The original list of address hashes. + - A list of smart contracts corresponding to the address hashes. + + """ + @spec smart_contract_association_for_implementations([Hash.Address.t()]) :: [ + {[Hash.Address.t()], [SmartContract.t() | nil]} + ] + def smart_contract_association_for_implementations(nested_address_hashes) do + query = + from(smart_contract in SmartContract, where: smart_contract.address_hash in ^List.flatten(nested_address_hashes)) + + smart_contracts_map = + query + |> Repo.replica().all() + |> Map.new(&{&1.address_hash, &1}) + + for address_hashes <- nested_address_hashes, + smart_contracts when not is_nil(smart_contracts) <- address_hashes |> Enum.map(&smart_contracts_map[&1]) do + {address_hashes, smart_contracts} + end + end + if @chain_type == :filecoin do @doc """ Fetches associated addresses for Filecoin based on the provided nested IDs. @@ -471,7 +569,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do |> Map.new(&{&1.hash, &1}) for ids <- nested_ids, - address <- ids |> Enum.map(&addresses_map[&1]) do + address when not is_nil(address) <- ids |> List.flatten() |> Enum.map(&addresses_map[&1]) do {ids, address} end end @@ -484,10 +582,15 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do ## Examples iex> Explorer.Chain.SmartContract.Proxy.Models.Implementation.proxy_implementations_association() - [proxy_implementations: [addresses: &Explorer.Chain.SmartContract.Proxy.Models.Implementation.addresses_association_for_filecoin/1]] + [ + proxy_implementations: [ + addresses: &Explorer.Chain.SmartContract.Proxy.Models.Implementation.addresses_association_for_filecoin/1, + conflicting_addresses: &Explorer.Chain.SmartContract.Proxy.Models.Implementation.addresses_association_for_filecoin/1 + ] + ] """ @spec proxy_implementations_association() :: [ - proxy_implementations: [addresses: fun()] + proxy_implementations: [addresses: fun(), conflicting_addresses: fun()] ] def proxy_implementations_association do [proxy_implementations: proxy_implementations_addresses_association()] @@ -501,12 +604,28 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do ## Examples iex> Explorer.Chain.SmartContract.Proxy.Models.Implementation.proxy_implementations_addresses_association() - [addresses: &Explorer.Chain.SmartContract.Proxy.Models.Implementation.addresses_association_for_filecoin/1] + [ + addresses: &Explorer.Chain.SmartContract.Proxy.Models.Implementation.addresses_association_for_filecoin/1, + conflicting_addresses: &Explorer.Chain.SmartContract.Proxy.Models.Implementation.addresses_association_for_filecoin/1 + ] """ - @spec proxy_implementations_association() :: [addresses: fun()] + @spec proxy_implementations_addresses_association() :: [addresses: fun(), conflicting_addresses: fun()] def proxy_implementations_addresses_association do - [addresses: &__MODULE__.addresses_association_for_filecoin/1] + [ + addresses: &__MODULE__.addresses_association_for_filecoin/1, + conflicting_addresses: &__MODULE__.addresses_association_for_filecoin/1 + ] + end + + def proxy_implementations_smart_contracts_association do + [ + proxy_implementations: [ + addresses: &__MODULE__.addresses_association_for_filecoin/1, + conflicting_addresses: &__MODULE__.addresses_association_for_filecoin/1, + smart_contracts: &__MODULE__.smart_contract_association_for_implementations/1 + ] + ] end else @doc """ @@ -541,5 +660,9 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do def proxy_implementations_addresses_association do [] end + + def proxy_implementations_smart_contracts_association do + [proxy_implementations: [smart_contracts: &__MODULE__.smart_contract_association_for_implementations/1]] + end end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolved_delegate_proxy.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolved_delegate_proxy.ex index 60e44d5430c0..9851cbea70bc 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolved_delegate_proxy.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolved_delegate_proxy.ex @@ -1,148 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.ResolvedDelegateProxy do @moduledoc """ Module for fetching proxy implementation from ResolvedDelegateProxy https://github.com/ethereum-optimism/optimism/blob/9580179013a04b15e6213ae8aa8d43c3f559ed9a/packages/contracts-bedrock/src/legacy/ResolvedDelegateProxy.sol """ - alias Explorer.Chain - alias Explorer.Chain.{Hash, SmartContract} - alias Explorer.SmartContract.Helper, as: SmartContractHelper - # 8da5cb5b = keccak256(owner()) - @owner_signature "8da5cb5b" - - # 204e1c7a = keccak256(getProxyImplementation(address)) - @get_proxy_implementation_signature "204e1c7a" - - @resolved_delegate_proxy_abi [ - %{ - "inputs" => [ - %{ - "internalType" => "contract AddressManager", - "name" => "_addressManager", - "type" => "address" - }, - %{ - "internalType" => "string", - "name" => "_implementationName", - "type" => "string" - } - ], - "stateMutability" => "nonpayable", - "type" => "constructor" - }, - %{"stateMutability" => "payable", "type" => "fallback"} - ] - - @owner_method_abi [ - %{ - "inputs" => [], - "name" => "owner", - "outputs" => [ - %{ - "internalType" => "address", - "name" => "", - "type" => "address" - } - ], - "stateMutability" => "view", - "type" => "function" - } - ] - - @get_proxy_implementation_method_abi [ - %{ - "inputs" => [ - %{ - "internalType" => "address", - "name" => "_proxy", - "type" => "address" - } - ], - "name" => "getProxyImplementation", - "outputs" => [ - %{ - "internalType" => "address", - "name" => "", - "type" => "address" - } - ], - "stateMutability" => "view", - "type" => "function" - } - ] - - @doc """ - Get implementation address hash string following ResolvedDelegateProxy proxy pattern. It returns the value as array of the strings. - """ - @spec get_implementation_address_hash_strings(Hash.Address.t(), [Chain.api?()]) :: [binary()] | :error - def get_implementation_address_hash_strings(proxy_address_hash, options \\ []) do - case get_implementation_address_hash_string(proxy_address_hash, options) do - nil -> [] + alias Explorer.Chain.{Data, Hash} + alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.SmartContract.Proxy.ResolverBehaviour + + @behaviour ResolverBehaviour + + @resolved_delegate_proxy <<0x608060408181523060009081526001602090815282822054908290529181207FBF40FAC1000000000000000000000000000000000000000000000000000000009093529173FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF9091169063BF40FAC19061006D9060846101E2565B602060405180830381865AFA15801561008A573D6000803E3D6000FD5B505050506040513D601F19601F820116820180604052508101906100AE91906102C5565B905073FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8116610157576040517F08C379A000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527F5265736F6C76656444656C656761746550726F78793A2074617267657420616460448201527F6472657373206D75737420626520696E697469616C697A656400000000000000606482015260840160405180910390FD5B6000808273FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF16600036604051610182929190610302565B600060405180830381855AF49150503D80600081146101BD576040519150601F19603F3D011682016040523D82523D6000602084013E6101C2565B606091505B5090925090508115156001036101DA57805160208201F35B805160208201FD5B600060208083526000845481600182811C91508083168061020457607F831692505B858310810361023A577F4E487B710000000000000000000000000000000000000000000000000000000085526022600452602485FD5B878601838152602001818015610257576001811461028B576102B6565B7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF008616825284151560051B820196506102B6565B60008B81526020902060005B868110156102B057815484820152908501908901610297565B83019750505B50949998505050505050505050565B6000602082840312156102D757600080FD5B815173FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF811681146102FB57600080FD5B9392505050565B818382376000910190815291905056FEA164736F6C634300080F000A::6392>> + + @impl true + def quick_resolve_implementations(proxy_address, _proxy_type) do + with {:match, @resolved_delegate_proxy} <- + {:match, proxy_address.contract_code && proxy_address.contract_code.bytes}, + reqs = get_fetch_requirements(proxy_address.hash), + {:ok, values} <- Proxy.fetch_values(reqs, proxy_address.hash), + {:ok, implementation_name} <- extract_short_string(values[reqs |> Enum.at(0)]), + {:ok, address_manager_address_hash} <- Proxy.extract_address_hash(values[reqs |> Enum.at(1)]), + {:ok, implementation_value} <- + Proxy.fetch_value( + {:call, "0x" <> Base.encode16(ABI.encode("getAddress(string)", [implementation_name]), case: :lower)}, + address_manager_address_hash + ), + {:ok, address_hash} <- Proxy.extract_address_hash(implementation_value) do + {:ok, [address_hash]} + else :error -> :error - implementation_address_hash_string -> [implementation_address_hash_string] + # proceed to other proxy types only if bytecode doesn't match + {:match, _} -> nil + # if bytecode matches but resolution fails, we should halt + _ -> {:ok, []} end end - @doc """ - Returns the ABI of the ResolvedDelegateProxy smart contract. - """ - @spec resolved_delegate_proxy_abi() :: [map()] - def resolved_delegate_proxy_abi do - @resolved_delegate_proxy_abi - end - - # Get implementation address hash string following ResolvedDelegateProxy proxy pattern - @spec get_implementation_address_hash_string(Hash.Address.t(), Keyword.t()) :: binary() | nil | :error - defp get_implementation_address_hash_string(proxy_address_hash, options) do - proxy_smart_contract = - proxy_address_hash - |> SmartContract.address_hash_to_smart_contract(options) - - if proxy_smart_contract && proxy_smart_contract.abi == @resolved_delegate_proxy_abi do - case SmartContract.format_constructor_arguments( - proxy_smart_contract.abi, - proxy_smart_contract.constructor_arguments - ) do - [[address_manager_hash_string, _address_manager_type_abi], _] -> - owner_address_hash_string = get_owner_from_address_manager(address_manager_hash_string) + @spec get_fetch_requirements(Hash.Address.t()) :: [ResolverBehaviour.fetch_requirement()] + def get_fetch_requirements(proxy_address_hash) do + # slot 0 + # mapping(address => string) private implementationName; + implementation_name_slot = ExKeccak.hash_256(<<0::96, proxy_address_hash.bytes::binary, 0::256>>) - get_implementation_from_owner(owner_address_hash_string, proxy_address_hash) + # slot 1 + # mapping(address => AddressManager) private addressManager; + address_manager_slot = ExKeccak.hash_256(<<0::96, proxy_address_hash.bytes::binary, 1::256>>) - _ -> - :error - end - else - nil - end - end - - defp get_owner_from_address_manager(address_manager_hash_string) do - case @owner_signature - |> SmartContractHelper.get_binary_string_from_contract_getter( - address_manager_hash_string, - @owner_method_abi - ) do - <> -> - owner_address_hash_string - - _other_result -> - nil - end + [ + storage: "0x" <> Base.encode16(implementation_name_slot, case: :lower), + storage: "0x" <> Base.encode16(address_manager_slot, case: :lower) + ] end - defp get_implementation_from_owner(nil, _proxy_address_hash), do: nil - - defp get_implementation_from_owner(owner_address_hash_string, proxy_address_hash) do - case @get_proxy_implementation_signature - |> SmartContractHelper.get_binary_string_from_contract_getter( - owner_address_hash_string, - @get_proxy_implementation_method_abi, - [to_string(proxy_address_hash)] - ) do - <> -> - implementation_address_hash_string - - _other_result -> - :error + # Decodes string value from smart-contract storage value, works only for short strings (<= 31 bytes) + @spec extract_short_string(String.t() | nil) :: {:ok, String.t()} | :error | nil + defp extract_short_string(value) do + with false <- is_nil(value), + {:ok, %Data{bytes: bytes}} <- Data.cast(value), + 32 <- byte_size(bytes), + double_length when double_length > 0 and double_length < 64 <- :binary.last(bytes), + 0 <- rem(double_length, 2) do + {:ok, binary_part(bytes, 0, div(double_length, 2))} + else + :error -> :error + _ -> nil end end end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolver_behaviour.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolver_behaviour.ex new file mode 100644 index 000000000000..89364994d837 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/resolver_behaviour.ex @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.Proxy.ResolverBehaviour do + @moduledoc """ + Behaviour for smart contract proxy resolvers + """ + + alias Explorer.Chain.{Address, Hash} + + @type fetch_requirement :: {:storage | :call, String.t()} + @type fetch_requirements :: %{atom() => fetch_requirement()} + @type fetched_values :: %{atom() => String.t() | nil} + + @optional_callbacks resolve_implementations: 3 + + @doc """ + Tries to immediately resolve implementations for the given proxy address and type, + e.g., when proxy matches the well-known bytecode pattern, so there is no ambiguity. + + For other proxy types, returns a list of fetch requirements that need to be fetched + before calling resolve_implementations, or nil if proxy type should not be resolved further. + + ## Parameters + - `proxy_address`: The address of the proxy contract. + - `proxy_type`: The type of the proxy contract. + + ## Returns + - `{:ok, [Hash.Address.t()]}` if proxy implementations are resolved immediately without ambiguity. + - `{:cont, fetch_requirements()}` if implementations cannot be resolved immediately, + caller should fetch given requirements and call resolve_implementations. + - `:error` if proxy resolution failed. + - `nil` if proxy pattern does not match and no further resolution for this type is necessary. + """ + @callback quick_resolve_implementations(proxy_address :: Address.t(), proxy_type :: atom()) :: + {:ok, [Hash.Address.t()]} | {:cont, fetch_requirements()} | :error | nil + + @doc """ + Resolves implementations for the given proxy address and type. + + ## Parameters + - `proxy_address`: The address of the proxy contract. + - `proxy_type`: The type of the proxy contract. + - `prefetched_values`: The values that were fetched in advance, according + to the fetch requirements returned by quick_resolve_implementations. + + ## Returns + - `{:ok, [Address.t()]}` if implementations are resolved. + - `:error` if resolution failed. + - `nil` if proxy pattern does not match. + """ + @callback resolve_implementations( + proxy_address :: Address.t(), + proxy_type :: atom(), + prefetched_values :: fetched_values() + ) :: + {:ok, [Hash.Address.t()]} | :error | nil +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/verification_status.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/verification_status.ex index ac483648e67e..214afd1e3003 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/verification_status.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/verification_status.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.VerificationStatus do @moduledoc """ Represents single proxy verification submission @@ -7,9 +8,9 @@ defmodule Explorer.Chain.SmartContract.Proxy.VerificationStatus do import Ecto.Changeset + alias Explorer.{Chain, Repo} alias Explorer.Chain.Hash alias Explorer.Chain.SmartContract.Proxy.Models.Implementation - alias Explorer.{Chain, Repo} @typep status :: integer() | atom() diff --git a/apps/explorer/lib/explorer/chain/smart_contract/verification_status.ex b/apps/explorer/lib/explorer/chain/smart_contract/verification_status.ex index 4ababe2d5824..769489093aaf 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/verification_status.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/verification_status.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.VerificationStatus do @moduledoc """ Represents single verification try @@ -7,26 +8,26 @@ defmodule Explorer.Chain.SmartContract.VerificationStatus do import Ecto.Changeset - alias Explorer.Chain.Hash alias Explorer.{Chain, Repo} + alias Explorer.Chain.Hash alias Explorer.SmartContract.Solidity.PublisherWorker, as: SolidityPublisherWorker alias Que.Persistence, as: QuePersistence @typedoc """ - * `address_hash` - address of the contract which was tried to verify + * `contract_address_hash` - address of the contract which was tried to verify * `status` - try status: :pending | :pass | :fail * `uid` - unique verification try identifier """ @primary_key false - typed_schema "contract_verification_status" do + typed_schema "smart_contract_verification_statuses" do field(:uid, :string, primary_key: true, null: false) field(:status, :integer, null: false) - field(:address_hash, Hash.Address, null: false) + field(:contract_address_hash, Hash.Address, null: false) timestamps() end - @required_fields ~w(uid status address_hash)a + @required_fields ~w(uid status contract_address_hash)a def changeset(%__MODULE__{} = struct, params \\ %{}) do casted_params = encode_status(params) @@ -74,7 +75,7 @@ defmodule Explorer.Chain.SmartContract.VerificationStatus do {:ok, hash} = if is_binary(address_hash), do: Chain.string_to_address_hash(address_hash), else: address_hash %__MODULE__{} - |> changeset(%{uid: uid, status: status, address_hash: hash}) + |> changeset(%{uid: uid, status: status, contract_address_hash: hash}) |> Repo.insert() end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/verified_contract_addresses_query.ex b/apps/explorer/lib/explorer/chain/smart_contract/verified_contract_addresses_query.ex new file mode 100644 index 000000000000..3507e122541f --- /dev/null +++ b/apps/explorer/lib/explorer/chain/smart_contract/verified_contract_addresses_query.ex @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.VerifiedContractAddressesQuery do + @moduledoc """ + Query functions for fetching verified smart-contract addresses. + """ + + import Ecto.Query + + alias Explorer.{Chain, SortingHelper} + alias Explorer.Chain.{Address, SmartContract} + alias Explorer.Helper, as: ExplorerHelper + + @doc """ + Returns verified contract addresses with optional filtering, search, sorting, + pagination, and association preloads. + """ + @spec list(keyword()) :: [Address.t()] + def list(options \\ []) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) + sorting = Keyword.get(options, :sorting) + + # If no sorting options are provided, we sort by `:id` descending only. If + # there are some sorting options supplied, we sort by `:hash` ascending as a + # secondary key. + {sorting_options, default_sorting_options} = + case sorting do + nil -> + {[], [{:desc, :id, :smart_contract}]} + + sorting_options -> + {sorting_options, [asc: :hash]} + end + + addresses_query = verified_addresses_query_by_join(options) + + addresses_query + |> ExplorerHelper.maybe_hide_scam_addresses_with_select(:hash, options) + |> SortingHelper.apply_sorting(sorting_options, default_sorting_options) + |> SortingHelper.page_with_sorting(paging_options, sorting_options, default_sorting_options) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all() + end + + # Kept for reference in case we need to revisit the lateral-join approach. + # + # @spec verified_addresses_query_by_lateral(keyword()) :: Ecto.Query.t() + # defp verified_addresses_query_by_lateral(options) do + # filter = Keyword.get(options, :filter) + # search_string = Keyword.get(options, :search) + # + # smart_contracts_by_address_hash_query = + # from( + # contract in SmartContract, + # where: contract.address_hash == parent_as(:address).hash + # ) + # + # smart_contracts_subquery = + # smart_contracts_by_address_hash_query + # |> filter_contracts(filter, :lateral) + # |> search_contracts(search_string, :lateral) + # |> limit(1) + # |> subquery() + # + # from( + # address in Address, + # as: :address, + # where: address.verified == true, + # inner_lateral_join: contract in ^smart_contracts_subquery, + # as: :smart_contract, + # on: true, + # select: address, + # preload: [smart_contract: contract] + # ) + # end + + @spec verified_addresses_query_by_join(keyword()) :: Ecto.Query.t() + defp verified_addresses_query_by_join(options) do + filter = Keyword.get(options, :filter) + search_string = Keyword.get(options, :search) + + addresses_query = + from( + address in Address, + join: contract in SmartContract, + as: :smart_contract, + on: address.hash == contract.address_hash, + preload: [:smart_contract] + ) + + addresses_query + |> filter_contracts(filter, :join) + |> search_contracts(search_string, :join) + end + + @spec search_contracts(Ecto.Query.t(), String.t() | nil, :join) :: Ecto.Query.t() + defp search_contracts(query, nil, _strategy), do: query + + # defp search_contracts(query, search_string, :lateral) do + # search_pattern = escape_search_pattern(search_string) + # + # from(contract in query, + # where: + # fragment("? ILIKE ? ESCAPE '\\'", contract.name, ^search_pattern) or + # fragment( + # "? ILIKE ? ESCAPE '\\'", + # fragment("'0x' || encode(?, 'hex')", contract.address_hash), + # ^search_pattern + # ) + # ) + # end + + defp search_contracts(query, search_string, :join) do + search_pattern = escape_search_pattern(search_string) + + from([_address, contract] in query, + where: + fragment("? ILIKE ? ESCAPE '\\'", contract.name, ^search_pattern) or + fragment( + "? ILIKE ? ESCAPE '\\'", + fragment("'0x' || encode(?, 'hex')", contract.address_hash), + ^search_pattern + ) + ) + end + + @spec escape_search_pattern(String.t()) :: String.t() + defp escape_search_pattern(search_string) do + escaped_search_string = + search_string + |> String.replace("\\", "\\\\") + |> String.replace("%", "\\%") + |> String.replace("_", "\\_") + + "%#{escaped_search_string}%" + end + + @spec filter_contracts(Ecto.Query.t(), atom() | nil, :join) :: Ecto.Query.t() + defp filter_contracts(query, nil, _strategy), do: query + + # defp filter_contracts(query, language, :lateral) do + # query |> where(language: ^language) + # end + + defp filter_contracts(query, language, :join) do + query |> where([_address, contract], contract.language == ^language) + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract_additional_source.ex b/apps/explorer/lib/explorer/chain/smart_contract_additional_source.ex index 108f1e611f61..4812f9bfe5e9 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract_additional_source.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract_additional_source.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContractAdditionalSource do @moduledoc """ The representation of a verified Smart Contract additional sources. @@ -19,6 +20,7 @@ defmodule Explorer.Chain.SmartContractAdditionalSource do field(:file_name, :string, null: false) field(:contract_source_code, :string, null: false) + # TODO: Why not use smart_contract_id? belongs_to( :smart_contract, SmartContract, @@ -38,19 +40,19 @@ defmodule Explorer.Chain.SmartContractAdditionalSource do :contract_source_code, :address_hash ]) - |> validate_required([:file_name, :contract_source_code, :address_hash]) - |> unique_constraint(:address_hash) + |> validate_required([:address_hash, :file_name, :contract_source_code]) + |> unique_constraint([:address_hash, :file_name]) end def invalid_contract_changeset(%__MODULE__{} = smart_contract_additional_source, attrs, error) do validated = smart_contract_additional_source |> cast(attrs, [ + :address_hash, :file_name, - :contract_source_code, - :address_hash + :contract_source_code ]) - |> validate_required([:file_name, :address_hash]) + |> validate_required([:address_hash, :file_name]) add_error(validated, :contract_source_code, error_message(error)) end diff --git a/apps/explorer/lib/explorer/chain/stability/validator.ex b/apps/explorer/lib/explorer/chain/stability/validator.ex index e5567c09532d..a46aaa41c390 100644 --- a/apps/explorer/lib/explorer/chain/stability/validator.ex +++ b/apps/explorer/lib/explorer/chain/stability/validator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Stability.Validator do @moduledoc """ Stability validators @@ -5,9 +6,9 @@ defmodule Explorer.Chain.Stability.Validator do use Explorer.Schema - alias Explorer.Chain.{Address, Import} - alias Explorer.Chain.Hash.Address, as: HashAddress alias Explorer.{Chain, Repo, SortingHelper} + alias Explorer.Chain.{Address, Block, Import} + alias Explorer.Chain.Hash.Address, as: HashAddress alias Explorer.SmartContract.Reader require Logger @@ -23,13 +24,13 @@ defmodule Explorer.Chain.Stability.Validator do typed_schema "validators_stability" do field(:address_hash, HashAddress, primary_key: true) field(:state, Ecto.Enum, values: @state_enum) - field(:blocks_validated, :integer, virtual: true) + field(:blocks_validated, :integer) has_one(:address, Address, foreign_key: :hash, references: :address_hash) timestamps() end - @required_attrs ~w(address_hash)a + @required_attrs ~w(address_hash blocks_validated)a @optional_attrs ~w(state)a def changeset(%__MODULE__{} = validator, attrs) do validator @@ -55,13 +56,6 @@ defmodule Explorer.Chain.Stability.Validator do __MODULE__ |> apply_filter_by_state(states) - |> select_merge([vs], %{ - blocks_validated: - fragment( - "SELECT count(*) FROM blocks WHERE miner_hash = ?", - vs.address_hash - ) - }) |> Chain.join_associations(necessity_by_association) |> SortingHelper.apply_sorting(sorting, @default_sorting) |> SortingHelper.page_with_sorting(paging_options, sorting, @default_sorting) @@ -164,7 +158,9 @@ defmodule Explorer.Chain.Stability.Validator do result = case format_missing_blocks_result(response) do {:error, message} -> - Logger.warning(fn -> ["Error on getValidatorMissingBlocks for #{validators_address_hashes}: #{message}"] end) + Logger.warning(fn -> + ["Error on getValidatorMissingBlocks for #{address_hash}: #{message}"] + end) nil @@ -204,7 +200,7 @@ defmodule Explorer.Chain.Stability.Validator do Delete validators by address hashes """ @spec delete_validators_by_address_hashes([binary() | HashAddress.t()]) :: {non_neg_integer(), nil | []} | :ignore - def delete_validators_by_address_hashes(list) when is_list(list) and length(list) > 0 do + def delete_validators_by_address_hashes(list) when is_list(list) and list !== [] do __MODULE__ |> where([vs], vs.address_hash in ^list) |> Repo.delete_all() @@ -218,7 +214,7 @@ defmodule Explorer.Chain.Stability.Validator do @spec insert_validators([map()]) :: {non_neg_integer(), nil | []} def insert_validators(validators) do Repo.insert_all(__MODULE__, validators, - on_conflict: {:replace_all_except, [:inserted_at]}, + on_conflict: {:replace_all_except, [:inserted_at, :blocks_validated]}, conflict_target: [:address_hash] ) end @@ -245,20 +241,6 @@ defmodule Explorer.Chain.Stability.Validator do @spec state_enum() :: Keyword.t() def state_enum, do: @state_enum - @doc """ - Returns dynamic query for validated blocks count. Needed for SortingHelper - """ - @spec dynamic_validated_blocks() :: Ecto.Query.dynamic_expr() - def dynamic_validated_blocks do - dynamic( - [vs], - fragment( - "SELECT count(*) FROM blocks WHERE miner_hash = ?", - vs.address_hash - ) - ) - end - @doc """ Returns total count of validators. """ @@ -286,4 +268,18 @@ defmodule Explorer.Chain.Stability.Validator do |> where([vs], vs.state == :active) |> Repo.aggregate(:count, :address_hash) end + + @doc """ + Fetch blocks validated + """ + @spec fetch_blocks_validated(list(binary())) :: list({binary(), integer()}) + def fetch_blocks_validated([_ | _] = miner_address_hashes) do + Block + |> where([b], b.miner_hash in ^miner_address_hashes) + |> group_by([b], b.miner_hash) + |> select([b], {b.miner_hash, count(b.hash)}) + |> Repo.all() + end + + def fetch_blocks_validated(_), do: [] end diff --git a/apps/explorer/lib/explorer/chain/supply.ex b/apps/explorer/lib/explorer/chain/supply.ex index 0287a0dde80b..ebd091800430 100644 --- a/apps/explorer/lib/explorer/chain/supply.ex +++ b/apps/explorer/lib/explorer/chain/supply.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Supply do @moduledoc """ Behaviour for API needed to calculate data related to a chain's supply. diff --git a/apps/explorer/lib/explorer/chain/supply/exchange_rate.ex b/apps/explorer/lib/explorer/chain/supply/exchange_rate.ex index 1ef83b1a90c2..d05231cf6098 100644 --- a/apps/explorer/lib/explorer/chain/supply/exchange_rate.ex +++ b/apps/explorer/lib/explorer/chain/supply/exchange_rate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Supply.ExchangeRate do @moduledoc """ Defines the supply API for calculating supply for coins from exchange_rate.. diff --git a/apps/explorer/lib/explorer/chain/supply/proof_of_authority.ex b/apps/explorer/lib/explorer/chain/supply/proof_of_authority.ex index bb8e625740ff..d942b6201db6 100644 --- a/apps/explorer/lib/explorer/chain/supply/proof_of_authority.ex +++ b/apps/explorer/lib/explorer/chain/supply/proof_of_authority.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Supply.ProofOfAuthority do @moduledoc """ Defines the supply API for calculating supply for POA. diff --git a/apps/explorer/lib/explorer/chain/supply/rsk.ex b/apps/explorer/lib/explorer/chain/supply/rsk.ex index 726d7144fc27..52967b809c34 100644 --- a/apps/explorer/lib/explorer/chain/supply/rsk.ex +++ b/apps/explorer/lib/explorer/chain/supply/rsk.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Supply.RSK do @moduledoc """ Defines the supply API for calculating supply for coins from RSK. diff --git a/apps/explorer/lib/explorer/chain/token.ex b/apps/explorer/lib/explorer/chain/token.ex index 58f8a01dbe3b..bd283659ae64 100644 --- a/apps/explorer/lib/explorer/chain/token.ex +++ b/apps/explorer/lib/explorer/chain/token.ex @@ -1,8 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Token.Schema do @moduledoc false use Utils.CompileTimeEnvHelper, bridged_tokens_enabled: [:explorer, [Explorer.Chain.BridgedToken, :enabled]] - alias Explorer.Chain.{Address, Hash} + alias Explorer.Chain.{Address, Address.Reputation, Hash} + alias Explorer.Chain.Token.FiatValue if @bridged_tokens_enabled do @bridged_field [ @@ -28,11 +30,13 @@ defmodule Explorer.Chain.Token.Schema do field(:skip_metadata, :boolean) field(:total_supply_updated_at_block, :integer) field(:metadata_updated_at, :utc_datetime_usec) - field(:fiat_value, :decimal) - field(:circulating_market_cap, :decimal) + field(:fiat_value, FiatValue) + field(:circulating_market_cap, FiatValue) field(:icon_url, :string) field(:is_verified_via_admin_panel, :boolean) - field(:volume_24h, :decimal) + field(:volume_24h, FiatValue) + field(:circulating_supply, :decimal) + field(:transfer_count, :integer) belongs_to( :contract_address, @@ -44,6 +48,8 @@ defmodule Explorer.Chain.Token.Schema do null: false ) + has_one(:reputation, Reputation, foreign_key: :address_hash, references: :contract_address_hash) + unquote_splicing(@bridged_field) timestamps() @@ -64,6 +70,8 @@ defmodule Explorer.Chain.Token do * ERC-721 * ERC-1155 * ERC-404 + * ZRC-2 (for Zilliqa chain type) + * ERC-7984 ## Token Specifications @@ -72,7 +80,10 @@ defmodule Explorer.Chain.Token do * [ERC-777](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-777.md) * [ERC-1155](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1155.md) * [ERC-404](https://github.com/Pandora-Labs-Org/erc404) + * [ZRC-2](https://github.com/Zilliqa/ZRC/blob/main/zrcs/zrc-2.md) + * [ERC-7984](https://github.com/ethereum/ERCs/blob/39197cde3e32d8fc7fde74c7d0ce5e67ad4de409/ERCS/erc-7984.md) """ + require Logger use Explorer.Schema @@ -83,7 +94,8 @@ defmodule Explorer.Chain.Token do alias Ecto.{Changeset, Multi} alias Explorer.{Chain, SortingHelper} alias Explorer.Chain.{Address, BridgedToken, Hash, Search, Token} - alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Chain.Cache.{BackgroundMigrations, BlockNumber} + alias Explorer.Chain.Cache.Counters.{TokenHoldersCount, TokenTransfersCount} alias Explorer.Chain.Import.Runner alias Explorer.Helper, as: ExplorerHelper alias Explorer.Repo @@ -129,6 +141,7 @@ defmodule Explorer.Chain.Token do * `contract_address_hash` - Address hash foreign key * `holder_count` - the number of `t:Explorer.Chain.Address.t/0` (except the burn address) that have a `t:Explorer.Chain.CurrentTokenBalance.t/0` `value > 0`. Can be `nil` when data not migrated. + * `transfer_count` - the number of token transfers for `t:Explorer.Chain.Address.t/0` token * `fiat_value` - The price of a token in a configured currency (USD by default). * `circulating_market_cap` - The circulating market cap of a token in a configured currency (USD by default). * `icon_url` - URL of the token's icon. @@ -137,7 +150,18 @@ defmodule Explorer.Chain.Token do Explorer.Chain.Token.Schema.generate() @required_attrs ~w(contract_address_hash type)a - @optional_attrs ~w(cataloged decimals name symbol total_supply skip_metadata total_supply_updated_at_block metadata_updated_at updated_at fiat_value circulating_market_cap icon_url is_verified_via_admin_panel volume_24h)a + @optional_attrs ~w(cataloged decimals name symbol total_supply skip_metadata total_supply_updated_at_block metadata_updated_at updated_at fiat_value circulating_market_cap circulating_supply icon_url is_verified_via_admin_panel volume_24h)a + + @doc """ + Returns the **ordered** list of allowed NFT type labels. + """ + @spec allowed_nft_type_labels() :: [String.t()] + def allowed_nft_type_labels, + do: [ + "ERC-721", + "ERC-1155", + "ERC-404" + ] @doc false def changeset(%Token{} = token, params \\ %{}) do @@ -169,7 +193,7 @@ defmodule Explorer.Chain.Token do changeset property -> - put_change(changeset, key, Helper.sanitize_input(property)) + put_change(changeset, key, Helper.escape_minimal(property)) end end @@ -195,7 +219,7 @@ defmodule Explorer.Chain.Token do """ @spec stream_cataloged_tokens( initial :: accumulator, - reducer :: (entry :: Token.t(), accumulator -> accumulator), + reducer :: (entry :: __MODULE__.t(), accumulator -> accumulator), some_time_ago_updated :: integer(), limited? :: boolean() ) :: {:ok, accumulator} @@ -203,7 +227,7 @@ defmodule Explorer.Chain.Token do def stream_cataloged_tokens(initial, reducer, some_time_ago_updated \\ 2880, limited? \\ false) when is_function(reducer, 2) do some_time_ago_updated - |> Token.cataloged_tokens() + |> cataloged_tokens() |> Chain.add_fetcher_limit(limited?) |> order_by(asc_nulls_first: :metadata_updated_at) |> Repo.stream_reduce(initial, reducer) @@ -215,9 +239,10 @@ defmodule Explorer.Chain.Token do As part of updating token, an additional record is inserted for naming the address for reference if a name is provided for a token. """ - @spec update(Token.t(), map(), boolean(), :base | :metadata_update) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()} + @spec update(__MODULE__.t(), map(), boolean(), :base | :metadata_update) :: + {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()} def update( - %Token{contract_address_hash: address_hash} = token, + %__MODULE__{contract_address_hash: address_hash} = token, params \\ %{}, info_from_admin_panel? \\ false, operation_type \\ :base @@ -233,7 +258,7 @@ defmodule Explorer.Chain.Token do token_changeset = token - |> Token.changeset( + |> __MODULE__.changeset( filtered_params |> Map.put(:updated_at, DateTime.utc_now()) ) @@ -336,7 +361,7 @@ defmodule Explorer.Chain.Token do | {:token_type, [String.t()]} | {:necessity_by_association, map()} | Chain.show_scam_tokens?() - ]) :: [Token.t()] + ]) :: [__MODULE__.t()] def list_top(filter, options \\ []) do paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) token_type = Keyword.get(options, :token_type, nil) @@ -350,25 +375,70 @@ defmodule Explorer.Chain.Token do sorted_paginated_query = Token |> Chain.join_associations(necessity_by_association) - |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_with_select(:contract_address_hash, options) |> apply_filter(token_type) |> SortingHelper.apply_sorting(sorting, @default_sorting) |> SortingHelper.page_with_sorting(paging_options, sorting, @default_sorting) filtered_query = - case filter && filter !== "" && Search.prepare_search_term(filter) do - {:some, filter_term} -> - sorted_paginated_query - |> where(fragment("to_tsvector('english', symbol || ' ' || name) @@ to_tsquery(?)", ^filter_term)) + case filter && Chain.string_to_address_hash(filter) do + {:ok, address_hash} -> + from(t in sorted_paginated_query, where: t.contract_address_hash == ^address_hash) _ -> - sorted_paginated_query + case filter && filter !== "" && Search.prepare_search_term(filter) do + {:some, filter_term} -> apply_fts_filter(sorted_paginated_query, filter_term) + _ -> sorted_paginated_query + end end filtered_query |> Chain.select_repo(options).all() end + @doc """ + Applies full-text search filtering to a token query. + + This function handles tokens with and without symbols differently: + - For tokens with a symbol, it searches across both symbol and name. + - For tokens without a symbol (e.g., ERC-1155), it searches only the name field. + + ## Parameters + - `query`: The Ecto query to filter. + - `filter_term`: The prepared search term (from Search.prepare_search_term/1). + + ## Returns + - An Ecto query with FTS filtering applied. + """ + @spec apply_fts_filter(Ecto.Query.t(), String.t()) :: Ecto.Query.t() + def apply_fts_filter(query, filter_term) do + if BackgroundMigrations.get_heavy_indexes_create_tokens_name_partial_fts_index_finished() do + query + |> where( + [token], + (not is_nil(token.symbol) and + fragment( + "to_tsvector('english', ? || ' ' || ?) @@ to_tsquery(?)", + token.symbol, + token.name, + ^filter_term + )) or + (is_nil(token.symbol) and + fragment( + "to_tsvector('english', ?) @@ to_tsquery(?)", + token.name, + ^filter_term + )) + ) + else + query + |> where( + [token], + fragment("to_tsvector('english', ? || ' ' || ?) @@ to_tsquery(?)", token.symbol, token.name, ^filter_term) + ) + end + end + defp apply_filter(query, empty_type) when empty_type in [nil, []], do: query defp apply_filter(query, token_types) when is_list(token_types) do @@ -376,15 +446,26 @@ defmodule Explorer.Chain.Token do end def get_by_contract_address_hash(hash, options) do - Chain.select_repo(options).get_by(__MODULE__, contract_address_hash: hash) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + __MODULE__ + |> where([t], t.contract_address_hash == ^hash) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).one() end @doc """ Gets tokens with given contract address hashes. """ - @spec get_by_contract_address_hashes([Hash.Address.t()], [Chain.api?()]) :: [Token.t()] + @spec get_by_contract_address_hashes([Hash.Address.t()], [Chain.api?() | Chain.necessity_by_association_option()]) :: + [__MODULE__.t()] def get_by_contract_address_hashes(hashes, options) do - Chain.select_repo(options).all(from(t in __MODULE__, where: t.contract_address_hash in ^hashes)) + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + + __MODULE__ + |> where([t], t.contract_address_hash in ^hashes) + |> Chain.join_associations(necessity_by_association) + |> Chain.select_repo(options).all() end @doc """ @@ -407,13 +488,38 @@ defmodule Explorer.Chain.Token do It used by Explorer.Chain.Cache.Counters.TokenHoldersCount module. """ @spec update_token_holder_count(Hash.Address.t(), integer()) :: {non_neg_integer(), nil} - def update_token_holder_count(contract_address_hash, holder_count) when not is_nil(holder_count) do + def update_token_holder_count(contract_address_hash, holders_count) when not is_nil(holders_count) do now = DateTime.utc_now() Repo.update_all( from(t in __MODULE__, where: t.contract_address_hash == ^contract_address_hash, - update: [set: [holder_count: ^holder_count, updated_at: ^now]] + update: [set: [holder_count: ^holders_count, updated_at: ^now]] + ), + [], + timeout: @timeout + ) + end + + @doc """ + Updates `transfer_count` field for a given `contract_address_hash`. + Used by the `Explorer.Chain.Cache.Counters.TokenTransfersCount` module. + + ## Parameters + - `contract_address_hash`: The address of the token contract. + - `transfer_count`: The updated counter value. + + ## Returns + - `{updated_count, nil}` tuple where `updated_count` is the number of updated rows in the db table. + """ + @spec update_token_transfer_count(Hash.Address.t(), non_neg_integer()) :: {non_neg_integer(), nil} + def update_token_transfer_count(contract_address_hash, transfer_count) when not is_nil(transfer_count) do + now = DateTime.utc_now() + + Repo.update_all( + from(t in __MODULE__, + where: t.contract_address_hash == ^contract_address_hash, + update: [set: [transfer_count: ^transfer_count, updated_at: ^now]] ), [], timeout: @timeout @@ -464,4 +570,171 @@ defmodule Explorer.Chain.Token do Chain.select_repo(options).exists?(query) end + + @doc """ + Checks if the given token is ZRC-2 token. + + ## Parameters + - `token`: The token to check the type of. + + ## Returns + - `true` if this is ZRC-2 token, `false` otherwise. + """ + @spec zrc_2_token?(__MODULE__.t()) :: bool + def zrc_2_token?(token) do + case Map.get(token, :type) do + "ZRC-2" -> true + _ -> false + end + end + + @doc """ + Fetches token counters (transfers count and holders count) for a given token address. + + This function spawns two async tasks to fetch the token transfers count and + token holders count concurrently. If a task times out or exits, it falls back + to fetching the cached value. + + ## Parameters + - `address_hash`: The contract address hash of the token + - `timeout`: The timeout in milliseconds for the async tasks + + ## Returns + - A tuple `{transfers_count, holders_count}` where each value is an integer or nil + """ + @spec fetch_token_counters(Hash.Address.t(), timeout()) :: {integer() | nil, integer() | nil} + def fetch_token_counters(address_hash, timeout) do + total_token_transfers_task = + Task.async(fn -> + TokenTransfersCount.fetch(address_hash) + end) + + total_token_holders_task = + Task.async(fn -> + TokenHoldersCount.fetch(address_hash) + end) + + [total_token_transfers_task, total_token_holders_task] + |> Task.yield_many(timeout) + |> Enum.map(fn {task, res} -> + case res do + {:ok, result} -> + result + + {:exit, reason} -> + Logger.warning("Query fetching token counters terminated: #{inspect(reason)}") + + fallback_cached_value_based_on_async_task_pid( + task.pid, + total_token_transfers_task.pid, + total_token_holders_task.pid, + address_hash + ) + + nil -> + Logger.warning("Query fetching token counters timed out.") + + fallback_cached_value_based_on_async_task_pid( + task.pid, + total_token_transfers_task.pid, + total_token_holders_task.pid, + address_hash + ) + end + end) + |> List.to_tuple() + end + + defp fallback_cached_value_based_on_async_task_pid( + task_pid, + total_token_transfers_task_pid, + total_token_holders_task_pid, + address_hash + ) do + case task_pid do + ^total_token_transfers_task_pid -> + TokenTransfersCount.fetch_count_from_cache(address_hash) + + ^total_token_holders_task_pid -> + TokenHoldersCount.fetch_count_from_cache(address_hash) + end + end + + @doc """ + Checks if a `t:Explorer.Chain.Token.t/0` with the given `hash` exists. + + Returns `:ok` if found + + iex> address = insert(:address) + iex> insert(:token, contract_address: address) + iex> Explorer.Chain.Token.check_token_exists(address.hash) + :ok + + Returns `:not_found` if not found + + iex> {:ok, hash} = Explorer.Chain.string_to_address_hash("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed") + iex> Explorer.Chain.Token.check_token_exists(hash) + :not_found + """ + @spec check_token_exists(Hash.Address.t()) :: :ok | :not_found + def check_token_exists(hash) do + hash + |> token_exists?() + |> Chain.boolean_to_check_result() + end + + # Checks if a `t:Explorer.Chain.Token.t/0` with the given `hash` exists. + + # Returns `true` if found + + # iex> address = insert(:address) + # iex> insert(:token, contract_address: address) + # iex> Explorer.Chain.token_exists?(address.hash) + # true + + # Returns `false` if not found + + # iex> {:ok, hash} = Explorer.Chain.string_to_address_hash("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed") + # iex> Explorer.Chain.token_exists?(hash) + # false + @spec token_exists?(Hash.Address.t()) :: boolean() + defp token_exists?(hash) do + query = + from( + token in Token, + where: token.contract_address_hash == ^hash + ) + + Repo.exists?(query) + end + + @doc """ + Gets the token type for a given contract address hash. + """ + @spec get_token_type(Hash.Address.t()) :: String.t() | nil + def get_token_type(hash) do + query = + from( + token in __MODULE__, + where: token.contract_address_hash == ^hash, + select: token.type + ) + + Repo.one(query) + end + + @doc """ + Gets the token types for a list of contract address hashes. + """ + @spec get_token_types([Hash.Address.t()]) :: [{Hash.Address.t(), String.t()}] + def get_token_types(hashes) do + query = + from( + token in __MODULE__, + where: token.contract_address_hash in ^hashes, + select: {token.contract_address_hash, token.type} + ) + + Repo.all(query) + end end diff --git a/apps/explorer/lib/explorer/chain/token/fiat_value.ex b/apps/explorer/lib/explorer/chain/token/fiat_value.ex new file mode 100644 index 000000000000..be894d97dd59 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/token/fiat_value.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Token.FiatValue do + @moduledoc """ + Represents money values, used to hide the value if there is a chance that the value is not relevant. + """ + + use Ecto.Type + + alias Explorer.Market + + @type t :: Decimal.t() | nil + + @impl Ecto.Type + def type, do: :decimal + + @impl Ecto.Type + def cast(value) when is_binary(value) do + case Decimal.parse(value) do + {decimal, ""} -> + {:ok, decimal} + + _ -> + :error + end + end + + @impl Ecto.Type + def cast(value) when is_integer(value) do + {:ok, Decimal.new(value)} + end + + @impl Ecto.Type + def cast(value) when is_float(value) do + {:ok, Decimal.from_float(value)} + end + + @impl Ecto.Type + def cast(%Decimal{} = decimal) do + {:ok, decimal} + end + + @impl Ecto.Type + def cast(_), do: :error + + @impl Ecto.Type + def dump(%Decimal{} = decimal) do + {:ok, decimal} + end + + @impl Ecto.Type + def dump(_), do: :error + + @impl Ecto.Type + def load(%Decimal{} = decimal) do + if Market.token_fetcher_enabled?() do + {:ok, decimal} + else + {:ok, nil} + end + end +end diff --git a/apps/explorer/lib/explorer/chain/token/instance.ex b/apps/explorer/lib/explorer/chain/token/instance.ex index 3a947106371e..53ae31fad005 100644 --- a/apps/explorer/lib/explorer/chain/token/instance.ex +++ b/apps/explorer/lib/explorer/chain/token/instance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Token.Instance do @moduledoc """ Represents an ERC-721/ERC-1155/ERC-404 token instance and stores metadata defined in https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md. @@ -7,7 +8,7 @@ defmodule Explorer.Chain.Token.Instance do alias Explorer.{Chain, Helper, QueryHelper, Repo} alias Explorer.Chain.{Address, Hash, Token, TokenTransfer, Transaction} - alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Address.{CurrentTokenBalance, Reputation} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Chain.Token.Instance.Thumbnails alias Explorer.Helper, as: ExplorerHelper @@ -134,47 +135,107 @@ defmodule Explorer.Chain.Token.Instance do do: from(i in __MODULE__, where: i.token_contract_address_hash == ^token_contract_address and i.token_id == ^token_id) - @spec nft_list(binary() | Hash.Address.t(), keyword()) :: [__MODULE__.t()] - def nft_list(address_hash, options \\ []) + @spec page_nft_list( + binary() | Hash.Address.t(), + keyword(), + (PagingOptions.t() -> String.t() | nil), + %{ + String.t() => (binary() + | Hash.Address.t(), + keyword() -> + [any()]) + } + ) :: [any()] + defp page_nft_list(address_hash, options, get_type, type_to_fetch_func) when is_list(options) do + paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) + page_size = paging_options.page_size + remaining_types = remaining_token_types(options, get_type) + + {acc, _filled} = + Enum.reduce_while(remaining_types, {[], 0}, fn type, {list, count} -> + options = + if Enum.empty?(list) do + options + else + Keyword.put(options, :paging_options, %PagingOptions{ + page_size: page_size - count + }) + end + + fetch = Map.get(type_to_fetch_func, type, fn _, _ -> [] end) + fetched = fetch.(address_hash, options) + new_list = list ++ fetched + new_count = count + length(fetched) + + if new_count >= page_size do + {:halt, {Enum.take(new_list, page_size), page_size}} + else + {:cont, {new_list, new_count}} + end + end) - def nft_list(address_hash, options) when is_list(options) do - nft_list(address_hash, Keyword.get(options, :token_type, []), options) + acc end - defp nft_list(address_hash, ["ERC-721"], options) do - erc_721_token_instances_by_owner_address_hash(address_hash, options) - end + @spec remaining_token_types(keyword(), (PagingOptions.t() -> String.t() | nil)) :: [String.t()] + defp remaining_token_types(options, get_type) do + token_types = + options + |> Keyword.get(:token_type, []) + |> then(fn types -> + if Enum.empty?(types) do + Token.allowed_nft_type_labels() + else + Token.allowed_nft_type_labels() |> Enum.filter(&(&1 in types)) + end + end) - defp nft_list(address_hash, ["ERC-1155"], options) do - erc_1155_token_instances_by_address_hash(address_hash, options) - end + options + |> Keyword.get(:paging_options, Chain.default_paging_options()) + |> get_type.() + |> case do + type when is_binary(type) -> + Enum.drop_while(token_types, &(&1 != type)) - defp nft_list(address_hash, ["ERC-404"], options) do - erc_404_token_instances_by_address_hash(address_hash, options) + _ -> + token_types + end end - defp nft_list(address_hash, _, options) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - - case paging_options do - %PagingOptions{key: {_contract_address_hash, _token_id, "ERC-1155"}} -> - erc_1155_token_instances_by_address_hash(address_hash, options) + @doc """ + Paginated NFT instances owned by an address. - %PagingOptions{key: {_contract_address_hash, _token_id, "ERC-404"}} -> - erc_404_token_instances_by_address_hash(address_hash, options) + ## Notes + * Filter: `:token_type` list (empty or omitted = all). + * Pagination key: `{token_contract_address_hash, token_id, token_type}`. + * Resumes at the key's type; earlier types skipped. + * Fills one page sequentially across types; spillover allowed. - _ -> - erc_721 = erc_721_token_instances_by_owner_address_hash(address_hash, options) - - if length(erc_721) == paging_options.page_size do - erc_721 - else - erc_1155 = erc_1155_token_instances_by_address_hash(address_hash, options) - erc_404 = erc_404_token_instances_by_address_hash(address_hash, options) + ## Params + * `address_hash` - owner address (binary or `Hash.Address`). + * `options` (keyword): + - `:paging_options` (%PagingOptions{}; default `Chain.default_paging_options/0`). + - `:token_type` (list of labels) filter. + - `:necessity_by_association` pass-through for joins. - (erc_721 ++ erc_1155 ++ erc_404) |> Enum.take(paging_options.page_size) - end - end + ## Returns + * list (<= page_size) of `%Explorer.Chain.Token.Instance{}`; empty list if none. + """ + @spec nft_list(binary() | Hash.Address.t(), keyword()) :: [__MODULE__.t()] + def nft_list(address_hash, options) when is_list(options) do + page_nft_list( + address_hash, + options, + fn + %PagingOptions{key: {_, _, type}} -> type + _ -> nil + end, + %{ + "ERC-721" => &erc_721_token_instances_by_owner_address_hash/2, + "ERC-1155" => &erc_1155_token_instances_by_address_hash/2, + "ERC-404" => &erc_404_token_instances_by_address_hash/2 + } + ) end @doc """ @@ -290,63 +351,40 @@ defmodule Explorer.Chain.Token.Instance do @doc """ Function to be used in BlockScoutWeb.Chain.next_page_params/4 """ - @spec nft_list_next_page_params(__MODULE__.t()) :: %{binary() => any} + @spec nft_list_next_page_params(__MODULE__.t()) :: %{atom() => any} def nft_list_next_page_params(%__MODULE__{ current_token_balance: %CurrentTokenBalance{}, token_contract_address_hash: token_contract_address_hash, token_id: token_id, token: token }) do - %{"token_contract_address_hash" => token_contract_address_hash, "token_id" => token_id, "token_type" => token.type} + %{token_contract_address_hash: token_contract_address_hash, token_id: token_id, token_type: token.type} end def nft_list_next_page_params(%__MODULE__{ token_contract_address_hash: token_contract_address_hash, token_id: token_id }) do - %{"token_contract_address_hash" => token_contract_address_hash, "token_id" => token_id, "token_type" => "ERC-721"} + %{token_contract_address_hash: token_contract_address_hash, token_id: token_id, token_type: "ERC-721"} end @preloaded_nfts_limit 9 - @spec nft_collections(binary() | Hash.Address.t(), keyword) :: list - def nft_collections(address_hash, options \\ []) - + @spec nft_collections(binary() | Hash.Address.t(), keyword()) :: [CurrentTokenBalance.t()] def nft_collections(address_hash, options) when is_list(options) do - nft_collections(address_hash, Keyword.get(options, :token_type, []), options) - end - - defp nft_collections(address_hash, ["ERC-721"], options) do - erc_721_collections_by_address_hash(address_hash, options) - end - - defp nft_collections(address_hash, ["ERC-1155"], options) do - erc_1155_collections_by_address_hash(address_hash, options) - end - - defp nft_collections(address_hash, ["ERC-404"], options) do - erc_404_collections_by_address_hash(address_hash, options) - end - - defp nft_collections(address_hash, _, options) do - paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) - - case paging_options do - %PagingOptions{key: {_contract_address_hash, "ERC-1155"}} -> - erc_1155_collections_by_address_hash(address_hash, options) - - _ -> - erc_721 = erc_721_collections_by_address_hash(address_hash, options) - - if length(erc_721) == paging_options.page_size do - erc_721 - else - erc_1155 = erc_1155_collections_by_address_hash(address_hash, options) - erc_404 = erc_404_collections_by_address_hash(address_hash, options) - - (erc_721 ++ erc_1155 ++ erc_404) |> Enum.take(paging_options.page_size) - end - end + page_nft_list( + address_hash, + options, + fn + %PagingOptions{key: {_, type}} -> type + _ -> nil + end, + %{ + "ERC-721" => &erc_721_collections_by_address_hash/2, + "ERC-1155" => &erc_1155_collections_by_address_hash/2, + "ERC-404" => &erc_404_collections_by_address_hash/2 + } + ) end @spec erc_721_collections_by_address_hash(binary() | Hash.Address.t(), keyword) :: [CurrentTokenBalance.t()] @@ -383,7 +421,6 @@ defmodule Explorer.Chain.Token.Instance do CurrentTokenBalance |> where([ctb], ctb.address_hash == ^address_hash and ctb.value > 0 and ctb.token_type == "ERC-1155") - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) |> group_by([ctb], ctb.token_contract_address_hash) |> order_by([ctb], asc: ctb.token_contract_address_hash) |> select([ctb], %{ @@ -391,11 +428,19 @@ defmodule Explorer.Chain.Token.Instance do distinct_token_instances_count: fragment("COUNT(*)"), token_ids: fragment("array_agg(?)", ctb.token_id) }) + |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) |> page_erc_1155_nft_collections(paging_options) |> limit(^paging_options.page_size) |> Chain.select_repo(options).all() |> Enum.map(&erc_1155_preload_nft(&1, address_hash, options)) - |> Helper.custom_preload(options, Token, :token_contract_address_hash, :contract_address_hash, :token) + |> Helper.custom_preload( + options, + Token, + :token_contract_address_hash, + :contract_address_hash, + :token, + Reputation.reputation_association() + ) end defp page_erc_1155_nft_collections(query, %PagingOptions{key: {contract_address_hash, "ERC-1155"}}) do @@ -416,7 +461,6 @@ defmodule Explorer.Chain.Token.Instance do CurrentTokenBalance |> where([ctb], ctb.address_hash == ^address_hash and not is_nil(ctb.token_id) and ctb.token_type == "ERC-404") - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) |> group_by([ctb], ctb.token_contract_address_hash) |> order_by([ctb], asc: ctb.token_contract_address_hash) |> select([ctb], %{ @@ -424,11 +468,19 @@ defmodule Explorer.Chain.Token.Instance do distinct_token_instances_count: fragment("COUNT(*)"), token_ids: fragment("array_agg(?)", ctb.token_id) }) + |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) |> page_erc_404_nft_collections(paging_options) |> limit(^paging_options.page_size) |> Chain.select_repo(options).all() |> Enum.map(&erc_1155_preload_nft(&1, address_hash, options)) - |> Helper.custom_preload(options, Token, :token_contract_address_hash, :contract_address_hash, :token) + |> Helper.custom_preload( + options, + Token, + :token_contract_address_hash, + :contract_address_hash, + :token, + Reputation.reputation_association() + ) end defp page_erc_404_nft_collections(query, %PagingOptions{key: {contract_address_hash, "ERC-404"}}) do @@ -488,20 +540,20 @@ defmodule Explorer.Chain.Token.Instance do Function to be used in BlockScoutWeb.Chain.next_page_params/4 """ @spec nft_collections_next_page_params(%{:token_contract_address_hash => any, optional(any) => any}) :: %{ - binary() => any + atom() => any } def nft_collections_next_page_params(%{ token_contract_address_hash: token_contract_address_hash, token: %Token{type: token_type} }) do - %{"token_contract_address_hash" => token_contract_address_hash, "token_type" => token_type} + %{token_contract_address_hash: token_contract_address_hash, token_type: token_type} end def nft_collections_next_page_params(%{ token_contract_address_hash: token_contract_address_hash, token_type: token_type }) do - %{"token_contract_address_hash" => token_contract_address_hash, "token_type" => token_type} + %{token_contract_address_hash: token_contract_address_hash, token_type: token_type} end @spec token_instances_by_holder_address_hash(Token.t(), binary() | Hash.Address.t(), keyword) :: [__MODULE__.t()] @@ -629,7 +681,7 @@ defmodule Explorer.Chain.Token.Instance do is_unique is true for ERC-721 always and for ERC-1155 only if token_id is unique """ @spec put_is_unique(__MODULE__.t(), Token.t(), Keyword.t()) :: __MODULE__.t() - def put_is_unique(instance, token, options) do + def put_is_unique(%__MODULE__{} = instance, token, options) do %__MODULE__{instance | is_unique: unique?(instance, token, options)} end @@ -658,7 +710,8 @@ defmodule Explorer.Chain.Token.Instance do Sets metadata for the given Explorer.Chain.Token.Instance """ @spec set_metadata(t(), map()) :: {non_neg_integer(), nil} - def set_metadata(token_instance, metadata) when is_map(metadata) do + def set_metadata(token_instance, %{metadata: metadata, skip_metadata_url: skip_metadata_url} = result) + when is_map(metadata) do now = DateTime.utc_now() Repo.update_all( @@ -666,7 +719,18 @@ defmodule Explorer.Chain.Token.Instance do where: instance.token_contract_address_hash == ^token_instance.token_contract_address_hash, where: instance.token_id == ^token_instance.token_id ), - [set: [metadata: metadata, error: nil, updated_at: now, thumbnails: nil, media_type: nil, cdn_upload_error: nil]], + [ + set: [ + metadata: metadata, + error: nil, + updated_at: now, + thumbnails: nil, + media_type: nil, + cdn_upload_error: nil, + skip_metadata_url: skip_metadata_url, + metadata_url: result[:metadata_url] + ] + ], timeout: @timeout ) end @@ -768,7 +832,7 @@ defmodule Explorer.Chain.Token.Instance do def batch_upsert_cdn_results(instances) do {_, result} = - Repo.insert_all(__MODULE__, instances, + Repo.safe_insert_all(__MODULE__, instances, on_conflict: {:replace, [:thumbnails, :media_type, :updated_at, :cdn_upload_error]}, conflict_target: [:token_id, :token_contract_address_hash], returning: true diff --git a/apps/explorer/lib/explorer/chain/token/instance/media_urls.ex b/apps/explorer/lib/explorer/chain/token/instance/media_urls.ex index 26f1bc2e972f..c59c655336cc 100644 --- a/apps/explorer/lib/explorer/chain/token/instance/media_urls.ex +++ b/apps/explorer/lib/explorer/chain/token/instance/media_urls.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Token.Instance.Thumbnails do @moduledoc """ Module defines thumbnails type for token instances @@ -20,24 +21,27 @@ defmodule Explorer.Chain.Token.Instance.Thumbnails do def cast(_), do: :error def load([file_path, sizes, original_uploaded?]) do - uri = - Application.get_env(:ex_aws, :s3)[:public_r2_url] |> URI.parse() |> URI.append_path(file_path) |> URI.to_string() - - thumbnails = - sizes - |> Enum.map(fn size -> - key = "#{size}x#{size}" - {key, String.replace(uri, "{}", key)} - end) - |> Enum.into(%{}) - - {:ok, - if original_uploaded? do - key = "original" - Map.put(thumbnails, key, String.replace(uri, "{}", key)) - else - thumbnails - end} + if public_r2_url = Application.get_env(:ex_aws, :s3)[:public_r2_url] do + uri = public_r2_url |> URI.parse() |> URI.append_path(file_path) |> URI.to_string() + + thumbnails = + sizes + |> Enum.map(fn size -> + key = "#{size}x#{size}" + {key, String.replace(uri, "{}", key)} + end) + |> Enum.into(%{}) + + {:ok, + if original_uploaded? do + key = "original" + Map.put(thumbnails, key, String.replace(uri, "{}", key)) + else + thumbnails + end} + else + {:ok, nil} + end end def load(_), do: :error diff --git a/apps/explorer/lib/explorer/chain/token_transfer.ex b/apps/explorer/lib/explorer/chain/token_transfer.ex index 874181d75b81..a7bec3679a6d 100644 --- a/apps/explorer/lib/explorer/chain/token_transfer.ex +++ b/apps/explorer/lib/explorer/chain/token_transfer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.TokenTransfer.Schema do @moduledoc """ Models token transfers. @@ -5,7 +6,8 @@ defmodule Explorer.Chain.TokenTransfer.Schema do Changes in the schema should be reflected in the bulk import module: - Explorer.Chain.Import.Runner.TokenTransfers """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] alias Explorer.Chain.{ Address, @@ -16,10 +18,10 @@ defmodule Explorer.Chain.TokenTransfer.Schema do alias Explorer.Chain.Token.Instance - # Remove `transaction_hash` from primary key for `:celo` chain type. See + # Remove `transaction_hash` from primary key for `optimism-celo` chain type. See # `Explorer.Chain.Log.Schema` for more details. - @transaction_field (case @chain_type do - :celo -> + @transaction_field (case @chain_identity do + {:optimism, :celo} -> quote do [ belongs_to(:transaction, Transaction, @@ -134,14 +136,17 @@ defmodule Explorer.Chain.TokenTransfer do """ use Explorer.Schema - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + use Utils.RuntimeEnvHelper, chain_identity: [:explorer, :chain_identity] require Explorer.Chain.TokenTransfer.Schema import Ecto.Changeset + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] alias Explorer.Chain - alias Explorer.Chain.{DenormalizationHelper, Hash, Log, TokenTransfer} + alias Explorer.Chain.{DenormalizationHelper, Hash, Log, Token, TokenTransfer} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Helper, as: ExplorerHelper alias Explorer.{PagingOptions, QueryHelper, Repo} @@ -159,6 +164,19 @@ defmodule Explorer.Chain.TokenTransfer do @erc404_erc20_transfer_event "0xe59fdd36d0d223c0c7d996db7ad796880f45e1936cb0bb7ac102e7082e031487" @erc404_erc721_transfer_event "0xe5f815dc84b8cecdfd4beedfc3f91ab5be7af100eca4e8fb11552b867995394f" + # event NativeCoinTransferred(address indexed from, address indexed to, uint256 amount) + @arc_native_coin_transferred_event "0x62f084c00a442dcf51cdbb51beed2839bf42a268da8474b0e98f38edb7db5a22" + + # event NativeCoinMinted(address indexed recipient, uint256 amount) + @arc_native_coin_minted_event "0xb049859d09b3a7d0189a07db4d4becee1a2aa269023205478b1360ab6fc12114" + + # event NativeCoinBurned(address indexed from, uint256 amount) + @arc_native_coin_burned_event "0xaaf1ef013644e67c5cea90217acdf0accd334f8437fc9a89a53cfc9b25fb5c25" + @erc7984_transfer_event "0x67500e8d0ed826d2194f514dd0d8124f35648ab6e3fb5e6ed867134cffe661e9" + + # EIP-7708: log `address` for protocol-emitted ETH/native transfer logs + @eip7708_system_address "0xfffffffffffffffffffffffffffffffffffffffe" + @transfer_function_signature "0xa9059cbb" @typedoc """ @@ -184,16 +202,16 @@ defmodule Explorer.Chain.TokenTransfer do Explorer.Chain.TokenTransfer.Schema.generate() @required_attrs ~w(block_number log_index from_address_hash to_address_hash token_contract_address_hash block_hash token_type)a - |> (&(case @chain_type do - :celo -> + |> (&(case @chain_identity do + {:optimism, :celo} -> &1 _ -> [:transaction_hash | &1] end)).() @optional_attrs ~w(amount amounts token_ids block_consensus)a - |> (&(case @chain_type do - :celo -> + |> (&(case @chain_identity do + {:optimism, :celo} -> [:transaction_hash | &1] _ -> @@ -226,6 +244,19 @@ defmodule Explorer.Chain.TokenTransfer do def erc404_erc721_transfer_event, do: @erc404_erc721_transfer_event + def arc_native_coin_transferred_event, do: @arc_native_coin_transferred_event + + def arc_native_coin_minted_event, do: @arc_native_coin_minted_event + + def arc_native_coin_burned_event, do: @arc_native_coin_burned_event + + def erc7984_transfer_event, do: @erc7984_transfer_event + + @doc """ + EIP-7708 [`SYSTEM_ADDRESS`](https://eips.ethereum.org/EIPS/eip-7708) — log emitter for protocol `Transfer` events. + """ + def eip7708_system_address, do: @eip7708_system_address + @doc """ ERC 20's transfer(address,uint256) function signature """ @@ -243,7 +274,7 @@ defmodule Explorer.Chain.TokenTransfer do preloads = DenormalizationHelper.extend_transaction_preload([ :transaction, - :token, + [token: reputation_association()], [from_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]], [to_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]] ]) @@ -270,7 +301,7 @@ defmodule Explorer.Chain.TokenTransfer do preloads = DenormalizationHelper.extend_transaction_preload([ :transaction, - :token, + [token: reputation_association()], [from_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]], [to_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]] ]) @@ -303,7 +334,7 @@ defmodule Explorer.Chain.TokenTransfer do preloads = DenormalizationHelper.extend_transaction_preload([ :transaction, - :token, + [token: reputation_association()], [from_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]], [to_address: [:scam_badge, :names, :smart_contract, Implementation.proxy_implementations_association()]] ]) @@ -312,14 +343,45 @@ defmodule Explorer.Chain.TokenTransfer do |> preload(^preloads) |> order_by([tt], desc: tt.block_number, desc: tt.log_index) |> maybe_filter_by_token_type(token_type) - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) |> page_token_transfer(paging_options) |> limit(^paging_options.page_size) |> Chain.select_repo(options).all() end end - defp maybe_filter_by_token_type(query, token_types) do + @doc """ + Conditionally filters token transfers by token type based on denormalization status. + + This function applies token type filtering to the query using either the + denormalized `token_type` field or by joining with the tokens table, + depending on whether the token transfer denormalization process has been + completed. When denormalization is finished, it filters directly on + `tt.token_type`. Otherwise, it joins with the associated token and filters + on `token.type`. + + ## Parameters + - `query`: An Ecto query for token transfers + - `token_type`: Either a binary token type (e.g., "ERC-20") or a list of + token types to filter by + + ## Returns + - The modified query with token type filtering applied + - For empty token type lists, returns the original query unchanged + """ + @spec maybe_filter_by_token_type(Ecto.Query.t(), binary() | [binary()]) :: Ecto.Query.t() + def maybe_filter_by_token_type(query, token_type) when is_binary(token_type) do + if DenormalizationHelper.tt_denormalization_finished?() do + query + |> where([tt], tt.token_type == ^token_type) + else + query + |> join(:inner, [tt], token in assoc(tt, :token), as: :token) + |> where([tt, block, token], token.type == ^token_type) + end + end + + def maybe_filter_by_token_type(query, token_types) do if Enum.empty?(token_types) do query else @@ -543,7 +605,7 @@ defmodule Explorer.Chain.TokenTransfer do |> join(:inner, [tt], token in assoc(tt, :token), as: :token) |> preload([token: token], [{:token, token}]) |> filter_by_type(token_types) - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) |> handle_paging_options(paging_options) else to_address_hash_query = @@ -553,7 +615,7 @@ defmodule Explorer.Chain.TokenTransfer do |> filter_by_token_address_hash(token_address_hash) |> filter_by_type(token_types) |> order_by([tt], desc: tt.block_number, desc: tt.log_index) - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) |> handle_paging_options(paging_options) |> Chain.wrapped_union_subquery() @@ -564,7 +626,7 @@ defmodule Explorer.Chain.TokenTransfer do |> filter_by_token_address_hash(token_address_hash) |> filter_by_type(token_types) |> order_by([tt], desc: tt.block_number, desc: tt.log_index) - |> ExplorerHelper.maybe_hide_scam_addresses(:token_contract_address_hash, options) + |> ExplorerHelper.maybe_hide_scam_addresses_for_token_transfers(options) |> handle_paging_options(paging_options) |> Chain.wrapped_union_subquery() @@ -633,13 +695,7 @@ defmodule Explorer.Chain.TokenTransfer do l.first_topic == ^@constant or l.first_topic == ^@erc1155_single_transfer_signature or l.first_topic == ^@erc1155_batch_transfer_signature, - where: - not exists( - from(tf in TokenTransfer, - where: tf.transaction_hash == parent_as(:log).transaction_hash, - where: tf.log_index == parent_as(:log).index - ) - ), + where: not exists(token_transfer_exists_query()), select: l.block_number, distinct: l.block_number ) @@ -647,15 +703,37 @@ defmodule Explorer.Chain.TokenTransfer do Repo.stream_reduce(query, [], &[&1 | &2]) end - @doc """ - Returns ecto query to fetch consensus token transfers with ERC-721 token type - """ - @spec erc_721_token_transfers_query() :: Ecto.Query.t() - def erc_721_token_transfers_query do - only_consensus_transfers_query() - |> join(:inner, [tt], token in assoc(tt, :token), as: :token) - |> where([tt, token: token], token.type == "ERC-721") - |> preload([tt, token: token], [{:token, token}]) + # Builds a query to check if a token transfer exists for a given log. Handles + # chain-specific logic for transaction_hash comparison. + # + # For Celo epoch blocks, `transaction_hash` can be `nil` in both `Log` and + # `TokenTransfer`. A direct SQL comparison `NULL = NULL` evaluates to + # `UNKNOWN` (effectively false in this context). Therefore, we need a + # NULL-safe comparison for `transaction_hash`. Additionally, `block_hash` is + # included in the join condition to uniquely identify the token transfer, as + # `transaction_hash` (when nil) and `log_index` alone are insufficient. + @spec token_transfer_exists_query() :: Ecto.Query.t() + defp token_transfer_exists_query do + query = + from(tt in TokenTransfer, + where: tt.block_hash == parent_as(:log).block_hash, + where: tt.log_index == parent_as(:log).index + ) + + chain_identity() + |> case do + {:optimism, :celo} -> + query + |> where( + [tt], + tt.transaction_hash == parent_as(:log).transaction_hash or + (is_nil(parent_as(:log).transaction_hash) and is_nil(tt.transaction_hash)) + ) + + _ -> + query + |> where([tt], tt.transaction_hash == parent_as(:log).transaction_hash) + end end @doc """ @@ -725,4 +803,126 @@ defmodule Explorer.Chain.TokenTransfer do true end end + + def token_transfer_amount_for_api(%{ + token: token, + token_type: token_type, + amount: amount, + amounts: amounts, + token_ids: token_ids + }) do + do_token_transfer_amount_for_api(token, token_type, amount, amounts, token_ids) + end + + def token_transfer_amount_for_api(%{token: token, token_type: token_type, amount: amount, token_ids: token_ids}) do + do_token_transfer_amount_for_api(token, token_type, amount, nil, token_ids) + end + + # TODO: remove this clause along with token transfer denormalization + defp do_token_transfer_amount_for_api(%Token{type: "ERC-20"}, nil, nil, nil, _token_ids) do + {:ok, nil} + end + + defp do_token_transfer_amount_for_api(_token, "ERC-20", nil, nil, _token_ids) do + {:ok, nil} + end + + # TODO: remove this clause along with token transfer denormalization + defp do_token_transfer_amount_for_api( + %Token{type: "ERC-20", decimals: decimals}, + nil, + amount, + _amounts, + _token_ids + ) do + {:ok, amount, decimals} + end + + defp do_token_transfer_amount_for_api( + %Token{decimals: decimals}, + "ERC-20", + amount, + _amounts, + _token_ids + ) do + {:ok, amount, decimals} + end + + # TODO: remove this clause along with token transfer denormalization + defp do_token_transfer_amount_for_api(%Token{type: "ZRC-2"}, nil, nil, nil, _token_ids) do + {:ok, nil} + end + + defp do_token_transfer_amount_for_api(_token, "ZRC-2", nil, nil, _token_ids) do + {:ok, nil} + end + + # TODO: remove this clause along with token transfer denormalization + defp do_token_transfer_amount_for_api( + %Token{type: "ZRC-2", decimals: decimals}, + nil, + amount, + _amounts, + _token_ids + ) do + {:ok, amount, decimals} + end + + defp do_token_transfer_amount_for_api( + %Token{decimals: decimals}, + "ZRC-2", + amount, + _amounts, + _token_ids + ) do + {:ok, amount, decimals} + end + + # TODO: remove this clause along with token transfer denormalization + defp do_token_transfer_amount_for_api(%Token{type: "ERC-721"}, nil, _amount, _amounts, _token_ids) do + {:ok, :erc721_instance} + end + + defp do_token_transfer_amount_for_api(_token, "ERC-721", _amount, _amounts, _token_ids) do + {:ok, :erc721_instance} + end + + # TODO: remove this clause along with token transfer denormalization + defp do_token_transfer_amount_for_api( + %Token{type: type, decimals: decimals}, + nil, + amount, + amounts, + token_ids + ) + when type in ["ERC-1155", "ERC-404"] do + if amount do + {:ok, :erc1155_erc404_instance, amount, decimals} + else + {:ok, :erc1155_erc404_instance, amounts, token_ids, decimals} + end + end + + defp do_token_transfer_amount_for_api( + %Token{decimals: decimals}, + type, + amount, + amounts, + token_ids + ) + when type in ["ERC-1155", "ERC-404"] do + if amount do + {:ok, :erc1155_erc404_instance, amount, decimals} + else + {:ok, :erc1155_erc404_instance, amounts, token_ids, decimals} + end + end + + defp do_token_transfer_amount_for_api(%Token{decimals: decimals}, "ERC-7984", _amount, _amounts, _token_ids) do + {:ok, nil, decimals} + end + + defp do_token_transfer_amount_for_api(_token, _token_type, _amount, _amounts, _token_ids) do + nil + end end diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index 6375e487d16d..8aca43eb8b66 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.Schema do @moduledoc """ Models transactions. @@ -5,7 +6,9 @@ defmodule Explorer.Chain.Transaction.Schema do Changes in the schema should be reflected in the bulk import module: - Explorer.Chain.Import.Runner.Transactions """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.CompileTimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] alias Explorer.Chain @@ -20,14 +23,12 @@ defmodule Explorer.Chain.Transaction.Schema do PendingTransactionOperation, SignedAuthorization, TokenTransfer, - TransactionAction, Wei } alias Explorer.Chain.Arbitrum.BatchBlock, as: ArbitrumBatchBlock alias Explorer.Chain.Arbitrum.BatchTransaction, as: ArbitrumBatchTransaction alias Explorer.Chain.Arbitrum.Message, as: ArbitrumMessage - alias Explorer.Chain.PolygonZkevm.BatchTransaction, as: ZkevmBatchTransaction alias Explorer.Chain.Transaction.{Fork, Status} alias Explorer.Chain.ZkSync.BatchTransaction, as: ZkSyncBatchTransaction @@ -49,6 +50,9 @@ defmodule Explorer.Chain.Transaction.Schema do field(:l1_gas_used, :decimal) field(:l1_transaction_origin, Hash.Full) field(:l1_block_number, :integer) + field(:operator_fee_scalar, :decimal) + field(:operator_fee_constant, :decimal) + field(:da_footprint_gas_scalar, :decimal) end, 2 ) @@ -97,29 +101,6 @@ defmodule Explorer.Chain.Transaction.Schema do 2 ) - :polygon_zkevm -> - elem( - quote do - has_one(:zkevm_batch_transaction, ZkevmBatchTransaction, - foreign_key: :hash, - references: :hash - ) - - has_one(:zkevm_batch, through: [:zkevm_batch_transaction, :batch], references: :hash) - - has_one(:zkevm_sequence_transaction, - through: [:zkevm_batch, :sequence_transaction], - references: :hash - ) - - has_one(:zkevm_verify_transaction, - through: [:zkevm_batch, :verify_transaction], - references: :hash - ) - end, - 2 - ) - :zksync -> elem( quote do @@ -136,28 +117,6 @@ defmodule Explorer.Chain.Transaction.Schema do 2 ) - :celo -> - elem( - quote do - field(:gateway_fee, Wei) - - belongs_to(:gas_fee_recipient, Address, - foreign_key: :gas_fee_recipient_address_hash, - references: :hash, - type: Hash.Address - ) - - belongs_to(:gas_token_contract_address, Address, - foreign_key: :gas_token_contract_address_hash, - references: :hash, - type: Hash.Address - ) - - has_one(:gas_token, through: [:gas_token_contract_address, :token]) - end, - 2 - ) - :arbitrum -> elem( quote do @@ -196,10 +155,49 @@ defmodule Explorer.Chain.Transaction.Schema do 2 ) + :zilliqa -> + alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer + + quote do + [ + has_many(:zilliqa_zrc2_token_transfers, Zrc2TokenTransfer, + foreign_key: :transaction_hash, + references: :hash + ) + ] + end + _ -> [] end) + @chain_identity_fields (case @chain_identity do + {:optimism, :celo} -> + elem( + quote do + field(:gateway_fee, Wei) + + belongs_to(:gas_fee_recipient, Address, + foreign_key: :gas_fee_recipient_address_hash, + references: :hash, + type: Hash.Address + ) + + belongs_to(:gas_token_contract_address, Address, + foreign_key: :gas_token_contract_address_hash, + references: :hash, + type: Hash.Address + ) + + has_one(:gas_token, through: [:gas_token_contract_address, :token]) + end, + 2 + ) + + _ -> + [] + end) + defmacro generate do quote do @primary_key false @@ -229,7 +227,9 @@ defmodule Explorer.Chain.Transaction.Schema do field(:max_fee_per_gas, Wei) field(:type, :integer) field(:has_error_in_internal_transactions, :boolean) + field(:fhe_operations_count, :integer) field(:has_token_transfers, :boolean, virtual: true) + field(:internal_transactions, {:array, :map}, virtual: true) # stability virtual fields field(:transaction_fee_log, :any, virtual: true) @@ -253,17 +253,10 @@ defmodule Explorer.Chain.Transaction.Schema do type: Hash.Address ) - has_many(:internal_transactions, InternalTransaction, foreign_key: :transaction_hash, references: :hash) has_many(:logs, Log, foreign_key: :transaction_hash, references: :hash) has_many(:token_transfers, TokenTransfer, foreign_key: :transaction_hash, references: :hash) - has_many(:transaction_actions, TransactionAction, - foreign_key: :hash, - preload_order: [asc: :log_index], - references: :hash - ) - belongs_to( :to_address, Address, @@ -290,6 +283,7 @@ defmodule Explorer.Chain.Transaction.Schema do has_one(:pending_operation, PendingTransactionOperation, foreign_key: :transaction_hash, references: :hash) unquote_splicing(@chain_type_fields) + unquote_splicing(@chain_identity_fields) end end end @@ -302,15 +296,21 @@ defmodule Explorer.Chain.Transaction do use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity], decode_not_a_contract_calls: [:explorer, :decode_not_a_contract_calls] + use Utils.RuntimeEnvHelper, + op_jovian_timestamp: [:indexer, [Indexer.Fetcher.Optimism.EIP1559ConfigUpdate, :jovian_timestamp_l2]] + require Logger require Explorer.Chain.Transaction.Schema alias ABI.FunctionSelector alias Ecto.Association.NotLoaded alias Ecto.Changeset - alias Explorer.{Chain, Helper, PagingOptions, Repo, SortingHelper} + alias EthereumJSONRPC + alias EthereumJSONRPC.Transaction, as: EthereumJSONRPCTransaction + alias Explorer.{Chain, Helper, PagingOptions, QueryHelper, Repo, SortingHelper} alias Explorer.Chain.{ Address, @@ -320,17 +320,20 @@ defmodule Explorer.Chain.Transaction do Data, DenormalizationHelper, Hash, + InternalTransaction, + MethodIdentifier, SmartContract.Proxy, TokenTransfer, - Transaction, Wei } alias Explorer.Chain.Block.Reader.General, as: BlockReaderGeneral + alias Explorer.Chain.Cache.Transactions + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation - alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Chain.Zilliqa.Helper, as: ZilliqaHelper alias Explorer.SmartContract.SigProviderInterface @@ -342,7 +345,7 @@ defmodule Explorer.Chain.Transaction do @chain_type_optional_attrs (case @chain_type do :optimism -> - ~w(l1_fee l1_fee_scalar l1_gas_price l1_gas_used l1_transaction_origin l1_block_number)a + ~w(l1_fee l1_fee_scalar l1_gas_price l1_gas_used l1_transaction_origin l1_block_number operator_fee_scalar operator_fee_constant da_footprint_gas_scalar)a :scroll -> ~w(l1_fee queue_index)a @@ -353,13 +356,18 @@ defmodule Explorer.Chain.Transaction do :arbitrum -> ~w(gas_used_for_l1)a - :celo -> - ~w(gateway_fee gas_fee_recipient_address_hash gas_token_contract_address_hash)a - _ -> ~w()a end) + @chain_identity_optional_attrs (case @chain_identity do + {:optimism, :celo} -> + ~w(gateway_fee gas_fee_recipient_address_hash gas_token_contract_address_hash)a + + _ -> + ~w()a + end) + @required_attrs ~w(from_address_hash gas hash input nonce value)a @typedoc """ @@ -523,6 +531,9 @@ defmodule Explorer.Chain.Transaction do * `wrapped_r` - R field of the signature from the `wrapped` field (used by Suave) * `wrapped_s` - S field of the signature from the `wrapped` field (used by Suave) * `wrapped_hash` - hash from the `wrapped` field (used by Suave) + * `operator_fee_scalar` - operatorFeeScalar is a uint32 scalar set by a chain operator (used by some OP chains) + * `operator_fee_constant` - operatorFeeConstant is a uint64 constant set by a chain operator (used by some OP chains) + * `da_footprint_gas_scalar` - daFootprintGasScalar is a uint16 scalar used to calculate daFootprint introduced in Jovian OP upgrade """ Explorer.Chain.Transaction.Schema.generate() @@ -679,7 +690,8 @@ defmodule Explorer.Chain.Transaction do attrs_to_cast = @required_attrs ++ @optional_attrs ++ - @chain_type_optional_attrs + @chain_type_optional_attrs ++ + @chain_identity_optional_attrs transaction |> cast(attrs, attrs_to_cast) @@ -761,7 +773,7 @@ defmodule Explorer.Chain.Transaction do _ -> decoded_input_data( - %Transaction{ + %__MODULE__{ to_address: smart_contract, hash: hash, input: %Data{bytes: binary_revert_reason} @@ -777,7 +789,7 @@ defmodule Explorer.Chain.Transaction do # Because there is no contract association, we know the contract was not verified @spec decoded_input_data( - NotLoaded.t() | Transaction.t(), + NotLoaded.t() | __MODULE__.t(), boolean(), [Chain.api?()], methods_map, @@ -809,6 +821,14 @@ defmodule Explorer.Chain.Transaction do def decoded_input_data(%NotLoaded{}, _, _, _, _), do: {:error, :not_loaded} + if @chain_identity == {:optimism, :celo} do + # Celo's Epoch logs does not have an associated transaction and linked to + # the block instead, so we discard these token transfers for transaction + # decoding + def decoded_input_data(nil, _, _, _, _), + do: {:error, :celo_epoch_log} + end + # skip decoding if input is empty def decoded_input_data( %__MODULE__{input: %{bytes: bytes}}, @@ -833,6 +853,33 @@ defmodule Explorer.Chain.Transaction do do: {:error, :not_a_contract_call} end + # if to_address is a verified contract but smart_contract is not preloaded, + # decode using the pre-built smart_contract_full_abi_map (keyed by address hash) + def decoded_input_data( + %__MODULE__{ + input: %{bytes: data} = input, + to_address: %Address{verified: true, hash: to_address_hash, smart_contract: %NotLoaded{}}, + hash: hash + }, + skip_sig_provider?, + options, + methods_map, + smart_contract_full_abi_map + ) do + full_abi = Map.get(smart_contract_full_abi_map, to_address_hash, []) + + decode_input_data_with_fallback( + data, + full_abi, + input, + hash, + skip_sig_provider?, + options, + methods_map, + smart_contract_full_abi_map + ) + end + # if to_address's smart_contract is nil reduce to the case when to_address is not loaded def decoded_input_data( %__MODULE__{ @@ -895,6 +942,7 @@ defmodule Explorer.Chain.Transaction do methods_map, _smart_contract_full_abi_map ) do + {:ok, method_id} = MethodIdentifier.cast(method_id) methods = check_methods_cache(method_id, methods_map, options) candidates = @@ -938,6 +986,37 @@ defmodule Explorer.Chain.Transaction do ) do full_abi = check_full_abi_cache(smart_contract, smart_contract_full_abi_map, options) + decode_input_data_with_fallback( + data, + full_abi, + input, + hash, + skip_sig_provider?, + options, + methods_map, + smart_contract_full_abi_map + ) + end + + def decoded_input_data( + %__MODULE__{to_address: %{metadata: _, ens_domain_name: _}}, + _, + _, + _, + _ + ), + do: {:error, :no_to_address} + + defp decode_input_data_with_fallback( + data, + full_abi, + input, + hash, + skip_sig_provider?, + options, + methods_map, + smart_contract_full_abi_map + ) do case do_decoded_input_data(data, full_abi, hash) do # In some cases transactions use methods of some unpredictable contracts, so we can try to look up for method in a whole DB {:error, error} when error in [:could_not_decode, :no_matching_function] -> @@ -1040,7 +1119,8 @@ defmodule Explorer.Chain.Transaction do parse_method_name(decoded_func) {:error, :contract_not_verified, []} -> - ExplorerHelper.add_0x_prefix(method_id) + {:ok, method_id} = MethodIdentifier.cast(method_id) + to_string(method_id) _ -> "Transfer" @@ -1124,7 +1204,9 @@ defmodule Explorer.Chain.Transaction do Produces a list of queries starting from the given one and adding filters for transactions that are linked to the given address_hash through a direction. """ - def matching_address_queries_list(query, :from, address_hashes) when is_list(address_hashes) do + def matching_address_queries_list(query, direction, address_hashes, custom_sorting \\ []) + + def matching_address_queries_list(query, :from, address_hashes, _custom_sorting) when is_list(address_hashes) do [ from( a in fragment("SELECT unnest(?) as from_address_hash", type(^address_hashes, {:array, Hash.Address})), @@ -1140,7 +1222,7 @@ defmodule Explorer.Chain.Transaction do ] end - def matching_address_queries_list(query, :to, address_hashes) when is_list(address_hashes) do + def matching_address_queries_list(query, :to, address_hashes, _custom_sorting) when is_list(address_hashes) do [ from( a in fragment("SELECT unnest(?) as to_address_hash", type(^address_hashes, {:array, Hash.Address})), @@ -1173,30 +1255,51 @@ defmodule Explorer.Chain.Transaction do ] end - def matching_address_queries_list(query, _direction, address_hashes) when is_list(address_hashes) do + def matching_address_queries_list(query, _direction, address_hashes, _custom_sorting) when is_list(address_hashes) do matching_address_queries_list(query, :from, address_hashes) ++ matching_address_queries_list(query, :to, address_hashes) end - def matching_address_queries_list(query, :from, address_hash) do - [where(query, [t], t.from_address_hash == ^address_hash)] - end + # in ^[address_hash] addresses this issue: https://github.com/blockscout/blockscout/issues/12393 + def matching_address_queries_list(query, :from, address_hash, custom_sorting) do + order = + for {key, :block_number = value} <- custom_sorting do + {value, key} + end + |> Keyword.get(:block_number, :desc) - def matching_address_queries_list(query, :to, address_hash) do [ - where(query, [t], t.to_address_hash == ^address_hash), - where(query, [t], t.created_contract_address_hash == ^address_hash) + query + |> where([t], t.from_address_hash in ^[address_hash]) + |> prepend_order_by([t], [{^order, t.from_address_hash}]) ] end - def matching_address_queries_list(query, _direction, address_hash) do + def matching_address_queries_list(query, :to, address_hash, custom_sorting) do + order = + for {key, :block_number = value} <- custom_sorting do + {value, key} + end + |> Keyword.get(:block_number, :desc) + [ - where(query, [t], t.from_address_hash == ^address_hash), - where(query, [t], t.to_address_hash == ^address_hash), - where(query, [t], t.created_contract_address_hash == ^address_hash) + query + |> where([t], t.to_address_hash in ^[address_hash]) + |> prepend_order_by([t], [{^order, t.to_address_hash}]), + query + |> where( + [t], + t.created_contract_address_hash in ^[address_hash] + ) + |> prepend_order_by([t], [{^order, t.created_contract_address_hash}]) ] end + def matching_address_queries_list(query, _direction, address_hash, custom_sorting) do + matching_address_queries_list(query, :from, address_hash, custom_sorting) ++ + matching_address_queries_list(query, :to, address_hash, custom_sorting) + end + def not_pending_transactions(query) do where(query, [t], not is_nil(t.block_number)) end @@ -1289,7 +1392,7 @@ defmodule Explorer.Chain.Transaction do defp transactions_with_token_transfers_query(address_hash, token_hash) do from( - t in Transaction, + t in __MODULE__, inner_join: tt in TokenTransfer, on: t.hash == tt.transaction_hash, where: tt.token_contract_address_hash == ^token_hash, @@ -1311,7 +1414,7 @@ defmodule Explorer.Chain.Transaction do defp transactions_with_token_transfers_query_direction(:from, address_hash) do from( - t in Transaction, + t in __MODULE__, inner_join: tt in TokenTransfer, on: t.hash == tt.transaction_hash, where: tt.from_address_hash == ^address_hash, @@ -1321,7 +1424,7 @@ defmodule Explorer.Chain.Transaction do defp transactions_with_token_transfers_query_direction(:to, address_hash) do from( - t in Transaction, + t in __MODULE__, inner_join: tt in TokenTransfer, on: t.hash == tt.transaction_hash, where: tt.to_address_hash == ^address_hash, @@ -1331,7 +1434,7 @@ defmodule Explorer.Chain.Transaction do defp transactions_with_token_transfers_query_direction(_, address_hash) do from( - t in Transaction, + t in __MODULE__, inner_join: tt in TokenTransfer, on: t.hash == tt.transaction_hash, where: tt.from_address_hash == ^address_hash or tt.to_address_hash == ^address_hash, @@ -1344,7 +1447,7 @@ defmodule Explorer.Chain.Transaction do """ def transactions_with_block_number(block_number) do from( - t in Transaction, + t in __MODULE__, where: t.block_number == ^block_number ) end @@ -1355,7 +1458,7 @@ defmodule Explorer.Chain.Transaction do @spec transactions_for_block_numbers([non_neg_integer()]) :: Ecto.Query.t() def transactions_for_block_numbers(block_numbers) do from( - t in Transaction, + t in __MODULE__, where: t.block_number in ^block_numbers ) end @@ -1363,14 +1466,25 @@ defmodule Explorer.Chain.Transaction do @doc """ Builds an `Ecto.Query` to fetch transactions by hashes """ - @spec transactions_by_hashes([Hash.t()]) :: Ecto.Query.t() - def transactions_by_hashes(hashes) do + @spec by_hashes_query([Hash.t()]) :: Ecto.Query.t() + def by_hashes_query(hashes) do from( - t in Transaction, + t in __MODULE__, where: t.hash in ^hashes ) end + @doc """ + Builds an `Ecto.Query` to fetch transaction by {block_number, index} pairs + """ + @spec by_block_number_index_query([{non_neg_integer(), non_neg_integer()}]) :: Ecto.Query.t() + def by_block_number_index_query(block_number_index_pairs) do + from( + t in __MODULE__, + where: ^QueryHelper.tuple_in([:block_number, :index], block_number_index_pairs) + ) + end + @doc """ Builds an `Ecto.Query` to fetch the last nonce from the given address hash. @@ -1380,7 +1494,7 @@ defmodule Explorer.Chain.Transaction do """ def last_nonce_by_address_query(address_hash) do from( - t in Transaction, + t in __MODULE__, select: t.nonce, where: t.from_address_hash == ^address_hash, order_by: [desc: :block_number], @@ -1388,10 +1502,43 @@ defmodule Explorer.Chain.Transaction do ) end + @doc """ + Streams a batch of transactions without OP operator fee for which the fee needs to be defined. + + This function selects specific fields from the transaction records and applies a reducer function to each entry in the stream, accumulating the result. + + ## Parameters + - `initial`: The initial accumulator value. + - `reducer`: A function that takes an entry and the current accumulator, returning the updated accumulator. + - `start_timestamp`: A timestamp starting from which the transactions should be scanned. + + ## Returns + - `{:ok, accumulator}`: A tuple containing `:ok` and the final accumulator after processing the stream. + """ + @spec stream_transactions_without_operator_fee( + initial :: accumulator, + reducer :: (entry :: Hash.t(), accumulator -> accumulator), + start_timestamp :: non_neg_integer() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_transactions_without_operator_fee(initial, reducer, start_timestamp) when is_function(reducer, 2) do + limit = Application.get_env(:indexer, Indexer.Fetcher.Optimism.OperatorFee)[:init_limit] + start_datetime = DateTime.from_unix!(start_timestamp) + + __MODULE__ + |> select([t], t.hash) + |> where( + [t], + t.block_timestamp >= ^start_datetime and t.block_consensus == true and is_nil(t.operator_fee_constant) + ) + |> limit(^limit) + |> Repo.stream_reduce(initial, reducer) + end + @doc """ Returns true if the transaction is a Rootstock REMASC transaction. """ - @spec rootstock_remasc_transaction?(Explorer.Chain.Transaction.t()) :: boolean + @spec rootstock_remasc_transaction?(__MODULE__.t()) :: boolean def rootstock_remasc_transaction?(%__MODULE__{to_address_hash: to_address_hash}) do case Hash.Address.cast(Application.get_env(:explorer, __MODULE__)[:rootstock_remasc_address]) do {:ok, address} -> address == to_address_hash @@ -1402,7 +1549,7 @@ defmodule Explorer.Chain.Transaction do @doc """ Returns true if the transaction is a Rootstock bridge transaction. """ - @spec rootstock_bridge_transaction?(Explorer.Chain.Transaction.t()) :: boolean + @spec rootstock_bridge_transaction?(__MODULE__.t()) :: boolean def rootstock_bridge_transaction?(%__MODULE__{to_address_hash: to_address_hash}) do case Hash.Address.cast(Application.get_env(:explorer, __MODULE__)[:rootstock_bridge_address]) do {:ok, address} -> address == to_address_hash @@ -1559,6 +1706,7 @@ defmodule Explorer.Chain.Transaction do Chain.paging_options() | Chain.necessity_by_association_option() | {:sorting, SortingHelper.sorting_params()} + | Chain.timeout_option() ], boolean() ) :: [__MODULE__.t()] @@ -1577,14 +1725,20 @@ defmodule Explorer.Chain.Transaction do direction = Keyword.get(options, :direction) necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) old_ui? = old_ui? || is_tuple(Keyword.get(options, :paging_options, Chain.default_paging_options()).key) + sorting_options = Keyword.get(options, :sorting, []) + timeout = Keyword.get(options, :timeout) options |> address_to_transactions_tasks_query(false, old_ui?) |> not_dropped_or_replaced_transactions() |> Chain.join_associations(necessity_by_association) |> put_has_token_transfers_to_transaction(old_ui?) - |> matching_address_queries_list(direction, address_hash) - |> Enum.map(fn query -> Task.async(fn -> Chain.select_repo(options).all(query) end) end) + |> matching_address_queries_list(direction, address_hash, sorting_options) + |> Enum.map(fn query -> + Task.async(fn -> + Chain.select_repo(options).all(query, Helper.maybe_timeout(timeout)) + end) + end) end @doc """ @@ -1650,17 +1804,11 @@ defmodule Explorer.Chain.Transaction do end end - defp compare_custom_sorting([{block_order, :block_number}, {index_order, :index}]) do - fn a, b -> - case {Helper.compare(a.block_number, b.block_number), Helper.compare(a.index, b.index)} do - {:eq, :eq} -> compare_default_sorting(a, b) - {:eq, :gt} -> index_order == :desc - {:eq, :lt} -> index_order == :asc - {:gt, _} -> block_order == :desc - {:lt, _} -> block_order == :asc - end - end - end + defp compare_custom_sorting([{:desc, :block_number}, {:desc, :index}, {:desc, :inserted_at}, {:asc, :hash}]), + do: &compare_default_sorting/2 + + defp compare_custom_sorting([{:asc, :block_number}, {:asc, :index}, {:asc, :inserted_at}, {:desc, :hash}]), + do: &(!compare_default_sorting(&1, &2)) defp compare_custom_sorting([{:dynamic, :fee, order, _dynamic_fee}]) do fn a, b -> @@ -1900,20 +2048,20 @@ defmodule Explorer.Chain.Transaction do @doc """ Returns next page params based on the provided transaction. """ - @spec address_transactions_next_page_params(Explorer.Chain.Transaction.t()) :: %{ - required(String.t()) => Decimal.t() | Wei.t() | non_neg_integer | DateTime.t() | Hash.t() + @spec address_transactions_next_page_params(__MODULE__.t()) :: %{ + required(atom()) => Decimal.t() | Wei.t() | non_neg_integer | DateTime.t() | Hash.t() } def address_transactions_next_page_params( %__MODULE__{block_number: block_number, index: index, inserted_at: inserted_at, hash: hash, value: value} = transaction ) do %{ - "fee" => transaction |> fee(:wei) |> elem(1), - "value" => value, - "block_number" => block_number, - "index" => index, - "inserted_at" => inserted_at, - "hash" => hash + fee: transaction |> fee(:wei) |> elem(1), + value: value, + block_number: block_number, + index: index, + inserted_at: inserted_at, + hash: hash } end @@ -1946,59 +2094,123 @@ defmodule Explorer.Chain.Transaction do {:actual, Decimal.new(4)} """ - @spec fee(Transaction.t(), :ether | :gwei | :wei) :: {:maximum, Decimal.t()} | {:actual, Decimal.t() | nil} - def fee(%Transaction{gas: _gas, gas_price: nil, gas_used: nil}, _unit), do: {:maximum, nil} + @spec fee(__MODULE__.t(), :ether | :gwei | :wei) :: {:maximum, Decimal.t() | nil} | {:actual, Decimal.t() | nil} + def fee(%__MODULE__{gas: _gas, gas_price: nil, gas_used: nil}, _unit), do: {:maximum, nil} - def fee(%Transaction{gas: gas, gas_price: gas_price, gas_used: nil} = transaction, unit) do - {:maximum, fee_calc(transaction, gas_price, gas, unit)} + def fee(%__MODULE__{gas: gas, gas_price: _gas_price, gas_used: nil} = transaction, unit) do + {:maximum, fee_calc(transaction, gas, unit)} end if @chain_type == :optimism do - def fee(%Transaction{gas_price: nil, gas_used: _gas_used}, _unit) do + def fee(%__MODULE__{gas_price: nil, gas_used: _gas_used}, _unit) do {:actual, nil} end else - def fee(%Transaction{gas_price: nil, gas_used: gas_used} = transaction, unit) do + def fee(%__MODULE__{gas_price: nil, gas_used: gas_used} = transaction, unit) do gas_price = effective_gas_price(transaction) {:actual, gas_price && l2_fee_calc(gas_price, gas_used, unit)} end end - def fee(%Transaction{gas_price: gas_price, gas_used: gas_used} = transaction, unit) do - {:actual, fee_calc(transaction, gas_price, gas_used, unit)} + def fee(%__MODULE__{gas_price: _gas_price, gas_used: gas_used} = transaction, unit) do + {:actual, fee_calc(transaction, gas_used, unit)} end - defp fee_calc(transaction, gas_price, gas_used, unit) do + # Internal function calculating a total fee of the transaction as follows: + # total_fee = l2_fee + l1_fee + operator_fee + # The `operator_fee` is only calculated for OP chains (for others it's zero) starting from the Isthmus upgrade. + # + # ## Parameters + # - `transaction`: The transaction entity. + # - `gas_used`: The amount of gas used in the transaction. Equals to gas limit for pending transactions. + # - `unit`: Which unit the result should be presented in. One of [:ether, :gwei, :wei]. + # + # ## Returns + # - The calculated total fee. + @spec fee_calc(__MODULE__.t(), Decimal.t(), :ether | :gwei | :wei) :: Decimal.t() + defp fee_calc(transaction, gas_used, unit) do l1_fee = case Map.get(transaction, :l1_fee) do nil -> Wei.from(Decimal.new(0), :wei) value -> value end - gas_price + {:ok, operator_fee} = + transaction + |> operator_fee() + |> Wei.cast() + + transaction.gas_price |> l2_fee_calc(gas_used, unit) |> Wei.from(unit) |> Wei.sum(l1_fee) + |> Wei.sum(operator_fee) |> Wei.to(unit) end + @doc """ + The operator fee is calculated for OP chains starting from the Isthmus upgrade + as described in https://specs.optimism.io/protocol/isthmus/exec-engine.html#operator-fee + The formula changed in Jovian upgrade as follows: + https://specs.optimism.io/protocol/jovian/exec-engine.html#fee-formula-update + + If the `operatorFeeScalar` or `operatorFeeConstant` is `nil`, it's treated as zero. + + ## Parameters + - `transaction`: The transaction entity. + + ## Returns + - The calculated operator fee for the given transaction. + """ + @spec operator_fee(__MODULE__.t()) :: Decimal.t() + def operator_fee( + %__MODULE__{ + gas: gas, + gas_used: gas_used + } = transaction + ) do + gas_used = gas_used || gas + operator_fee_scalar = Map.get(transaction, :operator_fee_scalar) || Decimal.new(0) + operator_fee_constant = Map.get(transaction, :operator_fee_constant) || Decimal.new(0) + + jovian_timestamp = op_jovian_timestamp() + + block_timestamp = + Map.get(transaction, :block_timestamp) || (jovian_timestamp && DateTime.from_unix!(jovian_timestamp)) || + DateTime.from_unix!(0) + + if DateTime.to_unix(block_timestamp) >= jovian_timestamp do + # use the formula for Jovian + gas_used + |> Decimal.mult(operator_fee_scalar) + |> Decimal.mult(100) + |> Decimal.add(operator_fee_constant) + else + # use the formula for Isthmus + gas_used + |> Decimal.mult(operator_fee_scalar) + |> Decimal.div_int(1_000_000) + |> Decimal.add(operator_fee_constant) + end + end + @doc """ The execution fee a `transaction` paid for the `t:Explorer.Chain.Transaction.t/0` `gas`. Doesn't include L1 fee. See the description for the `fee` function for parameters and return values. """ - @spec l2_fee(Transaction.t(), :ether | :gwei | :wei) :: {:maximum, Decimal.t() | nil} | {:actual, Decimal.t() | nil} - def l2_fee(%Transaction{gas: _gas, gas_price: nil, gas_used: nil}, _unit), do: {:maximum, nil} + @spec l2_fee(__MODULE__.t(), :ether | :gwei | :wei) :: {:maximum, Decimal.t() | nil} | {:actual, Decimal.t() | nil} + def l2_fee(%__MODULE__{gas: _gas, gas_price: nil, gas_used: nil}, _unit), do: {:maximum, nil} - def l2_fee(%Transaction{gas: gas, gas_price: gas_price, gas_used: nil}, unit) do + def l2_fee(%__MODULE__{gas: gas, gas_price: gas_price, gas_used: nil}, unit) do {:maximum, l2_fee_calc(gas_price, gas, unit)} end - def l2_fee(%Transaction{gas_price: nil, gas_used: gas_used} = transaction, unit) do + def l2_fee(%__MODULE__{gas_price: nil, gas_used: gas_used} = transaction, unit) do gas_price = effective_gas_price(transaction) {:actual, gas_price && l2_fee_calc(gas_price, gas_used, unit)} end - def l2_fee(%Transaction{gas_price: gas_price, gas_used: gas_used}, unit) do + def l2_fee(%__MODULE__{gas_price: gas_price, gas_used: gas_used}, unit) do {:actual, l2_fee_calc(gas_price, gas_used, unit)} end @@ -2008,23 +2220,46 @@ defmodule Explorer.Chain.Transaction do |> Decimal.mult(gas_used) end + @doc """ + Calculates burnt fees for a transaction as `base_fee_per_gas * gas_used`. + + ## Parameters + - `gas_used`: Gas used by the transaction. + - `max_fee_per_gas`: Max fee per gas in Wei. + - `base_fee_per_gas`: Base fee per gas in Wei. + + ## Returns + - The calculated amount of the burnt fees. + - `nil` if any parameter is `nil`. + """ + @spec burnt_fees(Decimal.t() | nil, Wei.t() | nil, Wei.t() | nil) :: Wei.t() | nil + def burnt_fees(gas_used, max_fee_per_gas, base_fee_per_gas) do + if !is_nil(max_fee_per_gas) and !is_nil(gas_used) and !is_nil(base_fee_per_gas) do + if Decimal.compare(max_fee_per_gas.value, 0) == :eq do + Wei.zero() + else + Wei.mult(base_fee_per_gas, gas_used) + end + end + end + @doc """ Wrapper around `effective_gas_price/2` """ - @spec effective_gas_price(Transaction.t()) :: Wei.t() | nil - def effective_gas_price(%Transaction{} = transaction), do: effective_gas_price(transaction, transaction.block) + @spec effective_gas_price(__MODULE__.t()) :: Wei.t() | nil + def effective_gas_price(%__MODULE__{} = transaction), do: effective_gas_price(transaction, transaction.block) @doc """ Calculates effective gas price for transaction with type 2 (EIP-1559) `effective_gas_price = priority_fee_per_gas + block.base_fee_per_gas` """ - @spec effective_gas_price(Transaction.t(), Block.t()) :: Wei.t() | nil + @spec effective_gas_price(__MODULE__.t(), Block.t()) :: Wei.t() | nil - def effective_gas_price(%Transaction{}, %NotLoaded{}), do: nil - def effective_gas_price(%Transaction{}, nil), do: nil + def effective_gas_price(%__MODULE__{}, %NotLoaded{}), do: nil + def effective_gas_price(%__MODULE__{}, nil), do: nil - def effective_gas_price(%Transaction{} = transaction, block) do + def effective_gas_price(%__MODULE__{} = transaction, block) do base_fee_per_gas = block.base_fee_per_gas max_priority_fee_per_gas = transaction.max_priority_fee_per_gas max_fee_per_gas = transaction.max_fee_per_gas @@ -2077,7 +2312,7 @@ defmodule Explorer.Chain.Transaction do def transaction_count_for_block_range(from..to//_) do Repo.replica().aggregate( from( - t in Transaction, + t in __MODULE__, where: t.block_number >= ^from and t.block_number <= ^to and t.block_consensus == true ), :count, @@ -2090,7 +2325,7 @@ defmodule Explorer.Chain.Transaction do Where - `decoded_input_data` is list of results: either `{:ok, _identifier, _text, _mapping}` or `nil` """ - @spec decode_transactions([Transaction.t()], boolean(), Keyword.t()) :: [nil | {:ok, String.t(), String.t(), map()}] + @spec decode_transactions([__MODULE__.t()], boolean(), Keyword.t()) :: [nil | {:ok, String.t(), String.t(), map()}] def decode_transactions(transactions, skip_sig_provider?, opts) do smart_contract_full_abi_map = combine_smart_contract_full_abi_map(transactions) @@ -2098,8 +2333,12 @@ defmodule Explorer.Chain.Transaction do empty_methods_map = transactions |> Enum.flat_map(fn - %{input: %{bytes: <>}} -> [method_id] - _ -> [] + %{input: %{bytes: <>}} -> + {:ok, method_id} = MethodIdentifier.cast(method_id) + [method_id] + + _ -> + [] end) |> Enum.into(%{}, &{&1, []}) @@ -2126,25 +2365,56 @@ defmodule Explorer.Chain.Transaction do # decode remaining transaction using methods map decoded_transactions - |> Enum.map(fn - {nil, transaction} -> - transaction - |> Map.put(:to_address, %NotLoaded{}) - |> decoded_input_data(skip_sig_provider?, opts, methods_map, smart_contract_full_abi_map) - |> format_decoded_input() + |> Enum.map( + &decode_remaining_transaction( + &1, + skip_sig_provider?, + opts, + methods_map, + smart_contract_full_abi_map + ) + ) + end - {decoded, _} -> - decoded - end) + @zetachain_non_traceable_type 88 + @doc """ + Filters out transactions that are known to not have traceable internal transactions. + """ + @spec filter_non_traceable_transactions([__MODULE__.t() | map()]) :: [__MODULE__.t() | map()] + def filter_non_traceable_transactions(transactions) do + case Application.get_env(:explorer, :chain_type) do + :zetachain -> Enum.reject(transactions, &(Map.get(&1, :type) == @zetachain_non_traceable_type)) + :zilliqa -> Enum.reject(transactions, &ZilliqaHelper.scilla_transaction?(Map.get(&1, :type))) + _ -> transactions + end end + if @chain_identity == {:optimism, :celo} do + defp decode_remaining_transaction({nil, nil}, _, _, _, _), do: nil + end + + defp decode_remaining_transaction( + {nil, transaction}, + skip_sig_provider?, + opts, + methods_map, + smart_contract_full_abi_map + ) do + transaction + |> Map.put(:to_address, %NotLoaded{}) + |> decoded_input_data(skip_sig_provider?, opts, methods_map, smart_contract_full_abi_map) + |> format_decoded_input() + end + + defp decode_remaining_transaction({decoded, _}, _, _, _, _), do: decoded + defp combine_smart_contract_full_abi_map(transactions) do # parse unique address hashes of smart-contracts from to_address and created_contract_address properties of the transactions list unique_to_address_hashes = transactions |> Enum.flat_map(fn - %Transaction{to_address: %Address{hash: hash}} -> [hash] - %Transaction{created_contract_address: %Address{hash: hash}} -> [hash] + %__MODULE__{to_address: %Address{hash: hash}} -> [hash] + %__MODULE__{created_contract_address: %Address{hash: hash}} -> [hash] _ -> [] end) |> Enum.uniq() @@ -2207,7 +2477,8 @@ defmodule Explorer.Chain.Transaction do skip_sc_check? ) do if skip_sc_check? || Address.smart_contract?(to_address) do - ExplorerHelper.add_0x_prefix(method_id) + {:ok, method_id} = MethodIdentifier.cast(method_id) + method_id |> to_string() else nil end @@ -2216,4 +2487,672 @@ defmodule Explorer.Chain.Transaction do def method_name(_, _, _) do nil end + + @doc """ + Return method id used in transaction + """ + def method_id(%{ + created_contract_address_hash: nil, + input: %{bytes: <>} + }), + do: "0x" <> Base.encode16(method_id, case: :lower) + + def method_id(_transaction), do: "0x" + + @doc """ + Fetches the revert reason of a transaction. + """ + @spec fetch_transaction_revert_reason(__MODULE__.t()) :: String.t() + def fetch_transaction_revert_reason(transaction) do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + hash_string = to_string(transaction.hash) + + response = + InternalTransaction.fetch_first_trace( + [ + %{ + block_hash: transaction.block_hash, + block_number: transaction.block_number, + hash_data: hash_string, + transaction_index: transaction.index + } + ], + json_rpc_named_arguments + ) + + revert_reason = + case response do + {:ok, first_trace_params} -> + first_trace_params |> Enum.at(0) |> Map.get(:output, %Data{bytes: <<>>}) |> to_string() + + {:error, reason} -> + Logger.error(fn -> + ["Error while fetching first trace for transaction: #{hash_string} error reason: ", inspect(reason)] + end) + + fetch_transaction_revert_reason_using_call(transaction) + + :ignore -> + fetch_transaction_revert_reason_using_call(transaction) + end + + if !is_nil(revert_reason) do + transaction + |> Changeset.change(%{revert_reason: revert_reason}) + |> Repo.update() + end + + revert_reason + end + + defp fetch_transaction_revert_reason_using_call(%__MODULE__{ + block_number: block_number, + to_address_hash: to_address_hash, + from_address_hash: from_address_hash, + input: data, + gas: gas, + gas_price: gas_price, + value: value + }) do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + req = + EthereumJSONRPCTransaction.eth_call_request( + 0, + block_number, + data, + to_address_hash, + from_address_hash, + Wei.hex_format(gas), + Wei.hex_format(gas_price), + Wei.hex_format(value) + ) + + case EthereumJSONRPC.json_rpc(req, json_rpc_named_arguments) do + {:error, error} -> + Chain.parse_revert_reason_from_error(error) + + _ -> + nil + end + end + + @default_page_size 50 + @default_paging_options %PagingOptions{page_size: @default_page_size} + @limit_showing_transactions 10_000 + + @doc """ + Returns the maximum number of transactions that can be shown in the UI. + """ + @spec limit_showing_transactions :: non_neg_integer() + def limit_showing_transactions, do: @limit_showing_transactions + + @doc """ + Returns the paged list of collated transactions that occurred recently from newest to oldest using `block_number` + and `index`. + + iex> newest_first_transactions = 50 |> insert_list(:transaction) |> with_block() |> Enum.reverse() + iex> oldest_seen = Enum.at(newest_first_transactions, 9) + iex> paging_options = %Explorer.PagingOptions{page_size: 10, key: {oldest_seen.block_number, oldest_seen.index}} + iex> recent_collated_transactions = Explorer.Chain.Transaction.recent_collated_transactions(true, paging_options: paging_options) + iex> length(recent_collated_transactions) + 10 + iex> hd(recent_collated_transactions).hash == Enum.at(newest_first_transactions, 10).hash + true + + ## Options + + * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is + `:required`, and the `t:Explorer.Chain.Transaction.t/0` has no associated record for that association, + then the `t:Explorer.Chain.Transaction.t/0` will not be included in the list. + * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` and + `:key` (a tuple of the lowest/oldest `{block_number, index}`) and. Results will be the transactions older than + the `block_number` and `index` that are passed. + + """ + @spec recent_collated_transactions(true | false, [ + Chain.paging_options() | Chain.necessity_by_association_option() | Chain.api?() + ]) :: [t()] + def recent_collated_transactions(old_ui?, options \\ []) + when is_list(options) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + method_id_filter = Keyword.get(options, :method) + type_filter = Keyword.get(options, :type) + + case !paging_options.key && method_id_filter in [nil, []] && type_filter in [nil, []] && + Transactions.atomic_take_enough(paging_options.page_size) do + transactions when is_list(transactions) -> + transactions |> Chain.select_repo(options).preload(Map.keys(necessity_by_association)) + + _ -> + fetch_recent_collated_transactions( + old_ui?, + paging_options, + necessity_by_association, + method_id_filter, + type_filter, + options + ) + end + end + + defp fetch_recent_collated_transactions( + old_ui?, + paging_options, + necessity_by_association, + method_id_filter, + type_filter, + options + ) do + case paging_options do + %PagingOptions{key: {0, 0}, is_index_in_asc_order: false} -> + [] + + _ -> + paging_options + |> fetch_transactions() + |> where([transaction], not is_nil(transaction.block_number) and not is_nil(transaction.index)) + |> apply_filter_by_method_id_to_transactions(method_id_filter) + |> apply_filter_by_type_to_transactions(type_filter) + |> Chain.join_associations(necessity_by_association) + |> put_has_token_transfers_to_transaction(old_ui?) + |> (&if(old_ui?, do: preload(&1, [{:token_transfers, [:token, :from_address, :to_address]}]), else: &1)).() + |> Chain.select_repo(options).all() + end + end + + @doc """ + Applies a filter to the query based on the method ID of the transaction. + """ + @spec apply_filter_by_method_id_to_transactions(query :: Ecto.Query.t(), filter :: list() | nil) :: Ecto.Query.t() + def apply_filter_by_method_id_to_transactions(query, nil), do: query + + def apply_filter_by_method_id_to_transactions(query, filter) when is_list(filter) do + method_ids = Enum.flat_map(filter, &map_name_or_method_id_to_method_id/1) + + if method_ids != [] do + query + |> where([transaction], fragment("SUBSTRING(? FOR 4)", transaction.input) in ^method_ids) + else + query + end + end + + def apply_filter_by_method_id_to_transactions(query, filter), + do: apply_filter_by_method_id_to_transactions(query, [filter]) + + @method_name_to_id_map %{ + "approve" => "095ea7b3", + "transfer" => "a9059cbb", + "multicall" => "5ae401dc", + "mint" => "40c10f19", + "commit" => "f14fcbc8" + } + + defp map_name_or_method_id_to_method_id(string) when is_binary(string) do + if id = @method_name_to_id_map[string] do + decode_method_id(id) + else + trimmed = + string + |> String.replace("0x", "", global: false) + + decode_method_id(trimmed) + end + end + + defp decode_method_id(method_id) when is_binary(method_id) do + case String.length(method_id) == 8 && Base.decode16(method_id, case: :mixed) do + {:ok, bytes} -> + [bytes] + + _ -> + [] + end + end + + @doc """ + Applies a filter to the query based on the type of transaction. + """ + @spec apply_filter_by_type_to_transactions(query :: Ecto.Query.t(), filter :: list()) :: Ecto.Query.t() + def apply_filter_by_type_to_transactions(query, [_ | _] = filter) do + {dynamic, modified_query} = apply_filter_by_type_to_transactions_inner(filter, query) + + modified_query + |> where(^dynamic) + end + + def apply_filter_by_type_to_transactions(query, _filter), do: query + + defp apply_filter_by_type_to_transactions_inner(dynamic \\ dynamic(false), filter, query) + + defp apply_filter_by_type_to_transactions_inner(dynamic, [type | remain], query) do + case type do + :contract_call -> + dynamic + |> filter_contract_call_dynamic() + |> apply_filter_by_type_to_transactions_inner( + remain, + join(query, :inner, [transaction], address in assoc(transaction, :to_address), as: :to_address) + ) + + :contract_creation -> + dynamic + |> filter_contract_creation_dynamic() + |> apply_filter_by_type_to_transactions_inner(remain, query) + + :coin_transfer -> + dynamic + |> filter_transaction_dynamic() + |> apply_filter_by_type_to_transactions_inner(remain, query) + + :token_transfer -> + dynamic + |> filter_token_transfer_dynamic() + |> apply_filter_by_type_to_transactions_inner(remain, query) + + :token_creation -> + dynamic + |> filter_token_creation_dynamic() + |> apply_filter_by_type_to_transactions_inner( + remain, + join(query, :inner, [transaction], token in Token, + on: token.contract_address_hash == transaction.created_contract_address_hash, + as: :created_token + ) + ) + + :blob_transaction -> + dynamic + |> filter_blob_transaction_dynamic() + |> apply_filter_by_type_to_transactions_inner(remain, query) + end + end + + defp apply_filter_by_type_to_transactions_inner(dynamic_query, _, query), do: {dynamic_query, query} + + defp filter_contract_creation_dynamic(dynamic) do + dynamic([transaction], ^dynamic or is_nil(transaction.to_address_hash)) + end + + defp filter_transaction_dynamic(dynamic) do + dynamic([transaction], ^dynamic or transaction.value > ^0) + end + + defp filter_contract_call_dynamic(dynamic) do + dynamic([transaction, to_address: to_address], ^dynamic or not is_nil(to_address.contract_code)) + end + + defp filter_token_transfer_dynamic(dynamic) do + # TokenTransfer.__struct__.__meta__.source + dynamic( + [transaction], + ^dynamic or + fragment( + "NOT (SELECT transaction_hash FROM token_transfers WHERE transaction_hash = ? LIMIT 1) IS NULL", + transaction.hash + ) + ) + end + + defp filter_token_creation_dynamic(dynamic) do + dynamic([transaction, created_token: created_token], ^dynamic or not is_nil(created_token)) + end + + defp filter_blob_transaction_dynamic(dynamic) do + # EIP-2718 blob transaction type + dynamic([transaction], ^dynamic or transaction.type == 3) + end + + @doc """ + Returns the count of available transactions shown in the UI. + + This function returns the number of transactions that have been mined and are + available for display, up to a maximum of #{@limit_showing_transactions} transactions. + + ## Returns + - The count of available transactions (non-negative integer) + """ + @spec transactions_available_count() :: non_neg_integer() + def transactions_available_count do + __MODULE__ + |> where([transaction], not is_nil(transaction.block_number) and not is_nil(transaction.index)) + |> limit(^@limit_showing_transactions) + |> Repo.aggregate(:count, :hash) + end + + @doc """ + Finds and updates replaced transactions in the database. + """ + @spec find_and_update_replaced_transactions([ + %{ + required(:nonce) => non_neg_integer, + required(:from_address_hash) => Hash.Address.t(), + required(:hash) => Hash.t() + } + ]) :: {integer(), nil | [term()]} + def find_and_update_replaced_transactions(transactions, timeout \\ :infinity) do + query = + transactions + |> Enum.reduce( + __MODULE__, + fn %{hash: hash, nonce: nonce, from_address_hash: from_address_hash}, query -> + from(t in query, + or_where: + t.nonce == ^nonce and t.from_address_hash == ^from_address_hash and t.hash != ^hash and + not is_nil(t.block_number) + ) + end + ) + # Enforce Transaction ShareLocks order (see docs: sharelocks.md) + |> order_by(asc: :hash) + |> lock("FOR NO KEY UPDATE") + + hashes = Enum.map(transactions, & &1.hash) + + transactions_to_update = + from(pending in __MODULE__, + join: duplicate in subquery(query), + on: duplicate.nonce == pending.nonce and duplicate.from_address_hash == pending.from_address_hash, + where: pending.hash in ^hashes and is_nil(pending.block_hash) + ) + + Repo.update_all(transactions_to_update, [set: [error: "dropped/replaced", status: :error]], timeout: timeout) + end + + @doc """ + Updates the replaced transactions in the database. + """ + @spec update_replaced_transactions([ + %{ + required(:nonce) => non_neg_integer, + required(:from_address_hash) => Hash.Address.t(), + required(:block_hash) => Hash.Full.t() + } + ]) :: {integer(), nil | [term()]} + def update_replaced_transactions(transactions, timeout \\ :infinity) do + filters = + transactions + |> Enum.filter(fn transaction -> + transaction.block_hash && transaction.nonce && transaction.from_address_hash + end) + |> Enum.map(fn transaction -> + {transaction.nonce, transaction.from_address_hash} + end) + |> Enum.uniq() + + if Enum.empty?(filters) do + {0, []} + else + query = + filters + |> Enum.reduce(__MODULE__, fn {nonce, from_address}, query -> + from(t in query, + or_where: + t.nonce == ^nonce and + t.from_address_hash == ^from_address and + is_nil(t.block_hash) and + (is_nil(t.error) or t.error != "dropped/replaced") + ) + end) + # Enforce Transaction ShareLocks order (see docs: sharelocks.md) + |> order_by(asc: :hash) + |> lock("FOR NO KEY UPDATE") + + Repo.update_all( + from(t in __MODULE__, join: s in subquery(query), on: t.hash == s.hash), + [set: [error: "dropped/replaced", status: :error]], + timeout: timeout + ) + end + end + + @doc """ + Streams pending transactions with the given fields. + """ + @spec stream_pending_transactions( + fields :: [ + :block_hash + | :created_contract_code_indexed_at + | :from_address_hash + | :gas + | :gas_price + | :hash + | :index + | :input + | :nonce + | :r + | :s + | :to_address_hash + | :v + | :value + ], + initial :: accumulator, + reducer :: (entry :: term(), accumulator -> accumulator), + limited? :: boolean() + ) :: {:ok, accumulator} + when accumulator: term() + def stream_pending_transactions(fields, initial, reducer, limited? \\ false) when is_function(reducer, 2) do + query = + __MODULE__ + |> pending_transactions_query() + |> select(^fields) + |> Chain.add_fetcher_limit(limited?) + + Repo.stream_reduce(query, initial, reducer) + end + + @doc """ + Query to return all pending transactions + """ + @spec pending_transactions_query(Ecto.Queryable.t()) :: Ecto.Queryable.t() + def pending_transactions_query(query) do + from(transaction in query, + where: is_nil(transaction.block_hash) and (is_nil(transaction.error) or transaction.error != "dropped/replaced") + ) + end + + @doc """ + Returns pending transactions list from the DB + """ + @spec pending_transactions_list(non_neg_integer()) :: Ecto.Schema.t() | term() + def pending_transactions_list(window_size \\ 86_400_000) do + __MODULE__ + |> pending_transactions_query() + |> where([t], t.inserted_at < ago(^window_size, "millisecond")) + |> Repo.all(timeout: :infinity) + end + + @doc """ + Return the list of pending transactions that occurred recently. + + iex> 2 |> insert_list(:transaction) + iex> :transaction |> insert() |> with_block() + iex> 8 |> insert_list(:transaction) + iex> recent_pending_transactions = Explorer.Chain.Transaction.recent_pending_transactions() + iex> length(recent_pending_transactions) + 10 + iex> Enum.all?(recent_pending_transactions, fn %Explorer.Chain.Transaction{block_hash: block_hash} -> + ...> is_nil(block_hash) + ...> end) + true + + ## Options + + * `:necessity_by_association` - use to load `t:association/0` as `:required` or `:optional`. If an association is + `:required`, and the `t:Explorer.Chain.Transaction.t/0` has no associated record for that association, + then the `t:Explorer.Chain.Transaction.t/0` will not be included in the list. + * `:paging_options` - a `t:Explorer.PagingOptions.t/0` used to specify the `:page_size` (defaults to + `#{@default_paging_options.page_size}`) and `:key` (a tuple of the lowest/oldest `{inserted_at, hash}`) and. + Results will be the transactions older than the `inserted_at` and `hash` that are passed. + + """ + @spec recent_pending_transactions([Chain.paging_options() | Chain.necessity_by_association_option()], true | false) :: + [ + __MODULE__.t() + ] + def recent_pending_transactions(options \\ [], old_ui? \\ true) + when is_list(options) do + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + method_id_filter = Keyword.get(options, :method) + type_filter = Keyword.get(options, :type) + + __MODULE__ + |> page_pending_transaction(paging_options) + |> limit(^paging_options.page_size) + |> pending_transactions_query() + |> apply_filter_by_method_id_to_transactions(method_id_filter) + |> apply_filter_by_type_to_transactions(type_filter) + |> order_by([transaction], desc: transaction.inserted_at, asc: transaction.hash) + |> Chain.join_associations(necessity_by_association) + |> (&if(old_ui?, do: preload(&1, [{:token_transfers, [:token, :from_address, :to_address]}]), else: &1)).() + |> Chain.select_repo(options).all() + end + + @doc """ + Finds all transactions of a certain block number + """ + def get_transactions_of_block_number(block_number) do + block_number + |> transactions_with_block_number() + |> Repo.all() + end + + @doc """ + Finds all transactions of a certain block numbers + """ + def get_transactions_of_block_numbers(block_numbers) do + block_numbers + |> transactions_for_block_numbers() + |> Repo.all() + end + + @doc """ + Finds transactions by hashes + """ + @spec get_transactions_by_hashes([Hash.t()]) :: [__MODULE__.t()] + def get_transactions_by_hashes(transaction_hashes) do + transaction_hashes + |> by_hashes_query() + |> Repo.all() + end + + @doc """ + Finds transactions by {block_number, index} pairs + """ + @spec get_transactions_by_block_number_index([{non_neg_integer(), non_neg_integer()}]) :: [__MODULE__.t()] + def get_transactions_by_block_number_index(block_number_index_pairs) do + block_number_index_pairs + |> by_block_number_index_query() + |> Repo.all() + end + + @doc """ + Preloads internal transactions for parent transaction records. + + When a list of transactions is provided, the function fetches all matching + internal transactions by `{block_number, transaction_index}`, applies the + requested `preloads`, and attaches the resulting list to the + `:internal_transactions` field of each parent transaction. + + The loaded internal transactions are also passed through + `InternalTransaction.preload_transaction/3` so that each internal transaction + has its parent `:transaction` populated from the already available + transaction list. + + ## Parameters + - `transactions`: A single transaction or a list of transactions + - `preloads`: Optional associations to preload for each internal transaction + - `repo`: The repo used to fetch and preload internal transactions + + ## Returns + - A list of transactions with the `:internal_transactions` field populated + - A single transaction with the `:internal_transactions` field populated + """ + @spec preload_internal_transactions( + __MODULE__.t() | [__MODULE__.t()], + list(), + module() + ) :: __MODULE__.t() | [__MODULE__.t()] + def preload_internal_transactions(transactions, address_preloads \\ [], repo \\ Repo) + + def preload_internal_transactions(transactions, address_preloads, repo) when is_list(transactions) do + block_number_index_to_internal_transactions_map = + transactions + |> Enum.map(&{&1.block_number, &1.index}) + |> Enum.uniq() + |> InternalTransaction.by_block_number_transaction_index_query() + |> repo.all() + |> InternalTransaction.preload_transaction(repo, transactions) + |> InternalTransaction.preload_addresses([address_preloads: address_preloads], repo) + |> Enum.group_by(&{&1.block_number, &1.transaction_index}) + + Enum.map( + transactions, + &Map.put( + &1, + :internal_transactions, + block_number_index_to_internal_transactions_map[{&1.block_number, &1.index}] || [] + ) + ) + end + + def preload_internal_transactions(transaction, address_preloads, repo) do + [transaction] + |> preload_internal_transactions(address_preloads, repo) + |> List.first() + end + + @doc """ + Checks if a `t:Explorer.Chain.Transaction.t/0` with the given `hash` exists. + + Returns `:ok` if found + + iex> %Transaction{hash: hash} = insert(:transaction) + iex> Explorer.Chain.Transaction.check_transaction_exists(hash) + :ok + + Returns `:not_found` if not found + + iex> {:ok, hash} = Explorer.Chain.string_to_full_hash( + ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" + ...> ) + iex> Explorer.Chain.Transaction.check_transaction_exists(hash) + :not_found + """ + @spec check_transaction_exists(Hash.Full.t()) :: :ok | :not_found + def check_transaction_exists(hash) do + hash + |> transaction_exists?() + |> Chain.boolean_to_check_result() + end + + # Checks if a `t:Explorer.Chain.Transaction.t/0` with the given `hash` exists. + + # Returns `true` if found + + # iex> %Transaction{hash: hash} = insert(:transaction) + # iex> Explorer.Chain.transaction_exists?(hash) + # true + + # Returns `false` if not found + + # iex> {:ok, hash} = Explorer.Chain.string_to_full_hash( + # ...> "0x9fc76417374aa880d4449a1f7f31ec597f00b1f6f3dd2d66f4c9c6c445836d8b" + # ...> ) + # iex> Explorer.Chain.transaction_exists?(hash) + # false + @spec transaction_exists?(Hash.Full.t()) :: boolean() + defp transaction_exists?(hash) do + query = + from( + transaction in __MODULE__, + where: transaction.hash == ^hash + ) + + Repo.exists?(query) + end end diff --git a/apps/explorer/lib/explorer/chain/transaction/fork.ex b/apps/explorer/lib/explorer/chain/transaction/fork.ex index 794d64c17360..3d77e6ec1441 100644 --- a/apps/explorer/lib/explorer/chain/transaction/fork.ex +++ b/apps/explorer/lib/explorer/chain/transaction/fork.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.Fork do @moduledoc """ A transaction fork has the same `hash` as a `t:Explorer.Chain.Transaction.t/0`, but associates that `hash` with a diff --git a/apps/explorer/lib/explorer/chain/transaction/history/historian.ex b/apps/explorer/lib/explorer/chain/transaction/history/historian.ex index d454af6717a0..f55dc8ca6e09 100644 --- a/apps/explorer/lib/explorer/chain/transaction/history/historian.ex +++ b/apps/explorer/lib/explorer/chain/transaction/history/historian.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.History.Historian do @moduledoc """ Implements behaviour Historian which will compile TransactionStats from Block/Transaction data and then save the TransactionStats into the database for later retrieval. @@ -7,6 +8,7 @@ defmodule Explorer.Chain.Transaction.History.Historian do alias Explorer.Chain.{Block, DenormalizationHelper, Transaction} alias Explorer.Chain.Block.Reader.General, as: BlockGeneralReader + alias Explorer.Chain.Cache.Counters.LastFetchedCounter alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Transaction.History.TransactionStats alias Explorer.History.Process, as: HistoryProcess @@ -111,35 +113,34 @@ defmodule Explorer.Chain.Transaction.History.Historian do select: {min(block.number), max(block.number)} ) - case Repo.one(min_max_block_query, timeout: :infinity) do - {min_block, max_block} when not is_nil(min_block) and not is_nil(max_block) -> - # Collects stats for the block range determining the given day and add - # the date determining the day to the record. - record = - min_block - |> compile_records_in_range(max_block) - |> Map.put(:date, day_to_fetch) - - records = [ - record - | records - ] - - # By making recursive calls to collect stats for every next day, eventually - # all stats for the specified number of days will be collected. - compile_records(num_days - 1, records) - - _ -> - # If it is not possible to identify the block range for the given day, - # the stats for the day are set to zero. - Logger.warning("tx/per day chart: failed to get min/max blocks through a fallback option}") - records = [%{date: day_to_fetch, number_of_transactions: 0, gas_used: 0, total_fee: 0} | records] - compile_records(num_days - 1, records) - end + compile_records_with_fallback_range(min_max_block_query, day_to_fetch, num_days, records) end end end + defp compile_records_with_fallback_range(min_max_block_query, day_to_fetch, num_days, records) do + case Repo.one(min_max_block_query, timeout: :infinity) do + {min_block, max_block} when not is_nil(min_block) and not is_nil(max_block) -> + # Collects stats for the block range determining the given day and add + # the date determining the day to the record. + record = + min_block + |> compile_records_in_range(max_block) + |> Map.put(:date, day_to_fetch) + + # By making recursive calls to collect stats for every next day, eventually + # all stats for the specified number of days will be collected. + compile_records(num_days - 1, [record | records]) + + _ -> + # If it is not possible to identify the block range for the given day, + # the stats for the day are set to zero. + Logger.warning("tx/per day chart: failed to get min/max blocks through a fallback option}") + records = [%{date: day_to_fetch, number_of_transactions: 0, gas_used: 0, total_fee: 0} | records] + compile_records(num_days - 1, records) + end + end + # Compiles transaction statistics for a given block range. # # This function aggregates data from transactions within the specified block @@ -253,6 +254,12 @@ defmodule Explorer.Chain.Transaction.History.Historian do Logger.info("tx/per day chart: number of inserted #{num_inserted}") + # we need to store the last timestamp of success to use it in Indexer.Fetcher.MultichainSearchDb.CountersFetcher + LastFetchedCounter.upsert(%{ + counter_type: transaction_stats_last_save_records_timestamp(), + value: DateTime.to_unix(DateTime.utc_now()) + }) + Publisher.broadcast(:transaction_stats) num_inserted end @@ -269,4 +276,16 @@ defmodule Explorer.Chain.Transaction.History.Historian do defp date_today do HistoryProcess.config_or_default(:utc_today, Date.utc_today(), __MODULE__) end + + @doc """ + Defines a name of counter type for LastFetchedCounter row + storing the last timestamp of when the `save_records` function was called. + + ## Returns + - The counter type name. + """ + @spec transaction_stats_last_save_records_timestamp() :: String.t() + def transaction_stats_last_save_records_timestamp do + "transaction_stats_last_save_records_timestamp" + end end diff --git a/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex b/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex index f1a7f6eb67ff..017f5726ae06 100644 --- a/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex +++ b/apps/explorer/lib/explorer/chain/transaction/history/transaction_stats.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.History.TransactionStats do @moduledoc """ Represents daily chain performance stats diff --git a/apps/explorer/lib/explorer/chain/transaction/reader.ex b/apps/explorer/lib/explorer/chain/transaction/reader.ex index 9064d135a415..e53de1f7e80e 100644 --- a/apps/explorer/lib/explorer/chain/transaction/reader.ex +++ b/apps/explorer/lib/explorer/chain/transaction/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.Reader do @moduledoc """ Functions for reading transaction data. diff --git a/apps/explorer/lib/explorer/chain/transaction/state_change.ex b/apps/explorer/lib/explorer/chain/transaction/state_change.ex index 3acf6eadc92a..e1d878e41a9f 100644 --- a/apps/explorer/lib/explorer/chain/transaction/state_change.ex +++ b/apps/explorer/lib/explorer/chain/transaction/state_change.ex @@ -1,10 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.StateChange do @moduledoc """ Helper functions and struct for storing state changes """ + use Utils.RuntimeEnvHelper, + miner_gets_burnt_fees?: [:explorer, [Explorer.Chain.Transaction, :block_miner_gets_burnt_fees?]] + alias Explorer.Chain - alias Explorer.Chain.{Address, Block, Hash, InternalTransaction, TokenTransfer, Transaction, Wei} + + alias Explorer.Chain.{ + Address, + Block, + DenormalizationHelper, + Hash, + InternalTransaction, + TokenTransfer, + Transaction, + Wei + } + alias Explorer.Chain.Transaction.StateChange defstruct [:coin_or_token_transfers, :address, :token_id, :balance_before, :balance_after, :balance_diff, :miner?] @@ -21,8 +36,6 @@ defmodule Explorer.Chain.Transaction.StateChange do @type coin_balances_map :: %{Hash.Address.t() => {Address.t(), Wei.t()}} - @zero_wei %Wei{value: Decimal.new(0)} - @spec coin_balances_before(Transaction.t(), [Transaction.t()], coin_balances_map()) :: coin_balances_map() def coin_balances_before(transaction, block_transactions, coin_balances_before_block) do block = transaction.block @@ -57,8 +70,12 @@ defmodule Explorer.Chain.Transaction.StateChange do end end - defp update_coin_balances_from_internal_transaction(%InternalTransaction{call_type: :delegatecall}, coin_balances), - do: coin_balances + defp update_coin_balances_from_internal_transaction( + %InternalTransaction{call_type: call_type, call_type_enum: call_type_enum}, + coin_balances + ) + when :delegatecall in [call_type, call_type_enum], + do: coin_balances defp update_coin_balances_from_internal_transaction(%InternalTransaction{index: 0}, coin_balances), do: coin_balances @@ -96,36 +113,48 @@ defmodule Explorer.Chain.Transaction.StateChange do end defp token_transfers_balances_reducer(transfer, state_balances_map, include_transfers) do - from = transfer.from_address - to = transfer.to_address - token = transfer.token_contract_address_hash - - state_balances_map - |> case do - # from address is needed to be updated in our map - %{^from => %{^token => values}} = balances_map -> - put_in( - balances_map, - Enum.map([from, token], &Access.key(&1, %{})), - do_update_balance(values, :from, transfer, include_transfers) - ) - - # we are not interested in this address - balances_map -> - balances_map - end - |> case do - # to address is needed to be updated in our map - %{^to => %{^token => values}} = balances_map -> - put_in( - balances_map, - Enum.map([to, token], &Access.key(&1, %{})), - do_update_balance(values, :to, transfer, include_transfers) - ) - - # we are not interested in this address - balances_map -> - balances_map + token_type = + if DenormalizationHelper.tt_denormalization_finished?() do + transfer.token_type + else + transfer.token && transfer.token.type + end + + # Skip ERC-7984 (confidential) transfers - we can't track encrypted balances + if token_type == "ERC-7984" do + state_balances_map + else + from = transfer.from_address + to = transfer.to_address + token = transfer.token_contract_address_hash + + state_balances_map + |> case do + # from address is needed to be updated in our map + %{^from => %{^token => values}} = balances_map -> + put_in( + balances_map, + Enum.map([from, token], &Access.key(&1, %{})), + do_update_balance(values, :from, transfer, include_transfers) + ) + + # we are not interested in this address + balances_map -> + balances_map + end + |> case do + # to address is needed to be updated in our map + %{^to => %{^token => values}} = balances_map -> + put_in( + balances_map, + Enum.map([to, token], &Access.key(&1, %{})), + do_update_balance(values, :to, transfer, include_transfers) + ) + + # we are not interested in this address + balances_map -> + balances_map + end end end @@ -138,7 +167,14 @@ defmodule Explorer.Chain.Transaction.StateChange do end defp do_update_balance(old_val, type, transfer, _) do - token_ids = if transfer.token.type == "ERC-1155", do: transfer.token_ids, else: [nil] + token_type = + if DenormalizationHelper.tt_denormalization_finished?() do + transfer.token_type + else + transfer.token.type + end + + token_ids = if token_type == "ERC-1155", do: transfer.token_ids, else: [nil] transfer_amounts = transfer.amounts || [transfer.amount || 1] sub_or_add = @@ -173,7 +209,7 @@ defmodule Explorer.Chain.Transaction.StateChange do end def from_loss(%InternalTransaction{} = transaction) do - transaction.value + transaction.value || Wei.zero() end @doc """ @@ -183,18 +219,30 @@ defmodule Explorer.Chain.Transaction.StateChange do @spec to_profit(Transaction.t() | InternalTransaction.t()) :: Wei.t() def to_profit(%Transaction{} = transaction) do if error?(transaction) do - %Wei{value: 0} + Wei.zero() else transaction.value end end def to_profit(%InternalTransaction{} = transaction) do - transaction.value + transaction.value || Wei.zero() end + # Calculates block miner profit for the given transaction. + # + # Typically that is priority fee, but if `BLOCK_MINER_GETS_BURNT_FEES` is enabled, + # the profit is the sum of the priority fee and the fee which would be burnt. + # + # ## Parameters + # - `transaction`: The transaction entity containing info needed to calculate the profit. + # - `block`: The block entity containing info needed to calculate the profit. + # + # ## Returns + # - The miner profit amount in Wei. + @spec miner_profit(Transaction.t(), Block.t()) :: Wei.t() defp miner_profit(transaction, block) do - base_fee_per_gas = block.base_fee_per_gas || %Wei{value: Decimal.new(0)} + base_fee_per_gas = block.base_fee_per_gas || Wei.zero() max_priority_fee_per_gas = transaction.max_priority_fee_per_gas || transaction.gas_price max_fee_per_gas = transaction.max_fee_per_gas || transaction.gas_price @@ -203,7 +251,16 @@ defmodule Explorer.Chain.Transaction.StateChange do Wei.to(x, :wei) end) - Wei.mult(priority_fee_per_gas, transaction.gas_used) + burnt_fees_for_miner = + case miner_gets_burnt_fees?() && Transaction.burnt_fees(transaction.gas_used, max_fee_per_gas, base_fee_per_gas) do + false -> Wei.zero() + nil -> Wei.zero() + value -> value + end + + priority_fee_per_gas + |> Wei.mult(transaction.gas_used) + |> Wei.sum(burnt_fees_for_miner) end defp error?(transaction) do @@ -277,7 +334,7 @@ defmodule Explorer.Chain.Transaction.StateChange do defp update_balance(coin_balances, address_hash, update_function) do if Map.has_key?(coin_balances, address_hash) do - Map.update(coin_balances, address_hash, @zero_wei, fn {address, balance} -> + Map.update(coin_balances, address_hash, Wei.zero(), fn {address, balance} -> {address, update_function.(balance)} end) else @@ -301,7 +358,10 @@ defmodule Explorer.Chain.Transaction.StateChange do balance_diff = Decimal.sub(balance, balance_before) transfer = elem(List.first(transfers), 1) - if transfer.token.type != "ERC-20" or has_diff?(balance_diff) do + token_type = + if DenormalizationHelper.tt_denormalization_finished?(), do: transfer.token_type, else: transfer.token.type + + if token_type not in ["ERC-20", "ZRC-2"] or has_diff?(balance_diff) do %StateChange{ coin_or_token_transfers: transfers, address: address, diff --git a/apps/explorer/lib/explorer/chain/transaction/status.ex b/apps/explorer/lib/explorer/chain/transaction/status.ex index df4e56597f5f..ff6a0b7ca8be 100644 --- a/apps/explorer/lib/explorer/chain/transaction/status.ex +++ b/apps/explorer/lib/explorer/chain/transaction/status.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.Status do @moduledoc """ Whether a transaction succeeded (`:ok`) or failed (`:error`). diff --git a/apps/explorer/lib/explorer/chain/transaction_action.ex b/apps/explorer/lib/explorer/chain/transaction_action.ex deleted file mode 100644 index 795892a6f167..000000000000 --- a/apps/explorer/lib/explorer/chain/transaction_action.ex +++ /dev/null @@ -1,76 +0,0 @@ -defmodule Explorer.Chain.TransactionAction do - @moduledoc "Models transaction action." - - use Explorer.Schema - - alias Explorer.Chain.{ - Hash, - Transaction - } - - @required_attrs ~w(hash protocol data type log_index)a - @supported_protocols [:uniswap_v3, :opensea_v1_1, :wrapping, :approval, :zkbob, :aave_v3] - - @typedoc """ - * `hash` - transaction hash - * `protocol` - name of the action protocol (see possible values for Enum of the db table field) - * `data` - transaction action details (json formatted) - * `type` - type of the action protocol (see possible values for Enum of the db table field) - * `log_index` - index of the action for sorting (taken from log.index) - """ - @primary_key false - typed_schema "transaction_actions" do - field(:protocol, Ecto.Enum, values: @supported_protocols, null: false) - field(:data, :map, null: false) - - field(:type, Ecto.Enum, - values: [ - :mint_nft, - :mint, - :burn, - :collect, - :swap, - :sale, - :cancel, - :transfer, - :wrap, - :unwrap, - :approve, - :revoke, - :withdraw, - :deposit, - :borrow, - :supply, - :repay, - :flash_loan, - :enable_collateral, - :disable_collateral, - :liquidation_call - ], - null: false - ) - - field(:log_index, :integer, primary_key: true, null: false) - - belongs_to(:transaction, Transaction, - foreign_key: :hash, - primary_key: true, - references: :hash, - type: Hash.Full, - null: false - ) - - timestamps() - end - - def changeset(%__MODULE__{} = transaction_actions, attrs \\ %{}) do - transaction_actions - |> cast(attrs, @required_attrs) - |> validate_required(@required_attrs) - |> foreign_key_constraint(:hash) - end - - def supported_protocols do - @supported_protocols - end -end diff --git a/apps/explorer/lib/explorer/chain/transaction_error.ex b/apps/explorer/lib/explorer/chain/transaction_error.ex new file mode 100644 index 000000000000..54627dd03d78 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/transaction_error.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.TransactionError do + @moduledoc """ + Stores errors for transactions and internal transactions. + """ + + use Explorer.Schema + + alias Explorer.Repo + + typed_schema "transaction_errors" do + field(:message, :string, null: false) + + timestamps(updated_at: false) + end + + def changeset(%__MODULE__{} = transaction_error, attrs) do + cast(transaction_error, attrs, [:message]) + end + + def id_to_error(nil), do: nil + + def id_to_error(id) do + __MODULE__ + |> where([te], te.id == ^id) + |> select([te], te.message) + |> Repo.one() + end + + def find_or_create(error_message) do + [error_message] + |> find_or_create_multiple() + |> Map.get(error_message) + end + + def find_or_create_multiple(error_messages) do + error_messages + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + |> do_find_or_create_multiple() + end + + defp do_find_or_create_multiple([]), do: %{} + + defp do_find_or_create_multiple(filtered_error_messages) do + existing_map = + __MODULE__ + |> where([te], te.message in ^filtered_error_messages) + |> select([te], {te.message, te.id}) + |> Repo.all() + |> Map.new() + + missing_errors = filtered_error_messages -- Map.keys(existing_map) + + now = DateTime.utc_now() + insert_params = Enum.map(missing_errors, &%{message: &1, inserted_at: now}) + + {_total, inserted} = + Repo.insert_all(__MODULE__, insert_params, + on_conflict: {:replace, [:inserted_at]}, + conflict_target: [:message], + returning: true + ) + + new_records_map = Map.new(inserted, &{&1.message, &1.id}) + + Map.merge(existing_map, new_records_map) + end +end diff --git a/apps/explorer/lib/explorer/chain/user_operation.ex b/apps/explorer/lib/explorer/chain/user_operation.ex index cdcb322e964d..854bd426f15c 100644 --- a/apps/explorer/lib/explorer/chain/user_operation.ex +++ b/apps/explorer/lib/explorer/chain/user_operation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.UserOperation do @moduledoc """ The representation of a user operation for account abstraction (EIP-4337). diff --git a/apps/explorer/lib/explorer/chain/validator.ex b/apps/explorer/lib/explorer/chain/validator.ex index 76447d851921..d917abaec4f2 100644 --- a/apps/explorer/lib/explorer/chain/validator.ex +++ b/apps/explorer/lib/explorer/chain/validator.ex @@ -1,11 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Validator do @moduledoc """ Tracks info about POA validator """ use Explorer.Schema - alias Explorer.Chain.Hash.Address alias Explorer.{Chain, Repo} + alias Explorer.Chain.Hash.Address @primary_key false typed_schema "validators" do diff --git a/apps/explorer/lib/explorer/chain/wei.ex b/apps/explorer/lib/explorer/chain/wei.ex index 555476433708..bb2cd0fba94f 100644 --- a/apps/explorer/lib/explorer/chain/wei.ex +++ b/apps/explorer/lib/explorer/chain/wei.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Wei do @moduledoc """ The smallest fractional unit of Ether. Using wei instead of ether allows code to do integer match instead of using @@ -315,6 +316,15 @@ defmodule Explorer.Chain.Wei do @spec to(t(), :wei) :: wei() | nil def to(nil, :wei), do: nil def to(%__MODULE__{value: wei}, :wei), do: wei + + @doc """ + Returns zero Wei value. + + ## Returns + - Zero Wei value. + """ + @spec zero() :: __MODULE__.t() + def zero, do: %__MODULE__{value: Decimal.new(0)} end defimpl Inspect, for: Explorer.Chain.Wei do diff --git a/apps/explorer/lib/explorer/chain/withdrawal.ex b/apps/explorer/lib/explorer/chain/withdrawal.ex index ecba16f92ffd..02af2fcceb79 100644 --- a/apps/explorer/lib/explorer/chain/withdrawal.ex +++ b/apps/explorer/lib/explorer/chain/withdrawal.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Withdrawal do @moduledoc """ A stored representation of withdrawal introduced in [EIP-4895](https://eips.ethereum.org/EIPS/eip-4895) @@ -6,6 +7,7 @@ defmodule Explorer.Chain.Withdrawal do use Explorer.Schema alias Explorer.Chain.{Address, Block, Hash, Wei} + alias Explorer.Chain.Cache.Counters.{LastFetchedCounter, WithdrawalsSum} alias Explorer.PagingOptions @required_attrs ~w(index validator_index amount address_hash block_hash)a @@ -103,4 +105,35 @@ defmodule Explorer.Chain.Withdrawal do order_by: [desc: :index] ) end + + @doc """ + Upserts the count of all withdrawals into the `last_fetched_counters` table. + """ + @spec upsert_count_withdrawals(non_neg_integer()) :: {:ok, any()} | {:error, any()} + def upsert_count_withdrawals(index) do + current_value = LastFetchedCounter.get("withdrawals_count") + + if index > current_value do + LastFetchedCounter.upsert(%{ + counter_type: "withdrawals_count", + value: index + }) + end + end + + @doc """ + Fetches the count of withdrawals from the cache and adds 1 to it. + """ + @spec count_withdrawals_from_cache(Keyword.t()) :: Decimal.t() + def count_withdrawals_from_cache(options \\ []) do + "withdrawals_count" |> LastFetchedCounter.get(options) |> Decimal.add(1) + end + + @doc """ + Fetches the sum of withdrawals from the cache. + """ + @spec sum_withdrawals_from_cache(Keyword.t()) :: Decimal.t() | nil + def sum_withdrawals_from_cache(options \\ []) do + WithdrawalsSum.fetch(options) + end end diff --git a/apps/explorer/lib/explorer/chain/zilliqa/aggregate_quorum_certificate.ex b/apps/explorer/lib/explorer/chain/zilliqa/aggregate_quorum_certificate.ex index f26936009f65..ee66b6cb53f6 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/aggregate_quorum_certificate.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/aggregate_quorum_certificate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.AggregateQuorumCertificate do @moduledoc """ A stored representation of a Zilliqa aggregate quorum certificate in the diff --git a/apps/explorer/lib/explorer/chain/zilliqa/hash/bls_public_key.ex b/apps/explorer/lib/explorer/chain/zilliqa/hash/bls_public_key.ex index a0f16b6eef46..ea6eb3973176 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/hash/bls_public_key.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/hash/bls_public_key.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.Hash.BLSPublicKey do @moduledoc """ Staker's `blsPubKey` diff --git a/apps/explorer/lib/explorer/chain/zilliqa/hash/peer_id.ex b/apps/explorer/lib/explorer/chain/zilliqa/hash/peer_id.ex index 5ad612b059f5..0f00bbbfd013 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/hash/peer_id.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/hash/peer_id.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.Hash.PeerID do @moduledoc """ libp2p peer ID, corresponding to the staker's `blsPubKey` diff --git a/apps/explorer/lib/explorer/chain/zilliqa/hash/signature.ex b/apps/explorer/lib/explorer/chain/zilliqa/hash/signature.ex index 44c4253f990b..191e02b9de8e 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/hash/signature.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/hash/signature.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.Hash.Signature do @moduledoc """ A 96-byte BLS signature of the supermajority of the validators. diff --git a/apps/explorer/lib/explorer/chain/zilliqa/helper.ex b/apps/explorer/lib/explorer/chain/zilliqa/helper.ex index 748ae1f1e059..514c695a667b 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/helper.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.Helper do @moduledoc """ Common helper functions for Zilliqa. diff --git a/apps/explorer/lib/explorer/chain/zilliqa/nested_quorum_certificate.ex b/apps/explorer/lib/explorer/chain/zilliqa/nested_quorum_certificate.ex index 8edd55a14813..1d7647db85c9 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/nested_quorum_certificate.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/nested_quorum_certificate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.NestedQuorumCertificate do @moduledoc """ A stored representation of a nested quorum certificate in Zilliqa's PBFT diff --git a/apps/explorer/lib/explorer/chain/zilliqa/quorum_certificate.ex b/apps/explorer/lib/explorer/chain/zilliqa/quorum_certificate.ex index 61235bae2699..b608aafd2fbb 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/quorum_certificate.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/quorum_certificate.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.QuorumCertificate do @moduledoc """ A stored representation of a Zilliqa quorum certificate in the context of PBFT diff --git a/apps/explorer/lib/explorer/chain/zilliqa/reader.ex b/apps/explorer/lib/explorer/chain/zilliqa/reader.ex index 8a65532ee069..648d368e0d26 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/reader.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.Reader do @moduledoc """ Reads Zilliqa-related data from the database. @@ -39,7 +40,7 @@ defmodule Explorer.Chain.Zilliqa.Reader do a in Address, join: t in Transaction, on: a.hash == t.created_contract_address_hash, - where: t.v == 0 and not is_nil(a.contract_code) and a.verified == false, + where: t.status == :ok and t.v == 0 and not is_nil(a.contract_code) and a.verified == false, order_by: [desc: t.block_number], select: a ) diff --git a/apps/explorer/lib/explorer/chain/zilliqa/staker.ex b/apps/explorer/lib/explorer/chain/zilliqa/staker.ex index 36b7722f1d7e..bd4f178464f2 100644 --- a/apps/explorer/lib/explorer/chain/zilliqa/staker.ex +++ b/apps/explorer/lib/explorer/chain/zilliqa/staker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Zilliqa.Staker do @moduledoc """ Represents Zilliqa staker (i.e. validator) in the database. This is the @@ -99,7 +100,7 @@ defmodule Explorer.Chain.Zilliqa.Staker do def paginated_active_stakers(options \\ []) do paging_options = Keyword.get(options, :paging_options, Chain.default_paging_options()) necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) - sorting = Keyword.get(options, :sorting, []) + sorting = Keyword.get(options, :sorting_options, []) active_stakers_query() |> Chain.join_associations(necessity_by_association) @@ -113,6 +114,6 @@ defmodule Explorer.Chain.Zilliqa.Staker do """ @spec next_page_params(t()) :: map() def next_page_params(%__MODULE__{index: index}) do - %{"index" => index} + %{index: index} end end diff --git a/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_adapter.ex b/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_adapter.ex new file mode 100644 index 000000000000..9f6fb7cb238c --- /dev/null +++ b/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_adapter.ex @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Zilliqa.Zrc2.TokenAdapter do + @moduledoc """ + Represents a list of `ERC-20 adapter contract address <-> ZRC-2 token contract address` pairs. + + Changes in the schema should be reflected in the bulk import module: + - `Explorer.Chain.Import.Runner.Zilliqa.Zrc2.TokenAdapters` + """ + use Explorer.Schema + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Address, Hash} + + @required_attrs ~w(zrc2_address_hash adapter_address_hash)a + + @typedoc """ + * `zrc2_address_hash` - The ZRC-2 token contract address hash. + * `zrc2_address` - An instance of `Explorer.Chain.Address` referenced by `zrc2_address_hash`. + * `adapter_address_hash` - The ERC-20 adapter contract address hash. + * `adapter_address` - An instance of `Explorer.Chain.Address` referenced by `adapter_address_hash`. + """ + @primary_key false + typed_schema "zilliqa_zrc2_token_adapters" do + belongs_to(:zrc2_address, Address, + foreign_key: :zrc2_address_hash, + references: :hash, + type: Hash.Address, + null: false + ) + + belongs_to(:adapter_address, Address, + foreign_key: :adapter_address_hash, + primary_key: true, + references: :hash, + type: Hash.Address, + null: false + ) + + timestamps() + end + + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = pair, attrs) do + pair + |> cast(attrs, @required_attrs) + |> validate_required(@required_attrs) + |> foreign_key_constraint(:zrc2_address_hash) + |> foreign_key_constraint(:adapter_address_hash) + |> unique_constraint(:adapter_address_hash) + end + + @doc """ + Builds `%{zrc2_address_hash => adapter_address_hash}` map for the given ZRC-2 address hashes. + If adapter address is not found for some ZRC-2 address, the corresponding key will be absent in the map. + + ## Parameters + - `zrc2_address_hashes`: The list of ZRC-2 address hashes. + + ## Returns + - `%{zrc2_address_hash => adapter_address_hash}` map. The map can be empty. + """ + @spec adapter_address_hash_by_zrc2_address_hash([Hash.t()]) :: %{Hash.t() => Hash.t()} + def adapter_address_hash_by_zrc2_address_hash([]), do: %{} + + def adapter_address_hash_by_zrc2_address_hash(zrc2_address_hashes) do + query = + from(a in __MODULE__, + select: {a.zrc2_address_hash, a.adapter_address_hash}, + where: a.zrc2_address_hash in ^zrc2_address_hashes + ) + + query + |> Repo.all(timeout: :infinity) + |> Enum.into(%{}) + end + + @doc """ + Returns ZRC-2 address hash for the given adapter address hash. + + ## Parameters + - `adapter_address_hash`: The adapter address hash. + - `options`: A keyword list of options that may include whether to use a replica database. + + ## Returns + - ZRC-2 address hash for the adapter address hash. + - `nil` if the ZRC-2 address hash is not found. + """ + @spec adapter_address_hash_to_zrc2_address_hash(Hash.t(), [Chain.api?()]) :: Hash.t() | nil + def adapter_address_hash_to_zrc2_address_hash(adapter_address_hash, options \\ []) do + query = + from(a in __MODULE__, + select: a.zrc2_address_hash, + where: a.adapter_address_hash == ^adapter_address_hash + ) + + Chain.select_repo(options).one(query) + end +end diff --git a/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex b/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex new file mode 100644 index 000000000000..d0629a12a668 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/zilliqa/zrc2/token_transfer.ex @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Zilliqa.Zrc2.TokenTransfer do + @moduledoc """ + Represents a token transfer between addresses for a given ZRC-2 token with unknown ERC-20 adapter contract address yet. + + Changes in the schema should be reflected in the bulk import module: + - Explorer.Chain.Import.Runner.Zilliqa.Zrc2.TokenTransfers + """ + + use Explorer.Schema + + import Ecto.Changeset + + alias Explorer.Chain.{Address, Block, Data, Hash, Log, Transaction} + alias Explorer.Chain.Zilliqa.Zrc2.TokenAdapter + alias Explorer.Repo + + @typedoc """ + * `:transaction_hash` - Transaction foreign key. + * `:transaction` - The `t:Explorer.Chain.Transaction.t/0` ledger. + * `:log_index` - Index of the corresponding `t:Explorer.Chain.Log.t/0` in the block. + * `:from_address_hash` - Address hash foreign key. + * `:from_address` - The `t:Explorer.Chain.Address.t/0` that sent the tokens. + * `:to_address_hash` - Address hash foreign key. + * `:to_address` - The `t:Explorer.Chain.Address.t/0` that received the tokens. + * `:amount` - The token transferred amount. + * `:zrc2_address_hash` - Address hash foreign key. + * `:zrc2_address` - The `t:Explorer.Chain.Address.t/0` of the token's contract. + * `:block_number` - The block number that the transfer took place in. + * `:block_hash` - The hash of the block. + """ + @primary_key false + typed_schema "zilliqa_zrc2_token_transfers" do + belongs_to(:transaction, Transaction, + foreign_key: :transaction_hash, + primary_key: true, + references: :hash, + type: Hash.Full, + null: false + ) + + field(:log_index, :integer, primary_key: true, null: false) + + belongs_to(:from_address, Address, + foreign_key: :from_address_hash, + references: :hash, + type: Hash.Address, + null: false + ) + + belongs_to(:to_address, Address, + foreign_key: :to_address_hash, + references: :hash, + type: Hash.Address, + null: false + ) + + field(:amount, :decimal) + + belongs_to( + :zrc2_address, + Address, + foreign_key: :zrc2_address_hash, + references: :hash, + type: Hash.Address, + null: false + ) + + field(:block_number, :integer) + + belongs_to(:block, Block, + foreign_key: :block_hash, + primary_key: true, + references: :hash, + type: Hash.Full, + null: false + ) + + timestamps() + end + + @required_attrs ~w(transaction_hash log_index from_address_hash to_address_hash amount zrc2_address_hash block_number block_hash)a + + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = transfer, attrs) do + transfer + |> cast(attrs, @required_attrs) + |> validate_required(@required_attrs) + |> foreign_key_constraint(:transaction_hash) + |> unique_constraint([:transaction_hash, :log_index, :block_hash]) + end + + @doc """ + Gets all transfer logs from the `logs` table for the given block. + This function is only used by the historic handler. + + ## Parameters + - `block_number`: The block number. + - `transfer_events`: The list of transfer logs (their signatures) needed to be found. + + ## Returns + - A list of found log maps for the given block. + Each returned log can have the corresponding non-empty `adapter_address_hash` field. + """ + @spec read_block_logs(non_neg_integer(), [String.t()]) :: [ + %{ + first_topic: Hash.t(), + data: Data.t(), + address_hash: Hash.t(), + transaction_hash: Hash.t(), + index: non_neg_integer(), + block_number: non_neg_integer(), + block_hash: Hash.t(), + adapter_address_hash: Hash.t() | nil + } + ] + def read_block_logs(block_number, transfer_events) do + Repo.all( + from( + l in Log, + inner_join: b in Block, + on: b.hash == l.block_hash and b.consensus == true, + left_join: a in TokenAdapter, + on: a.zrc2_address_hash == l.address_hash, + where: l.block_number == ^block_number and l.first_topic in ^transfer_events, + select: %{ + first_topic: l.first_topic, + data: l.data, + address_hash: l.address_hash, + transaction_hash: l.transaction_hash, + index: l.index, + block_number: l.block_number, + block_hash: l.block_hash, + adapter_address_hash: a.adapter_address_hash + } + ), + timeout: :infinity + ) + end + + @doc """ + Gets a list of transactions with necessary data (such as transaction hash, input, and `to` address hash) + by the list of logs prepared with the `read_block_logs` function. This function is only used by the + historic handler. + + We only need the transactions having the `TransferSuccess` event with unknown ZRC-2 adapter address. + + ## Parameters + - `logs`: The list of logs. + - `zrc2_transfer_success_event`: The signature of the `TransferSuccess` event. + + ## Returns + - A list of transaction maps for the given list of logs. + """ + @spec read_transfer_transactions( + [%{first_topic: Hash.t(), transaction_hash: Hash.t(), adapter_address_hash: Hash.t() | nil}], + String.t() + ) :: [%{hash: Hash.t(), input: Data.t(), to_address_hash: Hash.t()}] + def read_transfer_transactions(logs, zrc2_transfer_success_event) do + transaction_hashes = + logs + |> Enum.filter( + &(Hash.to_string(&1.first_topic) == zrc2_transfer_success_event and is_nil(&1.adapter_address_hash)) + ) + |> Enum.map(& &1.transaction_hash) + + Repo.all( + from( + t in Transaction, + where: t.hash in ^transaction_hashes, + select: %{ + hash: t.hash, + input: t.input, + to_address_hash: t.to_address_hash + } + ), + timeout: :infinity + ) + end + + @doc """ + Scans the `zilliqa_zrc2_token_transfers` table for the rows that have corresponding + adapter addresses in the `zilliqa_zrc2_token_adapters` table and returns the found rows. + + ## Returns + - The list of found rows. The list can be empty. + """ + @spec zrc2_token_transfers_having_adapter() :: [ + %{ + transaction_hash: Hash.t(), + log_index: non_neg_integer(), + from_address_hash: Hash.t(), + to_address_hash: Hash.t(), + amount: Decimal.t(), + adapter_address_hash: Hash.t(), + block_number: non_neg_integer(), + block_hash: Hash.t() + } + ] + def zrc2_token_transfers_having_adapter do + query = + from( + ztt in __MODULE__, + inner_join: a in TokenAdapter, + on: a.zrc2_address_hash == ztt.zrc2_address_hash, + select: %{ + transaction_hash: ztt.transaction_hash, + log_index: ztt.log_index, + from_address_hash: ztt.from_address_hash, + to_address_hash: ztt.to_address_hash, + amount: ztt.amount, + adapter_address_hash: a.adapter_address_hash, + block_number: ztt.block_number, + block_hash: ztt.block_hash + } + ) + + query + |> Repo.all(timeout: :infinity) + end +end diff --git a/apps/explorer/lib/explorer/chain/zksync/batch_block.ex b/apps/explorer/lib/explorer/chain/zksync/batch_block.ex index 08c9be6912d0..de4c59ca9f6c 100644 --- a/apps/explorer/lib/explorer/chain/zksync/batch_block.ex +++ b/apps/explorer/lib/explorer/chain/zksync/batch_block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ZkSync.BatchBlock do @moduledoc "Models a list of blocks related to a batch for ZkSync." diff --git a/apps/explorer/lib/explorer/chain/zksync/batch_transaction.ex b/apps/explorer/lib/explorer/chain/zksync/batch_transaction.ex index d9c95f42f6b4..5caea124b07c 100644 --- a/apps/explorer/lib/explorer/chain/zksync/batch_transaction.ex +++ b/apps/explorer/lib/explorer/chain/zksync/batch_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ZkSync.BatchTransaction do @moduledoc """ Models a list of transactions related to a batch for ZkSync. diff --git a/apps/explorer/lib/explorer/chain/zksync/lifecycle_transaction.ex b/apps/explorer/lib/explorer/chain/zksync/lifecycle_transaction.ex index cc2ec207a6a2..0765e4c11011 100644 --- a/apps/explorer/lib/explorer/chain/zksync/lifecycle_transaction.ex +++ b/apps/explorer/lib/explorer/chain/zksync/lifecycle_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ZkSync.LifecycleTransaction do @moduledoc "Models an L1 lifecycle transaction for ZkSync." diff --git a/apps/explorer/lib/explorer/chain/zksync/reader.ex b/apps/explorer/lib/explorer/chain/zksync/reader.ex index f16e124d50d7..c66702ca2143 100644 --- a/apps/explorer/lib/explorer/chain/zksync/reader.ex +++ b/apps/explorer/lib/explorer/chain/zksync/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ZkSync.Reader do @moduledoc "Contains read functions for zksync modules." diff --git a/apps/explorer/lib/explorer/chain/zksync/transaction_batch.ex b/apps/explorer/lib/explorer/chain/zksync/transaction_batch.ex index d19f22af61c3..be7f84bd29e9 100644 --- a/apps/explorer/lib/explorer/chain/zksync/transaction_batch.ex +++ b/apps/explorer/lib/explorer/chain/zksync/transaction_batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ZkSync.TransactionBatch do @moduledoc "Models a batch of transactions for ZkSync." diff --git a/apps/explorer/lib/explorer/chain_spec/genesis_data.ex b/apps/explorer/lib/explorer/chain_spec/genesis_data.ex index 7fa2a5ddcb60..5bf9bf4a1ed6 100644 --- a/apps/explorer/lib/explorer/chain_spec/genesis_data.ex +++ b/apps/explorer/lib/explorer/chain_spec/genesis_data.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ChainSpec.GenesisData do @moduledoc """ Handles the genesis data import. @@ -14,8 +15,8 @@ defmodule Explorer.ChainSpec.GenesisData do alias Explorer.Chain.SmartContract alias Explorer.ChainSpec.Geth.Importer, as: GethImporter alias Explorer.ChainSpec.Parity.Importer - alias Explorer.Helper - alias HTTPoison.Response + alias Explorer.HttpClient + alias Utils.ConfigHelper, as: UtilsConfigHelper def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) @@ -155,7 +156,7 @@ defmodule Explorer.ChainSpec.GenesisData do # Retrieves a JSON data from either a file or URL based on the source. @spec fetch_spec_as_json(binary()) :: {:ok, list() | map()} | {:error, any()} defp fetch_spec_as_json(path) do - if Helper.valid_url?(path) do + if UtilsConfigHelper.valid_url?(path) do fetch_from_url(path) else fetch_from_file(path) @@ -172,10 +173,10 @@ defmodule Explorer.ChainSpec.GenesisData do end # Fetches JSON data from a provided URL. - @spec fetch_from_url(binary()) :: {:ok, list() | map()} | {:error, Jason.DecodeError.t() | HTTPoison.Error.t()} + @spec fetch_from_url(binary()) :: {:ok, list() | map()} | {:error, Jason.DecodeError.t() | any()} defp fetch_from_url(url) do - case HTTPoison.get(url) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url, [], timeout: 60_000, recv_timeout: 60_000) do + {:ok, %{body: body, status_code: 200}} -> {:ok, Jason.decode!(body)} reason -> @@ -205,7 +206,7 @@ defmodule Explorer.ChainSpec.GenesisData do # Resulting spec will be handled by Explorer.ChainSpec.Geth.Importer defp extend_chain_spec(chain_spec, precompiles_config, variant) - when is_list(chain_spec) and variant == EthereumJSONRPC.Geth do + when is_list(chain_spec) and variant in [EthereumJSONRPC.Geth, EthereumJSONRPC.Besu] do precompiles_as_map = precompiles_config |> Enum.reduce(%{}, fn contract, acc -> @@ -227,7 +228,7 @@ defmodule Explorer.ChainSpec.GenesisData do # Resulting spec will be handled by Explorer.ChainSpec.Geth.Importer defp extend_chain_spec(%{"genesis" => sub_entity} = chain_spec, precompiles_config, variant) - when variant == EthereumJSONRPC.Geth do + when variant in [EthereumJSONRPC.Geth, EthereumJSONRPC.Besu] do updated_sub_entity = extend_chain_spec(sub_entity, precompiles_config, variant) Map.put(chain_spec, "genesis", updated_sub_entity) @@ -235,7 +236,7 @@ defmodule Explorer.ChainSpec.GenesisData do # Resulting spec will be handled by Explorer.ChainSpec.Geth.Importer defp extend_chain_spec(chain_spec, precompiles_config, variant) - when is_map(chain_spec) and variant == EthereumJSONRPC.Geth do + when is_map(chain_spec) and variant in [EthereumJSONRPC.Geth, EthereumJSONRPC.Besu] do accounts = case chain_spec["alloc"] do nil -> %{} @@ -275,7 +276,7 @@ defmodule Explorer.ChainSpec.GenesisData do defp import_genesis_accounts(chain_spec, variant) do if not Enum.empty?(chain_spec) do case variant do - EthereumJSONRPC.Geth -> + variant when variant in [EthereumJSONRPC.Geth, EthereumJSONRPC.Besu] -> {:ok, _} = GethImporter.import_genesis_accounts(chain_spec) _ -> diff --git a/apps/explorer/lib/explorer/chain_spec/geth/importer.ex b/apps/explorer/lib/explorer/chain_spec/geth/importer.ex index 1a7e86d2bffe..d4cbc47ebe76 100644 --- a/apps/explorer/lib/explorer/chain_spec/geth/importer.ex +++ b/apps/explorer/lib/explorer/chain_spec/geth/importer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ChainSpec.Geth.Importer do @moduledoc """ Imports data from Geth genesis.json. diff --git a/apps/explorer/lib/explorer/chain_spec/parity/importer.ex b/apps/explorer/lib/explorer/chain_spec/parity/importer.ex index d104b59312cf..3e382e0b0495 100644 --- a/apps/explorer/lib/explorer/chain_spec/parity/importer.ex +++ b/apps/explorer/lib/explorer/chain_spec/parity/importer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule Explorer.ChainSpec.Parity.Importer do @moduledoc """ diff --git a/apps/explorer/lib/explorer/chain_spec/poa/importer.ex b/apps/explorer/lib/explorer/chain_spec/poa/importer.ex index d97da4b8a979..50792972adb8 100644 --- a/apps/explorer/lib/explorer/chain_spec/poa/importer.ex +++ b/apps/explorer/lib/explorer/chain_spec/poa/importer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ChainSpec.POA.Importer do @moduledoc """ Imports emission reward range for POA chain. @@ -5,11 +6,11 @@ defmodule Explorer.ChainSpec.POA.Importer do require Logger + alias Explorer.Chain.Block.{EmissionReward, Range} alias Explorer.Chain.Wei + alias Explorer.ChainSpec.GenesisData alias Explorer.Repo alias Explorer.SmartContract.Reader - alias Explorer.Chain.Block.{EmissionReward, Range} - alias Explorer.ChainSpec.GenesisData import Ecto.Query diff --git a/apps/explorer/lib/explorer/custom_contracts_helper.ex b/apps/explorer/lib/explorer/custom_contracts_helper.ex index 658f88cda9ad..d9325ba2f724 100644 --- a/apps/explorer/lib/explorer/custom_contracts_helper.ex +++ b/apps/explorer/lib/explorer/custom_contracts_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.CustomContractsHelper do @moduledoc """ Helper to enable custom contracts themes diff --git a/apps/explorer/lib/explorer/encrypted/address_hash.ex b/apps/explorer/lib/explorer/encrypted/address_hash.ex index fb90251f1e60..68b17d8728b3 100644 --- a/apps/explorer/lib/explorer/encrypted/address_hash.ex +++ b/apps/explorer/lib/explorer/encrypted/address_hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Encrypted.AddressHash do @moduledoc false diff --git a/apps/explorer/lib/explorer/encrypted/binary.ex b/apps/explorer/lib/explorer/encrypted/binary.ex index 7a5e62bd39af..c1db2746ce4b 100644 --- a/apps/explorer/lib/explorer/encrypted/binary.ex +++ b/apps/explorer/lib/explorer/encrypted/binary.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Encrypted.Binary do @moduledoc false diff --git a/apps/explorer/lib/explorer/encrypted/transaction_hash.ex b/apps/explorer/lib/explorer/encrypted/transaction_hash.ex index fa240c27e3b1..c609cb30de4f 100644 --- a/apps/explorer/lib/explorer/encrypted/transaction_hash.ex +++ b/apps/explorer/lib/explorer/encrypted/transaction_hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Encrypted.TransactionHash do @moduledoc false diff --git a/apps/explorer/lib/explorer/encrypted/types/address_hash.ex b/apps/explorer/lib/explorer/encrypted/types/address_hash.ex index f9ca332c538f..55f38f8bfdfd 100644 --- a/apps/explorer/lib/explorer/encrypted/types/address_hash.ex +++ b/apps/explorer/lib/explorer/encrypted/types/address_hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Encrypted.Types.AddressHash do @moduledoc """ An `Ecto.Type` to encrypt address_hash fields. diff --git a/apps/explorer/lib/explorer/encrypted/types/transaction_hash.ex b/apps/explorer/lib/explorer/encrypted/types/transaction_hash.ex index 7c39a9aeca57..c5a1fc4cf6ab 100644 --- a/apps/explorer/lib/explorer/encrypted/types/transaction_hash.ex +++ b/apps/explorer/lib/explorer/encrypted/types/transaction_hash.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Encrypted.Types.TransactionHash do @moduledoc """ An `Ecto.Type` to encrypt transaction_hash fields. diff --git a/apps/explorer/lib/explorer/env_var_translator.ex b/apps/explorer/lib/explorer/env_var_translator.ex index 5e2db974d91b..9dc388cc2a1a 100644 --- a/apps/explorer/lib/explorer/env_var_translator.ex +++ b/apps/explorer/lib/explorer/env_var_translator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.EnvVarTranslator do @moduledoc """ The module for transformation of environment variables diff --git a/apps/explorer/lib/explorer/eth_rpc.ex b/apps/explorer/lib/explorer/eth_rpc.ex index a8ec94ee6913..55b22253df0d 100644 --- a/apps/explorer/lib/explorer/eth_rpc.ex +++ b/apps/explorer/lib/explorer/eth_rpc.ex @@ -1,11 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.EthRPC do @moduledoc """ Ethereum JSON RPC methods logic implementation. """ import Explorer.EthRpcHelper - import EthereumJSONRPC, only: [integer_to_quantity: 1] - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] alias Ecto.Type, as: EctoType @@ -868,21 +867,20 @@ defmodule Explorer.EthRPC do "from" => transaction.from_address_hash, "gas" => encode_quantity(transaction.gas), "gasPrice" => transaction.gas_price |> Wei.to(:wei) |> encode_quantity(), - "maxPriorityFeePerGas" => transaction.max_priority_fee_per_gas |> Wei.to(:wei) |> encode_quantity(), - "maxFeePerGas" => transaction.max_fee_per_gas |> Wei.to(:wei) |> encode_quantity(), "hash" => transaction.hash, "input" => transaction.input, "nonce" => encode_quantity(transaction.nonce), "to" => transaction.to_address_hash, "transactionIndex" => encode_quantity(transaction.index), "value" => transaction.value |> Wei.to(:wei) |> encode_quantity(), - "type" => encode_quantity(transaction.type), + "type" => encode_quantity(transaction.type) || "0x0", "chainId" => chain_id(), "v" => encode_quantity(transaction.v), - "yParity" => encode_quantity(transaction.v), "r" => encode_quantity(transaction.r), "s" => encode_quantity(transaction.s) } + |> maybe_add_eip_1559_fields(transaction) + |> maybe_add_y_parity(transaction) |> maybe_add_signed_authorizations(transaction) |> maybe_add_chain_type_extra_transaction_info_properties(transaction) |> maybe_add_access_list(transaction) @@ -934,20 +932,40 @@ defmodule Explorer.EthRPC do "to" => transaction.to_address_hash, "transactionHash" => transaction.hash, "transactionIndex" => encode_quantity(transaction.index), - "type" => encode_quantity(transaction.type) + "type" => encode_quantity(transaction.type) || "0x0" } |> maybe_add_chain_type_extra_receipt_properties(transaction) {:ok, props} end + defp maybe_add_eip_1559_fields(props, %Transaction{ + max_fee_per_gas: max_fee_per_gas, + max_priority_fee_per_gas: max_priority_fee_per_gas + }) + when not is_nil(max_fee_per_gas) and not is_nil(max_priority_fee_per_gas) do + props + |> Map.put("maxFeePerGas", max_fee_per_gas |> Wei.to(:wei) |> encode_quantity()) + |> Map.put("maxPriorityFeePerGas", max_priority_fee_per_gas |> Wei.to(:wei) |> encode_quantity()) + end + + defp maybe_add_eip_1559_fields(props, _), do: props + + # yParity shouldn't be added for legacy (type 0) and is_nil(type) transactions + defp maybe_add_y_parity(props, %Transaction{type: type, v: v}) when not is_nil(type) and type > 0 do + props + |> Map.put("yParity", encode_quantity(v)) + end + + defp maybe_add_y_parity(props, %Transaction{type: _type}), do: props + defp maybe_add_signed_authorizations(props, %Transaction{type: 4, signed_authorizations: signed_authorizations}) do prepared_signed_authorizations = signed_authorizations |> Enum.map(fn signed_authorization -> %{ - "chainId" => String.downcase(integer_to_quantity(signed_authorization.chain_id)), - "nonce" => Helper.integer_to_hex(Decimal.to_integer(signed_authorization.nonce)), + "chainId" => Helper.decimal_to_hex(signed_authorization.chain_id), + "nonce" => Helper.decimal_to_hex(signed_authorization.nonce), "address" => to_string(signed_authorization.address), "r" => Helper.decimal_to_hex(signed_authorization.r), "s" => Helper.decimal_to_hex(signed_authorization.s), @@ -966,7 +984,7 @@ defmodule Explorer.EthRPC do defp maybe_add_signed_authorizations(props, _transaction), do: props - defp maybe_add_access_list(props, %Transaction{type: type}) when type > 0 do + defp maybe_add_access_list(props, %Transaction{type: type}) when not is_nil(type) and type > 0 do props |> Map.put("accessList", []) end @@ -999,7 +1017,7 @@ defmodule Explorer.EthRPC do defp validate_and_render_transaction(transaction_hash_string, render_func, params) do with {:transaction_hash, {:ok, transaction_hash}} <- - {:transaction_hash, Chain.string_to_transaction_hash(transaction_hash_string)}, + {:transaction_hash, Chain.string_to_full_hash(transaction_hash_string)}, {:transaction, {:ok, transaction}} <- {:transaction, Chain.hash_to_transaction(transaction_hash, params)} do render_func.(transaction) else @@ -1177,17 +1195,7 @@ defmodule Explorer.EthRPC do from_block = Map.get(filters, "fromBlock", "latest") to_block = Map.get(filters, "toBlock", "latest") - if from_block == "latest" || to_block == "latest" || from_block == "pending" || to_block == "pending" do - max_block_number = max_consensus_block_number() - - if is_nil(max_block_number) do - {:error, :empty} - else - to_block_numbers(from_block, to_block, max_block_number) - end - else - to_block_numbers(from_block, to_block, nil) - end + resolve_logs_blocks_range(from_block, to_block) {:block, _} -> {:error, "Invalid Block Hash"} @@ -1197,6 +1205,20 @@ defmodule Explorer.EthRPC do end end + defp resolve_logs_blocks_range(from_block, to_block) do + if from_block == "latest" || to_block == "latest" || from_block == "pending" || to_block == "pending" do + max_block_number = max_consensus_block_number() + + if is_nil(max_block_number) do + {:error, :empty} + else + to_block_numbers(from_block, to_block, max_block_number) + end + else + to_block_numbers(from_block, to_block, nil) + end + end + defp paging_options(%{ "paging_options" => %{ "logIndex" => log_index, diff --git a/apps/explorer/lib/explorer/eth_rpc_helper.ex b/apps/explorer/lib/explorer/eth_rpc_helper.ex index 2cecf52be106..298898cce215 100644 --- a/apps/explorer/lib/explorer/eth_rpc_helper.ex +++ b/apps/explorer/lib/explorer/eth_rpc_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.EthRpcHelper do @moduledoc """ Helper module for Explorer.EthRPC. Mostly contains functions to validate input args diff --git a/apps/explorer/lib/explorer/etherscan.ex b/apps/explorer/lib/explorer/etherscan.ex index 0cb6aea24dd6..95c12d4dcba1 100644 --- a/apps/explorer/lib/explorer/etherscan.ex +++ b/apps/explorer/lib/explorer/etherscan.ex @@ -1,32 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Etherscan do @moduledoc """ The etherscan context. """ - import Ecto.Query, - only: [ - from: 2, - where: 3, - union: 2, - subquery: 1, - order_by: 3, - limit: 2, - offset: 2, - preload: 2, - preload: 3, - select_merge: 3 - ] + import Ecto.Query import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - alias Explorer.Etherscan.Logs alias Explorer.{Chain, Repo} - alias Explorer.Chain.Address.{CurrentTokenBalance, TokenBalance} alias Explorer.Chain.{Address, Block, DenormalizationHelper, Hash, InternalTransaction, TokenTransfer, Transaction} + alias Explorer.Chain.Address.{CurrentTokenBalance, TokenBalance} + alias Explorer.Chain.Cache.BackgroundMigrations alias Explorer.Chain.Transaction.History.TransactionStats + alias Explorer.Etherscan.Logs @default_options %{ order_by_direction: :desc, + include_zero_value: false, page_number: 1, page_size: 10_000, startblock: nil, @@ -44,6 +35,14 @@ defmodule Explorer.Etherscan do @default_options.page_size end + @doc """ + Returns the default options map used for querying operations. + + The map includes default values for pagination, ordering, block ranges, and filtering options. + """ + @spec default_options() :: map() + def default_options, do: @default_options + @doc """ Gets a list of transactions for a given `t:Explorer.Chain.Hash.Address.t/0`. @@ -84,22 +83,31 @@ defmodule Explorer.Etherscan do end @internal_transaction_fields ~w( - from_address_hash - to_address_hash - transaction_hash + block_number + transaction_index index value - created_contract_address_hash input type call_type + call_type_enum gas gas_used - error + error_id )a @doc """ - Gets a list of internal transactions for a given transaction hash + Returns the list of internal transaction fields used in query selections. + + These fields represent the core attributes of internal transactions that are + consistently retrieved across different query operations. + """ + @spec internal_transaction_fields() :: [atom()] + def internal_transaction_fields, do: @internal_transaction_fields + + @doc """ + Gets a list of all internal transactions (with :all option) or for a given address hash + (`t:Explorer.Chain.Hash.Address.t/0`) or transaction hash (`t:Explorer.Chain.Hash.Full.t/0`). Note that this function relies on `Explorer.Chain` to exclude/include @@ -109,126 +117,118 @@ defmodule Explorer.Etherscan do transaction * include internal transactions of type create, reward, or selfdestruct even when they are alone in the parent transaction - """ - @spec list_internal_transactions(Hash.Full.t()) :: [map()] - def list_internal_transactions(%Hash{byte_count: unquote(Hash.Full.byte_count())} = transaction_hash) do + @spec list_internal_transactions(Hash.Full.t() | Hash.Address.t() | :all, map()) :: [map()] + def list_internal_transactions(transaction_or_address_hash_param_or_no_param, raw_options \\ %{}) + + def list_internal_transactions(%Hash{byte_count: unquote(Hash.Full.byte_count())} = transaction_hash, raw_options) do + options = Map.merge(@default_options, raw_options) + query = if DenormalizationHelper.transactions_denormalization_finished?() do - from( - it in InternalTransaction, - inner_join: transaction in assoc(it, :transaction), - where: not is_nil(transaction.block_hash), - where: it.transaction_hash == ^transaction_hash, - limit: 10_000, - select: - merge(map(it, ^@internal_transaction_fields), %{ - block_timestamp: transaction.block_timestamp, - block_number: transaction.block_number - }) + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> where(not is_nil(as(:transaction).block_hash)) + |> where(as(:transaction).hash == ^transaction_hash) + |> limit(10_000) + |> select( + [it], + merge(map(it, ^@internal_transaction_fields), %{ + block_timestamp: as(:transaction).block_timestamp, + transaction_hash: as(:transaction).hash, + from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + created_contract_address_hash: + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + }) ) else - from( - it in InternalTransaction, - inner_join: t in assoc(it, :transaction), - inner_join: b in assoc(t, :block), - where: it.transaction_hash == ^transaction_hash, - limit: 10_000, - select: - merge(map(it, ^@internal_transaction_fields), %{ - block_timestamp: b.timestamp, - block_number: b.number - }) + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> join(:inner, [it, t], b in assoc(t, :block), as: :block) + |> where(as(:transaction).hash == ^transaction_hash) + |> limit(10_000) + |> select( + [it], + merge(map(it, ^@internal_transaction_fields), %{ + block_timestamp: as(:block).timestamp, + transaction_hash: as(:transaction).hash, + from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + created_contract_address_hash: + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + }) ) end query - |> InternalTransaction.where_transaction_has_multiple_internal_transactions() |> InternalTransaction.where_is_different_from_parent_transaction() - |> InternalTransaction.where_nonpending_block() + |> InternalTransaction.where_nonpending_operation() + |> InternalTransaction.include_zero_value(options.include_zero_value) + |> order_by( + [q], + [ + {^options.order_by_direction, q.block_number}, + {^options.order_by_direction, q.transaction_index}, + {^options.order_by_direction, q.index} + ] + ) |> Repo.replica().all() + |> InternalTransaction.preload_error() end - @doc """ - Gets a list of all internal transactions (with :all option) or for a given address hash - (`t:Explorer.Chain.Hash.Address.t/0`). - - Note that this function relies on `Explorer.Chain` to exclude/include - internal transactions as follows: - - * exclude internal transactions of type call with no siblings in the - transaction - * include internal transactions of type create, reward, or selfdestruct - even when they are alone in the parent transaction - - """ - @spec list_internal_transactions(Hash.Address.t() | :all, map()) :: [map()] - def list_internal_transactions(address_hash_param_or_no_param, raw_options \\ %{}) - def list_internal_transactions( %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash, raw_options ) do options = Map.merge(@default_options, raw_options) - direction = - case options do - %{filter_by: "to"} -> :to - %{filter_by: "from"} -> :from - _ -> nil + options + |> options_to_directions() + |> then(fn directions -> + if BackgroundMigrations.get_empty_internal_transactions_data_finished() and + Enum.member?(directions, :to_address_hash) do + directions + |> Kernel.--([:created_contract_address_hash, :to_address_hash]) + |> Enum.concat([:to]) + else + directions end - - if direction do + end) + |> Enum.map(fn direction -> options - |> internal_transactions_with_transactions_and_blocks_query() - |> InternalTransaction.where_transaction_has_multiple_internal_transactions() + |> consensus_internal_transactions_with_transactions_and_blocks_query() |> InternalTransaction.where_address_fields_match(address_hash, direction) |> InternalTransaction.where_is_different_from_parent_transaction() - |> where_start_block_match(options) - |> where_end_block_match(options) - |> InternalTransaction.where_nonpending_block() - |> Repo.replica().all() - else - consensus_blocks = Block.consensus_blocks_query() - query = internal_transactions_query(options, consensus_blocks) - - query_to_address_hash_wrapped = - query - |> InternalTransaction.where_address_fields_match(address_hash, :to_address_hash) - |> InternalTransaction.where_is_different_from_parent_transaction() - |> where_start_block_match(options) - |> where_end_block_match(options) - |> Chain.wrapped_union_subquery() - - query_from_address_hash_wrapped = - query - |> InternalTransaction.where_address_fields_match(address_hash, :from_address_hash) - |> InternalTransaction.where_is_different_from_parent_transaction() - |> where_start_block_match(options) - |> where_end_block_match(options) - |> Chain.wrapped_union_subquery() - - query_created_contract_address_hash_wrapped = - query - |> InternalTransaction.where_address_fields_match(address_hash, :created_contract_address_hash) - |> InternalTransaction.where_is_different_from_parent_transaction() - |> where_start_block_match(options) - |> where_end_block_match(options) - |> Chain.wrapped_union_subquery() - - query_to_address_hash_wrapped - |> union(^query_from_address_hash_wrapped) - |> union(^query_created_contract_address_hash_wrapped) + |> InternalTransaction.include_zero_value(options.include_zero_value) + |> where_start_block_match_internal_transaction(options) + |> where_end_block_match_internal_transaction(options) + |> InternalTransaction.where_nonpending_operation() |> Chain.wrapped_union_subquery() - |> order_by( - [q], - [ - {^options.order_by_direction, q.block_number}, - desc: q.index - ] - ) - |> Repo.replica().all() - end + end) + |> Enum.reduce(fn query, acc -> + union(acc, ^query) + end) + |> Chain.wrapped_union_subquery() + |> order_by( + [q], + [ + {^options.order_by_direction, q.block_number}, + {^options.order_by_direction, q.transaction_index}, + {^options.order_by_direction, q.index} + ] + ) + |> offset(^options_to_offset(options)) + |> limit(^options.page_size) + |> Repo.replica().all() + |> InternalTransaction.preload_error() + |> InternalTransaction.preload_transaction() end def list_internal_transactions( @@ -242,134 +242,163 @@ defmodule Explorer.Etherscan do options |> internal_transactions_query(consensus_blocks) |> InternalTransaction.where_is_different_from_parent_transaction() - |> where_start_block_match(options) - |> where_end_block_match(options) + |> InternalTransaction.include_zero_value(options.include_zero_value) + |> where_start_block_match_internal_transaction(options) + |> where_end_block_match_internal_transaction(options) |> Repo.replica().all() + |> InternalTransaction.preload_error() + |> InternalTransaction.preload_transaction() end - defp internal_transactions_with_transactions_and_blocks_query(options) do + defp consensus_internal_transactions_with_transactions_and_blocks_query(options) do if DenormalizationHelper.transactions_denormalization_finished?() do - from( - it in InternalTransaction, - inner_join: transaction in assoc(it, :transaction), - where: not is_nil(transaction.block_hash), - order_by: [{^options.order_by_direction, transaction.block_number}], - limit: ^options.page_size, - offset: ^offset(options), - select: - merge(map(it, ^@internal_transaction_fields), %{ - block_timestamp: transaction.block_timestamp, - block_number: transaction.block_number - }) + InternalTransaction + |> from(as: :internal_transaction) + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> where(not is_nil(as(:transaction).block_hash)) + |> where(as(:transaction).block_consensus == true) + |> order_by( + [it], + [ + {^options.order_by_direction, it.block_number}, + {^options.order_by_direction, it.transaction_index}, + {^options.order_by_direction, it.index} + ] + ) + |> limit(^options_to_limit_for_inner_query(options)) + |> select( + [it, transaction], + merge(map(it, ^@internal_transaction_fields), %{ + block_timestamp: transaction.block_timestamp, + transaction_hash: transaction.hash, + from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + created_contract_address_hash: + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + }) ) else - from( - it in InternalTransaction, - inner_join: t in assoc(it, :transaction), - inner_join: b in assoc(t, :block), - order_by: [{^options.order_by_direction, t.block_number}], - limit: ^options.page_size, - offset: ^offset(options), - select: - merge(map(it, ^@internal_transaction_fields), %{ - block_timestamp: b.timestamp, - block_number: b.number - }) + InternalTransaction + |> from(as: :internal_transaction) + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> join(:inner, [_it, t], b in assoc(t, :block), as: :block) + |> where(as(:block).consensus == true) + |> order_by( + [it], + [ + {^options.order_by_direction, it.block_number}, + {^options.order_by_direction, it.transaction_index}, + {^options.order_by_direction, it.index} + ] + ) + |> limit(^options_to_limit_for_inner_query(options)) + |> select( + [it], + merge(map(it, ^@internal_transaction_fields), %{ + block_timestamp: as(:block).timestamp, + transaction_hash: as(:transaction).hash, + from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + created_contract_address_hash: + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + }) ) end end defp internal_transactions_query(options, consensus_blocks) do - from( - it in InternalTransaction, - inner_join: block in subquery(consensus_blocks), - on: it.block_number == block.number, - order_by: [ + InternalTransaction + |> from(as: :internal_transaction) + |> InternalTransaction.join_transaction_query() + |> InternalTransaction.join_address_mapping_query(:from_address) + |> InternalTransaction.join_address_mapping_query(:to_address) + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> join(:inner, [it], block in subquery(consensus_blocks), on: it.block_number == block.number, as: :block) + |> order_by( + [it], + [ {^options.order_by_direction, it.block_number}, - {:desc, it.transaction_index}, - {:desc, it.index} - ], - limit: ^options.page_size, - offset: ^offset(options), - select: - merge(map(it, ^@internal_transaction_fields), %{ - block_timestamp: block.timestamp, - block_number: block.number - }) + {^options.order_by_direction, it.transaction_index}, + {^options.order_by_direction, it.index} + ] + ) + |> limit(^options.page_size) + |> offset(^options_to_offset(options)) + |> select( + [it], + merge(map(it, ^@internal_transaction_fields), %{ + block_timestamp: as(:block).timestamp, + transaction_hash: as(:transaction).hash, + from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + to_address_hash: coalesce(it.to_address_hash, as(:to_address_mapping).address_hash), + created_contract_address_hash: + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + }) ) end @doc """ - Gets a list of token transfers for a given `t:Explorer.Chain.Hash.Address.t/0`. - + Retrieves token transfers filtered by token standard type with optional address and contract filtering. + + This function queries token transfers based on the specified token standard + (ERC-20, ERC-721, ERC-1155, ERC-404, or ZRC-2) and applies optional filtering by + address and contract address. The function merges provided options with + default settings for pagination, ordering, and block range filtering. + + For ERC-1155 transfers, the function performs additional processing to unnest + arrays of token IDs and amounts into individual transfer records, with each + record containing the specific token ID, amount, and index within the batch. + + ## Parameters + - `token_transfers_type`: The token standard type (`:erc20`, `:erc721`, + `:erc1155`, `:erc404`, or `:zrc2`) + - `address_hash`: Optional address hash to filter transfers involving this + address as sender or recipient (filters by `from_address_hash` or + `to_address_hash`) + - `contract_address_hash`: Optional contract address hash to filter transfers + for a specific token contract + - `options`: Map of query options that gets merged with default options + including pagination (`page_number`, `page_size`), ordering + (`order_by_direction`), and block range filtering (`startblock`, `endblock`) + + ## Returns + - A list of `TokenTransfer` structs matching the specified criteria + - For ERC-1155 transfers, each struct includes unnested `token_id`, `amount`, + and `index_in_batch` fields """ - @spec list_token_transfers(Hash.Address.t(), Hash.Address.t() | nil, map()) :: [map()] - def list_token_transfers( - %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash, - contract_address_hash, - options \\ @default_options - ) do - case Chain.max_consensus_block_number() do - {:ok, block_height} -> - merged_options = Map.merge(@default_options, options) - list_token_transfers(address_hash, contract_address_hash, block_height, merged_options) + @spec list_token_transfers( + :erc20 | :erc721 | :erc1155 | :erc404 | :zrc2, + Hash.Address.t() | nil, + Hash.Address.t() | nil, + map() + ) :: [TokenTransfer.t()] + def list_token_transfers(token_transfers_type, address_hash, contract_address_hash, options) do + options = Map.merge(@default_options, options) - _ -> - [] - end - end + case token_transfers_type do + :erc20 -> + list_erc20_token_transfers(address_hash, contract_address_hash, options) - @doc """ - Gets a list of ERC-721 token transfers for a given address_hash. If contract_address_hash is not nil, transfers will be filtered by contract. - """ - @spec list_nft_transfers(Hash.Address.t(), Hash.Address.t() | nil, map()) :: [TokenTransfer.t()] - def list_nft_transfers( - %Hash{byte_count: unquote(Hash.Address.byte_count())} = address_hash, - contract_address_hash, - options \\ @default_options - ) do - options - |> base_nft_transfers_query(contract_address_hash) - |> where([tt], tt.from_address_hash == ^address_hash or tt.to_address_hash == ^address_hash) - |> Repo.replica().all() - end + :erc721 -> + list_nft_transfers(address_hash, contract_address_hash, options) - @doc """ - Gets a list of ERC-721 token transfers for a given token contract_address_hash. - """ - @spec list_nft_transfers_by_token(Hash.Address.t(), map()) :: [TokenTransfer.t()] - def list_nft_transfers_by_token( - %Hash{byte_count: unquote(Hash.Address.byte_count())} = contract_address_hash, - options \\ @default_options - ) do - options - |> base_nft_transfers_query(contract_address_hash) - |> Repo.replica().all() - end + :erc1155 -> + list_erc1155_token_transfers(address_hash, contract_address_hash, options) - defp base_nft_transfers_query(options, contract_address_hash) do - options = Map.merge(@default_options, options) + :erc404 -> + list_erc404_token_transfers(address_hash, contract_address_hash, options) - TokenTransfer.erc_721_token_transfers_query() - |> where_contract_address_match(contract_address_hash) - |> order_by([tt], [ - {^options.order_by_direction, tt.block_number}, - {^options.order_by_direction, tt.log_index} - ]) - |> where_start_block_match_tt(options) - |> where_end_block_match_tt(options) - |> limit(^options.page_size) - |> offset(^offset(options)) - |> maybe_preload_block() - end + :zrc2 -> + list_zrc2_token_transfers(address_hash, contract_address_hash, options) - defp maybe_preload_block(query) do - if DenormalizationHelper.tt_denormalization_finished?() do - query - |> preload(:transaction) - else - query - |> preload([block: block], [{:block, block}, :transaction]) + :erc7984 -> + list_erc7984_token_transfers(address_hash, contract_address_hash, options) end end @@ -399,7 +428,7 @@ defmodule Explorer.Etherscan do where: block.miner_hash == ^address_hash, order_by: [desc: block.number], limit: ^merged_options.page_size, - offset: ^offset(merged_options), + offset: ^options_to_offset(merged_options), select: %{ number: block.number, timestamp: block.timestamp @@ -497,15 +526,30 @@ defmodule Explorer.Etherscan do query = from( t in Transaction, - limit: ^options.page_size, - offset: ^offset(options), + limit: ^options_to_limit_for_inner_query(options), select: map(t, ^@pending_transaction_fields) ) - query - |> where_address_match(address_hash, options) - |> Chain.pending_transactions_query() - |> order_by([transaction], desc: transaction.inserted_at, desc: transaction.hash) + options + |> options_to_directions() + |> Enum.map(fn direction -> + query + |> where_address_match(address_hash, direction) + |> Transaction.pending_transactions_query() + |> order_by([transaction], desc: transaction.inserted_at, desc: transaction.hash) + |> Chain.wrapped_union_subquery() + end) + |> Enum.reduce(fn query, acc -> + union(acc, ^query) + end) + |> Chain.wrapped_union_subquery() + |> order_by( + [transaction], + desc: transaction.inserted_at, + desc: transaction.hash + ) + |> offset(^options_to_offset(options)) + |> limit(^options.page_size) |> Repo.replica().all() end @@ -516,9 +560,8 @@ defmodule Explorer.Etherscan do t in Transaction, where: not is_nil(t.block_hash), where: t.block_consensus == true, - order_by: [{^options.order_by_direction, t.block_number}], - limit: ^options.page_size, - offset: ^offset(options), + order_by: [{^options.order_by_direction, t.block_number}, {^options.order_by_direction, t.index}], + limit: ^options_to_limit_for_inner_query(options), select: merge(map(t, ^@transaction_fields), %{ confirmations: fragment("? - ?", ^max_block_number, t.block_number) @@ -529,9 +572,8 @@ defmodule Explorer.Etherscan do t in Transaction, inner_join: b in assoc(t, :block), where: b.consensus == true, - order_by: [{^options.order_by_direction, t.block_number}], - limit: ^options.page_size, - offset: ^offset(options), + order_by: [{^options.order_by_direction, t.block_number}, {^options.order_by_direction, t.index}], + limit: ^options_to_limit_for_inner_query(options), select: merge(map(t, ^@transaction_fields), %{ block_timestamp: b.timestamp, @@ -540,152 +582,208 @@ defmodule Explorer.Etherscan do ) end - query - |> where_address_match(address_hash, options) - |> where_start_transaction_block_match(options) - |> where_end_transaction_block_match(options) - |> where_start_timestamp_match(options) - |> where_end_timestamp_match(options) + options + |> options_to_directions() + |> Enum.map(fn direction -> + query + |> where_address_match(address_hash, direction) + |> where_start_transaction_block_match(options) + |> where_end_transaction_block_match(options) + |> where_start_timestamp_match(options) + |> where_end_timestamp_match(options) + |> Chain.wrapped_union_subquery() + end) + |> Enum.reduce(fn query, acc -> + union(acc, ^query) + end) + |> Chain.wrapped_union_subquery() + |> order_by( + [q], + [ + {^options.order_by_direction, q.block_number}, + {^options.order_by_direction, q.index} + ] + ) + |> offset(^options_to_offset(options)) + |> limit(^options.page_size) |> Repo.replica().all() end - defp where_address_match(query, address_hash, %{filter_by: "to"}) do + defp where_address_match(query, address_hash, :to_address_hash) do where(query, [t], t.to_address_hash == ^address_hash) end - defp where_address_match(query, address_hash, %{filter_by: "from"}) do + defp where_address_match(query, address_hash, :from_address_hash) do where(query, [t], t.from_address_hash == ^address_hash) end - defp where_address_match(query, address_hash, _) do - where( - query, - [t], - t.to_address_hash == ^address_hash or t.from_address_hash == ^address_hash or - t.created_contract_address_hash == ^address_hash + defp where_address_match(query, address_hash, :created_contract_address_hash) do + where(query, [t], t.created_contract_address_hash == ^address_hash) + end + + defp list_erc20_token_transfers(address_hash, contract_address_hash, options) do + "ERC-20" |> base_token_transfers_query(address_hash, contract_address_hash, options) |> Repo.replica().all() + end + + # Retrieves token transfers filtered by ZRC-2 type with optional address and contract filtering. + # + # This function queries token transfers based on the ZRC-2 token standard + # and applies optional filtering by address and contract address. + # + # ## Parameters + # - `address_hash`: Optional address hash to filter transfers involving this + # address as sender or recipient (filters by `from_address_hash` or `to_address_hash`). + # - `contract_address_hash`: Optional contract address hash to filter transfers + # for a specific token contract. + # - `options`: Map of query options that gets merged with default options + # including pagination (`page_number`, `page_size`), ordering + # (`order_by_direction`), and block range filtering (`startblock`, `endblock`). + # + # ## Returns + # - A list of `TokenTransfer` structs matching the specified criteria. + @spec list_zrc2_token_transfers(Hash.Address.t() | nil, Hash.Address.t() | nil, map()) :: [TokenTransfer.t()] + defp list_zrc2_token_transfers(address_hash, contract_address_hash, options) do + "ZRC-2" |> base_token_transfers_query(address_hash, contract_address_hash, options) |> Repo.replica().all() + end + + defp list_nft_transfers(address_hash, contract_address_hash, options) do + "ERC-721" |> base_token_transfers_query(address_hash, contract_address_hash, options) |> Repo.replica().all() + end + + defp list_erc1155_token_transfers(address_hash, contract_address_hash, options) do + base_query = build_erc1155_base_query(address_hash, contract_address_hash, options) + + from(tt in {"base", TokenTransfer}) + |> with_cte("base", as: ^base_query, materialized: true) + |> join( + :inner, + [tt], + unnest in fragment( + "LATERAL (SELECT unnest(?) AS token_id, unnest(COALESCE(?, ARRAY[?])) AS amount, GENERATE_SERIES(0, COALESCE(ARRAY_LENGTH(?, 1), 0) - 1) AS index_in_batch)", + tt.token_ids, + tt.amounts, + tt.amount, + tt.amounts + ), + as: :unnest, + on: true ) + |> select_merge([unnest: unnest], %{ + token_id: fragment("?::numeric", unnest.token_id), + amount: fragment("?::numeric", unnest.amount), + index_in_batch: fragment("?::integer", unnest.index_in_batch) + }) + |> order_by([tt, unnest: unnest], [ + {^options.order_by_direction, tt.block_number}, + {^options.order_by_direction, tt.log_index}, + {^options.order_by_direction, unnest.index_in_batch} + ]) + |> limit(^options.page_size) + |> offset(^options_to_offset(options)) + |> maybe_preload_entities() + |> Repo.replica().all() end - @token_transfer_fields ~w( - block_number - block_hash - block_consensus - token_contract_address_hash - transaction_hash - from_address_hash - to_address_hash - amount - amounts - )a - - defp list_token_transfers(address_hash, contract_address_hash, block_height, options) do - tt_query = - from( - tt in TokenTransfer, - inner_join: tkn in assoc(tt, :token), - where: tt.from_address_hash == ^address_hash, - or_where: tt.to_address_hash == ^address_hash, - order_by: [{^options.order_by_direction, tt.block_number}, {^options.order_by_direction, tt.log_index}], - limit: ^options.page_size, - offset: ^offset(options), - select: - merge(map(tt, ^@token_transfer_fields), %{ - token_ids: tt.token_ids, - token_name: tkn.name, - token_symbol: tkn.symbol, - token_decimals: tkn.decimals, - token_log_index: tt.log_index - }) - ) - - tt_query_with_token_type = - if DenormalizationHelper.tt_denormalization_finished?() do - select_merge(tt_query, [tt, _tkn], %{token_type: tt.token_type}) - else - select_merge(tt_query, [_tt, tkn], %{token_type: tkn.type}) - end + defp build_erc1155_base_query(nil, contract_address_hash, options) do + TokenTransfer.only_consensus_transfers_query() + |> TokenTransfer.maybe_filter_by_token_type("ERC-1155") + |> where_contract_address_match(contract_address_hash) + |> where_start_block_match_tt(options) + |> where_end_block_match_tt(options) + |> order_by([tt], [ + {^options.order_by_direction, tt.block_number}, + {^options.order_by_direction, tt.log_index} + ]) + end - tt_specific_token_query = - tt_query_with_token_type + defp build_erc1155_base_query(address_hash, contract_address_hash, options) do + inner = + TokenTransfer.only_consensus_transfers_query() + |> TokenTransfer.maybe_filter_by_token_type("ERC-1155") + |> where_contract_address_match(contract_address_hash) |> where_start_block_match_tt(options) |> where_end_block_match_tt(options) - |> where_contract_address_match(contract_address_hash) + |> order_by([tt], [ + {^options.order_by_direction, tt.block_number}, + {^options.order_by_direction, tt.log_index} + ]) + |> limit(^options_to_limit_for_inner_query(options)) + + from_query = inner |> where([tt], tt.from_address_hash == ^address_hash) |> Chain.wrapped_union_subquery() + to_query = inner |> where([tt], tt.to_address_hash == ^address_hash) |> Chain.wrapped_union_subquery() + + union(from_query, ^to_query) + |> Chain.wrapped_union_subquery() + |> order_by([q], [ + {^options.order_by_direction, q.block_number}, + {^options.order_by_direction, q.log_index} + ]) + end - wrapped_query = - if DenormalizationHelper.transactions_denormalization_finished?() do - from( - tt in subquery(tt_specific_token_query), - inner_join: t in Transaction, - on: - tt.transaction_hash == t.hash and tt.block_number == t.block_number and tt.block_hash == t.block_hash and - t.block_consensus == true, - order_by: [{^options.order_by_direction, tt.block_number}, {^options.order_by_direction, tt.token_log_index}], - select: %{ - token_contract_address_hash: tt.token_contract_address_hash, - transaction_hash: tt.transaction_hash, - from_address_hash: tt.from_address_hash, - to_address_hash: tt.to_address_hash, - amount: tt.amount, - amounts: tt.amounts, - transaction_nonce: t.nonce, - transaction_index: t.index, - transaction_gas: t.gas, - transaction_gas_price: t.gas_price, - transaction_gas_used: t.gas_used, - transaction_cumulative_gas_used: t.cumulative_gas_used, - transaction_input: t.input, - block_hash: t.block_hash, - block_number: t.block_number, - block_timestamp: t.block_timestamp, - confirmations: fragment("? - ?", ^block_height, t.block_number), - token_ids: tt.token_ids, - token_name: tt.token_name, - token_symbol: tt.token_symbol, - token_decimals: tt.token_decimals, - token_type: tt.token_type, - token_log_index: tt.token_log_index - } - ) - else - from( - tt in subquery(tt_specific_token_query), - inner_join: t in Transaction, - on: tt.transaction_hash == t.hash and tt.block_number == t.block_number and tt.block_hash == t.block_hash, - inner_join: b in assoc(t, :block), - where: b.consensus == true, - order_by: [{^options.order_by_direction, tt.block_number}, {^options.order_by_direction, tt.token_log_index}], - select: %{ - token_contract_address_hash: tt.token_contract_address_hash, - transaction_hash: tt.transaction_hash, - from_address_hash: tt.from_address_hash, - to_address_hash: tt.to_address_hash, - amount: tt.amount, - amounts: tt.amounts, - transaction_nonce: t.nonce, - transaction_index: t.index, - transaction_gas: t.gas, - transaction_gas_price: t.gas_price, - transaction_gas_used: t.gas_used, - transaction_cumulative_gas_used: t.cumulative_gas_used, - transaction_input: t.input, - block_hash: b.hash, - block_number: b.number, - block_timestamp: b.timestamp, - confirmations: fragment("? - ?", ^block_height, t.block_number), - token_ids: tt.token_ids, - token_name: tt.token_name, - token_symbol: tt.token_symbol, - token_decimals: tt.token_decimals, - token_type: tt.token_type, - token_log_index: tt.token_log_index - } - ) - end + defp list_erc404_token_transfers(address_hash, contract_address_hash, options) do + "ERC-404" + |> base_token_transfers_query(address_hash, contract_address_hash, options) + |> Repo.replica().all() + end - wrapped_query + defp list_erc7984_token_transfers(address_hash, contract_address_hash, options) do + "ERC-7984" + |> base_token_transfers_query(address_hash, contract_address_hash, options) |> Repo.replica().all() end + defp base_token_transfers_query(transfers_type, nil, contract_address_hash, options) do + TokenTransfer.only_consensus_transfers_query() + |> TokenTransfer.maybe_filter_by_token_type(transfers_type) + |> where_contract_address_match(contract_address_hash) + |> where_start_block_match_tt(options) + |> where_end_block_match_tt(options) + |> order_by([tt], [ + {^options.order_by_direction, tt.block_number}, + {^options.order_by_direction, tt.log_index} + ]) + |> limit(^options.page_size) + |> offset(^options_to_offset(options)) + |> maybe_preload_entities() + end + + defp base_token_transfers_query(transfers_type, address_hash, contract_address_hash, options) do + inner_query = + TokenTransfer.only_consensus_transfers_query() + |> TokenTransfer.maybe_filter_by_token_type(transfers_type) + |> where_contract_address_match(contract_address_hash) + |> where_start_block_match_tt(options) + |> where_end_block_match_tt(options) + |> order_by([tt], [ + {^options.order_by_direction, tt.block_number}, + {^options.order_by_direction, tt.log_index} + ]) + |> limit(^options_to_limit_for_inner_query(options)) + + from_query = inner_query |> where([tt], tt.from_address_hash == ^address_hash) |> Chain.wrapped_union_subquery() + to_query = inner_query |> where([tt], tt.to_address_hash == ^address_hash) |> Chain.wrapped_union_subquery() + + union(from_query, ^to_query) + |> Chain.wrapped_union_subquery() + |> order_by([q], [ + {^options.order_by_direction, q.block_number}, + {^options.order_by_direction, q.log_index} + ]) + |> limit(^options.page_size) + |> offset(^options_to_offset(options)) + |> maybe_preload_entities() + end + + defp maybe_preload_entities(query) do + if DenormalizationHelper.tt_denormalization_finished?() do + query + |> preload([:transaction, :token]) + else + query + |> preload([:block, :token, :transaction]) + end + end + defp where_start_block_match(query, %{startblock: nil}), do: query defp where_start_block_match(query, %{startblock: start_block}) do @@ -730,6 +828,18 @@ defmodule Explorer.Etherscan do where(query, [tt], tt.block_number <= ^end_block) end + defp where_start_block_match_internal_transaction(query, %{startblock: nil}), do: query + + defp where_start_block_match_internal_transaction(query, %{startblock: start_block}) do + where(query, [internal_transaction: internal_transaction], internal_transaction.block_number >= ^start_block) + end + + defp where_end_block_match_internal_transaction(query, %{endblock: nil}), do: query + + defp where_end_block_match_internal_transaction(query, %{endblock: end_block}) do + where(query, [internal_transaction: internal_transaction], internal_transaction.block_number <= ^end_block) + end + defp where_start_timestamp_match(query, %{start_timestamp: nil}), do: query defp where_start_timestamp_match(query, %{start_timestamp: start_timestamp}) do @@ -756,7 +866,17 @@ defmodule Explorer.Etherscan do where(query, [tt], tt.token_contract_address_hash == ^contract_address_hash) end - defp offset(options), do: (options.page_number - 1) * options.page_size + defp options_to_offset(options), do: (options.page_number - 1) * options.page_size + + defp options_to_limit_for_inner_query(options), do: options.page_number * options.page_size + + defp options_to_directions(options) do + case options do + %{filter_by: "to"} -> [:to_address_hash, :created_contract_address_hash] + %{filter_by: "from"} -> [:from_address_hash] + _ -> [:to_address_hash, :from_address_hash, :created_contract_address_hash] + end + end @doc """ Gets a list of logs that meet the criteria in a given filter map. diff --git a/apps/explorer/lib/explorer/etherscan/addresses.ex b/apps/explorer/lib/explorer/etherscan/addresses.ex index 999d34ca6a69..901f09e0be48 100644 --- a/apps/explorer/lib/explorer/etherscan/addresses.ex +++ b/apps/explorer/lib/explorer/etherscan/addresses.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Etherscan.Addresses do @moduledoc """ This module contains functions for working with addresses, as they pertain to the @@ -13,6 +14,9 @@ defmodule Explorer.Etherscan.Addresses do alias Explorer.Chain.Address alias Explorer.Repo + @doc """ + Lists addresses ordered by insertion time and hash, with pagination. + """ @spec list_ordered_addresses(non_neg_integer(), non_neg_integer()) :: [Address.t()] def list_ordered_addresses(offset, limit) do query = diff --git a/apps/explorer/lib/explorer/etherscan/blocks.ex b/apps/explorer/lib/explorer/etherscan/blocks.ex index 0249f46743d3..dfff0630040f 100644 --- a/apps/explorer/lib/explorer/etherscan/blocks.ex +++ b/apps/explorer/lib/explorer/etherscan/blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Etherscan.Blocks do @moduledoc """ This module contains functions for working with blocks, as they pertain to the diff --git a/apps/explorer/lib/explorer/etherscan/contracts.ex b/apps/explorer/lib/explorer/etherscan/contracts.ex index dd470fff87c2..8719a9f29979 100644 --- a/apps/explorer/lib/explorer/etherscan/contracts.ex +++ b/apps/explorer/lib/explorer/etherscan/contracts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Etherscan.Contracts do @moduledoc """ This module contains functions for working with contracts, as they pertain to the @@ -11,10 +12,10 @@ defmodule Explorer.Etherscan.Contracts do where: 3 ] - alias Explorer.Repo alias Explorer.Chain.{Address, Hash, SmartContract} alias Explorer.Chain.SmartContract.Proxy alias Explorer.Chain.SmartContract.Proxy.Models.Implementation + alias Explorer.Repo @doc """ Returns address with preloaded SmartContract and proxy info if it exists @@ -51,9 +52,7 @@ defmodule Explorer.Etherscan.Contracts do implementation_address_fetched?: false, refetch_necessity_checked?: false }, - [ - {:proxy_without_abi?, true} - ] + [] ) address_verified_bytecode_twin_contract = diff --git a/apps/explorer/lib/explorer/etherscan/logs.ex b/apps/explorer/lib/explorer/etherscan/logs.ex index 14afe2c16709..45a0cafe5a35 100644 --- a/apps/explorer/lib/explorer/etherscan/logs.ex +++ b/apps/explorer/lib/explorer/etherscan/logs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Etherscan.Logs do @moduledoc """ This module contains functions for working with logs, as they pertain to the @@ -5,10 +6,10 @@ defmodule Explorer.Etherscan.Logs do """ - import Ecto.Query, only: [dynamic: 2, from: 2, limit: 2, where: 3, subquery: 1, order_by: 3, union: 2] + import Ecto.Query, only: [from: 2, where: 3, subquery: 1, order_by: 3] alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Block, DenormalizationHelper, InternalTransaction, Log, Transaction} + alias Explorer.Chain.{DenormalizationHelper, Log, Transaction} @base_filter %{ from_block: nil, @@ -75,16 +76,15 @@ defmodule Explorer.Etherscan.Logs do paging_options = if is_nil(paging_options), do: @default_paging_options, else: paging_options prepared_filter = Map.merge(@base_filter, filter) - if DenormalizationHelper.transactions_denormalization_finished?() do - logs_query = - Log - |> where_topic_match(prepared_filter) - |> where([log], log.address_hash == ^address_hash) - |> where([log], log.block_number >= ^prepared_filter.from_block) - |> where([log], log.block_number <= ^prepared_filter.to_block) - |> limit(1000) - |> page_logs(paging_options) + logs_query = + Log + |> where_topic_match(prepared_filter) + |> where([log], log.address_hash == ^address_hash) + |> where([log], log.block_number >= ^prepared_filter.from_block) + |> where([log], log.block_number <= ^prepared_filter.to_block) + |> page_logs(paging_options) + if DenormalizationHelper.transactions_denormalization_finished?() do all_transaction_logs_query = from(log in subquery(logs_query), join: transaction in Transaction, @@ -99,7 +99,9 @@ defmodule Explorer.Etherscan.Logs do block_number: transaction.block_number, block_timestamp: transaction.block_timestamp, block_consensus: transaction.block_consensus - } + }, + order_by: [asc: transaction.block_number, asc: log.index], + limit: 1000 ) all_transaction_logs_query @@ -107,75 +109,29 @@ defmodule Explorer.Etherscan.Logs do |> order_by([log], asc: log.block_number, asc: log.index) |> Repo.replica().all() else - logs_query = where_topic_match(Log, prepared_filter) - - query_to_address_hash_wrapped = - logs_query - |> internal_transaction_query(:to_address_hash, prepared_filter, address_hash) - |> Chain.wrapped_union_subquery() - - query_from_address_hash_wrapped = - logs_query - |> internal_transaction_query(:from_address_hash, prepared_filter, address_hash) - |> Chain.wrapped_union_subquery() - - query_created_contract_address_hash_wrapped = - logs_query - |> internal_transaction_query(:created_contract_address_hash, prepared_filter, address_hash) - |> Chain.wrapped_union_subquery() - - internal_transaction_log_query = - query_to_address_hash_wrapped - |> union(^query_from_address_hash_wrapped) - |> union(^query_created_contract_address_hash_wrapped) - - all_transaction_logs_query_base = - from(transaction in Transaction, - join: log in ^logs_query, - on: log.transaction_hash == transaction.hash, - where: transaction.block_number >= ^prepared_filter.from_block, - where: transaction.block_number <= ^prepared_filter.to_block, + all_transaction_logs_query = + from(log in subquery(logs_query), + join: transaction in Transaction, + on: log.transaction_hash == transaction.hash and log.block_hash == transaction.block_hash, + inner_join: block in assoc(transaction, :block), + where: block.consensus == true, select: map(log, ^@log_fields), select_merge: %{ gas_price: transaction.gas_price, gas_used: transaction.gas_used, transaction_index: transaction.index, - block_number: transaction.block_number - }, - union: ^internal_transaction_log_query - ) - - dynamic = - dynamic( - [transaction], - ^Transaction.where_transactions_to_from(address_hash) or - transaction.created_contract_address_hash == ^address_hash - ) - - all_transaction_logs_query = - all_transaction_logs_query_base - |> where([transaction], ^dynamic) - - query_with_blocks = - from(log_transaction_data in subquery(all_transaction_logs_query), - join: block in Block, - on: block.number == log_transaction_data.block_number, - where: log_transaction_data.address_hash == ^address_hash, - where: block.consensus == true, - order_by: block.number, - limit: 1000, - select_merge: %{ - transaction_index: log_transaction_data.transaction_index, - block_hash: block.hash, - block_number: block.number, + block_hash: transaction.block_hash, + block_number: transaction.block_number, block_timestamp: block.timestamp, block_consensus: block.consensus - } + }, + order_by: [asc: block.number, asc: log.index], + limit: 1000 ) - query_with_blocks - |> order_by([log], asc: log.index) - |> page_logs(paging_options) + all_transaction_logs_query + |> Chain.wrapped_union_subquery() + |> order_by([log], asc: log.block_number, asc: log.index) |> Repo.replica().all() end end @@ -321,7 +277,7 @@ defmodule Explorer.Etherscan.Logs do end defp sanitize_string_topic_value(topic_value) do - case Chain.string_to_block_hash(topic_value) do + case Chain.string_to_full_hash(topic_value) do {:ok, _} -> topic_value @@ -358,25 +314,4 @@ defmodule Explorer.Etherscan.Logs do where: {data.block_number, data.index} > {^block_number, ^log_index} ) end - - defp internal_transaction_query(logs_query, direction, prepared_filter, address_hash) do - query = - from(internal_transaction in InternalTransaction.where_nonpending_block(), - join: transaction in assoc(internal_transaction, :transaction), - join: log in ^logs_query, - on: log.transaction_hash == internal_transaction.transaction_hash, - where: internal_transaction.block_number >= ^prepared_filter.from_block, - where: internal_transaction.block_number <= ^prepared_filter.to_block, - select: - merge(map(log, ^@log_fields), %{ - gas_price: transaction.gas_price, - gas_used: transaction.gas_used, - transaction_index: transaction.index, - block_number: internal_transaction.block_number - }) - ) - - query - |> InternalTransaction.where_address_fields_match(address_hash, direction) - end end diff --git a/apps/explorer/lib/explorer/graphql.ex b/apps/explorer/lib/explorer/graphql.ex index 3ebc7e575ed5..d2d028a91929 100644 --- a/apps/explorer/lib/explorer/graphql.ex +++ b/apps/explorer/lib/explorer/graphql.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.GraphQL do @moduledoc """ The GraphQL context. @@ -43,11 +44,18 @@ defmodule Explorer.GraphQL do Returns an internal transaction for a given transaction hash and index. """ @spec get_internal_transaction(map()) :: {:ok, InternalTransaction.t()} | {:error, String.t()} - def get_internal_transaction(%{transaction_hash: _, index: _} = clauses) do - if internal_transaction = Repo.replica().get_by(InternalTransaction.where_nonpending_block(), clauses) do - {:ok, internal_transaction} - else - {:error, "Internal transaction not found."} + def get_internal_transaction(%{transaction_hash: transaction_hash, index: index}) do + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> where([it], as(:transaction).hash == ^transaction_hash) + |> where([it], it.index == ^index) + |> InternalTransaction.where_nonpending_operation() + |> Repo.replica().one() + |> InternalTransaction.preload_transaction(Repo.replica()) + |> InternalTransaction.preload_addresses([], Repo.replica()) + |> case do + nil -> {:error, "Internal transaction not found."} + internal_transaction -> {:ok, internal_transaction} end end @@ -60,17 +68,12 @@ defmodule Explorer.GraphQL do def transaction_to_internal_transactions_query(%Transaction{ hash: %Hash{byte_count: unquote(Hash.Full.byte_count())} = hash }) do - query = - from( - it in InternalTransaction, - inner_join: t in assoc(it, :transaction), - order_by: [asc: it.index], - where: it.transaction_hash == ^hash - ) - - query - |> InternalTransaction.where_nonpending_block() - |> InternalTransaction.where_transaction_has_multiple_internal_transactions() + InternalTransaction + |> InternalTransaction.join_transaction_query() + |> where([it], as(:transaction).hash == ^hash) + |> order_by([it], it.index) + |> InternalTransaction.where_nonpending_operation() + |> InternalTransaction.where_is_different_from_parent_transaction() end @doc """ diff --git a/apps/explorer/lib/explorer/graphql/celo.ex b/apps/explorer/lib/explorer/graphql/celo.ex index 6f82167619dc..d4d4d68d20c4 100644 --- a/apps/explorer/lib/explorer/graphql/celo.ex +++ b/apps/explorer/lib/explorer/graphql/celo.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.GraphQL.Celo do @moduledoc """ Defines Ecto queries to fetch Celo blockchain data for the legacy GraphQL diff --git a/apps/explorer/lib/explorer/helper.ex b/apps/explorer/lib/explorer/helper.ex index 672b0d2f9b1d..2ad1c6f66492 100644 --- a/apps/explorer/lib/explorer/helper.ex +++ b/apps/explorer/lib/explorer/helper.ex @@ -1,14 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Helper do @moduledoc """ Auxiliary common functions. """ + import Ecto.Query + import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] + alias ABI.TypeDecoder alias Explorer.Chain - alias Explorer.Chain.{Data, Hash} + alias Explorer.Chain.{Address.Reputation, Address.ScamBadgeToAddress, Data, Hash, Wei} + alias Redix.URI, as: RedixURI - import Ecto.Query, only: [where: 3] - import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] + require Logger @max_safe_integer round(:math.pow(2, 63)) - 1 @@ -39,7 +43,7 @@ defmodule Explorer.Helper do address. ## Parameters - - `address_hash` (`EthereumJSONRPC.hash()` | `nil`): The full address hash to + - `address_hash` (`EthereumJSONRPC.hash()` | `Hash.t()` | `nil`): The full address hash to be truncated, or `nil`. ## Returns @@ -51,12 +55,21 @@ defmodule Explorer.Helper do iex> truncate_address_hash("0x000000000000000000000000abcdef1234567890abcdef1234567890abcdef") "0xabcdef1234567890abcdef1234567890abcdef" + iex> truncate_address_hash(%Explorer.Chain.Hash{byte_count: 32, bytes: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 66, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7>>}) + "0x4200000000000000000000000000000000000007" + iex> truncate_address_hash(nil) "0x0000000000000000000000000000000000000000" """ - @spec truncate_address_hash(EthereumJSONRPC.hash() | nil) :: EthereumJSONRPC.address() + @spec truncate_address_hash(EthereumJSONRPC.hash() | Hash.t() | nil) :: EthereumJSONRPC.address() def truncate_address_hash(address_hash) + def truncate_address_hash(%Hash{} = address_hash) do + address_hash + |> Hash.to_string() + |> truncate_address_hash() + end + def truncate_address_hash(nil), do: burn_address_hash_string() def truncate_address_hash("0x000000000000000000000000" <> truncated_hash) do @@ -133,11 +146,13 @@ defmodule Explorer.Helper do iex> safe_parse_non_negative_integer("27606393966689717254124294199939478533331961967491413693980084341759630764504") {:error, :too_big_integer} """ - def safe_parse_non_negative_integer(string) do + @spec safe_parse_non_negative_integer(String.t(), integer()) :: + {:ok, integer()} | {:error, :negative_integer | :too_big_integer | :invalid_integer} + def safe_parse_non_negative_integer(string, max_safe_integer \\ @max_safe_integer) do case Integer.parse(string) do {num, ""} -> case num do - _ when num > @max_safe_integer -> {:error, :too_big_integer} + _ when num > max_safe_integer -> {:error, :too_big_integer} _ when num < 0 -> {:error, :negative_integer} _ -> {:ok, num} end @@ -154,12 +169,21 @@ defmodule Explorer.Helper do Results will be placed to `preload_field` """ @spec custom_preload(list(map()), keyword(), atom(), atom(), atom(), atom()) :: list() - def custom_preload(list, options, struct, foreign_key_field, references_field, preload_field) do + def custom_preload( + list, + options, + struct, + foreign_key_field, + references_field, + preload_field, + preload_field_association \\ [] + ) do to_fetch_from_db = list |> Enum.map(& &1[foreign_key_field]) |> Enum.uniq() associated_elements = struct |> where([t], field(t, ^references_field) in ^to_fetch_from_db) + |> preload(^preload_field_association) |> Chain.select_repo(options).all() |> Enum.reduce(%{}, fn el, acc -> Map.put(acc, Map.from_struct(el)[references_field], el) end) @@ -204,18 +228,6 @@ defmodule Explorer.Helper do def validate_url(_), do: :error - @doc """ - Validate url - """ - @spec valid_url?(String.t()) :: boolean() - def valid_url?(string) when is_binary(string) do - uri = URI.parse(string) - - !is_nil(uri.scheme) && !is_nil(uri.host) - end - - def valid_url?(_), do: false - @doc """ Compare two values and returns either :lt, :eq or :gt. @@ -243,26 +255,169 @@ defmodule Explorer.Helper do The modified query with scam addresses hidden, if applicable. """ - @spec maybe_hide_scam_addresses(nil | Ecto.Query.t(), atom(), [ + @spec maybe_hide_scam_addresses_with_select(nil | Ecto.Query.t(), atom(), [ Chain.paging_options() | Chain.api?() | Chain.show_scam_tokens?() - ]) :: Ecto.Query.t() + ]) :: Ecto.Query.t() | nil + def maybe_hide_scam_addresses_with_select(nil, _address_hash_key, _options), do: nil + + def maybe_hide_scam_addresses_with_select(query, address_hash_key, options) do + cond do + Application.get_env(:block_scout_web, :hide_scam_addresses) && !options[:show_scam_tokens?] -> + query + |> join(:left, [q], sabm in ScamBadgeToAddress, as: :sabm, on: sabm.address_hash == field(q, ^address_hash_key)) + |> where([sabm: sabm], is_nil(sabm.address_hash)) + |> select_merge([q], %{reputation: %Reputation{reputation: "ok"}}) + + Application.get_env(:block_scout_web, :hide_scam_addresses) && options[:show_scam_tokens?] -> + query + |> join(:left, [q], sabm in ScamBadgeToAddress, as: :sabm, on: sabm.address_hash == field(q, ^address_hash_key)) + |> select_merge([q, sabm: sabm], %{ + reputation: %Reputation{ + reputation: fragment("CASE WHEN ? THEN ? ELSE ? END", is_nil(sabm.address_hash), "ok", "scam") + } + }) + + true -> + query + |> select_merge([q], %{reputation: %Reputation{reputation: "ok"}}) + end + end + + @doc """ + Conditionally hides scam addresses in the given query, does not select the reputation field. + + Accepts two forms for the address hash locator: + - `atom()` — a field key on the query's root binding, e.g. `:to_address_hash`. + - `{binding, field}` tuple — a named binding already present in the query plus its hash + field, e.g. `{:to_address, :hash}`. Use this form when the addresses table is already + joined; it lets the query planner use the binding's join statistics for better index + selection. + """ + @spec maybe_hide_scam_addresses( + nil | Ecto.Query.t(), + atom() | {atom(), atom()}, + [Chain.paging_options() | Chain.api?() | Chain.show_scam_tokens?()] + ) :: Ecto.Query.t() | nil def maybe_hide_scam_addresses(nil, _address_hash_key, _options), do: nil - def maybe_hide_scam_addresses(query, address_hash_key, options) do - if Application.get_env(:block_scout_web, :hide_scam_addresses) && !options[:show_scam_tokens?] do - query - |> where( - [q], - fragment( - "NOT EXISTS (SELECT 1 FROM scam_address_badge_mappings sabm WHERE sabm.address_hash=?)", - field(q, ^address_hash_key) + def maybe_hide_scam_addresses(query, address_hash_key, options) when is_atom(address_hash_key) do + cond do + Application.get_env(:block_scout_web, :hide_scam_addresses) && !options[:show_scam_tokens?] -> + query + |> join(:left, [q], sabm in ScamBadgeToAddress, as: :sabm, on: sabm.address_hash == field(q, ^address_hash_key)) + |> where([sabm: sabm], is_nil(sabm.address_hash)) + + Application.get_env(:block_scout_web, :hide_scam_addresses) && options[:show_scam_tokens?] -> + query + + true -> + query + end + end + + def maybe_hide_scam_addresses(query, {named_binding, hash_field}, options) do + cond do + Application.get_env(:block_scout_web, :hide_scam_addresses) && !options[:show_scam_tokens?] -> + query + |> join(:left, [{^named_binding, address}], sabm in ScamBadgeToAddress, + as: :sabm, + on: sabm.address_hash == field(address, ^hash_field) ) - ) - else + |> where([sabm: sabm], is_nil(sabm.address_hash)) + + Application.get_env(:block_scout_web, :hide_scam_addresses) && options[:show_scam_tokens?] -> + query + + true -> + query + end + end + + @doc """ + Conditionally hides scam addresses in the given query for token transfers. + If query already has a named binding :token, it MUST be an inner join with the token table. + + Rationale of inner join with token table: + + PostgreSQL query planner misestimates anti-join selectivity when + scam_address_badge_mappings has more unique addresses (50k) than + token_transfers.token_contract_address_hash n_distinct (10k). + The planner assumes nearly all contracts are covered by the scam table, + estimates rows=1 after anti-join, and chooses a full sequential scan + instead of using the (block_number DESC, log_index DESC) index with + early LIMIT termination. + + Workaround: adding an INNER JOIN to the tokens table changes the + intermediate n_distinct estimate — the join with tokens produces a much + higher row/distinct estimate, making the planner correctly recognize that + the anti-join will filter out only a small fraction. This allows it to + choose Nested Loop Anti Join + Index Scan with early termination (~5ms + instead of ~1500s). + """ + @spec maybe_hide_scam_addresses_for_token_transfers(nil | Ecto.Query.t(), [ + Chain.paging_options() | Chain.api?() | Chain.show_scam_tokens?() + ]) :: Ecto.Query.t() | nil + def maybe_hide_scam_addresses_for_token_transfers(nil, _options), do: nil + + def maybe_hide_scam_addresses_for_token_transfers(query, options) do + cond do + Application.get_env(:block_scout_web, :hide_scam_addresses) && !options[:show_scam_tokens?] -> + query + |> maybe_join_token_table() + |> join(:left, [token: token], sabm in ScamBadgeToAddress, + as: :sabm, + on: sabm.address_hash == token.contract_address_hash + ) + |> where([sabm: sabm], is_nil(sabm.address_hash)) + + Application.get_env(:block_scout_web, :hide_scam_addresses) && options[:show_scam_tokens?] -> + query + + true -> + query + end + end + + defp maybe_join_token_table(query) do + if has_named_binding?(query, :token) do query + else + join(query, :inner, [tt], token in assoc(tt, :token), as: :token) end end + @doc """ + Conditionally hides scam addresses in the given query, does not select the reputation field. + """ + @spec maybe_hide_scam_addresses_for_search(nil | Ecto.Query.t(), atom(), [ + Chain.paging_options() | Chain.api?() | Chain.show_scam_tokens?() + ]) :: Ecto.Query.t() | nil + def maybe_hide_scam_addresses_for_search(nil, _address_hash_key, _options), do: nil + + def maybe_hide_scam_addresses_for_search(query, address_hash_key, options) do + cond do + Application.get_env(:block_scout_web, :hide_scam_addresses) && !options[:show_scam_tokens?] -> + query + |> join(:left, [q], sabm in ScamBadgeToAddress, as: :sabm, on: sabm.address_hash == field(q, ^address_hash_key)) + |> where([sabm: sabm], is_nil(sabm.address_hash)) + + Application.get_env(:block_scout_web, :hide_scam_addresses) && options[:show_scam_tokens?] -> + query + |> join(:left, [q], sabm in ScamBadgeToAddress, as: :sabm, on: sabm.address_hash == field(q, ^address_hash_key)) + + true -> + query + end + end + + @doc """ + Function used for identify cases when user explicitly requests to show scam addresses and there are enabled scam addresses in the application. + """ + @spec force_show_scam_addresses?(keyword()) :: boolean() + def force_show_scam_addresses?(options) do + Application.get_env(:block_scout_web, :hide_scam_addresses) && options[:show_scam_tokens?] + end + @doc """ Checks if a specified time interval has passed since a given datetime. @@ -353,7 +508,7 @@ defmodule Explorer.Helper do end def add_0x_prefix(binary_hash) when is_binary(binary_hash) do - if String.starts_with?(binary_hash, "0x") do + if String.starts_with?(binary_hash, "0x") and String.printable?(binary_hash) do binary_hash else "0x" <> Base.encode16(binary_hash, case: :lower) @@ -485,4 +640,216 @@ defmodule Explorer.Helper do |> DateTime.from_unix!(unit) |> DateTime.to_date() end + + @doc """ + Extracts the method ID from an ABI specification. + + ## Parameters + - `method` ([map()] | map()): The ABI specification, either as a single map + or a list containing one map. + + ## Returns + - `binary()`: The method ID extracted from the ABI specification. + + ## Examples + + iex> Indexer.Fetcher.Celo.Helper.abi_to_method_id([%{"name" => "transfer", "type" => "function", "inputs" => [%{"name" => "to", "type" => "address"}]}]) + <<26, 105, 82, 48>> + + """ + @spec abi_to_method_id([map()] | map()) :: binary() + def abi_to_method_id([method]), do: abi_to_method_id(method) + + def abi_to_method_id(method) when is_map(method) do + [parsed_method] = ABI.parse_specification([method]) + parsed_method.method_id + end + + @doc """ + Adds `inserted_at` and `updated_at` timestamps to a list of maps. + + This function takes a list of maps (`params`) and adds the current UTC + timestamp (`DateTime.utc_now/0`) as the values for the `:inserted_at` and + `:updated_at` keys in each map. + + ## Parameters + + - `params` - A list of maps to which the timestamps will be added. + + ## Returns + + - A list of maps, each containing the original keys and values along with + the `:inserted_at` and `:updated_at` keys set to the current UTC timestamp. + """ + @spec add_timestamps([map()]) :: [map()] + def add_timestamps(params) do + now = DateTime.utc_now() + + Enum.map(params, &Map.merge(&1, %{inserted_at: now, updated_at: now})) + end + + @doc """ + Converts various value types to a Decimal type. + + This function handles multiple input types and ensures they are properly + converted to a Decimal representation. + + ## Parameters + - `value`: The value to convert, which can be: + - `nil`: Converted to Decimal 0 + - `%Wei{}`: The Decimal value is extracted from the struct + - `float`: Converted using Decimal.from_float/1 + - `String.t()` or `integer()`: Converted using Decimal.new/1 + - `Decimal.t()`: Returned unchanged + + ## Returns + - A Decimal representation of the input value + """ + @spec number_to_decimal(nil | Wei.t() | integer() | float() | String.t() | Decimal.t()) :: Decimal.t() + def number_to_decimal(nil), do: Decimal.new(0) + def number_to_decimal(%Wei{value: value}), do: value + def number_to_decimal(value) when is_float(value), do: Decimal.from_float(value) + def number_to_decimal(value) when is_binary(value) or is_integer(value), do: Decimal.new(value) + def number_to_decimal(%Decimal{} = value), do: value + + @doc """ + Determines whether the specified node is configured to run indexer operations. + + This function checks if the node's `:explorer` application mode is set to + either `:all` or `:indexer`. It performs a remote procedure call to retrieve + the application environment configuration from the target node. If the RPC + call fails or the mode is set to a different value, the function returns + `false`. + + ## Parameters + - `node`: The node to check for indexer configuration. + + ## Returns + - `true` if the node's mode is `:all` or `:indexer` + - `false` if the node's mode is any other value, not set, or if the RPC call + fails + """ + @spec indexer_node?(Node.t()) :: boolean() + def indexer_node?(node) do + (node |> :rpc.call(Explorer, :mode, []) |> process_rpc_response(node, nil)) in [ + :all, + :indexer + ] + end + + @doc """ + Processes the response from a remote procedure call, handling errors gracefully. + + This function examines the RPC response and returns either the successful + result or a fallback value if the RPC call failed. When a `{:badrpc, reason}` + error tuple is encountered, it logs an error message including the node name + and error details, then returns the provided fallback value. For successful + responses, the original response is returned unchanged. + + ## Parameters + - `response`: The result from an RPC call, either a successful value or a + `{:badrpc, reason}` error tuple + - `node`: The node that was called via RPC, used for error logging + - `fallback`: The value to return if the RPC call failed + + ## Returns + - The original response if the RPC call succeeded + - The fallback value if the RPC call failed with a `{:badrpc, reason}` error + """ + @spec process_rpc_response(res | {:badrpc, reason}, Node.t(), fallback) :: res | fallback + when res: any(), reason: any(), fallback: any() + def process_rpc_response({:badrpc, _reason} = error, node, fallback) do + Logger.error("Received an error from #{node}: #{inspect(error)}") + fallback + end + + def process_rpc_response(response, _node, _fallback), do: response + + @doc """ + Generates a key from chain_id and a given string for storing in Redis. + + This function combines the chain_id (if available) with the provided string to + create a unique key for Redis storage. + + ## Parameters + - `string`: The string to be combined with the chain_id + + ## Returns + - `String.t()` representing the generated key + """ + @spec redis_key(String.t()) :: String.t() + def redis_key(key) do + chain_id = Application.get_env(:block_scout_web, :chain_id) + + if chain_id do + chain_id <> "_" <> key + else + key + end + end + + @doc """ + Returns a keyword list with a timeout option if a timeout is provided. + + This helper is needed for Repo calls, since passing `timeout: nil` is not supported. + If `timeout` is `nil`, returns an empty keyword list. Otherwise, returns + a keyword list with the `:timeout` key set to the given value. + + ## Parameters + + - timeout: The timeout value to use, or `nil`. + + ## Returns + + - A keyword list with the `:timeout` key, or an empty keyword list. + """ + @spec maybe_timeout(timeout() | nil) :: keyword() + def maybe_timeout(nil), do: [] + def maybe_timeout(timeout), do: [timeout: timeout] + + @doc """ + Builds Redix connection options for either direct Redis URL or Redis Sentinel configuration. + + This function supports two connection modes: + - **Direct connection**: When `sentinel_urls` is `nil` or empty, parses the + provided Redis URL into Redix start options. + - **Sentinel connection**: When `sentinel_urls` is provided, configures Redix + to connect through Redis Sentinel for high availability. In this mode, the + `sentinel_master_name` is required. + + ## Parameters + - `url`: Redis connection URL (e.g., `redis://host:port/db`). Used only in + direct connection mode. + - `use_ssl?`: When `true`, adds SSL options with certificate verification + disabled. + - `sentinel_urls`: Comma-separated list of Sentinel node URLs. When provided, + enables Sentinel connection mode. + - `sentinel_master_name`: The name of the master group monitored by Sentinel. + Required when `sentinel_urls` is provided. + + ## Returns + - A keyword list of Redix connection options. + + ## Raises + - `RuntimeError` if `sentinel_urls` is provided but `sentinel_master_name` is + `nil` or empty. + """ + @spec redix_opts(String.t() | nil, boolean(), String.t() | nil, String.t() | nil) :: keyword() + def redix_opts(url, use_ssl?, sentinel_urls, sentinel_master_name) do + ssl_opts = if use_ssl?, do: [ssl: true, socket_opts: [verify: :verify_none]], else: [] + + case sentinel_urls do + sentinel_urls when sentinel_urls in [nil, ""] -> + url |> RedixURI.to_start_options() |> Keyword.merge(ssl_opts) + + sentinel_urls_str -> + sentinel_urls = String.split(sentinel_urls_str, ",") + + if sentinel_master_name in [nil, ""] do + raise "sentinel_master_name is required when sentinel_urls is set" + end + + [sentinel: [sentinels: sentinel_urls, group: sentinel_master_name]] |> Keyword.merge(ssl_opts) + end + end end diff --git a/apps/explorer/lib/explorer/history/historian.ex b/apps/explorer/lib/explorer/history/historian.ex index f0dcedeae27c..a9d852773bc1 100644 --- a/apps/explorer/lib/explorer/history/historian.ex +++ b/apps/explorer/lib/explorer/history/historian.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.History.Historian do @moduledoc """ Interface for compiling, saving, and fetching historical records. diff --git a/apps/explorer/lib/explorer/history/process.ex b/apps/explorer/lib/explorer/history/process.ex index 3b9c190140d2..a717a8f003ce 100644 --- a/apps/explorer/lib/explorer/history/process.ex +++ b/apps/explorer/lib/explorer/history/process.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.History.Process do @moduledoc """ Creates the GenServer process used by a Historian to compile_history and to save_records. @@ -124,7 +125,7 @@ defmodule Explorer.History.Process do defp calculate_delay_until_next_midnight do now = DateTime.utc_now() # was added for testing possibility - time_to_fetch_at = config_or_default(:time_to_fetch_at, Time.new!(0, 0, 0, 0)) + time_to_fetch_at = config_or_default(:time_to_fetch_at, Time.new!(0, 0, 1, 0)) days_to_add = config_or_default(:days_to_add, 1) tomorrow = DateTime.new!(Date.add(Date.utc_today(), days_to_add), time_to_fetch_at, now.time_zone) diff --git a/apps/explorer/lib/explorer/http_client.ex b/apps/explorer/lib/explorer/http_client.ex new file mode 100644 index 000000000000..d7378d06a6ec --- /dev/null +++ b/apps/explorer/lib/explorer/http_client.ex @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.HttpClient do + @moduledoc false + + def get(url, headers \\ [], options \\ []) do + adapter().get(url, headers, options) + end + + def get!(url, headers \\ [], options \\ []) do + adapter().get!(url, headers, options) + end + + def post(url, body, headers \\ [], options \\ []) do + adapter().post(url, body, headers, options) + end + + def head(url, headers \\ [], options \\ []) do + adapter().head(url, headers, options) + end + + def request(method, url, headers, body, options \\ []) do + adapter().request(method, url, headers, body, options) + end + + defp adapter do + Application.get_env(:explorer, :http_client) + end +end diff --git a/apps/explorer/lib/explorer/http_client/httpoison.ex b/apps/explorer/lib/explorer/http_client/httpoison.ex new file mode 100644 index 000000000000..7030baddb471 --- /dev/null +++ b/apps/explorer/lib/explorer/http_client/httpoison.ex @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.HttpClient.HTTPoison do + @moduledoc false + + alias Utils.HttpClient.HTTPoisonHelper + + def get(url, headers, options) do + url + |> HTTPoison.get(headers, HTTPoisonHelper.request_opts(options)) + |> parse_response() + end + + def get!(url, headers, options) do + url + |> HTTPoison.get!(headers, HTTPoisonHelper.request_opts(options)) + |> parse_response() + end + + def post(url, body, headers, options) do + url + |> HTTPoison.post(body, headers, HTTPoisonHelper.request_opts(options)) + |> parse_response() + end + + def head(url, headers, options) do + url + |> HTTPoison.head(headers, HTTPoisonHelper.request_opts(options)) + |> parse_response() + end + + def request(method, url, headers, body, options) do + method + |> HTTPoison.request(url, body, headers, HTTPoisonHelper.request_opts(options)) + |> parse_response() + end + + defp parse_response({:ok, %{body: body, status_code: status_code, headers: response_headers}}) do + {:ok, %{body: body, status_code: status_code, headers: response_headers}} + end + + defp parse_response(%{body: body, status_code: status_code, headers: response_headers}) do + %{body: body, status_code: status_code, headers: response_headers} + end + + defp parse_response({:error, %{reason: reason}}), do: {:error, reason} + defp parse_response(error), do: error +end diff --git a/apps/explorer/lib/explorer/http_client/tesla.ex b/apps/explorer/lib/explorer/http_client/tesla.ex new file mode 100644 index 000000000000..8d8c68997718 --- /dev/null +++ b/apps/explorer/lib/explorer/http_client/tesla.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.HttpClient.Tesla do + @moduledoc false + + alias Utils.HttpClient.TeslaHelper + + def get(url, headers, options) do + options + |> TeslaHelper.client() + |> Tesla.get(url, headers: headers, query: options[:params] || [], opts: TeslaHelper.request_opts(options)) + |> parse_response() + end + + def get!(url, headers, options) do + options + |> TeslaHelper.client() + |> Tesla.get!(url, headers: headers, query: options[:params] || [], opts: TeslaHelper.request_opts(options)) + |> parse_response() + end + + def post(url, body, headers, options) do + options + |> TeslaHelper.client() + |> Tesla.post(url, body, headers: headers, opts: TeslaHelper.request_opts(options)) + |> parse_response() + end + + def head(url, headers, options) do + options + |> TeslaHelper.client() + |> Tesla.head(url, headers: headers, opts: TeslaHelper.request_opts(options)) + |> parse_response() + end + + def request(method, url, headers, body, options) do + options + |> TeslaHelper.client() + |> Tesla.request(method: method, url: url, headers: headers, body: body, opts: TeslaHelper.request_opts(options)) + |> parse_response() + end + + defp parse_response({:ok, %{body: body, status: status_code, headers: response_headers}}) do + {:ok, %{body: body, status_code: status_code, headers: response_headers}} + end + + defp parse_response(%{body: body, status: status_code, headers: response_headers}) do + %{body: body, status_code: status_code, headers: response_headers} + end + + defp parse_response(error), do: error +end diff --git a/apps/explorer/lib/explorer/logger.ex b/apps/explorer/lib/explorer/logger.ex index 580a04eac912..7e8576365dd5 100644 --- a/apps/explorer/lib/explorer/logger.ex +++ b/apps/explorer/lib/explorer/logger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Logger do @moduledoc """ Helper for `Logger`. diff --git a/apps/explorer/lib/explorer/mailer.ex b/apps/explorer/lib/explorer/mailer.ex index ba5c095714bc..3b82e063cec8 100644 --- a/apps/explorer/lib/explorer/mailer.ex +++ b/apps/explorer/lib/explorer/mailer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Mailer do @moduledoc """ Base module for mail sending diff --git a/apps/explorer/lib/explorer/market/AGENTS.md b/apps/explorer/lib/explorer/market/AGENTS.md new file mode 100644 index 000000000000..b5a0c5ed418b --- /dev/null +++ b/apps/explorer/lib/explorer/market/AGENTS.md @@ -0,0 +1,13 @@ +# Explorer Market + +## FiatValue.load and persistent_term + +`Explorer.Chain.Token.FiatValue.load/1` returns `nil` for any value when `:market_token_fetcher_enabled` persistent_term is `false` (the default in test env). If your test needs to assert on loaded `fiat_value` / `circulating_market_cap` fields, add to setup: + +```elixir +:persistent_term.put(:market_token_fetcher_enabled, true) + +on_exit(fn -> + :persistent_term.put(:market_token_fetcher_enabled, false) +end) +``` \ No newline at end of file diff --git a/apps/explorer/lib/explorer/market/CLAUDE.md b/apps/explorer/lib/explorer/market/CLAUDE.md new file mode 100644 index 000000000000..43c994c2d361 --- /dev/null +++ b/apps/explorer/lib/explorer/market/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/explorer/lib/explorer/market/fetcher/coin.ex b/apps/explorer/lib/explorer/market/fetcher/coin.ex index 2d66dd93c47d..35a4aca38ffd 100644 --- a/apps/explorer/lib/explorer/market/fetcher/coin.ex +++ b/apps/explorer/lib/explorer/market/fetcher/coin.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Fetcher.Coin do @moduledoc """ Local cache for native coin exchange rates. @@ -75,7 +76,7 @@ defmodule Explorer.Market.Fetcher.Coin do :ets.insert(table_name(), {secondary_coin?, coin}) end - unless secondary_coin? do + if !secondary_coin? do broadcast_event(:exchange_rate) end diff --git a/apps/explorer/lib/explorer/market/fetcher/history.ex b/apps/explorer/lib/explorer/market/fetcher/history.ex index baa66d65ba3b..9a2fa55ab234 100644 --- a/apps/explorer/lib/explorer/market/fetcher/history.ex +++ b/apps/explorer/lib/explorer/market/fetcher/history.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Fetcher.History do @moduledoc """ Fetches the daily market history. @@ -64,11 +65,16 @@ defmodule Explorer.Market.Fetcher.History do }} end) - state = %{types_states: types_states} + if Enum.all?(types_states, fn {_type, type_state} -> is_nil(type_state.source) end) do + Logger.info("No market history sources are configured") + :ignore + else + state = %{types_states: types_states} - send(self(), {:fetch_all, config(:first_fetch_day_count)}) + send(self(), {:fetch_all, config(:first_fetch_day_count)}) - {:ok, state} + {:ok, state} + end end def handle_info({:fetch_all, day_count}, state) do @@ -121,6 +127,7 @@ defmodule Explorer.Market.Fetcher.History do new_types_states = put_in(state.types_states, [type, :failed_attempts], failed_attempts) new_state = %{state | types_states: new_types_states} + maybe_insert_and_schedule_refetch(new_state) {:noreply, new_state} end diff --git a/apps/explorer/lib/explorer/market/fetcher/token.ex b/apps/explorer/lib/explorer/market/fetcher/token.ex index d67bda0cfafe..d058a44c35ae 100644 --- a/apps/explorer/lib/explorer/market/fetcher/token.ex +++ b/apps/explorer/lib/explorer/market/fetcher/token.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Fetcher.Token do @moduledoc """ Periodically fetches fiat value of tokens. @@ -6,16 +7,23 @@ defmodule Explorer.Market.Fetcher.Token do require Logger - alias Explorer.Chain + import Ecto.Query + + alias Ecto.Multi + alias Explorer.{Chain, Repo} + alias Explorer.Chain.Hash.Address alias Explorer.Chain.Import.Runner.Tokens + alias Explorer.Chain.Token alias Explorer.Market.Source + alias Explorer.MicroserviceInterfaces.MultichainSearch defstruct [ :source, :source_state, :max_batch_size, :interval, - :refetch_interval + :refetch_interval, + seen_token_hashes: MapSet.new() ] @spec start_link(term()) :: GenServer.on_start() @@ -58,9 +66,11 @@ defmodule Explorer.Market.Fetcher.Token do ) do case source.fetch_tokens(source_state, max_batch_size) do {:ok, source_state, fetch_finished?, tokens_data} -> - case update_tokens(tokens_data) do + filtered_tokens = Enum.reject(tokens_data, &Source.zero_or_nil?(&1[:fiat_value])) + + case update_tokens(filtered_tokens) do {:ok, _imported} -> - :ok + enqueue_to_multichain(filtered_tokens) {:error, err} -> Logger.error("Error while importing tokens market data: #{inspect(err)}") @@ -69,13 +79,23 @@ defmodule Explorer.Market.Fetcher.Token do Logger.error("Error while importing tokens market data: #{inspect({step, failed_value, changes_so_far})}") end - if fetch_finished? do - Process.send_after(self(), {:fetch, 0}, refetch_interval) - else - Process.send_after(self(), {:fetch, 0}, interval) - end + new_seen = + filtered_tokens + |> Enum.reduce(state.seen_token_hashes, fn token, acc -> + MapSet.put(acc, token.contract_address_hash) + end) + + new_seen = + if fetch_finished? do + nullify_stale_market_data(new_seen) + Process.send_after(self(), {:fetch, 0}, refetch_interval) + MapSet.new() + else + Process.send_after(self(), {:fetch, 0}, interval) + new_seen + end - {:noreply, %{state | source_state: source_state}} + {:noreply, %{state | source_state: source_state, seen_token_hashes: new_seen}} {:error, reason} -> Logger.error("Error while fetching tokens: #{inspect(reason)}") @@ -104,6 +124,91 @@ defmodule Explorer.Market.Fetcher.Token do {:noreply, state} end + # Adds market data of the token (such as price and market cap) to the queue to send that to Multichain service. + # + # ## Parameters + # - `tokens_data`: A list of token data. + # + # ## Returns + # - `:ok` if the data is accepted for insertion. + # - `:ignore` if the Multichain service is not used. + @spec enqueue_to_multichain([ + %{ + :contract_address_hash => Address.t(), + optional(:fiat_value) => Decimal.t(), + optional(:circulating_market_cap) => Decimal.t(), + optional(any()) => any() + } + ]) :: :ok | :ignore + defp enqueue_to_multichain(tokens_data) do + tokens_data + |> Enum.reduce(%{}, fn token, acc -> + data_for_multichain = MultichainSearch.prepare_token_market_data_for_queue(token) + + if data_for_multichain == %{} do + acc + else + Map.put(acc, token.contract_address_hash.bytes, data_for_multichain) + end + end) + |> MultichainSearch.send_token_info_to_queue(:market_data) + end + + defp nullify_stale_market_data(seen_token_hashes) do + if !Enum.empty?(seen_token_hashes) do + do_nullify_stale_market_data(seen_token_hashes) + end + end + + defp do_nullify_stale_market_data(seen_token_hashes) do + entries = Enum.map(seen_token_hashes, &%{contract_address_hash: &1.bytes}) + + Multi.new() + |> Multi.run(:create_temp_seen_token_hashes_table, fn repo, _changes -> + repo.query(""" + CREATE TEMP TABLE temp_seen_token_hashes ( + contract_address_hash bytea + ) ON COMMIT DROP + """) + end) + |> Multi.run(:insert_seen_token_hashes, fn repo, _changes -> + {:ok, repo.safe_insert_all("temp_seen_token_hashes", entries, [])} + end) + |> Multi.update_all( + :nullify_stale_market_data, + from(t in Token, + as: :token, + where: not is_nil(t.fiat_value), + where: + not exists( + from(s in "temp_seen_token_hashes", + where: s.contract_address_hash == parent_as(:token).contract_address_hash, + select: 1 + ) + ) + ), + [ + set: [ + fiat_value: nil, + circulating_market_cap: nil, + circulating_supply: nil, + volume_24h: nil, + updated_at: DateTime.utc_now() + ] + ], + timeout: :infinity + ) + |> Repo.transaction() + |> case do + {:ok, _} -> + :ok + + {:error, step, reason, _} -> + Logger.error("Failed to nullify stale market data at step #{step}: #{inspect(reason)}") + :error + end + end + defp update_tokens(token_params) do Chain.import(%{ tokens: %{ diff --git a/apps/explorer/lib/explorer/market/fetcher/token_list.ex b/apps/explorer/lib/explorer/market/fetcher/token_list.ex new file mode 100644 index 000000000000..bdfe3a989eb6 --- /dev/null +++ b/apps/explorer/lib/explorer/market/fetcher/token_list.ex @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.Fetcher.TokenList do + @moduledoc """ + Periodically fetches a token list from a URL conforming to the + [Token Lists](https://tokenlists.org/) standard and imports token + metadata (icon, name, symbol, decimals) into the database. + + Enabled when `TOKEN_LIST_URL` environment variable is set. + """ + use GenServer, restart: :transient + + use Utils.RuntimeEnvHelper, + chain_id: [:explorer, :chain_id] + + alias Explorer.{Chain, HttpClient} + alias Explorer.Chain.Import.Runner.Tokens + + require Logger + + defstruct [:url, :refetch_interval] + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl GenServer + def init(_args) do + config = Application.get_env(:explorer, __MODULE__) + url = config[:token_list_url] + + if url do + state = %__MODULE__{ + url: url, + refetch_interval: config[:refetch_interval] || :timer.hours(24) + } + + send(self(), :fetch) + + {:ok, state} + else + :ignore + end + end + + @impl GenServer + def handle_info(:fetch, %__MODULE__{url: url, refetch_interval: refetch_interval} = state) do + case fetch_and_import(url) do + {:ok, count} -> + Logger.info("Token list: imported #{count} tokens from #{url}") + + {:error, reason} -> + Logger.error("Token list: failed to fetch from #{url}: #{inspect(reason)}") + end + + Process.send_after(self(), :fetch, refetch_interval) + {:noreply, state} + end + + @impl GenServer + def handle_info(_msg, state), do: {:noreply, state} + + defp fetch_and_import(url) do + with {:ok, %{body: body, status_code: 200}} <- HttpClient.get(url), + {:ok, %{"tokens" => tokens}} when is_list(tokens) <- Jason.decode(body) do + token_params = + tokens + |> filter_by_chain_id() + |> Enum.map(&to_token_params/1) + |> Enum.reject(&is_nil/1) + + case import_tokens(token_params) do + {:ok, _} -> {:ok, length(token_params)} + {:error, _} = error -> error + {:error, step, failed_value, _changes} -> {:error, {step, failed_value}} + end + else + {:ok, %{status_code: status}} -> {:error, {:http_status, status}} + {:ok, _invalid_payload} -> {:error, :invalid_token_list} + {:error, _} = error -> error + end + end + + defp filter_by_chain_id(tokens) do + case chain_id() do + nil -> + Logger.warning("Token list: CHAIN_ID is not set, importing all tokens from the list") + tokens + + chain_id_string -> + chain_id_int = String.to_integer(chain_id_string) + Enum.filter(tokens, fn token -> token["chainId"] == chain_id_int end) + end + end + + defp to_token_params(%{"address" => address} = token) when is_binary(address) do + %{ + contract_address_hash: address, + name: token["name"], + symbol: token["symbol"], + decimals: token["decimals"], + icon_url: token["logoURI"], + type: "ERC-20" + } + end + + defp to_token_params(_), do: nil + + defp import_tokens([]), do: {:ok, %{}} + + defp import_tokens(token_params) do + Chain.import(%{ + tokens: %{ + params: token_params, + on_conflict: Tokens.token_list_on_conflict(), + fields_to_update: Tokens.token_list_fields_to_update() + } + }) + end +end diff --git a/apps/explorer/lib/explorer/market/market.ex b/apps/explorer/lib/explorer/market/market.ex index e58a2788fc05..e7311169751d 100644 --- a/apps/explorer/lib/explorer/market/market.ex +++ b/apps/explorer/lib/explorer/market/market.ex @@ -1,11 +1,70 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market do @moduledoc """ Context for data related to the cryptocurrency market. """ - alias Explorer.Market.Fetcher.Coin + use GenServer + + require Logger + alias Explorer.Helper + alias Explorer.Market.Fetcher.Coin, as: CoinFetcher + alias Explorer.Market.Fetcher.History, as: HistoryFetcher + alias Explorer.Market.Fetcher.Token, as: TokenFetcher + alias Explorer.Market.{MarketHistory, MarketHistoryCache, Token} + @history_key :market_history_fetcher_enabled + @tokens_key :market_token_fetcher_enabled + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl GenServer + def init(_opts) do + if Explorer.mode() == :all do + {history_pid, token_pid} = find_history_and_token_fetchers() + :persistent_term.put(@history_key, !is_nil(history_pid)) + :persistent_term.put(@tokens_key, !is_nil(token_pid)) + :ignore + else + {:ok, nil, {:continue, 1}} + end + end + + @impl GenServer + def handle_continue(attempt, _state) do + attempt |> Kernel.**(3) |> :timer.seconds() |> :timer.sleep() + + case Node.list() |> Enum.filter(&Helper.indexer_node?/1) do + [] -> + if attempt < 5 do + {:noreply, nil, {:continue, attempt + 1}} + else + raise "No indexer nodes discovered after #{attempt} attempts" + end + + [indexer] -> + {history_pid, token_pid} = + indexer + |> :rpc.call(__MODULE__, :find_history_and_token_fetchers, []) + |> Helper.process_rpc_response(indexer, {nil, nil}) + + :persistent_term.put(@history_key, !is_nil(history_pid)) + :persistent_term.put(@tokens_key, !is_nil(token_pid)) + {:stop, :normal, nil} + + multiple_indexers -> + if attempt < 5 do + {:noreply, nil, {:continue, attempt + 1}} + else + raise "Multiple indexer nodes discovered: #{inspect(multiple_indexers)}" + end + end + end + @doc """ Retrieves the history for the recent specified amount of days. @@ -13,18 +72,11 @@ defmodule Explorer.Market do """ @spec fetch_recent_history(boolean()) :: [MarketHistory.t()] def fetch_recent_history(secondary_coin? \\ false) do - MarketHistoryCache.fetch(secondary_coin?) - end - - @doc """ - Retrieves today's native coin exchange rate from the database. - """ - @spec get_native_coin_exchange_rate_from_db(boolean()) :: Token.t() - def get_native_coin_exchange_rate_from_db(secondary_coin? \\ false) do - secondary_coin? - |> fetch_recent_history() - |> List.first() - |> MarketHistory.to_token() + if history_fetcher_enabled?() do + MarketHistoryCache.fetch(secondary_coin?) + else + [] + end end @doc """ @@ -32,7 +84,7 @@ defmodule Explorer.Market do """ @spec get_coin_exchange_rate() :: Token.t() def get_coin_exchange_rate do - Coin.get_coin_exchange_rate() || get_native_coin_exchange_rate_from_db() + CoinFetcher.get_coin_exchange_rate() || Token.null() end @doc """ @@ -40,7 +92,7 @@ defmodule Explorer.Market do """ @spec get_secondary_coin_exchange_rate() :: Token.t() def get_secondary_coin_exchange_rate do - Coin.get_secondary_coin_exchange_rate() || get_native_coin_exchange_rate_from_db(true) + CoinFetcher.get_secondary_coin_exchange_rate() || Token.null() end @doc """ @@ -70,4 +122,44 @@ defmodule Explorer.Market do |> MarketHistory.price_at_date(false, options) |> MarketHistory.to_token() end + + @doc """ + Checks if the market token fetcher is enabled in the application. + + This function retrieves the enablement status from persistent term storage + using the `:market_token_fetcher_enabled` key. If the key is not set, it + defaults to `false`. + + ## Returns + - `true` if the market token fetcher is enabled + - `false` if the market token fetcher is disabled or not configured + """ + @spec token_fetcher_enabled?() :: boolean() + def token_fetcher_enabled? do + :persistent_term.get(@tokens_key, false) + end + + @spec history_fetcher_enabled?() :: boolean() + defp history_fetcher_enabled? do + :persistent_term.get(@history_key, false) + end + + @doc """ + Locates the running processes for the market history and token fiat value fetchers. + + This function checks whether the `Explorer.Market.Fetcher.History` and + `Explorer.Market.Fetcher.Token` GenServer processes are currently running + and returns their process identifiers. + + ## Parameters + None. + + ## Returns + - A tuple `{history_fetcher, token_fetcher}` where each element is: + - `nil` if the corresponding fetcher process is not running + - A `pid()` if the process is registered locally + - A `{atom(), atom()}` tuple if registered via `:global` or `:via` + """ + @spec find_history_and_token_fetchers() :: {nil | pid() | {atom(), atom()}, nil | pid() | {atom(), atom()}} + def find_history_and_token_fetchers, do: {GenServer.whereis(HistoryFetcher), GenServer.whereis(TokenFetcher)} end diff --git a/apps/explorer/lib/explorer/market/market_history.ex b/apps/explorer/lib/explorer/market/market_history.ex index db3709d61444..d99aa1ec3c82 100644 --- a/apps/explorer/lib/explorer/market/market_history.ex +++ b/apps/explorer/lib/explorer/market/market_history.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.MarketHistory do @moduledoc """ Represents market history of configured coin to USD. @@ -41,7 +42,7 @@ defmodule Explorer.Market.MarketHistory do end @doc false - @spec bulk_insert([map()]) :: {non_neg_integer(), nil | [term()]} + @spec bulk_insert([map()]) :: {:ok, {non_neg_integer(), nil | [term()]}} | {:error, term()} def bulk_insert(records) do records_without_zeroes = records @@ -53,10 +54,12 @@ defmodule Explorer.Market.MarketHistory do # Enforce MarketHistory ShareLocks order (see docs: sharelocks.md) |> Enum.sort_by(& &1.date) - Repo.safe_insert_all(__MODULE__, records_without_zeroes, - on_conflict: market_history_on_conflict(), - conflict_target: [:date, :secondary_coin] - ) + Repo.transaction(fn -> + Repo.safe_insert_all(__MODULE__, records_without_zeroes, + on_conflict: market_history_on_conflict(), + conflict_target: [:date, :secondary_coin] + ) + end) end @spec to_token(t() | nil) :: Token.t() @@ -72,7 +75,8 @@ defmodule Explorer.Market.MarketHistory do name: nil, symbol: nil, volume_24h: nil, - image_url: nil + image_url: nil, + circulating_supply: nil } end @@ -83,61 +87,20 @@ defmodule Explorer.Market.MarketHistory do market_history in __MODULE__, update: [ set: [ - opening_price: - fragment( - """ - CASE WHEN (? IS NULL OR ? = 0) AND EXCLUDED.opening_price IS NOT NULL AND EXCLUDED.opening_price > 0 - THEN EXCLUDED.opening_price - ELSE ? - END - """, - market_history.opening_price, - market_history.opening_price, - market_history.opening_price - ), - closing_price: - fragment( - """ - CASE WHEN (? IS NULL OR ? = 0) AND EXCLUDED.closing_price IS NOT NULL AND EXCLUDED.closing_price > 0 - THEN EXCLUDED.closing_price - ELSE ? - END - """, - market_history.closing_price, - market_history.closing_price, - market_history.closing_price - ), - market_cap: - fragment( - """ - CASE WHEN (? IS NULL OR ? = 0) AND EXCLUDED.market_cap IS NOT NULL AND EXCLUDED.market_cap > 0 - THEN EXCLUDED.market_cap - ELSE ? - END - """, - market_history.market_cap, - market_history.market_cap, - market_history.market_cap - ), - tvl: - fragment( - """ - CASE WHEN (? IS NULL OR ? = 0) AND EXCLUDED.tvl IS NOT NULL AND EXCLUDED.tvl > 0 - THEN EXCLUDED.tvl - ELSE ? - END - """, - market_history.tvl, - market_history.tvl, - market_history.tvl - ) + opening_price: fragment("COALESCE(EXCLUDED.opening_price, ?)", market_history.opening_price), + closing_price: fragment("COALESCE(EXCLUDED.closing_price, ?)", market_history.closing_price), + market_cap: fragment("COALESCE(EXCLUDED.market_cap, ?)", market_history.market_cap), + tvl: fragment("COALESCE(EXCLUDED.tvl, ?)", market_history.tvl) ] ], where: - is_nil(market_history.tvl) or market_history.tvl == 0 or is_nil(market_history.market_cap) or - market_history.market_cap == 0 or is_nil(market_history.opening_price) or - market_history.opening_price == 0 or is_nil(market_history.closing_price) or - market_history.closing_price == 0 + fragment( + "(EXCLUDED.opening_price, EXCLUDED.closing_price, EXCLUDED.market_cap, EXCLUDED.tvl) IS DISTINCT FROM (? , ?, ?, ?)", + market_history.opening_price, + market_history.closing_price, + market_history.market_cap, + market_history.tvl + ) ) end end diff --git a/apps/explorer/lib/explorer/market/market_history_cache.ex b/apps/explorer/lib/explorer/market/market_history_cache.ex index 8c0d479a776a..910b13944192 100644 --- a/apps/explorer/lib/explorer/market/market_history_cache.ex +++ b/apps/explorer/lib/explorer/market/market_history_cache.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.MarketHistoryCache do @moduledoc """ Caches recent market history. diff --git a/apps/explorer/lib/explorer/market/source.ex b/apps/explorer/lib/explorer/market/source.ex index 3842368bd944..2df47cf104d8 100644 --- a/apps/explorer/lib/explorer/market/source.ex +++ b/apps/explorer/lib/explorer/market/source.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source do @moduledoc """ Defines behaviors and utilities for fetching cryptocurrency market data from multiple sources. @@ -34,7 +35,7 @@ defmodule Explorer.Market.Source do """ alias Explorer.Chain.Hash - alias Explorer.Helper + alias Explorer.{Helper, HttpClient} alias Explorer.Market.Source.{ CoinGecko, @@ -42,13 +43,12 @@ defmodule Explorer.Market.Source do CryptoCompare, CryptoRank, DefiLlama, + DIA, Mobula } alias Explorer.Market.Token - alias HTTPoison.{Error, Response} - # Native coin processing @callback native_coin_fetching_enabled?() :: boolean() | :ignore @callback fetch_native_coin() :: {:ok, Token.t()} | {:error, any()} | :ignore @@ -128,22 +128,22 @@ defmodule Explorer.Market.Source do - HTTP client errors: reason will be the underlying error - JSON decoding errors: reason will be the raw response body """ - @spec http_request(String.t(), HTTPoison.headers()) :: {:ok, any()} | {:error, any()} + @spec http_request(String.t(), [{atom() | binary(), binary()}]) :: {:ok, any()} | {:error, any()} def http_request(source_url, additional_headers) do - case HTTPoison.get(source_url, headers() ++ additional_headers) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(source_url, headers() ++ additional_headers) do + {:ok, %{body: body, status_code: 200}} -> parse_http_success_response(body) - {:ok, %Response{body: body, status_code: status_code}} when status_code in 400..526 -> + {:ok, %{body: body, status_code: status_code}} when status_code in 400..526 -> {:error, "#{status_code}: #{body}"} - {:ok, %Response{status_code: status_code}} when status_code in 300..308 -> + {:ok, %{status_code: status_code}} when status_code in 300..308 -> {:error, "Source redirected"} - {:ok, %Response{status_code: _status_code}} -> + {:ok, %{status_code: _status_code}} -> {:error, "Source unexpected status code"} - {:error, %Error{reason: reason}} -> + {:error, reason} -> {:error, reason} end end @@ -236,7 +236,14 @@ defmodule Explorer.Market.Source do Decimal.new(value) end - @sources [CoinGecko, CoinMarketCap, CryptoCompare, CryptoRank, DefiLlama, Mobula] + @doc """ + Returns true if the value is nil or a Decimal equal to zero. + """ + @spec zero_or_nil?(Decimal.t() | nil) :: boolean() + def zero_or_nil?(nil), do: true + def zero_or_nil?(%Decimal{} = value), do: Decimal.equal?(value, Decimal.new(0)) + + @sources [CoinGecko, CoinMarketCap, CryptoCompare, CryptoRank, DefiLlama, Mobula, DIA] @doc """ Returns a module for fetching native coin market data. diff --git a/apps/explorer/lib/explorer/market/source/coin_gecko.ex b/apps/explorer/lib/explorer/market/source/coin_gecko.ex index b2bbd881bf62..581378b91c76 100644 --- a/apps/explorer/lib/explorer/market/source/coin_gecko.ex +++ b/apps/explorer/lib/explorer/market/source/coin_gecko.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.CoinGecko do @moduledoc """ Adapter for fetching exchange rates from https://coingecko.com @@ -30,7 +31,7 @@ defmodule Explorer.Market.Source.CoinGecko do {:error, _reason} = error -> error - tokens_to_fetch when is_list(tokens_to_fetch) and length(tokens_to_fetch) > 0 -> + tokens_to_fetch when is_list(tokens_to_fetch) and tokens_to_fetch !== [] -> fetch_tokens(tokens_to_fetch, batch_size) _ -> @@ -147,7 +148,8 @@ defmodule Explorer.Market.Source.CoinGecko do symbol: String.upcase(data["symbol"]), fiat_value: Source.to_decimal(market_data["current_price"][config(:currency)]), volume_24h: Source.to_decimal(market_data["total_volume"][config(:currency)]), - image_url: Source.handle_image_url(data["image"]["small"] || data["image"]["thumb"]) + image_url: Source.handle_image_url(data["image"]["small"] || data["image"]["thumb"]), + circulating_supply: Source.to_decimal(market_data["circulating_supply"]) }} else nil -> {:error, coin_id_not_specified_error} @@ -167,41 +169,50 @@ defmodule Explorer.Market.Source.CoinGecko do headers() ) do tokens - |> Enum.reduce([], fn - %{ - "id" => id, - "symbol" => symbol, - "name" => name, - "platforms" => %{ - ^platform => token_contract_address_hash_string - } - }, - acc -> - case Hash.Address.cast(token_contract_address_hash_string) do - {:ok, token_contract_address_hash} -> - token = %{ - id: id, - symbol: symbol, - name: name, - contract_address_hash: token_contract_address_hash, - type: "ERC-20" - } - - [token | acc] - - _ -> - acc - end - - _, acc -> - acc - end) + |> Enum.reduce([], &reduce_coingecko_token(&1, &2, platform)) else nil -> {:error, "Platform not specified"} {:error, reason} -> {:error, reason} end end + defp reduce_coingecko_token( + %{ + "id" => id, + "symbol" => symbol, + "name" => name, + "platforms" => platforms + }, + acc, + platform + ) do + case Map.get(platforms, platform) do + nil -> + acc + + token_contract_address_hash_string -> + case Hash.Address.cast(token_contract_address_hash_string) do + {:ok, token_contract_address_hash} -> + [build_coingecko_token(id, symbol, name, token_contract_address_hash) | acc] + + _ -> + acc + end + end + end + + defp reduce_coingecko_token(_, acc, _platform), do: acc + + defp build_coingecko_token(id, symbol, name, token_contract_address_hash) do + %{ + id: id, + symbol: symbol, + name: name, + contract_address_hash: token_contract_address_hash, + type: "ERC-20" + } + end + defp put_market_data_to_tokens(tokens, market_data) do currency = config(:currency) market_cap = currency <> "_market_cap" diff --git a/apps/explorer/lib/explorer/market/source/coin_market_cap.ex b/apps/explorer/lib/explorer/market/source/coin_market_cap.ex index 3d4a9bbd021e..8dc6a8f4ed37 100644 --- a/apps/explorer/lib/explorer/market/source/coin_market_cap.ex +++ b/apps/explorer/lib/explorer/market/source/coin_market_cap.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.CoinMarketCap do @moduledoc """ Adapter for fetching exchange rates from https://coinmarketcap.com/api/ @@ -119,7 +120,8 @@ defmodule Explorer.Market.Source.CoinMarketCap do symbol: String.upcase(token_properties["symbol"]), fiat_value: Source.to_decimal(currency_id["price"]), volume_24h: Source.to_decimal(currency_id["volume_24h"]), - image_url: nil + image_url: nil, + circulating_supply: Source.to_decimal(token_properties["circulating_supply"]) }} else nil -> @@ -160,24 +162,7 @@ defmodule Explorer.Market.Source.CoinMarketCap do for {%{"timestamp" => date, "quote" => %{^currency_id => %{"price" => opening_price}}}, closing_quote} <- Stream.zip(quotes, Stream.concat(closing_quotes, [nil])) do date = Source.maybe_get_date(date) - - case closing_quote do - %{"quote" => %{^currency_id => %{"price" => closing_price}}} -> - %{ - closing_price: Source.to_decimal(closing_price), - date: date && DateTime.to_date(date), - opening_price: Source.to_decimal(opening_price), - secondary_coin: secondary_coin? - } - - _ -> - %{ - closing_price: Source.to_decimal(opening_price), - date: date && DateTime.to_date(date), - opening_price: Source.to_decimal(opening_price), - secondary_coin: secondary_coin? - } - end + build_price_history_entry(closing_quote, currency_id, opening_price, date, secondary_coin?) end {:ok, result} @@ -189,6 +174,21 @@ defmodule Explorer.Market.Source.CoinMarketCap do end end + defp build_price_history_entry(closing_quote, currency_id, opening_price, date, secondary_coin?) do + closing_price = + case closing_quote do + %{"quote" => %{^currency_id => %{"price" => value}}} -> value + _ -> opening_price + end + + %{ + closing_price: Source.to_decimal(closing_price), + date: date && DateTime.to_date(date), + opening_price: Source.to_decimal(opening_price), + secondary_coin: secondary_coin? + } + end + defp base_url do URI.parse(config(:base_url)) end diff --git a/apps/explorer/lib/explorer/market/source/crypto_compare.ex b/apps/explorer/lib/explorer/market/source/crypto_compare.ex index 92344c868b08..b2115a8a9d47 100644 --- a/apps/explorer/lib/explorer/market/source/crypto_compare.ex +++ b/apps/explorer/lib/explorer/market/source/crypto_compare.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.CryptoCompare do @moduledoc """ Adapter for fetching market history from https://cryptocompare.com. diff --git a/apps/explorer/lib/explorer/market/source/crypto_rank.ex b/apps/explorer/lib/explorer/market/source/crypto_rank.ex index 37607db348bb..3a22bc691c66 100644 --- a/apps/explorer/lib/explorer/market/source/crypto_rank.ex +++ b/apps/explorer/lib/explorer/market/source/crypto_rank.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.CryptoRank do @moduledoc """ Adapter for fetching market history from https://cryptorank.io/. @@ -38,8 +39,12 @@ defmodule Explorer.Market.Source.CryptoRank do |> URI.to_string(), headers() ) do - {tokens_to_import, initial_tokens_len} = tokens |> Enum.reduce({[], 0}, &reduce_token(platform_id, &1, &2)) - {:ok, skip + batch_size, initial_tokens_len < batch_size, tokens_to_import} + {tokens_to_import, initial_tokens_len} = + tokens |> Enum.reduce({[], 0}, &reduce_token(platform_id, &1, &2)) + + fetch_finished? = initial_tokens_len < batch_size + new_state = if fetch_finished?, do: nil, else: skip + batch_size + {:ok, new_state, fetch_finished?, tokens_to_import} else nil -> {:error, "Platform ID not specified"} {:ok, unexpected_response} -> {:error, Source.unexpected_response_error("CryptoRank", unexpected_response)} @@ -47,37 +52,37 @@ defmodule Explorer.Market.Source.CryptoRank do end end - defp reduce_token(platform_id, %{"contracts" => [_ | _] = tokens} = token, acc) do - Enum.reduce(tokens, acc, fn - %{ - "address" => token_contract_address_hash_string, - "chainId" => ^platform_id - }, - {tokens, count} -> + defp reduce_token(platform_id, %{"contracts" => [_ | _] = tokens} = token, {tokens_to_import, count}) do + tokens + |> Enum.find_value(fn + %{"chainId" => ^platform_id, "address" => token_contract_address_hash_string} -> case Hash.Address.cast(token_contract_address_hash_string) do {:ok, token_contract_address_hash} -> fiat_value = Source.to_decimal(token["priceUSD"]) circulating_supply = Source.to_decimal(token["circulatingSupply"]) - token = %{ + %{ symbol: token["symbol"], name: token["name"], fiat_value: fiat_value, volume_24h: Source.to_decimal(token["volume24hUSD"]), circulating_market_cap: circulating_supply && fiat_value && Decimal.mult(fiat_value, circulating_supply), + circulating_supply: circulating_supply, contract_address_hash: token_contract_address_hash, type: "ERC-20" } - {[token | tokens], count + 1} - _ -> - {tokens, count + 1} + false end - _, {tokens, count} -> - {tokens, count + 1} + _ -> + false end) + |> case do + nil -> {tokens_to_import, count + 1} + token -> {[token | tokens_to_import], count + 1} + end end defp reduce_token(_, _, {tokens, count}), do: {tokens, count + 1} @@ -124,7 +129,8 @@ defmodule Explorer.Market.Source.CryptoRank do symbol: String.upcase(coin["symbol"]), fiat_value: Source.to_decimal(coin_data["price"]), volume_24h: Source.to_decimal(coin_data["volume24h"]), - image_url: Source.handle_image_url(coin["images"]["60x60"] || coin["images"]["16x16"]) + image_url: Source.handle_image_url(coin["images"]["60x60"] || coin["images"]["16x16"]), + circulating_supply: Source.to_decimal(coin["circulatingSupply"]) }} else nil -> {:error, coin_id_not_specified_error} diff --git a/apps/explorer/lib/explorer/market/source/defillama.ex b/apps/explorer/lib/explorer/market/source/defillama.ex index a92fa667daab..a740ee7e5dc9 100644 --- a/apps/explorer/lib/explorer/market/source/defillama.ex +++ b/apps/explorer/lib/explorer/market/source/defillama.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.DefiLlama do @moduledoc """ Adapter for fetching market history from https://defillama.com/. diff --git a/apps/explorer/lib/explorer/market/source/dia.ex b/apps/explorer/lib/explorer/market/source/dia.ex new file mode 100644 index 000000000000..ee98d2693905 --- /dev/null +++ b/apps/explorer/lib/explorer/market/source/dia.ex @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.Source.DIA do + @moduledoc """ + Adapter for fetching exchange rates from https://www.diadata.org/ + """ + + require Logger + + alias Explorer.Chain.Hash + alias Explorer.Market.{Source, Token} + + @behaviour Source + + @impl Source + def native_coin_fetching_enabled?, do: not is_nil(config(:coin_address_hash)) + + @impl Source + def fetch_native_coin, do: do_fetch_coin(config(:coin_address_hash), "Coin address hash not specified") + + @impl Source + def secondary_coin_fetching_enabled?, do: not is_nil(config(:secondary_coin_address_hash)) + + @impl Source + def fetch_secondary_coin, + do: do_fetch_coin(config(:secondary_coin_address_hash), "Secondary coin address hash not specified") + + @impl Source + def tokens_fetching_enabled?, do: not is_nil(config(:blockchain)) + + @impl Source + def fetch_tokens(state, batch_size) when state in [[], nil] do + case init_tokens_fetching() do + {:error, _reason} = error -> + error + + tokens_to_fetch when is_list(tokens_to_fetch) and tokens_to_fetch !== [] -> + fetch_tokens(tokens_to_fetch, batch_size) + + _ -> + {:error, "Tokens not found for configured blockchain: #{config(:blockchain)}"} + end + end + + @impl Source + def fetch_tokens(state, batch_size) do + # it safe to pattern match here because init_tokens_fetching/0 + # would have returned an error otherwise + blockchain = config(:blockchain) + {to_fetch, remaining} = Enum.split(state, batch_size) + + tasks_results = + to_fetch + |> Task.async_stream( + fn token -> + case Source.http_request( + base_url() + |> URI.append_path("/assetQuotation") + |> URI.append_path("/#{blockchain}") + |> URI.append_path("/#{token.contract_address_hash}") + |> URI.to_string(), + [] + ) do + {:ok, data} -> + token_to_import = + Map.merge(token, %{ + symbol: data["Symbol"], + name: data["Name"], + fiat_value: Source.to_decimal(data["Price"]), + volume_24h: Source.to_decimal(data["VolumeYesterdayUSD"]), + type: "ERC-20" + }) + + {:ok, token_to_import} + + {:error, reason} -> + {:error, {token, reason}} + end + end, + max_concurrency: 5, + timeout: :timer.seconds(60), + zip_input_on_exit: true + ) + |> Enum.group_by( + fn + {:ok, {:ok, _}} -> :ok + _ -> :error + end, + fn + {:ok, {:ok, token_to_import}} -> token_to_import + {:ok, {:error, {token, reason}}} -> {token, reason} + {:error, {token, reason}} -> {token, reason} + end + ) + + to_import = Map.get(tasks_results, :ok, []) + tokens_with_errors = Map.get(tasks_results, :error, []) + + remaining_with_errors = + if Enum.empty?(tokens_with_errors) do + remaining + else + Logger.error("Errors while fetching tokens from DIA: #{inspect(tokens_with_errors)}") + remaining ++ Enum.map(tokens_with_errors, fn {token, _reason} -> token end) + end + + if Enum.empty?(to_import) and !Enum.empty?(tokens_with_errors) do + {:error, tokens_with_errors} + else + {:ok, remaining_with_errors, Enum.empty?(remaining_with_errors), to_import} + end + end + + @impl Source + def native_coin_price_history_fetching_enabled?, do: not is_nil(config(:coin_address_hash)) + + @impl Source + def fetch_native_coin_price_history(previous_days), do: do_fetch_coin_price_history(previous_days, false) + + @impl Source + def secondary_coin_price_history_fetching_enabled?, do: not is_nil(config(:secondary_coin_address_hash)) + + @impl Source + def fetch_secondary_coin_price_history(previous_days), do: do_fetch_coin_price_history(previous_days, true) + + @impl Source + def market_cap_history_fetching_enabled?, do: :ignore + + @impl Source + def fetch_market_cap_history(_previous_days), do: :ignore + + @impl Source + def tvl_history_fetching_enabled?, do: :ignore + + @impl Source + def fetch_tvl_history(_previous_days), do: :ignore + + defp do_fetch_coin(coin_address_hash, coin_address_hash_not_specified_error) do + with {:coin, coin_address_hash} when not is_nil(coin_address_hash) <- {:coin, coin_address_hash}, + {:blockchain, blockchain} when not is_nil(blockchain) <- {:blockchain, config(:blockchain)}, + {:ok, %{"Price" => _price} = data} <- + Source.http_request( + base_url() + |> URI.append_path("/assetQuotation/#{blockchain}/#{coin_address_hash}") + |> URI.to_string(), + [] + ) do + {:ok, + %Token{ + available_supply: nil, + total_supply: nil, + btc_value: nil, + last_updated: Source.maybe_get_date(data["Time"]), + market_cap: nil, + tvl: nil, + name: data["Name"], + symbol: data["Symbol"], + fiat_value: Source.to_decimal(data["Price"]), + volume_24h: Source.to_decimal(data["VolumeYesterdayUSD"]), + image_url: nil, + circulating_supply: nil + }} + else + {:coin, nil} -> {:error, coin_address_hash_not_specified_error} + {:blockchain, nil} -> {:error, "Blockchain not specified"} + {:ok, unexpected_response} -> {:error, Source.unexpected_response_error("DIA", unexpected_response)} + {:error, _reason} = error -> error + end + end + + defp init_tokens_fetching do + with blockchain when not is_nil(blockchain) <- config(:blockchain), + coin_address_hash = config(:coin_address_hash), + {:ok, tokens} <- + Source.http_request( + base_url() + |> URI.append_path("/quotedAssets") + |> URI.append_query("blockchain=#{blockchain}") + |> URI.to_string(), + [] + ) do + tokens + |> Enum.reduce([], &reduce_dia_token(&1, &2, coin_address_hash)) + else + nil -> {:error, "Blockchain not specified"} + {:error, reason} -> {:error, reason} + end + end + + defp reduce_dia_token( + %{ + "Asset" => %{ + "Address" => token_contract_address_hash_string, + "Decimals" => decimals + } + }, + acc, + coin_address_hash + ) do + same_as_coin? = + !is_nil(coin_address_hash) && + String.downcase(token_contract_address_hash_string) == String.downcase(coin_address_hash) + + if same_as_coin? do + acc + else + case Hash.Address.cast(token_contract_address_hash_string) do + {:ok, token_contract_address_hash} -> [build_dia_token(token_contract_address_hash, decimals) | acc] + _ -> acc + end + end + end + + defp reduce_dia_token(_, acc, _coin_address_hash), do: acc + + defp build_dia_token(token_contract_address_hash, decimals) do + %{ + contract_address_hash: token_contract_address_hash, + decimals: decimals + } + end + + defp do_fetch_coin_price_history(previous_days, secondary_coin?) do + datetime_now = DateTime.utc_now() + unix_now = datetime_now |> DateTime.to_unix() + unix_from = unix_now - previous_days * 24 * 60 * 60 + + with {:coin, coin_address_hash} when not is_nil(coin_address_hash) <- + {:coin, if(secondary_coin?, do: config(:secondary_coin_address_hash), else: config(:coin_address_hash))}, + {:blockchain, blockchain} when not is_nil(blockchain) <- {:blockchain, config(:blockchain)}, + {:ok, %{"DataPoints" => [%{"Series" => [%{"values" => values}]}]}} <- + Source.http_request( + base_url() + |> URI.append_path("/assetChartPoints/MA120/#{blockchain}/#{coin_address_hash}") + |> URI.append_query("starttime=#{unix_from}") + |> URI.append_query("endtime=#{unix_now}") + |> URI.to_string(), + [] + ) do + values + |> Enum.reduce_while(%{}, fn value, acc -> + reduce_dia_price_history_entry(value, acc, secondary_coin?) + end) + |> case do + {:error, _reason} = error -> + error + + price_history_map -> + {:ok, Map.values(price_history_map)} + end + else + {:coin, nil} -> {:error, "#{Source.secondary_coin_string(secondary_coin?)} address hash not specified"} + {:blockchain, nil} -> {:error, "Blockchain not specified"} + {:ok, _} -> {:ok, []} + {:error, _reason} = error -> error + end + end + + defp reduce_dia_price_history_entry(value, acc, secondary_coin?) do + with time when not is_nil(time) <- List.first(value), + {:ok, datetime, _} <- DateTime.from_iso8601(time), + date = DateTime.to_date(datetime), + price when not is_nil(price) <- List.last(value) do + {:cont, + Map.update( + acc, + date, + %{ + closing_price: Source.to_decimal(price), + date: date, + opening_price: Source.to_decimal(price), + secondary_coin: secondary_coin? + }, + fn existing_entry -> + %{ + existing_entry + | opening_price: Source.to_decimal(price) + } + end + )} + else + _ -> + {:halt, {:error, "Wrong format of DIA coin price history response: #{inspect(value)}"}} + end + end + + defp base_url do + :base_url |> config() |> URI.parse() + end + + @spec config(atom()) :: term + defp config(key) do + Application.get_env(:explorer, __MODULE__)[key] + end +end diff --git a/apps/explorer/lib/explorer/market/source/mobula.ex b/apps/explorer/lib/explorer/market/source/mobula.ex index ed6756e04117..b5bdbe7f21f6 100644 --- a/apps/explorer/lib/explorer/market/source/mobula.ex +++ b/apps/explorer/lib/explorer/market/source/mobula.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.Mobula do @moduledoc """ Adapter for fetching exchange rates from https://mobula.io @@ -43,30 +44,12 @@ defmodule Explorer.Market.Source.Mobula do headers() ) do {tokens_to_import, initial_tokens_len} = - Enum.reduce(tokens, {[], 0}, fn token, {to_import, count} -> - address_hash = token["contracts"] && List.first(token["contracts"])["address"] - - case address_hash && Hash.Address.cast(address_hash) do - {:ok, token_contract_address_hash} -> - token_to_import = %{ - symbol: token["symbol"], - name: token["name"], - fiat_value: Source.to_decimal(token["price"]), - volume_24h: Source.to_decimal(token["off_chain_volume"]), - circulating_market_cap: Source.to_decimal(token["market_cap"]), - icon_url: Source.handle_image_url(token["logo"]), - contract_address_hash: token_contract_address_hash, - type: "ERC-20" - } - - {[token_to_import | to_import], count + 1} - - _ -> - {to_import, count + 1} - end - end) - - {:ok, offset + batch_size, initial_tokens_len < batch_size, tokens_to_import} + Enum.reduce(tokens, {[], 0}, &reduce_mobula_token/2) + + fetch_finished? = initial_tokens_len < batch_size + new_state = if fetch_finished?, do: nil, else: offset + batch_size + + {:ok, new_state, fetch_finished?, tokens_to_import} else nil -> {:error, "Platform ID not specified"} {:ok, unexpected_response} -> {:error, Source.unexpected_response_error("Mobula", unexpected_response)} @@ -74,6 +57,32 @@ defmodule Explorer.Market.Source.Mobula do end end + defp reduce_mobula_token(token, {to_import, count}) do + address_hash = token["contracts"] && List.first(token["contracts"])["address"] + + to_import_updated = + case address_hash && Hash.Address.cast(address_hash) do + {:ok, token_contract_address_hash} -> [build_mobula_token(token, token_contract_address_hash) | to_import] + _ -> to_import + end + + {to_import_updated, count + 1} + end + + defp build_mobula_token(token, token_contract_address_hash) do + %{ + symbol: token["symbol"], + name: token["name"], + fiat_value: Source.to_decimal(token["price"]), + volume_24h: Source.to_decimal(token["off_chain_volume"]), + circulating_market_cap: Source.to_decimal(token["market_cap"]), + circulating_supply: Source.to_decimal(token["circulating_supply"]), + icon_url: Source.handle_image_url(token["logo"]), + contract_address_hash: token_contract_address_hash, + type: "ERC-20" + } + end + @impl Source def native_coin_price_history_fetching_enabled?, do: not is_nil(config(:coin_id)) @@ -120,7 +129,8 @@ defmodule Explorer.Market.Source.Mobula do symbol: data["symbol"], fiat_value: Source.to_decimal(data["price"]), volume_24h: Source.to_decimal(data["off_chain_volume"]), - image_url: Source.handle_image_url(data["logo"]) + image_url: Source.handle_image_url(data["logo"]), + circulating_supply: Source.to_decimal(data["circulating_supply"]) }} else nil -> diff --git a/apps/explorer/lib/explorer/market/token.ex b/apps/explorer/lib/explorer/market/token.ex index 1b5b0797bd2b..91f702ef9938 100644 --- a/apps/explorer/lib/explorer/market/token.ex +++ b/apps/explorer/lib/explorer/market/token.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Token do @moduledoc """ Data container for modeling an exchange rate for a currency/token. @@ -18,6 +19,7 @@ defmodule Explorer.Market.Token do * `:fiat_value` - The fiat value of the currency * `:volume_24h` - The volume from the last 24 hours * `:image_url` - Token image URL + * `:circulating_supply` - Circulating supply of a token """ @type t :: %__MODULE__{ available_supply: Decimal.t() | nil, @@ -30,12 +32,13 @@ defmodule Explorer.Market.Token do symbol: String.t() | nil, fiat_value: Decimal.t() | nil, volume_24h: Decimal.t() | nil, - image_url: String.t() | nil + image_url: String.t() | nil, + circulating_supply: Decimal.t() | nil } @derive Jason.Encoder - @enforce_keys ~w(available_supply total_supply btc_value last_updated market_cap tvl name symbol fiat_value volume_24h image_url)a - defstruct ~w(available_supply total_supply btc_value last_updated market_cap tvl name symbol fiat_value volume_24h image_url)a + @enforce_keys ~w(available_supply total_supply btc_value last_updated market_cap tvl name symbol fiat_value volume_24h image_url circulating_supply)a + defstruct ~w(available_supply total_supply btc_value last_updated market_cap tvl name symbol fiat_value volume_24h image_url circulating_supply)a def null, do: %__MODULE__{ @@ -49,7 +52,8 @@ defmodule Explorer.Market.Token do symbol: nil, fiat_value: nil, volume_24h: nil, - image_url: nil + image_url: nil, + circulating_supply: nil } def null?(token), do: token == null() diff --git a/apps/explorer/lib/explorer/metadata_uri_validator.ex b/apps/explorer/lib/explorer/metadata_uri_validator.ex index 3320d3d41a2e..e17d0bc8d7e5 100644 --- a/apps/explorer/lib/explorer/metadata_uri_validator.ex +++ b/apps/explorer/lib/explorer/metadata_uri_validator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MetadataURIValidator do @moduledoc """ Validates metadata URI @@ -63,7 +64,8 @@ defmodule Explorer.MetadataURIValidator do """ @spec validate_uri(String.t()) :: :ok | {:error, atom()} def validate_uri(uri) do - with {:empty_host, %URI{host: host, scheme: scheme}} when host not in ["", nil] <- {:empty_host, URI.parse(uri)}, + with {:not_printable, false} <- {:not_printable, not String.printable?(uri)}, + {:empty_host, %URI{host: host, scheme: scheme}} when host not in ["", nil] <- {:empty_host, URI.parse(uri)}, {:disallowed_protocol, false} <- {:disallowed_protocol, scheme not in allowed_uri_protocols()}, {:nxdomain, ip_list} when not is_nil(ip_list) <- {:nxdomain, host_to_ip_list(host)}, {:blacklist, false} <- {:blacklist, not Enum.all?(ip_list, &allowed_ip?/1)} do diff --git a/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex b/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex index a525f42b8a92..42416ff41812 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/account_abstraction.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MicroserviceInterfaces.AccountAbstraction do @moduledoc """ Interface to interact with Blockscout Account Abstraction (EIP-4337) microservice """ + alias Explorer.HttpClient alias Explorer.Utility.Microservice - alias HTTPoison.Response require Logger @doc """ @@ -138,13 +139,13 @@ defmodule Explorer.MicroserviceInterfaces.AccountAbstraction do end defp http_get_request(url, query_params) do - case HTTPoison.get(url, [], params: query_params) do - {:ok, %Response{body: body, status_code: status_code}} + case HttpClient.get(url, [], params: query_params) do + {:ok, %{body: body, status_code: status_code}} when status_code in [200, 404] -> {:ok, response_json} = Jason.decode(body) {status_code, response_json} - {_, %Response{body: body, status_code: status_code} = error} -> + {_, %{body: body, status_code: status_code} = error} -> old_truncate = Application.get_env(:logger, :truncate) Logger.configure(truncate: :infinity) @@ -159,7 +160,7 @@ defmodule Explorer.MicroserviceInterfaces.AccountAbstraction do {:ok, response_json} = Jason.decode(body) {status_code, response_json} - {:error, %HTTPoison.Error{reason: reason}} -> + {:error, reason} -> {500, %{error: reason}} end end diff --git a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex index fe2c8792ba0f..618f211a5fba 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex @@ -1,15 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MicroserviceInterfaces.BENS do @moduledoc """ Interface to interact with Blockscout ENS microservice """ - alias Explorer.Chain + alias Explorer.{Chain, HttpClient} alias Explorer.Chain.Address.MetadataPreloader - alias Explorer.Chain.{Address, Transaction} + alias Explorer.Chain.{Address, Block, Transaction} alias Explorer.Utility.Microservice - alias HTTPoison.Response require Logger @@ -19,72 +19,95 @@ defmodule Explorer.MicroserviceInterfaces.BENS do @request_error_msg "Error while sending request to BENS microservice" @doc """ - Batch request for ENS names via POST {{baseUrl}}/api/v1/:chainId/addresses:batch-resolve-names + Batch request for ENS names via POST. + In multiprotocol mode: {{baseUrl}}/api/v1/addresses:batch-resolve with protocols in body. + In legacy mode: {{baseUrl}}/api/v1/:chainId/addresses:batch-resolve-names """ @spec ens_names_batch_request([binary()]) :: {:error, :disabled | binary() | Jason.DecodeError.t()} | {:ok, any} def ens_names_batch_request(addresses) do with :ok <- Microservice.check_enabled(__MODULE__) do - body = %{ - addresses: Enum.map(addresses, &to_string/1) - } + body = + %{addresses: Enum.map(addresses, &to_string/1)} + |> maybe_put_protocols_in_body() http_post_request(batch_resolve_name_url(), body) end end @doc """ - Request for ENS name via GET {{baseUrl}}/api/v1/:chainId/addresses:lookup + Request for address ENS name via GET. + In multiprotocol mode: {{baseUrl}}/api/v1/addresses:lookup with protocols query parameter. + In legacy mode: {{baseUrl}}/api/v1/:chainId/addresses:lookup """ @spec address_lookup(binary()) :: {:error, :disabled | binary() | Jason.DecodeError.t()} | {:ok, any} def address_lookup(address) do with :ok <- Microservice.check_enabled(__MODULE__) do - query_params = %{ - "address" => to_string(address), - "resolved_to" => true, - "owned_by" => false, - "only_active" => true, - "order" => "ASC" - } + query_params = + %{ + "address" => to_string(address), + "resolved_to" => true, + "owned_by" => false, + "only_active" => true, + "order" => "ASC" + } + |> maybe_put_protocols_param() http_get_request(address_lookup_url(), query_params) end end @doc """ - Request for ENS name via GET {{baseUrl}}/api/v1/:chainId/addresses/{address_hash} + Request for address ENS name via GET. + In multiprotocol mode: {{baseUrl}}/api/v1/addresses/{address_hash} with protocol_id query parameter (first protocol). + In legacy mode: {{baseUrl}}/api/v1/:chainId/addresses/{address_hash} """ @spec get_address(binary()) :: map() | nil def get_address(address) do result = with :ok <- Microservice.check_enabled(__MODULE__) do - http_get_request(get_address_url(address), nil) + http_get_request(get_address_url(address), maybe_protocol_id_param()) end parse_get_address_response(result) end @doc """ - Lookup for ENS domain name via GET {{baseUrl}}/api/v1/:chainId/domains:lookup + Lookup for ENS domain name via GET. + In multiprotocol mode: {{baseUrl}}/api/v1/domains:lookup with protocols query parameter. + In legacy mode: {{baseUrl}}/api/v1/:chainId/domains:lookup """ @spec ens_domain_lookup(binary()) :: {:error, :disabled | binary() | Jason.DecodeError.t()} | {:ok, any} def ens_domain_lookup(domain) do with :ok <- Microservice.check_enabled(__MODULE__) do - query_params = %{ - "name" => domain, - "only_active" => true, - "sort" => "registration_date", - "order" => "DESC" - } + query_params = + %{ + "name" => domain, + "only_active" => true, + "sort" => "registration_date", + "order" => "DESC" + } + |> maybe_put_protocols_param() http_get_request(domain_lookup_url(), query_params) end end @doc """ - Request for ENS name via GET {{baseUrl}}/api/v1/:chainId/domains:lookup + Request for ENS name via GET. + In multiprotocol mode: {{baseUrl}}/api/v1/domains:lookup + In legacy mode: {{baseUrl}}/api/v1/:chainId/domains:lookup """ @spec ens_domain_name_lookup(binary()) :: - nil | %{address_hash: binary(), expiry_date: any(), name: any(), names_count: integer(), protocol: any()} + nil + | %{ + address_hash: binary() | nil, + expiry_date: any(), + name: any(), + names_count: integer(), + protocol: any(), + protocol_dapp_url: binary() | nil, + protocol_dapp_logo: binary() | nil + } def ens_domain_name_lookup(domain) do domain |> ens_domain_lookup() |> parse_lookup_response() end @@ -92,8 +115,8 @@ defmodule Explorer.MicroserviceInterfaces.BENS do defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HTTPoison.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do + {:ok, %{body: body, status_code: 200}} -> Jason.decode(body) {_, error} -> @@ -113,8 +136,8 @@ defmodule Explorer.MicroserviceInterfaces.BENS do end defp http_get_request(url, query_params) do - case HTTPoison.get(url, [], params: query_params) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url, [], params: query_params) do + {:ok, %{body: body, status_code: 200}} -> Jason.decode(body) {_, error} -> @@ -138,10 +161,18 @@ defmodule Explorer.MicroserviceInterfaces.BENS do defp batch_resolve_name_url do # workaround for https://github.com/PSPDFKit-labs/bypass/issues/122 - if Mix.env() == :test do - "#{addresses_url()}:batch_resolve_names" - else - "#{addresses_url()}:batch-resolve-names" + cond do + Mix.env() == :test and multiprotocol?() -> + "#{addresses_url()}:batch_resolve" + + Mix.env() == :test -> + "#{addresses_url()}:batch_resolve_names" + + multiprotocol?() -> + "#{addresses_url()}:batch-resolve" + + true -> + "#{addresses_url()}:batch-resolve-names" end end @@ -166,8 +197,50 @@ defmodule Explorer.MicroserviceInterfaces.BENS do end defp base_url do - chain_id = Application.get_env(:block_scout_web, :chain_id) - "#{Microservice.base_url(__MODULE__)}/api/v1/#{chain_id}" + if multiprotocol?() do + "#{Microservice.base_url(__MODULE__)}/api/v1" + else + chain_id = Application.get_env(:block_scout_web, :chain_id) + "#{Microservice.base_url(__MODULE__)}/api/v1/#{chain_id}" + end + end + + defp protocols do + Application.get_env(:explorer, __MODULE__)[:protocols] || [] + end + + defp multiprotocol? do + protocols() != [] + end + + defp main_protocol do + List.first(protocols()) + end + + defp protocols_string do + Enum.join(protocols(), ",") + end + + defp maybe_put_protocols_param(query_params) do + if multiprotocol?() do + Map.put(query_params, "protocols", protocols_string()) + else + query_params + end + end + + defp maybe_protocol_id_param do + if multiprotocol?() do + %{"protocol_id" => main_protocol()} + end + end + + defp maybe_put_protocols_in_body(body) do + if multiprotocol?() do + Map.put(body, :protocols, protocols_string()) + else + body + end end defp parse_lookup_response( @@ -178,21 +251,24 @@ defmodule Explorer.MicroserviceInterfaces.BENS do %{ "name" => name, "expiry_date" => expiry_date, - "resolved_address" => %{"hash" => address_hash_string}, + "resolved_address" => resolved_address, "protocol" => protocol - } + } = first_item | _other ] = items }} ) do - {:ok, hash} = Chain.string_to_address_hash(address_hash_string) + hash_or_nil = resolved_address["hash"] && Chain.string_to_address_hash_or_nil(resolved_address["hash"]) + address_hash = hash_or_nil && Address.checksum(hash_or_nil) %{ name: name, expiry_date: expiry_date, names_count: Enum.count(items), - address_hash: Address.checksum(hash), - protocol: protocol + address_hash: address_hash, + protocol: protocol, + protocol_dapp_url: first_item["protocol_dapp_url"], + protocol_dapp_logo: first_item["protocol_dapp_logo"] } end @@ -201,11 +277,12 @@ defmodule Explorer.MicroserviceInterfaces.BENS do defp parse_get_address_response( {:ok, %{ - "domain" => %{ - "name" => name, - "expiry_date" => expiry_date, - "resolved_address" => %{"hash" => address_hash_string} - }, + "domain" => + %{ + "name" => name, + "expiry_date" => expiry_date, + "resolved_address" => %{"hash" => address_hash_string} + } = domain, "resolved_domains_count" => resolved_domains_count }} ) do @@ -215,7 +292,9 @@ defmodule Explorer.MicroserviceInterfaces.BENS do name: name, expiry_date: expiry_date, names_count: resolved_domains_count, - address_hash: Address.checksum(hash) + address_hash: Address.checksum(hash), + protocol_dapp_url: domain["protocol_dapp_url"], + protocol_dapp_logo: domain["protocol_dapp_logo"] } end @@ -252,4 +331,67 @@ defmodule Explorer.MicroserviceInterfaces.BENS do def maybe_preload_ens_to_address(address) do maybe_preload_meta(address, __MODULE__, &MetadataPreloader.preload_ens_to_address/1) end + + @doc """ + Preloads ENS data to the block if BENS is enabled + """ + @spec maybe_preload_ens_to_block(Block.t()) :: Block.t() + def maybe_preload_ens_to_block(block) do + maybe_preload_meta(block, __MODULE__, &MetadataPreloader.preload_ens_to_block/1) + end + + @doc """ + Preloads ENS data to the list of blocks unless disabled via DISABLE_BLOCKS_BENS_PRELOAD. + + Checks `Application.get_env(:explorer, __MODULE__, [])[:disable_blocks_bens_preload]`; + if the flag is set, the input is returned unchanged, otherwise `maybe_preload_ens/1` + is called to enrich the list with ENS names from the BENS microservice. + + ## Parameters + + - `blocks` (`MetadataPreloader.supported_input()`) — a list of block structs + (or any value accepted by `MetadataPreloader.supported_input()`) whose miner + and other address fields should be enriched with ENS domain names. + + ## Returns + + - `MetadataPreloader.supported_input()` — the original `blocks` value unchanged + when `DISABLE_BLOCKS_BENS_PRELOAD` is `true`; otherwise the same collection + with ENS names preloaded via `maybe_preload_ens/1`. + """ + @spec maybe_preload_ens_for_blocks(MetadataPreloader.supported_input()) :: + MetadataPreloader.supported_input() + def maybe_preload_ens_for_blocks(blocks) do + if Application.get_env(:explorer, __MODULE__, [])[:disable_blocks_bens_preload] do + blocks + else + maybe_preload_ens(blocks) + end + end + + @doc """ + Preloads ENS data to the list of token transfers unless disabled via DISABLE_TOKEN_TRANSFERS_BENS_PRELOAD + """ + @spec maybe_preload_ens_for_token_transfers(MetadataPreloader.supported_input()) :: + MetadataPreloader.supported_input() + def maybe_preload_ens_for_token_transfers(token_transfers) do + if Application.get_env(:explorer, __MODULE__, [])[:disable_token_transfers_bens_preload] do + token_transfers + else + maybe_preload_ens(token_transfers) + end + end + + @doc """ + Preloads ENS data to the list of transactions unless disabled via DISABLE_TRANSACTIONS_BENS_PRELOAD + """ + @spec maybe_preload_ens_for_transactions(MetadataPreloader.supported_input()) :: + MetadataPreloader.supported_input() + def maybe_preload_ens_for_transactions(transactions) do + if Application.get_env(:explorer, __MODULE__, [])[:disable_transactions_bens_preload] do + transactions + else + maybe_preload_ens(transactions) + end + end end diff --git a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex index 48a2e4342463..793ec473e413 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex @@ -1,12 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MicroserviceInterfaces.Metadata do @moduledoc """ Module to interact with Metadata microservice """ - alias Explorer.Chain - alias Explorer.Chain.{Address.MetadataPreloader, Transaction} + alias Explorer.{Chain, HttpClient} + alias Explorer.Chain.{Address.MetadataPreloader, Block, Transaction} alias Explorer.Utility.Microservice - alias HTTPoison.Response import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_meta: 3] @@ -103,8 +103,8 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do defp http_get_request(url, params, parsing_function \\ &decode_meta/1) do headers = [] - case HTTPoison.get(url, headers, params: params, recv_timeout: @request_timeout) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url, headers, params: params, recv_timeout: @request_timeout) do + {:ok, %{body: body, status_code: 200}} -> body |> Jason.decode() |> parsing_function.() {_, error} -> @@ -120,11 +120,11 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do end defp http_get_request_for_proxy_method(url, params, parsing_function) do - case HTTPoison.get(url, [], params: params, recv_timeout: config()[:proxy_requests_timeout]) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url, [], params: params, recv_timeout: config()[:proxy_requests_timeout]) do + {:ok, %{body: body, status_code: 200}} -> {200, body |> Jason.decode() |> parsing_function.()} - {_, %Response{body: body, status_code: status_code} = error} -> + {_, %{body: body, status_code: status_code} = error} -> old_truncate = Application.get_env(:logger, :truncate) Logger.configure(truncate: :infinity) @@ -139,7 +139,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do {:ok, response_json} = Jason.decode(body) {status_code, response_json} - {:error, %HTTPoison.Error{reason: reason}} -> + {:error, reason} -> {500, %{error: reason}} end end @@ -183,6 +183,14 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do maybe_preload_meta(transaction, __MODULE__, &MetadataPreloader.preload_metadata_to_transaction/1) end + @doc """ + Preloads metadata to block if Metadata microservice is enabled + """ + @spec maybe_preload_metadata_to_block(Block.t()) :: Block.t() + def maybe_preload_metadata_to_block(block) do + maybe_preload_meta(block, __MODULE__, &MetadataPreloader.preload_metadata_to_block/1) + end + defp decode_meta({:ok, %{"addresses" => addresses} = result}) do prepared_address = Enum.reduce(addresses, %{}, fn {address, meta}, acc -> diff --git a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex index 41e99b9ac4a2..e621047fdb9b 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/multichain_search.ex @@ -1,186 +1,1241 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MicroserviceInterfaces.MultichainSearch do @moduledoc """ Module to interact with Multichain search microservice """ - alias Ecto.Association.NotLoaded + alias Explorer.Chain + alias Explorer.Chain.{Address, Block, Hash, Token, Transaction, Wei} + alias Explorer.Chain.Block.Range alias Explorer.Chain.Cache.ChainId - alias Explorer.Chain.{Address, Block, Hash, Transaction} - alias Explorer.Repo + + alias Explorer.Chain.MultichainSearchDb.{ + BalancesExportQueue, + CountersExportQueue, + MainExportQueue, + TokenInfoExportQueue + } + + alias Explorer.{Helper, HttpClient, Repo} alias Explorer.Utility.Microservice - alias HTTPoison.Response + require Decimal require Logger - @addresses_chunk_size 7_000 @max_concurrency 5 @post_timeout :timer.minutes(5) - @request_error_msg "Error while sending request to Multichain Search Service" @doc """ - Performs a batch import of addresses, blocks, and transactions to the Multichain Search microservice. + Processes a batch import of data by splitting the input parameters into chunks and sending each chunk as an HTTP POST request to a configured microservice endpoint. - ## Parameters - - `params`: A map containing: - - `addresses`: List of address structs. - - `blocks`: List of block structs. - - `transactions`: List of transaction structs. + If the microservice is enabled, the function: + - Splits the input `params` into manageable chunks. + - Sends each chunk concurrently using `Task.async_stream/3` with a maximum concurrency and timeout. + - Collects the results, merging any errors and accumulating data that needs to be retried. + - Returns `{:ok, {:chunks_processed, params_chunks}}` if all chunks are processed successfully. + - Returns `{:error, data_to_retry}` if any chunk fails, where `data_to_retry` contains the addresses, block ranges, and hashes that need to be retried. - ## Returns - - `{:ok, :service_disabled}`: If the integration with Multichain Search Service is disabled. - - `{:ok, result}`: If the import was successful. - - `{:error, reason}`: If an error occurred. - """ - @spec batch_import(%{ - addresses: [Address.t()], - blocks: [Block.t()], - transactions: [Transaction.t()] - }) :: {:error, :disabled | String.t() | Jason.DecodeError.t()} | {:ok, any()} + If the microservice is disabled, returns `{:ok, :service_disabled}`. + + ## Parameters + + - `params` (`map()`): The parameters to be imported, which will be split into chunks for processing. + + ## Returns + + - `{:ok, any()}`: If all chunks are processed successfully or the service is disabled. + - `{:error, map()}`: If one or more chunks fail, with details about the data that needs to be retried. + """ + @spec batch_import(map()) :: {:error, map()} | {:ok, any()} def batch_import(params) do + # todo: rename this function to `batch_export` (and all related places in code & comments) if enabled?() do params_chunks = extract_batch_import_params_into_chunks(params) + url = batch_import_url() params_chunks |> Task.async_stream( - fn body -> http_post_request(batch_import_url(), body) end, + fn export_body -> http_post_request(url, export_body) end, max_concurrency: @max_concurrency, - timeout: @post_timeout + timeout: @post_timeout, + zip_input_on_exit: true ) - |> Enum.reduce_while({:ok, :chunks_processed}, fn - {:ok, _result}, acc -> {:cont, acc} - {:exit, _reason}, _acc -> {:halt, {:error, @request_error_msg}} - {:error, _reason}, _acc -> {:halt, {:error, @request_error_msg}} + |> Enum.reduce({:ok, {:chunks_processed, params_chunks}}, fn + {:ok, {:ok, _result}}, acc -> + acc + + {:ok, {:error, error}}, acc -> + on_error(error) + + case acc do + {:ok, {:chunks_processed, _}} -> + {:error, + %{ + addresses: error.data_to_retry.addresses, + block_ranges: error.data_to_retry.block_ranges, + hashes: error.data_to_retry.hashes, + address_coin_balances: error.data_to_retry.address_coin_balances, + address_token_balances: error.data_to_retry.address_token_balances + }} + + {:error, data_to_retry} -> + merged_data_to_retry = %{ + addresses: error.data_to_retry.addresses ++ data_to_retry.addresses, + block_ranges: error.data_to_retry.block_ranges ++ data_to_retry.block_ranges, + hashes: error.data_to_retry.hashes ++ data_to_retry.hashes, + address_coin_balances: error.data_to_retry.address_coin_balances ++ data_to_retry.address_coin_balances, + address_token_balances: + error.data_to_retry.address_token_balances ++ data_to_retry.address_token_balances + } + + {:error, merged_data_to_retry} + end + + {:exit, {export_body, reason}}, acc -> + on_error(%{ + url: url, + data_to_retry: export_body, + reason: reason + }) + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + case acc do + {:ok, {:chunks_processed, _}} -> + {:error, export_body} + + {:error, error} -> + merged_data_to_retry = %{ + addresses: error.data_to_retry.addresses ++ export_body.addresses, + block_ranges: error.data_to_retry.block_ranges ++ export_body.block_ranges, + hashes: error.data_to_retry.hashes ++ export_body.hashes, + address_coin_balances: error.data_to_retry.address_coin_balances ++ export_body.address_coin_balances, + address_token_balances: error.data_to_retry.address_token_balances ++ export_body.address_token_balances + } + + {:error, merged_data_to_retry} + end end) else {:ok, :service_disabled} end end - defp http_post_request(url, body) do - headers = [{"Content-Type", "application/json"}] + @doc """ + Processes a batch export of token info by splitting the items from db queue into chunks and sending each chunk as an HTTP POST request to a configured microservice endpoint. - case HTTPoison.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do - {:ok, %Response{body: response_body, status_code: 200}} -> - response_body |> Jason.decode() + If the microservice is enabled, the function: + - Splits the input `items_from_db_queue` into manageable chunks. + - Sends each chunk concurrently using `Task.async_stream/5` with a maximum concurrency and timeout. + - Collects the results, merging any errors and accumulating data that needs to be retried. + - Returns `{:ok, {:chunks_processed, chunks}}` if all chunks are processed successfully. + - Returns `{:error, data_to_retry}` if any chunk fails, where `data_to_retry` contains tokens that need to be retried. + + If the microservice is disabled, returns `{:ok, :service_disabled}`. - {:ok, %Response{body: response_body, status_code: status_code}} -> - old_truncate = Application.get_env(:logger, :truncate) - Logger.configure(truncate: :infinity) - - Logger.error(fn -> - [ - "Error while sending request to microservice url: #{url}, ", - "status_code: #{inspect(status_code)}, ", - "response_body: #{inspect(response_body, limit: :infinity, printable_limit: :infinity)}, ", - "request_body: #{inspect(body |> Map.drop([:api_key]), limit: :infinity, printable_limit: :infinity)}" - ] + ## Parameters + - `items_from_db_queue`: The queue items to be exported, which will be split into chunks for processing. + + ## Returns + - `{:ok, any()}`: If all chunks are processed successfully or the service is disabled. + - `{:error, map()}`: If one or more chunks fail, with details about the data that needs to be retried. + """ + @spec batch_export_token_info([ + %{ + :address_hash => Hash.Address.t() | binary(), + :data_type => :metadata | :total_supply | :counters | :market_data, + :data => map() + } + ]) :: {:ok, any()} | {:error, map()} + def batch_export_token_info(items_from_db_queue) do + if enabled?() do + url = batch_import_url() + api_key = api_key() + chain_id = to_string(ChainId.get_id()) + + chunks = + items_from_db_queue + |> Enum.chunk_every(token_info_chunk_size()) + |> Enum.map(fn chunk_items -> + %{ + api_key: api_key, + chain_id: chain_id, + tokens: Enum.map(chunk_items, &token_info_queue_item_to_http_item(&1)) + } end) - Logger.configure(truncate: old_truncate) - {:error, @request_error_msg} + chunks + |> Task.async_stream( + fn export_body -> http_post_request(url, export_body) end, + max_concurrency: @max_concurrency, + timeout: @post_timeout, + zip_input_on_exit: true + ) + |> Enum.reduce({:ok, {:chunks_processed, chunks}}, fn + {:ok, {:ok, _result}}, acc -> + acc + + {:ok, {:error, error}}, acc -> + token_info_on_error(error) + + case acc do + {:ok, {:chunks_processed, _}} -> + {:error, %{tokens: error.data_to_retry.tokens}} + + {:error, data_to_retry} -> + {:error, %{tokens: data_to_retry.tokens ++ error.data_to_retry.tokens}} + end + + {:exit, {export_body, reason}}, acc -> + token_info_on_error(%{ + url: url, + data_to_retry: export_body, + reason: reason + }) + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + case acc do + {:ok, {:chunks_processed, _}} -> + {:error, %{tokens: export_body.tokens}} + + {:error, data_to_retry} -> + {:error, %{tokens: data_to_retry.tokens ++ export_body.tokens}} + end + end) + else + {:ok, :service_disabled} + end + end + + @doc """ + Processes a batch export of counters by splitting the items from db queue into chunks + and sending each chunk as an HTTP POST request to a configured microservice endpoint. + + If the microservice is enabled, the function: + - Splits the input `items_from_db_queue` into manageable chunks. + - Sends each chunk concurrently using `Task.async_stream/5` with a maximum concurrency and timeout. + - Collects the results, merging any errors and accumulating data that needs to be retried. + - Returns `{:ok, {:chunks_processed, chunks}}` if all chunks are processed successfully. + - Returns `{:error, data_to_retry}` if any chunk fails, where `data_to_retry` contains counters that need to be retried. + + If the microservice is disabled, returns `{:ok, :service_disabled}`. + + ## Parameters + - `items_from_db_queue`: The queue items to be exported, which will be split into chunks for processing. - error -> - old_truncate = Application.get_env(:logger, :truncate) - Logger.configure(truncate: :infinity) + ## Returns + - `{:ok, any()}`: If all chunks are processed successfully or the service is disabled. + - `{:error, map()}`: If one or more chunks fail, with details about the data that needs to be retried. + """ + @spec batch_export_counters([ + %{ + :timestamp => DateTime.t(), + :counter_type => :global, + :data => map() + } + ]) :: {:ok, any()} | {:error, map()} + def batch_export_counters(items_from_db_queue) do + if enabled?() do + url = batch_import_url() + api_key = api_key() + chain_id = to_string(ChainId.get_id()) - Logger.error(fn -> - [ - "Error while sending request to microservice url: #{url}, request_body: #{inspect(body |> Map.drop([:api_key]), limit: :infinity, printable_limit: :infinity)}: ", - inspect(error, limit: :infinity, printable_limit: :infinity) - ] + chunks = + items_from_db_queue + |> Enum.chunk_every(counters_chunk_size()) + |> Enum.map(fn chunk_items -> + %{ + api_key: api_key, + chain_id: chain_id, + counters: Enum.map(chunk_items, &counter_queue_item_to_http_item(&1)) + } end) - Logger.configure(truncate: old_truncate) - {:error, @request_error_msg} + chunks + |> Task.async_stream( + fn export_body -> http_post_request(url, export_body) end, + max_concurrency: @max_concurrency, + timeout: @post_timeout, + zip_input_on_exit: true + ) + |> Enum.reduce({:ok, {:chunks_processed, chunks}}, fn + {:ok, {:ok, _result}}, acc -> + acc + + {:ok, {:error, error}}, acc -> + counter_on_error(error) + + case acc do + {:ok, {:chunks_processed, _}} -> + {:error, %{counters: error.data_to_retry.counters}} + + {:error, data_to_retry} -> + {:error, %{counters: data_to_retry.counters ++ error.data_to_retry.counters}} + end + + {:exit, {export_body, reason}}, acc -> + counter_on_error(%{ + url: url, + data_to_retry: export_body, + reason: reason + }) + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + case acc do + {:ok, {:chunks_processed, _}} -> + {:error, %{counters: export_body.counters}} + + {:error, data_to_retry} -> + {:error, %{counters: data_to_retry.counters ++ export_body.counters}} + end + end) + else + {:ok, :service_disabled} end end - defp extract_batch_import_params_into_chunks(%{ - addresses: raw_addresses, - blocks: blocks, - transactions: transactions + defp log_error(%{ + url: url, + data_to_retry: data_to_retry, + reason: reason }) do - chain_id = ChainId.get_id() - block_ranges = get_block_ranges(blocks) + old_truncate = Application.get_env(:logger, :truncate) + Logger.configure(truncate: :infinity) - addresses = - raw_addresses - |> Repo.preload([:token, :smart_contract]) - |> Enum.map(fn address -> - %{ - hash: Hash.to_string(address.hash), - is_contract: !is_nil(address.contract_code), - is_verified_contract: address.verified, - is_token: token?(address.token), - ens_name: address.ens_domain_name, - token_name: get_token_name(address.token), - token_type: get_token_type(address.token), - contract_name: get_smart_contract_name(address.smart_contract) + Logger.error(fn -> + [ + "Error while sending request to microservice url: #{url}, ", + "error_reason: #{inspect(reason, limit: :infinity, printable_limit: :infinity)}, ", + "request_body: #{inspect(data_to_retry |> Map.drop([:api_key]), limit: :infinity, printable_limit: :infinity)}" + ] + end) + + Logger.configure(truncate: old_truncate) + end + + defp log_error(%{ + url: url, + data_to_retry: data_to_retry, + status_code: status_code, + response_body: response_body + }) do + old_truncate = Application.get_env(:logger, :truncate) + Logger.configure(truncate: :infinity) + + Logger.error(fn -> + [ + "Error while sending request to microservice url: #{url}, ", + "status_code: #{inspect(status_code)}, ", + "response_body: #{inspect(response_body, limit: :infinity, printable_limit: :infinity)}, ", + "request_body: #{inspect(data_to_retry |> Map.drop([:api_key]), limit: :infinity, printable_limit: :infinity)}" + ] + end) + + Logger.configure(truncate: old_truncate) + end + + @spec on_error(map()) :: {non_neg_integer(), nil | [term()]} | :ok + defp on_error( + %{ + data_to_retry: data_to_retry + } = error + ) do + log_error(error) + + {prepared_main_data, prepared_balances_data} = prepare_export_data_for_queue(data_to_retry) + + Repo.insert_all( + MainExportQueue, + Helper.add_timestamps(prepared_main_data), + on_conflict: MainExportQueue.default_on_conflict(), + conflict_target: [:hash, :hash_type] + ) + + Repo.insert_all( + BalancesExportQueue, + Helper.add_timestamps(prepared_balances_data), + on_conflict: BalancesExportQueue.default_on_conflict(), + conflict_target: + {:unsafe_fragment, ~s<(address_hash, token_contract_address_hash_or_native, COALESCE(token_id, -1))>} + ) + end + + defp on_error(_), do: :ignore + + # Logs error when trying to send token info from the queue to Multichain service + # and increments `retries_number` counter of the corresponding queue items. + # + # ## Parameters + # - `error`: A map with the queue items. + # + # ## Returns + # - Nothing. + @spec token_info_on_error(%{ + :data_to_retry => %{:tokens => [map()], optional(any()) => any()}, + optional(any()) => any() + }) :: any() + defp token_info_on_error(%{data_to_retry: data_to_retry} = error) do + log_error(error) + + prepared_token_info_data = + data_to_retry.tokens + |> Enum.map(&token_info_http_item_to_queue_item(&1)) + |> Helper.add_timestamps() + + Repo.insert_all( + TokenInfoExportQueue, + prepared_token_info_data, + on_conflict: TokenInfoExportQueue.increase_retries_on_conflict(), + conflict_target: [:address_hash, :data_type] + ) + end + + defp token_info_on_error(_), do: :ignore + + # Logs error when trying to send counter from the queue to Multichain service + # and increments `retries_number` counter of the corresponding queue items. + # + # ## Parameters + # - `error`: A map with the queue items. + # + # ## Returns + # - Nothing. + @spec counter_on_error(%{ + :data_to_retry => %{:counters => [map()], optional(any()) => any()}, + optional(any()) => any() + }) :: any() + defp counter_on_error(%{data_to_retry: data_to_retry} = error) do + log_error(error) + + prepared_counters_data = + data_to_retry.counters + |> Enum.map(&counter_http_item_to_queue_item(&1)) + |> Helper.add_timestamps() + + Repo.insert_all( + CountersExportQueue, + prepared_counters_data, + on_conflict: CountersExportQueue.increase_retries_on_conflict(), + conflict_target: [:timestamp, :counter_type] + ) + end + + defp counter_on_error(_), do: :ignore + + @doc """ + Converts database queue item with token info to the item ready to send to Multichain service via HTTP. + + ## Parameters + - `item_from_db_queue`: The queue item map from database. + + ## Returns + - A map ready to send to Multichain service via HTTP. + """ + @spec token_info_queue_item_to_http_item(%{ + :address_hash => Hash.Address.t() | binary(), + :data_type => :metadata | :total_supply | :counters | :market_data, + :data => map() + }) :: + %{:address_hash => String.t(), :metadata => map()} + | %{:address_hash => String.t(), :counters => map()} + | %{:address_hash => String.t(), :price_data => map()} + def token_info_queue_item_to_http_item(item_from_db_queue) do + token = %{address_hash: item_from_db_queue.address_hash |> cast_address_hash!() |> Hash.to_string()} + + case item_from_db_queue.data_type do + :metadata -> Map.put(token, :metadata, item_from_db_queue.data) + :total_supply -> Map.put(token, :metadata, item_from_db_queue.data) + :counters -> Map.put(token, :counters, item_from_db_queue.data) + :market_data -> Map.put(token, :price_data, item_from_db_queue.data) + end + end + + @doc """ + Converts queue item (containing token info) ready to send to Multichain service via HTTP + to the queue item ready to be written to the database. + + ## Parameters + - `http_item`: The queue item for HTTP. + + ## Returns + - A map ready to write to the database. + """ + @spec token_info_http_item_to_queue_item( + %{:address_hash => String.t(), :metadata => map()} + | %{:address_hash => String.t(), :counters => map()} + | %{:address_hash => String.t(), :price_data => map()} + ) :: %{ + :address_hash => Hash.Address.t(), + :data_type => :metadata | :total_supply | :counters | :market_data, + :data => map() } + def token_info_http_item_to_queue_item(%{address_hash: "0x" <> address_string} = http_item) do + address_hash = cast_address_hash!("0x" <> address_string) + + metadata = Map.get(http_item, :metadata) + + {data_type, data} = + cond do + !is_nil(metadata) and (!is_nil(Map.get(metadata, :token_type)) or !is_nil(Map.get(metadata, "token_type"))) -> + {:metadata, http_item[:metadata]} + + !is_nil(metadata) -> + {:total_supply, http_item[:metadata]} + + !is_nil(Map.get(http_item, :counters)) -> + {:counters, http_item[:counters]} + + !is_nil(Map.get(http_item, :price_data)) -> + {:market_data, http_item[:price_data]} + end + + %{ + address_hash: address_hash, + data_type: data_type, + data: data + } + end + + @doc """ + Converts database queue item with counters to the item ready to send to Multichain service via HTTP. + + ## Parameters + - `item_from_db_queue`: The queue item map from database. + + ## Returns + - A map ready to send to Multichain service via HTTP. + """ + @spec counter_queue_item_to_http_item(%{ + :timestamp => DateTime.t(), + :counter_type => :global, + :data => map() + }) :: %{:timestamp => String.t(), :global_counters => map()} + def counter_queue_item_to_http_item(item_from_db_queue) do + counter = %{timestamp: to_string(DateTime.to_unix(item_from_db_queue.timestamp))} + + case item_from_db_queue.counter_type do + :global -> Map.put(counter, :global_counters, item_from_db_queue.data) + end + end + + @doc """ + Converts queue item (containing counter data) ready to send to Multichain service via HTTP + to the queue item ready to be written to the database. + + ## Parameters + - `http_item`: The queue item for HTTP. + + ## Returns + - A map ready to write to the database. + """ + @spec counter_http_item_to_queue_item(%{:timestamp => String.t(), :global_counters => map()}) :: %{ + :timestamp => DateTime.t(), + :counter_type => :global, + :data => map() + } + def counter_http_item_to_queue_item(%{timestamp: timestamp_string} = http_item) do + timestamp_integer = String.to_integer(timestamp_string) + + %{ + timestamp: DateTime.from_unix!(timestamp_integer * 1_000_000, :microsecond), + counter_type: :global, + data: http_item[:global_counters] + } + end + + @doc """ + Sends provided blockchain data to the appropriate export queues for further processing. + + Accepts a map containing lists of addresses, blocks, transactions, and address current token balances. + If all lists are empty, returns `:ignore`. Otherwise, if the export functionality is enabled, + the data is split into chunks, prepared, and inserted into the `MainExportQueue` and `BalancesExportQueue` tables. + If the export functionality is disabled, returns `:ignore`. + + ## Parameters + + - `data`: A map with the following keys: + - `:addresses` - List of address data. + - `:blocks` - List of block data. + - `:transactions` - List of transaction data. + - `:address_current_token_balances` - List of address token balance data. + + ## Returns + + - `:ok` if the data was successfully sent to the queues. + - `:ignore` if the data is empty or the export functionality is disabled. + """ + @spec send_data_to_queue(map()) :: :ignore | :ok + def send_data_to_queue(%{addresses: [], blocks: [], transactions: [], address_current_token_balances: []}), + do: :ignore + + def send_data_to_queue(data) do + if enabled?() do + data + |> extract_batch_import_params_into_chunks() + |> Enum.each(fn data_chunk -> + {prepared_main_data, prepared_balances_data} = prepare_export_data_for_queue(data_chunk) + + Repo.insert_all(MainExportQueue, Helper.add_timestamps(prepared_main_data), on_conflict: :nothing) + + Repo.insert_all(BalancesExportQueue, Helper.add_timestamps(prepared_balances_data), + on_conflict: {:replace, [:value, :updated_at]}, + conflict_target: + {:unsafe_fragment, + ~s<(address_hash, token_contract_address_hash_or_native, COALESCE(token_id, -1::integer::numeric))>} + ) end) - |> Enum.uniq() - block_hashes = - blocks - |> Enum.map( - &%{ - hash: Hash.to_string(&1.hash), - hash_type: "BLOCK" + :ok + else + :ignore + end + end + + @doc """ + Prepares token metadata for writing to database queue and subsequent sending to Multichain service. + + ## Parameters + - `token`: An instance of `Token.t()` containing token type and probably `icon_url`. + - `metadata`: A map with token metadata. + + ## Returns + - A map containing token type and its metadata in the format approved on Multichain service. + """ + @spec prepare_token_metadata_for_queue(Token.t(), %{ + :token_type => String.t(), + optional(:name) => String.t(), + optional(:symbol) => String.t(), + optional(:decimals) => non_neg_integer(), + optional(:total_supply) => non_neg_integer(), + optional(any()) => any() + }) :: %{ + optional(:token_type) => String.t(), + optional(:name) => String.t(), + optional(:symbol) => String.t(), + optional(:decimals) => String.t(), + optional(:total_supply) => String.t(), + optional(:icon_url) => String.t() } - ) + def prepare_token_metadata_for_queue(%Token{} = token, metadata) do + if enabled?() do + %{token_type: token.type} + |> token_optional_field(metadata, :name) + |> token_optional_field(metadata, :symbol) + |> token_optional_field(token, :icon_url) + |> token_optional_field(metadata, :decimals) + |> token_optional_field(metadata, :total_supply, true) + else + %{} + end + end + + @doc """ + Prepares token total supply for writing to database queue and subsequent sending to Multichain service. + + ## Parameters + - `total_supply`: The total supply value. Can be `nil`. + + ## Returns + - A map containing total supply in the format approved on Multichain service. + - `nil` if the `total_supply` parameter is `nil`. + """ + @spec prepare_token_total_supply_for_queue(non_neg_integer() | nil) :: %{:total_supply => String.t()} | nil + def prepare_token_total_supply_for_queue(nil), do: nil - transaction_hashes = - transactions + def prepare_token_total_supply_for_queue(total_supply) do + if enabled?() do + %{total_supply: to_string(total_supply)} + end + end + + @doc """ + Prepares token market data (such as price and market cap) for writing to database queue + and subsequent sending to Multichain service. + + ## Parameters + - `token`: A token map containing the market data. + + ## Returns + - A map containing the market data in the format approved on Multichain service. + """ + @spec prepare_token_market_data_for_queue(%{ + optional(:fiat_value) => Decimal.t(), + optional(:circulating_market_cap) => Decimal.t(), + optional(any()) => any() + }) :: map() + def prepare_token_market_data_for_queue(token) do + if enabled?() do + %{} + |> token_optional_field(token, :fiat_value) + |> token_optional_field(token, :circulating_market_cap) + |> Enum.map(fn {key, value} -> + {key, Decimal.to_string(value, :normal)} + end) + |> Enum.into(%{}) + else + %{} + end + end + + @doc """ + Prepares token counters for writing to database queue and subsequent sending to Multichain service. + + ## Parameters + - `transfer_count`: The number of the token transfers count. + - `holder_count`: The number of the token holders count. + + ## Returns + - A map containing the counters in the format approved on Multichain service. + """ + @spec prepare_token_counters_for_queue(non_neg_integer(), non_neg_integer()) :: %{ + :transfers_count => String.t(), + :holders_count => String.t() + } + def prepare_token_counters_for_queue(transfers_count, holders_count) do + if enabled?() do + %{transfers_count: to_string(transfers_count), holders_count: to_string(holders_count)} + else + %{} + end + end + + @spec filter_addresses_to_multichain_import( + [Address.t()], + atom() | nil + ) :: [Address.t()] + def filter_addresses_to_multichain_import(addresses, :on_demand) do + addresses + |> Enum.filter(fn %Address{ + fetched_coin_balance: fetched_coin_balance, + transactions_count: transactions_count, + token_transfers_count: token_transfers_count + } -> + case fetched_coin_balance do + %Wei{value: value} -> Decimal.compare(value, 0) == :gt + _ -> false + end || + (is_number(transactions_count) and transactions_count > 0) || + (is_number(token_transfers_count) and token_transfers_count > 0) + end) + end + + def filter_addresses_to_multichain_import(addresses, _broadcast) do + addresses + end + + defp token_optional_field(data, metadata, key, convert_to_string \\ false) do + case Map.get(metadata, key) do + nil -> + data + + value -> + if convert_to_string do + Map.put(data, key, to_string(value)) + else + Map.put(data, key, value) + end + end + end + + @doc """ + Writes token info to database queue to send that to Multichain service later. + + ## Parameters + - `entries`: A map of token entries with data prepared with one of the `prepare_token_*` functions. + - `entries_type`: A type of the token entries. + + ## Returns + # - `:ok` if the data is accepted for insertion. + # - `:ignore` if the Multichain service is not used. + """ + @spec send_token_info_to_queue( + %{(Hash.Address.t() | binary()) => map()}, + :metadata | :total_supply | :counters | :market_data + ) :: + :ok | :ignore + def send_token_info_to_queue(entries, entries_type) do + if enabled?() do + entries + |> extract_token_info_entries_into_chunks(entries_type) + |> Enum.each(fn chunk -> + Repo.insert_all( + TokenInfoExportQueue, + Helper.add_timestamps(chunk), + on_conflict: {:replace, [:data, :updated_at]}, + conflict_target: [:address_hash, :data_type] + ) + end) + + :ok + else + :ignore + end + end + + @spec extract_token_info_entries_into_chunks( + %{(Hash.Address.t() | binary()) => map()}, + :metadata | :total_supply | :counters | :market_data + ) :: list() + defp extract_token_info_entries_into_chunks(entries, entries_type) do + entries + |> Enum.map(fn {address_hash, data} -> + %{ + address_hash: cast_address_hash!(address_hash), + data_type: entries_type, + data: data + } + end) + |> Enum.chunk_every(token_info_chunk_size()) + end + + defp cast_address_hash!(address_hash) do + case Hash.Address.cast(address_hash) do + {:ok, cast_address_hash} -> cast_address_hash + :error -> raise ArgumentError, "invalid token info address_hash: #{inspect(address_hash)}" + end + end + + @doc """ + Writes counters to database queue to send those to Multichain service later. + + ## Parameters + - `entries`: A map of counter entries. + - `entries_type`: A type of the counter entries. + + ## Returns + # - `:ok` if the data is accepted for insertion. + # - `:ignore` if the Multichain service is not used. + """ + @spec send_counters_to_queue(%{DateTime.t() => map()}, :global) :: :ok | :ignore + def send_counters_to_queue(entries, entries_type) do + if enabled?() do + entries + |> extract_counter_entries_into_chunks(entries_type) + |> Enum.each(fn chunk -> + Repo.insert_all( + CountersExportQueue, + Helper.add_timestamps(chunk), + on_conflict: {:replace, [:data, :updated_at]}, + conflict_target: [:timestamp, :counter_type] + ) + end) + + :ok + else + :ignore + end + end + + # Takes a map of counter entries and makes a list of the entries divided into chunks. + # The chunk max size is defined by `MICROSERVICE_MULTICHAIN_SEARCH_COUNTERS_CHUNK_SIZE` env variable. + # + # ## Parameters + # - `entries`: A map of the counter entries. + # - `entries_type`: A type of the counter entries. + # + # ## Returns + # - A list of chunks with the entries. + @spec extract_counter_entries_into_chunks(%{DateTime.t() => map()}, :global) :: list() + defp extract_counter_entries_into_chunks(entries, entries_type) do + entries + |> Enum.map(fn {timestamp, data} -> + %{ + timestamp: timestamp, + counter_type: entries_type, + data: data + } + end) + |> Enum.chunk_every(counters_chunk_size()) + end + + # sobelow_skip ["DOS.StringToAtom"] + defp prepare_export_data_for_queue(%{ + addresses: addresses, + hashes: hashes, + block_ranges: block_ranges, + address_coin_balances: address_coin_balances, + address_token_balances: address_token_balances + }) do + block_range = + case block_ranges do + [%{min_block_number: nil, max_block_number: nil} | _] -> + nil + + [%{min_block_number: min_str, max_block_number: max_str} | _] -> + with {min_num, ""} <- Integer.parse(min_str), + {max_num, ""} <- Integer.parse(max_str) do + %Range{from: min_num, to: max_num} + else + _ -> nil + end + + _ -> + nil + end + + hashes_to_queue = + hashes |> Enum.map( &%{ - hash: Hash.to_string(&1.hash), - hash_type: "TRANSACTION" + hash: Helper.hash_to_binary(&1.hash), + hash_type: &1.hash_type |> String.downcase() |> String.to_atom(), + block_range: block_range } ) + addresses_to_queue = + addresses + |> Enum.map(fn %{hash: address_hash_string} -> + %{ + hash: Helper.hash_to_binary(address_hash_string), + hash_type: :address, + block_range: block_range + } + end) + + main_queue = hashes_to_queue ++ addresses_to_queue + + balances_queue = compose_balances_queue(address_coin_balances, address_token_balances) + + {main_queue, balances_queue} + end + + defp prepare_export_data_for_queue(%{ + address_coin_balances: address_coin_balances, + address_token_balances: address_token_balances + }) do + balances_queue = compose_balances_queue(address_coin_balances, address_token_balances) + + {[], balances_queue} + end + + defp compose_balances_queue(address_coin_balances, address_token_balances) do + coin_balances_queue = + address_coin_balances + |> Enum.map(fn %{address_hash: address_hash, value: value} -> + %{ + address_hash: address_hash |> Chain.string_to_address_hash() |> elem(1), + token_contract_address_hash_or_native: "native", + value: value + } + end) + + token_balances_queue = + address_token_balances + |> Enum.map(fn %{ + address_hash: address_hash, + token_address_hash: token_address_hash, + value: value, + token_id: token_id + } -> + %{ + address_hash: address_hash |> Chain.string_to_address_hash() |> elem(1), + token_contract_address_hash_or_native: Helper.hash_to_binary(token_address_hash), + # value is of Wei type in Explorer.Chain.Address.CoinBalance + # value is of Decimal type in Explorer.Chain.Address.TokenBalance + value: if(Decimal.is_decimal(value), do: value |> Wei.cast() |> elem(1), else: value), + token_id: token_id + } + end) + + coin_balances_queue ++ token_balances_queue + end + + @spec http_post_request(String.t(), map()) :: {:ok, any()} | {:error, String.t()} + defp http_post_request(url, body) do + headers = [{"Content-Type", "application/json"}] + + case HttpClient.post(url, Jason.encode!(body), headers, + recv_timeout: @post_timeout, + pool: false + ) do + {:ok, %{body: response_body, status_code: 200}} -> + response_body |> Jason.decode() + + {:ok, %{body: response_body, status_code: status_code}} -> + {:error, + %{ + url: url, + data_to_retry: body, + status_code: status_code, + response_body: response_body + }} + + {:error, reason} -> + {:error, + %{ + url: url, + data_to_retry: body, + reason: reason + }} + end + end + + @doc """ + Extracts and organizes batch import parameters into chunks suitable for microservice requests. + + Given a map of parameters, this function: + - Uniquely formats and chunks addresses. + - Prepares block ranges and hashes (from blocks, block hashes, and transactions). + - Associates each chunk with the current API key and chain ID. + - Ensures that the first chunk includes all block ranges and hashes, while subsequent chunks only contain address data. + + Returns a list of maps, each representing a chunk of import parameters with the following structure: + - `:api_key` - The API key as a string. + - `:chain_id` - The chain ID as a string. + - `:addresses` - A list of formatted address strings. + - `:block_ranges` - A list of maps with `:min_block_number` and `:max_block_number` as strings. + - `:hashes` - A list of maps with `:hash` and `:hash_type` as strings. + + If there are no addresses, a single chunk is returned containing only the hashes and block ranges. + """ + @spec extract_batch_import_params_into_chunks(map()) :: [ + %{ + api_key: String.t(), + chain_id: String.t(), + addresses: [String.t()], + block_ranges: [%{min_block_number: String.t(), max_block_number: String.t()}], + hashes: [%{hash: String.t(), hash_type: String.t()}], + address_token_balances: [map()], + address_coin_balances: [map()] + } + ] + def extract_batch_import_params_into_chunks(params) do + block_ranges = + if Map.has_key?(params, :block_ranges), + do: params.block_ranges, + else: get_block_ranges(Map.get(params, :blocks, [])) + + {addresses, coin_balances_from_addresses_list} = + params + |> Map.get(:addresses, []) + |> Repo.preload([:token, :smart_contract]) + |> Enum.reduce({[], []}, fn address, {acc_addresses, acc_coin_balances} -> + {[format_address(address) | acc_addresses], [format_address_coin_balance(address) | acc_coin_balances]} + end) + + address_coin_balances = + if Map.has_key?(params, :address_coin_balances) do + params.address_coin_balances + |> Enum.map(fn address_coin_balance -> + %{ + address_hash: Hash.to_string(address_coin_balance.address_hash), + token_contract_address_hash_or_native: "native", + value: address_coin_balance.value + } + end) + else + coin_balances_from_addresses_list + end + + block_hashes = + if Map.has_key?(params, :block_hashes), + do: format_block_hashes(params.block_hashes), + else: format_blocks(Map.get(params, :blocks, [])) + + transaction_hashes = format_transactions(Map.get(params, :transactions, [])) + block_transaction_hashes = block_hashes ++ transaction_hashes indexed_addresses_chunks = addresses - |> Enum.chunk_every(@addresses_chunk_size) + |> Enum.sort_by(& &1.hash) + |> Enum.uniq() + |> Enum.chunk_every(addresses_chunk_size()) + |> Enum.with_index() + + indexed_address_coin_balances_chunks = + address_coin_balances + |> Enum.sort_by(& &1.address_hash) + |> Enum.uniq() + |> Enum.reject(&is_nil(&1.value)) + |> Enum.chunk_every(addresses_chunk_size()) |> Enum.with_index() + address_token_balances = + cond do + Map.has_key?(params, :address_current_token_balances) -> + params.address_current_token_balances + |> Enum.map(&format_address_token_balance/1) + + Map.has_key?(params, :address_token_balances) -> + params.address_token_balances + |> Enum.map(fn address_token_balance -> + %{ + address_hash: Hash.to_string(address_token_balance.address_hash), + token_address_hash: address_token_balance.token_contract_address_hash, + token_id: address_token_balance.token_id, + value: address_token_balance.value + } + end) + + true -> + [] + end + + sanitized_address_token_balances = + address_token_balances + |> Enum.reject(&(is_nil(&1.value) && is_nil(&1.token_id))) + + api_key = api_key() + chain_id = ChainId.get_id() + chain_id_string = to_string(chain_id) + + base_data_chunk = %{ + api_key: api_key, + chain_id: chain_id_string, + addresses: [], + block_ranges: [], + hashes: [], + address_token_balances: [], + address_coin_balances: [] + } + + prepare_chunk(indexed_addresses_chunks, indexed_address_coin_balances_chunks, base_data_chunk, %{ + block_transaction_hashes: block_transaction_hashes, + block_ranges: block_ranges, + address_token_balances: sanitized_address_token_balances + }) + end + + defp address_token_balances_chunk_by_index(address_token_balances, 0), do: address_token_balances + + defp address_token_balances_chunk_by_index(_address_token_balances, _index), do: [] + + defp prepare_chunk([], [], base_data_chunk, %{ + block_transaction_hashes: block_transaction_hashes, + block_ranges: block_ranges, + address_token_balances: address_token_balances + }) do + [ + base_data_chunk + |> Map.put(:hashes, block_transaction_hashes) + |> Map.put(:block_ranges, block_ranges) + |> Map.put(:address_token_balances, address_token_balances) + ] + end + + defp prepare_chunk(indexed_addresses_chunks, indexed_address_coin_balances_chunks, base_data_chunk, %{ + block_transaction_hashes: block_transaction_hashes, + block_ranges: block_ranges, + address_token_balances: address_token_balances + }) + when indexed_addresses_chunks != [] do Enum.map(indexed_addresses_chunks, fn {addresses_chunk, index} -> - hashes = if index == 0, do: block_transaction_hashes, else: [] + # credo:disable-for-lines:3 Credo.Check.Refactor.Nesting + hashes_in_chunk = if index == 0, do: block_transaction_hashes, else: [] + block_ranges_in_chunk = if index == 0, do: block_ranges, else: [] + address_token_balances_in_chunk = address_token_balances_chunk_by_index(address_token_balances, index) - %{ - api_key: api_key(), - chain_id: to_string(chain_id), - addresses: addresses_chunk, - block_ranges: block_ranges, - hashes: hashes - } + address_coin_balances_chunk = + case Enum.fetch(indexed_address_coin_balances_chunks, index) do + {:ok, {chunk, ^index}} when is_list(chunk) -> chunk + _ -> [] + end + + base_data_chunk + |> Map.put(:addresses, addresses_chunk) + |> Map.put( + :address_coin_balances, + address_coin_balances_chunk + ) + |> Map.put(:hashes, hashes_in_chunk) + |> Map.put(:block_ranges, block_ranges_in_chunk) + |> Map.put(:address_token_balances, address_token_balances_in_chunk) end) end - defp token?(nil), do: false + defp prepare_chunk(_indexed_addresses_chunks, indexed_address_coin_balances_chunks, base_data_chunk, %{ + address_token_balances: address_token_balances + }) + when indexed_address_coin_balances_chunks != [] do + Enum.map(indexed_address_coin_balances_chunks, fn {indexed_address_coin_balance_chunk, index} -> + address_token_balances_in_chunk = address_token_balances_chunk_by_index(address_token_balances, index) - defp token?(%NotLoaded{}), do: false + base_data_chunk + |> Map.put( + :address_coin_balances, + indexed_address_coin_balance_chunk + ) + |> Map.put(:address_token_balances, address_token_balances_in_chunk) + end) + end - defp token?(_), do: true + defp format_address(address) do + %{ + hash: Hash.to_string(address.hash), + is_contract: !is_nil(address.contract_code), + is_verified_contract: address.verified, + contract_name: get_smart_contract_name(address.smart_contract) + } + end - defp get_token_name(nil), do: nil + defp format_address_coin_balance(address) do + %{ + address_hash: Hash.to_string(address.hash), + value: address.fetched_coin_balance + } + end - defp get_token_name(%NotLoaded{}), do: nil + defp format_address_token_balance(address_current_token_balance) do + %{ + address_hash: Hash.to_string(address_current_token_balance.address_hash), + token_address_hash: Hash.to_string(address_current_token_balance.token_contract_address_hash), + token_id: address_current_token_balance.token_id, + value: address_current_token_balance.value + } + end - defp get_token_name(token), do: token.name + @spec format_blocks([Block.t() | %{hash: String.t(), hash_type: String.t()}]) :: [ + %{hash: String.t(), hash_type: String.t()} + ] + defp format_blocks(blocks) do + blocks + |> Enum.map(&format_block_hash(to_string(&1.hash))) + end - defp get_smart_contract_name(nil), do: nil + @spec format_block_hashes([Hash.t()]) :: [ + %{hash: String.t(), hash_type: String.t()} + ] + defp format_block_hashes(block_hashes) do + block_hashes + |> Enum.map(&format_block_hash(to_string(&1))) + end - defp get_smart_contract_name(%NotLoaded{}), do: nil + @spec format_transactions([Transaction.t() | %{hash: String.t(), hash_type: String.t()}]) :: [ + %{hash: String.t(), hash_type: String.t()} + ] + defp format_transactions(transactions) do + transactions + |> Enum.map(&format_transaction(to_string(&1.hash))) + end - defp get_smart_contract_name(smart_contract), do: smart_contract.name + @spec format_block_hash(String.t()) :: %{ + hash: String.t(), + hash_type: String.t() + } + defp format_block_hash(block_hash_string) do + %{ + hash: block_hash_string, + hash_type: "BLOCK" + } + end - defp get_token_type(nil), do: "UNSPECIFIED" + @spec format_transaction(String.t()) :: %{ + hash: String.t(), + hash_type: String.t() + } + defp format_transaction(transaction_hash_string) do + %{ + hash: transaction_hash_string, + hash_type: "TRANSACTION" + } + end - defp get_token_type(%NotLoaded{}), do: "UNSPECIFIED" + defp get_smart_contract_name(nil), do: nil - defp get_token_type(token), do: token.type + defp get_smart_contract_name(%NotLoaded{}), do: nil + + defp get_smart_contract_name(smart_contract), do: smart_contract.name defp get_block_ranges([]), do: [] @@ -198,7 +1253,14 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do ] end - defp batch_import_url do + @doc """ + Returns a full URL to the Multichain service API import endpoint. + + ## Returns + - A string containing the URL. + """ + @spec batch_import_url() :: String.t() + def batch_import_url do "#{base_url()}/import:batch" end @@ -212,7 +1274,15 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do end end - defp api_key do + @doc """ + Returns an API key for the Multichain service. + + ## Returns + - A string containing the API key. + - `nil` if the key is not defined. + """ + @spec api_key() :: String.t() | nil + def api_key do Microservice.api_key(__MODULE__) end @@ -233,4 +1303,16 @@ defmodule Explorer.MicroserviceInterfaces.MultichainSearch do @return `true` if the base URL is not nil, `false` otherwise. """ def enabled?, do: !is_nil(base_url()) + + defp addresses_chunk_size do + Application.get_env(:explorer, __MODULE__)[:addresses_chunk_size] + end + + defp token_info_chunk_size do + Application.get_env(:explorer, __MODULE__)[:token_info_chunk_size] + end + + defp counters_chunk_size do + Application.get_env(:explorer, __MODULE__)[:counters_chunk_size] + end end diff --git a/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex b/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex new file mode 100644 index 000000000000..a75acd11afc0 --- /dev/null +++ b/apps/explorer/lib/explorer/microservice_interfaces/tac_operation_lifecycle.ex @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.MicroserviceInterfaces.TACOperationLifecycle do + @moduledoc """ + Interface to interact with Tac Operation Lifecycle Service (https://github.com/blockscout/blockscout-rs/tree/main/tac-operation-lifecycle) + """ + + alias Explorer.HttpClient + alias Explorer.Utility.Microservice + require Logger + + @request_error_msg "Error while sending request to Tac Operation Lifecycle Service" + + @spec get_operations_by_id_or_sender_or_transaction_hash(String.t(), nil | map()) :: + {:ok, %{items: [map()], next_page_params: map() | nil}} + | {:error, :disabled | :not_found | Jason.DecodeError.t() | String.t()} + def get_operations_by_id_or_sender_or_transaction_hash(param, page_params) do + with :ok <- Microservice.check_enabled(__MODULE__) do + query_params = + %{ + "q" => param + } + |> Map.merge(page_params || %{}) + + operations_quick_search_url() + |> http_get_request(query_params) + |> case do + {:ok, %{"items" => operations, "next_page_params" => next_page_params}} -> + {:ok, %{items: operations, next_page_params: next_page_params}} + + error -> + error + end + end + end + + defp http_get_request(url, query_params) do + case HttpClient.get(url, [], params: query_params) do + {:ok, %{body: body, status_code: 200}} -> + case Jason.decode(body) do + {:ok, decoded_body} -> + {:ok, decoded_body} + + error -> + log_error(error) + {:error, error} + end + + {:ok, %{body: _body, status_code: 404}} -> + {:error, :not_found} + + {_, error} -> + log_error(error) + {:error, @request_error_msg} + end + end + + defp log_error(error) do + old_truncate = Application.get_env(:logger, :truncate) + Logger.configure(truncate: :infinity) + + Logger.error(fn -> + [ + "#{@request_error_msg}: ", + inspect(error, limit: :infinity, printable_limit: :infinity) + ] + end) + + Logger.configure(truncate: old_truncate) + end + + defp operations_quick_search_url do + "#{base_url()}/tac/operations" + end + + defp base_url do + "#{Microservice.base_url(__MODULE__)}/api/v1" + end +end diff --git a/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex b/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex index 70ae0cf74124..0fa5cbc93559 100644 --- a/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex +++ b/apps/explorer/lib/explorer/migrator/address_current_token_balance_token_type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.AddressCurrentTokenBalanceTokenType do @moduledoc """ Fill empty token_type's for address_current_token_balances diff --git a/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex b/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex index f5977455692c..d00dcf3528af 100644 --- a/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex +++ b/apps/explorer/lib/explorer/migrator/address_token_balance_token_type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.AddressTokenBalanceTokenType do @moduledoc """ Fill empty token_type's for address_token_balances diff --git a/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex b/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex index 759e1b1d2b9e..49d4627151e1 100644 --- a/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex +++ b/apps/explorer/lib/explorer/migrator/arbitrum_da_records_normalization.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.ArbitrumDaRecordsNormalization do @moduledoc """ Normalizes batch-to-blob associations by moving them from arbitrum_da_multi_purpose to a dedicated diff --git a/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex b/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex index eea496c52454..30bab5fcf4c7 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_metadata_url.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.BackfillMetadataURL do @moduledoc """ Backfills the metadata_url field for token instances @@ -7,8 +8,9 @@ defmodule Explorer.Migrator.BackfillMetadataURL do import Ecto.Query alias EthereumJSONRPC.NFT - alias Explorer.{Chain, MetadataURIValidator, Repo} + alias Explorer.Chain.Token alias Explorer.Chain.Token.Instance + alias Explorer.{MetadataURIValidator, Repo} alias Explorer.Migrator.FillingMigration require Logger @@ -33,7 +35,7 @@ defmodule Explorer.Migrator.BackfillMetadataURL do current_token_type_map = tokens_address_hashes_to_preload_from_db - |> Chain.get_token_types() + |> Token.get_token_types() |> Enum.map(fn {address_hash, type} -> {address_hash.bytes, type} end) |> Enum.into(%{}) |> Map.merge(tokens_address_hash_to_type) @@ -143,7 +145,7 @@ defmodule Explorer.Migrator.BackfillMetadataURL do defp process_common_url(url) do case MetadataURIValidator.validate_uri(url) do :ok -> - %{metadata_url: url, skip_metadata_url: false} + %{metadata_url: String.slice(url, 0, 2048), skip_metadata_url: false} {:error, reason} -> %{ diff --git a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex index 67ed5897ff6a..7014767189da 100644 --- a/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex +++ b/apps/explorer/lib/explorer/migrator/backfill_multichain_search_db.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.BackfillMultichainSearchDB do @moduledoc """ Copies existing data from Blockscout instance to Multichain Search DB instance. @@ -77,10 +78,10 @@ defmodule Explorer.Migrator.BackfillMultichainSearchDB do |> Repo.all(timeout: :infinity) end) - block_hashes = blocks |> Enum.map(& &1.hash) + block_numbers = Enum.map(blocks, & &1.number) internal_transactions_query = - from(internal_transaction in InternalTransaction, where: internal_transaction.block_hash in ^block_hashes) + from(internal_transaction in InternalTransaction, where: internal_transaction.block_number in ^block_numbers) internal_transactions_task = Task.async(fn -> diff --git a/apps/explorer/lib/explorer/migrator/celo_accounts.ex b/apps/explorer/lib/explorer/migrator/celo_accounts.ex new file mode 100644 index 000000000000..393c009d26fe --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/celo_accounts.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.CeloAccounts do + @moduledoc """ + Backfills pending account operations table for each address that has + Celo-specific events, indicating that their Celo account information needs to + be fetched and updated. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.Celo.{Account, PendingAccountOperation} + alias Explorer.Chain.Celo.Legacy.{Accounts, Events} + alias Explorer.Chain.Log + alias Explorer.Migrator.FillingMigration + + @migration_name "celo_accounts" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([log], log) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + pending_op_hashes_query = from(op in PendingAccountOperation, select: op.address_hash) + existing_account_hashes_query = from(a in Account, select: a.address_hash) + excluded_hashes_query = pending_op_hashes_query |> union(^existing_account_hashes_query) + + from( + log in Log, + where: + log.first_topic in ^Events.account_events() and + fragment("SUBSTRING(? from 13)", log.second_topic) not in subquery(excluded_hashes_query), + order_by: [asc: log.block_number, asc: log.index] + ) + end + + @impl FillingMigration + def update_batch(logs) do + %{accounts: pending_operation_params} = Accounts.parse(logs) + + unique_pending_operation_params = Enum.uniq_by(pending_operation_params, & &1.address_hash) + address_params = Enum.map(unique_pending_operation_params, &%{hash: &1.address_hash}) + + Chain.import(%{ + addresses: %{params: address_params}, + celo_pending_account_operations: %{ + params: unique_pending_operation_params + } + }) + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex b/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex new file mode 100644 index 000000000000..5a3589c611c4 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/celo_aggregated_election_rewards.ex @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.CeloAggregatedElectionRewards do + @moduledoc """ + Backfills the `celo_aggregated_election_rewards` table with aggregated + statistics from the `celo_election_rewards` table for all finalized epochs. + + This migration calculates the sum and count of rewards grouped by epoch number + and reward type, creating pre-computed aggregates that significantly improve + query performance for epoch reward statistics. + + Only epochs with at least one election reward are processed. Epochs with zero + rewards are skipped. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.Celo.{AggregatedElectionReward, ElectionReward, Epoch} + alias Explorer.Migrator.FillingMigration + + @migration_name "celo_aggregated_election_rewards" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + epoch_numbers = + unprocessed_data_query() + |> select([e], e.number) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {epoch_numbers, state} + end + + @impl FillingMigration + def unprocessed_data_query do + # Get all epochs that have been finalized (have end_processing_block_hash) + # but don't yet have aggregated election rewards. + # Only process epochs that have at least one election reward to avoid + # reprocessing epochs with no rewards indefinitely. + aggregated_epoch_numbers = + from(aer in AggregatedElectionReward, + select: aer.epoch_number, + distinct: true + ) + + epochs_with_rewards = + from(er in ElectionReward, + select: er.epoch_number, + distinct: true + ) + + from( + e in Epoch, + join: r in subquery(epochs_with_rewards), + on: r.epoch_number == e.number, + where: not is_nil(e.end_processing_block_hash), + where: e.number not in subquery(aggregated_epoch_numbers), + order_by: [asc: e.number] + ) + end + + @impl FillingMigration + def update_batch(epoch_numbers) when is_list(epoch_numbers) do + query = + from( + er in ElectionReward, + where: er.epoch_number in ^epoch_numbers, + group_by: [er.epoch_number, er.type], + select: %{ + epoch_number: er.epoch_number, + type: er.type, + sum: sum(er.amount), + count: count(er.amount) + } + ) + + aggregates = Repo.all(query, timeout: :infinity) + + Chain.import(%{ + celo_aggregated_election_rewards: %{ + params: aggregates + } + }) + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex b/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex new file mode 100644 index 000000000000..54a35ef485e6 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/celo_l2_epochs.ex @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.CeloL2Epochs do + @moduledoc """ + Backfills Celo L2 epochs data. It processes logs related to epoch processing + and fills the `Epoch` table with the relevant data. + """ + + use Explorer.Migrator.FillingMigration + + use Utils.RuntimeEnvHelper, + epoch_manager_contract_address_hash: [ + :explorer, + [:celo, :epoch_manager_contract_address] + ] + + import Ecto.Query + + alias Explorer.Chain.Celo.Epoch + alias Explorer.Chain.{Import, Log} + alias Explorer.Chain.Import.Runner.Celo.Epochs + alias Explorer.{Helper, Repo} + alias Explorer.Migrator.FillingMigration + + @migration_name "celo_l2_epochs" + + # Events from the EpochManager contract + @epoch_processing_started_topic "0xae58a33f8b8d696bcbaca9fa29d9fdc336c140e982196c2580db3d46f3e6d4b6" + @epoch_processing_ended_topic "0xc8e58d8e6979dd5e68bad79d4a4368a1091f6feb2323e612539b1b84e0663a8f" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([log], log) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + epochs_start_processing_block_hashes = + from(epoch in Epoch, select: epoch.start_processing_block_hash) + + epochs_end_processing_block_hashes = + from(epoch in Epoch, select: epoch.end_processing_block_hash) + + from( + log in Log, + where: + log.address_hash == ^epoch_manager_contract_address_hash() and + ((log.first_topic == ^@epoch_processing_started_topic and + log.block_hash not in subquery(epochs_start_processing_block_hashes)) or + (log.first_topic == ^@epoch_processing_ended_topic and + log.block_hash not in subquery(epochs_end_processing_block_hashes))), + order_by: [asc: log.block_number] + ) + end + + @impl FillingMigration + def update_batch(logs) do + changes_list = + logs + |> Enum.reduce(%{}, fn log, epochs_acc -> + # Extract epoch number from the log + [epoch_number] = log.second_topic |> to_string() |> Helper.decode_data([{:uint, 256}]) + + current_epoch = Map.get(epochs_acc, epoch_number, %{number: epoch_number}) + + updated_epoch = + log.first_topic + |> to_string() + |> case do + @epoch_processing_started_topic -> + Map.put(current_epoch, :start_processing_block_hash, log.block_hash) + + @epoch_processing_ended_topic -> + Map.put(current_epoch, :end_processing_block_hash, log.block_hash) + end + + Map.put(epochs_acc, epoch_number, updated_epoch) + end) + |> Map.values() + + Epochs.insert( + Repo, + changes_list, + %{ + timeout: :infinity, + timestamps: Import.timestamps() + } + ) + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex b/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex new file mode 100644 index 000000000000..5d48a4dfd957 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/delete_zero_value_internal_transactions.ex @@ -0,0 +1,260 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.DeleteZeroValueInternalTransactions do + @moduledoc """ + Continuously deletes all zero-value calls older than + `MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_STORAGE_PERIOD` from DB. + """ + + use GenServer + + import Ecto.Query + import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] + + alias Explorer.Chain.{Block, InternalTransaction} + alias Explorer.Chain.Cache.Counters.AverageBlockTime + alias Explorer.Migrator.MigrationStatus + alias Explorer.Repo + alias Explorer.Utility.{AddressIdToAddressHash, InternalTransactionsAddressPlaceholder} + alias Timex.Duration + + @migration_name "delete_zero_value_internal_transactions" + @shrink_internal_transactions_migration_name "shrink_internal_transactions" + @not_completed_check_interval 10 + @default_check_interval :timer.minutes(1) + @default_batch_size 100 + @default_storage_period :timer.hours(24) * 30 + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @doc """ + Returns the border block number. All internal transactions with zero value (and no contract creation) and + block number less than or equal to the border number are subject to deletion. + """ + @spec border_number() :: non_neg_integer() | nil + def border_number, do: get_border_number() + + @impl true + def init(_) do + {:ok, %{}, {:continue, :ok}} + end + + @impl true + def handle_continue(:ok, state) do + check_dependency_and_start(state) + end + + @impl true + def handle_info(:check_dependency, state) do + check_dependency_and_start(state) + end + + @impl true + def handle_info(:update, %{"max_block_number" => max_number} = state) do + border_number = get_border_number() + to_number = border_number && min(max_number + batch_size(), border_number) + clear_internal_transactions(max_number, to_number) + completed? = not is_nil(border_number) and to_number == border_number + new_max_number = (to_number && to_number + 1) || max_number + + new_state = + if completed? and is_nil(state["completed"]) do + MigrationStatus.set_status(@migration_name, "completed") + Map.merge(state, %{"max_block_number" => new_max_number, "completed" => true}) + else + %{state | "max_block_number" => new_max_number} + end + + MigrationStatus.update_meta(@migration_name, new_state) + schedule_check(completed? or is_nil(border_number)) + {:noreply, new_state} + end + + defp check_dependency_and_start(state) do + shrink_config = Application.get_env(:explorer, Explorer.Migrator.ShrinkInternalTransactions) || [] + shrink_enabled? = shrink_config[:enabled] != false + shrink_status = MigrationStatus.get_status(@shrink_internal_transactions_migration_name) + + if shrink_enabled? && shrink_status != "completed" do + schedule_dependency_check() + {:noreply, state} + else + state = + case MigrationStatus.fetch(@migration_name) do + nil -> + state = %{"max_block_number" => -1} + MigrationStatus.set_status(@migration_name, "started") + MigrationStatus.update_meta(@migration_name, state) + state + + %{meta: meta} -> + meta + end + + schedule_check() + {:noreply, state} + end + end + + defp clear_internal_transactions(from_number, to_number) + when is_integer(from_number) and is_integer(to_number) and from_number < to_number do + dynamic_condition = dynamic([it], it.block_number >= ^from_number and it.block_number <= ^to_number) + + do_clear_internal_transactions(dynamic_condition) + end + + defp clear_internal_transactions(_from, _to), do: :ok + + @smallint_max_value 32767 + defp do_clear_internal_transactions(dynamic_condition) do + Repo.transaction( + fn -> + condition = dynamic([it], ^dynamic_condition and it.type == ^:call and (is_nil(it.value) or it.value == ^0)) + + locked_internal_transactions_to_delete_query = + from( + it in InternalTransaction, + select: select_ctid(it), + where: ^condition, + order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], + lock: "FOR UPDATE" + ) + + delete_query = + from( + it in InternalTransaction, + inner_join: locked_it in subquery(locked_internal_transactions_to_delete_query), + on: join_on_ctid(it, locked_it), + select: %{ + from_address_hash: it.from_address_hash, + from_address_id: it.from_address_id, + to_address_hash: it.to_address_hash, + to_address_id: it.to_address_id, + block_number: it.block_number, + index: it.index + } + ) + + {_count, deleted_internal_transactions} = Repo.delete_all(delete_query, timeout: :infinity) + + address_hashes = + deleted_internal_transactions + |> Enum.flat_map(&[&1.from_address_hash, &1.to_address_hash]) + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + + id_to_address_params = Enum.map(address_hashes, &%{address_hash: &1}) + + Repo.insert_all(AddressIdToAddressHash, id_to_address_params, on_conflict: :nothing) + + address_to_id_map = + AddressIdToAddressHash + |> where([a], a.address_hash in ^address_hashes) + |> select([a], {a.address_hash, a.address_id}) + |> Repo.all() + |> Map.new() + + placeholders_params = + deleted_internal_transactions + |> Enum.group_by(& &1.block_number) + |> Enum.flat_map(fn {block_number, internal_transactions} -> + internal_transactions + |> Enum.reduce(%{}, fn + %{index: 0}, inner_acc -> + inner_acc + + internal_transaction, inner_acc -> + from_address_id = + internal_transaction.from_address_id || address_to_id_map[internal_transaction.from_address_hash] + + to_address_id = + internal_transaction.to_address_id || address_to_id_map[internal_transaction.to_address_hash] + + inner_acc + |> Map.update( + from_address_id, + %{ + address_id: from_address_id, + block_number: block_number, + count_tos: 0, + count_froms: 1 + }, + fn existing_params -> + %{existing_params | count_froms: min(existing_params.count_froms + 1, @smallint_max_value)} + end + ) + |> Map.update( + to_address_id, + %{ + address_id: to_address_id, + block_number: block_number, + count_tos: 1, + count_froms: 0 + }, + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + fn existing_params -> + %{existing_params | count_tos: min(existing_params.count_tos + 1, @smallint_max_value)} + end + ) + end) + |> Map.values() + |> Enum.reject(&is_nil(&1.address_id)) + end) + |> Enum.sort_by(&{&1.address_id, &1.block_number}) + + placeholders_params + |> Enum.chunk_every(1000) + |> Enum.each(fn placeholders_batch -> + Repo.insert_all(InternalTransactionsAddressPlaceholder, placeholders_batch, + on_conflict: :replace_all, + conflict_target: [:address_id, :block_number] + ) + end) + end, + timeout: :infinity + ) + end + + defp get_border_number do + storage_period = Application.get_env(:explorer, __MODULE__)[:storage_period] || @default_storage_period + border_timestamp = Timex.shift(Timex.now(), milliseconds: -storage_period) + + Block + |> where([b], b.timestamp < ^border_timestamp) + |> order_by([b], desc: b.timestamp) + |> limit(1) + |> select([b], b.number) + |> Repo.one() + end + + defp schedule_check(completed? \\ false) do + Process.send_after(self(), :update, (completed? && completed_check_interval()) || @not_completed_check_interval) + end + + defp schedule_dependency_check do + interval = Application.get_env(:explorer, __MODULE__)[:dependency_check_interval] || :timer.hours(1) + Process.send_after(self(), :check_dependency, interval) + end + + defp completed_check_interval do + with nil <- Application.get_env(:explorer, __MODULE__)[:check_interval], + nil <- get_average_block_time() do + @default_check_interval + else + interval -> interval + end + end + + defp get_average_block_time do + case AverageBlockTime.average_block_time() do + {:error, :disabled} -> nil + average_block_time -> Duration.to_milliseconds(average_block_time) + end + end + + defp batch_size do + max(Application.get_env(:explorer, __MODULE__)[:batch_size] || @default_batch_size, 1) + end +end diff --git a/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex b/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex new file mode 100644 index 000000000000..e604f98dbf64 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts.ex @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts do + @moduledoc """ + Finds all existing selfdestruct internal transactions and empties the contract_code + for addresses that still have bytecode, excluding contracts that were created and + selfdestructed in the same transaction. + + This migration processes blocks from the head of the chain down to the first block, + identifying selfdestruct operations and clearing the bytecode of affected contracts. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.{Address, Data, InternalTransaction} + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Migrator.{FillingMigration, MigrationStatus} + alias Explorer.{QueryHelper, Repo} + + require Logger + + @migration_name "empty_bytecode_for_selfdestructed_smart_contracts" + @empty_contract_code %Data{bytes: <<>>} + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(%{"min_block_number" => min_block_number} = state) + when not is_nil(min_block_number) and min_block_number < 0 do + {[], state} + end + + def last_unprocessed_identifiers(%{"min_block_number" => min_block_number} = state) + when not is_nil(min_block_number) do + limit = batch_size() * concurrency() + from_block_number = min_block_number + to_block_number = max(from_block_number - limit + 1, 0) + + block_numbers = Enum.to_list(from_block_number..to_block_number//-1) + + {block_numbers, %{state | "min_block_number" => to_block_number - 1}} + end + + def last_unprocessed_identifiers(state) do + query = + from( + migration_status in MigrationStatus, + where: migration_status.migration_name == ^@migration_name, + select: migration_status.meta + ) + + meta = Repo.one(query, timeout: :infinity) + + state + |> Map.put("min_block_number", (meta && meta["min_block_number"]) || BlockNumber.get_max()) + |> last_unprocessed_identifiers() + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + if Enum.empty?(block_numbers) do + {:ok, []} + else + # Find all selfdestruct internal transactions in these blocks + selfdestruct_query = + InternalTransaction + |> InternalTransaction.join_address_mapping_query(:from_address) + |> where([it], it.block_number in ^block_numbers) + |> where([it], it.type == :selfdestruct) + |> select([it], %{ + transaction_index: it.transaction_index, + from_address_hash: coalesce(it.from_address_hash, as(:from_address_mapping).address_hash), + block_number: it.block_number + }) + + selfdestruct_transactions = Repo.all(selfdestruct_query, timeout: :infinity) + + if Enum.empty?(selfdestruct_transactions) do + {:ok, []} + else + # Get unique transaction block numbers and indexes to check for create/create2 + transaction_identifiers = + selfdestruct_transactions + |> Enum.map(&{&1.block_number, &1.transaction_index}) + |> Enum.uniq() + + # Find all create/create2 internal transactions in the same transactions + create_query = + InternalTransaction + |> InternalTransaction.join_address_mapping_query(:created_contract_address) + |> where(^QueryHelper.tuple_in([:block_number, :transaction_index], transaction_identifiers)) + |> where([it], it.type in [:create, :create2]) + |> select([it], %{ + block_number: it.block_number, + transaction_index: it.transaction_index, + created_contract_address_hash: + coalesce(it.created_contract_address_hash, as(:created_contract_address_mapping).address_hash) + }) + + created_contracts = Repo.all(create_query, timeout: :infinity) + + # Build a set of {block_number, transaction_index, address_hash} for contracts created in same tx + created_in_same_tx = + created_contracts + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.created_contract_address_hash}) + |> MapSet.new() + + # Filter to find addresses that were selfdestructed but NOT created in the same transaction + addresses_to_empty = + selfdestruct_transactions + |> Enum.reject(fn sd -> + MapSet.member?(created_in_same_tx, {sd.block_number, sd.transaction_index, sd.from_address_hash}) + end) + |> Enum.map(& &1.from_address_hash) + |> Enum.uniq() + + if Enum.empty?(addresses_to_empty) do + {:ok, []} + else + # Only update addresses that still have non-empty contract_code + update_query = + from( + address in Address, + where: address.hash in ^addresses_to_empty, + where: not is_nil(address.contract_code), + where: fragment("octet_length(?) > 0", address.contract_code), + update: [set: [contract_code: ^@empty_contract_code]], + select: address.hash + ) + + {count, updated_hashes} = Repo.update_all(update_query, [], timeout: :infinity) + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if count > 0 do + Logger.info( + "Emptied contract_code for #{count} selfdestructed contracts in blocks #{inspect(block_numbers)}: #{inspect(updated_hashes, limit: :infinity)}" + ) + end + + {:ok, count} + end + end + end + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex b/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex new file mode 100644 index 000000000000..0aba5a1a6988 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/empty_internal_transactions_data.ex @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.EmptyInternalTransactionsData do + @moduledoc """ + Searches for all internal transactions with non-empty `trace_address` and empties it. + Also searches for all internal transactions with zero `value` and empties it. + Also updates `call_type` with `call_type_enum`. + Also empties `error` column and fills `transaction_errors` with its values. + Also fills `to_address_hash` column with the data from `created_contract_address_hash`. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.{InternalTransaction, TransactionError} + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "empty_internal_transactions_data" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => -1} = state), do: {[], state} + + def last_unprocessed_identifiers(%{"max_block_number" => from_block_number} = state) do + limit = batch_size() * concurrency() + to_block_number = max(from_block_number - limit + 1, 0) + + {Enum.to_list(from_block_number..to_block_number), %{state | "max_block_number" => to_block_number - 1}} + end + + def last_unprocessed_identifiers(state) do + query = + from( + it in InternalTransaction, + where: + not is_nil(it.trace_address) or it.value == ^0 or (is_nil(it.call_type_enum) and not is_nil(it.call_type)) or + not is_nil(it.error) or (not is_nil(it.created_contract_address_hash) and is_nil(it.to_address_hash)), + select: max(it.block_number) + ) + + max_block_number = Repo.one(query, timeout: :infinity) + + state + |> Map.put("max_block_number", max_block_number || -1) + |> last_unprocessed_identifiers() + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + Repo.transaction( + fn -> + lock_query = + from( + it in InternalTransaction, + select: select_ctid(it), + select_merge: %{error: it.error}, + where: + it.block_number in ^block_numbers and + (not is_nil(it.trace_address) or it.value == ^0 or + (is_nil(it.call_type_enum) and not is_nil(it.call_type)) or + not is_nil(it.error) or (not is_nil(it.created_contract_address_hash) and is_nil(it.to_address_hash))), + order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], + lock: "FOR UPDATE" + ) + + extract_error_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.error), + distinct: true, + select: it.error + ) + + error_messages = Repo.all(extract_error_query, timeout: :infinity) + + TransactionError.find_or_create_multiple(error_messages) + + update_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + left_join: te in TransactionError, + on: te.message == locked_it.error, + update: [ + set: [ + error: nil, + error_id: + fragment( + "CASE WHEN ? IS NOT NULL THEN COALESCE(?, ?) ELSE ? END", + it.error, + te.id, + it.error_id, + it.error_id + ), + call_type_enum: + fragment( + "CASE WHEN ? IS NULL AND ? IS NOT NULL THEN (?::internal_transactions_call_type) ELSE ? END", + it.call_type_enum, + it.call_type, + it.call_type, + it.call_type_enum + ), + call_type: + fragment( + "CASE WHEN ? IS NULL AND ? IS NOT NULL THEN NULL ELSE ? END", + it.call_type_enum, + it.call_type, + it.call_type + ), + trace_address: nil, + value: fragment("CASE WHEN ? = 0 THEN NULL ELSE ? END", it.value, it.value), + to_address_hash: + fragment( + "CASE WHEN ? IS NOT NULL AND ? IS NULL THEN ? ELSE ? END", + it.created_contract_address_hash, + it.to_address_hash, + it.created_contract_address_hash, + it.to_address_hash + ) + ] + ] + ) + + Repo.update_all(update_query, [], timeout: :infinity) + end, + timeout: :infinity + ) + end + + @impl FillingMigration + def update_cache do + BackgroundMigrations.set_empty_internal_transactions_data_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex b/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex index 47254d880590..ad8b14d066d3 100644 --- a/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex +++ b/apps/explorer/lib/explorer/migrator/filecoin_pending_address_operations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.FilecoinPendingAddressOperations do @moduledoc """ Creates a pending address operation for each address missing Filecoin address diff --git a/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex b/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex new file mode 100644 index 000000000000..fa0995d85ffa --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/fill_internal_transactions_address_ids.ex @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.FillInternalTransactionsAddressIds do + @moduledoc """ + Clears `*_address_hash` fields in internal transactions and fills their `*_address_id` copies. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + import Explorer.QueryHelper, only: [select_ctid: 1, join_on_ctid: 2] + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.InternalTransaction + alias Explorer.Migrator.FillingMigration + alias Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError + alias Explorer.Repo + alias Explorer.Utility.AddressIdToAddressHash + + @migration_name "fill_internal_transactions_address_ids" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def dependent_from_migrations, + do: [RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError.migration_name()] + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => -1} = state), do: {[], state} + + def last_unprocessed_identifiers(%{"max_block_number" => from_block_number} = state) do + limit = batch_size() * concurrency() + to_block_number = max(from_block_number - limit + 1, 0) + + {Enum.to_list(from_block_number..to_block_number), %{state | "max_block_number" => to_block_number - 1}} + end + + def last_unprocessed_identifiers(state) do + query = + from( + it in InternalTransaction, + where: + not is_nil(it.from_address_hash) or not is_nil(it.to_address_hash) or + not is_nil(it.created_contract_address_hash), + select: max(it.block_number) + ) + + max_block_number = Repo.one(query, timeout: :infinity) + + state + |> Map.put("max_block_number", max_block_number || -1) + |> last_unprocessed_identifiers() + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + Repo.transaction( + fn -> + lock_query = + from( + it in InternalTransaction, + select: select_ctid(it), + select_merge: %{ + from_address_hash: it.from_address_hash, + to_address_hash: it.to_address_hash, + created_contract_address_hash: it.created_contract_address_hash + }, + where: + it.block_number in ^block_numbers and + (not is_nil(it.from_address_hash) or not is_nil(it.to_address_hash) or + not is_nil(it.created_contract_address_hash)), + order_by: [asc: it.block_number, asc: it.transaction_index, asc: it.index], + lock: "FOR UPDATE" + ) + + from_address_hashes_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.from_address_hash), + distinct: true, + select: it.from_address_hash + ) + + to_address_hashes_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.to_address_hash), + distinct: true, + select: it.to_address_hash + ) + + created_contract_address_hashes_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + where: not is_nil(it.created_contract_address_hash), + distinct: true, + select: it.created_contract_address_hash + ) + + from_address_hashes_query + |> union_all(^to_address_hashes_query) + |> union_all(^created_contract_address_hashes_query) + |> Repo.all() + |> Enum.uniq() + |> AddressIdToAddressHash.find_or_create_multiple() + + update_query = + from(it in InternalTransaction, + inner_join: locked_it in subquery(lock_query), + on: join_on_ctid(it, locked_it), + left_join: from_map in AddressIdToAddressHash, + on: from_map.address_hash == locked_it.from_address_hash, + left_join: to_map in AddressIdToAddressHash, + on: to_map.address_hash == locked_it.to_address_hash, + left_join: created_map in AddressIdToAddressHash, + on: created_map.address_hash == locked_it.created_contract_address_hash, + update: [ + set: [ + from_address_id: from_map.address_id, + to_address_id: coalesce(to_map.address_id, created_map.address_id), + created_contract_address_id: created_map.address_id, + from_address_hash: nil, + to_address_hash: nil, + created_contract_address_hash: nil + ] + ] + ) + + Repo.update_all(update_query, [], timeout: :infinity) + end, + timeout: :infinity + ) + end + + @impl FillingMigration + def update_cache do + BackgroundMigrations.set_fill_internal_transactions_address_ids_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/filling_migration.ex b/apps/explorer/lib/explorer/migrator/filling_migration.ex index 506fb1129f5f..c12fab604188 100644 --- a/apps/explorer/lib/explorer/migrator/filling_migration.ex +++ b/apps/explorer/lib/explorer/migrator/filling_migration.ex @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +# credo:disable-for-this-file defmodule Explorer.Migrator.FillingMigration do @moduledoc """ Provides a behaviour and implementation for data migration tasks that fill or update @@ -62,6 +64,7 @@ defmodule Explorer.Migrator.FillingMigration do limiting is handled within `last_unprocessed_identifiers/1`. """ @callback unprocessed_data_query :: Ecto.Query.t() | nil + @callback unprocessed_data_query(map()) :: Ecto.Query.t() | nil @doc """ This callback retrieves the next batch of data for migration processing. It returns @@ -153,6 +156,13 @@ defmodule Explorer.Migrator.FillingMigration do """ @callback before_start :: any() + @doc """ + Returns a list of migration names that the current migration depends on. + """ + @callback dependent_from_migrations :: list(String.t()) + + @optional_callbacks unprocessed_data_query: 0, unprocessed_data_query: 1 + defmacro __using__(opts) do quote do @behaviour Explorer.Migrator.FillingMigration @@ -161,6 +171,8 @@ defmodule Explorer.Migrator.FillingMigration do import Ecto.Query + alias Explorer.Chain.Block + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper alias Explorer.Migrator.MigrationStatus alias Explorer.Repo @@ -184,7 +196,13 @@ defmodule Explorer.Migrator.FillingMigration do @impl true def init(_) do - {:ok, %{}, {:continue, :ok}} + if Repo.exists?(Block) do + {:ok, %{}, {:continue, :ok}} + else + MigrationStatus.set_status(migration_name(), "completed") + update_cache() + :ignore + end end # Called once when the GenServer starts to initialize the migration process by checking its @@ -211,13 +229,24 @@ defmodule Explorer.Migrator.FillingMigration do {:stop, :normal, state} migration_status -> - MigrationStatus.set_status(migration_name(), "started") - before_start() - schedule_batch_migration(0) + schedule_next_migration_readiness_check(0) {:noreply, (migration_status && migration_status.meta) || %{}} end end + @impl true + def handle_info(:check_migration_readiness, state) do + if migration_is_ready_to_start?() do + MigrationStatus.set_status(migration_name(), "started") + before_start() + schedule_batch_migration(0) + else + schedule_next_migration_readiness_check() + end + + {:noreply, state} + end + # Processes a batch of unprocessed identifiers for migration. # # Retrieves the next batch of unprocessed identifiers and processes them in parallel. @@ -256,7 +285,7 @@ defmodule Explorer.Migrator.FillingMigration do |> Task.await_many(:infinity) unquote do - unless opts[:skip_meta_update?] do + if !opts[:skip_meta_update?] do quote do MigrationStatus.update_meta(migration_name(), new_state) end @@ -272,6 +301,25 @@ defmodule Explorer.Migrator.FillingMigration do @spec run_task([any()]) :: any() defp run_task(batch), do: Task.async(fn -> update_batch(batch) end) + defp migration_is_ready_to_start? do + migrations = dependent_from_migrations() + + if Enum.empty?(migrations) do + true + else + all_statuses = MigrationStatus.fetch_migration_statuses(migrations) + Enum.count(all_statuses) == Enum.count(migrations) and Enum.all?(all_statuses, &(&1 == "completed")) + end + end + + defp schedule_next_migration_readiness_check(timeout \\ nil) do + Process.send_after( + self(), + :check_migration_readiness, + timeout || HeavyDbIndexOperationHelper.get_check_interval() + ) + end + # Schedules the next batch migration by sending a delayed :migrate_batch message. # # ## Parameters @@ -302,7 +350,11 @@ defmodule Explorer.Migrator.FillingMigration do :ignore end - defoverridable on_finish: 0, before_start: 0 + def dependent_from_migrations do + [] + end + + defoverridable on_finish: 0, before_start: 0, dependent_from_migrations: 0 end end end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex index 0b25143f6b68..f0d293a44a85 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation do @moduledoc """ Provides a template for making heavy DB operations such as creation/deletion of new indexes in the large tables @@ -7,7 +8,7 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do @doc """ Returns the name of the migration. The name is used to track the operation's status in `Explorer.Migrator.MigrationStatus`. - Heavy DB migration is either `heavy_indexes_create_{lower_case_index_name}` or `heavy_indexes_drop_{lower_case_index_name}` + Heavy DB migration is either `heavy_indexes_create_{lower_case_index_name}`, `heavy_indexes_drop_{lower_case_index_name}`, or `heavy_indexes_rename_{lower_case_index_name}` """ @callback migration_name :: String.t() @@ -27,18 +28,21 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do | :logs | :internal_transactions | :token_transfers + | :token_instances | :addresses | :smart_contracts | :arbitrum_batch_l2_blocks - + | :smart_contracts_additional_sources + | :tokens @doc """ Specifies the type of operation to be performed on the database index. ## Returns - `:create` - Indicates that the operation is to add a new index. - `:drop` - Indicates that the operation is to drop an existing index. + - `:rename` - Indicates that the operation is to rename an existing index. """ - @callback operation_type :: :create | :drop + @callback operation_type :: :create | :drop | :rename @doc """ Returns the name of the index as a string. @@ -126,6 +130,7 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do import Ecto.Query alias Ecto.Adapters.SQL + alias Explorer.Chain.Block alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper alias Explorer.Migrator.MigrationStatus alias Explorer.Repo @@ -148,7 +153,22 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do @impl true def init(_) do - {:ok, %{}, {:continue, :ok}} + with {:green_install?, true} <- {:green_install?, not Repo.exists?(Block)}, + {:migration_finished?, false} <- {:migration_finished?, migration_finished?()}, + {:dependent_from_migrations_completed?, true} <- + {:dependent_from_migrations_completed?, dependent_from_migrations_completed?()}, + {:db_index_operation, :ok} <- {:db_index_operation, db_index_operation()} do + MigrationStatus.set_status(migration_name(), "completed") + update_cache() + :ignore + else + {:migration_finished?, true} -> + update_cache() + :ignore + + _ -> + {:ok, %{}, {:continue, :ok}} + end end @impl true @@ -165,31 +185,25 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do {:stop, :normal, state} migration_status -> - Process.send(self(), :check_if_db_operation_need_to_be_started, []) + Process.send(self(), :check_db_index_operation_progress, []) {:noreply, state} end end - @impl true - def handle_info(:check_if_db_operation_need_to_be_started, state) do - if db_operation_is_ready_to_start?() do - Process.send(self(), :check_db_index_operation_progress, []) - else - schedule_next_db_operation_readiness_check() - end - - {:noreply, state} - end - @impl true def handle_info(:check_db_index_operation_progress, state) do with {:index_operation_progress, status} when status in [:finished_or_not_started, :finished] <- {:index_operation_progress, check_db_index_operation_progress()}, {:db_index_operation_status, :not_initialized} <- {:db_index_operation_status, db_index_operation_status()} do - MigrationStatus.set_status(migration_name(), "started") - db_index_operation() - schedule_next_db_operation_status_check() + if db_operation_is_ready_to_start?() do + MigrationStatus.set_status(migration_name(), "started") + timeout = (db_index_operation() == :ok && 0) || nil + schedule_next_db_operation_status_check(timeout) + else + schedule_next_db_operation_readiness_check() + end + {:noreply, state} else {:index_operation_progress, _status} -> @@ -228,16 +242,20 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do if running_other_heavy_migration_exists?(migration_name()) do false else - if Enum.empty?(dependent_from_migrations()) do - true - else - all_statuses = - MigrationStatus.fetch_migration_statuses(dependent_from_migrations()) + dependent_from_migrations_completed?() + end + end - all_statuses_completed? = not Enum.empty?(all_statuses) && all_statuses |> Enum.all?(&(&1 == "completed")) + defp dependent_from_migrations_completed? do + if Enum.empty?(dependent_from_migrations()) do + true + else + all_statuses = + MigrationStatus.fetch_migration_statuses(dependent_from_migrations()) - all_statuses_completed? && Enum.count(all_statuses) == Enum.count(dependent_from_migrations()) - end + all_statuses_completed? = not Enum.empty?(all_statuses) && all_statuses |> Enum.all?(&(&1 == "completed")) + + all_statuses_completed? && Enum.count(all_statuses) == Enum.count(dependent_from_migrations()) end end @@ -252,7 +270,7 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation do defp schedule_next_db_operation_readiness_check(timeout \\ nil) do Process.send_after( self(), - :check_if_db_operation_need_to_be_started, + :check_db_index_operation_progress, timeout || HeavyDbIndexOperationHelper.get_check_interval() ) end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/README.md b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/README.md index e757d3bb3bc8..7cce6ed0a8a8 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/README.md +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/README.md @@ -8,10 +8,11 @@ The modules under `Explorer.Migrator.HeavyDbIndexOperation` provide a standardiz ## Operation Types -The framework supports two types of operations (`@operation_type`): +The framework supports three types of operations (`@operation_type`): - `:create` - Creates a new index on a table - `:drop` - Removes an existing index from a table +- `:rename` - Renames an existing index (typically used as the final step in a create → drop → rename workflow for zero-downtime index replacement) ## Index Creation Methods @@ -46,6 +47,144 @@ This approach is simpler and more suitable when: - The index is not partial - Default column ordering is acceptable +### 3. Rename Operations + +For rename operations, you don't use `@query_string` or `@table_columns`. Instead, you define the old and new index names and implement custom SQL: + +```elixir +@old_index_name "transactions_2nd_created_contract_address_hash_with_pending_index_a" +@new_index_name "transactions_created_contract_address_hash_with_pending_index_a" +@operation_type :rename + +def db_index_operation do + case Repo.query("ALTER INDEX \"#{@old_index_name}\" RENAME TO \"#{@new_index_name}\";", [], timeout: :infinity) do + {:ok, _} -> + :ok + {:error, error} -> + Logger.error("Failed to rename index: #{inspect(error)}") + :error + end +end +``` + +**Note:** For rename operations: +- `index_name/0` should return the **new** (final) index name +- The migration name will be `heavy_indexes_rename_{new_index_name_lowercase}` +- Rename operations are typically dependent on a drop operation completing first +- `db_index_operation_status/0` should check both old and new index existence to determine status + +## Zero-Downtime Index Replacement Workflow + +When you need to replace an existing index with a modified version (different columns, sort order, WHERE clause, etc.), use the create → drop → rename workflow to ensure zero downtime: + +### Workflow Steps + +1. **Create** - Build a new index with a temporary name (e.g., using `_2nd_` prefix) +2. **Drop** - Remove the old index (queries can now use the new index) +3. **Rename** - Rename the temporary index to the original name (restores the original index name) + +### Example: Replacing `my_table_column_index` + +```elixir +# Step 1: Create new index with temporary name +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateMyTable2ndColumnIndex do + use Explorer.Migrator.HeavyDbIndexOperation + + @table_name :my_table + @index_name "my_table_2nd_column_index" # Temporary name with _2nd_ prefix + @operation_type :create + + @query_string """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS "#{@index_name}" + ON #{@table_name} (column_a, column_b DESC) + WHERE column_a IS NOT NULL; + """ + + def dependent_from_migrations, do: [] # Or depend on previous migrations +end + +# Step 2: Drop old index +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropMyTableColumnIndex do + use Explorer.Migrator.HeavyDbIndexOperation + + @table_name :my_table + @index_name "my_table_column_index" # Original index name + @operation_type :drop + + def dependent_from_migrations do + # Wait for new index to be created + [CreateMyTable2ndColumnIndex.migration_name()] + end +end + +# Step 3: Rename temporary index to original name +defmodule Explorer.Migrator.HeavyDbIndexOperation.RenameMyTable2ndColumnIndex do + use Explorer.Migrator.HeavyDbIndexOperation + + @table_name :my_table + @old_index_name "my_table_2nd_column_index" + @new_index_name "my_table_column_index" # Restore original name + @operation_type :rename + + def index_name, do: @new_index_name # Return the final name + + def dependent_from_migrations do + # Wait for old index to be dropped + [DropMyTableColumnIndex.migration_name()] + end + + def db_index_operation do + case Repo.query("ALTER INDEX \"#{@old_index_name}\" RENAME TO \"#{@new_index_name}\";", [], timeout: :infinity) do + {:ok, _} -> + :ok + {:error, error} -> + Logger.error("Failed to rename: #{inspect(error)}") + :error + end + end + + def db_index_operation_status do + old_exists = HeavyDbIndexOperationHelper.db_index_exists_and_valid?(@old_index_name) + new_exists = HeavyDbIndexOperationHelper.db_index_exists_and_valid?(@new_index_name) + + cond do + # Completed: old gone, new exists + old_exists == %{exists?: false, valid?: nil} and + new_exists == %{exists?: true, valid?: true} -> :completed + + # Not started: old exists, new doesn't + old_exists == %{exists?: true, valid?: true} and + new_exists == %{exists?: false, valid?: nil} -> :not_initialized + + true -> :unknown + end + end + + def restart_db_index_operation do + # Rename back to old name to retry + case Repo.query("ALTER INDEX \"#{@new_index_name}\" RENAME TO \"#{@old_index_name}\";", [], timeout: :infinity) do + {:ok, _} -> :ok + {:error, error} -> + Logger.error("Failed to reverse rename: #{inspect(error)}") + :error + end + end +end +``` + +### Benefits of This Approach + +- **Zero Downtime** - The database always has an index available (either old or new) +- **Safe Rollback** - If the new index is invalid, the old index is still available +- **Query Compatibility** - Queries continue working throughout the migration +- **Progressive Migration** - Each step completes independently with dependency tracking + +**Note:** For rename operations: +- `index_name/0` should return the **new** (final) index name +- The migration name will be `heavy_indexes_rename_{new_index_name_lowercase}` +- Rename operations are typically dependent on a drop operation completing first +- `db_index_operation_status/0` should check both old and new index existence to determine status + ## Cache Management The `update_cache/0` callback is used to maintain the in-memory cache of migration completion status. @@ -99,8 +238,8 @@ end Basic callbacks (usually just return module attributes): - `table_name/0` - Returns the table name (usually returns `@table_name`) - - `operation_type/0` - Returns `:create` or `:drop` (usually returns `@operation_type`) - - `index_name/0` - Returns the index name (usually returns `@index_name`) + - `operation_type/0` - Returns `:create`, `:drop`, or `:rename` (usually returns `@operation_type`) + - `index_name/0` - Returns the index name (usually returns `@index_name`). For `:rename` operations, this should return the **new** index name Operation-specific callbacks: - `dependent_from_migrations/0` - Returns list of migration names that must complete before this one diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_hash_contract_code_not_null_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_hash_contract_code_not_null_index.ex new file mode 100644 index 000000000000..5790f31fba9a --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_hash_contract_code_not_null_index.ex @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesHashContractCodeNotNullIndex do + @moduledoc """ + Create partial B-tree index on `addresses` table filtering by `contract_code IS NOT NULL` + on the `hash` column. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :addresses + @index_name "addresses_hash_contract_code_not_null" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name}(hash) + WHERE contract_code IS NOT NULL; + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_addresses_hash_contract_code_not_null_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_asc_coin_balance_desc_hash_partial.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_asc_coin_balance_desc_hash_partial.ex index bf08aa2cda9e..84985e3533f8 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_asc_coin_balance_desc_hash_partial.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_asc_coin_balance_desc_hash_partial.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountAscCoinBalanceDescHashPartialIndex do @moduledoc """ Create partial B-tree index on `addresses` table filtering by `fetched_coin_balance > 0` diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_desc_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_desc_partial_index.ex index 75e3cb1a5724..53ae195c6f7b 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_desc_partial_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_transactions_count_desc_partial_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesTransactionsCountDescPartialIndex do @moduledoc """ Create partial B-tree index on `addresses` table filtering by `fetched_coin_balance > 0` diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_fetched_coin_balance_desc_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_fetched_coin_balance_desc_hash_index.ex index 4449d18c3ea7..e882fdc9c90d 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_fetched_coin_balance_desc_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_fetched_coin_balance_desc_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesVerifiedFetchedCoinBalanceDescHashIndex do @moduledoc """ Create partial B-tree index on `addresses` table filtering by `verified=true` diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_hash_index.ex index 57addb5188bd..c4016900bdbf 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesVerifiedHashIndex do @moduledoc """ Create partial B-tree index on `addresses` table filtering by `verified=true` diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_index.ex index 1b8325dfe678..f6ba8af1cea5 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesVerifiedIndex do @moduledoc """ Create partial B-tree index on `addresses` table filtering by `verified = true`. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_transactions_count_desc_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_transactions_count_desc_hash_index.ex index b87b4dbc7cc3..17d669139d2e 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_transactions_count_desc_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_addresses_verified_transactions_count_desc_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateAddressesVerifiedTransactionsCountDescHashIndex do @moduledoc """ Create partial B-tree index on `addresses` table filtering by `verified=true` diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_arbitrum_batch_l2_blocks_unconfirmed_blocks_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_arbitrum_batch_l2_blocks_unconfirmed_blocks_index.ex index e1d25a93c5f2..c92f2e18bf16 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_arbitrum_batch_l2_blocks_unconfirmed_blocks_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_arbitrum_batch_l2_blocks_unconfirmed_blocks_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateArbitrumBatchL2BlocksUnconfirmedBlocksIndex do @moduledoc """ Create partial B-tree index on `arbitrum_batch_l2_blocks` table filtering by `confirmation_id IS NULL`. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_created_contract_address_id_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_created_contract_address_id_partial_index.ex new file mode 100644 index 000000000000..31700f1bde9c --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_created_contract_address_id_partial_index.ex @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex do + @moduledoc """ + Create partial B-tree index `internal_transactions_block_number_created_contract_address_id_index` on `internal_transactions` table for (block_number, created_contract_address_id) columns where created_contract_address_id is not NULL. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_block_number_created_contract_address_id_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [CreateInternalTransactionsCreatedContractAddressIdIndex.migration_name()] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name}(block_number, created_contract_address_id) + WHERE (created_contract_address_id IS NOT NULL); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_address_ids_internal_transactions_indexes_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_desc_block_index_desc_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_desc_block_index_desc_index.ex index 9b7654ed5b1f..90ab1ef05871 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_desc_block_index_desc_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_desc_block_index_desc_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberDescTransactionIndexDescIndexDescIndex do @moduledoc """ Create B-tree index `internal_transactions_block_number_DESC_transaction_index_DESC_index_DESC_index` on `internal_transactions` table for (`block_number` DESC, `transaction_index` DESC, `index` DESC) columns. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_transaction_index_index_unique_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_transaction_index_index_unique_index.ex new file mode 100644 index 000000000000..d4b0a9f73494 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_block_number_transaction_index_index_unique_index.ex @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex do + @moduledoc """ + Create unique B-tree index `internal_transactions_block_number_transaction_index_index_index` on `internal_transactions` table for (`block_number`, `transaction_index`, `index`) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{ + EmptyInternalTransactionsData, + HeavyDbIndexOperation, + MigrationStatus + } + + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_block_number_transaction_index_index_index" + @operation_type :create + @table_columns ["block_number", "transaction_index", "index"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + EmptyInternalTransactionsData.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns, true) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns, true) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_created_contract_address_id_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_created_contract_address_id_index.ex new file mode 100644 index 000000000000..37def77e79bc --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_created_contract_address_id_index.ex @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsCreatedContractAddressIdIndex do + @moduledoc """ + Create B-tree index on `internal_transactions` table for `created_contract_address_id` column. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsToAddressIdPartialIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_created_contract_address_id_index" + @operation_type :create + @table_columns ["created_contract_address_id"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [CreateInternalTransactionsToAddressIdPartialIndex.migration_name()] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_from_address_id_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_from_address_id_partial_index.ex new file mode 100644 index 000000000000..b2acff175525 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_from_address_id_partial_index.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsFromAddressIdPartialIndex do + @moduledoc """ + Create partial B-tree index `internal_transactions_from_address_id_partial_index` on `internal_transactions` table for (`from_address_id`, `block_number` DESC, `transaction_index` DESC, `index` DESC) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_from_address_id_partial_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name}(from_address_id ASC, block_number DESC, transaction_index DESC, index DESC) + WHERE ((type = 'call' AND index > 0) OR type != 'call'); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_to_address_id_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_to_address_id_partial_index.ex new file mode 100644 index 000000000000..acc1c985820b --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_internal_transactions_to_address_id_partial_index.ex @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsToAddressIdPartialIndex do + @moduledoc """ + Create partial B-tree index `internal_transactions_to_address_id_partial_index` on `internal_transactions` table for (`to_address_id`, `block_number` DESC, `transaction_index` DESC, `index` DESC) columns. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsFromAddressIdPartialIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_to_address_id_partial_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [CreateInternalTransactionsFromAddressIdPartialIndex.migration_name()] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name}(to_address_id ASC, block_number DESC, transaction_index DESC, index DESC) + WHERE ((type = 'call' AND index > 0) OR type != 'call'); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index.ex index 6dae3af18d79..222ee72abacf 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashBlockNumberDescIndexDescIndex do @moduledoc """ Create B-tree index `logs_address_hash_block_number_DESC_index_DESC_index` on `logs` table for (`address_hash`, `block_number DESC`, `index DESC`) columns. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_first_topic_block_number_index_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_first_topic_block_number_index_index.ex index 2842faca9a73..813fd2dfef89 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_first_topic_block_number_index_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_first_topic_block_number_index_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashFirstTopicBlockNumberIndexIndex do @moduledoc """ Create B-tree index `logs_address_hash_first_topic_block_number_index_index` on `logs` table for (`address_hash`, `first_topic`, `block_number`, `index`) columns. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_hash_index.ex index 5242457ff8d1..04e8a69fa9f0 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_block_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsBlockHashIndex do @moduledoc """ Create B-tree index on `logs` table for `block_hash` column. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index.ex index bf94d4f8840d..b08e4abfbbb8 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_logs_deposits_withdrawals_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsDepositsWithdrawalsIndex do @moduledoc """ Create partial B-tree index `logs_deposits_withdrawals_index` on `logs` table for (`transaction_hash`, `block_hash`, `index`, `address_hash`) columns, filtered by first_topic IN diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contract_additional_sources_unique_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contract_additional_sources_unique_index.ex new file mode 100644 index 000000000000..67eec9c86371 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contract_additional_sources_unique_index.ex @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateSmartContractAdditionalSourcesUniqueIndex do + @moduledoc """ + Creates a unique index on the smart_contract_additional_sources table. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{ + HeavyDbIndexOperation, + MigrationStatus, + SanitizeDuplicateSmartContractAdditionalSources + } + + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :smart_contracts_additional_sources + @index_name "smart_contracts_additional_sources_file_name_address_hash_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [SanitizeDuplicateSmartContractAdditionalSources.migration_name()] + + @query_string """ + CREATE UNIQUE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name} (address_hash, file_name); + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contracts_language_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contracts_language_index.ex index 4f1bfad09172..c8145ca1a33f 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contracts_language_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_smart_contracts_language_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateSmartContractsLanguageIndex do @moduledoc """ Create B-tree index `smart_contracts_language_index` on `smart_contracts` diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_name_partial_fts_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_name_partial_fts_index.ex new file mode 100644 index 000000000000..5318b69d78ef --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_name_partial_fts_index.ex @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTokensNamePartialFtsIndex do + @moduledoc """ + Creates partial full-text search index on the `tokens` table with filtering by `symbol IS NULL`. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + + alias Explorer.Migrator.{ + HeavyDbIndexOperation, + MigrationStatus + } + + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :tokens + @index_name "tokens_name_partial_fts_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name} USING GIN (to_tsvector('english', name)) + WHERE symbol IS NULL; + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_tokens_name_partial_fts_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_fiat_holder_name_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_fiat_holder_name_index.ex new file mode 100644 index 000000000000..43217d76b522 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_fiat_holder_name_index.ex @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdFiatHolderNameIndex do + @moduledoc """ + Create B-tree index `idx_tokens_ord_fiat_holder_name` on `tokens` table for + (`fiat_value DESC NULLS LAST`, `holder_count DESC NULLS LAST`, `name`). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdMcapFiatHolderNameIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :tokens + @index_name "idx_tokens_ord_fiat_holder_name" + @operation_type :create + @table_columns [ + "fiat_value DESC NULLS LAST", + "holder_count DESC NULLS LAST", + "name" + ] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [CreateTokensOrdMcapFiatHolderNameIndex.migration_name()] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_idx_tokens_ord_fiat_holder_name_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_holder_name_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_holder_name_index.ex new file mode 100644 index 000000000000..9e0f49d7082b --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_holder_name_index.ex @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdHolderNameIndex do + @moduledoc """ + Create B-tree index `idx_tokens_ord_holder_name` on `tokens` table for + (`holder_count DESC NULLS LAST`, `name`). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdFiatHolderNameIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :tokens + @index_name "idx_tokens_ord_holder_name" + @operation_type :create + @table_columns ["holder_count DESC NULLS LAST", "name"] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [CreateTokensOrdFiatHolderNameIndex.migration_name()] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_idx_tokens_ord_holder_name_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_mcap_fiat_holder_name_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_mcap_fiat_holder_name_index.ex new file mode 100644 index 000000000000..bc0d5a20dbf5 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_tokens_ord_mcap_fiat_holder_name_index.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTokensOrdMcapFiatHolderNameIndex do + @moduledoc """ + Create B-tree index `idx_tokens_ord_mcap_fiat_holder_name` on `tokens` table + for (`circulating_market_cap DESC NULLS LAST`, `fiat_value DESC NULLS LAST`, + `holder_count DESC NULLS LAST`, `name`). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :tokens + @index_name "idx_tokens_ord_mcap_fiat_holder_name" + @operation_type :create + @table_columns [ + "circulating_market_cap DESC NULLS LAST", + "fiat_value DESC NULLS LAST", + "holder_count DESC NULLS LAST", + "name" + ] + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@index_name, @table_name, @table_columns) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.create_index_query_string(@index_name, @table_name, @table_columns) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_idx_tokens_ord_mcap_fiat_holder_name_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_created_contract_address_hash_w_pending_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_created_contract_address_hash_w_pending_index.ex new file mode 100644 index 000000000000..24fc1fe1c0a7 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/create_transactions_created_contract_address_hash_w_pending_index.ex @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex do + @moduledoc """ + Creates partial index "transactions_created_contract_address_hash_w_pending_index" on transactions (created_contract_address_hash, block_number ASC NULLS LAST, index ASC NULLS LAST, inserted_at ASC, hash DESC) WHERE created_contract_address_hash IS NOT NULL. + """ + + use Explorer.Migrator.HeavyDbIndexOperation + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :transactions + @index_name "transactions_created_contract_address_hash_w_pending_index" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + if @chain_type == :optimism do + alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsOperatorFeeConstantIndex + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [DropTransactionsOperatorFeeConstantIndex.migration_name()] + end + else + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [] + end + end + + @query_string """ + CREATE INDEX #{HeavyDbIndexOperationHelper.add_concurrently_flag?()} IF NOT EXISTS "#{@index_name}" + ON #{@table_name} (created_contract_address_hash, block_number ASC NULLS LAST, "index" ASC NULLS LAST, inserted_at ASC, hash DESC) + WHERE created_contract_address_hash IS NOT NULL; + """ + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.create_db_index(@query_string) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, @query_string) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_creation_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished( + true + ) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_addresses_verified_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_addresses_verified_index.ex index 867aa18700ba..6d75f90be2c6 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_addresses_verified_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_addresses_verified_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropAddressesVerifiedIndex do @moduledoc """ Drops index "addresses_verified_index" on the addresses table. diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_block_hash_transaction_index_index_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_block_hash_transaction_index_index_index.ex new file mode 100644 index 000000000000..374d8e58e9b0 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_block_hash_transaction_index_index_index.ex @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockHashTransactionIndexIndexIndex do + @moduledoc """ + Drops index "internal_transactions_block_hash_transaction_index_index_index" btree (block_hash, transaction_index, index). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{ + EmptyInternalTransactionsData, + HeavyDbIndexOperation, + MigrationStatus + } + + alias Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashPartialIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_block_hash_transaction_index_index_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [ + EmptyInternalTransactionsData.migration_name(), + DropInternalTransactionsCreatedContractAddressHashPartialIndex.migration_name() + ] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + with :ok <- HeavyDbIndexOperationHelper.cancel_index_creating_query(@index_name) do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_block_number_created_contract_address_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_block_number_created_contract_address_hash_index.ex new file mode 100644 index 000000000000..0d19cd7926fc --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_block_number_created_contract_address_hash_index.ex @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex do + @moduledoc """ + Drops index "internal_transactions_block_number_created_contract_address_hash" btree (block_number, created_contract_address_hash) WHERE (created_contract_address_hash IS NOT NULL). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillInternalTransactionsAddressIds, HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_block_number_created_contract_address_hash" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [FillInternalTransactionsAddressIds.migration_name()] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_created_contract_address_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_created_contract_address_hash_index.ex new file mode 100644 index 000000000000..d07d2a8b95dc --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_created_contract_address_hash_index.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex do + @moduledoc """ + Drops index "internal_transactions_created_contract_address_hash_index" btree (created_contract_address_hash). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillInternalTransactionsAddressIds, HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_created_contract_address_hash_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillInternalTransactionsAddressIds.migration_name(), + DropInternalTransactionsBlockNumberCreatedContractAddressHashIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_created_contract_address_hash_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_created_contract_address_hash_partial_index.ex new file mode 100644 index 000000000000..e3889aca61ce --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_created_contract_address_hash_partial_index.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashPartialIndex do + @moduledoc """ + Drops index "internal_transactions_created_contract_address_hash_partial_index" on internal_transactions(created_contract_address_hash, block_number DESC, transaction_index DESC, index DESC). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{EmptyInternalTransactionsData, HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_created_contract_address_hash_partial_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [EmptyInternalTransactionsData.migration_name()] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_drop_internal_transactions_created_contract_address_hash_partial_index_finished( + true + ) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_index.ex index 8ac159cafb69..1051a5c1679c 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashIndex do @moduledoc """ Drops index "internal_transactions_from_address_hash_index" btree (from_address_hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_partial_index.ex new file mode 100644 index 000000000000..b424cd6b2855 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_from_address_hash_partial_index.ex @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex do + @moduledoc """ + Drops index "internal_transactions_from_address_hash_partial_index" btree (from_address_hash, block_number DESC, transaction_index DESC, index DESC) WHERE ((((type)::text = 'call'::text) AND (index > 0)) OR ((type)::text <> 'call'::text)). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillInternalTransactionsAddressIds, HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsCreatedContractAddressHashIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_from_address_hash_partial_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillInternalTransactionsAddressIds.migration_name(), + DropInternalTransactionsCreatedContractAddressHashIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_to_address_hash_partial_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_to_address_hash_partial_index.ex new file mode 100644 index 000000000000..ccec84d6a2c8 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_internal_transactions_to_address_hash_partial_index.ex @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsToAddressHashPartialIndex do + @moduledoc """ + Drops index "internal_transactions_to_address_hash_partial_index" btree (to_address_hash, block_number DESC, transaction_index DESC, index DESC) WHERE ((((type)::text = 'call'::text) AND (index > 0)) OR ((type)::text <> 'call'::text)). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{FillInternalTransactionsAddressIds, HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.DropInternalTransactionsFromAddressHashPartialIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :internal_transactions + @index_name "internal_transactions_to_address_hash_partial_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + FillInternalTransactionsAddressIds.migration_name(), + DropInternalTransactionsFromAddressHashPartialIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_index.ex index 3f7244a40bc7..8dc777e6b12e 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashIndex do @moduledoc """ Drops index "logs_address_hash_index" btree (address_hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_transaction_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_transaction_hash_index.ex index d463bdb6c5ef..5f84a19378de 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_transaction_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_address_hash_transaction_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsAddressHashTransactionHashIndex do @moduledoc """ Drops index "logs_address_hash_transaction_hash_index" btree (address_hash, transaction_hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index.ex index 837aa2dd802a..f34bf69f23d1 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsBlockNumberAscIndexAscIndex do @moduledoc """ Drops index "logs_block_number_ASC__index_ASC_index" btree (block_number, index). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_index_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_index_index.ex index 6646c901c3a7..118400c0138e 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_index_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_logs_index_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropLogsIndexIndex do @moduledoc """ Drops index "logs_index_index" btree (index). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_instances_token_id_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_instances_token_id_index.ex new file mode 100644 index 000000000000..8616a1ef7b11 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_instances_token_id_index.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTokenInstancesTokenIdIndex do + @moduledoc """ + Drops index "token_instances_token_id_index" btree (token_id). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :token_instances + @index_name "token_instances_token_id_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_drop_token_instances_token_id_index_finished(true) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_asc_log_index_asc_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_asc_log_index_asc_index.ex index b7b76039a066..9e59518fbb78 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_asc_log_index_asc_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_asc_log_index_asc_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTokenTransfersBlockNumberAscLogIndexAscIndex do @moduledoc """ Drops index "token_transfers_block_number_ASC_log_index_ASC_index" btree (block_number, log_index). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_index.ex index 68129241a6d3..0a9b88fcba14 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_block_number_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTokenTransfersBlockNumberIndex do @moduledoc """ Drops index "token_transfers_block_number_index" btree (block_number). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_from_address_hash_transaction_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_from_address_hash_transaction_hash_index.ex index 1649096b6a58..1b7f87ce4c13 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_from_address_hash_transaction_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_from_address_hash_transaction_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTokenTransfersFromAddressHashTransactionHashIndex do @moduledoc """ Drops index "token_transfers_from_address_hash_transaction_hash_index" btree (from_address_hash, transaction_hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_to_address_hash_transaction_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_to_address_hash_transaction_hash_index.ex index dd94d4d5e9a9..0fcb9c87ce31 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_to_address_hash_transaction_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_to_address_hash_transaction_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTokenTransfersToAddressHashTransactionHashIndex do @moduledoc """ Drops index "token_transfers_to_address_hash_transaction_hash_index" btree (to_address_hash, transaction_hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_token_contract_address_hash_transaction_hash_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_token_contract_address_hash_transaction_hash_index.ex index 638cec9fa075..5651ab30607f 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_token_contract_address_hash_transaction_hash_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_token_transfers_token_contract_address_hash_transaction_hash_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTokenTransfersTokenContractAddressHashTransactionHashIndex do @moduledoc """ Drops index "token_transfers_token_contract_address_hash_transaction_hash_index" btree (token_contract_address_hash, transaction_hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index.ex index 083d92118f4d..540bfba1b43a 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndex do @moduledoc """ Drops index "transactions_created_contract_address_hash_with_pending_index" btree (created_contract_address_hash, block_number DESC, index DESC, inserted_at DESC, hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index_a.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index_a.ex new file mode 100644 index 000000000000..bd9237245b33 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index_a.ex @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA do + @moduledoc """ + Drops index "transactions_created_contract_address_hash_with_pending_index_a" btree (created_contract_address_hash, block_number ASC NULLS LAST, index ASC NULLS LAST, inserted_at ASC, hash DESC). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :transactions + @index_name "transactions_created_contract_address_hash_with_pending_index_a" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations do + [CreateTransactionsCreatedContractAddressHashWPendingIndex.migration_name()] + end + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished( + true + ) + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_from_address_hash_with_pending_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_from_address_hash_with_pending_index.ex index 0a72cb56fed2..7dfedb1088c4 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_from_address_hash_with_pending_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_from_address_hash_with_pending_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsFromAddressHashWithPendingIndex do @moduledoc """ Drops index "transactions_from_address_hash_with_pending_index" btree (from_address_hash, block_number DESC, index DESC, inserted_at DESC, hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_operator_fee_constant_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_operator_fee_constant_index.ex new file mode 100644 index 000000000000..59289090eea8 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_operator_fee_constant_index.ex @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsOperatorFeeConstantIndex do + @moduledoc """ + Drops index "transactions_operator_fee_constant_index" btree (operator_fee_constant IS NULL). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper, as: HeavyDbIndexOperationHelper + + @table_name :transactions + @index_name "transactions_operator_fee_constant_index" + @operation_type :drop + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, do: [] + + @impl HeavyDbIndexOperation + def db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + operation = HeavyDbIndexOperationHelper.drop_index_query_string(@index_name) + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) + end + + @impl HeavyDbIndexOperation + def db_index_operation_status do + HeavyDbIndexOperationHelper.db_index_dropping_status(@index_name) + end + + @impl HeavyDbIndexOperation + def restart_db_index_operation do + HeavyDbIndexOperationHelper.safely_drop_db_index(@index_name) + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_to_address_hash_with_pending_index.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_to_address_hash_with_pending_index.ex index e0f4067f2fe5..75ea59bf6e39 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_to_address_hash_with_pending_index.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/drop_transactions_to_address_hash_with_pending_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsToAddressHashWithPendingIndex do @moduledoc """ Drops index "transactions_to_address_hash_with_pending_index" btree (to_address_hash, block_number DESC, index DESC, inserted_at DESC, hash). diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex index d8920bfab76a..6b3b74e37e0e 100644 --- a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do @moduledoc """ Common functions for Explorer.Migrator.HeavyDbIndexOperation.* modules @@ -106,9 +107,9 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do Creates DB index with the given name and table name atom, if it doesn't exist. """ @spec create_db_index(String.t(), atom(), list()) :: :ok | :error - def create_db_index(raw_index_name, table_name_atom, table_columns) do + def create_db_index(raw_index_name, table_name_atom, table_columns, unique? \\ false) do index_name = sanitize_index_name(raw_index_name) - query = create_index_query_string(index_name, table_name_atom, table_columns) + query = create_index_query_string(index_name, table_name_atom, table_columns, unique?) run_create_db_index_query(query) end @@ -153,9 +154,9 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do "CREATE INDEX CONCURRENTLY IF NOT EXISTS \"my_index\" on my_table (column1, column2);" """ - @spec create_index_query_string(String.t(), atom(), list()) :: String.t() - def create_index_query_string(index_name, table_name_atom, table_columns) do - "CREATE INDEX #{add_concurrently_flag?()} IF NOT EXISTS \"#{index_name}\" on #{to_string(table_name_atom)} (#{Enum.join(table_columns, ", ")});" + @spec create_index_query_string(String.t(), atom(), list(), boolean()) :: String.t() + def create_index_query_string(index_name, table_name_atom, table_columns, unique? \\ false) do + "CREATE #{(unique? && "UNIQUE") || ""} INDEX #{add_concurrently_flag?()} IF NOT EXISTS \"#{index_name}\" on #{to_string(table_name_atom)} (#{Enum.join(table_columns, ", ")});" end @doc """ @@ -176,6 +177,24 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do end end + @doc """ + Cancels currently active index creating query. + """ + @spec cancel_index_creating_query(String.t()) :: :ok | :error + # sobelow_skip ["SQL"] + def cancel_index_creating_query(raw_index_name) do + index_name = sanitize_index_name(raw_index_name) + + case SQL.query(Repo, cancel_index_query_string(index_name), [], timeout: :infinity) do + {:ok, _} -> + :ok + + {:error, error} -> + Logger.error("Failed to cancel creating DB index '#{index_name}': #{inspect(error)}") + :error + end + end + @doc """ Returns the prefix used for naming heavy database operation migrations. @@ -214,6 +233,23 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.Helper do "DROP INDEX #{add_concurrently_flag?()} IF EXISTS \"#{index_name}\";" end + @doc """ + Generates a SQL query string to cancel a currently active `CREATE INDEX` query. + """ + @spec cancel_index_query_string(String.t()) :: String.t() + def cancel_index_query_string(raw_index_name) do + index_name = sanitize_index_name(raw_index_name) + + """ + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + AND query ILIKE '%create%index%' + AND query ILIKE '%#{index_name}%' + AND state = 'active'; + """ + end + @doc """ As a workaround we have to remove `CONCURRENTLY` in tests since the error like "DROP INDEX CONCURRENTLY cannot run inside a transaction diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_block_hash_transaction_hash_block_index_error.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_block_hash_transaction_hash_block_index_error.ex new file mode 100644 index 000000000000..3ec34266a3be --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/remove_internal_transactions_block_hash_transaction_hash_block_index_error.ex @@ -0,0 +1,220 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError do + @moduledoc """ + Removes `block_hash`, `transaction_hash`, `block_index` and `error` columns from `internal_transactions` + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex, + CreateInternalTransactionsCreatedContractAddressIdIndex, + CreateInternalTransactionsFromAddressIdPartialIndex, + CreateInternalTransactionsToAddressIdPartialIndex, + UpdateInternalTransactionsPrimaryKey + } + + alias Explorer.Repo + + @table_name :internal_transactions + @index_name "internal_transactions_remove_block_hash_transaction_hash_block_index_error" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + UpdateInternalTransactionsPrimaryKey.migration_name(), + CreateInternalTransactionsCreatedContractAddressIdIndex.migration_name(), + CreateInternalTransactionsBlockNumberCreatedContractAddressIdPartialIndex.migration_name(), + CreateInternalTransactionsFromAddressIdPartialIndex.migration_name(), + CreateInternalTransactionsToAddressIdPartialIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + Logger.info("Migration RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError started") + + with {:ok, _} <- drop_blocks_foreign_key(), + {:ok, _} <- drop_transactions_foreign_key(), + {:ok, _} <- Repo.query(drop_columns_query_string(), [], timeout: :infinity) do + Logger.info("Migration RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError finished") + :ok + else + {:error, error} -> + Logger.error( + "Migration RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError failed: #{inspect(error)}" + ) + + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, drop_columns_query_string()) + end + + @impl HeavyDbIndexOperation + # credo:disable-for-next-line /Complexity/ + def db_index_operation_status do + completed? = + case Repo.query(""" + SELECT COUNT(*) = 0 + FROM information_schema.columns + WHERE table_name = '#{@table_name}' AND column_name IN ('block_hash', 'transaction_hash', 'block_index', 'error'); + """) do + {:ok, %Postgrex.Result{rows: [[completed]]}} -> completed + _ -> nil + end + + started? = + case check_db_index_operation_progress() do + :in_progress -> true + :finished_or_not_started -> false + _ -> nil + end + + cond do + completed? == true -> :completed + started? == true -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def restart_db_index_operation, do: db_index_operation() + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok + + # sobelow_skip ["SQL"] + defp drop_blocks_foreign_key do + if foreign_key_exists?("internal_transactions_block_hash_fkey") do + do_drop_blocks_foreign_key() + else + {:ok, :dropped} + end + end + + # sobelow_skip ["SQL"] + defp do_drop_blocks_foreign_key do + Repo.transaction( + fn -> + with {:ok, _} <- Repo.query(lock_blocks_query_string(), [], timeout: :infinity), + {:ok, _} <- Repo.query(drop_blocks_foreign_key_query_string(), [], timeout: :infinity) do + Logger.info("Migration RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError finished blocks") + :ok + else + {:error, error} -> Repo.rollback(error) + end + end, + timeout: :infinity + ) + end + + # sobelow_skip ["SQL"] + defp drop_transactions_foreign_key do + if foreign_key_exists?("internal_transactions_transaction_hash_fkey") do + do_drop_transactions_foreign_key() + else + {:ok, :dropped} + end + end + + # sobelow_skip ["SQL"] + defp do_drop_transactions_foreign_key do + Repo.transaction( + fn -> + with {:ok, _} <- Repo.query(lock_internal_transactions_query_string(), [], timeout: :infinity), + {:ok, _} <- Repo.query(lock_transactions_query_string(), [], timeout: :infinity), + {:ok, _} <- Repo.query(drop_transactions_foreign_key_query_string(), [], timeout: :infinity) do + Logger.info( + "Migration RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError finished transactions" + ) + + :ok + else + {:error, error} -> Repo.rollback(error) + end + end, + timeout: :infinity + ) + end + + # sobelow_skip ["SQL"] + defp foreign_key_exists?(foreign_key_name) do + {:ok, %Postgrex.Result{rows: [[exists?]]}} = + Repo.query(""" + SELECT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = '#{foreign_key_name}' + ); + """) + + exists? + end + + defp lock_blocks_query_string do + """ + LOCK TABLE blocks IN ACCESS EXCLUSIVE MODE; + """ + end + + defp lock_transactions_query_string do + """ + LOCK TABLE transactions IN ACCESS EXCLUSIVE MODE; + """ + end + + defp lock_internal_transactions_query_string do + """ + LOCK TABLE internal_transactions IN ACCESS EXCLUSIVE MODE; + """ + end + + defp drop_blocks_foreign_key_query_string do + """ + ALTER TABLE #{@table_name} + DROP CONSTRAINT IF EXISTS internal_transactions_block_hash_fkey; + """ + end + + defp drop_transactions_foreign_key_query_string do + """ + ALTER TABLE #{@table_name} + DROP CONSTRAINT IF EXISTS internal_transactions_transaction_hash_fkey; + """ + end + + defp drop_columns_query_string do + """ + ALTER TABLE #{@table_name} + DROP COLUMN IF EXISTS block_hash, + DROP COLUMN IF EXISTS transaction_hash, + DROP COLUMN IF EXISTS block_index, + DROP COLUMN IF EXISTS error; + """ + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_internal_transactions_primary_key.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_internal_transactions_primary_key.ex new file mode 100644 index 000000000000..c385291823bf --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/update_internal_transactions_primary_key.ex @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey do + @moduledoc """ + Update primary key on `internal_transactions` table from (`block_hash`, `block_index`) to (`block_number`, `transaction_index`, `index`). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + + alias Explorer.Migrator.HeavyDbIndexOperation.{ + CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, + ValidateInternalTransactionsBlockNumberTransactionIndexNotNull + } + + alias Explorer.Repo + + @table_name :internal_transactions + @index_name "internal_transactions_pkey" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + ValidateInternalTransactionsBlockNumberTransactionIndexNotNull.migration_name() + ] + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + result = + Repo.transaction(fn -> + with {:ok, _} <- Repo.query(drop_pk_constraint_query_string()), + {:ok, _} <- Repo.query(rename_index_query_string()), + {:ok, _} <- Repo.query(add_new_pk_query_string()), + {:ok, _} <- Repo.query(drop_block_index_not_null_query_string()), + {:ok, _} <- Repo.query(drop_block_hash_not_null_query_string()) do + update_cache() + :ok + else + {:error, error} -> + Repo.rollback(error) + end + end) + + case result do + {:ok, :ok} -> + :ok + + {:error, error} -> + Logger.error("Migration UpdateInternalTransactionsPrimaryKey failed: #{inspect(error)}") + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + all_operations = [ + drop_pk_constraint_query_string(), + rename_index_query_string(), + add_new_pk_query_string(), + drop_block_index_not_null_query_string(), + drop_block_hash_not_null_query_string() + ] + + Enum.reduce_while(all_operations, :finished_or_not_started, fn operation, acc -> + case HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) do + :finished_or_not_started -> {:cont, acc} + progress -> {:halt, progress} + end + end) + end + + @impl HeavyDbIndexOperation + # credo:disable-for-next-line /Complexity/ + def db_index_operation_status do + completed? = + case Repo.query(""" + SELECT is_nullable + FROM information_schema.columns + WHERE table_name = '#{@table_name}' AND column_name = 'block_index'; + """) do + {:ok, %Postgrex.Result{rows: [["YES"]]}} -> true + {:ok, %Postgrex.Result{rows: [["NO"]]}} -> false + _ -> nil + end + + started? = + case Repo.query(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND table_name = '#{@table_name}' + AND constraint_type = 'PRIMARY KEY' + ); + """) do + {:ok, %Postgrex.Result{rows: [[false]]}} -> true + {:ok, %Postgrex.Result{rows: [[true]]}} -> false + _ -> nil + end + + cond do + completed? == true -> :completed + started? == true -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def restart_db_index_operation do + result = + Repo.transaction(fn -> + with {:ok, _} <- Repo.query(drop_pk_constraint_query_string()), + {:ok, _} <- Repo.query(set_block_index_not_null_query_string()), + {:ok, _} <- Repo.query(set_block_hash_not_null_query_string()), + {:ok, _} <- Repo.query(add_old_pk_query_string()) do + :ok + else + {:error, error} -> + Repo.rollback(error) + end + end) + + case result do + {:ok, :ok} -> + :ok + + {:error, error} -> + Logger.error("Migration UpdateInternalTransactionsPrimaryKey rollback failed: #{inspect(error)}") + :error + end + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache do + BackgroundMigrations.set_heavy_indexes_update_internal_transactions_primary_key_finished(true) + end + + defp drop_pk_constraint_query_string do + "ALTER TABLE #{@table_name} DROP CONSTRAINT #{@index_name};" + end + + defp rename_index_query_string do + "ALTER INDEX #{CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex.index_name()} RENAME TO #{@index_name};" + end + + defp add_new_pk_query_string do + "ALTER TABLE #{@table_name} ADD PRIMARY KEY USING INDEX #{@index_name};" + end + + defp add_old_pk_query_string do + "ALTER TABLE #{@table_name} ADD PRIMARY KEY (block_hash, block_index);" + end + + defp drop_block_index_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN block_index DROP NOT NULL;" + end + + defp set_block_index_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN block_index SET NOT NULL;" + end + + defp drop_block_hash_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN block_hash DROP NOT NULL;" + end + + defp set_block_hash_not_null_query_string do + "ALTER TABLE #{@table_name} ALTER COLUMN block_hash SET NOT NULL;" + end +end diff --git a/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex new file mode 100644 index 000000000000..6c0b046d5597 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/heavy_db_index_operation/validate_internal_transactions_block_number_transaction_index_not_null.ex @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.ValidateInternalTransactionsBlockNumberTransactionIndexNotNull do + @moduledoc """ + Validate `NOT NULL` constraints for `internal_transactions` (`block_number`, `transaction_index`). + """ + + use Explorer.Migrator.HeavyDbIndexOperation + + require Logger + + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex + alias Explorer.Repo + + @table_name :internal_transactions + @index_name "internal_transactions_not_null_constraints" + @operation_type :create + + @impl HeavyDbIndexOperation + def table_name, do: @table_name + + @impl HeavyDbIndexOperation + def operation_type, do: @operation_type + + @impl HeavyDbIndexOperation + def index_name, do: @index_name + + @impl HeavyDbIndexOperation + def dependent_from_migrations, + do: [ + CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex.migration_name() + ] + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def db_index_operation do + result = + Repo.transaction( + fn -> + with {:ok, _} <- Repo.query(validate_constraint_query_string("block_number"), [], timeout: :infinity), + {:ok, _} <- Repo.query(validate_constraint_query_string("transaction_index"), [], timeout: :infinity), + {:ok, _} <- Repo.query(set_not_null_query_string("block_number")), + {:ok, _} <- Repo.query(set_not_null_query_string("transaction_index")) do + :ok + else + {:error, error} -> + Repo.rollback(error) + end + end, + timeout: :infinity + ) + + case result do + {:ok, :ok} -> + :ok + + {:error, error} -> + Logger.error( + "Migration ValidateInternalTransactionsBlockNumberTransactionIndexNotNull failed: #{inspect(error)}" + ) + + :error + end + end + + @impl HeavyDbIndexOperation + def check_db_index_operation_progress do + all_operations = [ + validate_constraint_query_string("block_number"), + validate_constraint_query_string("transaction_index"), + set_not_null_query_string("block_number"), + set_not_null_query_string("transaction_index") + ] + + Enum.reduce_while(all_operations, :finished_or_not_started, fn operation, acc -> + case HeavyDbIndexOperationHelper.check_db_index_operation_progress(@index_name, operation) do + :finished_or_not_started -> {:cont, acc} + progress -> {:halt, progress} + end + end) + end + + @impl HeavyDbIndexOperation + # credo:disable-for-next-line /Complexity/ + def db_index_operation_status do + completed? = + case Repo.query(""" + SELECT is_nullable + FROM information_schema.columns + WHERE table_name = '#{@table_name}' AND (column_name = 'block_number' OR column_name = 'transaction_index'); + """) do + {:ok, %Postgrex.Result{rows: [["NO"], ["NO"]]}} -> true + {:ok, %Postgrex.Result{rows: [_, _]}} -> false + _ -> nil + end + + started? = + case Repo.query(""" + SELECT convalidated + FROM pg_constraint + WHERE conname = '#{@table_name}_block_number_not_null'; + """) do + {:ok, %Postgrex.Result{rows: [[true]]}} -> true + {:ok, %Postgrex.Result{rows: [[false]]}} -> false + _ -> nil + end + + cond do + completed? == true -> :completed + started? == true -> :not_completed + is_nil(completed?) or is_nil(started?) -> :unknown + true -> :not_initialized + end + end + + @impl HeavyDbIndexOperation + # sobelow_skip ["SQL"] + def restart_db_index_operation do + case Repo.query(""" + SELECT pg_cancel_backend(pid) + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + AND (query ILIKE '%#{validate_constraint_query_string("block_number")}%' OR query ILIKE '%#{validate_constraint_query_string("transaction_index")}%') + AND state = 'active'; + """) do + {:ok, _} -> :ok + _ -> :error + end + end + + @impl HeavyDbIndexOperation + def running_other_heavy_migration_exists?(migration_name) do + MigrationStatus.running_other_heavy_migration_for_table_exists?(@table_name, migration_name) + end + + @impl HeavyDbIndexOperation + def update_cache, do: :ok + + defp validate_constraint_query_string(column) do + "ALTER TABLE #{@table_name} VALIDATE CONSTRAINT #{@table_name}_#{column}_not_null;" + end + + defp set_not_null_query_string(column) do + "ALTER TABLE #{@table_name} ALTER COLUMN #{column} SET NOT NULL;" + end +end diff --git a/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex b/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex new file mode 100644 index 000000000000..473d03fcf55d --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/merge_adjacent_missing_block_ranges.ex @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.MergeAdjacentMissingBlockRanges do + @moduledoc """ + Merges adjacent missing block ranges (like 10..5, 4..3) into one (10..3). + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + alias Explorer.Utility.MissingBlockRange + + @migration_name "merge_adjacent_missing_block_ranges" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() + + data = + unprocessed_data_query() + |> select([m1, _m2], m1) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {data, state} + end + + @impl FillingMigration + def unprocessed_data_query do + from(m1 in MissingBlockRange, + inner_join: m2 in MissingBlockRange, + on: + m1.from_number + 1 == m2.to_number and + ((is_nil(m1.priority) and is_nil(m2.priority)) or m1.priority == m2.priority) + ) + end + + @impl FillingMigration + def update_batch(ranges_batch) do + {priority_ranges, non_priority_ranges, delete_ids} = + Enum.reduce(ranges_batch, {[], [], []}, fn range, {priority_acc, non_priority_acc, delete_acc} -> + if is_nil(range.priority) do + {priority_acc, [range.from_number..range.to_number | non_priority_acc], [range.id | delete_acc]} + else + {[range.from_number..range.to_number | priority_acc], non_priority_acc, [range.id | delete_acc]} + end + end) + + Repo.transaction(fn -> + MissingBlockRange + |> where([m], m.id in ^Enum.uniq(delete_ids)) + |> Repo.delete_all(timeout: :infinity) + + priority_ranges + |> RangesHelper.sanitize_ranges() + |> MissingBlockRange.save_batch(1) + + non_priority_ranges + |> RangesHelper.sanitize_ranges() + |> MissingBlockRange.save_batch(nil) + end) + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/migration_status.ex b/apps/explorer/lib/explorer/migrator/migration_status.ex index a2a16b0bcd95..cd085ebab391 100644 --- a/apps/explorer/lib/explorer/migrator/migration_status.ex +++ b/apps/explorer/lib/explorer/migrator/migration_status.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.MigrationStatus do @moduledoc """ Module is responsible for keeping the current status of background migrations. @@ -129,13 +130,16 @@ defmodule Explorer.Migrator.MigrationStatus do @doc """ Checks if there are any running heavy migrations except the current. - A heavy migration is identified by its name starting with "heavy_indexes_create_{table_name}" or "heavy_indexes_drop_{table_name}" prefixes. + A heavy migration is identified by its name starting with "heavy_indexes_create_{table_name}", "heavy_indexes_drop_{table_name}", or "heavy_indexes_rename_{table_name}" prefixes. """ @spec running_other_heavy_migration_for_table_exists?(Ecto.Queryable.t(), atom(), String.t()) :: boolean() def running_other_heavy_migration_for_table_exists?(query \\ __MODULE__, table_name, migration_name) do heavy_migrations_create_prefix = "#{HeavyDbIndexOperationHelper.heavy_db_operation_migration_name_prefix()}create_#{to_string(table_name)}%" + heavy_migrations_rename_prefix = + "#{HeavyDbIndexOperationHelper.heavy_db_operation_migration_name_prefix()}rename_#{to_string(table_name)}%" + heavy_migrations_drop_prefix = "#{HeavyDbIndexOperationHelper.heavy_db_operation_migration_name_prefix()}drop_#{to_string(table_name)}%" @@ -143,6 +147,7 @@ defmodule Explorer.Migrator.MigrationStatus do from(ms in query, where: ilike(ms.migration_name, ^heavy_migrations_create_prefix) or + ilike(ms.migration_name, ^heavy_migrations_rename_prefix) or ilike(ms.migration_name, ^heavy_migrations_drop_prefix), where: ms.migration_name != ^migration_name, where: ms.status == ^"started" @@ -169,4 +174,19 @@ defmodule Explorer.Migrator.MigrationStatus do |> fetch_migration_statuses_query() |> Repo.all() end + + @doc """ + Fetches all uncompleted migrations (status != 'completed'). + + ## Returns + + - A list of uncompleted migration status structs. + + """ + @spec fetch_uncompleted_migrations() :: list(__MODULE__.t()) + def fetch_uncompleted_migrations do + __MODULE__ + |> where([ms], ms.status != "completed") + |> Repo.all() + end end diff --git a/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex b/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex index a20574417c1c..2a6ee0accbe5 100644 --- a/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex +++ b/apps/explorer/lib/explorer/migrator/refetch_contract_codes.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.RefetchContractCodes do @moduledoc """ Refetch contract_code for. Migration created for running on zksync chain type. diff --git a/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex new file mode 100644 index 000000000000..39e75750d076 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/reindex_blocks_with_missing_transactions.ex @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.ReindexBlocksWithMissingTransactions do + @moduledoc """ + Searches for all blocks where the number of transactions differs from the number of transactions on the node, + and sets refetch_needed=true for them. + """ + + use Explorer.Migrator.FillingMigration + + require Logger + + import Ecto.Query + + alias Explorer.Chain.{Block, Transaction} + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Migrator.{FillingMigration, MigrationStatus} + alias Explorer.Repo + + @migration_name "reindex_blocks_with_missing_transactions" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(%{"max_block_number" => -1} = state), do: {[], state} + + def last_unprocessed_identifiers(%{"max_block_number" => from_block_number} = state) do + limit = batch_size() * concurrency() + to_block_number = max(from_block_number - limit + 1, 0) + + {Enum.to_list(from_block_number..to_block_number), %{state | "max_block_number" => to_block_number - 1}} + end + + def last_unprocessed_identifiers(state) do + state + |> Map.put("max_block_number", BlockNumber.get_max()) + |> last_unprocessed_identifiers() + end + + @impl FillingMigration + def unprocessed_data_query, do: nil + + @impl FillingMigration + def update_batch(block_numbers) do + Block + |> where([b], b.number in ^block_numbers) + |> where([b], b.consensus == true) + |> where([b], b.refetch_needed == false) + |> select([b], b.number) + |> Repo.all() + |> do_update() + end + + @impl FillingMigration + def update_cache, do: :ok + + defp do_update([]), do: :ok + + defp do_update(consensus_block_numbers) do + db_transactions_count_map = + Transaction + |> where([t], t.block_number in ^consensus_block_numbers) + |> group_by([t], t.block_number) + |> select([t], {t.block_number, count("*")}) + |> Repo.all() + |> Map.new() + + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + case EthereumJSONRPC.fetch_transactions_count(consensus_block_numbers, json_rpc_named_arguments) do + {:ok, %{transactions_count_map: node_transactions_count_map, errors: errors}} -> + if !Enum.empty?(errors) do + Logger.warning("Migration #{@migration_name} encountered errors fetching blocks: #{inspect(errors)}") + end + + consensus_block_numbers + |> Enum.filter(&Map.has_key?(node_transactions_count_map, &1)) + |> Enum.reject(fn number -> db_transactions_count_map[number] == node_transactions_count_map[number] end) + |> Block.set_refetch_needed() + + error -> + Logger.error("Migration #{@migration_name} failed: #{inspect(error)}") + {:error, error} + end + end +end diff --git a/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex b/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex index 96e18ac95f78..833f2a65821f 100644 --- a/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex +++ b/apps/explorer/lib/explorer/migrator/reindex_internal_transactions_with_incompatible_status.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do @moduledoc """ Searches for all failed transactions for which all internal transactions are successful @@ -8,7 +9,7 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do import Ecto.Query - alias Explorer.{Chain, Repo} + alias Explorer.Repo alias Explorer.Chain.{ Block, @@ -52,14 +53,19 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do it_query = from( it in InternalTransaction, - where: parent_as(:transaction).hash == it.transaction_hash and it.index > 0, + where: + parent_as(:transaction).block_number == it.block_number and + parent_as(:transaction).index == it.transaction_index and it.index > 0, select: 1 ) it_error_query = from( it in InternalTransaction, - where: parent_as(:transaction).hash == it.transaction_hash and not is_nil(it.error) and it.index > 0, + where: + parent_as(:transaction).block_number == it.block_number and + parent_as(:transaction).index == it.transaction_index and it.index > 0 and + (not is_nil(it.error) or not is_nil(it.error_id)), select: 1 ) @@ -104,7 +110,7 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do Repo.insert_all(PendingTransactionOperation, params, on_conflict: :nothing, returning: [:transaction_hash]) end - unless is_nil(Process.whereis(InternalTransactionFetcher)) do + if not is_nil(Process.whereis(InternalTransactionFetcher)) do {block_numbers, transactions} = case pending_operations_type do "blocks" -> @@ -114,7 +120,7 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatus do transactions = inserted |> Enum.map(& &1.transaction_hash) - |> Chain.get_transactions_by_hashes() + |> Transaction.get_transactions_by_hashes() {[], transactions} end diff --git a/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex b/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex index 8894e5c0f15a..c765b32ea2a4 100644 --- a/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/restore_omitted_weth_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do @moduledoc """ Inserts missed WETH token transfers @@ -118,7 +119,7 @@ defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do # fetch token balances @impl true def handle_info(:migrate, %{queue: queue, current_concurrency: current_concurrency} = state) do - if Enum.count(queue) > 0 and current_concurrency < concurrency() do + if not Enum.empty?(queue) and current_concurrency < concurrency() do to_take = batch_size() * (concurrency() - current_concurrency) {to_process, remainder} = Enum.split(queue, to_take) @@ -174,13 +175,7 @@ defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do log, [amount] <- Helper.decode_data(data, [{:uint, 256}]) do {from_address_hash, to_address_hash, balance_address_hash} = - if log.first_topic == TokenTransfer.weth_deposit_signature() do - to_address_hash = Helper.truncate_address_hash(to_string(second_topic)) - {burn_address_hash_string(), to_address_hash, to_address_hash} - else - from_address_hash = Helper.truncate_address_hash(to_string(second_topic)) - {from_address_hash, burn_address_hash_string(), from_address_hash} - end + determine_weth_address_hashes(log, second_topic) token_transfer = %{ amount: Decimal.new(amount || 0), @@ -240,6 +235,16 @@ defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do end end + defp determine_weth_address_hashes(log, second_topic) do + if log.first_topic == TokenTransfer.weth_deposit_signature() do + to_address_hash = Helper.truncate_address_hash(to_string(second_topic)) + {burn_address_hash_string(), to_address_hash, to_address_hash} + else + from_address_hash = Helper.truncate_address_hash(to_string(second_topic)) + {from_address_hash, burn_address_hash_string(), from_address_hash} + end + end + defp run_task(batch) do Task.Supervisor.async_nolink(Explorer.WETHMigratorSupervisor, fn -> migrate_batch(batch) @@ -248,7 +253,7 @@ defmodule Explorer.Migrator.RestoreOmittedWETHTransfers do defp check_token_types(token_address_hashes) do token_address_hashes - |> Chain.get_token_types() + |> Token.get_token_types() |> Enum.reduce(true, fn {token_hash, token_type}, acc -> if token_type == "ERC-20" do acc diff --git a/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex b/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex new file mode 100644 index 000000000000..ab95c0503bcf --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources.ex @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSources do + @moduledoc """ + Sanitizes the smart_contract_additional_sources table by removing duplicates. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.SmartContractAdditionalSource + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "sanitize_duplicate_smart_contract_additional_sources" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([t], t.id) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + query = + from(sc in SmartContractAdditionalSource, + select: %{ + id: sc.id, + rn: + fragment( + "ROW_NUMBER() OVER (PARTITION BY ?, ? ORDER BY ?)", + sc.address_hash, + sc.file_name, + sc.id + ) + } + ) + + from(t in subquery(query), where: t.rn > 1) + end + + @impl FillingMigration + def update_batch(ids) do + SmartContractAdditionalSource + |> where([sc], sc.id in ^ids) + |> Repo.delete_all(timeout: :infinity) + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex b/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex index 3e5c11fd7975..22db224ced2f 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_duplicated_log_index_logs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do @moduledoc """ This module is responsible for sanitizing duplicate log index entries in the database. @@ -8,7 +9,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do import Ecto.Query - alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.Cache.{BackgroundMigrations, BlockNumber} alias Explorer.Chain.{Log, TokenTransfer} alias Explorer.Chain.Token.Instance alias Explorer.Migrator.FillingMigration @@ -22,19 +23,16 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do def migration_name, do: @migration_name @impl FillingMigration + def last_unprocessed_identifiers(%{"block_number_to_process" => -1} = state), do: {[], state} + def last_unprocessed_identifiers(state) do - block_number = state[:block_number_to_process] || 0 + block_number = state["block_number_to_process"] || BlockNumber.get_max() limit = batch_size() * concurrency() - ids = - block_number - |> unprocessed_data_query(block_number + limit) - |> Repo.all(timeout: :infinity) - |> Enum.group_by(& &1.block_hash) - |> Map.to_list() + from_block_number = max(block_number - limit, 0) - {ids, Map.put(state, :block_number_to_process, block_number + limit)} + {Enum.to_list(from_block_number..block_number), Map.put(state, "block_number_to_process", from_block_number - 1)} end @doc """ @@ -46,11 +44,6 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do nil end - def unprocessed_data_query(block_number_start, block_number_end) do - Log - |> where([l], l.block_number >= ^block_number_start and l.block_number < ^block_number_end) - end - @impl FillingMigration @doc """ Updates a batch of logs grouped by block. @@ -63,7 +56,14 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do :ok """ - def update_batch(logs_by_block) do + def update_batch(block_numbers) do + logs_by_block = + Log + |> where([l], l.block_number in ^block_numbers) + |> Repo.all(timeout: :infinity) + |> Enum.group_by(& &1.block_hash) + |> Map.to_list() + logs_to_update = logs_by_block |> Enum.map(&process_block/1) @@ -72,7 +72,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do {ids, logs, ids_to_new_index} = logs_to_update - |> Enum.reduce({[], [], %{}}, fn {log, new_index}, {ids, logs, ids_to_new_index} -> + |> Enum.reduce({[], [], %{}}, fn {%Log{} = log, new_index}, {ids, logs, ids_to_new_index} -> id = {log.transaction_hash, log.block_hash, log.index} {[id | ids], @@ -107,7 +107,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do Repo.insert_all(Log, logs, timeout: :infinity) token_transfers - |> Enum.map(fn token_transfer -> + |> Enum.map(fn %TokenTransfer{} = token_transfer -> id = token_transfer_to_index(token_transfer) %TokenTransfer{token_transfer | log_index: ids_to_new_index[id]} @@ -149,7 +149,7 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogs do ^QueryHelper.tuple_in([:owner_updated_at_block, :owner_updated_at_log_index], nft_instances_params) ) |> Repo.all(timeout: :infinity) - |> Enum.map(fn nft -> + |> Enum.map(fn %Instance{} = nft -> %Instance{ nft | owner_updated_at_log_index: nft_updates_map[{nft.owner_updated_at_block, nft.owner_updated_at_log_index}] diff --git a/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex b/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex index 5e500e03794f..af3eef971206 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_empty_contract_code_addresses.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeEmptyContractCodeAddresses do @moduledoc """ Migration that sets contract code to `"0x"` for addresses where contract code diff --git a/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex b/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex new file mode 100644 index 000000000000..29a53fd12165 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/sanitize_erc_1155_token_balances_without_token_ids.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.SanitizeErc1155TokenBalancesWithoutTokenIds do + @moduledoc """ + Deletes token balances of ERC-1155 tokens with empty token_id. + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.Address.TokenBalance + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "sanitize_erc_1155_token_balances_without_token_ids" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([tb], tb.id) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + from( + tb in TokenBalance, + join: t in assoc(tb, :token), + where: tb.token_type == ^"ERC-1155" and t.type == ^"ERC-1155" and is_nil(tb.token_id) + ) + end + + @impl FillingMigration + def update_batch(token_balance_ids) do + query = from(tb in TokenBalance, where: tb.id in ^token_balance_ids) + + Repo.delete_all(query, timeout: :infinity) + end + + @impl FillingMigration + def update_cache, do: :ok +end diff --git a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex index cfe7b6c5de8a..b49f0e6cc8dd 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_nft_token_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers do @moduledoc """ Delete all token transfers of ERC-721 tokens with deposit/withdrawal signatures @@ -121,7 +122,11 @@ defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfers do defp unprocessed_identifiers("delete_erc_1155") do TokenTransfer |> select([tt], {tt.transaction_hash, tt.block_hash, tt.log_index}) - |> where([tt], tt.token_type == ^"ERC-1155" and is_nil(tt.amount) and is_nil(tt.amounts) and is_nil(tt.token_ids)) + |> where( + [tt], + tt.token_type == ^"ERC-1155" and is_nil(tt.amount) and (is_nil(tt.amounts) or tt.amounts == []) and + (is_nil(tt.token_ids) or tt.token_ids == []) + ) end defp unprocessed_identifiers("refetch") do diff --git a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex index 98991fea5ffd..79937c606c63 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_incorrect_weth_token_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers do @moduledoc """ This migrator will delete all incorrect WETH token transfers. As incorrect we consider: diff --git a/apps/explorer/lib/explorer/migrator/sanitize_missing_block_ranges.ex b/apps/explorer/lib/explorer/migrator/sanitize_missing_block_ranges.ex index 03166816f981..1361ce4a644e 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_missing_block_ranges.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_missing_block_ranges.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeMissingBlockRanges do @moduledoc """ Remove invalid missing block ranges (from_number < to_number and intersecting ones) diff --git a/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex b/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex index 5ec34fa3bfc4..53024cf3bb14 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_missing_token_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeMissingTokenBalances do @moduledoc """ Deletes empty current token balances if the related highest block_number historical token balance is filled. diff --git a/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex b/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex index 5b92f7176d3d..29b17cff8285 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_replaced_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeReplacedTransactions do @moduledoc """ Cleans the transactions that are related to non-consensus blocks. diff --git a/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex b/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex index 6dc7d5e38d8f..78636fead87d 100644 --- a/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex +++ b/apps/explorer/lib/explorer/migrator/sanitize_verified_addresses.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeVerifiedAddresses do @moduledoc """ Sets `verified` field to `true` for all addresses that have a corresponding diff --git a/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex b/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex index 29d8f6565b83..ff47dfc43c59 100644 --- a/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex +++ b/apps/explorer/lib/explorer/migrator/shrink_internal_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.ShrinkInternalTransactions do @moduledoc """ Removes the content of output field and leaves first 4 bytes signature in input field in internal transactions. @@ -8,7 +9,7 @@ defmodule Explorer.Migrator.ShrinkInternalTransactions do import Ecto.Query - alias Explorer.Chain.{Block, InternalTransaction} + alias Explorer.Chain.InternalTransaction alias Explorer.Migrator.FillingMigration alias Explorer.Repo @@ -47,11 +48,9 @@ defmodule Explorer.Migrator.ShrinkInternalTransactions do @impl FillingMigration def update_batch(block_numbers) do - block_hashes_query = from(b in Block, where: b.number in ^block_numbers, select: b.hash) - query = from(it in InternalTransaction, - where: it.block_hash in subquery(block_hashes_query), + where: it.block_number in ^block_numbers, update: [set: [input: fragment("substring(? FOR 4)", it.input), output: nil]] ) diff --git a/apps/explorer/lib/explorer/migrator/smart_contract_language.ex b/apps/explorer/lib/explorer/migrator/smart_contract_language.ex deleted file mode 100644 index c9967cc767cf..000000000000 --- a/apps/explorer/lib/explorer/migrator/smart_contract_language.ex +++ /dev/null @@ -1,73 +0,0 @@ -defmodule Explorer.Migrator.SmartContractLanguage do - @moduledoc """ - Backfills the smart contract language field for getting rid of - is_vyper_contract/is_yul bool flags - """ - - use Explorer.Migrator.FillingMigration - - import Ecto.Query - - alias Ecto.Enum - alias Explorer.Chain.Cache.BackgroundMigrations - alias Explorer.Chain.SmartContract - alias Explorer.Migrator.FillingMigration - alias Explorer.Repo - - @migration_name "smart_contract_language" - - @impl FillingMigration - def migration_name, do: @migration_name - - @impl FillingMigration - def last_unprocessed_identifiers(state) do - limit = batch_size() * concurrency() - - ids = - unprocessed_data_query() - |> select([sc], sc.address_hash) - |> limit(^limit) - |> Repo.all(timeout: :infinity) - - {ids, state} - end - - @impl FillingMigration - def unprocessed_data_query do - from(sc in SmartContract, where: is_nil(sc.language)) - end - - @impl FillingMigration - def update_batch(address_hashes) do - mappings = - SmartContract - |> Enum.mappings(:language) - |> Map.new() - - SmartContract - |> where([sc], sc.address_hash in ^address_hashes) - |> update( - set: [ - language: - fragment( - """ - CASE - WHEN is_vyper_contract THEN ?::smallint - WHEN abi IS NULL THEN ?::smallint - ELSE ?::smallint - END - """, - ^mappings.vyper, - ^mappings.yul, - ^mappings.solidity - ) - ] - ) - |> Repo.update_all([], timeout: :infinity) - end - - @impl FillingMigration - def update_cache do - BackgroundMigrations.set_smart_contract_language_finished(true) - end -end diff --git a/apps/explorer/lib/explorer/migrator/switch_pending_operations.ex b/apps/explorer/lib/explorer/migrator/switch_pending_operations.ex index 52b623d25cb7..0121dff5858e 100644 --- a/apps/explorer/lib/explorer/migrator/switch_pending_operations.ex +++ b/apps/explorer/lib/explorer/migrator/switch_pending_operations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SwitchPendingOperations do @moduledoc false diff --git a/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex b/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex index 2513c2a3716e..3ee56fa6504c 100644 --- a/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex +++ b/apps/explorer/lib/explorer/migrator/token_transfer_block_consensus.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.TokenTransferBlockConsensus do @moduledoc """ Fixes token transfers block_consensus field diff --git a/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex b/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex index af8d9f74b9fc..b154e3e7a5d0 100644 --- a/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex +++ b/apps/explorer/lib/explorer/migrator/token_transfer_token_type.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.TokenTransferTokenType do @moduledoc """ Migrates all token_transfers to have set token_type diff --git a/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex b/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex index 6024b10fba35..64e80a7d2946 100644 --- a/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex +++ b/apps/explorer/lib/explorer/migrator/transaction_block_consensus.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.TransactionBlockConsensus do @moduledoc """ Fixes transactions block_consensus field diff --git a/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex b/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex index 26c5e33804f5..3d780f171e84 100644 --- a/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex +++ b/apps/explorer/lib/explorer/migrator/transactions_denormalization.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.TransactionsDenormalization do @moduledoc """ Migrates all transactions to have set block_consensus and block_timestamp diff --git a/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex b/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex new file mode 100644 index 000000000000..7258b82e7510 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/unescape_ampersands_in_tokens.ex @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.UnescapeAmpersandsInTokens do + @moduledoc """ + Unescapes ampersands in `name` and `symbol` token fields + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.Token + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "unescape_ampersands_in_tokens" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([t], t.contract_address_hash) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + where(Token, [t], fragment("? ~ E'&' or ? ~ E'&'", t.name, t.symbol)) + end + + @impl FillingMigration + def update_batch(contract_address_hashes) do + params = + Token + |> where([t], t.contract_address_hash in ^contract_address_hashes) + |> Repo.all() + |> Enum.map(fn token -> + %{ + contract_address_hash: token.contract_address_hash, + name: do_unescape(token.name), + symbol: do_unescape(token.symbol), + type: token.type, + inserted_at: token.inserted_at, + updated_at: token.updated_at + } + end) + + Repo.insert_all(Token, params, on_conflict: {:replace, [:name, :symbol]}, conflict_target: :contract_address_hash) + end + + @impl FillingMigration + def update_cache, do: :ok + + defp do_unescape(nil), do: nil + + defp do_unescape(string) do + String.replace(string, "&", "&") + end +end diff --git a/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex b/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex new file mode 100644 index 000000000000..238de785b471 --- /dev/null +++ b/apps/explorer/lib/explorer/migrator/unescape_quotes_in_tokens.ex @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.UnescapeQuotesInTokens do + @moduledoc """ + Unescapes single and double quotes in `name` and `symbol` token fields + """ + + use Explorer.Migrator.FillingMigration + + import Ecto.Query + + alias Explorer.Chain.Token + alias Explorer.Migrator.FillingMigration + alias Explorer.Repo + + @migration_name "unescape_quotes_in_tokens" + + @impl FillingMigration + def migration_name, do: @migration_name + + @impl FillingMigration + def last_unprocessed_identifiers(state) do + limit = batch_size() * concurrency() + + ids = + unprocessed_data_query() + |> select([t], t.contract_address_hash) + |> limit(^limit) + |> Repo.all(timeout: :infinity) + + {ids, state} + end + + @impl FillingMigration + def unprocessed_data_query do + where(Token, [t], fragment("? ~ E'('|")' or ? ~ E'('|")'", t.name, t.symbol)) + end + + @impl FillingMigration + def update_batch(contract_address_hashes) do + params = + Token + |> where([t], t.contract_address_hash in ^contract_address_hashes) + |> Repo.all() + |> Enum.map(fn token -> + %{ + contract_address_hash: token.contract_address_hash, + name: do_unescape(token.name), + symbol: do_unescape(token.symbol), + type: token.type, + inserted_at: token.inserted_at, + updated_at: token.updated_at + } + end) + + Repo.insert_all(Token, params, on_conflict: {:replace, [:name, :symbol]}, conflict_target: :contract_address_hash) + end + + @impl FillingMigration + def update_cache, do: :ok + + defp do_unescape(nil), do: nil + + defp do_unescape(string) do + string + |> String.replace(""", "\"") + |> String.replace("'", "'") + end +end diff --git a/apps/explorer/lib/explorer/module_queue_registry.ex b/apps/explorer/lib/explorer/module_queue_registry.ex new file mode 100644 index 000000000000..7cae727187f8 --- /dev/null +++ b/apps/explorer/lib/explorer/module_queue_registry.ex @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ModuleQueueRegistry do + @moduledoc """ + Generic registry for module-scoped ETS-backed queues. + """ + + alias Explorer.BoundQueue + + @doc """ + Returns the ETS table name for the queue belonging to the given module. + """ + @callback table_name(module()) :: atom() + + defmacro __using__(_opts) do + quote location: :keep do + @behaviour Explorer.ModuleQueueRegistry + + alias Explorer.ModuleQueueRegistry + + @spec pop(module()) :: term() | nil + def pop(module), do: ModuleQueueRegistry.pop(__MODULE__, module) + + @spec push(term(), module()) :: true | {:error, :maximum_size} + def push(value, module), do: ModuleQueueRegistry.push(__MODULE__, value, module) + end + end + + @doc """ + Pops a value from the front of the queue for the specified module. + """ + @spec pop(module(), module()) :: term() | nil + def pop(registry_module, module) do + table_name = registry_module.table_name(module) + + :global.trans({__MODULE__, table_name}, fn -> + case BoundQueue.pop_front(queue_get(table_name)) do + {:ok, {value, updated_queue}} -> + :ets.insert(table_name, {:queue, updated_queue}) + value + + {:error, :empty} -> + nil + end + end) + end + + @doc """ + Pushes a value to the back of the queue for the specified module. + """ + @spec push(module(), term(), module()) :: true | {:error, :maximum_size} + def push(registry_module, value, module) do + table_name = registry_module.table_name(module) + + :global.trans({__MODULE__, table_name}, fn -> + case BoundQueue.push_back(queue_get(table_name), value) do + {:ok, updated_queue} -> + :ets.insert(table_name, {:queue, updated_queue}) + + {:error, :maximum_size} = error -> + error + end + end) + end + + @spec queue_get(atom()) :: BoundQueue.t(term()) + defp queue_get(table_name) do + ensure_table(table_name) + + case :ets.lookup(table_name, :queue) do + [{:queue, value}] -> value + [] -> %BoundQueue{} + end + end + + defp ensure_table(table_name) do + if :ets.whereis(table_name) == :undefined do + try do + :ets.new(table_name, [ + :set, + :named_table, + :public, + read_concurrency: true, + write_concurrency: true + ]) + rescue + ArgumentError -> :ok + end + end + end +end diff --git a/apps/explorer/lib/explorer/paging_options.ex b/apps/explorer/lib/explorer/paging_options.ex index 08239758abc9..ea52356235ad 100644 --- a/apps/explorer/lib/explorer/paging_options.ex +++ b/apps/explorer/lib/explorer/paging_options.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.PagingOptions do @moduledoc """ Defines paging options for paging by a stable key such as a timestamp or block diff --git a/apps/explorer/lib/explorer/prometheus/collector/active_db_connections.ex b/apps/explorer/lib/explorer/prometheus/collector/active_db_connections.ex index 24ea428651f0..78f1b086d780 100644 --- a/apps/explorer/lib/explorer/prometheus/collector/active_db_connections.ex +++ b/apps/explorer/lib/explorer/prometheus/collector/active_db_connections.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Prometheus.Collector.ActiveDbConnections do @moduledoc """ Custom collector to count number of currently active DB connections. diff --git a/apps/explorer/lib/explorer/prometheus/instrumenter.ex b/apps/explorer/lib/explorer/prometheus/instrumenter.ex index 397e5de88fca..c11edbd41fa2 100644 --- a/apps/explorer/lib/explorer/prometheus/instrumenter.ex +++ b/apps/explorer/lib/explorer/prometheus/instrumenter.ex @@ -1,6 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Prometheus.Instrumenter do @moduledoc """ - Blocks fetch and import metrics for `Prometheus`. + Explorer metrics for `Prometheus`. """ use Prometheus.Metric @@ -14,12 +15,15 @@ defmodule Explorer.Prometheus.Instrumenter do ] @histogram [ - name: :media_processing_time, - buckets: :default, - duration_unit: :seconds, - help: "Time in seconds taken for media resizing and uploading" + name: :stats_import_stage_runner_duration_microseconds, + labels: [:stats_type], + buckets: [1000, 5000, 10000, 100_000], + duration_unit: :microseconds, + help: "Stats import stage runner duration microseconds" ] + # Public chain metrics exposed at /public_metrics endpoint + @gauge [ name: :success_transactions_number, help: "Number of successful transactions in the period (default is 1 day)", @@ -63,6 +67,15 @@ defmodule Explorer.Prometheus.Instrumenter do registry: :public ] + # metrics of NFT media handler + + @histogram [ + name: :media_processing_time, + buckets: :default, + duration_unit: :seconds, + help: "Time in seconds taken for media resizing and uploading" + ] + @counter [ name: :successfully_uploaded_media_number, help: "Number of successfully uploaded media to CDN", @@ -77,6 +90,15 @@ defmodule Explorer.Prometheus.Instrumenter do @gauge [name: :batch_average_time, help: "L2 average batch time"] + def setup do + prepare_batch_metric([]) + end + + @doc """ + Defines the metric for the full processing time of a block (in microseconds). + """ + @spec block_import_stage_runner(function :: (-> any()), stage :: atom(), runner :: atom(), step :: atom()) :: + any() def block_import_stage_runner(function, stage, runner, step) do {time, result} = :timer.tc(function) @@ -85,50 +107,108 @@ defmodule Explorer.Prometheus.Instrumenter do result end - def success_transactions_number(number) do - Gauge.set([name: :success_transactions_number, registry: :public], number) + @doc """ + Defines the metric for the full processing time of a stats import stage runner + (in microseconds). + """ + @spec stats_import_stage_runner(function :: (-> any()), stats_type :: atom()) :: any() + def stats_import_stage_runner(function, stats_type) do + {time, result} = :timer.tc(function) + + Histogram.observe([name: :stats_import_stage_runner_duration_microseconds, labels: [stats_type]], time) + + result end - def media_processing_time(seconds) do - Histogram.observe([name: :media_processing_time], seconds) + @doc """ + Defines the metric for the number of successful transactions in the period (default is 1 day). + """ + @spec success_transactions_number(number :: integer()) :: :ok + def success_transactions_number(number) do + Gauge.set([name: :success_transactions_number, registry: :public], number) end + @doc """ + Defines the metric for the number of weekly successful transactions. + """ + @spec weekly_success_transactions_number(number :: integer()) :: :ok def weekly_success_transactions_number(number) do Gauge.set([name: :weekly_success_transactions_number, registry: :public], number) end + @doc """ + Defines the metric for the number of deployed smart-contracts in the period (default is 1 day). + """ + @spec deployed_smart_contracts_number(number :: integer()) :: :ok def deployed_smart_contracts_number(number) do Gauge.set([name: :deployed_smart_contracts_number, registry: :public], number) end + @doc """ + Defines the metric for the number of verified smart-contracts in the period (default is 1 day). + """ + @spec verified_smart_contracts_number(number :: integer()) :: :ok def verified_smart_contracts_number(number) do Gauge.set([name: :verified_smart_contracts_number, registry: :public], number) end + @doc """ + Defines the metric for the number of new addresses in the period (default is 1 day). + """ + @spec new_addresses_number(number :: integer()) :: :ok def new_addresses_number(number) do Gauge.set([name: :new_addresses_number, registry: :public], number) end + @doc """ + Defines the metric for the number of new tokens in the period (default is 1 day). + """ + @spec new_tokens_number(number :: integer()) :: :ok def new_tokens_number(number) do Gauge.set([name: :new_tokens_number, registry: :public], number) end + @doc """ + Defines the metric for the number of new token transfers in the period (default is 1 day). + """ + @spec new_token_transfers_number(number :: integer()) :: :ok def new_token_transfers_number(number) do Gauge.set([name: :new_token_transfers_number, registry: :public], number) end + @doc """ + Defines the metric for the number of active EOA addresses in the period (default is 1 day). + """ + @spec simplified_active_addresses_number(number :: integer()) :: :ok def simplified_active_addresses_number(number) do Gauge.set([name: :active_addresses_number, registry: :public], number) end + @doc """ + Defines the metric for time taken for media resizing and uploading (in seconds). + """ + @spec media_processing_time(number()) :: :ok + def media_processing_time(seconds) do + Histogram.observe([name: :media_processing_time], seconds) + end + + @doc """ + Increments the counter for successfully uploaded media to the CDN. + """ + @spec increment_successfully_uploaded_media_number() :: :ok def increment_successfully_uploaded_media_number do Counter.inc(name: :successfully_uploaded_media_number, registry: :public) end + @doc """ + Increments the counter for failed media uploads to the CDN. + """ + @spec increment_failed_uploading_media_number() :: :ok def increment_failed_uploading_media_number do Counter.inc(name: :failed_uploading_media_number, registry: :public) end + @spec batch_average_time(integer()) :: :ok defp batch_average_time(average_time) do Gauge.set([name: :batch_average_time], average_time) end @@ -185,6 +265,8 @@ defmodule Explorer.Prometheus.Instrumenter do def prepare_batch_metric(batches) do case batches do [] -> + batch_average_time(0) + {:error, :not_found} [batch] -> diff --git a/apps/explorer/lib/explorer/promo/autoscout.ex b/apps/explorer/lib/explorer/promo/autoscout.ex new file mode 100644 index 000000000000..e4b446eaa404 --- /dev/null +++ b/apps/explorer/lib/explorer/promo/autoscout.ex @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Promo.Autoscout do + @moduledoc """ + Module responsible for logging the Autoscout promo message on application startup + and once again 10 seconds after initialization. + """ + + require Logger + + use GenServer + + @autoscout_promo """ + + + █ █ ███ █ █ █████ ███ ████ ████ ███ █ █ █████ + █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ + █ █ █ █████ █ █ █ █ █ ███ █ █ █ █ █ █ + █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ + █ █ █ █ ███ █ ███ ████ ████ ███ ███ █ + + Deploy Blockscout explorer in 5 minutes at deploy.blockscout.com + """ + + def start_link(_) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl GenServer + def init(_) do + Process.send_after(self(), :promo, :timer.seconds(10)) + + {:ok, %{}} + end + + @impl GenServer + def handle_info(:promo, state) do + promo() + {:noreply, state} + end + + defp promo do + Logger.info(@autoscout_promo) + end +end diff --git a/apps/explorer/lib/explorer/query_helper.ex b/apps/explorer/lib/explorer/query_helper.ex index d4c5e96fe8f6..8a687e05cf82 100644 --- a/apps/explorer/lib/explorer/query_helper.ex +++ b/apps/explorer/lib/explorer/query_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.QueryHelper do @moduledoc """ Helping functions for `Ecto.Query` building. @@ -38,4 +39,63 @@ defmodule Explorer.QueryHelper do |> Code.eval_quoted() |> elem(0) end + + @doc """ + A macro generating a fragment that selects CTID column. + + CTID - is a system column representing the physical location + of the row version within its table. + + The macro is supposed to be used in `SELECT FOR UPDATE` part of update and delete statements + where corresponding rows are locked before modification in order to prevent deadlocks + (see docs: sharelock.md). Should be used along with `join_on_ctid/2`. + + ## Example + + ``` + ordered_query = + from(table in Table, + select: select_ctid(table), + # Enforce Table ShareLocks order + order_by: [ + table.column_1, + table.column_2, + ], + lock: "FOR UPDATE" + ) + + query = + from(table in Table, + inner_join: ordered_table in subquery(ordered_query), + on: join_on_ctid(table, ordered_table) + ) + Repo.delete_all(query) + ``` + + Will be transformed to such SQL: + ``` + DELETE + FROM "table" AS t0 USING (SELECT st0."ctid" AS "ctid" + FROM "table" AS st0 + ORDER BY st0."column_1", st0."column_2" FOR UPDATE) AS s1 + WHERE (t0."ctid" = s1."ctid"); + ``` + """ + defmacro select_ctid(table_binding) do + quote do + %{ctid: fragment(~s(?."ctid"), unquote(table_binding))} + end + end + + @doc """ + A macro generating a fragment that joins 2 tables on CTID column. + + It is supposed to be used as an `:on` option for joins. + See `select_ctid/1` for more details on usage. + """ + defmacro join_on_ctid(first_table_binding, second_table_binding) do + quote do + fragment(~s(?."ctid" = ?."ctid"), unquote(first_table_binding), unquote(second_table_binding)) + end + end end diff --git a/apps/explorer/lib/explorer/repo.ex b/apps/explorer/lib/explorer/repo.ex index e90ac8c601dc..6aca8ae8bfce 100644 --- a/apps/explorer/lib/explorer/repo.ex +++ b/apps/explorer/lib/explorer/repo.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo do use Ecto.Repo, otp_app: :explorer, @@ -134,6 +135,7 @@ defmodule Explorer.Repo do Explorer.Repo.Account, Explorer.Repo.BridgedTokens, Explorer.Repo.ShrunkInternalTransactions, + Explorer.Repo.EventNotifications, # Chain-type dependent repos Explorer.Repo.Arbitrum, @@ -144,7 +146,6 @@ defmodule Explorer.Repo do Explorer.Repo.Mud, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, diff --git a/apps/explorer/lib/explorer/repo/config_helper.ex b/apps/explorer/lib/explorer/repo/config_helper.ex index bfed70d369c7..624f06b47f03 100644 --- a/apps/explorer/lib/explorer/repo/config_helper.ex +++ b/apps/explorer/lib/explorer/repo/config_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ConfigHelper do @moduledoc """ Extracts values from environment and adds them to application config. @@ -7,6 +8,8 @@ defmodule Explorer.Repo.ConfigHelper do The priority of vars is postgrex environment vars < DATABASE_URL components, with values being overwritten by higher priority. """ + alias Utils.ConfigHelper, as: UtilsConfigHelper + # https://hexdocs.pm/postgrex/Postgrex.html#start_link/1-options @postgrex_env_vars [ username: "PGUSER", @@ -16,6 +19,8 @@ defmodule Explorer.Repo.ConfigHelper do database: "PGDATABASE" ] + @ecto_ssl_modes ~w(disable allow prefer require verify-ca verify-full) + def get_db_config(opts) do url_encoded = opts[:url] url = url_encoded && URI.decode(url_encoded) @@ -26,13 +31,23 @@ defmodule Explorer.Repo.ConfigHelper do |> Keyword.merge(extract_parameters(url)) end - def get_account_db_url, do: System.get_env("ACCOUNT_DATABASE_URL") || System.get_env("DATABASE_URL") + def get_account_db_url, + do: + UtilsConfigHelper.parse_url_env_var("ACCOUNT_DATABASE_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") - def get_suave_db_url, do: System.get_env("SUAVE_DATABASE_URL") || System.get_env("DATABASE_URL") + def get_suave_db_url, + do: UtilsConfigHelper.parse_url_env_var("SUAVE_DATABASE_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") - def get_api_db_url, do: System.get_env("DATABASE_READ_ONLY_API_URL") || System.get_env("DATABASE_URL") + def get_api_db_url, + do: + UtilsConfigHelper.parse_url_env_var("DATABASE_READ_ONLY_API_URL") || + UtilsConfigHelper.parse_url_env_var("DATABASE_URL") - def get_mud_db_url, do: System.get_env("MUD_DATABASE_URL") || System.get_env("DATABASE_URL") + def get_mud_db_url, + do: UtilsConfigHelper.parse_url_env_var("MUD_DATABASE_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") + + def get_event_notification_db_url, + do: UtilsConfigHelper.parse_url_env_var("DATABASE_EVENT_URL") || UtilsConfigHelper.parse_url_env_var("DATABASE_URL") def init_repo_module(module, opts) do db_url = Application.get_env(:explorer, module)[:url] @@ -52,7 +67,56 @@ defmodule Explorer.Repo.ConfigHelper do {:ok, opts |> Keyword.put(:url, remove_search_path(db_url)) |> Keyword.merge(Keyword.take(merged, [:search_path]))} end - def ssl_enabled?, do: String.equivalent?(System.get_env("ECTO_USE_SSL") || "true", "true") + @doc """ + Determines the Ecto SSL mode based on the ECTO_SSL_MODE environment variable, the + sslmode parameter in the database URL, or defaults to "require" if neither is set. + """ + @spec ecto_ssl_mode(database_url :: String.t() | nil) :: String.t() + def ecto_ssl_mode(database_url \\ nil), do: ecto_ssl_mode(database_url, &System.get_env/1) + + @spec ecto_ssl_mode(database_url :: String.t() | nil, env_function :: (String.t() -> String.t() | nil)) :: String.t() + def ecto_ssl_mode(database_url, env_function) do + mode = + blank_to_nil(env_function.("ECTO_SSL_MODE")) || + ssl_mode_from_database_url(database_url) || + "require" + + normalize_ssl_mode!(mode) + end + + defp blank_to_nil(value) when value in [nil, ""], do: nil + defp blank_to_nil(value), do: value + + @doc """ + Returns SSL options for Postgrex based on the ECTO_SSL_MODE environment variable or ssl mode parameter in the database URL. + """ + @spec ssl_options(database_url :: String.t() | nil) :: Keyword.t() + def ssl_options(database_url \\ nil), do: ssl_options(database_url, &System.get_env/1) + + @spec ssl_options(database_url :: String.t() | nil, env_function :: (String.t() -> String.t() | nil)) :: Keyword.t() + def ssl_options(database_url, env_function) do + case ecto_ssl_mode(database_url, env_function) do + "disable" -> + [ssl: false] + + # Postgrex cannot emulate allow/prefer fallback semantics exactly, + # so both modes are mapped to encrypted, non-verified transport. + mode when mode in ["allow", "prefer", "require"] -> + [ssl: [verify: :verify_none]] + + "verify-ca" -> + [ + ssl: [ + cacerts: :public_key.cacerts_get(), + verify: :verify_peer, + server_name_indication: :disable + ] + ] + + "verify-full" -> + [ssl: true] + end + end def extract_parameters(empty) when empty == nil or empty == "", do: [] @@ -108,6 +172,33 @@ defmodule Explorer.Repo.ConfigHelper do end) end + defp ssl_mode_from_database_url(nil), do: nil + defp ssl_mode_from_database_url(""), do: nil + + defp ssl_mode_from_database_url(database_url) do + case URI.parse(database_url) do + %{query: nil} -> + nil + + %{query: query} -> + query + |> URI.decode_query() + |> Map.get("sslmode") + end + end + + defp normalize_ssl_mode!(mode) when is_binary(mode) do + normalized_mode = mode |> String.trim() |> String.downcase() + + if normalized_mode in @ecto_ssl_modes do + normalized_mode + else + raise ArgumentError, + "Unsupported ECTO_SSL_MODE value: #{inspect(mode)}. " <> + "Supported values: #{Enum.join(@ecto_ssl_modes, ", ")}." + end + end + def network_path do path = System.get_env("NETWORK_PATH", "/") diff --git a/apps/explorer/lib/explorer/repo/prometheus_logger.ex b/apps/explorer/lib/explorer/repo/prometheus_logger.ex index 93f377809179..6a3b057ce25d 100644 --- a/apps/explorer/lib/explorer/repo/prometheus_logger.ex +++ b/apps/explorer/lib/explorer/repo/prometheus_logger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.PrometheusLogger do @moduledoc """ Log `Ecto` query durations as `Prometheus` metrics. diff --git a/apps/explorer/lib/explorer/schema.ex b/apps/explorer/lib/explorer/schema.ex index ef88e49fa60d..72c0f3f78863 100644 --- a/apps/explorer/lib/explorer/schema.ex +++ b/apps/explorer/lib/explorer/schema.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Schema do @moduledoc "Common configuration for Explorer schemas." diff --git a/apps/explorer/lib/explorer/smart_contract/certified_smart_contract_cataloger.ex b/apps/explorer/lib/explorer/smart_contract/certified_smart_contract_cataloger.ex index 0315dcf8eedf..41e9f966ff31 100644 --- a/apps/explorer/lib/explorer/smart_contract/certified_smart_contract_cataloger.ex +++ b/apps/explorer/lib/explorer/smart_contract/certified_smart_contract_cataloger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.CertifiedSmartContractCataloger do @moduledoc """ Actualizes certified smart-contracts. @@ -24,6 +25,6 @@ defmodule Explorer.SmartContract.CertifiedSmartContractCataloger do SmartContract.set_smart_contracts_certified_flag(certified_contracts_list) - {:noreply, state} + {:stop, :normal, state} end end diff --git a/apps/explorer/lib/explorer/smart_contract/compiler_version.ex b/apps/explorer/lib/explorer/smart_contract/compiler_version.ex index 99f164c43e40..52eecf275fd7 100644 --- a/apps/explorer/lib/explorer/smart_contract/compiler_version.ex +++ b/apps/explorer/lib/explorer/smart_contract/compiler_version.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.CompilerVersion do @moduledoc """ - Adapter for fetching compiler versions from https://solc-bin.ethereum.org/bin/list.json. + Adapter for fetching compiler versions from https://binaries.soliditylang.org/bin/list.json. """ - alias Explorer.Helper + alias Explorer.{Helper, HttpClient} alias Explorer.SmartContract.{RustVerifierInterface, StylusVerifierInterface} @unsupported_solc_versions ~w(0.1.1 0.1.2) @@ -76,15 +77,14 @@ defmodule Explorer.SmartContract.CompilerVersion do else headers = [{"Content-Type", "application/json"}] - # credo:disable-for-next-line - case HTTPoison.get(source_url(compiler_type), headers) do + case HttpClient.get(source_url(compiler_type), headers) do {:ok, %{status_code: 200, body: body}} -> {:ok, format_data(body, compiler_type)} {:ok, %{status_code: _status_code, body: body}} -> {:error, Helper.decode_json(body)["error"]} - {:error, %{reason: reason}} -> + {:error, reason} -> {:error, reason} end end diff --git a/apps/explorer/lib/explorer/smart_contract/eth_bytecode_db_interface.ex b/apps/explorer/lib/explorer/smart_contract/eth_bytecode_db_interface.ex index ace02a4d746e..368145ae864c 100644 --- a/apps/explorer/lib/explorer/smart_contract/eth_bytecode_db_interface.ex +++ b/apps/explorer/lib/explorer/smart_contract/eth_bytecode_db_interface.ex @@ -1,20 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.EthBytecodeDBInterface do @moduledoc """ Adapter for interaction with https://github.com/blockscout/blockscout-rs/tree/main/eth-bytecode-db """ - def search_contract(%{"bytecode" => _, "bytecodeType" => _} = body, address_hash) do + def search_contract(%{"bytecode" => _, "bytecodeType" => _} = body, address_hash, metadata) do if chain_id = Application.get_env(:block_scout_web, :chain_id) do http_post_request( bytecode_search_all_sources_url(), - Map.merge(body, %{ + body + |> Map.merge(%{ "chain" => to_string(chain_id), "address" => to_string(address_hash) - }), - false + }) + |> append_metadata(metadata) ) else - http_post_request(bytecode_search_sources_url(), body, false) + http_post_request(bytecode_search_sources_url(), append_metadata(body, metadata)) end end @@ -22,11 +24,12 @@ defmodule Explorer.SmartContract.EthBytecodeDBInterface do Function to search smart contracts in eth-bytecode-db, similar to `search_contract/2` but this function uses only `/api/v2/bytecodes/sources:search` method """ - @spec search_contract_in_eth_bytecode_internal_db(map(), binary(), keyword()) :: {:error, any} | {:ok, any} + @spec search_contract_in_eth_bytecode_internal_db(map(), binary(), keyword(), map()) :: {:error, any} | {:ok, any} def search_contract_in_eth_bytecode_internal_db( %{"bytecode" => _, "bytecodeType" => _} = body, address_hash_string, - options + options, + metadata ) do chain_id = Application.get_env(:block_scout_web, :chain_id) @@ -51,7 +54,7 @@ defmodule Explorer.SmartContract.EthBytecodeDBInterface do })} end - http_post_request(url, body, false, options) + http_post_request(url, body |> append_metadata(metadata), options) end def process_verifier_response( diff --git a/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex b/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex new file mode 100644 index 000000000000..d2508ef89eec --- /dev/null +++ b/apps/explorer/lib/explorer/smart_contract/geas/publisher.ex @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.SmartContract.Geas.Publisher do + @moduledoc """ + Module responsible for verifying and publishing GEAS smart contracts. + + The verification process includes: + 1. Processing verification response from the Ethereum Bytecode DB + 2. Extracting contract source files and ABI + 3. Creating or updating the smart contract record in the database + 4. Handling verification failures by creating invalid changesets with error messages + """ + + require Logger + + alias Explorer.Chain.SmartContract + alias Explorer.SmartContract.Helper + alias Explorer.SmartContract.Solidity.Publisher, as: SolidityPublisher + + @doc """ + Processes the verification response from the Ethereum Bytecode DB for GEAS contracts. + + ## Parameters + - `source`: Map containing verification response with GEAS-specific fields: + - `"abi"`: Contract ABI as JSON string + - `"compilerVersion"`: Version of the GEAS compiler used + - `"constructorArguments"`: Constructor arguments (can be null) + - `"contractName"`: Name of the contract + - `"fileName"`: Main source file name (typically .eas extension) + - `"sourceFiles"`: Map of file paths to source code contents + - `"compilerSettings"`: Compiler settings as JSON string + - `"matchType"`: Type of bytecode match ("FULL" or "PARTIAL") + - `address_hash`: The contract's address hash as binary or `t:Explorer.Chain.Hash.t/0` + - `initial_params`: Initial parameters from the verification request + - `save_file_path?`: Boolean indicating whether to save the file path + - `is_standard_json?`: Boolean indicating if this was a standard JSON verification + - `automatically_verified?`: Boolean indicating if this was automatically verified + + ## Returns + - `{:ok, smart_contract}` if verification and database storage succeed + - `{:error, changeset}` if verification fails or there are validation errors + """ + @spec process_rust_verifier_response( + map(), + binary() | Explorer.Chain.Hash.t(), + map(), + boolean(), + boolean(), + boolean() + ) :: + {:ok, Explorer.Chain.SmartContract.t()} | {:error, Ecto.Changeset.t()} + def process_rust_verifier_response( + %{ + "abi" => abi_string, + "compilerVersion" => compiler_version, + "constructorArguments" => constructor_arguments, + "contractName" => contract_name, + "fileName" => file_name, + "sourceFiles" => sources, + "compilerSettings" => compiler_settings_string, + "matchType" => match_type + } = source, + address_hash, + initial_params, + save_file_path?, + _is_standard_json?, + automatically_verified? \\ false + ) do + secondary_sources = + for {file, source_code} <- sources, + file != file_name, + do: %{ + "file_name" => file, + "contract_source_code" => source_code, + "address_hash" => address_hash + } + + %{^file_name => contract_source_code} = sources + + compiler_settings = Jason.decode!(compiler_settings_string) + + prepared_params = + %{} + |> Map.put("compiler_version", compiler_version) + |> Map.put("constructor_arguments", constructor_arguments) + |> Map.put("contract_source_code", contract_source_code) + |> Map.put("name", contract_name) + |> Map.put("file_path", if(save_file_path?, do: file_name)) + |> Map.put("secondary_sources", secondary_sources) + |> Map.put("partially_verified", match_type == "PARTIAL") + |> Map.put("verified_via_eth_bytecode_db", automatically_verified?) + |> Map.put("verified_via_verifier_alliance", source["verifier_alliance?"] || false) + |> Map.put("compiler_settings", compiler_settings) + |> Map.put("license_type", initial_params["license_type"]) + |> Map.put("is_blueprint", source["isBlueprint"] || false) + + publish_smart_contract(address_hash, prepared_params, Jason.decode!(abi_string || "null"), save_file_path?) + end + + @doc """ + Stores information about a verified GEAS smart contract in the database. + + ## Parameters + - `address_hash`: The contract's address hash as binary or `t:Explorer.Chain.Hash.t/0` + - `params`: Map containing contract details + - `abi`: Contract's ABI (Application Binary Interface) + - `verification_with_files?`: Boolean indicating if verification used source files + + ## Returns + - `{:ok, smart_contract}` if publishing succeeds + - `{:error, changeset}` if there are validation errors + - `{:error, message}` if the database operation fails + """ + @spec publish_smart_contract(binary() | Explorer.Chain.Hash.t(), map(), map(), boolean()) :: + {:ok, Explorer.Chain.SmartContract.t()} | {:error, Ecto.Changeset.t() | String.t()} + def publish_smart_contract(address_hash, params, abi, verification_with_files?) do + attrs = address_hash |> attributes(params, abi) + + ok_or_error = + SmartContract.create_or_update_smart_contract( + address_hash, + attrs, + verification_with_files? + ) + + case ok_or_error do + {:ok, _smart_contract} -> + Logger.info("GEAS smart-contract #{address_hash} successfully published") + + {:error, error} -> + Logger.error("GEAS smart-contract #{address_hash} failed to publish: #{inspect(error)}") + end + + ok_or_error + end + + # Private function to build contract attributes for database storage + defp attributes(address_hash, params, abi) do + constructor_arguments = params["constructor_arguments"] + compiler_settings = params["compiler_settings"] + + clean_constructor_arguments = SolidityPublisher.clear_constructor_arguments(constructor_arguments) + + clean_compiler_settings = + if compiler_settings in ["", nil, %{}] do + nil + else + compiler_settings + end + + %{ + address_hash: address_hash, + name: params["name"], + compiler_version: params["compiler_version"], + evm_version: nil, + optimization_runs: nil, + optimization: false, + contract_source_code: params["contract_source_code"], + constructor_arguments: clean_constructor_arguments, + external_libraries: [], + secondary_sources: params["secondary_sources"], + abi: abi, + verified_via_sourcify: false, + verified_via_eth_bytecode_db: params["verified_via_eth_bytecode_db"] || false, + verified_via_verifier_alliance: params["verified_via_verifier_alliance"] || false, + partially_verified: params["partially_verified"] || false, + file_path: params["file_path"], + compiler_settings: clean_compiler_settings, + license_type: Helper.prepare_license_type(params["license_type"]) || :none, + is_blueprint: params["is_blueprint"] || false, + language: :geas + } + end +end diff --git a/apps/explorer/lib/explorer/smart_contract/helper.ex b/apps/explorer/lib/explorer/smart_contract/helper.ex index 1f972a99209f..683361f246ea 100644 --- a/apps/explorer/lib/explorer/smart_contract/helper.ex +++ b/apps/explorer/lib/explorer/smart_contract/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Helper do @moduledoc """ SmartContract helper functions @@ -9,7 +10,6 @@ defmodule Explorer.SmartContract.Helper do alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Helper, as: ExplorerHelper alias Explorer.SmartContract.{Reader, Writer} - alias Phoenix.HTML @api_true [api?: true] @@ -67,7 +67,11 @@ defmodule Explorer.SmartContract.Helper do def add_contract_code_md5(attrs), do: attrs - def contract_code_md5(bytes) do + @doc """ + Calculates MD5 hash of given binary. + """ + @spec md5(binary()) :: String.t() + def md5(bytes) do :md5 |> :crypto.hash(bytes) |> Base.encode16(case: :lower) @@ -75,7 +79,7 @@ defmodule Explorer.SmartContract.Helper do defp attrs_extend_with_contract_code_md5(attrs, address) do if address.contract_code do - contract_code_md5 = contract_code_md5(address.contract_code.bytes) + contract_code_md5 = md5(address.contract_code.bytes) attrs |> Map.put_new(:contract_code_md5, contract_code_md5) @@ -84,15 +88,18 @@ defmodule Explorer.SmartContract.Helper do end end - def sanitize_input(nil), do: nil - - def sanitize_input(input) do + @doc """ + Escapes only < and > symbols + """ + @spec escape_minimal(any()) :: any() + def escape_minimal(input) when is_binary(input) do input - |> HTML.html_escape() - |> HTML.safe_to_string() - |> String.trim() + |> String.replace("<", "<") + |> String.replace(">", ">") end + def escape_minimal(other), do: other + def sol_file?(filename) do case List.last(String.split(String.downcase(filename), ".")) do "sol" -> @@ -184,8 +191,8 @@ defmodule Explorer.SmartContract.Helper do Metadata will be sent to a verifier microservice """ @spec fetch_data_for_verification(binary() | Hash.t()) :: {binary() | nil, binary(), map()} - def fetch_data_for_verification(address_hash) do - deployed_bytecode = Chain.smart_contract_bytecode(address_hash) + def fetch_data_for_verification(address_hash, deployed_bytecode \\ nil) do + deployed_bytecode = deployed_bytecode || Chain.smart_contract_bytecode(address_hash) metadata = %{ "contractAddress" => to_string(address_hash), @@ -223,7 +230,7 @@ defmodule Explorer.SmartContract.Helper do defp internal_transaction_to_metadata(internal_transaction, init) do %{ "blockNumber" => to_string(internal_transaction.block_number), - "transactionHash" => to_string(internal_transaction.transaction_hash), + "transactionHash" => to_string(internal_transaction.transaction.hash), "transactionIndex" => to_string(internal_transaction.transaction_index), "deployer" => to_string(internal_transaction.from_address_hash), "creationCode" => to_string(init) @@ -240,6 +247,23 @@ defmodule Explorer.SmartContract.Helper do def prepare_license_type(binary) when is_binary(binary), do: Helper.parse_integer(binary) || binary def prepare_license_type(_), do: nil + @doc """ + Parses contract language for the Solidity verification flow. + + Accepts `"language"` or `:language` set to `"yul"` or `:yul` and returns + `:yul`. For all other values, defaults to `:solidity`. + """ + @spec parse_solidity_verification_language(map()) :: :solidity | :yul + def parse_solidity_verification_language(params) do + language = params["language"] || params[:language] + + if language in ["yul", :yul] do + :yul + else + :solidity + end + end + @doc """ Pre-fetches implementation for unverified smart-contract or verified proxy smart-contract """ @@ -309,6 +333,7 @@ defmodule Explorer.SmartContract.Helper do nil end + # todo: Dangerous, fix with https://github.com/blockscout/blockscout/issues/12544 ExplorerHelper.add_0x_prefix(binary_hash) end end diff --git a/apps/explorer/lib/explorer/smart_contract/reader.ex b/apps/explorer/lib/explorer/smart_contract/reader.ex index 0b333454d356..728353cc2037 100644 --- a/apps/explorer/lib/explorer/smart_contract/reader.ex +++ b/apps/explorer/lib/explorer/smart_contract/reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Reader do @moduledoc """ Reads Smart Contract functions from the blockchain. diff --git a/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface.ex b/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface.ex index 43c6f4befd28..42453df16584 100644 --- a/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface.ex +++ b/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.RustVerifierInterface do @moduledoc """ Adapter for contracts verification with https://github.com/blockscout/blockscout-rs/blob/main/smart-contract-verifier diff --git a/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex b/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex index 0de3d6639fd7..42539a2b6767 100644 --- a/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex +++ b/apps/explorer/lib/explorer/smart_contract/rust_verifier_interface_behaviour.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do @moduledoc """ This behaviour module was created in order to add possibility to extend the functionality of RustVerifierInterface @@ -5,8 +6,8 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do defmacro __using__(_) do # credo:disable-for-next-line quote([]) do + alias Explorer.HttpClient alias Explorer.Utility.Microservice - alias HTTPoison.Response require Logger @post_timeout :timer.minutes(5) @@ -24,7 +25,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do } = body, metadata ) do - http_post_request(solidity_multiple_files_verification_url(), append_metadata(body, metadata), true) + http_post_request(solidity_multiple_files_verification_url(), append_metadata(body, metadata)) end def verify_standard_json_input( @@ -36,7 +37,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do } = body, metadata ) do - http_post_request(solidity_standard_json_verification_url(), append_metadata(body, metadata), true) + http_post_request(solidity_standard_json_verification_url(), append_metadata(body, metadata)) end def zksync_verify_standard_json_input( @@ -48,7 +49,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do } = body, metadata ) do - http_post_request(solidity_standard_json_verification_url(), append_metadata(body, metadata), true) + http_post_request(solidity_standard_json_verification_url(), append_metadata(body, metadata)) end def vyper_verify_multipart( @@ -60,7 +61,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do } = body, metadata ) do - http_post_request(vyper_multiple_files_verification_url(), append_metadata(body, metadata), true) + http_post_request(vyper_multiple_files_verification_url(), append_metadata(body, metadata)) end def vyper_verify_standard_json( @@ -72,16 +73,14 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do } = body, metadata ) do - http_post_request(vyper_standard_json_verification_url(), append_metadata(body, metadata), true) + http_post_request(vyper_standard_json_verification_url(), append_metadata(body, metadata)) end - def http_post_request(url, body, is_verification_request?, options \\ []) do + def http_post_request(url, body, options \\ []) do headers = [{"Content-Type", "application/json"}] - case HTTPoison.post(url, Jason.encode!(body), maybe_put_api_key_header(headers, is_verification_request?), - recv_timeout: @post_timeout - ) do - {:ok, %Response{body: body, status_code: _}} -> + case HttpClient.post(url, Jason.encode!(body), put_api_key_header(headers), recv_timeout: @post_timeout) do + {:ok, %{body: body, status_code: _}} -> process_verifier_response(body, options) {:error, error} -> @@ -100,9 +99,7 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do end end - defp maybe_put_api_key_header(headers, false), do: headers - - defp maybe_put_api_key_header(headers, true) do + defp put_api_key_header(headers) do api_key = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour)[:api_key] if api_key do @@ -113,11 +110,11 @@ defmodule Explorer.SmartContract.RustVerifierInterfaceBehaviour do end def http_get_request(url) do - case HTTPoison.get(url) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url) do + {:ok, %{body: body, status_code: 200}} -> process_verifier_response(body, []) - {:ok, %Response{body: body, status_code: _}} -> + {:ok, %{body: body, status_code: _}} -> {:error, body} {:error, error} -> diff --git a/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex b/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex index 606c5bb5a6fe..1e30181083bf 100644 --- a/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex +++ b/apps/explorer/lib/explorer/smart_contract/sig_provider_interface.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.SigProviderInterface do @moduledoc """ Adapter for decoding events and function calls with https://github.com/blockscout/blockscout-rs/tree/main/sig-provider """ + alias Explorer.HttpClient alias Explorer.Utility.Microservice - alias HTTPoison.Response require Logger @request_error_msg "Error while sending request to sig-provider" @@ -78,11 +79,11 @@ defmodule Explorer.SmartContract.SigProviderInterface do end defp http_get_request(url) do - case HTTPoison.get(url) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url) do + {:ok, %{body: body, status_code: 200}} -> process_sig_provider_response(body) - {:ok, %Response{body: body, status_code: _}} -> + {:ok, %{body: body, status_code: _}} -> {:error, body} {:error, error} -> @@ -104,8 +105,8 @@ defmodule Explorer.SmartContract.SigProviderInterface do defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HTTPoison.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do + {:ok, %{body: body, status_code: 200}} -> body |> Jason.decode() error -> @@ -142,7 +143,9 @@ defmodule Explorer.SmartContract.SigProviderInterface do defp decode_event_url, do: "#{base_api_url()}" <> "/event" - defp decode_events_batch_url, do: "#{base_api_url()}" <> "/events:batch-get" + # cspell:disable + defp decode_events_batch_url, do: "#{base_api_url()}" <> "/events%3Abatch-get" + # cspell:enable def base_api_url, do: "#{base_url()}" <> "/api/v1/abi" diff --git a/apps/explorer/lib/explorer/smart_contract/solc_downloader.ex b/apps/explorer/lib/explorer/smart_contract/solc_downloader.ex index 5f63b3189dbc..3c5d2f74324f 100644 --- a/apps/explorer/lib/explorer/smart_contract/solc_downloader.ex +++ b/apps/explorer/lib/explorer/smart_contract/solc_downloader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.SolcDownloader do @moduledoc """ Checks to see if the requested solc compiler version exists, and if not it @@ -5,6 +6,7 @@ defmodule Explorer.SmartContract.SolcDownloader do """ use GenServer + alias Explorer.HttpClient alias Explorer.SmartContract.CompilerVersion @latest_compiler_refetch_time :timer.minutes(30) @@ -90,10 +92,10 @@ defmodule Explorer.SmartContract.SolcDownloader do end defp download(version) do - download_path = "https://solc-bin.ethereum.org/bin/soljson-#{version}.js" + download_path = "https://binaries.soliditylang.org/bin/soljson-#{version}.js" download_path - |> HTTPoison.get!([], timeout: 60_000, recv_timeout: 60_000) + |> HttpClient.get!([], timeout: 60_000, recv_timeout: 60_000) |> Map.get(:body) end end diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex b/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex index 0e9489b067c6..284b78f7c404 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/code_compiler.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Solidity.CodeCompiler do @moduledoc """ Module responsible to compile the Solidity code of a given Smart Contract. @@ -20,6 +21,7 @@ defmodule Explorer.SmartContract.Solidity.CodeCompiler do ## Examples + iex(1)> Tesla.Test.expect_tesla_call(times: 1, returns: %Tesla.Env{status: 200, body: ""}) iex(1)> Explorer.SmartContract.Solidity.CodeCompiler.run([ ...> name: "SimpleStorage", ...> compiler_version: "v0.4.24+commit.e67f0147", diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex b/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex index 56597fae83ef..0f92cfec5fac 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/publish_helper.ex @@ -1,7 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Solidity.PublishHelper do @moduledoc """ Module responsible for preparing and publishing smart contracts """ + require Logger use Utils.RuntimeEnvHelper, eth_bytecode_db_enabled?: [ @@ -47,7 +49,9 @@ defmodule Explorer.SmartContract.Solidity.PublishHelper do :on_demand ) - _ -> + error -> + Logger.error("Unexpected error during verification: #{inspect(error)}") + EventsPublisher.broadcast( prepare_verification_error("Unexpected error", address_hash_string, conn, api_v2?), :on_demand diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex b/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex index 8c97bd77e59a..0993c96d018d 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/publisher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Solidity.Publisher do @moduledoc """ Module responsible to control the contract verification. @@ -5,7 +6,12 @@ defmodule Explorer.SmartContract.Solidity.Publisher do require Logger - import Explorer.SmartContract.Helper, only: [cast_libraries: 1, prepare_license_type: 1] + import Explorer.SmartContract.Helper, + only: [ + cast_libraries: 1, + parse_solidity_verification_language: 1, + prepare_license_type: 1 + ] alias Explorer.Chain.SmartContract alias Explorer.SmartContract.{CompilerVersion, Helper} @@ -175,7 +181,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do "compilerSettings" => compiler_settings_string, "runtimeMatch" => %{"type" => match_type}, "zkCompiler" => %{"version" => zk_compiler_version} - }, + } = source, address_hash, initial_params, is_standard_json?, @@ -198,6 +204,8 @@ defmodule Explorer.SmartContract.Solidity.Publisher do constructor_arguments = if initial_params["constructor_arguments"] !== "0x", do: initial_params["constructor_arguments"], else: nil + verification_language = parse_solidity_verification_language(initial_params) + prepared_params = %{} |> Map.put("optimization", optimization) @@ -207,7 +215,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do |> Map.put("zk_compiler_version", zk_compiler_version) |> Map.put("constructor_arguments", constructor_arguments) |> Map.put("contract_source_code", contract_source_code) - |> Map.put("external_libraries", cast_libraries(compiler_settings["libraries"] || %{})) + |> Map.put("external_libraries", source["libraries"] || cast_libraries(compiler_settings["libraries"] || %{})) |> Map.put("name", contract_name) |> Map.put("file_path", if(save_file_path?, do: file_name)) |> Map.put("secondary_sources", secondary_sources) @@ -218,6 +226,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do |> Map.put("verified_via_verifier_alliance", false) |> Map.put("license_type", initial_params["license_type"]) |> Map.put("is_blueprint", false) + |> Map.put("language", verification_language) publish_smart_contract(address_hash, prepared_params, abi, save_file_path?) end @@ -252,6 +261,8 @@ defmodule Explorer.SmartContract.Solidity.Publisher do optimization_runs = parse_optimization_runs(compiler_settings, optimization) + verification_language = parse_solidity_verification_language(initial_params) + prepared_params = %{} |> Map.put("optimization", optimization) @@ -260,7 +271,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do |> Map.put("compiler_version", compiler_version) |> Map.put("constructor_arguments", constructor_arguments) |> Map.put("contract_source_code", contract_source_code) - |> Map.put("external_libraries", cast_libraries(compiler_settings["libraries"] || %{})) + |> Map.put("external_libraries", source["libraries"] || cast_libraries(compiler_settings["libraries"] || %{})) |> Map.put("name", contract_name) |> Map.put("file_path", if(save_file_path?, do: file_name)) |> Map.put("secondary_sources", secondary_sources) @@ -271,6 +282,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do |> Map.put("verified_via_verifier_alliance", source["verifier_alliance?"]) |> Map.put("license_type", initial_params["license_type"]) |> Map.put("is_blueprint", source["isBlueprint"]) + |> Map.put("language", verification_language) publish_smart_contract(address_hash, prepared_params, Jason.decode!(abi_string || "null"), save_file_path?) end @@ -396,7 +408,7 @@ defmodule Explorer.SmartContract.Solidity.Publisher do compiler_settings: clean_compiler_settings, license_type: prepare_license_type(params["license_type"]) || :none, is_blueprint: params["is_blueprint"] || false, - language: (is_nil(abi) && :yul) || :solidity + language: parse_solidity_verification_language(params) } base_attributes @@ -406,7 +418,11 @@ defmodule Explorer.SmartContract.Solidity.Publisher do )).() end - defp clear_constructor_arguments(constructor_arguments) do + @doc """ + Helper function to clean constructor arguments + """ + @spec clear_constructor_arguments(String.t() | nil) :: String.t() | nil + def clear_constructor_arguments(constructor_arguments) do if constructor_arguments != nil && constructor_arguments != "" do constructor_arguments else diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/publisher_worker.ex b/apps/explorer/lib/explorer/smart_contract/solidity/publisher_worker.ex index f1d4dff9de43..e7d9780ff0d3 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/publisher_worker.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/publisher_worker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Solidity.PublisherWorker do @moduledoc """ Background smart contract verification worker. diff --git a/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex b/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex index ca3b3eecc00f..e5787e84fcd3 100644 --- a/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex +++ b/apps/explorer/lib/explorer/smart_contract/solidity/verifier.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule Explorer.SmartContract.Solidity.Verifier do @moduledoc """ @@ -12,6 +13,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do only: [ cast_libraries: 1, fetch_data_for_verification: 1, + parse_solidity_verification_language: 1, prepare_bytecode_for_microservice: 3 ] @@ -52,8 +54,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do %{} |> prepare_bytecode_for_microservice(creation_transaction_input, deployed_bytecode) |> Map.put("sourceFiles", %{ - "#{params["name"]}.#{smart_contract_source_file_extension(parse_boolean(params["is_yul"]))}" => - params["contract_source_code"] + "#{params["name"]}.#{smart_contract_source_file_extension(params)}" => params["contract_source_code"] }) |> Map.put("libraries", params["external_libraries"]) |> Map.put("optimizationRuns", prepare_optimization_runs(params["optimization"], params["optimization_runs"])) @@ -90,8 +91,9 @@ defmodule Explorer.SmartContract.Solidity.Verifier do end end - defp smart_contract_source_file_extension(true), do: "yul" - defp smart_contract_source_file_extension(_), do: "sol" + defp smart_contract_source_file_extension(params) do + if parse_solidity_verification_language(params) == :yul, do: "yul", else: "sol" + end defp prepare_optimization_runs(false_, _) when false_ in [false, "false"], do: nil @@ -479,6 +481,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do {meta, last_2_bytes} else _ -> + Logger.warning("Could not extract CBOR metadata from deployed bytecode") {"", ""} end end @@ -489,6 +492,7 @@ defmodule Explorer.SmartContract.Solidity.Verifier do decoded_meta else _ -> + Logger.warning("Failed to decode CBOR metadata from bytecode, falling back to empty metadata") %{} end end diff --git a/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex b/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex index 59bd673d2575..95cc36f4304d 100644 --- a/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/stylus/publisher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Stylus.Publisher do @moduledoc """ Module responsible for verifying and publishing Stylus smart contracts. diff --git a/apps/explorer/lib/explorer/smart_contract/stylus/publisher_worker.ex b/apps/explorer/lib/explorer/smart_contract/stylus/publisher_worker.ex index ad5357978cc7..e940d49ae2a1 100644 --- a/apps/explorer/lib/explorer/smart_contract/stylus/publisher_worker.ex +++ b/apps/explorer/lib/explorer/smart_contract/stylus/publisher_worker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Stylus.PublisherWorker do @moduledoc """ Processes Stylus smart contract verification requests asynchronously in the background. diff --git a/apps/explorer/lib/explorer/smart_contract/stylus/verifier.ex b/apps/explorer/lib/explorer/smart_contract/stylus/verifier.ex index e1f6baebe816..d459d4a6f062 100644 --- a/apps/explorer/lib/explorer/smart_contract/stylus/verifier.ex +++ b/apps/explorer/lib/explorer/smart_contract/stylus/verifier.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Stylus.Verifier do @moduledoc """ Verifies Stylus smart contracts by comparing their source code against deployed bytecode. @@ -100,7 +101,7 @@ defmodule Explorer.SmartContract.Stylus.Verifier do transaction.hash %{internal_transaction: internal_transaction} -> - internal_transaction.transaction_hash + internal_transaction.transaction.hash _ -> nil diff --git a/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex b/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex index 596ebc6b2ce8..5ce38b0b3f34 100644 --- a/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex +++ b/apps/explorer/lib/explorer/smart_contract/stylus_verifier_interface.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.StylusVerifierInterface do @moduledoc """ Provides an interface for verifying Stylus smart contracts by interacting with a verification @@ -6,7 +7,7 @@ defmodule Explorer.SmartContract.StylusVerifierInterface do Handles verification requests for Stylus contracts deployed from GitHub repositories by communicating with an external verification service. """ - alias HTTPoison.Response + alias Explorer.HttpClient require Logger @post_timeout :timer.minutes(5) @@ -55,8 +56,8 @@ defmodule Explorer.SmartContract.StylusVerifierInterface do defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HTTPoison.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do - {:ok, %Response{body: body, status_code: _}} -> + case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do + {:ok, %{body: body, status_code: _}} -> process_verifier_response(body) {:error, error} -> @@ -73,11 +74,11 @@ defmodule Explorer.SmartContract.StylusVerifierInterface do @spec http_get_request(String.t()) :: {:ok, [String.t()]} | {:error, any()} defp http_get_request(url) do - case HTTPoison.get(url) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url) do + {:ok, %{body: body, status_code: 200}} -> process_verifier_response(body) - {:ok, %Response{body: body, status_code: _}} -> + {:ok, %{body: body, status_code: _}} -> {:error, body} {:error, error} -> diff --git a/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex b/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex index 52e8248c612d..5e4078bc0e17 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper/code_compiler.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Vyper.CodeCompiler do @moduledoc """ Module responsible to compile the Vyper code of a given Smart Contract. diff --git a/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex b/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex index c3b90ca94892..567cb6da566b 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper/publisher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Vyper.Publisher do @moduledoc """ Module responsible to control Vyper contract verification. @@ -104,7 +105,8 @@ defmodule Explorer.SmartContract.Vyper.Publisher do |> Map.put("compiler_version", compiler_version) |> Map.put("constructor_arguments", constructor_arguments) |> Map.put("contract_source_code", contract_source_code) - |> Map.put("external_libraries", cast_libraries(compiler_settings["libraries"] || %{})) + # external_libraries is not used in vyper, but updating if we'll use it in the future + |> Map.put("external_libraries", source["libraries"] || cast_libraries(compiler_settings["libraries"] || %{})) |> Map.put("name", contract_name) |> Map.put("file_path", if(save_file_path?, do: file_name)) |> Map.put("secondary_sources", secondary_sources) diff --git a/apps/explorer/lib/explorer/smart_contract/vyper/publisher_worker.ex b/apps/explorer/lib/explorer/smart_contract/vyper/publisher_worker.ex index a060197d2ffd..83e40ba63799 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper/publisher_worker.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper/publisher_worker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Vyper.PublisherWorker do @moduledoc """ Background smart contract verification worker. diff --git a/apps/explorer/lib/explorer/smart_contract/vyper/verifier.ex b/apps/explorer/lib/explorer/smart_contract/vyper/verifier.ex index 049c9f544524..31d45eb14500 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper/verifier.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper/verifier.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # credo:disable-for-this-file defmodule Explorer.SmartContract.Vyper.Verifier do @moduledoc """ diff --git a/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex b/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex index dfb873276c7a..1e48babfa730 100644 --- a/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex +++ b/apps/explorer/lib/explorer/smart_contract/vyper_downloader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.VyperDownloader do @moduledoc """ Checks to see if the requested Vyper compiler version exists, and if not it @@ -5,6 +6,7 @@ defmodule Explorer.SmartContract.VyperDownloader do """ use GenServer + alias Explorer.HttpClient alias Explorer.SmartContract.CompilerVersion @latest_compiler_refetch_time :timer.minutes(30) @@ -97,7 +99,7 @@ defmodule Explorer.SmartContract.VyperDownloader do releases_body = releases_path - |> HTTPoison.get!([], timeout: 60_000, recv_timeout: 60_000) + |> HttpClient.get!([], timeout: 60_000, recv_timeout: 60_000) |> Map.get(:body) |> Jason.decode!() @@ -121,7 +123,11 @@ defmodule Explorer.SmartContract.VyperDownloader do end) download_path - |> HTTPoison.get!([], timeout: 60_000, recv_timeout: 60_000, follow_redirect: true, hackney: [force_redirect: true]) + |> HttpClient.get!([], + timeout: 60_000, + recv_timeout: 60_000, + follow_redirect: true + ) |> Map.get(:body) end end diff --git a/apps/explorer/lib/explorer/smart_contract/writer.ex b/apps/explorer/lib/explorer/smart_contract/writer.ex index 0b0dc52a7e2a..f145ef62fd5f 100644 --- a/apps/explorer/lib/explorer/smart_contract/writer.ex +++ b/apps/explorer/lib/explorer/smart_contract/writer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Writer do @moduledoc """ Generates smart-contract transactions diff --git a/apps/explorer/lib/explorer/sorting_helper.ex b/apps/explorer/lib/explorer/sorting_helper.ex index 1c0e064b2e13..07cb4c07405f 100644 --- a/apps/explorer/lib/explorer/sorting_helper.ex +++ b/apps/explorer/lib/explorer/sorting_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SortingHelper do @moduledoc """ Module that order and paginate queries dynamically based on default and provided sorting parameters. @@ -24,7 +25,7 @@ defmodule Explorer.SortingHelper do Applies sorting to query based on default sorting params and sorting params from the client, these params merged keeping provided one over default one. """ - @spec apply_sorting(Ecto.Query.t(), sorting_params, sorting_params) :: Ecto.Query.t() + @spec apply_sorting(Ecto.Query.t() | module(), sorting_params, sorting_params) :: Ecto.Query.t() def apply_sorting(query, sorting, default_sorting) when is_list(sorting) and is_list(default_sorting) do sorting |> merge_sorting_params_with_defaults(default_sorting) |> sorting_params_to_order_by(query) end @@ -61,8 +62,14 @@ defmodule Explorer.SortingHelper do |> merge_sorting_params_with_defaults(default_sorting) |> do_page_with_sorting() |> case do - nil -> query - dynamic_where -> query |> where(^dynamic_where.(key)) + nil -> + query + + dynamic_where -> + case query.group_bys do + [] -> query |> where(^dynamic_where.(key)) + _ -> query |> having(^dynamic_where.(key)) + end end |> limit_query(page_size) end diff --git a/apps/explorer/lib/explorer/stats/hot_smart_contracts.ex b/apps/explorer/lib/explorer/stats/hot_smart_contracts.ex new file mode 100644 index 000000000000..982bb4be3920 --- /dev/null +++ b/apps/explorer/lib/explorer/stats/hot_smart_contracts.ex @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Stats.HotSmartContracts do + @moduledoc """ + This module defines the HotSmartContracts schema and functions for aggregating and paginating hot contracts. + """ + use Explorer.Schema + + alias Explorer.{Chain, PagingOptions, SortingHelper} + alias Explorer.Chain.{Address, Block, Hash, Transaction} + alias Explorer.Chain.Block.Reader.General, as: BlockReaderGeneral + alias Explorer.Helper, as: ExplorerHelper + + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] + + @primary_key false + typed_schema "hot_smart_contracts_daily" do + field(:date, :date, primary_key: true) + field(:contract_address_hash, Hash.Address, primary_key: true) + field(:transactions_count, :integer) + field(:total_gas_used, :decimal) + + belongs_to(:contract_address, Address, + foreign_key: :contract_address_hash, + references: :hash, + type: Hash.Address, + define_field: false + ) + + timestamps() + end + + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() + def changeset(%__MODULE__{} = struct, params \\ %{}) do + struct + |> cast(params, [:date, :contract_address_hash, :transactions_count, :total_gas_used]) + |> validate_required([:date, :contract_address_hash, :transactions_count, :total_gas_used]) + end + + @spec aggregate_hot_smart_contracts_for_date(Date.t(), keyword()) :: + {:ok, + [%{contract_address_hash: Hash.Address.t(), transactions_count: integer(), total_gas_used: Decimal.t()}]} + | {:error, any()} + def aggregate_hot_smart_contracts_for_date(date, options \\ []) do + with {:ok, from_date} <- DateTime.new(date, ~T[00:00:00], "Etc/UTC"), + {:ok, to_date} <- date |> Date.add(1) |> DateTime.new(~T[00:00:00], "Etc/UTC"), + {:ok, from_block} <- + BlockReaderGeneral.timestamp_to_block_number(from_date, :after, options[:api?] || false, true), + {:ok, to_block} <- + BlockReaderGeneral.timestamp_to_block_number(to_date, :before, options[:api?] || false, true) do + {:ok, + from_block + |> aggregate_hot_smart_contracts_for_block_interval_query(to_block) + |> Chain.select_repo(options).all(timeout: :infinity) + |> Enum.map(&Map.put(&1, :date, date))} + else + error -> {:error, error} + end + end + + @spec aggregate_hot_smart_contracts_for_block_interval_query(Block.block_number(), Block.block_number()) :: + Ecto.Query.t() + defp aggregate_hot_smart_contracts_for_block_interval_query(from_block, to_block) do + from(transaction in Transaction, + as: :transaction, + where: transaction.block_number >= ^from_block and transaction.block_number <= ^to_block, + inner_join: address in assoc(transaction, :to_address), + as: :to_address, + where: not is_nil(address.contract_code), + group_by: transaction.to_address_hash, + select: %{ + contract_address_hash: selected_as(transaction.to_address_hash, :contract_address_hash), + transactions_count: selected_as(count(), :transactions_count), + total_gas_used: selected_as(sum(transaction.gas_used), :total_gas_used) + } + ) + end + + @spec indexed_dates(keyword()) :: [Date.t()] + def indexed_dates(options \\ []) do + __MODULE__ + |> select([hot_smart_contracts_daily], hot_smart_contracts_daily.date) + |> distinct(true) + |> Chain.select_repo(options).all(timeout: :infinity) + end + + @spec delete_older_than(Date.t(), keyword()) :: {non_neg_integer(), nil} + def delete_older_than(date, options \\ []) do + __MODULE__ + |> where([hot_smart_contracts_daily], hot_smart_contracts_daily.date < ^date) + |> Chain.select_repo(options).delete_all(timeout: :infinity) + end + + @doc """ + Paginate hot contracts by scale. + + ## Parameters + - `scale`: Time interval to aggregate hot contracts on (5m, 1h, 3h, 1d, 7d, 30d). + - `options`: The options to pass to the paginated function. + + ## Returns + - A list of hot contracts. + """ + @spec paginated(String.t(), keyword()) :: [__MODULE__.t()] | {:error, :not_found} + def paginated(scale, options \\ []) do + case scale do + "5m" -> last_n_seconds_paginated(300, options) + "1h" -> last_n_seconds_paginated(3600, options) + "3h" -> last_n_seconds_paginated(10800, options) + "1d" -> last_n_days_paginated(1, options) + "7d" -> last_n_days_paginated(7, options) + "30d" -> last_n_days_paginated(30, options) + _ -> raise "Invalid scale: #{scale}" + end + end + + defp last_n_seconds_paginated(n, options) do + default_sorting = [ + {:dynamic, :transactions_count, :desc_nulls_last, transactions_count_on_transactions_dynamic()}, + {:dynamic, :total_gas_used, :desc_nulls_last, total_gas_used_on_transactions_dynamic()}, + {:asc, :to_address_hash, :transaction} + ] + + paging_options = Keyword.get(options, :paging_options, PagingOptions.default_paging_options()) + sorting_options = Keyword.get(options, :sorting, %{})[:aggregated_on_transactions] || [] + + preloads = + Keyword.get(options, :preloads, + contract_address: [:smart_contract, :names, proxy_implementations_association(), reputation_association()] + ) + + now = DateTime.utc_now() + from_timestamp = DateTime.add(now, -n, :second) + + with {:ok, from_block} <- + BlockReaderGeneral.timestamp_to_block_number(from_timestamp, :after, options[:api?] || false, true), + {:ok, to_block} <- + BlockReaderGeneral.timestamp_to_block_number(now, :before, options[:api?] || false, true) do + from_block + |> aggregate_hot_smart_contracts_for_block_interval_query(to_block) + |> ExplorerHelper.maybe_hide_scam_addresses({:to_address, :hash}, options) + |> SortingHelper.apply_sorting(sorting_options, default_sorting) + |> SortingHelper.page_with_sorting(paging_options, sorting_options, default_sorting) + |> Chain.select_repo(options).all() + |> Enum.map(&struct(__MODULE__, &1)) + |> Chain.select_repo(options).preload(preloads) + end + end + + defp last_n_days_paginated(n, options) do + default_sorting = [ + {:dynamic, :transactions_count, :desc_nulls_last, transactions_count_dynamic()}, + {:dynamic, :total_gas_used, :desc_nulls_last, total_gas_used_dynamic()}, + asc: :contract_address_hash + ] + + paging_options = Keyword.get(options, :paging_options, PagingOptions.default_paging_options()) + sorting_options = Keyword.get(options, :sorting, %{})[:aggregated_on_hot_smart_contracts] || [] + + preloads = + Keyword.get(options, :preloads, + contract_address: [:smart_contract, :names, proxy_implementations_association(), reputation_association()] + ) + + __MODULE__ + |> where([hot_smart_contracts_daily], hot_smart_contracts_daily.date >= ^Date.add(Date.utc_today(), -n)) + |> ExplorerHelper.maybe_hide_scam_addresses(:contract_address_hash, options) + |> group_by([hot_smart_contracts_daily], hot_smart_contracts_daily.contract_address_hash) + |> select([hot_smart_contracts_daily], %{ + contract_address_hash: hot_smart_contracts_daily.contract_address_hash, + transactions_count: sum(hot_smart_contracts_daily.transactions_count), + total_gas_used: sum(hot_smart_contracts_daily.total_gas_used) + }) + |> SortingHelper.apply_sorting(sorting_options, default_sorting) + |> SortingHelper.page_with_sorting(paging_options, sorting_options, default_sorting) + |> Chain.select_repo(options).all() + |> Enum.map(&struct(__MODULE__, &1)) + |> Chain.select_repo(options).preload(preloads) + end + + @spec transactions_count_dynamic() :: Ecto.Query.dynamic_expr() + def transactions_count_dynamic do + dynamic([hot_smart_contracts_daily], sum(hot_smart_contracts_daily.transactions_count)) + end + + @spec total_gas_used_dynamic() :: Ecto.Query.dynamic_expr() + def total_gas_used_dynamic do + dynamic([hot_smart_contracts_daily], sum(hot_smart_contracts_daily.total_gas_used)) + end + + @spec transactions_count_on_transactions_dynamic() :: Ecto.Query.dynamic_expr() + def transactions_count_on_transactions_dynamic do + dynamic([transaction], count()) + end + + @spec total_gas_used_on_transactions_dynamic() :: Ecto.Query.dynamic_expr() + def total_gas_used_on_transactions_dynamic do + dynamic([transaction], sum(transaction.gas_used)) + end +end diff --git a/apps/explorer/lib/explorer/stats/hot_smart_contracts_cache.ex b/apps/explorer/lib/explorer/stats/hot_smart_contracts_cache.ex new file mode 100644 index 000000000000..5329bc0e8635 --- /dev/null +++ b/apps/explorer/lib/explorer/stats/hot_smart_contracts_cache.ex @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Stats.HotSmartContractsCache do + @moduledoc """ + Cache for hot smart contracts computed from realtime transaction cohorts. + """ + + alias Explorer.Stats.HotSmartContracts + + @cache_name :hot_smart_contracts + @cacheable_scales ~w(5m 1h 3h) + + @type scale :: String.t() + + @spec cache_name() :: atom() + def cache_name, do: @cache_name + + @doc """ + Fetches hot smart contracts for the given scale and options, using the cache if applicable. + If the scale is cacheable, it attempts to fetch from the cache or store the result of the fallback function. + If the scale is not cacheable, it directly calls the fallback function. + """ + @spec fetch(scale(), keyword(), (-> [HotSmartContracts.t()] | {:error, :not_found})) :: + [HotSmartContracts.t()] | {:error, :not_found} + def fetch(scale, options, fallback_fn) when is_function(fallback_fn, 0) do + if cacheable_scale?(scale) do + fetch_cached({scale, options}, scale, fallback_fn) + else + fallback_fn.() + end + end + + defp fetch_cached(cache_key, scale, fallback_fn) do + case ConCache.fetch_or_store(@cache_name, cache_key, fn -> + store_value(fallback_fn, scale) + end) do + {:ok, value} -> value + {:error, _reason} = error -> error + end + end + + defp store_value(fallback_fn, scale) do + case fallback_fn.() do + {:error, _reason} = error -> error + result -> {:ok, %ConCache.Item{value: result, ttl: ttl(scale)}} + end + end + + @spec cacheable_scale?(scale()) :: boolean() + def cacheable_scale?(scale), do: scale in @cacheable_scales + + @spec ttl(scale()) :: non_neg_integer() + def ttl(scale) do + :explorer + |> Application.get_env(__MODULE__) + |> Map.get(scale, 0) + end +end diff --git a/apps/explorer/lib/explorer/tags/address_tag.ex b/apps/explorer/lib/explorer/tags/address_tag.ex index d6c20c2d2694..ceb23c391b0d 100644 --- a/apps/explorer/lib/explorer/tags/address_tag.ex +++ b/apps/explorer/lib/explorer/tags/address_tag.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Tags.AddressTag do @moduledoc """ Represents an address Tag object. @@ -63,7 +64,7 @@ defmodule Explorer.Tags.AddressTag do @doc """ Fetches AddressTag.t() by label name from the DB """ - @spec get_id_by_label(String.t()) :: non_neg_integer() + @spec get_id_by_label(String.t() | nil) :: non_neg_integer() | nil def get_id_by_label(nil), do: nil def get_id_by_label(label) do diff --git a/apps/explorer/lib/explorer/tags/address_tag_cataloger.ex b/apps/explorer/lib/explorer/tags/address_tag_cataloger.ex index 5295a29d30f6..59db3ba687d7 100644 --- a/apps/explorer/lib/explorer/tags/address_tag_cataloger.ex +++ b/apps/explorer/lib/explorer/tags/address_tag_cataloger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Tags.AddressTag.Cataloger do @moduledoc """ Actualizes address tags. @@ -48,7 +49,7 @@ defmodule Explorer.Tags.AddressTag.Cataloger do all_tags |> Enum.each(fn %{label: tag_name} -> if tag_name !== "validator" && tag_name !== "amb bridge mediators" && tag_name !== "omni bridge" && - tag_name !== "l2" && !String.contains?(tag_name, "chainlink") do + tag_name !== "l2" && tag_name !== "fhe" && !String.contains?(tag_name, "chainlink") do env_var_name = "CUSTOM_CONTRACT_ADDRESSES_#{tag_name_to_env_var_part(tag_name)}" set_tag_for_env_var_multiple_addresses(env_var_name, tag_name) end diff --git a/apps/explorer/lib/explorer/tags/address_to_tag.ex b/apps/explorer/lib/explorer/tags/address_to_tag.ex index 4b6d18ecc2c2..b6f7205a61a4 100644 --- a/apps/explorer/lib/explorer/tags/address_to_tag.ex +++ b/apps/explorer/lib/explorer/tags/address_to_tag.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Tags.AddressToTag do @moduledoc """ Represents Address to Tag relation. @@ -9,7 +10,6 @@ defmodule Explorer.Tags.AddressToTag do alias Explorer.{Chain, Repo} alias Explorer.Chain.{Address, Hash} - alias Explorer.Helper, as: ExplorerHelper alias Explorer.Tags.AddressTag # Notation.import_types(BlockScoutWeb.GraphQL.Schema.Types) @@ -71,7 +71,7 @@ defmodule Explorer.Tags.AddressToTag do current_address_hashes_strings = current_address_hashes |> Enum.map(fn address_hash -> - ExplorerHelper.add_0x_prefix(address_hash.bytes) + to_string(address_hash) end) current_address_hashes_strings_tuples = MapSet.new(current_address_hashes_strings) @@ -92,22 +92,11 @@ defmodule Explorer.Tags.AddressToTag do changeset_to_add_list = addresses_to_add |> Enum.map(fn address_hash_string -> - with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), - :ok <- Address.check_address_exists(address_hash) do - %{ - tag_id: tag_id, - address_hash: address_hash, - inserted_at: DateTime.utc_now(), - updated_at: DateTime.utc_now() - } - else - _ -> - nil - end + build_address_to_tag_changeset(address_hash_string, tag_id) end) |> Enum.filter(&(!is_nil(&1))) - unless Enum.empty?(addresses_to_delete) do + if !Enum.empty?(addresses_to_delete) do delete_query_base = from( att in __MODULE__, @@ -128,6 +117,21 @@ defmodule Explorer.Tags.AddressToTag do end end + defp build_address_to_tag_changeset(address_hash_string, tag_id) do + with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string), + :ok <- Address.check_address_exists(address_hash) do + %{ + tag_id: tag_id, + address_hash: address_hash, + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + } + else + _ -> + nil + end + end + defp where_addresses(query, addresses_to_delete) do addresses_to_delete |> Enum.reduce(query, fn address_hash_string, acc -> diff --git a/apps/explorer/lib/explorer/third_party_integrations/airtable.ex b/apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex similarity index 50% rename from apps/explorer/lib/explorer/third_party_integrations/airtable.ex rename to apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex index f7e178002e86..c64c14ef25ab 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/airtable.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/airtable_audit_report.ex @@ -1,50 +1,19 @@ -defmodule Explorer.ThirdPartyIntegrations.AirTable do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.AirTableAuditReport do @moduledoc """ - Module is responsible for submitting requests for public tags and audit reports to AirTable + Module is responsible for submitting audit reports to AirTable """ require Logger alias Ecto.Changeset - alias Explorer.Account.PublicTagsRequest alias Explorer.Chain.SmartContract.AuditReport - alias Explorer.Repo - alias HTTPoison.Response + alias Explorer.HttpClient @doc """ Submits a public tags request or audit report to AirTable """ - @spec submit({:ok, PublicTagsRequest.t()} | {:error, Changeset.t()} | Changeset.t()) :: - {:ok, PublicTagsRequest.t()} | {:error, Changeset.t()} | Changeset.t() - def submit({:ok, %PublicTagsRequest{} = new_request} = input) do - if Mix.env() == :test do - new_request - |> PublicTagsRequest.changeset(%{request_id: "123"}) - |> Repo.account_repo().update() - - input - else - submit_entry( - PublicTagsRequest.to_map(new_request), - :air_table_public_tags, - fn request_id -> - new_request - |> PublicTagsRequest.changeset(%{request_id: request_id}) - |> Repo.account_repo().update() - - input - end, - fn -> - {:error, - %{ - (%PublicTagsRequest{} - |> PublicTagsRequest.changeset_without_constraints(PublicTagsRequest.to_map(new_request)) - |> Changeset.add_error(:full_name, "AirTable error. Please try again later")) - | action: :insert - }} - end - ) - end - end + @spec submit(Changeset.t()) :: Changeset.t() + @spec submit({:ok, AuditReport.t()} | {:error, Changeset.t()}, Changeset.t()) :: Changeset.t() def submit(%Changeset{} = changeset), do: submit(Changeset.apply_action(changeset, :insert), changeset) @@ -76,10 +45,10 @@ defmodule Explorer.ThirdPartyIntegrations.AirTable do "records" => [%{"fields" => map}] } - request = HTTPoison.post(url, Jason.encode!(body), headers, []) + request = HttpClient.post(url, Jason.encode!(body), headers) case request do - {:ok, %Response{body: body, status_code: 200}} -> + {:ok, %{body: body, status_code: 200}} -> request_id = Enum.at(Jason.decode!(body)["records"], 0)["fields"]["request_id"] success_callback.(request_id) diff --git a/apps/explorer/lib/explorer/third_party_integrations/auth0.ex b/apps/explorer/lib/explorer/third_party_integrations/auth0.ex index 12d2189e5391..b57dd5415ed9 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/auth0.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/auth0.ex @@ -1,26 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.Auth0 do @moduledoc """ - Module for fetching jwt Auth0 Management API (https://auth0.com/docs/api/management/v2) jwt + Auth0 Management REST API client for user management. """ require Logger - alias Explorer.Account.Identity - alias Explorer.{Account, Helper, Repo} - alias Explorer.Chain.Address - alias OAuth2.{AccessToken, Client} - alias Ueberauth.Auth - alias Ueberauth.Strategy.Auth0 + alias Explorer.Account.{Authentication, Identity} + alias Explorer.{Helper, HttpClient, Vault} + alias Explorer.ThirdPartyIntegrations.Auth0.Internal + alias Explorer.ThirdPartyIntegrations.Dynamic alias Ueberauth.Strategy.Auth0.OAuth - @redis_key "auth0" + @behaviour Authentication - @request_siwe_message "Request Sign in with Ethereum message via /api/account/v2/siwe_message" - @wrong_nonce "Wrong nonce in message" - @misconfiguration_detected "Misconfiguration detected, please contact support." - @disabled_otp_error_description "Grant type 'http://auth0.com/oauth/grant-type/passwordless/otp' not allowed for the client." - @users_path "/api/v2/users" @json_content_type [{"Content-type", "application/json"}] + @spec enabled? :: boolean() + def enabled? do + Application.get_env(:ueberauth, OAuth)[:domain] not in [nil, ""] and + !Application.get_env(:explorer, Dynamic)[:enabled] + end + @doc """ Retrieves a machine-to-machine JWT for interacting with the Auth0 Management API. @@ -33,12 +33,12 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do """ @spec get_m2m_jwt() :: nil | String.t() def get_m2m_jwt do - get_m2m_jwt_inner(Redix.command(:redix, ["GET", cookie_key(@redis_key)])) + get_m2m_jwt_inner(Redix.command(:redix, ["GET", m2m_jwt_key()])) end - def get_m2m_jwt_inner({:ok, token}) when not is_nil(token), do: token + defp get_m2m_jwt_inner({:ok, token}) when not is_nil(token), do: Vault.decrypt!(token) - def get_m2m_jwt_inner(_) do + defp get_m2m_jwt_inner(_) do config = Application.get_env(:ueberauth, OAuth) body = %{ @@ -48,49 +48,30 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do "grant_type" => "client_credentials" } - case HTTPoison.post("https://#{config[:domain]}/oauth/token", Jason.encode!(body), @json_content_type, []) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case HttpClient.post("https://#{config[:domain]}/oauth/token", Jason.encode!(body), @json_content_type) do + {:ok, %{status_code: 200, body: body}} -> case Jason.decode!(body) do %{"access_token" => token, "expires_in" => ttl} -> - cache_token(token, ttl - 1) + cache_token(Vault.encrypt!(token), ttl - 1) + token _ -> nil end - _ -> + error -> + Logger.error("Error while fetching Auth0 M2M JWT: #{inspect(error)}") nil end end - @doc """ - Generates a key from chain_id and cookie hash for storing in Redis. - - This function combines the chain_id (if available) with the provided hash to - create a unique key for Redis storage. - - ## Parameters - - `hash`: The hash to be combined with the chain_id - - ## Returns - - `String.t()` representing the generated key - """ - @spec cookie_key(binary) :: String.t() - def cookie_key(hash) do - chain_id = Application.get_env(:block_scout_web, :chain_id) - - if chain_id do - chain_id <> "_" <> hash - else - hash - end - end - defp cache_token(token, ttl) do - Redix.command(:redix, ["SET", cookie_key(@redis_key), token, "EX", ttl]) + Redix.command(:redix, ["SET", m2m_jwt_key(), token, "EX", ttl]) token end + defp m2m_jwt_key, do: Helper.redis_key(Internal.redis_key()) + @doc """ Sends a one-time password (OTP) for linking an email to an existing account. @@ -105,14 +86,15 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do - `:ok` if the OTP was sent successfully - `{:error, String.t()}` error with the description - `:error` if there was an unexpected error + - `{:format, :email}` if the email format is invalid """ - @spec send_otp_for_linking(String.t(), String.t()) :: :error | :ok | {:error, String.t()} + @impl Authentication def send_otp_for_linking(email, ip) do - case find_users_by_email(email) do + case Internal.find_users_by_email(email) do {:ok, []} -> - do_send_otp(email, ip) + Internal.send_otp(email, ip) - {:ok, users} when is_list(users) and length(users) > 0 -> + {:ok, users} when is_list(users) and users !== [] -> {:error, "Account with this email already exists"} error -> @@ -136,14 +118,14 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do - `:error` if there was an unexpected error - `{:interval, integer()}` if the user need to wait before sending the OTP """ - @spec send_otp(String.t(), String.t()) :: :error | :ok | {:interval, integer()} + @impl Authentication def send_otp(email, ip) do - case find_users_by_email(email) do + case Internal.find_users_by_email(email) do {:ok, []} -> - do_send_otp(email, ip) + Internal.send_otp(email, ip) {:ok, [user | _]} -> - handle_existing_user(user, email, ip) + Internal.handle_existing_user(user, email, ip) error -> error @@ -167,16 +149,14 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do - `{:error, String.t()}` error with the description - `:error` if there was an unexpected error """ - @spec link_email(Identity.session(), String.t(), String.t(), String.t()) :: - :error | {:ok, Auth.t()} | {:error, String.t()} - def link_email(%{uid: primary_user_id, email: nil}, email, otp, ip) do - case find_users_by_email(email) do + @impl Authentication + def link_email(%{uid: user_id_without_email, email: nil}, email, otp, ip) do + case Internal.find_users_by_email(email) do {:ok, []} -> - with {:ok, token} <- confirm_otp(email, otp, ip), - {:ok, %{"sub" => "email|" <> identity_id}} <- get_user_from_token(token), - :ok <- link_users(primary_user_id, identity_id, "email"), - {:ok, user} <- update_user_email(primary_user_id, email) do - {:ok, create_auth(user)} + with {:ok, token} <- Internal.confirm_otp(email, otp, ip), + {:ok, %{"sub" => user_id_with_email}} <- Internal.get_user_from_token(token), + {:ok, user} <- Internal.link_email(user_id_without_email, user_id_with_email, email) do + {:ok, Internal.create_auth(user)} end {:ok, users} when is_list(users) -> @@ -205,16 +185,17 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do - `{:error, String.t()}` error with the description - `:error` if there was an unexpected error """ - @spec confirm_otp_and_get_auth(String.t(), String.t(), String.t()) :: :error | {:error, String.t()} | {:ok, Auth.t()} + @impl Authentication def confirm_otp_and_get_auth(email, otp, ip) do - with {:ok, token} <- confirm_otp(email, otp, ip), - {:ok, %{"sub" => user_id} = user} <- get_user_from_token(token), - {:search, _user_from_token, {:ok, user}} <- {:search, user, get_user_by_id(user_id)} do - maybe_link_email_and_get_auth(user) + with {:ok, token} <- Internal.confirm_otp(email, otp, ip), + {:ok, %{"sub" => user_id} = user} <- Internal.get_user_from_token(token), + {:search, _user_from_token, {:ok, user}} <- {:search, user, Internal.get_user_by_id(user_id)}, + {:ok, user} <- Internal.process_email_user(user) do + {:ok, Internal.create_auth(user)} else # newly created user, sometimes just created user with otp does not appear in the search - {:search, %{"sub" => user_id} = user_from_token, {:error, "User not found"}} -> - {:ok, user_from_token |> Map.put("user_id", user_id) |> create_auth()} + {:search, %{"sub" => _user_id} = user_from_token, {:error, "User not found"}} -> + Internal.handle_not_found_just_created_email_user(user_from_token) err -> err @@ -235,100 +216,33 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do - `{:new, Identity.session()}` if the address hash was added to the session """ @spec update_session_with_address_hash(Identity.session()) :: {:old, Identity.session()} | {:new, Identity.session()} - def update_session_with_address_hash(%{address_hash: _} = session), do: {:old, session} - - def update_session_with_address_hash(%{uid: user_id} = session) do - case get_user_by_id(user_id) do - {:ok, user} -> - {:new, Map.put(session, :address_hash, user |> create_auth() |> Identity.address_hash_from_auth())} - - error -> - Logger.error("Error when updating session with address hash: #{inspect(error)}") - {:old, session} - end - end - - @doc """ - Generates a Sign-In with Ethereum (SIWE) message for the given address. - - This function creates a SIWE message with a unique nonce, caches the nonce, - and returns the formatted message string. - - ## Parameters - - `address`: The Ethereum address for which to generate the SIWE message - - ## Returns - - `{:ok, String.t()}` containing the generated SIWE message - - `{:error, String.t()}` error with the description - """ - @spec generate_siwe_message(String.t()) :: {:ok, String.t()} | {:error, String.t()} - def generate_siwe_message(address) do - nonce = Siwe.generate_nonce() - {int_chain_id, _} = Integer.parse(Application.get_env(:block_scout_web, :chain_id)) - - message = %Siwe.Message{ - domain: Helper.get_app_host(), - address: address, - statement: Application.get_env(:explorer, Account)[:siwe_message], - uri: - Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:scheme] <> - "://" <> Helper.get_app_host(), - version: "1", - chain_id: int_chain_id, - nonce: nonce, - issued_at: DateTime.utc_now() |> DateTime.to_iso8601(), - expiration_time: DateTime.utc_now() |> DateTime.add(300, :second) |> DateTime.to_iso8601() - } - - with {:cache, {:ok, _nonce}} <- {:cache, cache_nonce_for_address(nonce, address)}, - {:message, {:ok, message}} <- {:message, Siwe.to_str(message)} do - {:ok, message} - else - {:cache, {:error, error}} -> - Logger.error("Error while caching nonce: #{inspect(error)}") - {:error, @misconfiguration_detected} - - {:message, {:error, error}} -> - Logger.error("Error while generating Sign in with Ethereum Message: #{inspect(error)}") - {:error, error} - end - end + def update_session_with_address_hash(session), + do: Internal.update_session_with_address_hash(session) @doc """ - Links an Ethereum address to an existing user account. + Links a web3 wallet address to an existing user account. - This function verifies the SIWE message and signature, checks for existing - users with the same address, and updates the user's account with the new - address. + Checks that no other account is already associated with the given address, + then updates the user's Auth0 profile with the address and returns updated + authentication information. ## Parameters - - `user_id`: The ID of the existing user account - - `message`: The SIWE message - - `signature`: The signature of the SIWE message + - `user_id`: The Auth0 user ID of the account to update. + - `address`: The web3 wallet address to associate with the account. ## Returns - - `{:ok, Auth.t()}` if the address was successfully linked - - `{:error, String.t()}` error with the description - - `:error` if there was an unexpected error + - `{:ok, Auth.t()}` if the address was successfully linked. + - `{:error, "Account with this address already exists"}` if another account + is already using the given address. + - `{:error, String.t()}` if a known error occurs. + - `:error` if an unexpected error occurs. """ - @spec link_address(String.t(), String.t(), String.t()) :: :error | {:error, String.t()} | {:ok, Auth.t()} - def link_address(user_id, message, signature) do - with {:signature, {:ok, %{nonce: nonce, address: address}}} <- - {:signature, message |> String.trim() |> Siwe.parse_if_valid(signature)}, - {:nonce, {:ok, ^nonce}} <- {:nonce, get_nonce_for_address(address)}, - {:user, {:ok, []}} <- {:user, find_users_by_web3_address(address)}, - {:ok, user} <- update_user_with_web3_address(user_id, address) do - {:ok, create_auth(user)} + @impl Authentication + def link_address(user_id, address) do + with {:user, {:ok, []}} <- {:user, Internal.find_users_by_web3_address(address)}, + {:ok, user} <- Internal.update_user_with_web3_address(user_id, address) do + {:ok, Internal.create_auth(user)} else - {:nonce, {:ok, _}} -> - {:error, @wrong_nonce} - - {:nonce, _} -> - {:error, @request_siwe_message} - - {:signature, error} -> - error - {:user, {:ok, _users}} -> {:error, "Account with this address already exists"} @@ -341,490 +255,27 @@ defmodule Explorer.ThirdPartyIntegrations.Auth0 do end @doc """ - Authenticates a user using a Sign-In with Ethereum (SIWE) message and signature. + Finds an existing user by web3 wallet address or creates a new one. - This function verifies the SIWE message and signature, finds or creates a user - associated with the Ethereum address, and returns the authentication information. + Delegates to the Auth0 backend to locate a user whose profile contains the + given address. If no user is found, a new Auth0 account is created using the + address as the username and the cryptographic signature as the password. + On success, returns authentication information for the resolved user. ## Parameters - - `message`: The SIWE message - - `signature`: The signature of the SIWE message + - `address`: The web3 wallet address used to identify or create the user. + - `signature`: The cryptographic signature used as the password when creating + a new user. ## Returns - - `{:ok, Auth.t()}` if authentication is successful - - `{:error, String.t()}` error with the description - - `:error` if there was an unexpected error + - `{:ok, Auth.t()}` if the user was found or successfully created. + - `{:error, String.t()}` if a known error occurs. + - `:error` if an unexpected error occurs. """ - @spec get_auth_with_web3(String.t(), String.t()) :: :error | {:error, String.t()} | {:ok, Auth.t()} - def get_auth_with_web3(message, signature) do - with {:signature, {:ok, %{nonce: nonce, address: address}}} <- - {:signature, message |> String.trim() |> Siwe.parse_if_valid(signature)}, - {:nonce, {:ok, ^nonce}} <- {:nonce, get_nonce_for_address(address)}, - {:user, {:ok, user}} <- {:user, find_or_create_web3_user(address, signature)} do - {:ok, create_auth(user)} - else - {:nonce, {:ok, nil}} -> - {:error, @request_siwe_message} - - {:nonce, {:ok, _}} -> - {:error, @wrong_nonce} - - {_step, error} -> - error - end - end - - defp handle_existing_user(user, email, ip) do - user - |> create_auth() - |> Identity.find_identity() - |> handle_identity(email, ip) - end - - defp handle_identity(nil, email, ip), do: do_send_otp(email, ip) - - defp handle_identity(%Identity{otp_sent_at: otp_sent_at} = identity, email, ip) do - otp_resend_interval = Application.get_env(:explorer, Account, :otp_resend_interval) - - case Helper.check_time_interval(otp_sent_at, otp_resend_interval) do - true -> - identity - |> Identity.changeset(%{otp_sent_at: DateTime.utc_now()}) - |> Repo.account_repo().update() - - do_send_otp(email, ip) - - interval -> - {:interval, interval} - end - end - - defp do_send_otp(email, ip) do - client = OAuth.client() - - body = - %{ - email: email, - connection: :email, - send: :code - } - |> put_client_id_and_secret() - - headers = [{"auth0-forwarded-for", ip} | @json_content_type] - - case Client.post(client, "/passwordless/start", body, headers) do - {:ok, %OAuth2.Response{status_code: 200}} -> - :ok - - other -> - Logger.error("Error while sending otp: ", inspect(other)) - - :error - end - end - - defp get_user_from_token(%AccessToken{other_params: %{"id_token" => token}}) do - case Joken.peek_claims(token) do - {:ok, %{"sub" => _} = user} -> - {:ok, user} - - error -> - Logger.error("Error while peeking claims from token: #{inspect(error)}") - :error - end - end - - defp get_user_from_token(token) do - Logger.error("No id_token in token: #{inspect(Map.update(token, :access_token, "xxx", fn _ -> "xxx" end))}") - - {:error, @misconfiguration_detected} - end - - defp confirm_otp(email, otp, ip) do - client = OAuth.client() - - body = - %{ - username: email, - otp: otp, - realm: :email, - grant_type: :"http://auth0.com/oauth/grant-type/passwordless/otp" - } - |> put_client_id_and_secret() - - headers = [{"auth0-forwarded-for", ip} | @json_content_type] - - case Client.post(client, "/oauth/token", body, headers) do - {:ok, %OAuth2.Response{status_code: 200, body: body}} -> - {:ok, AccessToken.new(body)} - - {:error, - %OAuth2.Response{ - status_code: 403, - body: - %{ - "error" => "unauthorized_client", - "error_description" => @disabled_otp_error_description, - "error_uri" => "https://auth0.com/docs/clients/client-grant-types" - } = body - }} -> - Logger.error("Need to enable OTP: #{inspect(body)}") - {:error, @misconfiguration_detected} - - {:error, - %OAuth2.Response{ - status_code: 403, - body: %{"error" => "invalid_grant", "error_description" => "Wrong email or verification code."} - }} -> - {:error, "Wrong verification code."} - - {:error, - %OAuth2.Response{ - status_code: 403, - body: %{ - "error" => "invalid_grant", - "error_description" => "You've reached the maximum number of attempts. Please try to login again." - } - }} -> - {:error, "Max attempts reached. Please resend code."} - - other -> - Logger.error("Error while confirming otp: #{inspect(other)}") - - :error - end - end - - defp get_user_by_id(id) do - case get_m2m_jwt() do - token when is_binary(token) -> - client = OAuth.client(token: token) - - case Client.get(client, "#{@users_path}/#{URI.encode(id)}") do - {:ok, %OAuth2.Response{status_code: 200, body: %{"user_id" => ^id} = user}} -> - {:ok, user} - - {:error, %OAuth2.Response{status_code: 404}} -> - {:error, "User not found"} - - other -> - Logger.error(["Error while getting user by id: ", inspect(other)]) - :error - end - end - end - - defp find_users_by_email(email) do - case get_m2m_jwt() do - token when is_binary(token) -> - client = OAuth.client(token: token) - email = URI.encode(email) - - case Client.get(client, @users_path, [], - params: %{"q" => ~s(email:"#{email}" OR user_metadata.email:"#{email}")} - ) do - {:ok, %OAuth2.Response{status_code: 200, body: users}} when is_list(users) -> - {:ok, users} - - {:error, %OAuth2.Response{status_code: 403, body: %{"errorCode" => "insufficient_scope"} = body}} -> - Logger.error(["Failed to get web3 user. Insufficient scope: ", inspect(body)]) - {:error, @misconfiguration_detected} - - other -> - Logger.error(["Error while getting web3 user: ", inspect(other)]) - :error - end - - nil -> - Logger.error("Failed to get M2M JWT") - {:error, @misconfiguration_detected} - end - end - - defp maybe_link_email_and_get_auth(%{"email" => email, "user_id" => "email|" <> identity_id = user_id} = user) do - case get_m2m_jwt() do - token when is_binary(token) -> - client = OAuth.client(token: token) - - case Client.get(client, @users_path, [], - params: %{"q" => ~s(email:"#{URI.encode(email)}" AND NOT user_id:"#{URI.encode(user_id)}")} - ) do - {:ok, %OAuth2.Response{status_code: 200, body: []}} -> - {:ok, create_auth(user)} - - {:ok, %OAuth2.Response{status_code: 200, body: [%{"user_id" => primary_user_id} = user]}} -> - link_users(primary_user_id, identity_id, "email") - maybe_verify_email(user) - {:ok, create_auth(user)} - - {:ok, %OAuth2.Response{status_code: 200, body: users}} when is_list(users) and length(users) > 1 -> - merge_email_users(users, identity_id, "email") - - {:error, %OAuth2.Response{status_code: 403, body: %{"errorCode" => "insufficient_scope"} = body}} -> - Logger.error(["Failed to get web3 user. Insufficient scope: ", inspect(body)]) - {:error, @misconfiguration_detected} - - other -> - Logger.error(["Error while getting web3 user: ", inspect(other)]) - :error - end - - nil -> - Logger.error("Failed to get M2M JWT") - {:error, @misconfiguration_detected} - end - end - - defp maybe_link_email_and_get_auth(user) do - {:ok, create_auth(user)} - end - - defp merge_web3_users([primary_user | _] = users) do - identity_map = - users - |> Enum.map(& &1["user_id"]) - |> Identity.find_identities() - |> Map.new(&{&1.uid, &1}) - - users_map = users |> Enum.map(&{&1["user_id"], &1}) |> Map.new() - - case users |> Enum.map(&identity_map[&1["user_id"]]) |> Enum.reject(&is_nil(&1)) |> Account.merge() do - {{:ok, 0}, nil} -> - unless match?(%{"user_metadata" => %{"web3_address_hash" => _}}, primary_user) do - update_user_with_web3_address( - primary_user["user_id"], - primary_user |> create_auth() |> Identity.address_hash_from_auth() - ) - end - - {:ok, primary_user} - - {{:ok, _}, primary_identity} -> - primary_user_from_db = users_map[primary_identity.uid] - - unless match?(%{"user_metadata" => %{"web3_address_hash" => _}}, primary_user_from_db) do - update_user_with_web3_address( - primary_user_from_db["user_id"], - primary_user_from_db |> create_auth() |> Identity.address_hash_from_auth() - ) - end - - {:ok, primary_user_from_db} - - error -> - Logger.error(["Error while merging users with the same address: ", inspect(error)]) - :error - end - end - - defp merge_email_users([primary_user | _] = users, identity_id_to_link, provider_for_linking) do - identity_map = - users - |> Enum.map(& &1["user_id"]) - |> Identity.find_identities() - |> Map.new(&{&1.uid, &1}) - - users_map = users |> Enum.map(&{&1["user_id"], &1}) |> Map.new() - - case users |> Enum.map(&identity_map[&1["user_id"]]) |> Enum.reject(&is_nil(&1)) |> Account.merge() do - {{:ok, 0}, nil} -> - link_users(primary_user["user_id"], identity_id_to_link, provider_for_linking) - maybe_verify_email(primary_user) - {:ok, create_auth(primary_user)} - - {{:ok, _}, primary_identity} -> - link_users(primary_identity.uid, identity_id_to_link, provider_for_linking) - maybe_verify_email(users_map[primary_identity.uid]) - {:ok, create_auth(users_map[primary_identity.uid])} - - error -> - Logger.error(["Error while merging users with the same email: ", inspect(error)]) - :error - end - end - - defp maybe_verify_email(%{"email_verified" => false, "user_id" => user_id}) do - with token when is_binary(token) <- get_m2m_jwt(), - client = OAuth.client(token: token), - body = %{"email_verified" => true}, - {:ok, %OAuth2.Response{status_code: 200, body: _user}} <- - Client.patch(client, "#{@users_path}/#{URI.encode(user_id)}", body, @json_content_type) do - :ok - else - error -> handle_common_errors(error, "Failed to patch email_verified to true") - end - end - - defp maybe_verify_email(_), do: :ok - - defp cache_nonce_for_address(nonce, address) do - case Redix.command(:redix, ["SET", cookie_key(address <> "siwe_nonce"), nonce, "EX", 300]) do - {:ok, _} -> {:ok, nonce} - err -> err - end - end - - defp get_nonce_for_address(address_hash) do - cookie_key = cookie_key(Address.checksum(address_hash) <> "siwe_nonce") - - with {:get, {:ok, nonce}} <- {:get, Redix.command(:redix, ["GET", cookie_key])}, - {:del, {:ok, _}} <- {:del, Redix.command(:redix, ["DEL", cookie_key])} do - {:ok, nonce} - else - _ -> {:error, "Redis configuration problem, please contact support."} - end - end - - defp find_or_create_web3_user(address, signature) do - case find_users_by_web3_address(address) do - {:ok, [%{"user_metadata" => %{"web3_address_hash" => ^address}} = user]} -> - {:ok, user} - - {:ok, [%{"user_id" => user_id}]} -> - update_user_with_web3_address(user_id, address) - - {:ok, []} -> - create_web3_user(address, signature) - - {:ok, users} when is_list(users) and length(users) > 1 -> - merge_web3_users(users) - - other -> - other - end - end - - defp find_users_by_web3_address(address) do - with token when is_binary(token) <- get_m2m_jwt(), - client = OAuth.client(token: token), - {:ok, %OAuth2.Response{status_code: 200, body: users}} when is_list(users) <- - Client.get( - client, - @users_path, - [], - params: %{ - "q" => - ~s|user_id:*siwe*#{address} OR user_id:*Passkey*#{address} OR user_metadata.web3_address_hash:"#{address}" OR (user_id:*Passkey* AND nickname:"#{address}")| - } - ) do - {:ok, users} - else - error -> handle_common_errors(error, "Failed to search user by address") - end - end - - defp update_user_email(user_id, email) do - with token when is_binary(token) <- get_m2m_jwt(), - client = OAuth.client(token: token), - body = %{"user_metadata" => %{"email" => email}}, - {:ok, %OAuth2.Response{status_code: 200, body: user}} <- - Client.patch(client, "#{@users_path}/#{URI.encode(user_id)}", body, @json_content_type) do - {:ok, user} - else - error -> handle_common_errors(error, "Failed to update user email") - end - end - - defp update_user_with_web3_address(user_id, address) do - with token when is_binary(token) <- get_m2m_jwt(), - client = OAuth.client(token: token), - body = %{"user_metadata" => %{"web3_address_hash" => address}}, - {:ok, %OAuth2.Response{status_code: 200, body: user}} <- - Client.patch(client, "#{@users_path}/#{URI.encode(user_id)}", body, @json_content_type) do - {:ok, user} - else - error -> handle_common_errors(error, "Failed to update user address") - end - end - - defp create_web3_user(address, signature) do - with token when is_binary(token) <- get_m2m_jwt(), - client = OAuth.client(token: token), - body = %{ - username: address, - password: signature, - email_verified: true, - connection: "Username-Password-Authentication", - user_metadata: %{web3_address_hash: address} - }, - {:ok, %OAuth2.Response{status_code: 201, body: user}} <- - Client.post(client, @users_path, body, @json_content_type) do - {:ok, user} - else - {:error, - %OAuth2.Response{ - status_code: 400, - body: - %{ - "errorCode" => "invalid_body", - "message" => "Payload validation error: 'Missing required property: email'." - } = body - }} -> - Logger.error([ - "Failed to create web3 user. Need to allow users without email in Username-Password-Authentication connection: ", - inspect(body) - ]) - - {:error, @misconfiguration_detected} - - error -> - handle_common_errors(error, "Failed to create web3 user") - end - end - - defp link_users(primary_user_id, secondary_identity_id, provider) do - with token when is_binary(token) <- get_m2m_jwt(), - client = OAuth.client(token: token), - body = %{ - provider: provider, - user_id: secondary_identity_id - }, - {:ok, %OAuth2.Response{status_code: 201}} <- - Client.post(client, "#{@users_path}/#{URI.encode(primary_user_id)}/identities", body, @json_content_type) do - :ok - else - error -> handle_common_errors(error, "Failed to link accounts") - end - end - - defp create_auth(user) do - conn_stub = %{private: %{auth0_user: user, auth0_token: nil}} - - %Auth{ - uid: user["user_id"], - provider: :auth0, - strategy: Auth0, - info: Auth0.info(conn_stub), - credentials: %Auth.Credentials{}, - extra: Auth0.extra(conn_stub) - } - end - - defp put_client_id_and_secret(map) do - auth0_config = Application.get_env(:ueberauth, OAuth) - - Map.merge( - map, - %{ - client_id: auth0_config[:client_id], - client_secret: auth0_config[:client_secret] - } - ) - end - - defp handle_common_errors(error, error_msg) do - case error do - nil -> - Logger.error("Failed to get M2M JWT") - {:error, @misconfiguration_detected} - - {:error, %OAuth2.Response{status_code: 403, body: %{"errorCode" => "insufficient_scope"} = body}} -> - Logger.error(["#{error_msg}. Insufficient scope: ", inspect(body)]) - {:error, @misconfiguration_detected} - - other -> - Logger.error(["#{error_msg}: ", inspect(other)]) - :error + @impl Authentication + def find_or_create_web3_user(address, signature) do + with {:ok, user} <- Internal.process_web3_user(address, signature) do + {:ok, Internal.create_auth(user)} end end end diff --git a/apps/explorer/lib/explorer/third_party_integrations/auth0/internal.ex b/apps/explorer/lib/explorer/third_party_integrations/auth0/internal.ex new file mode 100644 index 000000000000..33805c812079 --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/auth0/internal.ex @@ -0,0 +1,631 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Auth0.Internal do + @moduledoc """ + Module for internal usage, not supposed to be used directly, if + you want to interact with Auth0, use `Explorer.ThirdPartyIntegrations.Auth0`. + + Provides internal implementation for Auth0 authentication functionality. + + This module handles core Auth0 operations including user management, OTP verification, + web3 authentication, and user identity handling. It supports both legacy and migrated + Auth0 configurations, and implements various authentication flows such as email-based + OTP authentication and web3 wallet-based authentication. + + The module serves as the internal implementation layer for the Explorer application's + Auth0 integration, managing API interactions with Auth0 services while providing error + handling and logging capabilities. + """ + + require Logger + + alias Explorer.{Account, Helper, Repo} + alias Explorer.Account.Identity + alias Explorer.ThirdPartyIntegrations.Auth0 + alias Explorer.ThirdPartyIntegrations.Auth0.{Legacy, Migrated} + alias OAuth2.{AccessToken, Client} + alias Ueberauth.Strategy.Auth0.OAuth + + @misconfiguration_detected "Misconfiguration detected, please contact support." + @disabled_otp_error_description "Grant type 'http://auth0.com/oauth/grant-type/passwordless/otp' not allowed for the client." + @users_path "/api/v2/users" + @json_content_type [{"Content-type", "application/json"}] + + def users_path, do: @users_path + def json_content_type, do: @json_content_type + + @doc """ + Returns the Redis key prefix for Auth0-related cached data. + + Delegates to the appropriate Auth0 module (Legacy or Migrated) to provide the + correct Redis key prefix based on the current Auth0 configuration: + - Legacy: Returns "auth0" + - Migrated: Returns "auth0_migrated" + + ## Returns + - `String.t()`: The Redis key prefix to use for Auth0-related data + """ + @spec redis_key() :: String.t() + def redis_key do + auth0_module().redis_key() + end + + @doc """ + Searches for Auth0 users by email address. + + Encodes the email, creates a query using the appropriate Auth0 module + (Legacy or Migrated), and searches for users matching the query. + + ## Parameters + - `email`: The email address to search for + + ## Returns + - `{:ok, [map()]}`: List of user objects if successful + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec find_users_by_email(String.t()) :: {:ok, [map()]} | :error | {:error, String.t()} + def find_users_by_email(email) do + email + |> URI.encode() + |> auth0_module().find_users_by_email_query() + |> find_users("Failed to search user by email") + end + + @doc """ + Searches for Auth0 users based on a provided query string. + + Obtains an M2M JWT token, creates an OAuth client, and sends a request to the Auth0 + Users API with the provided query. Handles any errors through the common error + handling mechanism. + + ## Parameters + - `q`: The query string for searching users in Auth0 + - `error_message`: Custom error message for logging (defaults to "Failed to find users") + + ## Returns + - `{:ok, [map()]}`: List of user objects if successful + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec find_users(String.t(), String.t()) :: {:ok, [map()]} | :error | {:error, String.t()} + def find_users(q, error_message \\ "Failed to find users") do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + {:ok, %OAuth2.Response{status_code: 200, body: users}} when is_list(users) <- + Client.get(client, @users_path, [], params: [q: q]) do + {:ok, users} + else + error -> handle_common_errors(error, error_message) + end + end + + @doc """ + Sends a one-time password (OTP) to the specified email address. + + Initiates the Auth0 passwordless authentication flow by requesting an OTP code be + sent to the user's email. Includes the client's IP address in the request for audit + and security purposes. + + ## Parameters + - `email`: The email address to which the OTP will be sent + - `ip`: The IP address of the client requesting the OTP + + ## Returns + - `:ok`: If the OTP was successfully sent + - `:error`: If there was an error sending the OTP + - `{:format, :email}`: If the email format is invalid + """ + @spec send_otp(String.t(), String.t()) :: :ok | :error | {:format, :email} + def send_otp(email, ip) do + client = OAuth.client() + + body = + %{ + email: email, + connection: :email, + send: :code + } + |> put_client_id_and_secret() + + headers = [{"auth0-forwarded-for", ip} | @json_content_type] + + case Client.post(client, "/passwordless/start", body, headers) do + {:ok, %OAuth2.Response{status_code: 200}} -> + :ok + + {:error, %OAuth2.Response{status_code: 400, body: %{"error" => "bad.email"}}} -> + {:format, :email} + + other -> + Logger.error("Error while sending otp: ", inspect(other)) + + :error + end + end + + @doc """ + Processes an existing Auth0 user and manages OTP authentication flow. + + Creates an auth structure from the user, finds the corresponding identity record, + and handles the identity by either sending an OTP or checking the resend interval. + + ## Parameters + - `user`: The Auth0 user map containing user information + - `email`: The email address of the user + - `ip`: The IP address of the client making the request + + ## Returns + - `:ok`: If OTP was successfully sent + - `:error`: If there was an error in the process + - `{:interval, integer()}`: If OTP was recently sent and the resend interval hasn't elapsed + """ + @spec handle_existing_user(map(), String.t(), String.t()) :: :ok | :error | {:interval, integer()} | {:format, :email} + def handle_existing_user(user, email, ip) do + user + |> create_auth() + |> Identity.find_identity() + |> handle_identity(email, ip) + end + + @doc """ + Validates a one-time password (OTP) for the specified email. + + Attempts to authenticate with Auth0 using the provided email and OTP code. Includes + the client's IP address for audit and security purposes. Handles various error + scenarios with specific error messages. + + ## Parameters + - `email`: The email address associated with the OTP + - `otp`: The one-time password code to validate + - `ip`: The IP address of the client submitting the OTP + + ## Returns + - `{:ok, AccessToken.t()}`: Access token if OTP validation succeeds + - `{:error, "Wrong verification code."}`: If the OTP is incorrect + - `{:error, "Max attempts reached. Please resend code."}`: If maximum attempt limit exceeded + - `{:error, String.t()}`: For other known errors with descriptive messages + - `:error`: For unspecified errors + """ + @spec confirm_otp(String.t(), String.t(), String.t()) :: {:ok, AccessToken.t()} | :error | {:error, String.t()} + def confirm_otp(email, otp, ip) do + client = OAuth.client() + + body = + email + |> auth0_module().confirm_otp_body(otp) + |> put_client_id_and_secret() + + headers = [{"auth0-forwarded-for", ip} | @json_content_type] + + case Client.post(client, "/oauth/token", body, headers) do + {:ok, %OAuth2.Response{status_code: 200, body: body}} -> + {:ok, AccessToken.new(body)} + + {:error, + %OAuth2.Response{ + status_code: 403, + body: + %{ + "error" => "unauthorized_client", + "error_description" => @disabled_otp_error_description, + "error_uri" => "https://auth0.com/docs/clients/client-grant-types" + } = body + }} -> + Logger.error("Need to enable OTP: #{inspect(body)}") + {:error, @misconfiguration_detected} + + {:error, + %OAuth2.Response{ + status_code: 403, + body: %{"error" => "invalid_grant", "error_description" => "Wrong email or verification code."} + }} -> + {:error, "Wrong verification code."} + + {:error, + %OAuth2.Response{ + status_code: 403, + body: %{ + "error" => "invalid_grant", + "error_description" => "You've reached the maximum number of attempts. Please try to login again." + } + }} -> + {:error, "Max attempts reached. Please resend code."} + + other -> + Logger.error("Error while confirming otp: #{inspect(other)}") + + :error + end + end + + @doc """ + Extracts user information from an Auth0 access token. + + Retrieves the user claims from the ID token within the access token. Handles two + scenarios: tokens with valid ID tokens, and tokens missing ID tokens. + + ## Parameters + - `token`: The OAuth2 AccessToken struct containing Auth0 authentication information + + ## Returns + - `{:ok, map()}`: User claims if successfully extracted from the token + - `:error`: If there was an error decoding the claims + - `{:error, String.t()}`: If the token doesn't contain an ID token + """ + @spec get_user_from_token(AccessToken.t()) :: {:ok, map()} | :error | {:error, String.t()} + def get_user_from_token(%AccessToken{other_params: %{"id_token" => token}}) do + case Joken.peek_claims(token) do + {:ok, %{"sub" => _} = user} -> + {:ok, user} + + error -> + Logger.error("Error while peeking claims from token: #{inspect(error)}") + :error + end + end + + def get_user_from_token(token) do + Logger.error("No id_token in token: #{inspect(Map.update(token, :access_token, "xxx", fn _ -> "xxx" end))}") + + {:error, @misconfiguration_detected} + end + + @doc """ + Links an email-based identity to a user who doesn't have an email. + + Delegates to the appropriate Auth0 module implementation (Legacy or Migrated) to + associate an email address with a user. The implementation varies depending on + the Auth0 configuration: + - Legacy: Links the users and updates the email for the user without email + - Migrated: Transfers metadata between users and updates both user records + + ## Parameters + - `user_id_without_email`: The ID of the user who doesn't have an email + - `user_id_with_email`: The ID of the user who has the email to be linked + - `email`: The email address to link + + ## Returns + - `{:ok, map()}`: User information if linking was successful + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec link_email(String.t(), String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def link_email(user_id_without_email, user_id_with_email, email) do + auth0_module().link_email(user_id_without_email, user_id_with_email, email) + end + + @doc """ + Creates a Ueberauth authentication structure from an Auth0 user. + + Delegates to the appropriate Auth0 module (Legacy or Migrated) to construct a + standardized Ueberauth.Auth struct from an Auth0 user object. The implementation + varies based on the Auth0 configuration: + - Legacy: Uses the user_id directly from the user object + - Migrated: Extracts the user_id from nested metadata within the user object + + ## Parameters + - `user`: Map containing Auth0 user information + + ## Returns + - `Ueberauth.Auth.t()`: A structured authentication object containing user information + """ + @spec create_auth(map()) :: Ueberauth.Auth.t() + def create_auth(user) do + auth0_module().create_auth(user) + end + + @doc """ + Retrieves Auth0 user information by user ID. + + Obtains an M2M JWT token, creates an OAuth client, and sends a request to the Auth0 + Users API with the specified user ID. Returns the user information or handles errors + appropriately. + + ## Parameters + - `id`: The Auth0 user ID to retrieve + + ## Returns + - `{:ok, map()}`: User information if found + - `{:error, "User not found"}`: If the user ID doesn't exist + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec get_user_by_id(String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def get_user_by_id(id) do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + {:ok, %OAuth2.Response{status_code: 200, body: %{"user_id" => ^id} = user}} <- + Client.get(client, "#{@users_path}/#{URI.encode(id)}") do + {:ok, user} + else + {:error, %OAuth2.Response{status_code: 404}} -> {:error, "User not found"} + error -> handle_common_errors(error, "Failed to get user by id") + end + end + + @doc """ + Processes an email-based Auth0 user account. + + Delegates to the appropriate Auth0 module based on configuration. The behavior + differs between implementations: + - Legacy: Attempts to link the email user with existing accounts having the same + email address, potentially merging multiple accounts + - Migrated: Updates the user with application-specific metadata if not already + present + + ## Parameters + - `user`: Map containing Auth0 user information + + ## Returns + - `{:ok, map()}`: User information after processing + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec process_email_user(map()) :: {:ok, map()} | :error | {:error, String.t()} + def process_email_user(user) do + auth0_module().process_email_user(user) + end + + @spec handle_not_found_just_created_email_user(map()) :: {:ok, Ueberauth.Auth.t()} + def handle_not_found_just_created_email_user(user_from_token) do + auth0_module().handle_not_found_just_created_email_user(user_from_token) + end + + @doc """ + Ensures a session contains an address hash value. + + For sessions that already have an address hash, returns the unchanged session. + For sessions without an address hash but with a user ID, retrieves the user from + Auth0 and adds the address hash to the session. Uses different retrieval methods + based on Auth0 module configuration: + - Legacy: Retrieves the user directly by ID + - Migrated: Finds the user by querying metadata for the application-specific user ID + + ## Parameters + - `session`: Map containing session information + + ## Returns + - `{:old, map()}`: The original session if it already contains an address hash or if + retrieval fails + - `{:new, map()}`: Updated session with the address hash if retrieval succeeds + """ + @spec update_session_with_address_hash(map()) :: + {:new, %{:address_hash => nil | binary(), optional(any()) => any()}} + | {:old, map()} + def update_session_with_address_hash(%{address_hash: _} = session), do: {:old, session} + + def update_session_with_address_hash(%{uid: user_id} = session) do + case auth0_module().get_user_by_id_from_session(user_id) do + {:ok, user} -> + {:new, Map.put(session, :address_hash, user |> create_auth() |> Identity.address_hash_from_auth())} + + error -> + Logger.error("Error when updating session with address hash: #{inspect(error)}") + {:old, session} + end + end + + @doc """ + Searches for Auth0 users associated with a specific web3 wallet address. + + Constructs a query using the appropriate Auth0 module implementation (Legacy or + Migrated) and searches for users matching the query. The search criteria differ + between implementations: + - Legacy: Searches across multiple fields including user_id and metadata + - Migrated: Searches specifically in the application-scoped metadata + + ## Parameters + - `address`: The web3 wallet address to search for + + ## Returns + - `{:ok, [map()]}`: List of user objects if successful + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec find_users_by_web3_address(String.t()) :: {:ok, [map()]} | :error | {:error, String.t()} + def find_users_by_web3_address(address) do + address + |> auth0_module().find_users_by_web3_address_query() + |> find_users("Failed to find users by address") + end + + @doc """ + Associates a web3 wallet address with an Auth0 user. + + Delegates to the appropriate Auth0 module implementation (Legacy or Migrated) to + update the user's metadata with their web3 wallet address. The implementation + differs based on Auth0 configuration: + - Legacy: Directly updates the user's metadata with the address + - Migrated: First retrieves the user by session ID, then updates the application-scoped + metadata with the address + + ## Parameters + - `user_id`: The Auth0 user ID to update + - `address`: The web3 wallet address to associate with the user + + ## Returns + - `{:ok, map()}`: Updated user information if successful + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec update_user_with_web3_address(String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def update_user_with_web3_address(user_id, address) do + auth0_module().update_user_with_web3_address(user_id, address) + end + + @doc """ + Updates an Auth0 user's information. + + Obtains an M2M JWT token, creates an OAuth client, and sends a PATCH request to the + Auth0 Users API to update the specified user with the provided data. Handles any + errors through the common error handling mechanism. + + ## Parameters + - `user_id`: The Auth0 user ID to update + - `body`: The data to update (usually a map that will be JSON-encoded) + - `error_message`: Custom error message for logging (defaults to "Failed to update user") + + ## Returns + - `{:ok, map()}`: Updated user information if successful + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec update_user(String.t(), map(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def update_user(user_id, body, error_message \\ "Failed to update user") do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + {:ok, %OAuth2.Response{status_code: 200, body: user}} <- + Client.patch(client, "#{@users_path}/#{URI.encode(user_id)}", body, @json_content_type) do + {:ok, user} + else + error -> handle_common_errors(error, error_message) + end + end + + @doc """ + Processes authentication for a web3 wallet address. + + Delegates to the appropriate Auth0 module (Legacy or Migrated) to find an existing + user with the provided address or create a new one. The implementation differs based + on Auth0 configuration: + - Legacy: Finds existing users by address, updates if found without address metadata, + creates a new user if none found, or merges multiple matching users + - Migrated: Finds one user, creates a new user with application-scoped metadata if none + found, or returns an error if multiple users are found + + ## Parameters + - `address`: The web3 wallet address for authentication + - `signature`: The cryptographic signature for authentication + + ## Returns + - `{:ok, map()}`: User information if processing succeeds + - `:error`: If an unspecified error occurs + - `{:error, String.t()}`: If a known error occurs with error message + """ + @spec process_web3_user(String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def process_web3_user(address, signature) do + auth0_module().find_or_create_web3_user(address, signature) + end + + @doc """ + Creates a new Auth0 user with web3 wallet credentials. + + Obtains an M2M JWT token, creates an OAuth client, and sends a request to the Auth0 + Users API to create a new user with the web3 wallet address as username and the + signature as password. The user is created without an email address, which requires + specific Auth0 connection configuration. + + ## Parameters + - `address`: The web3 wallet address to use as username + - `signature`: The cryptographic signature to use as password + - `metadata`: Additional metadata to store with the user + + ## Returns + - `{:ok, map()}`: Created user information if successful + - `{:error, String.t()}`: If a configuration issue is detected + - `:error`: If an unspecified error occurs + """ + @spec create_web3_user(String.t(), String.t(), map()) :: {:ok, map()} | :error | {:error, String.t()} + def create_web3_user(address, signature, metadata) do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + body = %{ + username: address, + password: signature, + email_verified: true, + connection: "Username-Password-Authentication", + user_metadata: metadata + }, + {:ok, %OAuth2.Response{status_code: 201, body: user}} <- + Client.post(client, @users_path, body, @json_content_type) do + {:ok, user} + else + {:error, + %OAuth2.Response{ + status_code: 400, + body: + %{ + "errorCode" => "invalid_body", + "message" => "Payload validation error: 'Missing required property: email'." + } = body + }} -> + Logger.error([ + "Failed to create web3 user. Need to allow users without email in Username-Password-Authentication connection: ", + inspect(body) + ]) + + {:error, @misconfiguration_detected} + + error -> + handle_common_errors(error, "Failed to create web3 user") + end + end + + @doc """ + Standardizes error handling for Auth0 API requests. + + Processes common error patterns from Auth0 API responses, logs appropriate error + messages, and returns standardized error formats. Specifically handles missing JWT + tokens and insufficient scope errors with user-friendly messages. + + ## Parameters + - `error`: The error value or structure to handle + - `error_msg`: A descriptive message for logging that explains the context of the error + + ## Returns + - `{:error, String.t()}`: For known error types with a user-friendly message + - `:error`: For unspecified errors + """ + @spec handle_common_errors(any(), String.t()) :: :error | {:error, String.t()} + def handle_common_errors(error, error_msg) do + case error do + nil -> + Logger.error("Failed to get M2M JWT") + {:error, @misconfiguration_detected} + + {:error, %OAuth2.Response{status_code: 403, body: %{"errorCode" => "insufficient_scope"} = body}} -> + Logger.error(["#{error_msg}. Insufficient scope: ", inspect(body)]) + {:error, @misconfiguration_detected} + + other -> + Logger.error(["#{error_msg}: ", inspect(other)]) + :error + end + end + + defp auth0_module do + if Application.get_env(:ueberauth, OAuth)[:auth0_application_id] === "" do + Legacy + else + Migrated + end + end + + defp put_client_id_and_secret(map) do + auth0_config = Application.get_env(:ueberauth, OAuth) + + Map.merge( + map, + %{ + client_id: auth0_config[:client_id], + client_secret: auth0_config[:client_secret] + } + ) + end + + defp handle_identity(nil, email, ip), do: send_otp(email, ip) + + defp handle_identity(%Identity{otp_sent_at: otp_sent_at} = identity, email, ip) do + otp_resend_interval = Application.get_env(:explorer, Account)[:otp_resend_interval] + + case Helper.check_time_interval(otp_sent_at, otp_resend_interval) do + true -> + identity |> Identity.changeset(%{otp_sent_at: DateTime.utc_now()}) |> Repo.account_repo().update() + + send_otp(email, ip) + + interval -> + {:interval, interval} + end + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/auth0/legacy.ex b/apps/explorer/lib/explorer/third_party_integrations/auth0/legacy.ex new file mode 100644 index 000000000000..6c8496843865 --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/auth0/legacy.ex @@ -0,0 +1,254 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Auth0.Legacy do + @moduledoc """ + Module for internal usage, not supposed to be used directly, if + you want to interact with Auth0, use `Explorer.ThirdPartyIntegrations.Auth0`. + + Provides Auth0 authentication for legacy Auth0 configuration. + + This module implements Auth0 authentication functionality for the legacy Auth0 + configuration where the application identifier is not set. It handles user + management operations including email and web3 authentication flows, user + linking, and identity merging. + + The module specializes in managing Auth0 users with different identity + providers, linking multiple identities to a single user, and handling user + metadata for both email and web3-based authentication. + """ + + require Logger + + alias Explorer.Account + alias Explorer.Account.Identity + alias Explorer.ThirdPartyIntegrations.Auth0 + alias Explorer.ThirdPartyIntegrations.Auth0.Internal + alias OAuth2.Client + alias Ueberauth.Auth + alias Ueberauth.Strategy.Auth0, as: UeberauthAuth0 + alias Ueberauth.Strategy.Auth0.OAuth + + @spec redis_key() :: String.t() + def redis_key do + client_id = Application.get_env(:ueberauth, OAuth)[:client_id] + + client_id <> "auth0:legacy" + end + + @spec find_users_by_email_query(String.t()) :: String.t() + def find_users_by_email_query(encoded_email) do + ~s(email:"#{encoded_email}" OR user_metadata.email:"#{encoded_email}") + end + + @spec link_email(String.t(), String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def link_email(user_id_without_email, user_id_with_email, email) do + with :ok <- link_users(user_id_without_email, user_id_with_email, "email") do + update_user_email(user_id_without_email, email) + end + end + + @spec create_auth(map()) :: Auth.t() + def create_auth(user) do + conn_stub = %{private: %{auth0_user: user, auth0_token: nil}} + + %Auth{ + uid: user["user_id"], + provider: :auth0, + strategy: UeberauthAuth0, + info: UeberauthAuth0.info(conn_stub), + credentials: %Auth.Credentials{}, + extra: UeberauthAuth0.extra(conn_stub) + } + end + + @spec confirm_otp_body(String.t(), String.t()) :: map() + def confirm_otp_body(email, otp) do + %{ + username: email, + otp: otp, + realm: :email, + grant_type: :"http://auth0.com/oauth/grant-type/passwordless/otp" + } + end + + @spec process_email_user(map()) :: {:ok, map()} | :error | {:error, String.t()} + def process_email_user(user) do + maybe_link_email(user) + end + + @spec handle_not_found_just_created_email_user(map()) :: {:ok, Auth.t()} + def handle_not_found_just_created_email_user(%{"sub" => user_id} = user_from_token) do + {:ok, user_from_token |> Map.put("user_id", user_id) |> Internal.create_auth()} + end + + @spec get_user_by_id_from_session(String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def get_user_by_id_from_session(user_id) do + Internal.get_user_by_id(user_id) + end + + @spec find_users_by_web3_address_query(String.t()) :: String.t() + def find_users_by_web3_address_query(address) do + ~s|user_id:*siwe*#{address} OR user_id:*Passkey*#{address} OR user_metadata.web3_address_hash:"#{address}" OR (user_id:*Passkey* AND nickname:"#{address}")| + end + + @spec update_user_with_web3_address(String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def update_user_with_web3_address(user_id, address) do + Internal.update_user( + user_id, + %{"user_metadata" => %{"web3_address_hash" => address}}, + "Failed to update user address" + ) + end + + @spec find_or_create_web3_user(String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def find_or_create_web3_user(address, signature) do + case Internal.find_users_by_web3_address(address) do + {:ok, [user]} -> + if same_web3_address?(user, address) do + {:ok, user} + else + update_user_with_web3_address(user["user_id"], address) + end + + {:ok, []} -> + Internal.create_web3_user(address, signature, %{web3_address_hash: address}) + + {:ok, users} when is_list(users) and length(users) > 1 -> + merge_web3_users(users) + + other -> + other + end + end + + defp link_users(primary_user_id, secondary_identity_id, provider) do + with token when is_binary(token) <- Auth0.get_m2m_jwt(), + client = OAuth.client(token: token), + body = %{ + provider: provider, + user_id: secondary_identity_id + }, + {:ok, %OAuth2.Response{status_code: 201}} <- + Client.post( + client, + "#{Internal.users_path()}/#{URI.encode(primary_user_id)}/identities", + body, + Internal.json_content_type() + ) do + :ok + else + error -> Internal.handle_common_errors(error, "Failed to link accounts") + end + end + + defp update_user_email(user_id, email) do + Internal.update_user(user_id, %{"user_metadata" => %{"email" => email}}, "Failed to update user email") + end + + defp maybe_link_email(%{"email" => email, "user_id" => "email|" <> identity_id = user_id} = user) do + case Internal.find_users( + ~s(email:"#{email}" AND NOT user_id:"#{user_id}"), + "Failed to find legacy users by email" + ) do + {:ok, []} -> + {:ok, user} + + {:ok, [%{"user_id" => primary_user_id} = user]} -> + link_users(primary_user_id, identity_id, "email") + maybe_verify_email(user) + {:ok, user} + + {:ok, users} -> + merge_email_users(users, identity_id, "email") + + error -> + error + end + end + + defp maybe_link_email(user) do + {:ok, user} + end + + defp maybe_verify_email(%{"email_verified" => false, "user_id" => user_id}) do + with {:ok, _} <- + Internal.update_user(user_id, %{"email_verified" => true}, "Failed to patch email_verified to true") do + :ok + end + end + + defp maybe_verify_email(_), do: :ok + + defp same_web3_address?(%{"user_metadata" => %{"web3_address_hash" => stored_address}}, address) + when is_binary(stored_address) do + String.downcase(stored_address) == String.downcase(address) + end + + defp same_web3_address?(_, _), do: false + + defp merge_email_users([primary_user | _] = users, identity_id_to_link, provider_for_linking) do + identity_map = get_identity_map(users) + + users_map = users |> Enum.map(&{&1["user_id"], &1}) |> Map.new() + + case users |> Enum.map(&identity_map[&1["user_id"]]) |> Enum.reject(&is_nil(&1)) |> Account.merge() do + {{:ok, 0}, nil} -> + link_users(primary_user["user_id"], identity_id_to_link, provider_for_linking) + maybe_verify_email(primary_user) + {:ok, primary_user} + + {{:ok, _}, primary_identity} -> + link_users(primary_identity.uid, identity_id_to_link, provider_for_linking) + maybe_verify_email(users_map[primary_identity.uid]) + {:ok, users_map[primary_identity.uid]} + + error -> + Logger.error(["Error while merging users with the same email: ", inspect(error)]) + :error + end + end + + defp merge_web3_users([primary_user | _] = users) do + identity_map = get_identity_map(users) + + users_map = users |> Enum.map(&{&1["user_id"], &1}) |> Map.new() + + users + |> Enum.map(&identity_map[&1["user_id"]]) + |> Enum.reject(&is_nil(&1)) + |> Account.merge() + |> case do + {{:ok, 0}, nil} -> + if !match?(%{"user_metadata" => %{"web3_address_hash" => _}}, primary_user) do + update_user_with_web3_address( + primary_user["user_id"], + primary_user |> Internal.create_auth() |> Identity.address_hash_from_auth() + ) + end + + {:ok, primary_user} + + {{:ok, _}, primary_identity} -> + primary_user_from_db = users_map[primary_identity.uid] + + if !match?(%{"user_metadata" => %{"web3_address_hash" => _}}, primary_user_from_db) do + update_user_with_web3_address( + primary_user_from_db["user_id"], + primary_user_from_db |> Internal.create_auth() |> Identity.address_hash_from_auth() + ) + end + + {:ok, primary_user_from_db} + + error -> + Logger.error(["Error while merging users with the same address: ", inspect(error)]) + :error + end + end + + defp get_identity_map(users) do + users + |> Enum.map(& &1["user_id"]) + |> Identity.find_identities() + |> Map.new(&{&1.uid, &1}) + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/auth0/migrated.ex b/apps/explorer/lib/explorer/third_party_integrations/auth0/migrated.ex new file mode 100644 index 000000000000..c929be2c4126 --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/auth0/migrated.ex @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Auth0.Migrated do + @moduledoc """ + Module for internal usage, not supposed to be used directly, if + you want to interact with Auth0, use `Explorer.ThirdPartyIntegrations.Auth0`. + + Provides Auth0 authentication for migrated Auth0 configuration. + + This module implements Auth0 authentication functionality for the migrated Auth0 + configuration where the application identifier is set. It handles user management + within application-scoped metadata, providing namespaced storage of user + attributes. + + The module supports both email and web3 authentication flows, focusing on + managing user identity within an application-specific context. It uses nested + metadata structures to prevent conflicts between different applications using + the same Auth0 tenant. + """ + + use Utils.RuntimeEnvHelper, + auth0_application_identifier: [:ueberauth, [Ueberauth.Strategy.Auth0.OAuth, :auth0_application_id]] + + alias Explorer.ThirdPartyIntegrations.Auth0.Internal + alias Ueberauth.Auth + alias Ueberauth.Strategy.Auth0, as: UeberauthAuth0 + + @spec redis_key() :: String.t() + def redis_key do + client_id = Application.get_env(:ueberauth, Ueberauth.Strategy.Auth0.OAuth)[:client_id] + + client_id <> "auth0:migrated" + end + + @spec find_users_by_email_query(String.t()) :: String.t() + def find_users_by_email_query(encoded_email) do + ~s(email:"#{encoded_email}") + end + + @spec link_email(String.t(), String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def link_email(session_user_id_without_email, user_id_with_email, _email) do + auth0_application_id = auth0_application_identifier() + + with {:ok, + %{ + "user_id" => user_id_without_email, + "user_metadata" => %{^auth0_application_id => user_without_email_metadata} + }} <- + get_user_by_id_from_session(session_user_id_without_email), + {:ok, user_with_email} <- + Internal.update_user( + user_id_with_email, + user_without_email_metadata, + "Failed to link email on updating user with email metadata step" + ), + {:ok, _} <- + Internal.update_user( + user_id_without_email, + %{"user_metadata" => %{auth0_application_id => %{}}}, + "Failed to link email on updating user without email metadata" + ) do + {:ok, user_with_email} + end + end + + @spec create_auth(map()) :: Auth.t() + def create_auth(user) do + uid = user["user_metadata"][auth0_application_identifier()]["user_id"] + user = Map.update(user, "user_metadata", nil, fn user_metadata -> user_metadata[auth0_application_identifier()] end) + conn_stub = %{private: %{auth0_user: user, auth0_token: nil}} + + %Auth{ + uid: uid, + provider: :auth0, + strategy: UeberauthAuth0, + info: UeberauthAuth0.info(conn_stub), + credentials: %Auth.Credentials{}, + extra: UeberauthAuth0.extra(conn_stub) + } + end + + @spec confirm_otp_body(String.t(), String.t()) :: map() + def confirm_otp_body(email, otp) do + %{ + username: email, + otp: otp, + realm: :email, + grant_type: :"http://auth0.com/oauth/grant-type/passwordless/otp", + chain_slug: auth0_application_identifier() + } + end + + @spec process_email_user(map()) :: {:ok, map()} | :error | {:error, String.t()} + def process_email_user(user) do + if user["user_metadata"][auth0_application_identifier()] do + {:ok, user} + else + Internal.update_user( + user["user_id"], + %{ + "user_metadata" => %{ + auth0_application_identifier() => Map.take(user, ["user_id", "name", "nickname", "picture"]) + } + }, + "Failed to update email user metadata" + ) + end + end + + @spec handle_not_found_just_created_email_user(map()) :: {:ok, Auth.t()} | :error | {:error, String.t()} + def handle_not_found_just_created_email_user(%{"sub" => user_id} = user_from_token) do + with {:ok, user} <- + Internal.update_user( + user_id, + %{ + "user_metadata" => %{ + auth0_application_identifier() => Map.take(user_from_token, ["sub", "name", "nickname", "picture"]) + } + }, + "Failed to update just created email user metadata" + ) do + {:ok, create_auth(user)} + end + end + + @spec get_user_by_id_from_session(String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def get_user_by_id_from_session(user_id) do + case Internal.find_users( + ~s(user_metadata.#{auth0_application_identifier()}.user_id:"#{user_id}"), + "Failed to find user by id from session" + ) do + {:ok, [user]} -> {:ok, user} + {:ok, []} -> {:error, "User with id from session not found"} + {:ok, _} -> {:error, "Multiple users with the same id in metadata found"} + error -> error + end + end + + @spec find_users_by_web3_address_query(String.t()) :: String.t() + def find_users_by_web3_address_query(address) do + ~s(user_metadata.#{auth0_application_identifier()}.web3_address_hash:"#{address}") + end + + @spec update_user_with_web3_address(String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def update_user_with_web3_address(session_user_id, address) do + auth0_application_id = auth0_application_identifier() + + with {:ok, %{"user_id" => user_id, "user_metadata" => %{^auth0_application_id => user_metadata}}} <- + get_user_by_id_from_session(session_user_id) do + Internal.update_user( + user_id, + %{"user_metadata" => %{auth0_application_id => Map.put(user_metadata, "web3_address_hash", address)}}, + "Failed to update user with web3 address" + ) + end + end + + @spec find_or_create_web3_user(String.t(), String.t()) :: {:ok, map()} | :error | {:error, String.t()} + def find_or_create_web3_user(address, signature) do + case Internal.find_users_by_web3_address(address) do + {:ok, [user]} -> + {:ok, user} + + {:ok, []} -> + create_web3_user(address, signature) + + {:ok, _} -> + {:error, "Multiple users with the same web3 address found"} + + error -> + error + end + end + + defp create_web3_user(address, signature) do + with {:ok, %{"user_id" => user_id} = user} <- Internal.create_web3_user(address, signature, %{}) do + Internal.update_user( + user_id, + %{ + "user_metadata" => %{ + auth0_application_identifier() => + Map.merge( + %{"web3_address_hash" => address}, + Map.take(user, ["user_id", "name", "nickname", "picture"]) + ) + } + }, + "Failed to update user with web3 address" + ) + end + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/dynamic.ex b/apps/explorer/lib/explorer/third_party_integrations/dynamic.ex new file mode 100644 index 000000000000..6e62416e575f --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/dynamic.ex @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Dynamic do + @moduledoc """ + Provides integration with Dynamic for JWT-based user authentication. + + This module handles authentication by verifying JWT tokens issued by Dynamic + and transforming their claims into `Ueberauth.Auth` structs. It extracts user + identity information from token claims, including data from OAuth and + blockchain verified credentials. + + Dynamic is an authentication provider that supports multiple credential + formats, including OAuth accounts and blockchain wallet addresses. + """ + + alias Explorer.ThirdPartyIntegrations.Dynamic.Token + alias Ueberauth.Auth + alias Ueberauth.Auth.{Extra, Info} + + require Logger + + @spec enabled? :: boolean() + def enabled? do + Application.get_env(:explorer, __MODULE__)[:enabled] || false + end + + @doc """ + Authenticates a user by verifying a JWT token and extracting identity information from its claims. + + The function validates the provided JWT token and, upon successful + verification, constructs an authentication struct containing user identity + data. It extracts information from the token's claims including user ID, + email, name, and verified credentials (OAuth and blockchain). + + If the token's scopes include `"requiresAdditionalAuth"`, authentication + is rejected, requiring further verification steps. + + ## Parameters + - `token`: A JWT bearer token string to verify and extract claims from. + + ## Returns + - `{:ok, Auth.t()}` if the token is valid and no additional authentication + is required. + - `{:error, String.t()}` if token verification fails or additional + authentication is required. + """ + @spec get_auth_from_token(String.t()) :: {:ok, Auth.t()} | {:error, String.t()} | {:enabled, false} + def get_auth_from_token(token) do + with {:enabled, true} <- {:enabled, enabled?()}, + {:ok, claims} <- Token.verify_and_validate(token) do + create_auth(claims) + else + {:error, reason} -> + Logger.error("Error while verifying token: #{inspect(reason)}") + {:error, "Invalid token"} + + not_enabled -> + not_enabled + end + end + + defp create_auth(claims) do + if claims["scopes"] && "requiresAdditionalAuth" in claims["scopes"] do + {:error, "Additional verification required"} + else + {:ok, do_create_auth(claims)} + end + end + + defp do_create_auth(claims) do + oauth = + Enum.find(claims["verified_credentials"] || [], fn + %{"format" => "oauth"} -> true + _ -> false + end) + + blockchain = + Enum.find(claims["verified_credentials"] || [], fn + %{"format" => "blockchain"} -> true + _ -> false + end) + + %Auth{ + uid: claims["metadata"]["auth0UserId"] || claims["sub"], + provider: :dynamic, + info: %Info{ + email: claims["email"], + name: (oauth && oauth["oauth_display_name"]) || claims["name"], + nickname: claims["username"], + image: oauth && List.first(oauth["oauth_account_photos"] || []) + }, + extra: %Extra{raw_info: %{address_hash: blockchain && blockchain["address"]}} + } + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/dynamic/strategy.ex b/apps/explorer/lib/explorer/third_party_integrations/dynamic/strategy.ex new file mode 100644 index 000000000000..5a216892b2b5 --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/dynamic/strategy.ex @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Dynamic.Strategy do + @moduledoc """ + JWKS fetching strategy for Dynamic + """ + use GenServer, restart: :transient + + alias Explorer.ThirdPartyIntegrations.Dynamic + alias JokenJwks.{DefaultStrategyTemplate, SignerMatchStrategy} + + @behaviour SignerMatchStrategy + + def init_opts(opts) do + url = Application.get_env(:explorer, Dynamic)[:url] + Keyword.merge(opts, jwks_url: url) + end + + @impl SignerMatchStrategy + def match_signer_for_kid(kid, opts), + do: DefaultStrategyTemplate.match_signer_for_kid(__MODULE__, kid, opts) + + @doc false + def start_link(opts), do: DefaultStrategyTemplate.start_link(__MODULE__, opts) + + # Server (callbacks) + @doc false + @impl GenServer + def init(opts) do + # with this trick first fetch is done immediately and asynchronously, no application startup delay + # and no `:time_interval` time without keys after application startup + case DefaultStrategyTemplate.init(__MODULE__, opts |> Keyword.put(:time_interval, 0)) do + {:ok, state} -> {:ok, state |> Keyword.put(:time_interval, opts[:time_interval] || 15 * 1_000)} + other -> other + end + end + + @doc false + @impl GenServer + def handle_info(:check_fetch, state) do + DefaultStrategyTemplate.check_fetch(__MODULE__, state[:jwks_url], state) + DefaultStrategyTemplate.schedule_check_fetch(__MODULE__, state[:time_interval]) + + {:noreply, state} + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/dynamic/token.ex b/apps/explorer/lib/explorer/third_party_integrations/dynamic/token.ex new file mode 100644 index 000000000000..118555df6389 --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/dynamic/token.ex @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Dynamic.Token do + @moduledoc """ + JWT token verification for Dynamic.xyz + """ + use Joken.Config + + alias Explorer.ThirdPartyIntegrations.Dynamic + alias Explorer.ThirdPartyIntegrations.Dynamic.Strategy + + add_hook(JokenJwks, strategy: Strategy) + + @impl Joken.Config + def token_config do + env_id = Application.get_env(:explorer, Dynamic)[:env_id] + + [skip: [:aud]] + |> default_claims() + |> add_claim("iss", nil, fn iss -> + iss == "app.dynamicauth.com/#{env_id}" + end) + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex b/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex new file mode 100644 index 000000000000..cb6aab51ee2b --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/keycloak.ex @@ -0,0 +1,492 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.Keycloak do + @moduledoc """ + Keycloak Admin REST API client for user management. + Mirrors Auth0 Management API calls. + """ + + use Utils.RuntimeEnvHelper, + domain: [:explorer, [__MODULE__, :domain]], + realm: [:explorer, [__MODULE__, :realm]], + client_id: [:explorer, [__MODULE__, :client_id]], + client_secret: [:explorer, [__MODULE__, :client_secret]], + otp_template: [:explorer, [Explorer.Account, :sendgrid, :otp_template]], + otp_sender: [:explorer, [Explorer.Account, :sendgrid, :sender]], + email_webhook_url: [:explorer, [__MODULE__, :email_webhook_url]] + + import Bamboo.{Email, SendGridHelper} + + alias Explorer.Account.Authentication + alias Explorer.{Helper, HttpClient, Mailer, Vault} + alias Ueberauth.Auth + + require Logger + + @behaviour Authentication + + @json_headers [{"content-type", "application/json"}] + + @otp_length 6 + @otp_ttl_seconds 300 + @max_otp_attempts 3 + + @spec enabled?() :: boolean() + def enabled? do + Enum.all?([domain(), realm(), client_id(), client_secret()], &(&1 not in [nil, ""])) + end + + @impl Authentication + def send_otp(email, _ip) do + otp = generate_otp() + + with :ok <- store_otp(email, otp), + :ok <- deliver_otp_email(email, otp) do + :ok + else + error -> + Logger.error("Error while sending otp: #{inspect(error)}") + :error + end + end + + @impl Authentication + def confirm_otp_and_get_auth(email, otp, _ip) do + case verify_otp(email, otp) do + :ok -> find_or_create_email_user(email) + :not_found -> {:error, "Verification code has expired."} + error -> error + end + end + + @impl Authentication + def send_otp_for_linking(email, ip) do + case find_users_by_email(email) do + {:ok, []} -> send_otp(email, ip) + {:ok, [_ | _]} -> {:error, "Account with this email already exists"} + error -> error + end + end + + @impl Authentication + def link_email(%{uid: user_id, email: nil}, email, otp, _ip) do + case verify_otp(email, otp) do + :ok -> + with :ok <- + "#{users_path()}/#{user_id}" + |> admin_put(%{email: email, emailVerified: true}) + |> handle_update("Failed to link email to user"), + {:ok, user} <- get_user(user_id) do + send_registration_webhook(email) + {:ok, create_auth(user)} + end + + :not_found -> + {:error, "Verification code has expired."} + + :error -> + :error + + {:error, _} = error -> + error + end + end + + def link_email(%{uid: _user_id, email: _}, _email, _otp, _ip) do + {:error, "User already has an email linked"} + end + + @impl Authentication + def find_or_create_web3_user(address_hash, _signature) do + case find_users_by_address(address_hash) do + {:ok, []} -> + with {:ok, user_id} <- create_web3_user(address_hash), + {:ok, user} <- get_user(user_id) do + {:ok, create_auth(user, address_hash)} + end + + {:ok, [user]} -> + {:ok, create_auth(user, address_hash)} + + {:ok, _} -> + {:error, "Multiple users with the same address found"} + + error -> + error + end + end + + @impl Authentication + def link_address(user_id, address_hash) do + case find_users_by_address(address_hash) do + {:ok, []} -> + link_address_to_user(user_id, address_hash) + + {:ok, _} -> + {:error, "Account with this address already exists"} + + error -> + error + end + end + + defp find_or_create_email_user(email) do + case find_users_by_email(email) do + {:ok, []} -> + with {:ok, user_id} <- create_email_user(email), + send_registration_webhook(email), + {:ok, user} <- get_user(user_id) do + {:ok, create_auth(user)} + end + + {:ok, [user]} -> + {:ok, create_auth(user)} + + {:ok, _} -> + {:error, "Multiple users with the same email found"} + + error -> + error + end + end + + @doc false + def find_users_by_email(email) do + case admin_get(users_path(), %{email: email, exact: true}) do + {:ok, %{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + {:ok, %{status_code: 404}} -> {:ok, []} + error -> handle_error(error, "Failed to search user by email") + end + end + + @doc false + def find_users_by_address(address) do + case admin_get(users_path(), %{q: "address:#{String.downcase(address)}"}) do + {:ok, %{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + {:ok, %{status_code: 404}} -> {:ok, []} + error -> handle_error(error, "Failed to search user by address") + end + end + + defp create_email_user(email) do + create_user(%{ + username: email, + email: email, + emailVerified: true, + enabled: true + }) + end + + defp create_web3_user(address_hash) do + create_user(%{ + username: String.downcase(address_hash), + enabled: true, + attributes: %{address: [String.downcase(address_hash)]} + }) + end + + @doc false + def create_user(body) do + with {:ok, %{status_code: 201, headers: headers}} <- admin_post(users_path(), body), + {:location_id, location_id, _headers} when not is_nil(location_id) <- + {:location_id, extract_location_id(headers), headers} do + {:ok, location_id} + else + {:location_id, nil, headers} -> + Logger.error("Failed to extract user ID from Keycloak response headers: #{inspect(headers)}") + :error + + {:ok, %{status_code: 409}} -> + {:error, "User already exists"} + + error -> + handle_error(error, "Failed to create user") + end + end + + defp link_address_to_user(user_id, address_hash) do + with {:ok, user} <- get_user(user_id), + new_attributes = %{"address" => [String.downcase(address_hash)]}, + merged = + Map.update(user, "attributes", new_attributes, fn attributes -> + Map.merge(attributes, new_attributes) + end), + :ok <- + "#{users_path()}/#{user_id}" |> admin_put(merged) |> handle_update("Failed to link address to user"), + {:ok, user} <- get_user(user_id) do + {:ok, create_auth(user, address_hash)} + end + end + + @doc false + def get_user(user_id) do + case admin_get("#{users_path()}/#{user_id}") do + {:ok, %{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} + {:ok, %{status_code: 404}} -> {:error, "User not found"} + error -> handle_error(error, "Failed to get user") + end + end + + @doc false + def update_user(user_id, body) do + "#{users_path()}/#{user_id}" |> admin_put(body) |> handle_update("Failed to update user") + end + + defp admin_get(path, params \\ %{}) do + with {:ok, token} <- get_admin_token() do + HttpClient.get( + build_url(path), + auth_headers(token) ++ @json_headers, + params: params + ) + end + end + + defp admin_post(path, body) do + with {:ok, token} <- get_admin_token() do + HttpClient.post( + build_url(path), + Jason.encode!(body), + auth_headers(token) ++ @json_headers + ) + end + end + + defp admin_put(path, body) do + with {:ok, token} <- get_admin_token() do + HttpClient.request( + :put, + build_url(path), + auth_headers(token) ++ @json_headers, + Jason.encode!(body) + ) + end + end + + defp auth_headers(token), do: [{"authorization", "Bearer #{token}"}] + + defp get_admin_token do + with {:redix, {:ok, token}} when not is_nil(token) <- + {:redix, Redix.command(:redix, ["GET", admin_token_key()])}, + {:vault, {:ok, decrypted_token}} <- {:vault, Vault.decrypt(token)} do + {:ok, decrypted_token} + else + {:redix, _} -> + fetch_and_cache_admin_token() + + {:vault, error} -> + Logger.error("Failed to decrypt admin token from Redis: #{inspect(error)}") + {:error, :decryption_failed} + end + end + + defp fetch_and_cache_admin_token do + url = build_url("/realms/#{URI.encode(realm())}/protocol/openid-connect/token") + + body = + URI.encode_query(%{ + grant_type: "client_credentials", + client_id: client_id(), + client_secret: client_secret() + }) + + headers = [{"content-type", "application/x-www-form-urlencoded"}] + + case HttpClient.post(url, body, headers) do + {:ok, %{status_code: 200, body: resp_body}} -> + case Jason.decode(resp_body) do + {:ok, %{"access_token" => token, "expires_in" => ttl}} -> + Redix.command(:redix, ["SET", admin_token_key(), Vault.encrypt!(token), "EX", ttl - 1]) + {:ok, token} + + _ -> + :error + end + + other -> + Logger.error("Failed to obtain Keycloak admin token: #{inspect(other)}") + :error + end + end + + defp admin_token_key, do: Helper.redis_key("keycloak:#{client_id()}:admin_token") + + defp handle_error({:ok, response}, error_message) do + Logger.error("#{error_message}: status=#{response.status_code} body=#{response.body}") + :error + end + + defp handle_error({:error, reason}, error_message) do + Logger.error("#{error_message}: #{inspect(reason)}") + :error + end + + defp handle_error(:error, error_message) do + Logger.error("#{error_message}: unknown error") + :error + end + + defp handle_update(result, error_message) do + case result do + {:ok, %{status_code: 204}} -> :ok + {:ok, %{status_code: 404}} -> {:error, "User not found"} + {:ok, %{status_code: 409}} -> {:error, "Email already in use by another account"} + error -> handle_error(error, error_message) + end + end + + defp build_url(path) do + domain() + |> URI.parse() + |> URI.append_path(path) + |> URI.to_string() + end + + defp users_path, do: "/admin/realms/#{URI.encode(realm())}/users" + + defp extract_location_id(headers) do + Enum.find_value(headers, fn {key, value} -> + String.downcase(key) == "location" && value |> String.split("/") |> List.last() + end) + end + + defp otp_key(email), do: Helper.redis_key("#{client_id()}:otp:#{String.downcase(email)}") + defp otp_attempts_key(email), do: Helper.redis_key("#{client_id()}:otp_attempts:#{String.downcase(email)}") + + defp store_otp(email, otp) do + case Redix.command(:redix, ["SET", otp_key(email), Vault.encrypt!(otp), "EX", @otp_ttl_seconds]) do + {:ok, "OK"} -> + Redix.command(:redix, ["DEL", otp_attempts_key(email)]) + :ok + + error -> + Logger.error("Failed to store OTP in Redis: #{inspect(error)}") + :error + end + end + + defp verify_otp(email, otp) do + case fetch_otp(email) do + {:ok, ^otp} -> + delete_otp(email) + :ok + + {:ok, _} -> + increment_and_check_attempts(email) + + other -> + other + end + end + + defp fetch_otp(email) do + case Redix.command(:redix, ["GET", otp_key(email)]) do + {:ok, nil} -> + :not_found + + {:ok, value} -> + case Vault.decrypt(value) do + {:ok, otp} -> + {:ok, otp} + + {:error, reason} -> + Logger.error("Failed to decrypt OTP from Redis: #{inspect(reason)}") + :error + end + + {:error, reason} -> + Logger.error("Failed to fetch OTP from Redis: #{inspect(reason)}") + :error + end + end + + defp delete_otp(email) do + Redix.command(:redix, ["DEL", otp_key(email), otp_attempts_key(email)]) + end + + defp increment_and_check_attempts(email) do + key = otp_attempts_key(email) + + case Redix.pipeline(:redix, [["INCR", key], ["EXPIRE", key, @otp_ttl_seconds]]) do + {:ok, [attempts, _]} when attempts >= @max_otp_attempts -> + delete_otp(email) + {:error, "Too many wrong verification code attempts. Please request a new code."} + + {:ok, _} -> + {:error, "Wrong verification code."} + + {:error, reason} -> + Logger.error("Failed to increment OTP attempts in Redis: #{inspect(reason)}") + {:error, "Wrong verification code."} + end + end + + defp generate_otp do + 4 + |> :crypto.strong_rand_bytes() + |> :binary.decode_unsigned() + |> rem(round(:math.pow(10, @otp_length))) + |> Integer.to_string() + |> String.pad_leading(@otp_length, "0") + end + + defp deliver_otp_email(email, otp) do + email + |> compose_otp_email(otp) + |> deliver() + end + + defp compose_otp_email(to, otp) do + email = new_email(from: otp_sender(), to: to) + + email + |> with_template(otp_template()) + |> add_dynamic_field("otp", otp) + |> add_dynamic_field("ttl_minutes", div(@otp_ttl_seconds, 60)) + end + + defp deliver(email) do + case Mailer.deliver_now(email, response: false) do + {:ok, _email} -> + :ok + + {:error, error} -> + Logger.error("Failed to deliver OTP email: #{inspect(error)}") + :error + end + end + + defp send_registration_webhook(email), do: do_send_registration_webhook(email, email_webhook_url()) + + defp do_send_registration_webhook(email, webhook_url) when not is_nil(webhook_url) do + payload = + Jason.encode!(%{ + email: email, + name: email, + labels: [Helper.get_app_host()] + }) + + Task.start(fn -> + case HttpClient.post(webhook_url, payload, @json_headers) do + {:ok, _} -> :ok + {:error, reason} -> Logger.error("Registration webhook failed: #{inspect(reason)}") + end + end) + end + + defp do_send_registration_webhook(_email, nil), do: :ok + + defp create_auth(user, address_hash \\ nil) do + address_hash = address_hash || List.last(user["attributes"]["address"] || []) + + %Auth{ + uid: user["id"], + provider: :keycloak, + info: %Auth.Info{ + email: user["email"], + name: user["firstName"] && user["lastName"] && "#{user["firstName"]} #{user["lastName"]}", + nickname: user["username"] + }, + extra: %Auth.Extra{raw_info: %{address_hash: address_hash}} + } + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex b/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex index bede506841d5..c59fcf6c6eb0 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/noves_fi.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.NovesFi do @moduledoc """ Module for Noves.Fi API integration https://blockscout.noves.fi/swagger/index.html @@ -5,7 +6,7 @@ defmodule Explorer.ThirdPartyIntegrations.NovesFi do require Logger - alias Explorer.Helper + alias Explorer.{Helper, HttpClient} alias Explorer.Utility.Microservice @recv_timeout 60_000 @@ -31,8 +32,8 @@ defmodule Explorer.ThirdPartyIntegrations.NovesFi do conn.query_params |> Map.drop(["hashes"]) - case HTTPoison.post(url, Jason.encode!(hashes), headers, recv_timeout: @recv_timeout, params: prepared_params) do - {:ok, %HTTPoison.Response{status_code: status, body: body}} -> + case HttpClient.post(url, Jason.encode!(hashes), headers, recv_timeout: @recv_timeout, params: prepared_params) do + {:ok, %{status_code: status, body: body}} -> {Helper.decode_json(body), status} {:error, reason} -> @@ -49,8 +50,8 @@ defmodule Explorer.ThirdPartyIntegrations.NovesFi do url_with_params = url <> "?" <> conn.query_string - case HTTPoison.get(url_with_params, headers, recv_timeout: @recv_timeout) do - {:ok, %HTTPoison.Response{status_code: status, body: body}} -> + case HttpClient.get(url_with_params, headers, recv_timeout: @recv_timeout) do + {:ok, %{status_code: status, body: body}} -> {Helper.decode_json(body), status} {:error, reason} -> diff --git a/apps/explorer/lib/explorer/third_party_integrations/solidityscan.ex b/apps/explorer/lib/explorer/third_party_integrations/solidityscan.ex index fc34f0339fee..bf3065ca8961 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/solidityscan.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/solidityscan.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.SolidityScan do @moduledoc """ Module for SolidityScan integration https://apidoc.solidityscan.com/solidityscan-security-api/solidityscan-other-apis/quickscan-api-v1 """ require Logger - alias Explorer.Helper + alias Explorer.{Helper, HttpClient} @recv_timeout 60_000 @@ -18,8 +19,8 @@ defmodule Explorer.ThirdPartyIntegrations.SolidityScan do url = base_url(address_hash_string) if url do - case HTTPoison.get(url, headers, recv_timeout: @recv_timeout) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case HttpClient.get(url, headers, recv_timeout: @recv_timeout) do + {:ok, %{status_code: 200, body: body}} -> Helper.decode_json(body) _ -> diff --git a/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex b/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex index d23491a94cd4..960fa47aef1b 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/sourcify.ex @@ -1,12 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.Sourcify do @moduledoc """ Adapter for contracts verification with https://sourcify.dev/ """ - use Tesla alias Explorer.Helper, as: ExplorerHelper + alias Explorer.HttpClient alias Explorer.SmartContract.{Helper, RustVerifierInterface} - alias HTTPoison.{Error, Response} alias Tesla.Multipart @post_timeout :timer.seconds(30) @@ -152,23 +152,23 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do end end - def http_get_request(url, params) do - request = HTTPoison.get(url, [], params: params) + defp http_get_request(url, params) do + request = HttpClient.get(url, [], params: params) case request do - {:ok, %Response{body: body, status_code: 200}} -> + {:ok, %{body: body, status_code: 200}} -> process_sourcify_response(url, body) - {:ok, %Response{body: body, status_code: status_code}} when status_code in 400..526 -> + {:ok, %{body: body, status_code: status_code}} when status_code in 400..526 -> parse_http_error_response(body) - {:ok, %Response{status_code: status_code}} when status_code in 300..308 -> + {:ok, %{status_code: status_code}} when status_code in 300..308 -> {:error, "Sourcify redirected"} - {:ok, %Response{status_code: _status_code}} -> + {:ok, %{status_code: _status_code}} -> {:error, "Sourcify unexpected status code"} - {:error, %Error{reason: reason}} -> + {:error, reason} -> {:error, reason} _ -> @@ -176,7 +176,7 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do end end - def http_post_request(url, body) do + defp http_post_request(url, body) do request = Tesla.post(url, body) case request do @@ -190,10 +190,10 @@ defmodule Explorer.ThirdPartyIntegrations.Sourcify do def http_post_request_rust_microservice(url, body) do request = - HTTPoison.post(url, Jason.encode!(body), [{"Content-Type", "application/json"}], recv_timeout: @post_timeout) + HttpClient.post(url, Jason.encode!(body), [{"Content-Type", "application/json"}], recv_timeout: @post_timeout) case request do - {:ok, %Response{body: body, status_code: 200}} -> + {:ok, %{body: body, status_code: 200}} -> process_sourcify_response(url, body) _ -> diff --git a/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex index 0e9750d4d0e4..a3fab5dfb465 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy.ex @@ -1,18 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do @moduledoc """ - Module for universal proxying 3dparty API endpoints + A single point of entry for proxying third-party HTTP API endpoints """ - use Tesla - - alias Explorer.Helper + require Logger + alias Explorer.{Helper, HttpClient} @recv_timeout 60_000 - @type api_request :: %{ + @type api_request_params :: %{ url: String.t() | nil, body: String.t(), headers: [{String.t(), String.t()}], method: atom() | nil, + protocol: atom() | nil, platform_config: map() | nil, platform_id: String.t() | nil } @@ -29,9 +30,10 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do } @allowed_methods [:get, :post, :put, :patch, :delete] + @allowed_protocols [:http, :https, :ws, :wss] @reserved_param_types ~w(address chain_id chain_id_dependent) + @unexpected_error "Unexpected error when calling proxied endpoint" - @config_url "https://raw.githubusercontent.com/blockscout/backend-configs/refs/heads/main/universal-proxy-config.json" @cache_name :universal_proxy_config @doc """ @@ -55,7 +57,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do 1. Retrieves the platform-specific configuration based on the `platform_id` key in `proxy_params`. 2. Constructs the request URL using the `base_url` and `base` endpoint path. 3. Parses and applies any API key and endpoint parameters. - 4. Sends the HTTP request using the Tesla library with the specified method, URL, headers, and body. + 4. Sends the HTTP request using `Explorer.HttpClient` with the specified method, URL, headers, and body. ## Returns @@ -75,52 +77,59 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do ## Notes - - The function uses the `Tesla` library with the `Tesla.Adapter.Mint` adapter. + - The function uses the `Explorer.HttpClient` with pre-configured adapter. - A timeout is applied to the request using the `@recv_timeout` module attribute. """ - @spec api_request(map()) :: {any(), integer()} + @spec api_request(map()) :: + {any(), integer()} | {:ok, pid()} | :ignore | {:error, {:already_started, pid()} | (reason :: term())} def api_request(proxy_params) do - %{ - url: url, - body: body, - headers: headers, - method: method, - platform_config: platform_config, - platform_id: platform_id - } = parse_proxy_params(proxy_params) + proxy_params + |> parse_proxy_params() + |> api_request_inner() + end - error_message = - cond do - !platform_config -> - "Platform '#{platform_id}' not found in config or 'platforms' property doesn't exist at all" + defp api_request_inner(%{ + url: url, + body: body, + headers: headers, + method: method, + protocol: protocol + }) + when protocol in [:http, :https] and not is_nil(url) and not is_nil(method) do + with {:invalid_config, false} <- {:invalid_config, is_nil(method)}, + {:ok, %{status_code: status, body: body}} <- + HttpClient.request(method, url, headers, body, recv_timeout: @recv_timeout) do + {Helper.decode_json(body), status} + else + err -> + Logger.error(fn -> ["#{@unexpected_error}: ", inspect(err)] end) + {@unexpected_error, 500} + end + end - is_nil(url) -> - "'base_url' is not defined for platform_id '#{platform_id}' or 'base' endpoint is not defined or 'base' endpoint path is not defined" + defp api_request_inner(%{ + url: url, + method: method, + platform_config: platform_config, + platform_id: platform_id + }) do + error_message = construct_error_message(platform_config, platform_id, url, method) + {"Invalid config: #{error_message}", 422} + end - is_nil(method) -> - "Invalid HTTP request method for platform '#{platform_id}'" + defp construct_error_message(platform_config, platform_id, url, method) do + cond do + !platform_config -> + "Platform '#{platform_id}' not found in config or 'platforms' property doesn't exist at all" - true -> - "Unexpected error" - end + is_nil(url) -> + "'base_url' is not defined for platform_id '#{platform_id}' or 'base' endpoint is not defined or 'base' endpoint path is not defined" - with {:invalid_config, false} <- {:invalid_config, is_nil(url)}, - {:invalid_config, false} <- {:invalid_config, is_nil(method)}, - {:ok, %Tesla.Env{status: status, body: body}} <- - Tesla.request( - method: method, - url: url, - headers: headers, - body: body, - opts: [timeout: @recv_timeout, adapter: [protocols: [:http1]]] - ) do - {Helper.decode_json(body), status} - else - {:invalid_config, true} -> - {"Invalid config: #{error_message}", 422} + is_nil(method) -> + "Invalid HTTP request method for platform '#{platform_id}'" - _ -> - {"Unexpected error when calling proxied endpoint", 500} + true -> + "Unexpected error" end end @@ -154,7 +163,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do - The function delegates further processing of the API key and endpoint parameters to `parse_endpoint_api_key/3` and `parse_endpoint_params/3`. """ - @spec parse_proxy_params(map()) :: api_request() + @spec parse_proxy_params(map()) :: api_request_params() def parse_proxy_params(proxy_params) do config = config() platform_id = proxy_params["platform_id"] @@ -164,6 +173,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do endpoint_api_key = platform_config && platform_config["api_key"] method = parse_method(platform_config) + protocol = parse_protocol(platform_config) raw_url = with true <- not is_nil(platform_config), @@ -184,6 +194,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do body: "", headers: [], method: method, + protocol: protocol, platform_config: platform_config, platform_id: platform_id } @@ -195,22 +206,33 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do defp parse_method(nil), do: nil # sobelow_skip ["DOS.StringToAtom"] - defp parse_method(platform_config) do + defp parse_method(%{ + "endpoints" => %{ + "base" => %{"method" => method} + } + }) do raw_method = - platform_config["endpoints"]["base"]["method"] + method # limit size of the input to prevent memory leak |> String.slice(0..10) |> String.downcase() |> String.to_atom() - if raw_method in @allowed_methods do - raw_method - else - nil - end + if raw_method in @allowed_methods, do: raw_method, else: nil + end + + defp parse_method(_platform_config), do: nil + + # sobelow_skip ["DOS.StringToAtom"] + defp parse_protocol(%{"base_url" => base_url}) do + protocol = base_url |> URI.parse() |> Map.get(:scheme) |> String.to_atom() + + if protocol in @allowed_protocols, do: protocol, else: nil end - @spec parse_endpoint_api_key(api_request(), api_key() | nil, String.t() | nil) :: api_request() + defp parse_protocol(_platform_config), do: nil + + @spec parse_endpoint_api_key(api_request_params(), api_key() | nil, String.t() | nil) :: api_request_params() defp parse_endpoint_api_key(%{url: _url, headers: _headers} = map, nil, _platform_id), do: map defp parse_endpoint_api_key( @@ -222,6 +244,13 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do endpoint_api_key_value = System.get_env(api_key_env_name) case location do + "path" -> + if endpoint_api_key_value do + Map.put(map, :url, String.replace(url, ":#{param_name}", endpoint_api_key_value)) + else + map + end + "header" -> if endpoint_api_key["prefix"] do Map.put(map, :headers, [ @@ -248,7 +277,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do defp parse_endpoint_api_key(map, _endpoint_api_key, _platform_id), do: map - @spec parse_endpoint_params(api_request(), [endpoint_param()], map()) :: api_request() + @spec parse_endpoint_params(api_request_params(), [endpoint_param()], map()) :: api_request_params() defp parse_endpoint_params(api_request_map, endpoint_params, proxy_params) do endpoint_params |> Enum.reduce(api_request_map, fn param, map -> @@ -275,7 +304,7 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do end) end - @spec parse_param_location(api_request(), endpoint_param(), String.t()) :: api_request() + @spec parse_param_location(api_request_params(), endpoint_param(), String.t()) :: api_request_params() defp parse_param_location( %{body: body, url: url, headers: headers} = api_request_map, %{"location" => location} = params, @@ -329,17 +358,17 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do end defp config do - case :persistent_term.get(@cache_name, nil) do - config_string when not is_nil(config_string) -> + case config_json() do + config_string when is_binary(config_string) -> safe_parse_config_string(config_string) - nil -> - case Tesla.get(@config_url) do - {:ok, %Tesla.Env{status: 200, body: config_string}} -> - safe_parse_config_string(config_string, true) + _ -> + case :persistent_term.get(@cache_name, nil) do + config_string when not is_nil(config_string) -> + safe_parse_config_string(config_string) - _ -> - %{} + nil -> + fetch_config_from_url() end end end @@ -354,4 +383,72 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxy do %{} end end + + defp fetch_config_from_url do + case HttpClient.get(config_url()) do + {:ok, %{status_code: 200, body: config_string}} -> + safe_parse_config_string(config_string, true) + + _ -> + %{} + end + end + + defp config_json do + Application.get_env(:explorer, __MODULE__)[:config_json] + end + + defp config_url do + Application.get_env(:explorer, __MODULE__)[:config_url] + end + + @doc """ + Retrieves a list of all WebSocket proxies that have been configured for use within the application. + + Returns: + - A list of tuples {platform_id, url}. + + ## Examples + + iex> Explorer.ThirdPartyIntegrations.UniversalProxy.websocket_proxies() + [ + {"ws_1", "wss://proxy1.example.com"}, + {"ws_2", "wss://proxy2.example.com"} + ] + """ + @spec websocket_proxies() :: [{String.t(), String.t()}] + def websocket_proxies do + if Mix.env() == :test do + [] + else + config = config() + + if is_nil(config["platforms"]) do + [] + else + config["platforms"] + |> Map.keys() + |> Enum.reduce([], fn platform_id, acc -> + proxy_params = parse_proxy_params(%{"platform_id" => platform_id}) + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if valid_websocket_url?(proxy_params.url) do + [{platform_id, proxy_params.url} | acc] + else + acc + end + end) + |> Enum.reverse() + end + end + end + + defp valid_websocket_url?(nil), do: false + + defp valid_websocket_url?(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} when scheme in ["ws", "wss"] and not is_nil(host) -> true + _ -> false + end + end end diff --git a/apps/explorer/lib/explorer/third_party_integrations/universal_proxy/socket_handler.ex b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy/socket_handler.ex new file mode 100644 index 000000000000..e71383c182da --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy/socket_handler.ex @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.UniversalProxy.SocketHandler do + @moduledoc """ + A simple WebSocket proxy handler that relays messages between a client and a target WebSocket server. + """ + @behaviour :cowboy_websocket + alias Explorer.ThirdPartyIntegrations.UniversalProxy.TargetClient + + # called after HTTP handshake + @spec init(req :: :cowboy_req.req(), opts :: keyword()) :: + {:cowboy_websocket, :cowboy_req.req(), map(), map()} + def init(req, opts) do + {:cowboy_websocket, req, %{target: nil, opts: opts}, %{idle_timeout: :infinity}} + end + + # called once WebSocket upgraded + @spec websocket_init(state :: map()) :: {:ok, map()} + def websocket_init(state) do + case TargetClient.start_link(self(), state.opts[:url]) do + {:ok, target_pid} -> + Process.monitor(target_pid) + {:ok, %{state | target: target_pid}} + + {:error, reason} -> + # Send error to client and close connection + {:reply, {:close, 1011, "Failed to connect to target: #{inspect(reason)}"}, state} + end + end + + @spec websocket_handle(frame :: tuple(), state :: map()) :: {:ok, map()} | {:reply, tuple(), map()} + def websocket_handle({:text, msg}, %{target: target_pid} = state) when not is_nil(target_pid) do + case TargetClient.forward(target_pid, msg) do + :ok -> + {:ok, state} + + {:error, _reason} -> + {:reply, {:close, 1011, "Target connection error"}, state} + end + end + + def websocket_handle({:text, _msg}, state) do + {:reply, {:close, 1011, "No target connection"}, state} + end + + @spec websocket_handle(other :: any(), state :: map()) :: {:ok, map()} + def websocket_handle(_other, state), do: {:ok, state} + + def websocket_info({:DOWN, _ref, :process, pid, _reason}, %{target: pid} = state) do + {:reply, {:close, 1011, "Target connection lost"}, %{state | target: nil}} + end + + def websocket_info({:EXIT, pid, _reason}, %{target: pid} = state) do + {:reply, {:close, 1011, "Target connection lost"}, %{state | target: nil}} + end + + @spec websocket_info(msg :: tuple(), state :: map()) :: {:ok, map()} | {:reply, tuple(), map()} + def websocket_info({:from_target, msg, :type, type}, state) do + {:reply, {type, msg}, state} + end + + def websocket_info(_, state), do: {:ok, state} + + @spec websocket_terminate(reason :: any(), state :: map()) :: :ok + def websocket_terminate(_reason, %{target: target_pid}) when not is_nil(target_pid) do + Process.exit(target_pid, :shutdown) + :ok + end + + def websocket_terminate(_reason, _state), do: :ok +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/universal_proxy/target_client.ex b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy/target_client.ex new file mode 100644 index 000000000000..935e4b71e300 --- /dev/null +++ b/apps/explorer/lib/explorer/third_party_integrations/universal_proxy/target_client.ex @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ThirdPartyIntegrations.UniversalProxy.TargetClient do + @moduledoc """ + A WebSocket client that connects to a target WebSocket server and forwards messages to/from a + parent process. + """ + use WebSockex + + @doc """ + Starts a WebSocket client that connects to the target URL and forwards messages to the parent process. + + ## Parameters + - `parent_pid`: The PID of the parent process that will receive forwarded messages. + - `url`: The WebSocket URL to connect to. + + ## Returns + - `{:ok, pid}` on successful connection + - `{:error, reason}` if the connection fails + """ + @spec start_link(parent_pid :: pid(), url :: String.t()) :: {:ok, pid()} | {:error, term()} + def start_link(parent_pid, url) do + WebSockex.start_link(url, __MODULE__, %{ + parent: parent_pid + }) + end + + ## Callbacks + + @impl true + def handle_frame({:text, msg}, %{parent: parent} = state) do + send(parent, {:from_target, msg, :type, :text}) + {:ok, state} + end + + @impl true + def handle_frame({:binary, msg}, %{parent: parent} = state) do + send(parent, {:from_target, msg, :type, :binary}) + {:ok, state} + end + + @doc """ + Forwards a text message to the target WebSocket server. + + ## Parameters + - `pid`: The PID of the TargetClient process. + - `msg`: The text message to send. + + ## Returns + - `:ok` on success + - `{:error, reason}` if the message cannot be sent + """ + @spec forward(pid :: pid(), msg :: String.t()) :: :ok | {:error, term()} + def forward(pid, msg) do + WebSockex.send_frame(pid, {:text, msg}) + end +end diff --git a/apps/explorer/lib/explorer/third_party_integrations/xname.ex b/apps/explorer/lib/explorer/third_party_integrations/xname.ex index aa86995cbe6f..1b7a7df0c8bc 100644 --- a/apps/explorer/lib/explorer/third_party_integrations/xname.ex +++ b/apps/explorer/lib/explorer/third_party_integrations/xname.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.Xname do @moduledoc """ Module for proxying xname https://xname.app/ API endpoints @@ -5,7 +6,7 @@ defmodule Explorer.ThirdPartyIntegrations.Xname do require Logger - alias Explorer.Helper + alias Explorer.{Helper, HttpClient} alias Explorer.Utility.Microservice @recv_timeout 60_000 @@ -19,8 +20,8 @@ defmodule Explorer.ThirdPartyIntegrations.Xname do def api_request(url, _conn, :get) do headers = [{"x-api-key", api_key()}] - case HTTPoison.get(url, headers, recv_timeout: @recv_timeout) do - {:ok, %HTTPoison.Response{status_code: status, body: body}} -> + case HttpClient.get(url, headers, recv_timeout: @recv_timeout) do + {:ok, %{status_code: status, body: body}} -> {Helper.decode_json(body), status} {:error, reason} -> diff --git a/apps/explorer/lib/explorer/token/balance_reader.ex b/apps/explorer/lib/explorer/token/balance_reader.ex index 25912d102a14..09e9b97c41be 100644 --- a/apps/explorer/lib/explorer/token/balance_reader.ex +++ b/apps/explorer/lib/explorer/token/balance_reader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Token.BalanceReader do @moduledoc """ Reads Token's balances using Smart Contract functions from the blockchain. diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index 96cc4492eb20..f6e1eff9f373 100644 --- a/apps/explorer/lib/explorer/token/metadata_retriever.ex +++ b/apps/explorer/lib/explorer/token/metadata_retriever.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Token.MetadataRetriever do @moduledoc """ Reads Token's fields using Smart Contract functions from the blockchain. @@ -5,16 +6,16 @@ defmodule Explorer.Token.MetadataRetriever do require Logger - alias Explorer.{MetadataURIValidator, Repo} alias Explorer.Chain.{Hash, Token} alias Explorer.Helper, as: ExplorerHelper + alias Explorer.{HttpClient, MetadataURIValidator} alias Explorer.SmartContract.Reader - alias HTTPoison.{Error, Response} @no_uri_error "no uri" @vm_execution_error "VM execution error" @invalid_base64_data "invalid data:application/json;base64" - @default_headers [{"User-Agent", "blockscout-8.0.2"}] + @invalid_ipfs_path "invalid ipfs path" + @default_headers [{"User-Agent", "blockscout-11.2.2"}] # https://eips.ethereum.org/EIPS/eip-1155#metadata @erc1155_token_id_placeholder "{id}" @@ -171,8 +172,10 @@ defmodule Explorer.Token.MetadataRetriever do It will retry to fetch each function in the Smart Contract according to :token_functions_reader_max_retries configured in the application env case one of them raised error. """ - @spec get_functions_of([Token.t()] | Token.t()) :: map() | {:ok, [map()]} - def get_functions_of(tokens) when is_list(tokens) do + @spec get_functions_of([Token.t()] | Token.t(), Keyword.t()) :: map() | {:ok, [map()]} + def get_functions_of(tokens, opts \\ []) + + def get_functions_of(tokens, _opts) when is_list(tokens) do requests = tokens |> Enum.flat_map(fn token -> @@ -226,25 +229,38 @@ defmodule Explorer.Token.MetadataRetriever do {:ok, processed_result} end - def get_functions_of(%Token{contract_address_hash: contract_address_hash, type: type}) do - base_metadata = + def get_functions_of(%Token{contract_address_hash: contract_address_hash, type: type}, opts) do + set_skip_metadata = Keyword.get(opts, :set_skip_metadata, false) + + raw_metadata = contract_address_hash |> fetch_functions_from_contract(@contract_functions) + + base_metadata = + raw_metadata |> format_contract_functions_result(contract_address_hash) metadata = try_to_fetch_erc_1155_name(base_metadata, contract_address_hash, type) - if metadata == %{} do - token_to_update = - Token - |> Repo.get_by(contract_address_hash: contract_address_hash) - - set_skip_metadata(token_to_update) + if Enum.empty?(metadata) && set_skip_metadata do + Map.put( + metadata, + :skip_metadata, + Enum.all?(raw_metadata, fn {_key, value} -> contract_failure?(value) end) + ) + else + metadata end - - metadata end + defp contract_failure?({:error, %{message: message}}) when is_binary(message), + do: String.match?(message, ~r/execution.*revert/) + + defp contract_failure?({:error, %{data: data}}) when is_binary(data), + do: String.match?(data, ~r/execution.*revert/) + + defp contract_failure?(error), do: EthereumJSONRPC.contract_failure?(error) + defp try_to_fetch_erc_1155_name(base_metadata, contract_address_hash, token_type) do if token_type == "ERC-1155" && !Map.has_key?(base_metadata, :name) do erc_1155_name_uri = @@ -254,18 +270,7 @@ defmodule Explorer.Token.MetadataRetriever do case erc_1155_name_uri do %{:name => name} when is_binary(name) -> - sanitized_name = String.trim(name) - uri = {:ok, [sanitized_name]} - - with {:ok, %{metadata: metadata}} <- uri |> fetch_json(nil, nil, false) |> parse_fetch_json_response(), - true <- Map.has_key?(metadata, "name"), - false <- is_nil(metadata["name"]) do - name_metadata = %{:name => metadata["name"]} - - Map.merge(base_metadata, name_metadata) - else - _ -> base_metadata - end + fetch_erc1155_name_metadata(name, base_metadata) _ -> base_metadata @@ -275,6 +280,20 @@ defmodule Explorer.Token.MetadataRetriever do end end + defp fetch_erc1155_name_metadata(name, base_metadata) do + sanitized_name = String.trim(name) + uri = {:ok, [sanitized_name]} + + with {:ok, %{metadata: metadata}} <- uri |> fetch_json(nil, nil, false) |> parse_fetch_json_response(), + true <- Map.has_key?(metadata, "name"), + false <- is_nil(metadata["name"]) do + name_metadata = %{:name => metadata["name"]} + Map.merge(base_metadata, name_metadata) + else + _ -> base_metadata + end + end + @doc """ Parses the response from metadata fetching. @@ -308,10 +327,6 @@ defmodule Explorer.Token.MetadataRetriever do other end - defp set_skip_metadata(token_to_update) do - Token.update(token_to_update, %{skip_metadata: true}) - end - def get_total_supply_of(contract_address_hash) when is_binary(contract_address_hash) do contract_address_hash |> fetch_functions_from_contract(@total_supply_function) @@ -387,8 +402,8 @@ defmodule Explorer.Token.MetadataRetriever do contract_functions |> handle_invalid_strings(contract_address_hash) - |> handle_large_strings - |> limit_decimals + |> handle_large_strings() + |> limit_decimals() end defp atomized_key(@name_signature), do: :name @@ -708,41 +723,41 @@ defmodule Explorer.Token.MetadataRetriever do case URI.parse(token_uri_string) do %URI{scheme: "ipfs", host: host, path: path} -> resource_id = - if host == "ipfs" do - "/" <> resource_id = path - resource_id - else - # credo:disable-for-next-line - if is_nil(path), do: host, else: host <> path + cond do + host == "ipfs" and is_binary(path) and String.starts_with?(path, "/") -> + String.replace_leading(path, "/", "") + + is_binary(host) and is_binary(path) -> + host <> path + + is_binary(host) and is_nil(path) -> + host + + true -> + nil end - fetch_from_ipfs(resource_id, hex_token_id) + fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) %URI{scheme: "ar", host: _host, path: resource_id} -> fetch_from_arweave(resource_id, hex_token_id) %URI{scheme: _, path: "/ipfs/" <> resource_id} -> - fetch_from_ipfs(resource_id, hex_token_id) + fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) %URI{scheme: _, path: "ipfs/" <> resource_id} -> - fetch_from_ipfs(resource_id, hex_token_id) + fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) %URI{scheme: scheme} when not is_nil(scheme) -> fetch_metadata_inner(token_uri_string, ipfs_params, token_id, hex_token_id, from_base_uri?) %URI{path: path} -> - case path do - "Qm" <> <<_::binary-size(44)>> = resource_id -> - fetch_from_ipfs(resource_id, hex_token_id) - - # todo: rewrite for strict CID v1 support - "bafybe" <> _ = resource_id -> - fetch_from_ipfs(resource_id, hex_token_id) - - _ -> - json = ExplorerHelper.decode_json(token_uri_string, true) + if is_binary(path) and valid_ipfs_path?(public_ipfs_link(path)) do + fetch_from_ipfs(path, hex_token_id) + else + json = ExplorerHelper.decode_json(token_uri_string, true) - check_type(json, hex_token_id) + check_type(json, hex_token_id) end end rescue @@ -848,12 +863,12 @@ defmodule Explorer.Token.MetadataRetriever do defp fetch_metadata_from_uri_request(uri, hex_token_id, ipfs_params) do headers = if ipfs?(ipfs_params), do: ipfs_headers(), else: @default_headers - case Application.get_env(:explorer, :http_adapter).get(uri, headers, + case HttpClient.get(uri, headers, recv_timeout: 30_000, follow_redirect: true, - hackney: [pool: :token_instance_fetcher] + pool: :token_instance_fetcher ) do - {:ok, %Response{body: body, status_code: 200, headers: response_headers}} -> + {:ok, %{body: body, status_code: 200, headers: response_headers}} -> content_type = get_content_type_from_headers(response_headers) case check_content_type(content_type, uri, hex_token_id, body, ipfs_params) do @@ -864,7 +879,7 @@ defmodule Explorer.Token.MetadataRetriever do {:error, reason} end - {:ok, %Response{body: body, status_code: code}} -> + {:ok, %{body: body, status_code: code}} -> Logger.debug( ["Request to token uri: #{inspect(uri)} failed with code #{code}. Body:", inspect(body)], fetcher: :token_instances @@ -872,7 +887,7 @@ defmodule Explorer.Token.MetadataRetriever do {:error_code, code} - {:error, %Error{reason: reason}} -> + {:error, reason} -> Logger.warning( ["Request to token uri failed: #{inspect(uri)}.", inspect(reason)], fetcher: :token_instances @@ -983,6 +998,24 @@ defmodule Explorer.Token.MetadataRetriever do String.replace(token_uri, @erc1155_token_id_placeholder, hex_token_id) end + def valid_ipfs_path?(path) when is_binary(path) do + # CIDv0: ipfs://Qm[1-9A-HJ-NP-Za-km-z]{44} + # CIDv1: ipfs://b[a-z2-7]{7,} + # Path format: ipfs://[CID]/optional/path + ipfs_path_regular_expression = ~r/^ipfs:\/\/(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]{7,})(\/.*)?$/ + String.match?(path, ipfs_path_regular_expression) + end + + def valid_ipfs_path?(_), do: false + + defp fetch_from_ipfs_if_valid_path(resource_id, hex_token_id) do + if is_binary(resource_id) and valid_ipfs_path?(public_ipfs_link(resource_id)) do + fetch_from_ipfs(resource_id, hex_token_id) + else + {:error, @invalid_ipfs_path} + end + end + @doc """ Truncate error string to @max_error_length symbols """ diff --git a/apps/explorer/lib/explorer/token_instance_owner_address_migration/helper.ex b/apps/explorer/lib/explorer/token_instance_owner_address_migration/helper.ex index 0f55492d61b1..5d80f405cd31 100644 --- a/apps/explorer/lib/explorer/token_instance_owner_address_migration/helper.ex +++ b/apps/explorer/lib/explorer/token_instance_owner_address_migration/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.TokenInstanceOwnerAddressMigration.Helper do @moduledoc """ Auxiliary functions for TokenInstanceOwnerAddressMigration.{Worker and Supervisor} @@ -8,8 +9,8 @@ defmodule Explorer.TokenInstanceOwnerAddressMigration.Helper do ] alias Explorer.{Chain, Repo} - alias Explorer.Chain.Token.Instance alias Explorer.Chain.{SmartContract, TokenTransfer} + alias Explorer.Chain.Token.Instance require Logger diff --git a/apps/explorer/lib/explorer/token_instance_owner_address_migration/supervisor.ex b/apps/explorer/lib/explorer/token_instance_owner_address_migration/supervisor.ex index 01ca324f5f2d..da916fd0549e 100644 --- a/apps/explorer/lib/explorer/token_instance_owner_address_migration/supervisor.ex +++ b/apps/explorer/lib/explorer/token_instance_owner_address_migration/supervisor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.TokenInstanceOwnerAddressMigration.Supervisor do @moduledoc """ Supervisor for Explorer.TokenInstanceOwnerAddressMigration.Worker diff --git a/apps/explorer/lib/explorer/token_instance_owner_address_migration/worker.ex b/apps/explorer/lib/explorer/token_instance_owner_address_migration/worker.ex index c9cfabc9d2ca..32810fff1940 100644 --- a/apps/explorer/lib/explorer/token_instance_owner_address_migration/worker.ex +++ b/apps/explorer/lib/explorer/token_instance_owner_address_migration/worker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.TokenInstanceOwnerAddressMigration.Worker do @moduledoc """ GenServer for filling owner_address_hash, owner_updated_at_block and owner_updated_at_log_index diff --git a/apps/explorer/lib/explorer/tracer.ex b/apps/explorer/lib/explorer/tracer.ex index 20297d80abe9..e7381803b36e 100644 --- a/apps/explorer/lib/explorer/tracer.ex +++ b/apps/explorer/lib/explorer/tracer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Tracer do @moduledoc false diff --git a/apps/explorer/lib/explorer/tuple_encoder.ex b/apps/explorer/lib/explorer/tuple_encoder.ex index 5e3dd685d24c..4705a3a14f15 100644 --- a/apps/explorer/lib/explorer/tuple_encoder.ex +++ b/apps/explorer/lib/explorer/tuple_encoder.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule TupleEncoder do @moduledoc """ Implementation of Jason.Encoder for Tuple diff --git a/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex b/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex index 167584ab3584..67eeed1fd983 100644 --- a/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex +++ b/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.AddressContractCodeFetchAttempt do @moduledoc """ Module is responsible for keeping the number of retries for diff --git a/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex b/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex new file mode 100644 index 000000000000..499027b48b65 --- /dev/null +++ b/apps/explorer/lib/explorer/utility/address_id_to_address_hash.ex @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.AddressIdToAddressHash do + @moduledoc """ + Module is responsible for keeping the address_id to address_hash correspondence. + """ + + use Explorer.Schema + + alias Explorer.Chain.{Address, Hash} + alias Explorer.Repo + + @primary_key false + typed_schema "address_ids_to_address_hashes" do + field(:address_id, :integer, primary_key: true) + + belongs_to(:address, Address, + foreign_key: :address_hash, + references: :hash, + type: Hash.Address + ) + end + + @doc false + def changeset(address_id_to_address_hash \\ %__MODULE__{}, params) do + cast(address_id_to_address_hash, params, [:address_id, :address_hash]) + end + + @doc """ + Finds the mapping for the given address hash or creates it if it does not yet + exist. + + This function is a convenience wrapper around `find_or_create_multiple/2` + for a single address hash. + + ## Parameters + - `address_hash`: The address hash to look up or create a mapping for + + ## Returns + - An `%Explorer.Utility.AddressIdToAddressHash{}` struct for the given hash + - `nil` if `address_hash` is `nil` + """ + @spec find_or_create(Hash.Address.t() | nil) :: __MODULE__.t() | nil + def find_or_create(address_hash) do + [address_hash] + |> find_or_create_multiple(false) + |> List.first() + end + + @doc """ + Finds or creates mappings for the given address hashes in bulk. + + The input is normalized by removing `nil` values, deduplicating hashes, and + casting each hash to `Hash.Address`. Missing mappings are inserted with + `on_conflict: :nothing`, so existing mappings are preserved. + + ## Parameters + - `address_hashes`: A list of address hashes to resolve + - `to_map?`: When `true`, returns a map of `%{address_hash => address_id}`. + When `false`, returns the list of `%Explorer.Utility.AddressIdToAddressHash{}` + records + + ## Returns + - A map of address hashes to address ids when `to_map?` is `true` + - A list of `%Explorer.Utility.AddressIdToAddressHash{}` structs when + `to_map?` is `false` + """ + @spec find_or_create_multiple([Hash.Address.t() | nil], true) :: %{optional(Hash.Address.t()) => integer()} + @spec find_or_create_multiple([Hash.Address.t() | nil], false) :: [__MODULE__.t()] + def find_or_create_multiple(address_hashes, to_map? \\ true) do + filtered_address_hashes = + address_hashes + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Enum.map(fn address_hash -> + {:ok, casted} = Hash.Address.cast(address_hash) + casted + end) + |> Enum.sort() + + Repo.safe_insert_all( + __MODULE__, + Enum.map(filtered_address_hashes, &%{address_hash: &1}), + on_conflict: :nothing + ) + + __MODULE__ + |> where([a], a.address_hash in ^filtered_address_hashes) + |> Repo.all() + |> then(fn records -> + if to_map?, do: Map.new(records, &{to_string(&1.address_hash), &1.address_id}), else: records + end) + end + + @doc """ + Retrieves the address_id for a given address_hash. + + ## Parameters + - `hash`: The address hash to look up + + ## Returns + - The address_id if found, nil otherwise + """ + @spec hash_to_id(Hash.Address.t()) :: integer() | nil + def hash_to_id(nil), do: nil + + def hash_to_id(hash) do + [hash] + |> hashes_to_ids() + |> List.first() + end + + @doc """ + Retrieves all address ids for the given address hashes. + + ## Parameters + - `hashes`: A list of address hashes to look up + + ## Returns + - A list of address ids for the matching mappings + """ + @spec hashes_to_ids([Hash.Address.t()]) :: [integer()] + def hashes_to_ids(hashes) do + __MODULE__ + |> where([a], a.address_hash in ^hashes) + |> select([a], a.address_id) + |> Repo.all() + end + + @doc """ + Retrieves the address hash for a given address_id. + + This function is a convenience wrapper around `ids_to_hashes/1` for a single + address id. + + ## Parameters + - `id`: The address id to look up + + ## Returns + - The address hash if found + - `nil` if the id is `nil` or no mapping exists + """ + @spec id_to_hash(integer() | nil) :: Hash.Address.t() | nil + def id_to_hash(nil), do: nil + + def id_to_hash(id) do + [id] + |> ids_to_hashes() + |> List.first() + end + + @doc """ + Retrieves all address hashes for the given address ids. + + ## Parameters + - `ids`: A list of address ids to look up + + ## Returns + - A list of address hashes for the matching mappings + """ + @spec ids_to_hashes([integer()]) :: [Hash.Address.t()] + def ids_to_hashes([]), do: [] + + def ids_to_hashes(ids) do + __MODULE__ + |> where([a], a.address_id in ^ids) + |> select([a], a.address_hash) + |> Repo.all() + end +end diff --git a/apps/explorer/lib/explorer/utility/event_notification.ex b/apps/explorer/lib/explorer/utility/event_notification.ex index ccb52ea5e9b0..7ffce20156cb 100644 --- a/apps/explorer/lib/explorer/utility/event_notification.ex +++ b/apps/explorer/lib/explorer/utility/event_notification.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.EventNotification do @moduledoc """ An auxiliary schema for sending postgres notifications. @@ -7,6 +8,8 @@ defmodule Explorer.Utility.EventNotification do typed_schema "event_notifications" do field(:data, :string) + + timestamps() end @doc false diff --git a/apps/explorer/lib/explorer/utility/hammer.ex b/apps/explorer/lib/explorer/utility/hammer.ex new file mode 100644 index 000000000000..2ac06508657a --- /dev/null +++ b/apps/explorer/lib/explorer/utility/hammer.ex @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.Hammer.ETS do + @moduledoc false + use Hammer, backend: Hammer.ETS +end + +defmodule Explorer.Utility.Hammer.Redis do + @moduledoc false + use Hammer, backend: Hammer.Redis +end + +defmodule Explorer.Utility.Hammer do + @moduledoc """ + Wrapper for the rate limit functions. Defines union of all functions from `Explorer.Utility.Hammer.ETS` and `Explorer.Utility.Hammer.Redis`. Resolves the backend to use based on `Application.get_env(:explorer, Explorer.Utility.RateLimiter)[:hammer_backend_module]` in runtime. + """ + + alias Explorer.Helper + alias Explorer.Utility.Hammer.{ETS, Redis} + + functions = + (ETS.__info__(:functions) ++ Redis.__info__(:functions)) + |> Enum.uniq() + + for {name, arity} <- functions do + args = Macro.generate_arguments(arity, nil) + + def unquote(name)(unquote_splicing(args)) do + apply( + Application.get_env(:explorer, Explorer.Utility.RateLimiter)[:hammer_backend_module], + unquote(name), + unquote(args) + ) + end + end + + def child_for_supervisor do + config = Application.get_env(:explorer, Explorer.Utility.RateLimiter) + + case config[:storage] do + :redis -> + {Explorer.Utility.Hammer.Redis, + Helper.redix_opts( + config[:redis_url], + config[:redis_ssl], + config[:redis_sentinel_urls], + config[:redis_sentinel_master_name] + )} + + :ets -> + {Explorer.Utility.Hammer.ETS, []} + end + end +end diff --git a/apps/explorer/lib/explorer/utility/internal_transaction_helper.ex b/apps/explorer/lib/explorer/utility/internal_transaction_helper.ex new file mode 100644 index 000000000000..c6bcecf05ab7 --- /dev/null +++ b/apps/explorer/lib/explorer/utility/internal_transaction_helper.ex @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.InternalTransactionHelper do + @moduledoc """ + Internal transactions helping functions. + """ + + alias Explorer.Chain.Cache.BackgroundMigrations + + @spec primary_key_updated? :: boolean() + def primary_key_updated? do + BackgroundMigrations.get_heavy_indexes_update_internal_transactions_primary_key_finished() + end +end diff --git a/apps/explorer/lib/explorer/utility/internal_transactions_address_placeholder.ex b/apps/explorer/lib/explorer/utility/internal_transactions_address_placeholder.ex new file mode 100644 index 000000000000..e64b3e73c5e5 --- /dev/null +++ b/apps/explorer/lib/explorer/utility/internal_transactions_address_placeholder.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.InternalTransactionsAddressPlaceholder do + @moduledoc """ + Module is responsible for keeping the information about the presence of internal transactions + on a particular address inside specific block + """ + + use Explorer.Schema + + alias Explorer.Repo + + @primary_key false + typed_schema "deleted_internal_transactions_address_placeholders" do + field(:address_id, :integer, primary_key: true) + field(:block_number, :integer, primary_key: true) + field(:count_tos, :integer) + field(:count_froms, :integer) + end + + @doc false + def changeset(placeholder \\ %__MODULE__{}, params) do + cast(placeholder, params, [:address_id, :block_number, :count_tos, :count_froms]) + end + + @spec empty?() :: boolean() + def empty? do + not Repo.exists?(__MODULE__) + end +end diff --git a/apps/explorer/lib/explorer/utility/massive_block.ex b/apps/explorer/lib/explorer/utility/massive_block.ex index 9aa710480396..cda8a29208e7 100644 --- a/apps/explorer/lib/explorer/utility/massive_block.ex +++ b/apps/explorer/lib/explorer/utility/massive_block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.MassiveBlock do @moduledoc """ Module is responsible for keeping the block numbers that are too large for regular import diff --git a/apps/explorer/lib/explorer/utility/microservice.ex b/apps/explorer/lib/explorer/utility/microservice.ex index 744bbec70a70..f581e866425e 100644 --- a/apps/explorer/lib/explorer/utility/microservice.ex +++ b/apps/explorer/lib/explorer/utility/microservice.ex @@ -1,28 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.Microservice do @moduledoc """ Module is responsible for common utils related to microservices. """ - alias Explorer.Helper + alias Utils.ConfigHelper, as: UtilsConfigHelper @doc """ Returns base url of the microservice or nil if it is invalid or not set """ - @spec base_url(atom(), atom()) :: false | nil | binary() + @spec base_url(atom(), atom()) :: nil | binary() def base_url(application \\ :explorer, module) do - url = Application.get_env(application, module)[:service_url] + url = config(application, module)[:service_url] - cond do - not Helper.valid_url?(url) -> - nil - - String.ends_with?(url, "/") -> - url - |> String.slice(0..(String.length(url) - 2)) - - true -> - url - end + if UtilsConfigHelper.valid_url?(url), do: url, else: nil end @doc """ @@ -30,7 +21,7 @@ defmodule Explorer.Utility.Microservice do """ @spec check_enabled(atom(), atom()) :: :ok | {:error, :disabled} def check_enabled(application \\ :explorer, module) do - if Application.get_env(application, module)[:enabled] && base_url(application, module) do + if config(application, module)[:enabled] && base_url(application, module) do :ok else {:error, :disabled} @@ -46,8 +37,11 @@ defmodule Explorer.Utility.Microservice do "your_api_key_here" """ - @spec api_key(atom(), atom()) :: String.t() + @spec api_key(atom(), atom()) :: String.t() | nil def api_key(application \\ :explorer, module) do - Application.get_env(application, module)[:api_key] + config(application, module)[:api_key] end + + @spec config(atom(), atom()) :: keyword() + defp config(application, module), do: Application.get_env(application, module, []) end diff --git a/apps/explorer/lib/explorer/utility/missing_balance_of_token.ex b/apps/explorer/lib/explorer/utility/missing_balance_of_token.ex index 71dda35b708f..069f43f5c317 100644 --- a/apps/explorer/lib/explorer/utility/missing_balance_of_token.ex +++ b/apps/explorer/lib/explorer/utility/missing_balance_of_token.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.MissingBalanceOfToken do @moduledoc """ Module is responsible for keeping address hashes of tokens that does not support the balanceOf function @@ -54,6 +55,25 @@ defmodule Explorer.Utility.MissingBalanceOfToken do |> Repo.update_all(set: [currently_implemented: true]) end + @doc """ + Filters provided token balances query by presence of record with the same `token_contract_address_hash` + and above or equal `block_number` in `missing_balance_of_tokens`. + """ + @spec filter_token_balances_query(Ecto.Query.t()) :: Ecto.Query.t() + def filter_token_balances_query(query) do + query + |> join(:left, [tb], mbot in __MODULE__, + on: tb.token_contract_address_hash == mbot.token_contract_address_hash, + as: :mbot + ) + |> where( + [tb], + is_nil(as(:mbot).token_contract_address_hash) or + (as(:mbot).currently_implemented == true and tb.block_number > as(:mbot).block_number) or + tb.block_number > as(:mbot).block_number + ^missing_balance_of_window() + ) + end + @doc """ Filters provided token balances params by presence of record with the same `token_contract_address_hash` and above or equal `block_number` in `missing_balance_of_tokens`. @@ -89,7 +109,7 @@ defmodule Explorer.Utility.MissingBalanceOfToken do params = token_balance_params - |> Enum.reject(&(&1.token_type == "ERC-404")) + |> Enum.reject(&(&1.token_type in ["ERC-404", "ERC-7984"])) |> Enum.group_by(& &1.token_contract_address_hash, & &1.block_number) |> Enum.map(fn {token_contract_address_hash, block_numbers} -> {:ok, token_contract_address_hash_casted} = Hash.Address.cast(token_contract_address_hash) diff --git a/apps/explorer/lib/explorer/utility/missing_block_range.ex b/apps/explorer/lib/explorer/utility/missing_block_range.ex index 976580605cea..724edcd66f34 100644 --- a/apps/explorer/lib/explorer/utility/missing_block_range.ex +++ b/apps/explorer/lib/explorer/utility/missing_block_range.ex @@ -1,9 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +# credo:disable-for-this-file defmodule Explorer.Utility.MissingBlockRange do @moduledoc """ Module is responsible for keeping the ranges of blocks that need to be (re)fetched. """ use Explorer.Schema + alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.Chain.{Block, BlockNumberHelper} alias Explorer.Repo @@ -68,7 +71,7 @@ defmodule Explorer.Utility.MissingBlockRange do - A list of `Range` structs, where each range represents a contiguous block range of missing blocks. """ - @spec get_latest_batch(integer()) :: [__MODULE__.t()] + @spec get_latest_batch(integer()) :: [Range.t()] def get_latest_batch(size \\ @default_returning_batch_size) do size |> get_latest_ranges_query() @@ -106,11 +109,14 @@ defmodule Explorer.Utility.MissingBlockRange do This function first converts the list of block numbers into ranges using `numbers_to_ranges/1` and then saves the resulting ranges in a batch with the specified priority. """ - @spec add_ranges_by_block_numbers([Block.block_number()], integer() | nil) :: [__MODULE__.t()] - def add_ranges_by_block_numbers(numbers, priority) do + @spec add_ranges_by_block_numbers([Block.block_number()], integer() | nil) :: :ok + def add_ranges_by_block_numbers(numbers, priority \\ nil) do numbers + |> RangesHelper.filter_by_block_ranges() |> numbers_to_ranges() |> save_batch(priority) + + :ok end # Saves a range of block numbers with an optional priority. @@ -154,45 +160,44 @@ defmodule Explorer.Utility.MissingBlockRange do min_number = min(from, to) max_number = max(from, to) - lower_range = get_range_by_block_number(min_number) - higher_range = get_range_by_block_number(max_number) - - case {lower_range, higher_range} do - {%__MODULE__{} = same_range, %__MODULE__{} = same_range} -> - if is_nil(same_range.priority) && not is_nil(priority) do - delete_range(same_range.from_number..same_range.to_number) + Repo.transaction(fn -> + {all_ranges, lower_range, higher_range} = lock_related_ranges(max_number, min_number) - inside_range_params = %{from_number: max_number, to_number: min_number, priority: priority} - insert_range(inside_range_params) - - insert_outside_right_range_params(same_range, min_number) - insert_outside_left_range_params(same_range, max_number) - end + case {lower_range, higher_range} do + {%__MODULE__{} = same_range, %__MODULE__{} = same_range} -> + if is_nil(same_range.priority) && not is_nil(priority) do + Repo.delete(same_range) - {%__MODULE__{} = range, nil} -> - delete_less_priority_range(range, priority) - fill_ranges_between(max_number, range.from_number + 1, priority) + inside_range_params = %{from_number: max_number, to_number: min_number, priority: priority} + insert_range(inside_range_params) - split_right_range_priorities(range, priority, min_number) + insert_outside_right_range_params(same_range, min_number) + insert_outside_left_range_params(same_range, max_number) + end - {nil, %__MODULE__{} = range} -> - delete_less_priority_range(range, priority) - fill_ranges_between(range.to_number - 1, min_number, priority) + {%__MODULE__{} = range, nil} -> + delete_less_priority_range(range, priority) + split_right_range_priorities(range, priority, min_number) + fill_ranges_between(all_ranges, max_number, range.from_number + 1, priority) - split_left_range_priorities(range, priority, max_number) + {nil, %__MODULE__{} = range} -> + delete_less_priority_range(range, priority) + split_left_range_priorities(range, priority, max_number) + fill_ranges_between(all_ranges, range.to_number - 1, min_number, priority) - {%__MODULE__{} = range_1, %__MODULE__{} = range_2} -> - delete_less_priority_range(range_2, priority) - delete_less_priority_range(range_1, priority) + {%__MODULE__{} = range_1, %__MODULE__{} = range_2} -> + delete_less_priority_range(range_2, priority) + delete_less_priority_range(range_1, priority) - fill_ranges_between(range_2.to_number - 1, range_1.from_number + 1, priority) + split_left_range_priorities(range_2, priority, max_number) + split_right_range_priorities(range_1, priority, min_number) - split_left_range_priorities(range_2, priority, max_number) - split_right_range_priorities(range_1, priority, min_number) + fill_ranges_between(all_ranges, range_2.to_number - 1, range_1.from_number + 1, priority) - {nil, nil} -> - fill_ranges_between(max_number, min_number, priority) - end + {nil, nil} -> + fill_ranges_between(all_ranges, max_number, min_number, priority) + end + end) end @spec insert_inside_left_range_params(__MODULE__.t(), Block.block_number(), integer() | nil) :: @@ -266,43 +271,60 @@ defmodule Explorer.Utility.MissingBlockRange do min_number = min(from, to) max_number = max(from, to) - lower_range = get_range_by_block_number(min_number) - higher_range = get_range_by_block_number(max_number) + Repo.transaction(fn -> + {all_ranges, lower_range, higher_range} = lock_related_ranges(max_number, min_number) - case {lower_range, higher_range} do - {%__MODULE__{} = same_range, %__MODULE__{} = same_range} -> - Repo.delete(same_range) + case {lower_range, higher_range} do + {%__MODULE__{} = same_range, %__MODULE__{} = same_range} -> + Repo.delete(same_range) - if same_range.from_number > max_number do - insert_range(%{ - from_number: same_range.from_number, - to_number: BlockNumberHelper.next_block_number(max_number) - }) - end + if same_range.from_number > max_number do + insert_range(%{ + from_number: same_range.from_number, + to_number: BlockNumberHelper.next_block_number(max_number), + priority: same_range.priority + }) + end - if same_range.to_number < min_number do - insert_range(%{ - from_number: BlockNumberHelper.previous_block_number(min_number), - to_number: same_range.to_number - }) - end + if same_range.to_number < min_number do + insert_range(%{ + from_number: BlockNumberHelper.previous_block_number(min_number), + to_number: same_range.to_number, + priority: same_range.priority + }) + end - {%__MODULE__{} = range, nil} -> - delete_ranges_between(max_number, range.from_number) - update_from_number_or_delete_range(range, min_number) + {%__MODULE__{} = range, nil} -> + delete_ranges_between(all_ranges, max_number, range.from_number) + update_from_number_or_delete_range(range, min_number) - {nil, %__MODULE__{} = range} -> - delete_ranges_between(range.to_number, min_number) - update_to_number_or_delete_range(range, max_number) + {nil, %__MODULE__{} = range} -> + delete_ranges_between(all_ranges, range.to_number, min_number) + update_to_number_or_delete_range(range, max_number) - {%__MODULE__{} = range_1, %__MODULE__{} = range_2} -> - delete_ranges_between(range_2.to_number, range_1.from_number) - update_from_number_or_delete_range(range_1, min_number) - update_to_number_or_delete_range(range_2, max_number) + {%__MODULE__{} = range_1, %__MODULE__{} = range_2} -> + delete_ranges_between(all_ranges, range_2.to_number, range_1.from_number) + update_from_number_or_delete_range(range_1, min_number) + update_to_number_or_delete_range(range_2, max_number) - _ -> - delete_ranges_between(max_number, min_number) - end + _ -> + delete_ranges_between(all_ranges, max_number, min_number) + end + end) + end + + defp lock_related_ranges(from, to) do + all_ranges = + __MODULE__ + |> where([m], fragment("int4range(?, ?, '[]') && int4range(?, ?, '[]')", ^to, ^from, m.to_number, m.from_number)) + |> order_by([m], desc: m.from_number) + |> lock("FOR UPDATE") + |> Repo.all() + + lower_range = Enum.find(all_ranges, &(&1.from_number >= to and &1.to_number <= to)) + higher_range = Enum.find(all_ranges, &(&1.from_number >= from and &1.to_number <= from)) + + {all_ranges, lower_range, higher_range} end def clear_batch(batch) do @@ -366,10 +388,19 @@ defmodule Explorer.Utility.MissingBlockRange do # Inserts a new missing block range record with the provided parameters @spec insert_range(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} - defp insert_range(params) do - params - |> changeset() - |> Repo.insert() + defp insert_range(%{from_number: from, to_number: to} = params) do + existing_record = + __MODULE__ + |> where([r], r.from_number == ^from and r.to_number == ^to) + |> Repo.one() + + if existing_record do + {:ok, existing_record} + else + params + |> changeset() + |> Repo.insert(on_conflict: :nothing, conflict_target: [:from_number, :to_number]) + end end # Updates a missing block range record with the provided parameters @@ -381,10 +412,10 @@ defmodule Explorer.Utility.MissingBlockRange do end defp update_from_number_or_delete_range(%{to_number: to} = range, from) when from <= to, do: Repo.delete(range) - defp update_from_number_or_delete_range(range, from), do: update_range(range, %{from_number: from}) + defp update_from_number_or_delete_range(range, from), do: update_range(range, %{from_number: from - 1}) defp update_to_number_or_delete_range(%{from_number: from} = range, to) when to >= from, do: Repo.delete(range) - defp update_to_number_or_delete_range(range, to), do: update_range(range, %{to_number: to}) + defp update_to_number_or_delete_range(range, to), do: update_range(range, %{to_number: to + 1}) @doc """ Fetches the range of blocks that includes the given block number if it falls @@ -397,29 +428,36 @@ defmodule Explorer.Utility.MissingBlockRange do - A single range record of `Explorer.Utility.MissingBlockRange` that includes the given block number, or `nil` if no such range is found. """ - @spec get_range_by_block_number(Block.block_number()) :: nil | __MODULE__.t() - def get_range_by_block_number(number) do + @spec get_range_by_block_number(Block.block_number(), integer() | nil | :not_specified) :: nil | __MODULE__.t() + def get_range_by_block_number(number, priority \\ :not_specified) do number |> include_bound_query() + |> priority_query(priority) |> Repo.one() end # Fills all missing block ranges that overlap with the interval [from, to] - @spec fill_ranges_between(Block.block_number(), Block.block_number(), integer() | nil) :: :ok - defp fill_ranges_between(from, to, priority) when from >= to do + @spec fill_ranges_between([__MODULE__.t()], Block.block_number(), Block.block_number(), integer() | nil) :: :ok + defp fill_ranges_between(all_ranges, from, to, priority) when from >= to do + ids_to_delete = + all_ranges + |> Enum.filter(fn range -> + range.to_number >= to and range.from_number <= from and priority_filter(range, priority) + end) + |> Enum.map(& &1.id) + __MODULE__ - |> where([r], r.from_number <= ^from) - |> where([r], r.to_number >= ^to) - |> priority_filter(priority) + |> where([r], r.id in ^ids_to_delete) |> Repo.delete_all() + filtered_ranges = Enum.filter(all_ranges, &(&1.id not in ids_to_delete)) + # select all left priority ranges - priority_ranges = select_all_ranges_within_the_range(from, to) + priority_ranges = select_all_ranges_within_the_range(filtered_ranges, from, to) if Enum.empty?(priority_ranges) do # if no priority ranges inside the requested interval, fill the full range - range = %{from_number: from, to_number: to, priority: priority} - insert_range(range) + insert_or_update_adjacent_ranges(from, to, priority, :both) else full_range_map_set = from @@ -427,74 +465,117 @@ defmodule Explorer.Utility.MissingBlockRange do |> Enum.to_list() |> MapSet.new() - ranges_to_fill = - priority_ranges - |> Enum.reduce(full_range_map_set, fn range, acc -> - map_set = - range.from_number - |> Range.new(range.to_number) - |> Enum.to_list() - |> MapSet.new() - - acc - |> MapSet.difference(map_set) - end) - |> MapSet.to_list() - |> Enum.sort_by(& &1, :desc) - |> Enum.reduce({[], {nil, nil}}, fn num, {ranges, {start_range, end_range}} -> - if is_nil(start_range) do - {ranges, {num, num}} - else - # credo:disable-for-next-line Credo.Check.Refactor.Nesting - if end_range - num > 1 do - {[Range.new(start_range, end_range) | ranges], {num, num}} - else - {ranges, {start_range, num}} - end - end - end) - |> then(fn {ranges, {start_range, end_range}} -> - if not is_nil(start_range) && not is_nil(end_range) do - [Range.new(start_range, end_range) | ranges] + priority_ranges + |> Enum.reduce(full_range_map_set, fn range, acc -> + map_set = + range.from_number + |> Range.new(range.to_number) + |> Enum.to_list() + |> MapSet.new() + + acc + |> MapSet.difference(map_set) + end) + |> MapSet.to_list() + |> Enum.sort_by(& &1, :desc) + |> Enum.reduce({[], {nil, nil}}, fn num, {ranges, {start_range, end_range}} -> + if is_nil(start_range) do + {ranges, {num, num}} + else + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if end_range - num > 1 do + {[Range.new(start_range, end_range) | ranges], {num, num}} else - ranges + {ranges, {start_range, num}} end - end) + end + end) + |> then(fn {ranges, {start_range, end_range}} -> + if not is_nil(start_range) && not is_nil(end_range) do + [Range.new(start_range, end_range) | ranges] + else + ranges + end + end) + |> then(fn + [range | []] -> + insert_or_update_adjacent_ranges(range.first, range.last, priority, :both) + + [lowest_range | rest_ranges] -> + [highest_range | middle_ranges] = Enum.reverse(rest_ranges) + + insert_or_update_adjacent_ranges(lowest_range.first, lowest_range.last, priority, :down) + insert_or_update_adjacent_ranges(highest_range.first, highest_range.last, priority, :up) - ranges_to_fill - |> Enum.each(fn %Range{first: first, last: last} -> - range_params = %{from_number: first, to_number: last, priority: priority} - insert_range(range_params) + middle_ranges + |> Enum.each(fn %Range{first: first, last: last} -> + range_params = %{from_number: first, to_number: last, priority: priority} + insert_range(range_params) + end) + + [] -> + :ok end) end :ok end - defp fill_ranges_between(_from, _to, _priority), do: :ok + defp fill_ranges_between(_all_ranges, _from, _to, _priority), do: :ok - defp select_all_ranges_within_the_range(from, to) do - __MODULE__ - |> where([r], r.from_number <= ^from) - |> where([r], r.to_number >= ^to) - |> order_by([r], desc: r.from_number) - |> Repo.all() + defp insert_or_update_adjacent_ranges(from, to, priority, :both) do + upper_range = get_range_by_block_number(from + 1, priority) + lower_range = get_range_by_block_number(to - 1, priority) + + case {lower_range, upper_range} do + {nil, nil} -> + insert_range(%{from_number: from, to_number: to, priority: priority}) + + {_, nil} -> + update_range(lower_range, %{from_number: from}) + + {nil, _} -> + update_range(upper_range, %{to_number: to}) + + {_, _} -> + Repo.delete(lower_range) + update_range(upper_range, %{to_number: lower_range.to_number}) + end end - defp priority_filter(query, nil) do - where(query, [r], is_nil(r.priority)) + defp insert_or_update_adjacent_ranges(from, to, priority, direction) do + {range, update_params} = + case direction do + :up -> {get_range_by_block_number(from + 1, priority), %{to_number: to}} + :down -> {get_range_by_block_number(to - 1, priority), %{from_number: from}} + end + + if is_nil(range) do + insert_range(%{from_number: from, to_number: to, priority: priority}) + else + update_range(range, update_params) + end end - defp priority_filter(query, _priority) do - query + defp select_all_ranges_within_the_range(all_ranges, from, to) do + all_ranges + |> Enum.filter(fn range -> range.to_number >= to and range.from_number <= from end) + |> Enum.sort_by(& &1.from_number, &>=/2) end + defp priority_filter(range, nil), do: is_nil(range.priority) + defp priority_filter(_range, _priority), do: true + # Deletes all missing block ranges that overlap with the interval [from, to] - @spec delete_ranges_between(Block.block_number(), Block.block_number()) :: :ok - defp delete_ranges_between(from, to) do - from - |> from_number_below_query() - |> to_number_above_query(to) + @spec delete_ranges_between([__MODULE__.t()], Block.block_number(), Block.block_number()) :: :ok + defp delete_ranges_between(all_ranges, from, to) do + ids_to_delete = + all_ranges + |> Enum.filter(fn range -> range.to_number > to and range.from_number < from end) + |> Enum.map(& &1.id) + + __MODULE__ + |> where([r], r.id in ^ids_to_delete) |> Repo.delete_all() end @@ -604,9 +685,13 @@ defmodule Explorer.Utility.MissingBlockRange do """ @spec include_bound_query(Block.block_number()) :: Ecto.Query.t() def include_bound_query(bound) do - from(r in __MODULE__, where: r.from_number >= ^bound, where: r.to_number <= ^bound) + from(r in __MODULE__, where: fragment("int4range(?, ?, '[]') @> ?::int", r.to_number, r.from_number, ^bound)) end + defp priority_query(query, :not_specified), do: query + defp priority_query(query, nil), do: where(query, [m], is_nil(m.priority)) + defp priority_query(query, _priority), do: where(query, [m], not is_nil(m.priority)) + defp numbers_to_ranges([]), do: [] defp numbers_to_ranges(numbers) when is_list(numbers) do @@ -627,4 +712,33 @@ defmodule Explorer.Utility.MissingBlockRange do fn range -> {:cont, range, nil} end ) end + + @doc """ + Finds the priority of missing block ranges for a list of block numbers. + + This function takes a list of block numbers and checks which missing block ranges + they fall into, returning a list of maps with the block number and its associated + priority (if any). + + ## Parameters + - `numbers`: A list of block numbers to check against the missing block ranges. + + ## Returns + - A list of tuples, each containing: + - `:number`: The block number from the input list. + - `:priority`: The priority of the range that includes the block number. + """ + @spec find_priority_by_numbers([Block.block_number()]) :: %{Block.block_number() => integer() | nil} + def find_priority_by_numbers(numbers) do + query = + from(i in fragment("SELECT unnest(?::int[]) AS number", ^numbers), + left_join: m in __MODULE__, + on: fragment("? BETWEEN ? AND ?", i.number, m.to_number, m.from_number), + select: {i.number, m.priority} + ) + + query + |> Repo.all() + |> Enum.into(%{}) + end end diff --git a/apps/explorer/lib/explorer/utility/missing_ranges_manipulator.ex b/apps/explorer/lib/explorer/utility/missing_ranges_manipulator.ex deleted file mode 100644 index e66aaf4fa2ac..000000000000 --- a/apps/explorer/lib/explorer/utility/missing_ranges_manipulator.ex +++ /dev/null @@ -1,61 +0,0 @@ -defmodule Explorer.Utility.MissingRangesManipulator do - @moduledoc """ - Performs concurrent-safe actions on missing block ranges. - """ - - use GenServer - - alias Explorer.Utility.MissingBlockRange - - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) - end - - def get_latest_batch(size) do - GenServer.call(__MODULE__, {:get_latest_batch, size}) - end - - def clear_batch(batch) do - GenServer.call(__MODULE__, {:clear_batch, batch}, timeout(batch)) - end - - def save_batch(batch, priority \\ nil) do - GenServer.call(__MODULE__, {:save_batch, batch, priority}, timeout(batch)) - end - - def add_ranges_by_block_numbers(numbers, priority \\ nil) do - GenServer.cast(__MODULE__, {:add_ranges_by_block_numbers, numbers, priority}) - end - - @impl true - def init(_) do - {:ok, %{}} - end - - @impl true - def handle_call({:get_latest_batch, size}, _from, state) do - {:reply, MissingBlockRange.get_latest_batch(size), state} - end - - def handle_call({:clear_batch, batch}, _from, state) do - {:reply, MissingBlockRange.clear_batch(batch), state} - end - - def handle_call({:save_batch, batch, priority}, _from, state) do - {:reply, MissingBlockRange.save_batch(batch, priority), state} - end - - @impl true - def handle_cast({:add_ranges_by_block_numbers, numbers, priority}, state) do - MissingBlockRange.add_ranges_by_block_numbers(numbers, priority) - - {:noreply, state} - end - - @default_timeout 5000 - @timeout_by_range 2000 - defp timeout(batch) do - @default_timeout + @timeout_by_range * Enum.count(batch) - end -end diff --git a/apps/explorer/lib/explorer/utility/rate_limiter.ex b/apps/explorer/lib/explorer/utility/rate_limiter.ex index 4945b2ca3f6b..197cd2426063 100644 --- a/apps/explorer/lib/explorer/utility/rate_limiter.ex +++ b/apps/explorer/lib/explorer/utility/rate_limiter.ex @@ -1,8 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.RateLimiter do @moduledoc """ Rate limit logic with separation by action type and exponential backoff for bans. """ + alias Explorer.Helper + alias Explorer.Utility.Hammer + use GenServer require Logger @@ -14,8 +18,18 @@ defmodule Explorer.Utility.RateLimiter do config = Application.get_env(:explorer, __MODULE__) case config[:storage] do - :redis -> Redix.start_link(config[:redis_url], name: @redis_conn_name) - :ets -> GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + :redis -> + config[:redis_url] + |> Helper.redix_opts( + config[:redis_ssl], + config[:redis_sentinel_urls], + config[:redis_sentinel_master_name] + ) + |> Keyword.merge(name: @redis_conn_name) + |> Redix.start_link() + + :ets -> + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end end @@ -74,7 +88,7 @@ defmodule Explorer.Utility.RateLimiter do end end - defp key(identifier, action), do: "#{identifier}_#{action}" + defp key(identifier, action), do: "#{Application.get_env(:block_scout_web, :chain_id)}_#{identifier}_#{action}" defp parse_ban_data(ban_data) do ban_data @@ -90,7 +104,7 @@ defmodule Explorer.Utility.RateLimiter do time_interval_limit = config[:time_interval_limit] limit_by_ip = config[:limit_by_ip] - case Hammer.check_rate(key, time_interval_limit, limit_by_ip) do + case Hammer.hit(key, time_interval_limit, limit_by_ip) do {:allow, _count} -> false diff --git a/apps/explorer/lib/explorer/utility/replica_accessibility_manager.ex b/apps/explorer/lib/explorer/utility/replica_accessibility_manager.ex index a3388db20c04..59a949dd990a 100644 --- a/apps/explorer/lib/explorer/utility/replica_accessibility_manager.ex +++ b/apps/explorer/lib/explorer/utility/replica_accessibility_manager.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.ReplicaAccessibilityManager do @moduledoc """ Module responsible for periodically checking replica accessibility. @@ -6,6 +7,7 @@ defmodule Explorer.Utility.ReplicaAccessibilityManager do use GenServer alias Explorer.Repo + alias Utils.ConfigHelper @interval :timer.seconds(10) @@ -15,7 +17,7 @@ defmodule Explorer.Utility.ReplicaAccessibilityManager do end def init(_) do - if System.get_env("DATABASE_READ_ONLY_API_URL") do + if ConfigHelper.parse_url_env_var("DATABASE_READ_ONLY_API_URL") do schedule_next_check(0) {:ok, %{}} diff --git a/apps/explorer/lib/explorer/utility/token_instance_metadata_refetch_attempt.ex b/apps/explorer/lib/explorer/utility/token_instance_metadata_refetch_attempt.ex index a40e754d36e4..89bb1406ef22 100644 --- a/apps/explorer/lib/explorer/utility/token_instance_metadata_refetch_attempt.ex +++ b/apps/explorer/lib/explorer/utility/token_instance_metadata_refetch_attempt.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.TokenInstanceMetadataRefetchAttempt do @moduledoc """ Module is responsible for keeping the number of retries for diff --git a/apps/explorer/lib/explorer/utility/version_constants_updater.ex b/apps/explorer/lib/explorer/utility/version_constants_updater.ex new file mode 100644 index 000000000000..e5a91095125e --- /dev/null +++ b/apps/explorer/lib/explorer/utility/version_constants_updater.ex @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.VersionConstantsUpdater do + @moduledoc """ + Module responsible for updating current and previous backend version in table `constants`. + """ + + use GenServer + + alias Explorer.Application.Constants + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + set_versions() + :ignore + end + + defp set_versions do + stored_current_version = Constants.get_current_backend_version() + current_version = to_string(Application.spec(:explorer, :vsn)) + + if stored_current_version != current_version do + Constants.insert_current_backend_version(current_version) + end + end +end diff --git a/apps/explorer/lib/explorer/utility/version_upgrade.ex b/apps/explorer/lib/explorer/utility/version_upgrade.ex new file mode 100644 index 000000000000..46132c5da454 --- /dev/null +++ b/apps/explorer/lib/explorer/utility/version_upgrade.ex @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.VersionUpgrade do + @moduledoc """ + Provides validation logic for application version upgrades. + + This module ensures that upgrading from one version of the application + to another is allowed according to a set of predefined rules. It is + designed to prevent unsafe upgrades that could lead to inconsistent + data or incomplete migrations. + + ## Upgrade Rules + + Upgrade rules are defined as a list of maps in `@upgrade_rules`. Each rule + applies to a range of target versions and has the following structure: + + * `:since` — the minimum target version (inclusive) for which the rule applies + * `:min_from` — the minimum allowed source version (inclusive) + * `:required_completed_migrations` — a list of migration names that must + have status `"completed"` before the upgrade is allowed + + Rules are evaluated based on the target version. If multiple rules match, + the most specific one (with the highest `:since` version) is applied. + + If no rule matches the target version, the upgrade is allowed by default. + """ + + use GenServer + + alias Explorer.Application.Constants + alias Explorer.Chain.Block + alias Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey + alias Explorer.Migrator.MigrationStatus + alias Explorer.Repo + + @upgrade_rules [ + %{ + since: "11.0.0", + min_from: "10.2.3", + required_completed_migrations: [UpdateInternalTransactionsPrimaryKey.migration_name()] + } + ] + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + validate_current_upgrade() + :ignore + end + + def validate_current_upgrade do + stored_current_version = Constants.get_current_backend_version() + current_version = to_string(Application.spec(:explorer, :vsn)) + + validate_upgrade(stored_current_version, current_version) + end + + def validate_upgrade(nil, to_version) do + case find_applicable_rule(to_version) do + nil -> + :ok + + %{min_from: min_from} -> + if Repo.exists?(Block) do + raise_wrong_version(nil, to_version, min_from) + else + :ok + end + end + end + + def validate_upgrade(from_version, to_version) do + case find_applicable_rule(to_version) do + nil -> + :ok + + rule -> + validate_min_from!(from_version, to_version, rule) + validate_required_migrations!(to_version, rule) + end + end + + defp find_applicable_rule(to_version) do + @upgrade_rules + |> Enum.filter(fn %{since: since_version} -> + Version.compare(to_version, since_version) in [:eq, :gt] + end) + |> Enum.max_by(&Version.parse!(&1.since), Version) + end + + defp validate_min_from!(from_version, to_version, %{min_from: min_from}) do + if Version.compare(from_version, min_from) in [:eq, :gt] do + :ok + else + raise_wrong_version(from_version, to_version, min_from) + end + end + + defp validate_required_migrations!(_to_version, %{required_completed_migrations: []}), do: :ok + + defp validate_required_migrations!(to_version, %{required_completed_migrations: migration_names}) do + not_completed = + Enum.flat_map(migration_names, fn migration_name -> + status = MigrationStatus.get_status(migration_name) + + if status == "completed" do + [] + else + ["#{migration_name} (status: #{inspect(status)})"] + end + end) + + if not_completed == [] do + :ok + else + raise_not_completed_migrations(to_version, not_completed) + end + end + + defp validate_required_migrations!(_to_version, _rule), do: :ok + + defp raise_wrong_version(from_version, to_version, min_from) do + raise "Upgrade to #{to_version} is allowed only from version #{min_from} and higher. Current previous version: #{from_version || "(empty)"}" + end + + defp raise_not_completed_migrations(to_version, not_completed) do + raise "Upgrade to #{to_version} is not allowed because required migrations are not completed: #{Enum.join(not_completed, ", ")}" + end +end diff --git a/apps/explorer/lib/explorer/validator/metadata_importer.ex b/apps/explorer/lib/explorer/validator/metadata_importer.ex index 8cc3792a77c3..2e0837408a45 100644 --- a/apps/explorer/lib/explorer/validator/metadata_importer.ex +++ b/apps/explorer/lib/explorer/validator/metadata_importer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Validator.MetadataImporter do @moduledoc """ module that upserts validator metadata from a list of maps diff --git a/apps/explorer/lib/explorer/validator/metadata_processor.ex b/apps/explorer/lib/explorer/validator/metadata_processor.ex index ebc9db9d46f0..0b627a0784f1 100644 --- a/apps/explorer/lib/explorer/validator/metadata_processor.ex +++ b/apps/explorer/lib/explorer/validator/metadata_processor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Validator.MetadataProcessor do @moduledoc """ module to periodically retrieve and update metadata belonging to validators diff --git a/apps/explorer/lib/explorer/validator/metadata_retriever.ex b/apps/explorer/lib/explorer/validator/metadata_retriever.ex index 10794167b13c..e189faf8ce67 100644 --- a/apps/explorer/lib/explorer/validator/metadata_retriever.ex +++ b/apps/explorer/lib/explorer/validator/metadata_retriever.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Validator.MetadataRetriever do @moduledoc """ Consults the configured smart contracts to fetch the validators' metadata @@ -5,16 +6,18 @@ defmodule Explorer.Validator.MetadataRetriever do alias Explorer.SmartContract.Reader + @spec fetch_data() :: [%{address_hash: String.t(), primary: true, name: String.t(), metadata: map()}] def fetch_data do fetch_validators_list() |> Enum.map(fn validator -> validator - |> fetch_validator_metadata - |> translate_metadata + |> fetch_validator_metadata() + |> translate_metadata() |> Map.merge(%{address_hash: validator, primary: true}) end) end + @spec fetch_validators_list() :: [String.t()] def fetch_validators_list do validators_contract_address = config(:validators_contract_address) diff --git a/apps/explorer/lib/explorer/vault.ex b/apps/explorer/lib/explorer/vault.ex index 70fc1ed17d7a..58f753966334 100644 --- a/apps/explorer/lib/explorer/vault.ex +++ b/apps/explorer/lib/explorer/vault.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Vault do @moduledoc """ Module responsible for encrypt/decrypt GenServer initialization diff --git a/apps/explorer/lib/explorer/visualize/sol2uml.ex b/apps/explorer/lib/explorer/visualize/sol2uml.ex index 459531a36299..fe32c719423d 100644 --- a/apps/explorer/lib/explorer/visualize/sol2uml.ex +++ b/apps/explorer/lib/explorer/visualize/sol2uml.ex @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Visualize.Sol2uml do @moduledoc """ Adapter for sol2uml visualizer with https://github.com/blockscout/blockscout-rs/blob/main/visualizer """ + alias Explorer.HttpClient alias Explorer.Utility.Microservice - alias HTTPoison.Response require Logger @post_timeout 60_000 @@ -13,14 +14,14 @@ defmodule Explorer.Visualize.Sol2uml do http_post_request(visualize_contracts_url(), body) end - def http_post_request(url, body) do + defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HTTPoison.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do + {:ok, %{body: body, status_code: 200}} -> process_visualizer_response(body) - {:ok, %Response{body: body, status_code: status_code}} -> + {:ok, %{body: body, status_code: status_code}} -> Logger.error(fn -> ["Invalid status code from visualizer: #{status_code}. body: ", inspect(body)] end) {:error, "failed to visualize contract"} diff --git a/apps/explorer/lib/fetch_celo_core_contracts.ex b/apps/explorer/lib/fetch_celo_core_contracts.ex index 6d720350aa9c..d68dbc1fdaf0 100644 --- a/apps/explorer/lib/fetch_celo_core_contracts.ex +++ b/apps/explorer/lib/fetch_celo_core_contracts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Mix.Tasks.FetchCeloCoreContracts do @moduledoc """ Fetch the addresses of celo core contracts: `mix help celo-contracts` diff --git a/apps/explorer/lib/mix/tasks/auth0_to_keycloak_migrate.ex b/apps/explorer/lib/mix/tasks/auth0_to_keycloak_migrate.ex new file mode 100644 index 000000000000..3821815677cb --- /dev/null +++ b/apps/explorer/lib/mix/tasks/auth0_to_keycloak_migrate.ex @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Mix.Tasks.Auth0ToKeycloakMigrate do + @moduledoc """ + Migrates users from Auth0 to Keycloak. + + Reads all account identities with Auth0 UIDs, fetches their Auth0 user data, + creates corresponding Keycloak users, and updates the identity UIDs. + + ## Usage + + mix auth0_to_keycloak_migrate [--dry-run] [--batch-size N] + + ## Options + + * `--dry-run` - Preview migration without making changes + * `--batch-size` - Number of users per batch (default: 50) + + ## Prerequisites + + Both Auth0 and Keycloak must be configured via environment variables. + Account access is automatically disabled during migration and re-enabled after. + """ + + use Mix.Task + + alias Explorer.Account.Auth0ToKeycloakMigration + alias Mix.Task, as: MixTask + + @shortdoc "Migrate users from Auth0 to Keycloak" + + @impl MixTask + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + switches: [dry_run: :boolean, batch_size: :integer], + aliases: [n: :dry_run, b: :batch_size] + ) + + MixTask.run("app.start") + + Auth0ToKeycloakMigration.run(opts) + end +end diff --git a/apps/explorer/lib/release_tasks.ex b/apps/explorer/lib/release_tasks.ex index a55174738421..b198ab3ba170 100644 --- a/apps/explorer/lib/release_tasks.ex +++ b/apps/explorer/lib/release_tasks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ReleaseTasks do @moduledoc """ Release tasks used to migrate or generate seeds. diff --git a/apps/explorer/lib/test_helper.ex b/apps/explorer/lib/test_helper.ex index 7bb268a85c22..c91a2b12ea97 100644 --- a/apps/explorer/lib/test_helper.ex +++ b/apps/explorer/lib/test_helper.ex @@ -1,116 +1,225 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.TestHelper do @moduledoc false import Mox + import EthereumJSONRPC, only: [integer_to_quantity: 1] alias ABI.TypeEncoder + alias Explorer.Chain.Hash + alias Explorer.Chain.SmartContract.Proxy.ResolvedDelegateProxy - def mock_logic_storage_pointer_request( + @zero_address_hash %Hash{byte_count: 20, bytes: <<0::160>>} + @random_beacon_address_hash %Hash{byte_count: 20, bytes: <<0x3C7EC3E3B80D78FBDD348D796466AB828B45234F::160>>} + @random_address_manager_address_hash %Hash{byte_count: 20, bytes: <<0xBFCEF74A0522F50A48C759D05BCE97FAB2CA84C6::160>>} + + @implementation_name_storage_value "0x494d504c454d454e544154494f4e00000000000000000000000000000000001c" + # cast cd 'getAddress(string)' IMPLEMENTATION + @address_manager_calldata "0xbf40fac10000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000e494d504c454d454e544154494f4e000000000000000000000000000000000000" + + # TODO: Execute this for all background migrations + def run_necessary_background_migrations do + for background_migration <- [ + Explorer.Migrator.HeavyDbIndexOperation.CreateInternalTransactionsBlockNumberTransactionIndexIndexUniqueIndex, + Explorer.Migrator.HeavyDbIndexOperation.UpdateInternalTransactionsPrimaryKey + ] do + case background_migration.db_index_operation_status() do + :completed -> + :ok + + _ -> + execute_background_migration(background_migration) + end + end + end + + defp execute_background_migration(background_migration) do + case background_migration.db_index_operation() do + :ok -> + background_migration.update_cache() + :ok + + :error -> + raise "Background migrations failed" + end + end + + def mock_erc7760_basic_requests( mox, error?, - resp \\ "0x0000000000000000000000000000000000000000000000000000000000000000" + %Hash{} = address_hash \\ @zero_address_hash ) do - response = if error?, do: {:error, "error"}, else: {:ok, resp} - - expect(mox, :json_rpc, fn %{ - id: 0, - method: "eth_getStorageAt", - params: [ - _, - "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", - "latest" - ] - }, + expect(mox, :json_rpc, fn [ + %{ + id: id, + method: "eth_getStorageAt", + params: [ + _, + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", + "latest" + ] + } + ], _options -> - response + if error?, + do: {:error, "error"}, + else: + {:ok, + [ + %{id: id, result: address_hash_to_full_hash_string(address_hash)} + ]} end) end - def mock_beacon_storage_pointer_request( + def mock_erc7760_beacon_requests( mox, error?, - resp \\ "0x0000000000000000000000000000000000000000000000000000000000000000" + %Hash{} = address_hash \\ @zero_address_hash ) do - response = if error?, do: {:error, "error"}, else: {:ok, resp} + if error? do + expect(mox, :json_rpc, fn [ + %{ + id: _, + method: "eth_getStorageAt", + params: [ + _, + "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50", + "latest" + ] + } + ], + _options -> + {:error, "error"} + end) + else + beacon_address_hash_string = to_string(@random_beacon_address_hash) - expect(mox, :json_rpc, fn %{ - id: 0, - method: "eth_getStorageAt", + mox + |> expect(:json_rpc, fn [ + %{ + id: id, + method: "eth_getStorageAt", + params: [ + _, + "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50", + "latest" + ] + } + ], + _options -> + {:ok, + [ + %{id: id, result: address_hash_to_full_hash_string(@random_beacon_address_hash)} + ]} + end) + |> expect(:json_rpc, fn %{ + id: _, + method: "eth_call", params: [ - _, - "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50", + %{ + data: "0x5c60da1b", + to: ^beacon_address_hash_string + }, "latest" ] }, _options -> - response - end) + {:ok, address_hash_to_full_hash_string(address_hash)} + end) + end end - def mock_oz_storage_pointer_request( + def mock_resolved_delegate_proxy_requests( mox, - error?, - resp \\ "0x0000000000000000000000000000000000000000000000000000000000000000" + %Hash{} = proxy_address_hash, + %Hash{} = implementation_address_hash \\ @zero_address_hash ) do - response = if error?, do: {:error, "error"}, else: {:ok, resp} + proxy_address_hash_string = to_string(proxy_address_hash) + address_manager_address_hash_string = to_string(@random_address_manager_address_hash) - expect(mox, :json_rpc, fn %{ - id: 0, + [ + storage: implementation_name_slot, + storage: address_manager_slot + ] = ResolvedDelegateProxy.get_fetch_requirements(proxy_address_hash) + + mox + |> expect(:json_rpc, fn [ + %{ + id: id1, method: "eth_getStorageAt", params: [ - _, - "0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3", + ^proxy_address_hash_string, + ^implementation_name_slot, "latest" ] }, - _options -> - response - end) - end - - def mock_eip_1822_storage_pointer_request( - mox, - error?, - resp \\ "0x0000000000000000000000000000000000000000000000000000000000000000" - ) do - response = if error?, do: {:error, "error"}, else: {:ok, resp} - - expect(mox, :json_rpc, fn %{ - id: 0, + %{ + id: id2, method: "eth_getStorageAt", params: [ - _, - "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7", + ^proxy_address_hash_string, + ^address_manager_slot, "latest" ] - }, - _options -> - response + } + ], + _options -> + {:ok, + [ + %{id: id1, result: @implementation_name_storage_value}, + %{id: id2, result: address_hash_to_full_hash_string(@random_address_manager_address_hash)} + ]} end) - end - - def mock_eip_2535_storage_pointer_request( - mox, - error?, - resp \\ "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000" - ) do - response = - if error?, - do: {:error, "error"}, - else: - {:ok, - [ + |> expect( + :json_rpc, + fn %{ + id: _, + method: "eth_call", + params: [ %{ - id: 0, - jsonrpc: "2.0", - result: resp - } - ]} + data: @address_manager_calldata, + to: ^address_manager_address_hash_string + }, + "latest" + ] + }, + _options -> + {:ok, address_hash_to_full_hash_string(implementation_address_hash)} + end + ) + end + def mock_generic_proxy_requests(mox, mocks \\ []) do expect(mox, :json_rpc, fn [ %{ - id: 0, - jsonrpc: "2.0", + id: id1, + method: "eth_getStorageAt", + params: [ + _, + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", + "latest" + ] + }, + %{ + id: id2, + method: "eth_getStorageAt", + params: [ + _, + "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7", + "latest" + ] + }, + %{ + id: id3, + method: "eth_getStorageAt", + params: [ + _, + "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50", + "latest" + ] + }, + %{ + id: id4, method: "eth_call", params: [ %{ @@ -119,133 +228,61 @@ defmodule Explorer.TestHelper do }, "latest" ] - } - ], - _options -> - response - end) - end - - def mock_resolved_delegate_proxy_get_owner_request( - mox, - error?, - resp \\ "0x0000000000000000000000000000000000000000000000000000000000000000" - ) do - response = - if error?, - do: {:error, "error"}, - else: - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: resp - } - ]} - - expect(mox, :json_rpc, fn [ + }, %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", + id: id5, + method: "eth_getStorageAt", params: [ - %{ - data: "0x8da5cb5b", - to: _ - }, + _, + "0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3", "latest" ] } + | rest ], _options -> - response + {:ok, + [ + mocks |> Keyword.get(:eip1967, @zero_address_hash) |> encode_in_batch_response(id1), + mocks |> Keyword.get(:eip1822, @zero_address_hash) |> encode_in_batch_response(id2), + mocks |> Keyword.get(:eip1967_beacon, @zero_address_hash) |> encode_in_batch_response(id3), + %{id: id4, error: "error"}, + mocks |> Keyword.get(:eip1967_oz, @zero_address_hash) |> encode_in_batch_response(id5) + ] ++ + Enum.map(rest, fn + %{id: id6, method: "eth_call", params: [%{data: "0x5c60da1b", to: _}, "latest"]} -> + mocks |> Keyword.get(:basic_implementation, @zero_address_hash) |> encode_in_batch_response(id6) + end)} end) - end - - def mock_resolved_delegate_proxy_get_implementation_from_owner_request( - mox, - error?, - proxy_address_hash_string_without_0x, - resp \\ "0x0000000000000000000000000000000000000000000000000000000000000000" - ) do - data = "0x204e1c7a" <> "000000000000000000000000" <> proxy_address_hash_string_without_0x - response = - if error?, - do: {:error, "error"}, - else: - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: resp - } - ]} + if Keyword.get(mocks, :eip1967_beacon) && Keyword.get(mocks, :eip1967_beacon_implementation) do + beacon_address_hash_string = to_string(Keyword.get(mocks, :eip1967_beacon)) - expect(mox, :json_rpc, fn [ - %{ - id: _, - jsonrpc: "2.0", + expect(mox, :json_rpc, fn %{ + id: 0, method: "eth_call", params: [ %{ - data: ^data, - to: _ + data: "0x5c60da1b", + to: ^beacon_address_hash_string }, "latest" ] - } - ], - _options -> - response - end) + }, + _options -> + {:ok, address_hash_to_full_hash_string(Keyword.get(mocks, :eip1967_beacon_implementation))} + end) + end end - def get_eip1967_implementation_non_zero_address(implementation_address_hash_string) do - EthereumJSONRPC.Mox - |> mock_logic_storage_pointer_request(false) - |> mock_beacon_storage_pointer_request(false) - |> mock_oz_storage_pointer_request(false, "0x000000000000000000000000" <> implementation_address_hash_string) - end + defp encode_in_batch_response(%Hash{byte_count: 20, bytes: _} = address_hash, id), + do: %{id: id, result: address_hash_to_full_hash_string(address_hash)} - def get_resolved_delegate_proxy_implementation_non_zero_address( - owner_address_hash_string_without_0x, - implementation_address_hash_string_without_0x, - proxy_address_hash_string_without_0x - ) do - EthereumJSONRPC.Mox - |> mock_logic_storage_pointer_request(false) - |> mock_beacon_storage_pointer_request(false) - |> mock_oz_storage_pointer_request(false) - |> mock_eip_1822_storage_pointer_request(false) - |> mock_eip_2535_storage_pointer_request(false) - |> mock_resolved_delegate_proxy_get_owner_request( - false, - "0x000000000000000000000000" <> owner_address_hash_string_without_0x - ) - |> mock_resolved_delegate_proxy_get_implementation_from_owner_request( - false, - proxy_address_hash_string_without_0x, - "0x000000000000000000000000" <> implementation_address_hash_string_without_0x - ) - end + defp encode_in_batch_response(:error, id), + do: %{id: id, error: "error"} - def get_all_proxies_implementation_zero_addresses do - EthereumJSONRPC.Mox - |> mock_logic_storage_pointer_request(false) - |> mock_beacon_storage_pointer_request(false) - |> mock_oz_storage_pointer_request(false) - |> mock_eip_1822_storage_pointer_request(false) - |> mock_eip_2535_storage_pointer_request(false) - end - - def get_eip1967_implementation_error_response do - EthereumJSONRPC.Mox - |> mock_logic_storage_pointer_request(true) - |> mock_beacon_storage_pointer_request(true) - |> mock_oz_storage_pointer_request(true) + defp address_hash_to_full_hash_string(%Hash{byte_count: 20, bytes: bytes}) do + to_string(%Hash{byte_count: 32, bytes: <<0::96, bytes::binary>>}) end def fetch_token_uri_mock(url, token_contract_address_hash_string) do @@ -286,4 +323,82 @@ defmodule Explorer.TestHelper do ]} end) end + + def get_chain_id_mock do + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn %{ + id: _id, + method: "eth_chainId", + params: [] + }, + _options -> + {:ok, "0x1"} + end) + end + + def topic(topic_hex_string) do + {:ok, topic} = Explorer.Chain.Hash.Full.cast(topic_hex_string) + topic + end + + defp eth_block_number_fake_response(block_quantity, miner \\ "0x0000000000000000000000000000000000000000", id \\ 0) do + %{ + id: id, + jsonrpc: "2.0", + result: %{ + "author" => miner, + "difficulty" => "0x20000", + "extraData" => "0x", + "gasLimit" => "0x663be0", + "gasUsed" => "0x0", + "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => miner, + "number" => block_quantity, + "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sealFields" => [ + "0x80", + "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "size" => "0x215", + "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", + "step" => "0", + "timestamp" => "0x0", + "totalDifficulty" => "0x20000", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + end + + def eth_get_block_by_number_expectation(block_number) do + block_quantity = integer_to_quantity(block_number) + res = eth_block_number_fake_response(block_quantity) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: [^block_quantity, true]} + ], + _opts -> + {:ok, [res]} + end) + end + + def eth_get_balance_expectation(address_hash, latest_block_number_hex, balance) do + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ + %{ + id: id, + method: "eth_getBalance", + params: [^address_hash, ^latest_block_number_hex] + } + ], + _options -> + {:ok, [%{id: id, jsonrpc: "2.0", result: balance}]} + end) + end end diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index 1b48769c3c6d..6a99382e41ef 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Mixfile do use Mix.Project @@ -15,16 +16,12 @@ defmodule Explorer.Mixfile do plt_add_apps: ~w(ex_unit mix)a, ignore_warnings: "../../.dialyzer_ignore.exs" ], - elixir: "~> 1.17", + elixir: "~> 1.19", elixirc_paths: elixirc_paths(Mix.env()), lockfile: "../../mix.lock", package: package(), - preferred_cli_env: [ - credo: :test, - dialyzer: :test - ], start_permanent: Mix.env() == :prod, - version: "8.0.2", + version: "11.2.2", xref: [exclude: [BlockScoutWeb.Routers.WebRouter.Helpers, Indexer.Helper, Indexer.Fetcher.InternalTransaction]] ] end @@ -39,6 +36,10 @@ defmodule Explorer.Mixfile do ] end + def cli do + [preferred_envs: [credo: :test, dialyzer: :test]] + end + # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["test/support" | elixirc_paths()] defp elixirc_paths(_), do: elixirc_paths() @@ -57,11 +58,11 @@ defmodule Explorer.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ - {:bamboo, "~> 2.4.0"}, + {:bamboo, "~> 2.5.0"}, {:mime, "~> 2.0"}, {:bcrypt_elixir, "~> 3.0"}, # benchmark optimizations - {:benchee, "~> 1.4.0", only: :test}, + {:benchee, "~> 1.5.0", only: :test}, # CSV output for benchee {:benchee_csv, "~> 1.0.0", only: :test}, {:bypass, "~> 2.1", only: :test}, @@ -86,23 +87,23 @@ defmodule Explorer.Mixfile do {:httpoison, "~> 2.0"}, {:jason, "~> 1.3"}, {:junit_formatter, ">= 0.0.0", only: [:test], runtime: false}, + {:libcluster, "~> 3.5"}, # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, + {:logger_json, "~> 7.0"}, {:math, "~> 0.7.0"}, {:mock, "~> 0.3.0", only: [:test], runtime: false}, - {:mox, "~> 1.0"}, - {:phoenix_html, "== 3.3.4"}, - {:poison, "~> 4.0.1"}, + {:mox, "~> 1.1.0"}, + {:poison, "~> 5.0.0"}, {:nimble_csv, "~> 1.1"}, {:postgrex, ">= 0.0.0"}, - # For compatibility with `prometheus_process_collector`, which hasn't been updated yet - {:prometheus, "~> 4.0", override: true}, + {:prometheus, "~> 6.0", override: true}, # Prometheus metrics for query duration {:prometheus_ecto, "~> 1.4.3"}, - {:prometheus_ex, git: "https://github.com/lanodan/prometheus.ex", branch: "fix/elixir-1.14", override: true}, + {:prometheus_ex, "~> 5.1.0", override: true}, # bypass optional dependency {:plug_cowboy, "~> 2.2", only: [:dev, :test]}, - {:que, "~> 0.10.1"}, + {:que, "~> 0.12.0"}, {:sobelow, ">= 0.7.0", only: [:dev, :test], runtime: false}, # Tracing {:spandex, "~> 3.0"}, @@ -111,16 +112,15 @@ defmodule Explorer.Mixfile do # `:spandex` tracing of `:ecto` {:spandex_ecto, "~> 0.7.0"}, # Attach `:prometheus_ecto` to `:ecto` - {:telemetry, "~> 1.3.0"}, + {:telemetry, "~> 1.4.1"}, # `Timex.Duration` for `Explorer.Chain.Cache.Counters.AverageBlockTime.average_block_time/0` {:timex, "~> 3.7.1"}, {:con_cache, "~> 1.0"}, - {:tesla, "~> 1.14.1"}, + {:tesla, "~> 1.20.0"}, {:cbor, "~> 1.0"}, {:cloak_ecto, "~> 1.3.0"}, {:redix, "~> 1.1"}, - {:hammer_backend_redis, "~> 6.1"}, - {:logger_json, "~> 5.1"}, + {:hammer_backend_redis, "~> 7.0"}, {:typed_ecto_schema, "~> 0.4.1"}, {:ueberauth, "~> 0.7"}, {:recon, "~> 2.5"}, @@ -130,10 +130,14 @@ defmodule Explorer.Mixfile do {:oauth2, "~> 2.0"}, {:siwe, github: "royal-markets/siwe-ex", ref: "51c9c08240eb7eea3c35693011f8d260cd9bb3be"}, {:joken, "~> 2.6"}, + {:joken_jwks, "~> 1.7.0"}, {:utils, in_umbrella: true}, {:dns, "~> 2.4.0"}, {:inet_cidr, "~> 1.0.0"}, - {:hammer, "~> 6.0"} + {:hammer, "~> 7.0"}, + {:ton, "~> 0.5.0"}, + {:mint, "~> 1.0"}, + {:oban, "~> 2.19"} ] end @@ -160,7 +164,7 @@ defmodule Explorer.Mixfile do defp package do [ maintainers: ["Blockscout"], - licenses: ["GPL 3.0"], + licenses: ["Blockscout Software Licence"], links: %{"GitHub" => "https://github.com/blockscout/blockscout"} ] end diff --git a/apps/explorer/package-lock.json b/apps/explorer/package-lock.json index c27dd57478f0..a7e7328459ef 100644 --- a/apps/explorer/package-lock.json +++ b/apps/explorer/package-lock.json @@ -5,9 +5,9 @@ "packages": { "": { "name": "blockscout", - "license": "GPL-3.0", + "license": "SEE LICENSE IN ../../LICENSE", "dependencies": { - "solc": "0.8.29" + "solc": "0.8.30" }, "engines": { "node": "18.x", @@ -76,9 +76,9 @@ } }, "node_modules/solc": { - "version": "0.8.29", - "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.29.tgz", - "integrity": "sha512-M1hmcsejAtT1RWC+wy45Oi8a82nSnDl65wDzA3/8uWTC7R7VcnrMVIHv6/LHqnQPZ7OM2RIeKh3zpNo4E5lYbw==", + "version": "0.8.30", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.30.tgz", + "integrity": "sha512-9Srk/gndtBmoUbg4CE6ypAzPQlElv8ntbnl6SigUBAzgXKn35v87sj04uZeoZWjtDkdzT0qKFcIo/wl63UMxdw==", "license": "MIT", "dependencies": { "command-exists": "^1.2.8", @@ -145,9 +145,9 @@ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==" }, "solc": { - "version": "0.8.29", - "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.29.tgz", - "integrity": "sha512-M1hmcsejAtT1RWC+wy45Oi8a82nSnDl65wDzA3/8uWTC7R7VcnrMVIHv6/LHqnQPZ7OM2RIeKh3zpNo4E5lYbw==", + "version": "0.8.30", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.30.tgz", + "integrity": "sha512-9Srk/gndtBmoUbg4CE6ypAzPQlElv8ntbnl6SigUBAzgXKn35v87sj04uZeoZWjtDkdzT0qKFcIo/wl63UMxdw==", "requires": { "command-exists": "^1.2.8", "commander": "^8.1.0", diff --git a/apps/explorer/package.json b/apps/explorer/package.json index 26858ef1b45b..85878348285a 100644 --- a/apps/explorer/package.json +++ b/apps/explorer/package.json @@ -6,13 +6,13 @@ "private": true, "name": "blockscout", "author": "Blockscout", - "license": "GPL-3.0", + "license": "SEE LICENSE IN ../../LICENSE", "engines": { "node": "18.x", "npm": "8.x" }, "scripts": {}, "dependencies": { - "solc": "0.8.29" + "solc": "0.8.30" } } diff --git a/apps/explorer/priv/account/migrations/20211031164954_create_account_identities.exs b/apps/explorer/priv/account/migrations/20211031164954_create_account_identities.exs index fb571247f0b4..7b2684487a62 100644 --- a/apps/explorer/priv/account/migrations/20211031164954_create_account_identities.exs +++ b/apps/explorer/priv/account/migrations/20211031164954_create_account_identities.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateAccountIdentities do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20211105114502_create_account_watchlists.exs b/apps/explorer/priv/account/migrations/20211105114502_create_account_watchlists.exs index 2e7c93f3cc97..006fbf951a16 100644 --- a/apps/explorer/priv/account/migrations/20211105114502_create_account_watchlists.exs +++ b/apps/explorer/priv/account/migrations/20211105114502_create_account_watchlists.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateAccountWatchlists do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20211105130907_create_account_watchlist_addresses.exs b/apps/explorer/priv/account/migrations/20211105130907_create_account_watchlist_addresses.exs index ef51c1850e5e..01a658390551 100644 --- a/apps/explorer/priv/account/migrations/20211105130907_create_account_watchlist_addresses.exs +++ b/apps/explorer/priv/account/migrations/20211105130907_create_account_watchlist_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateAccountWatchlistAddresses do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20211127212336_create_account_watchlist_notifications.exs b/apps/explorer/priv/account/migrations/20211127212336_create_account_watchlist_notifications.exs index 7d4af566fe46..3b48fd238e13 100644 --- a/apps/explorer/priv/account/migrations/20211127212336_create_account_watchlist_notifications.exs +++ b/apps/explorer/priv/account/migrations/20211127212336_create_account_watchlist_notifications.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateAccountWatchlistNotifications do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20211205220414_add_email_and_name_to_account_identity.exs b/apps/explorer/priv/account/migrations/20211205220414_add_email_and_name_to_account_identity.exs index 0633e0dbaeac..3c4cb7de8f72 100644 --- a/apps/explorer/priv/account/migrations/20211205220414_add_email_and_name_to_account_identity.exs +++ b/apps/explorer/priv/account/migrations/20211205220414_add_email_and_name_to_account_identity.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddEmailToAccountIdentity do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220212222222_create_account_tag_addresses.exs b/apps/explorer/priv/account/migrations/20220212222222_create_account_tag_addresses.exs index df0074041ee9..027c1fd00481 100644 --- a/apps/explorer/priv/account/migrations/20220212222222_create_account_tag_addresses.exs +++ b/apps/explorer/priv/account/migrations/20220212222222_create_account_tag_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateAccountTagAddresses do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220313133333_create_account_tag_transactions.exs b/apps/explorer/priv/account/migrations/20220313133333_create_account_tag_transactions.exs index ba6fa338b984..545fcca1a68f 100644 --- a/apps/explorer/priv/account/migrations/20220313133333_create_account_tag_transactions.exs +++ b/apps/explorer/priv/account/migrations/20220313133333_create_account_tag_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateAccountTagTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220324213333_add_subject_to_watchlist_notifications.exs b/apps/explorer/priv/account/migrations/20220324213333_add_subject_to_watchlist_notifications.exs index 6ff8a359ba4d..18a4c387563f 100644 --- a/apps/explorer/priv/account/migrations/20220324213333_add_subject_to_watchlist_notifications.exs +++ b/apps/explorer/priv/account/migrations/20220324213333_add_subject_to_watchlist_notifications.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddSubjectToWatchlistNotifications do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220407134152_add_api_keys_and_plans_tables.exs b/apps/explorer/priv/account/migrations/20220407134152_add_api_keys_and_plans_tables.exs index 11f23ffd6126..a832eeedfdcb 100644 --- a/apps/explorer/priv/account/migrations/20220407134152_add_api_keys_and_plans_tables.exs +++ b/apps/explorer/priv/account/migrations/20220407134152_add_api_keys_and_plans_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddApiKeysAndPlansTables do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220510094118_add_custom_abis_table.exs b/apps/explorer/priv/account/migrations/20220510094118_add_custom_abis_table.exs index a248c79458bf..2eb16d2211a9 100644 --- a/apps/explorer/priv/account/migrations/20220510094118_add_custom_abis_table.exs +++ b/apps/explorer/priv/account/migrations/20220510094118_add_custom_abis_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddCustomAbisTable do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220606194836_add_account_public_tags_requests.exs b/apps/explorer/priv/account/migrations/20220606194836_add_account_public_tags_requests.exs index dd0a9896b0ca..3135cb15faec 100644 --- a/apps/explorer/priv/account/migrations/20220606194836_add_account_public_tags_requests.exs +++ b/apps/explorer/priv/account/migrations/20220606194836_add_account_public_tags_requests.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddAccountPublicTagsRequests do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220620182600_add_account_identity_fields.exs b/apps/explorer/priv/account/migrations/20220620182600_add_account_identity_fields.exs index 1c7b13535f3c..fd5e69a8ebeb 100644 --- a/apps/explorer/priv/account/migrations/20220620182600_add_account_identity_fields.exs +++ b/apps/explorer/priv/account/migrations/20220620182600_add_account_identity_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddAccountIdentityFields do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220624142547_add_unique_constraints.exs b/apps/explorer/priv/account/migrations/20220624142547_add_unique_constraints.exs index 53293983d4a6..53cdc05d7ef9 100644 --- a/apps/explorer/priv/account/migrations/20220624142547_add_unique_constraints.exs +++ b/apps/explorer/priv/account/migrations/20220624142547_add_unique_constraints.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddUniqueConstraints do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220705195240_migrate_public_tags_addresses_to_array.exs b/apps/explorer/priv/account/migrations/20220705195240_migrate_public_tags_addresses_to_array.exs index 1282e2166c15..3014c9100c1d 100644 --- a/apps/explorer/priv/account/migrations/20220705195240_migrate_public_tags_addresses_to_array.exs +++ b/apps/explorer/priv/account/migrations/20220705195240_migrate_public_tags_addresses_to_array.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.MigratePublicTagsAddressesToArray do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220706114430_encrypt_account_data.exs b/apps/explorer/priv/account/migrations/20220706114430_encrypt_account_data.exs index c1fc49af2cd6..3e49846f1ce7 100644 --- a/apps/explorer/priv/account/migrations/20220706114430_encrypt_account_data.exs +++ b/apps/explorer/priv/account/migrations/20220706114430_encrypt_account_data.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.EncryptAccountData do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220706153506_remove_unencrypted_fields.exs b/apps/explorer/priv/account/migrations/20220706153506_remove_unencrypted_fields.exs index c64e1e5edbe0..d26fba28ae5b 100644 --- a/apps/explorer/priv/account/migrations/20220706153506_remove_unencrypted_fields.exs +++ b/apps/explorer/priv/account/migrations/20220706153506_remove_unencrypted_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.RemoveUnencryptedFields do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220706211444_set_new_indexes.exs b/apps/explorer/priv/account/migrations/20220706211444_set_new_indexes.exs index 26abb92abeb2..05e552cc77e8 100644 --- a/apps/explorer/priv/account/migrations/20220706211444_set_new_indexes.exs +++ b/apps/explorer/priv/account/migrations/20220706211444_set_new_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.SetNewIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20220905195203_remove_guardian_tokens.exs b/apps/explorer/priv/account/migrations/20220905195203_remove_guardian_tokens.exs index 2c76df8099c0..1bd25b4c386b 100644 --- a/apps/explorer/priv/account/migrations/20220905195203_remove_guardian_tokens.exs +++ b/apps/explorer/priv/account/migrations/20220905195203_remove_guardian_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.RemoveGuardianTokens do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs b/apps/explorer/priv/account/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs index 2fa317af207a..b6f9a157211c 100644 --- a/apps/explorer/priv/account/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs +++ b/apps/explorer/priv/account/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.CreateTokenTransferTokenIdMigratorProgress do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20230502083519_add_verification_email_sent_at.exs b/apps/explorer/priv/account/migrations/20230502083519_add_verification_email_sent_at.exs index a3145bab492c..7f368dcfbeb9 100644 --- a/apps/explorer/priv/account/migrations/20230502083519_add_verification_email_sent_at.exs +++ b/apps/explorer/priv/account/migrations/20230502083519_add_verification_email_sent_at.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddVerificationEmailSentAt do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20231207201701_add_watchlist_id_column.exs b/apps/explorer/priv/account/migrations/20231207201701_add_watchlist_id_column.exs index 176b65cd5a84..662f8de47964 100644 --- a/apps/explorer/priv/account/migrations/20231207201701_add_watchlist_id_column.exs +++ b/apps/explorer/priv/account/migrations/20231207201701_add_watchlist_id_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddWatchlistIdColumn do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20240219152220_add_account_watchlist_addresses_erc_404_fields.exs b/apps/explorer/priv/account/migrations/20240219152220_add_account_watchlist_addresses_erc_404_fields.exs index 7fbb08a48cd4..3fb54dbbe4f2 100644 --- a/apps/explorer/priv/account/migrations/20240219152220_add_account_watchlist_addresses_erc_404_fields.exs +++ b/apps/explorer/priv/account/migrations/20240219152220_add_account_watchlist_addresses_erc_404_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AddAccountWatchlistAddressesErc404Fields do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20240913194307_account_v2.exs b/apps/explorer/priv/account/migrations/20240913194307_account_v2.exs index 824f7f6c96fc..2d3384712007 100644 --- a/apps/explorer/priv/account/migrations/20240913194307_account_v2.exs +++ b/apps/explorer/priv/account/migrations/20240913194307_account_v2.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.AccountV2 do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20241015091450_rename_tx_hash_field.exs b/apps/explorer/priv/account/migrations/20241015091450_rename_tx_hash_field.exs index 61e1b43dc0a1..a64534860234 100644 --- a/apps/explorer/priv/account/migrations/20241015091450_rename_tx_hash_field.exs +++ b/apps/explorer/priv/account/migrations/20241015091450_rename_tx_hash_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.RenameTxHashField do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs b/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs index c4fe2b74a579..3d72ce492fd6 100644 --- a/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs +++ b/apps/explorer/priv/account/migrations/20241121140138_remove_abused_api_keys.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.RemoveAbusedApiKeys do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20241128100836_remove_abused_custom_abis.exs b/apps/explorer/priv/account/migrations/20241128100836_remove_abused_custom_abis.exs index 4c33d0c8ea38..c6e6e104a14a 100644 --- a/apps/explorer/priv/account/migrations/20241128100836_remove_abused_custom_abis.exs +++ b/apps/explorer/priv/account/migrations/20241128100836_remove_abused_custom_abis.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.RemoveAbusedCustomAbis do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20241204093817_remove_abused_public_tags_request.exs b/apps/explorer/priv/account/migrations/20241204093817_remove_abused_public_tags_request.exs index 65a0ab542753..ac07ded9b4e4 100644 --- a/apps/explorer/priv/account/migrations/20241204093817_remove_abused_public_tags_request.exs +++ b/apps/explorer/priv/account/migrations/20241204093817_remove_abused_public_tags_request.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Account.Migrations.RemoveAbusedPublicTagsRequest do use Ecto.Migration diff --git a/apps/explorer/priv/account/migrations/20250815115250_drop_account_public_tags_requests.exs b/apps/explorer/priv/account/migrations/20250815115250_drop_account_public_tags_requests.exs new file mode 100644 index 000000000000..c2748c93ae26 --- /dev/null +++ b/apps/explorer/priv/account/migrations/20250815115250_drop_account_public_tags_requests.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Account.Migrations.DropAccountPublicTagsRequests do + use Ecto.Migration + + def change do + drop(table(:account_public_tags_requests)) + end +end diff --git a/apps/explorer/priv/account/migrations/20251029115930_account_watchlist_zrc2_addresses.exs b/apps/explorer/priv/account/migrations/20251029115930_account_watchlist_zrc2_addresses.exs new file mode 100644 index 000000000000..c198eb96c514 --- /dev/null +++ b/apps/explorer/priv/account/migrations/20251029115930_account_watchlist_zrc2_addresses.exs @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Account.Migrations.AccountWatchlistZRC2Addresses do + use Ecto.Migration + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + if @chain_type == :zilliqa do + def change do + alter table(:account_watchlist_addresses) do + add(:watch_zrc_2_input, :boolean, default: true) + add(:watch_zrc_2_output, :boolean, default: true) + end + end + else + def change do + # does nothing for other chain types + end + end +end diff --git a/apps/explorer/priv/arbitrum/migrations/20240201125730_create_arbitrum_tables.exs b/apps/explorer/priv/arbitrum/migrations/20240201125730_create_arbitrum_tables.exs index 3181ad01932b..d485489c837a 100644 --- a/apps/explorer/priv/arbitrum/migrations/20240201125730_create_arbitrum_tables.exs +++ b/apps/explorer/priv/arbitrum/migrations/20240201125730_create_arbitrum_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.CreateArbitrumTables do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20240510184858_extend_transaction_and_block_tables.exs b/apps/explorer/priv/arbitrum/migrations/20240510184858_extend_transaction_and_block_tables.exs index 3bc802b127e1..8c9e581a7936 100644 --- a/apps/explorer/priv/arbitrum/migrations/20240510184858_extend_transaction_and_block_tables.exs +++ b/apps/explorer/priv/arbitrum/migrations/20240510184858_extend_transaction_and_block_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.ExtendTransactionAndBlockTables do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20240527212653_add_da_info.exs b/apps/explorer/priv/arbitrum/migrations/20240527212653_add_da_info.exs index ee81ae9f74e8..6defb86485a2 100644 --- a/apps/explorer/priv/arbitrum/migrations/20240527212653_add_da_info.exs +++ b/apps/explorer/priv/arbitrum/migrations/20240527212653_add_da_info.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.AddDaInfo do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20240628210148_add_index_for_messages.exs b/apps/explorer/priv/arbitrum/migrations/20240628210148_add_index_for_messages.exs index 9a62a7c41248..53f59d9df96a 100644 --- a/apps/explorer/priv/arbitrum/migrations/20240628210148_add_index_for_messages.exs +++ b/apps/explorer/priv/arbitrum/migrations/20240628210148_add_index_for_messages.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.AddIndexForMessages do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20241015093220_rename_tx_hash_field_arbitrum.exs b/apps/explorer/priv/arbitrum/migrations/20241015093220_rename_tx_hash_field_arbitrum.exs index 2e6aa5028e04..e9f8e4a38468 100644 --- a/apps/explorer/priv/arbitrum/migrations/20241015093220_rename_tx_hash_field_arbitrum.exs +++ b/apps/explorer/priv/arbitrum/migrations/20241015093220_rename_tx_hash_field_arbitrum.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.RenameTxHashFieldArbitrum do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20241111195112_add_stylus_fields.exs b/apps/explorer/priv/arbitrum/migrations/20241111195112_add_stylus_fields.exs index fb9ae49174e1..cb715519405f 100644 --- a/apps/explorer/priv/arbitrum/migrations/20241111195112_add_stylus_fields.exs +++ b/apps/explorer/priv/arbitrum/migrations/20241111195112_add_stylus_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.AddStylusFields do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20241217155103_add_data_blobs_to_batches_table.exs b/apps/explorer/priv/arbitrum/migrations/20241217155103_add_data_blobs_to_batches_table.exs index a1a03458284c..8f27945b7bb3 100644 --- a/apps/explorer/priv/arbitrum/migrations/20241217155103_add_data_blobs_to_batches_table.exs +++ b/apps/explorer/priv/arbitrum/migrations/20241217155103_add_data_blobs_to_batches_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Arbitrum.Migrations.AddDataBlobsToBatchesTable do use Ecto.Migration diff --git a/apps/explorer/priv/arbitrum/migrations/20250731001757_add_eigenda_batches.exs b/apps/explorer/priv/arbitrum/migrations/20250731001757_add_eigenda_batches.exs new file mode 100644 index 000000000000..75189f216478 --- /dev/null +++ b/apps/explorer/priv/arbitrum/migrations/20250731001757_add_eigenda_batches.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Arbitrum.Migrations.AddEigendaBatches do + use Ecto.Migration + + def change do + execute("ALTER TYPE arbitrum_da_containers_types ADD VALUE 'in_eigenda'") + end +end diff --git a/apps/explorer/priv/beacon/migrations/20240109102458_create_blobs_tables.exs b/apps/explorer/priv/beacon/migrations/20240109102458_create_blobs_tables.exs index e67cf501babc..d97c71bb2914 100644 --- a/apps/explorer/priv/beacon/migrations/20240109102458_create_blobs_tables.exs +++ b/apps/explorer/priv/beacon/migrations/20240109102458_create_blobs_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Beacon.Migrations.CreateBlobsTables do use Ecto.Migration diff --git a/apps/explorer/priv/beacon/migrations/20240318154323_create_blob_transactions_index.exs b/apps/explorer/priv/beacon/migrations/20240318154323_create_blob_transactions_index.exs index 094e49a51600..0799558d56f2 100644 --- a/apps/explorer/priv/beacon/migrations/20240318154323_create_blob_transactions_index.exs +++ b/apps/explorer/priv/beacon/migrations/20240318154323_create_blob_transactions_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Beacon.Migrations.AddTransactionsRecentBlobTransactionsIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/beacon/migrations/20250813001523_create_deposits.exs b/apps/explorer/priv/beacon/migrations/20250813001523_create_deposits.exs new file mode 100644 index 000000000000..d17b57d882b1 --- /dev/null +++ b/apps/explorer/priv/beacon/migrations/20250813001523_create_deposits.exs @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Beacon.Migrations.CreateDeposits do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE beacon_deposits_status AS ENUM ('invalid', 'pending', 'completed')", + "DROP TYPE beacon_deposits_status" + ) + + create table(:beacon_deposits, primary_key: false) do + add(:pubkey, :bytea, null: false) + add(:withdrawal_credentials, :bytea, null: false) + add(:amount, :decimal, precision: 100, scale: 0, null: false) + add(:signature, :bytea, null: false) + add(:index, :bigint, null: false, primary_key: true) + add(:block_number, :bigint, null: false) + add(:block_timestamp, :utc_datetime_usec, null: false) + add(:log_index, :integer, null: false) + add(:status, :beacon_deposits_status, null: false) + + add(:from_address_hash, references(:addresses, column: :hash, on_delete: :delete_all, type: :bytea), null: false) + + add(:block_hash, references(:blocks, column: :hash, on_delete: :delete_all, type: :bytea), null: false) + + add(:transaction_hash, references(:transactions, column: :hash, on_delete: :delete_all, type: :bytea), + null: false + ) + + timestamps(null: false, type: :utc_datetime_usec) + end + + create(index(:beacon_deposits, [:from_address_hash])) + create(index(:beacon_deposits, [:block_hash])) + create(index(:beacon_deposits, [:pubkey], where: "status != 'invalid'")) + + create( + index(:beacon_deposits, [:pubkey, :withdrawal_credentials, :amount, :signature, :block_timestamp], + where: "status = 'pending'", + name: :beacon_deposits_composite_key_only_pending_index + ) + ) + end +end diff --git a/apps/explorer/priv/beacon/migrations/20251029090030_deposits_remove_foreign_keys.exs b/apps/explorer/priv/beacon/migrations/20251029090030_deposits_remove_foreign_keys.exs new file mode 100644 index 000000000000..09e0763e78fc --- /dev/null +++ b/apps/explorer/priv/beacon/migrations/20251029090030_deposits_remove_foreign_keys.exs @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Beacon.Migrations.DepositsRemoveForeignKeys do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + drop_if_exists(constraint(:beacon_deposits, :beacon_deposits_block_hash_fkey)) + drop_if_exists(constraint(:beacon_deposits, :beacon_deposits_from_address_hash_fkey)) + drop_if_exists(constraint(:beacon_deposits, :beacon_deposits_transaction_hash_fkey)) + end +end diff --git a/apps/explorer/priv/blackfort/migrations/20240910112251_add_blackfort_validators.exs b/apps/explorer/priv/blackfort/migrations/20240910112251_add_blackfort_validators.exs index d1d2b55e8d0d..819a6f683c8e 100644 --- a/apps/explorer/priv/blackfort/migrations/20240910112251_add_blackfort_validators.exs +++ b/apps/explorer/priv/blackfort/migrations/20240910112251_add_blackfort_validators.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Blackfort.Migrations.AddBlackfortValidators do use Ecto.Migration diff --git a/apps/explorer/priv/bridged_tokens/migrations/20230919080116_add_bridged_tokens.exs b/apps/explorer/priv/bridged_tokens/migrations/20230919080116_add_bridged_tokens.exs index 2622358c1dff..796113ef125b 100644 --- a/apps/explorer/priv/bridged_tokens/migrations/20230919080116_add_bridged_tokens.exs +++ b/apps/explorer/priv/bridged_tokens/migrations/20230919080116_add_bridged_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.BridgedTokens.Migrations.AddBridgedTokens do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240323152023_add_custom_fields.exs b/apps/explorer/priv/celo/migrations/20240323152023_add_custom_fields.exs index 8de00c05e774..8e4ead713f73 100644 --- a/apps/explorer/priv/celo/migrations/20240323152023_add_custom_fields.exs +++ b/apps/explorer/priv/celo/migrations/20240323152023_add_custom_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.AddCustomFields do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240424121856_add_pending_epoch_block_operations.exs b/apps/explorer/priv/celo/migrations/20240424121856_add_pending_epoch_block_operations.exs index 3b36446be154..8d016048c40d 100644 --- a/apps/explorer/priv/celo/migrations/20240424121856_add_pending_epoch_block_operations.exs +++ b/apps/explorer/priv/celo/migrations/20240424121856_add_pending_epoch_block_operations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.AddPendingEpochBlockOperations do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240512143204_remove_transaction_hash_from_primary_key_in_logs.exs b/apps/explorer/priv/celo/migrations/20240512143204_remove_transaction_hash_from_primary_key_in_logs.exs index 9429c33f0e91..5cfebb6dcab7 100644 --- a/apps/explorer/priv/celo/migrations/20240512143204_remove_transaction_hash_from_primary_key_in_logs.exs +++ b/apps/explorer/priv/celo/migrations/20240512143204_remove_transaction_hash_from_primary_key_in_logs.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.RemoveTransactionHashFromPrimaryKeyInLogs do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240513091316_remove_transaction_hash_from_primary_key_in_token_transfers.exs b/apps/explorer/priv/celo/migrations/20240513091316_remove_transaction_hash_from_primary_key_in_token_transfers.exs index 31dd75fb6e85..60bbbec251cc 100644 --- a/apps/explorer/priv/celo/migrations/20240513091316_remove_transaction_hash_from_primary_key_in_token_transfers.exs +++ b/apps/explorer/priv/celo/migrations/20240513091316_remove_transaction_hash_from_primary_key_in_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.RemoveTransactionHashFromPrimaryKeyInTokenTransfers do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240607185817_add_epoch_rewards.exs b/apps/explorer/priv/celo/migrations/20240607185817_add_epoch_rewards.exs index b44e2069a2bd..ae4e61308aa8 100644 --- a/apps/explorer/priv/celo/migrations/20240607185817_add_epoch_rewards.exs +++ b/apps/explorer/priv/celo/migrations/20240607185817_add_epoch_rewards.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.AddEpochRewards do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240612135216_add_validator_group_votes.exs b/apps/explorer/priv/celo/migrations/20240612135216_add_validator_group_votes.exs index 3d6f1f4eeb18..3b9484d274e0 100644 --- a/apps/explorer/priv/celo/migrations/20240612135216_add_validator_group_votes.exs +++ b/apps/explorer/priv/celo/migrations/20240612135216_add_validator_group_votes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.AddValidatorGroupVotes do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240614125614_add_election_rewards.exs b/apps/explorer/priv/celo/migrations/20240614125614_add_election_rewards.exs index 30dc3a67de0a..8bc4479a4633 100644 --- a/apps/explorer/priv/celo/migrations/20240614125614_add_election_rewards.exs +++ b/apps/explorer/priv/celo/migrations/20240614125614_add_election_rewards.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.AddElectionRewards do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240715110334_remove_unused_fields_from_validator_group_votes.exs b/apps/explorer/priv/celo/migrations/20240715110334_remove_unused_fields_from_validator_group_votes.exs index 8d678d5c8341..e19c8a1af488 100644 --- a/apps/explorer/priv/celo/migrations/20240715110334_remove_unused_fields_from_validator_group_votes.exs +++ b/apps/explorer/priv/celo/migrations/20240715110334_remove_unused_fields_from_validator_group_votes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.RemoveUnusedFieldsFromValidatorGroupVotes do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20240830094610_add_account_address_and_group_address_to_primary_key.exs b/apps/explorer/priv/celo/migrations/20240830094610_add_account_address_and_group_address_to_primary_key.exs index d03857ba7e52..754a7468bcec 100644 --- a/apps/explorer/priv/celo/migrations/20240830094610_add_account_address_and_group_address_to_primary_key.exs +++ b/apps/explorer/priv/celo/migrations/20240830094610_add_account_address_and_group_address_to_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.AddAccountAddressAndGroupAddressToPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20241029131554_modify_collated_gas_price_constraint.exs b/apps/explorer/priv/celo/migrations/20241029131554_modify_collated_gas_price_constraint.exs index 3162375fd4d7..1586e528ec83 100644 --- a/apps/explorer/priv/celo/migrations/20241029131554_modify_collated_gas_price_constraint.exs +++ b/apps/explorer/priv/celo/migrations/20241029131554_modify_collated_gas_price_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Celo.Migrations.ModifyCollatedGasPriceConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/celo/migrations/20250416085705_create_epoch.exs b/apps/explorer/priv/celo/migrations/20250416085705_create_epoch.exs new file mode 100644 index 000000000000..d526644e5a9b --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20250416085705_create_epoch.exs @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.CreateEpoch do + use Ecto.Migration + + def change do + create table(:celo_epochs, primary_key: false) do + add(:number, :smallint, null: false, primary_key: true) + add(:is_fetched, :boolean, null: false, default: false) + + add(:start_block_number, :integer) + add(:end_block_number, :integer) + + add( + :start_processing_block_hash, + references( + :blocks, + column: :hash, + type: :bytea, + on_delete: :delete_all + ) + ) + + add( + :end_processing_block_hash, + references( + :blocks, + column: :hash, + type: :bytea, + on_delete: :delete_all + ) + ) + + timestamps() + end + + l2_migration_block_number = Application.get_env(:explorer, :celo)[:l2_migration_block] + + if l2_migration_block_number do + execute(""" + WITH epoch_blocks AS ( + SELECT + b.number AS block_number, + b.hash AS block_hash, + FLOOR(b.number / 17280) AS epoch_number + FROM celo_pending_epoch_block_operations op + JOIN blocks b ON op.block_hash = b.hash + WHERE b.consensus = true AND b.number > 0 AND b.number < #{l2_migration_block_number} + ) + INSERT INTO celo_epochs ( + number, + start_processing_block_hash, + end_processing_block_hash, + start_block_number, + end_block_number, + inserted_at, + updated_at + ) + SELECT + epoch_number, + block_hash AS start_processing_block_hash, + block_hash AS end_processing_block_hash, + ((epoch_number - 1) * 17280) AS start_block_number, + (block_number - 1) AS end_block_number, + NOW(), + NOW() + FROM epoch_blocks + """) + + execute(""" + WITH epoch_blocks AS ( + SELECT + b.number AS block_number, + b.hash AS block_hash, + FLOOR(b.number / 17280) AS epoch_number + FROM blocks b + WHERE + b.consensus = true AND + b.number > 0 AND + b.number < #{l2_migration_block_number} AND + b.number % 17280 = 0 AND + NOT EXISTS ( + SELECT 1 FROM celo_epochs e + WHERE e.number = FLOOR(b.number / 17280) + ) + ) + INSERT INTO celo_epochs ( + number, + is_fetched, + start_processing_block_hash, + end_processing_block_hash, + start_block_number, + end_block_number, + inserted_at, + updated_at + ) + SELECT + epoch_number, + true AS is_fetched, + block_hash AS start_processing_block_hash, + block_hash AS end_processing_block_hash, + ((epoch_number - 1) * 17280) AS start_block_number, + (epoch_number * 17280 - 1) AS end_block_number, -- End at last block of epoch + NOW(), + NOW() + FROM epoch_blocks + """) + end + end +end diff --git a/apps/explorer/priv/celo/migrations/20250418141539_remove_pending_epoch_block_operations.exs b/apps/explorer/priv/celo/migrations/20250418141539_remove_pending_epoch_block_operations.exs new file mode 100644 index 000000000000..bde81ec8dc0d --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20250418141539_remove_pending_epoch_block_operations.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.RemovePendingEpochBlockOperations do + use Ecto.Migration + + def change do + drop(table(:celo_pending_epoch_block_operations)) + end +end diff --git a/apps/explorer/priv/celo/migrations/20250420130550_epoch_rewards_replace_block_hash_with_epoch_number.exs b/apps/explorer/priv/celo/migrations/20250420130550_epoch_rewards_replace_block_hash_with_epoch_number.exs new file mode 100644 index 000000000000..ec6c6c524269 --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20250420130550_epoch_rewards_replace_block_hash_with_epoch_number.exs @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.EpochRewardsReplaceBlockHashWithEpochNumber do + use Ecto.Migration + + def change do + l2_migration_block_number = Application.get_env(:explorer, :celo)[:l2_migration_block] + + if l2_migration_block_number do + execute(""" + DELETE FROM celo_epoch_rewards er + USING blocks b + WHERE er.block_hash = b.hash + AND b.number >= #{l2_migration_block_number} + """) + end + + alter table(:celo_epoch_rewards) do + add(:epoch_number, references(:celo_epochs, column: :number, on_delete: :delete_all)) + end + + execute(""" + UPDATE celo_epoch_rewards er + SET epoch_number = e.number + FROM celo_epochs e + WHERE er.block_hash = e.end_processing_block_hash + """) + + execute(""" + ALTER TABLE celo_epoch_rewards DROP CONSTRAINT celo_epoch_rewards_pkey; + """) + + execute(""" + ALTER TABLE celo_epoch_rewards ADD PRIMARY KEY (epoch_number); + """) + + # Drop the block_hash column + alter table(:celo_epoch_rewards) do + remove(:block_hash) + end + end +end diff --git a/apps/explorer/priv/celo/migrations/20250420140107_election_rewards_replace_block_hash_with_epoch_number.exs b/apps/explorer/priv/celo/migrations/20250420140107_election_rewards_replace_block_hash_with_epoch_number.exs new file mode 100644 index 000000000000..5f8b81197bb3 --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20250420140107_election_rewards_replace_block_hash_with_epoch_number.exs @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ElectionRewardsReplaceBlockHashWithEpochNumber do + use Ecto.Migration + + def change do + alter table(:celo_election_rewards) do + add( + :epoch_number, + references( + :celo_epochs, + column: :number, + on_delete: :delete_all + ) + ) + end + + execute(""" + UPDATE celo_election_rewards er + SET epoch_number = e.number + FROM celo_epochs e + WHERE er.block_hash = e.end_processing_block_hash + """) + + execute(""" + ALTER TABLE celo_election_rewards DROP CONSTRAINT celo_election_rewards_pkey; + """) + + execute(""" + ALTER TABLE celo_election_rewards + ADD PRIMARY KEY (type, epoch_number, account_address_hash, associated_account_address_hash); + """) + + alter table(:celo_election_rewards) do + remove(:block_hash) + end + end +end diff --git a/apps/explorer/priv/celo/migrations/20250731143827_create_account.exs b/apps/explorer/priv/celo/migrations/20250731143827_create_account.exs new file mode 100644 index 000000000000..7040641fa315 --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20250731143827_create_account.exs @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.CreateAccount do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE celo_account_type AS ENUM ('regular', 'validator', 'group')", + "DROP TYPE celo_account_type" + ) + + create table(:celo_accounts, primary_key: false) do + add( + :address_hash, + references(:addresses, column: :hash, on_delete: :delete_all, type: :bytea), + null: false, + primary_key: true + ) + + add( + :type, + :celo_account_type, + null: false, + default: "regular" + ) + + add(:name, :string) + add(:metadata_url, :string) + add(:nonvoting_locked_celo, :numeric, precision: 100, null: false) + add(:locked_celo, :numeric, precision: 100, null: false) + + add( + :vote_signer_address_hash, + references(:addresses, column: :hash, type: :bytea), + null: true + ) + + add( + :validator_signer_address_hash, + references(:addresses, column: :hash, type: :bytea), + null: true + ) + + add( + :attestation_signer_address_hash, + references(:addresses, column: :hash, type: :bytea), + null: true + ) + + timestamps() + end + + create(constraint(:celo_accounts, :nonvoting_locked_celo_nonnegative, check: "nonvoting_locked_celo >= 0")) + create(constraint(:celo_accounts, :locked_celo_nonnegative, check: "locked_celo >= 0")) + end +end diff --git a/apps/explorer/priv/celo/migrations/20250801112346_create_pending_account_operations.exs b/apps/explorer/priv/celo/migrations/20250801112346_create_pending_account_operations.exs new file mode 100644 index 000000000000..6cbc5072708c --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20250801112346_create_pending_account_operations.exs @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.CreatePendingAccountOperations do + use Ecto.Migration + + def change do + create table(:celo_pending_account_operations, primary_key: false) do + add( + :address_hash, + references( + :addresses, + column: :hash, + type: :bytea, + on_delete: :delete_all + ), + primary_key: true + ) + + timestamps() + end + end +end diff --git a/apps/explorer/priv/celo/migrations/20251014111445_add_log_index_to_validator_group_votes.exs b/apps/explorer/priv/celo/migrations/20251014111445_add_log_index_to_validator_group_votes.exs new file mode 100644 index 000000000000..01805381f59a --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20251014111445_add_log_index_to_validator_group_votes.exs @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.AddLogIndexToValidatorGroupVotes do + use Ecto.Migration + + def up do + execute(""" + ALTER TABLE celo_validator_group_votes + DROP CONSTRAINT celo_validator_group_votes_pkey + """) + + alter table(:celo_validator_group_votes) do + add(:log_index, :integer, null: false, default: 0) + end + + execute(""" + ALTER TABLE celo_validator_group_votes + ADD CONSTRAINT celo_validator_group_votes_pkey + PRIMARY KEY ( + transaction_hash, + log_index, + account_address_hash, + group_address_hash + ) + """) + end + + def down do + execute(""" + ALTER TABLE celo_validator_group_votes + DROP CONSTRAINT celo_validator_group_votes_pkey + """) + + alter table(:celo_validator_group_votes) do + remove(:log_index) + end + + execute(""" + ALTER TABLE celo_validator_group_votes + ADD CONSTRAINT celo_validator_group_votes_pkey + PRIMARY KEY ( + transaction_hash, + account_address_hash, + group_address_hash + ) + """) + end +end diff --git a/apps/explorer/priv/celo/migrations/20251016095435_create_celo_aggregated_election_rewards.exs b/apps/explorer/priv/celo/migrations/20251016095435_create_celo_aggregated_election_rewards.exs new file mode 100644 index 000000000000..d89e1a904d01 --- /dev/null +++ b/apps/explorer/priv/celo/migrations/20251016095435_create_celo_aggregated_election_rewards.exs @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Celo.Migrations.CreateCeloAggregatedElectionRewards do + use Ecto.Migration + + def change do + create table(:celo_aggregated_election_rewards, primary_key: false) do + add(:sum, :numeric, precision: 100, null: false) + add(:count, :integer, null: false) + add(:type, :celo_election_reward_type, null: false, primary_key: true) + + add( + :epoch_number, + references( + :celo_epochs, + column: :number, + on_delete: :delete_all + ), + null: false, + primary_key: true + ) + + timestamps() + end + + # Index for efficient lookups by epoch_number + create(index(:celo_aggregated_election_rewards, [:epoch_number])) + end +end diff --git a/apps/explorer/priv/event_notifications/migrations/20220804114005_create_event_notifications.exs b/apps/explorer/priv/event_notifications/migrations/20220804114005_create_event_notifications.exs new file mode 100644 index 000000000000..6a2fc29bc75f --- /dev/null +++ b/apps/explorer/priv/event_notifications/migrations/20220804114005_create_event_notifications.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.EventNotifications.Migrations.CreateEventNotifications do + use Ecto.Migration + + def change do + create table(:event_notifications) do + add(:data, :text, null: false) + end + end +end diff --git a/apps/explorer/priv/event_notifications/migrations/20250704124014_add_timestamp_to_event_notifications.exs b/apps/explorer/priv/event_notifications/migrations/20250704124014_add_timestamp_to_event_notifications.exs new file mode 100644 index 000000000000..17c2168daae5 --- /dev/null +++ b/apps/explorer/priv/event_notifications/migrations/20250704124014_add_timestamp_to_event_notifications.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.EventNotifications.Migrations.AddTimestampToEventNotifications do + use Ecto.Migration + + def change do + execute("TRUNCATE event_notifications;") + + alter table(:event_notifications) do + timestamps() + end + end +end diff --git a/apps/explorer/priv/filecoin/migrations/20230731130103_modify_collated_gas_price_constraint.exs b/apps/explorer/priv/filecoin/migrations/20230731130103_modify_collated_gas_price_constraint.exs index 18ff64b5f382..bff066cc1e13 100644 --- a/apps/explorer/priv/filecoin/migrations/20230731130103_modify_collated_gas_price_constraint.exs +++ b/apps/explorer/priv/filecoin/migrations/20230731130103_modify_collated_gas_price_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.ModifyCollatedGasPriceConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/filecoin/migrations/20231109104957_create_null_round_heights.exs b/apps/explorer/priv/filecoin/migrations/20231109104957_create_null_round_heights.exs index 081d17637dab..d9febfaf26cc 100644 --- a/apps/explorer/priv/filecoin/migrations/20231109104957_create_null_round_heights.exs +++ b/apps/explorer/priv/filecoin/migrations/20231109104957_create_null_round_heights.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.CreateNullRoundHeights do use Ecto.Migration diff --git a/apps/explorer/priv/filecoin/migrations/20240219140124_change_null_round_heights_height_type.exs b/apps/explorer/priv/filecoin/migrations/20240219140124_change_null_round_heights_height_type.exs index 590a313661a3..2acb346e5121 100644 --- a/apps/explorer/priv/filecoin/migrations/20240219140124_change_null_round_heights_height_type.exs +++ b/apps/explorer/priv/filecoin/migrations/20240219140124_change_null_round_heights_height_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.ChangeNullRoundHeightsHeightType do use Ecto.Migration diff --git a/apps/explorer/priv/filecoin/migrations/20240801134142_create_pending_address_operations.exs b/apps/explorer/priv/filecoin/migrations/20240801134142_create_pending_address_operations.exs index 7da899939bdb..cc0cf306eb2a 100644 --- a/apps/explorer/priv/filecoin/migrations/20240801134142_create_pending_address_operations.exs +++ b/apps/explorer/priv/filecoin/migrations/20240801134142_create_pending_address_operations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.CreatePendingAddressOperations do use Ecto.Migration diff --git a/apps/explorer/priv/filecoin/migrations/20240807134138_add_chain_type_fields_to_address.exs b/apps/explorer/priv/filecoin/migrations/20240807134138_add_chain_type_fields_to_address.exs index 378e3b24c0b3..55f303b6ba3b 100644 --- a/apps/explorer/priv/filecoin/migrations/20240807134138_add_chain_type_fields_to_address.exs +++ b/apps/explorer/priv/filecoin/migrations/20240807134138_add_chain_type_fields_to_address.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.AddChainTypeFieldsToAddress do use Ecto.Migration diff --git a/apps/explorer/priv/filecoin/migrations/20241214110136_add_refetch_after_to_pending_address_operation.exs b/apps/explorer/priv/filecoin/migrations/20241214110136_add_refetch_after_to_pending_address_operation.exs index 212eadecf55a..18b8914391a4 100644 --- a/apps/explorer/priv/filecoin/migrations/20241214110136_add_refetch_after_to_pending_address_operation.exs +++ b/apps/explorer/priv/filecoin/migrations/20241214110136_add_refetch_after_to_pending_address_operation.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.AddRefetchAfterToPendingAddressOperation do use Ecto.Migration diff --git a/apps/explorer/priv/filecoin/migrations/20250220085529_replace_filecoin_addresses_indexes.exs b/apps/explorer/priv/filecoin/migrations/20250220085529_replace_filecoin_addresses_indexes.exs index 9b27c3c61a66..3f11cf2dbccb 100644 --- a/apps/explorer/priv/filecoin/migrations/20250220085529_replace_filecoin_addresses_indexes.exs +++ b/apps/explorer/priv/filecoin/migrations/20250220085529_replace_filecoin_addresses_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Filecoin.Migrations.ReplaceFilecoinAddressesIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/neon/migrations/20241203161152_create_neon_solana_transactions.exs b/apps/explorer/priv/neon/migrations/20241203161152_create_neon_solana_transactions.exs index 1e29fba47ee6..5168e67d2cda 100644 --- a/apps/explorer/priv/neon/migrations/20241203161152_create_neon_solana_transactions.exs +++ b/apps/explorer/priv/neon/migrations/20241203161152_create_neon_solana_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateNeonSolanaTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20220204060243_transaction_columns_to_support_l2.exs b/apps/explorer/priv/optimism/migrations/20220204060243_transaction_columns_to_support_l2.exs index 36a7902cfdf5..77e538652960 100644 --- a/apps/explorer/priv/optimism/migrations/20220204060243_transaction_columns_to_support_l2.exs +++ b/apps/explorer/priv/optimism/migrations/20220204060243_transaction_columns_to_support_l2.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TransactionColumnsToSupportL2 do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230131115105_add_op_output_roots_table.exs b/apps/explorer/priv/optimism/migrations/20230131115105_add_op_output_roots_table.exs index da07e936ee7d..242ea15a6a8d 100644 --- a/apps/explorer/priv/optimism/migrations/20230131115105_add_op_output_roots_table.exs +++ b/apps/explorer/priv/optimism/migrations/20230131115105_add_op_output_roots_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpOutputRootsTable do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230206123308_add_op_withdrawals_table.exs b/apps/explorer/priv/optimism/migrations/20230206123308_add_op_withdrawals_table.exs index cee87054f02b..83abe96286f1 100644 --- a/apps/explorer/priv/optimism/migrations/20230206123308_add_op_withdrawals_table.exs +++ b/apps/explorer/priv/optimism/migrations/20230206123308_add_op_withdrawals_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpWithdrawalsTable do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230212162845_add_op_withdrawal_events_table.exs b/apps/explorer/priv/optimism/migrations/20230212162845_add_op_withdrawal_events_table.exs index 12f5d3160614..34aa89143f1f 100644 --- a/apps/explorer/priv/optimism/migrations/20230212162845_add_op_withdrawal_events_table.exs +++ b/apps/explorer/priv/optimism/migrations/20230212162845_add_op_withdrawal_events_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpWithdrawalEventsTable do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230216135703_add_op_transaction_batches_table.exs b/apps/explorer/priv/optimism/migrations/20230216135703_add_op_transaction_batches_table.exs index 59fbc9822675..ae95097ce7a9 100644 --- a/apps/explorer/priv/optimism/migrations/20230216135703_add_op_transaction_batches_table.exs +++ b/apps/explorer/priv/optimism/migrations/20230216135703_add_op_transaction_batches_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpTransactionBatchesTable do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230220202107_create_op_deposits.exs b/apps/explorer/priv/optimism/migrations/20230220202107_create_op_deposits.exs index 3f313995528c..2544c26ed874 100644 --- a/apps/explorer/priv/optimism/migrations/20230220202107_create_op_deposits.exs +++ b/apps/explorer/priv/optimism/migrations/20230220202107_create_op_deposits.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateOpDeposits do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230301105051_rename_fields.exs b/apps/explorer/priv/optimism/migrations/20230301105051_rename_fields.exs index d7041587a38f..b0fb5d8f097b 100644 --- a/apps/explorer/priv/optimism/migrations/20230301105051_rename_fields.exs +++ b/apps/explorer/priv/optimism/migrations/20230301105051_rename_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RenameFields do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230303125841_add_op_indexes.exs b/apps/explorer/priv/optimism/migrations/20230303125841_add_op_indexes.exs index e9db8591c361..69551a11e66d 100644 --- a/apps/explorer/priv/optimism/migrations/20230303125841_add_op_indexes.exs +++ b/apps/explorer/priv/optimism/migrations/20230303125841_add_op_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230307090655_add_op_frame_sequences_table.exs b/apps/explorer/priv/optimism/migrations/20230307090655_add_op_frame_sequences_table.exs index ea7f192af638..916781b5611b 100644 --- a/apps/explorer/priv/optimism/migrations/20230307090655_add_op_frame_sequences_table.exs +++ b/apps/explorer/priv/optimism/migrations/20230307090655_add_op_frame_sequences_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpFrameSequencesTable do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20230731130103_modify_collated_gas_price_constraint.exs b/apps/explorer/priv/optimism/migrations/20230731130103_modify_collated_gas_price_constraint.exs index 59a07d849153..78e90b7df4d3 100644 --- a/apps/explorer/priv/optimism/migrations/20230731130103_modify_collated_gas_price_constraint.exs +++ b/apps/explorer/priv/optimism/migrations/20230731130103_modify_collated_gas_price_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.ModifyCollatedGasPriceConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20231025102325_add_op_withdrawal_index.exs b/apps/explorer/priv/optimism/migrations/20231025102325_add_op_withdrawal_index.exs index 3fda19158b86..15dc0977c26b 100644 --- a/apps/explorer/priv/optimism/migrations/20231025102325_add_op_withdrawal_index.exs +++ b/apps/explorer/priv/optimism/migrations/20231025102325_add_op_withdrawal_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOpWithdrawalIndex do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20240124124644_remove_op_epoch_number_field.exs b/apps/explorer/priv/optimism/migrations/20240124124644_remove_op_epoch_number_field.exs index 652f9551896c..8a774de8646b 100644 --- a/apps/explorer/priv/optimism/migrations/20240124124644_remove_op_epoch_number_field.exs +++ b/apps/explorer/priv/optimism/migrations/20240124124644_remove_op_epoch_number_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveOpEpochNumberField do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20240328125102_fault_proofs_support.exs b/apps/explorer/priv/optimism/migrations/20240328125102_fault_proofs_support.exs index cf3a139b5ec5..a4066f133be5 100644 --- a/apps/explorer/priv/optimism/migrations/20240328125102_fault_proofs_support.exs +++ b/apps/explorer/priv/optimism/migrations/20240328125102_fault_proofs_support.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.FaultProofsSupport do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20240503113124_add_celestia_blob_metadata.exs b/apps/explorer/priv/optimism/migrations/20240503113124_add_celestia_blob_metadata.exs index 3878db97f015..461e70126a86 100644 --- a/apps/explorer/priv/optimism/migrations/20240503113124_add_celestia_blob_metadata.exs +++ b/apps/explorer/priv/optimism/migrations/20240503113124_add_celestia_blob_metadata.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.AddCelestiaBlobMetadata do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20240612120541_add_view_ready_field.exs b/apps/explorer/priv/optimism/migrations/20240612120541_add_view_ready_field.exs index 9b3e26b59cee..012fbb493208 100644 --- a/apps/explorer/priv/optimism/migrations/20240612120541_add_view_ready_field.exs +++ b/apps/explorer/priv/optimism/migrations/20240612120541_add_view_ready_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.AddViewReadyField do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20240613065020_add_frame_sequence_id_prev_field.exs b/apps/explorer/priv/optimism/migrations/20240613065020_add_frame_sequence_id_prev_field.exs index 35416d64b060..b281d2e59249 100644 --- a/apps/explorer/priv/optimism/migrations/20240613065020_add_frame_sequence_id_prev_field.exs +++ b/apps/explorer/priv/optimism/migrations/20240613065020_add_frame_sequence_id_prev_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.AddFrameSequenceIdPrevField do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20241015140121_rename_tx_related_field_optimism.exs b/apps/explorer/priv/optimism/migrations/20241015140121_rename_tx_related_field_optimism.exs index 6f2b4a493540..44c9ac18f3c9 100644 --- a/apps/explorer/priv/optimism/migrations/20241015140121_rename_tx_related_field_optimism.exs +++ b/apps/explorer/priv/optimism/migrations/20241015140121_rename_tx_related_field_optimism.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.RenameTxRelatedFieldOptimism do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20241203113159_holocene_support.exs b/apps/explorer/priv/optimism/migrations/20241203113159_holocene_support.exs index fc23b0dcdd6f..b86682b0070b 100644 --- a/apps/explorer/priv/optimism/migrations/20241203113159_holocene_support.exs +++ b/apps/explorer/priv/optimism/migrations/20241203113159_holocene_support.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.HoloceneSupport do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20241209101134_op_withdrawal_events_key.exs b/apps/explorer/priv/optimism/migrations/20241209101134_op_withdrawal_events_key.exs index 026c8436cc4a..063cb4a06a61 100644 --- a/apps/explorer/priv/optimism/migrations/20241209101134_op_withdrawal_events_key.exs +++ b/apps/explorer/priv/optimism/migrations/20241209101134_op_withdrawal_events_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.OPWithdrawalEventsKey do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20250121131928_holocene_clear.exs b/apps/explorer/priv/optimism/migrations/20250121131928_holocene_clear.exs index 7c3007f2035d..fc5a57884641 100644 --- a/apps/explorer/priv/optimism/migrations/20250121131928_holocene_clear.exs +++ b/apps/explorer/priv/optimism/migrations/20250121131928_holocene_clear.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.HoloceneClear do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20250204050501_op_interop_messages.exs b/apps/explorer/priv/optimism/migrations/20250204050501_op_interop_messages.exs index c822ad30adc4..4a36d76c5190 100644 --- a/apps/explorer/priv/optimism/migrations/20250204050501_op_interop_messages.exs +++ b/apps/explorer/priv/optimism/migrations/20250204050501_op_interop_messages.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.OPInteropMessages do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20250221121840_reindex_batches.exs b/apps/explorer/priv/optimism/migrations/20250221121840_reindex_batches.exs index 2cc22da4e106..af139f05a58a 100644 --- a/apps/explorer/priv/optimism/migrations/20250221121840_reindex_batches.exs +++ b/apps/explorer/priv/optimism/migrations/20250221121840_reindex_batches.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.ReindexBatches do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20250317111838_rename_interop_addresses.exs b/apps/explorer/priv/optimism/migrations/20250317111838_rename_interop_addresses.exs index 4033f9578c3f..9ffa61052771 100644 --- a/apps/explorer/priv/optimism/migrations/20250317111838_rename_interop_addresses.exs +++ b/apps/explorer/priv/optimism/migrations/20250317111838_rename_interop_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Optimism.Migrations.RenameInteropAddresses do use Ecto.Migration diff --git a/apps/explorer/priv/optimism/migrations/20250428080401_game_address_field_in_withdrawals.exs b/apps/explorer/priv/optimism/migrations/20250428080401_game_address_field_in_withdrawals.exs new file mode 100644 index 000000000000..963cfc0c3411 --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250428080401_game_address_field_in_withdrawals.exs @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.GameAddressFieldInWithdrawals do + use Ecto.Migration + + def change do + alter table(:op_withdrawal_events) do + add(:game_address_hash, :bytea, null: true) + end + + rename(table(:op_dispute_games), :address, to: :address_hash) + create(index(:op_dispute_games, :address_hash)) + end +end diff --git a/apps/explorer/priv/optimism/migrations/20250501120524_op_interop_transfer_fields.exs b/apps/explorer/priv/optimism/migrations/20250501120524_op_interop_transfer_fields.exs new file mode 100644 index 000000000000..723d19ff69fe --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250501120524_op_interop_transfer_fields.exs @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.OPInteropTransferFields do + use Ecto.Migration + + def change do + execute("TRUNCATE TABLE op_interop_messages;") + + alter table(:op_interop_messages) do + add(:transfer_token_address_hash, :bytea, null: true, default: nil) + add(:transfer_from_address_hash, :bytea, null: true, default: nil) + add(:transfer_to_address_hash, :bytea, null: true, default: nil) + add(:transfer_amount, :decimal, null: true, default: nil) + add(:sent_to_multichain, :boolean, null: false, default: false) + end + end +end diff --git a/apps/explorer/priv/optimism/migrations/20250610100740_add_op_interop_indices.exs b/apps/explorer/priv/optimism/migrations/20250610100740_add_op_interop_indices.exs new file mode 100644 index 000000000000..3db3aa61616d --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250610100740_add_op_interop_indices.exs @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.AddOPInteropIndices do + use Ecto.Migration + + def up do + create(index(:op_interop_messages, [:init_transaction_hash, :init_chain_id, :sent_to_multichain, :block_number])) + create(index(:op_interop_messages, [:relay_transaction_hash, :relay_chain_id, :sent_to_multichain, :block_number])) + end + + def down do + drop_if_exists( + index(:op_interop_messages, [:init_transaction_hash, :init_chain_id, :sent_to_multichain, :block_number]) + ) + + drop_if_exists( + index(:op_interop_messages, [:relay_transaction_hash, :relay_chain_id, :sent_to_multichain, :block_number]) + ) + end +end diff --git a/apps/explorer/priv/optimism/migrations/20250619093450_op_sent_to_multichain_nullable.exs b/apps/explorer/priv/optimism/migrations/20250619093450_op_sent_to_multichain_nullable.exs new file mode 100644 index 000000000000..5d1d0be97fcf --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250619093450_op_sent_to_multichain_nullable.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.OPSentToMultichainNullable do + use Ecto.Migration + + def change do + alter table(:op_interop_messages) do + modify(:sent_to_multichain, :boolean, null: true, default: nil) + end + end +end diff --git a/apps/explorer/priv/optimism/migrations/20250901063610_op_operator_fee.exs b/apps/explorer/priv/optimism/migrations/20250901063610_op_operator_fee.exs new file mode 100644 index 000000000000..1aa7e1c206c3 --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250901063610_op_operator_fee.exs @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.OPOperatorFee do + use Ecto.Migration + + def up do + alter table(:transactions) do + add(:operator_fee_scalar, :decimal, null: true, default: nil) + add(:operator_fee_constant, :decimal, null: true, default: nil) + end + end + + def down do + alter table(:transactions) do + remove(:operator_fee_constant) + remove(:operator_fee_scalar) + end + end +end diff --git a/apps/explorer/priv/optimism/migrations/20250908073612_add_alt_da_blobs.exs b/apps/explorer/priv/optimism/migrations/20250908073612_add_alt_da_blobs.exs new file mode 100644 index 000000000000..9c700e9f9f69 --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250908073612_add_alt_da_blobs.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.AddAltDABlobs do + use Ecto.Migration + + def change do + execute("ALTER TYPE op_frame_sequence_blob_type ADD VALUE 'alt_da'") + end +end diff --git a/apps/explorer/priv/optimism/migrations/20250919073011_op_withdrawal_claim_button.exs b/apps/explorer/priv/optimism/migrations/20250919073011_op_withdrawal_claim_button.exs new file mode 100644 index 000000000000..33af75063069 --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20250919073011_op_withdrawal_claim_button.exs @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.OPWithdrawalClaimButton do + use Ecto.Migration + + def up do + execute(""" + CREATE OR REPLACE FUNCTION numeric_to_bytea32(n NUMERIC) + RETURNS BYTEA AS $$ + DECLARE + zero_left_pad INT := 32; + bytes BYTEA := repeat(E'\\\\000', zero_left_pad)::bytea; -- preallocate zero bytes + v INT; + pos INT := zero_left_pad - 1; -- index from rightmost byte + BEGIN + WHILE n > 0 AND pos >= 0 LOOP + v := n % 256; + bytes := set_byte(bytes, pos, v); + n := (n - v) / 256; + pos := pos - 1; + END LOOP; + RETURN bytes; + END; + $$ LANGUAGE plpgsql IMMUTABLE STRICT; + """) + end + + def down do + execute("DROP FUNCTION IF EXISTS numeric_to_bytea32(n NUMERIC);") + end +end diff --git a/apps/explorer/priv/optimism/migrations/20251104063632_jovian_support.exs b/apps/explorer/priv/optimism/migrations/20251104063632_jovian_support.exs new file mode 100644 index 000000000000..020912e7f4cf --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20251104063632_jovian_support.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.JovianSupport do + use Ecto.Migration + + def up do + alter table(:op_eip1559_config_updates) do + add(:min_base_fee, :bigint, null: true) + end + + alter table(:transactions) do + add(:da_footprint_gas_scalar, :decimal, null: true, default: nil) + end + end + + def down do + alter table(:transactions) do + remove(:da_footprint_gas_scalar) + end + + alter table(:op_eip1559_config_updates) do + remove(:min_base_fee) + end + end +end diff --git a/apps/explorer/priv/optimism/migrations/20251205112616_op_eigen_da_blobs.exs b/apps/explorer/priv/optimism/migrations/20251205112616_op_eigen_da_blobs.exs new file mode 100644 index 000000000000..0361751e9916 --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20251205112616_op_eigen_da_blobs.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.OPEigenDABlobs do + use Ecto.Migration + + def change do + execute("ALTER TYPE op_frame_sequence_blob_type ADD VALUE IF NOT EXISTS 'eigenda'") + end +end diff --git a/apps/explorer/priv/optimism/migrations/20260304115554_op_drop_operator_fee_index.exs b/apps/explorer/priv/optimism/migrations/20260304115554_op_drop_operator_fee_index.exs new file mode 100644 index 000000000000..4024c98919da --- /dev/null +++ b/apps/explorer/priv/optimism/migrations/20260304115554_op_drop_operator_fee_index.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Optimism.Migrations.OPDropOperatorFeeIndex do + use Ecto.Migration + + def change do + execute( + "UPDATE migrations_status SET status = 'completed', updated_at = NOW() WHERE migration_name = 'heavy_indexes_create_transactions_operator_fee_constant_index' AND status = 'started'" + ) + end +end diff --git a/apps/explorer/priv/polygon_edge/migrations/20230618132249_create_polygon_edge_withdrawal_tables.exs b/apps/explorer/priv/polygon_edge/migrations/20230618132249_create_polygon_edge_withdrawal_tables.exs index b69751a14a2b..40b2d2204bfd 100644 --- a/apps/explorer/priv/polygon_edge/migrations/20230618132249_create_polygon_edge_withdrawal_tables.exs +++ b/apps/explorer/priv/polygon_edge/migrations/20230618132249_create_polygon_edge_withdrawal_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.PolygonEdge.Migrations.CreatePolygonEdgeWithdrawalTables do use Ecto.Migration diff --git a/apps/explorer/priv/polygon_edge/migrations/20230707113550_create_polygon_edge_deposit_tables.exs b/apps/explorer/priv/polygon_edge/migrations/20230707113550_create_polygon_edge_deposit_tables.exs index 36ef8613b2e5..7377fdd84685 100644 --- a/apps/explorer/priv/polygon_edge/migrations/20230707113550_create_polygon_edge_deposit_tables.exs +++ b/apps/explorer/priv/polygon_edge/migrations/20230707113550_create_polygon_edge_deposit_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.PolygonEdge.Migrations.CreatePolygonEdgeDepositTables do use Ecto.Migration diff --git a/apps/explorer/priv/polygon_edge/migrations/20230731130103_modify_collated_gas_price_constraint.exs b/apps/explorer/priv/polygon_edge/migrations/20230731130103_modify_collated_gas_price_constraint.exs index 9a532bdd5c5b..30afa6f93c9c 100644 --- a/apps/explorer/priv/polygon_edge/migrations/20230731130103_modify_collated_gas_price_constraint.exs +++ b/apps/explorer/priv/polygon_edge/migrations/20230731130103_modify_collated_gas_price_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.PolygonEdge.Migrations.ModifyCollatedGasPriceConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/polygon_zkevm/migrations/20230420110800_create_zkevm_tables.exs b/apps/explorer/priv/polygon_zkevm/migrations/20230420110800_create_zkevm_tables.exs deleted file mode 100644 index 1c242e3b8ec4..000000000000 --- a/apps/explorer/priv/polygon_zkevm/migrations/20230420110800_create_zkevm_tables.exs +++ /dev/null @@ -1,55 +0,0 @@ -defmodule Explorer.Repo.PolygonZkevm.Migrations.CreateZkevmTables do - use Ecto.Migration - - def change do - create table(:zkevm_lifecycle_l1_transactions, primary_key: false) do - add(:id, :integer, null: false, primary_key: true) - add(:hash, :bytea, null: false) - add(:is_verify, :boolean, null: false) - timestamps(null: false, type: :utc_datetime_usec) - end - - create(unique_index(:zkevm_lifecycle_l1_transactions, :hash)) - - create table(:zkevm_transaction_batches, primary_key: false) do - add(:number, :integer, null: false, primary_key: true) - add(:timestamp, :"timestamp without time zone", null: false) - add(:l2_transactions_count, :integer, null: false) - add(:global_exit_root, :bytea, null: false) - add(:acc_input_hash, :bytea, null: false) - add(:state_root, :bytea, null: false) - - add( - :sequence_id, - references(:zkevm_lifecycle_l1_transactions, on_delete: :restrict, on_update: :update_all, type: :integer), - null: true - ) - - add( - :verify_id, - references(:zkevm_lifecycle_l1_transactions, on_delete: :restrict, on_update: :update_all, type: :integer), - null: true - ) - - timestamps(null: false, type: :utc_datetime_usec) - end - - create table(:zkevm_batch_l2_transactions, primary_key: false) do - add( - :batch_number, - references(:zkevm_transaction_batches, - column: :number, - on_delete: :delete_all, - on_update: :update_all, - type: :integer - ), - null: false - ) - - add(:hash, :bytea, null: false, primary_key: true) - timestamps(null: false, type: :utc_datetime_usec) - end - - create(index(:zkevm_batch_l2_transactions, :batch_number)) - end -end diff --git a/apps/explorer/priv/polygon_zkevm/migrations/20231010093238_add_bridge_tables.exs b/apps/explorer/priv/polygon_zkevm/migrations/20231010093238_add_bridge_tables.exs deleted file mode 100644 index 805acd4d6a70..000000000000 --- a/apps/explorer/priv/polygon_zkevm/migrations/20231010093238_add_bridge_tables.exs +++ /dev/null @@ -1,46 +0,0 @@ -defmodule Explorer.Repo.PolygonZkevm.Migrations.AddBridgeTables do - use Ecto.Migration - - def change do - create table(:polygon_zkevm_bridge_l1_tokens, primary_key: false) do - add(:id, :identity, primary_key: true, start_value: 0, increment: 1) - add(:address, :bytea, null: false) - add(:decimals, :smallint, null: true, default: nil) - add(:symbol, :string, size: 16, null: true, default: nil) - timestamps(null: false, type: :utc_datetime_usec) - end - - create(unique_index(:polygon_zkevm_bridge_l1_tokens, :address)) - - execute( - "CREATE TYPE polygon_zkevm_bridge_op_type AS ENUM ('deposit', 'withdrawal')", - "DROP TYPE polygon_zkevm_bridge_op_type" - ) - - create table(:polygon_zkevm_bridge, primary_key: false) do - add(:type, :polygon_zkevm_bridge_op_type, null: false, primary_key: true) - add(:index, :integer, null: false, primary_key: true) - add(:l1_transaction_hash, :bytea, null: true) - add(:l2_transaction_hash, :bytea, null: true) - - add( - :l1_token_id, - references(:polygon_zkevm_bridge_l1_tokens, on_delete: :restrict, on_update: :update_all, type: :identity), - null: true - ) - - add(:l1_token_address, :bytea, null: true) - add(:l2_token_address, :bytea, null: true) - add(:amount, :numeric, precision: 100, null: false) - add(:block_number, :bigint, null: true) - add(:block_timestamp, :"timestamp without time zone", null: true) - timestamps(null: false, type: :utc_datetime_usec) - end - - create(index(:polygon_zkevm_bridge, :l1_token_address)) - - rename(table(:zkevm_lifecycle_l1_transactions), to: table(:polygon_zkevm_lifecycle_l1_transactions)) - rename(table(:zkevm_transaction_batches), to: table(:polygon_zkevm_transaction_batches)) - rename(table(:zkevm_batch_l2_transactions), to: table(:polygon_zkevm_batch_l2_transactions)) - end -end diff --git a/apps/explorer/priv/polygon_zkevm/migrations/20240306080627_make_timestamp_optional.exs b/apps/explorer/priv/polygon_zkevm/migrations/20240306080627_make_timestamp_optional.exs deleted file mode 100644 index f813ba8ba735..000000000000 --- a/apps/explorer/priv/polygon_zkevm/migrations/20240306080627_make_timestamp_optional.exs +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Explorer.Repo.PolygonZkevm.Migrations.MakeTimestampOptional do - use Ecto.Migration - - def change do - alter table("polygon_zkevm_transaction_batches") do - modify(:timestamp, :"timestamp without time zone", null: true) - end - end -end diff --git a/apps/explorer/priv/repo/migrations/20180117221921_create_address.exs b/apps/explorer/priv/repo/migrations/20180117221921_create_address.exs index aabe0d93697b..dfacd5c0c3cc 100644 --- a/apps/explorer/priv/repo/migrations/20180117221921_create_address.exs +++ b/apps/explorer/priv/repo/migrations/20180117221921_create_address.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateAddress do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180117221922_create_blocks.exs b/apps/explorer/priv/repo/migrations/20180117221922_create_blocks.exs index b9157dc9a4d2..fabcb984189f 100644 --- a/apps/explorer/priv/repo/migrations/20180117221922_create_blocks.exs +++ b/apps/explorer/priv/repo/migrations/20180117221922_create_blocks.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateBlocks do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180117221923_create_transactions.exs b/apps/explorer/priv/repo/migrations/20180117221923_create_transactions.exs index 92dc197f372b..30b4bee3c8ac 100644 --- a/apps/explorer/priv/repo/migrations/20180117221923_create_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20180117221923_create_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTransactions do use Ecto.Migration @@ -41,7 +42,7 @@ defmodule Explorer.Repo.Migrations.CreateTransactions do add(:block_hash, references(:blocks, column: :hash, on_delete: :delete_all, type: :bytea), null: true) # `null` when a pending transaction - # denormalized from `blocks.number` to improve `Explorer.Chain.recent_collated_transactions/0` performance + # denormalized from `blocks.number` to improve `Explorer.Chain.Transaction.recent_collated_transactions/0` performance add(:block_number, :integer, null: true) add(:from_address_hash, references(:addresses, column: :hash, on_delete: :delete_all, type: :bytea), null: false) diff --git a/apps/explorer/priv/repo/migrations/20180212222309_create_logs.exs b/apps/explorer/priv/repo/migrations/20180212222309_create_logs.exs index b6dc879204e4..bc87ffc51d19 100644 --- a/apps/explorer/priv/repo/migrations/20180212222309_create_logs.exs +++ b/apps/explorer/priv/repo/migrations/20180212222309_create_logs.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateLogs do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180221001948_create_internal_transactions.exs b/apps/explorer/priv/repo/migrations/20180221001948_create_internal_transactions.exs index cb7c64c7c2ec..6b4de7df1e78 100644 --- a/apps/explorer/priv/repo/migrations/20180221001948_create_internal_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20180221001948_create_internal_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateInternalTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180424203101_create_market_history.exs b/apps/explorer/priv/repo/migrations/20180424203101_create_market_history.exs index 44c0e8efa1c1..cfb3f577c986 100644 --- a/apps/explorer/priv/repo/migrations/20180424203101_create_market_history.exs +++ b/apps/explorer/priv/repo/migrations/20180424203101_create_market_history.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateMarketHistory do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180508183700_create_users.exs b/apps/explorer/priv/repo/migrations/20180508183700_create_users.exs index c94a47492925..48c49b317357 100644 --- a/apps/explorer/priv/repo/migrations/20180508183700_create_users.exs +++ b/apps/explorer/priv/repo/migrations/20180508183700_create_users.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateUsers do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180508191045_create_user_contacts.exs b/apps/explorer/priv/repo/migrations/20180508191045_create_user_contacts.exs index 84b896ed5c86..cb95978171b2 100644 --- a/apps/explorer/priv/repo/migrations/20180508191045_create_user_contacts.exs +++ b/apps/explorer/priv/repo/migrations/20180508191045_create_user_contacts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateUserContacts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180518221256_create_smart_contract_verified.exs b/apps/explorer/priv/repo/migrations/20180518221256_create_smart_contract_verified.exs index 0402d4f40373..79af3ccd9bd4 100644 --- a/apps/explorer/priv/repo/migrations/20180518221256_create_smart_contract_verified.exs +++ b/apps/explorer/priv/repo/migrations/20180518221256_create_smart_contract_verified.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateSmartContractVerified do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180522154252_create_btree_gist_extension.exs b/apps/explorer/priv/repo/migrations/20180522154252_create_btree_gist_extension.exs index d773e024717c..882e17ace362 100644 --- a/apps/explorer/priv/repo/migrations/20180522154252_create_btree_gist_extension.exs +++ b/apps/explorer/priv/repo/migrations/20180522154252_create_btree_gist_extension.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateBtreeGistExtension do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180522154253_create_block_rewards.exs b/apps/explorer/priv/repo/migrations/20180522154253_create_block_rewards.exs index 5e1c4349ae91..02e9a4d19cab 100644 --- a/apps/explorer/priv/repo/migrations/20180522154253_create_block_rewards.exs +++ b/apps/explorer/priv/repo/migrations/20180522154253_create_block_rewards.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateBlockRewards do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180606135149_create_tokens.exs b/apps/explorer/priv/repo/migrations/20180606135149_create_tokens.exs index 4246dfee6b68..df67a18896d4 100644 --- a/apps/explorer/priv/repo/migrations/20180606135149_create_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20180606135149_create_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180606135150_create_token_transfers.exs b/apps/explorer/priv/repo/migrations/20180606135150_create_token_transfers.exs index e0e8b2c62577..8365d9efa388 100644 --- a/apps/explorer/priv/repo/migrations/20180606135150_create_token_transfers.exs +++ b/apps/explorer/priv/repo/migrations/20180606135150_create_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokenTransfers do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180626143840_add_inserted_at_index_to_blocks.exs b/apps/explorer/priv/repo/migrations/20180626143840_add_inserted_at_index_to_blocks.exs index 2b723ec120cb..2189d847b01f 100644 --- a/apps/explorer/priv/repo/migrations/20180626143840_add_inserted_at_index_to_blocks.exs +++ b/apps/explorer/priv/repo/migrations/20180626143840_add_inserted_at_index_to_blocks.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddInsertedAtIndexToBlocks do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180717204948_create_address_coin_balances.exs b/apps/explorer/priv/repo/migrations/20180717204948_create_address_coin_balances.exs index 7001f3b8b07d..ad1ebbec8488 100644 --- a/apps/explorer/priv/repo/migrations/20180717204948_create_address_coin_balances.exs +++ b/apps/explorer/priv/repo/migrations/20180717204948_create_address_coin_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateCoinBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180817021704_create_address_token_balances.exs b/apps/explorer/priv/repo/migrations/20180817021704_create_address_token_balances.exs index 94902f55933b..ff28bdd05b6e 100644 --- a/apps/explorer/priv/repo/migrations/20180817021704_create_address_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20180817021704_create_address_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateAddressTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180821142139_create_address_names.exs b/apps/explorer/priv/repo/migrations/20180821142139_create_address_names.exs index d501bee198af..7d0f6ad12cf7 100644 --- a/apps/explorer/priv/repo/migrations/20180821142139_create_address_names.exs +++ b/apps/explorer/priv/repo/migrations/20180821142139_create_address_names.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateAddressNames do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180917182319_create_block_second_degree_relations.exs b/apps/explorer/priv/repo/migrations/20180917182319_create_block_second_degree_relations.exs index ed77cdb3090d..ceaa5c855529 100644 --- a/apps/explorer/priv/repo/migrations/20180917182319_create_block_second_degree_relations.exs +++ b/apps/explorer/priv/repo/migrations/20180917182319_create_block_second_degree_relations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateBlockSecondDegreeRelations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180918200001_create_transaction_fork.exs b/apps/explorer/priv/repo/migrations/20180918200001_create_transaction_fork.exs index 06e77dcd4134..8f6840237cf8 100644 --- a/apps/explorer/priv/repo/migrations/20180918200001_create_transaction_fork.exs +++ b/apps/explorer/priv/repo/migrations/20180918200001_create_transaction_fork.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTransactionBlockUncles do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20180919175123_alter_token_decimals_to_bigint.exs b/apps/explorer/priv/repo/migrations/20180919175123_alter_token_decimals_to_bigint.exs index d185476853f8..b066ccfb753d 100644 --- a/apps/explorer/priv/repo/migrations/20180919175123_alter_token_decimals_to_bigint.exs +++ b/apps/explorer/priv/repo/migrations/20180919175123_alter_token_decimals_to_bigint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AlterTokenDecimalsToBigint do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181008195723_create_administrators.exs b/apps/explorer/priv/repo/migrations/20181008195723_create_administrators.exs index ea0f45d3bb73..f5c2cca8b829 100644 --- a/apps/explorer/priv/repo/migrations/20181008195723_create_administrators.exs +++ b/apps/explorer/priv/repo/migrations/20181008195723_create_administrators.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateAdministrators do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181011193212_add_fields_to_internal_transactions.exs b/apps/explorer/priv/repo/migrations/20181011193212_add_fields_to_internal_transactions.exs index 9eda36fe323b..a7c5461f8884 100644 --- a/apps/explorer/priv/repo/migrations/20181011193212_add_fields_to_internal_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20181011193212_add_fields_to_internal_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddFieldsToInternalTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181015173318_add_case_insensitive_extension.exs b/apps/explorer/priv/repo/migrations/20181015173318_add_case_insensitive_extension.exs index 5ddc4688ebd0..c901a1aa9107 100644 --- a/apps/explorer/priv/repo/migrations/20181015173318_add_case_insensitive_extension.exs +++ b/apps/explorer/priv/repo/migrations/20181015173318_add_case_insensitive_extension.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddCaseInsensitiveExtension do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181015173319_modify_users_username.exs b/apps/explorer/priv/repo/migrations/20181015173319_modify_users_username.exs index 44b21818d8a9..dec0a95b2c56 100644 --- a/apps/explorer/priv/repo/migrations/20181015173319_modify_users_username.exs +++ b/apps/explorer/priv/repo/migrations/20181015173319_modify_users_username.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ModifyUsersUsername do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181016163236_modify_user_contacts_email.exs b/apps/explorer/priv/repo/migrations/20181016163236_modify_user_contacts_email.exs index 986a18e259b5..94ce7d05de77 100644 --- a/apps/explorer/priv/repo/migrations/20181016163236_modify_user_contacts_email.exs +++ b/apps/explorer/priv/repo/migrations/20181016163236_modify_user_contacts_email.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ModifyUserContactsEmail do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181017141409_add_index_to_internal_transaction_table.exs b/apps/explorer/priv/repo/migrations/20181017141409_add_index_to_internal_transaction_table.exs index 0f7e6c183781..1bfc8050d4e0 100644 --- a/apps/explorer/priv/repo/migrations/20181017141409_add_index_to_internal_transaction_table.exs +++ b/apps/explorer/priv/repo/migrations/20181017141409_add_index_to_internal_transaction_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexToInternalTransactionTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181024141113_internal_transactions_composite_primary_key.exs b/apps/explorer/priv/repo/migrations/20181024141113_internal_transactions_composite_primary_key.exs index be09d23c935c..1479b8310813 100644 --- a/apps/explorer/priv/repo/migrations/20181024141113_internal_transactions_composite_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20181024141113_internal_transactions_composite_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.InternalTransactionsCompositePrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181024164623_logs_composite_primary_key.exs b/apps/explorer/priv/repo/migrations/20181024164623_logs_composite_primary_key.exs index 702f79eec134..81847b1aeb29 100644 --- a/apps/explorer/priv/repo/migrations/20181024164623_logs_composite_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20181024164623_logs_composite_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.LogsCompositePrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181024172010_token_transfers_composite_primary_key.exs b/apps/explorer/priv/repo/migrations/20181024172010_token_transfers_composite_primary_key.exs index a9736478988f..1859fd301270 100644 --- a/apps/explorer/priv/repo/migrations/20181024172010_token_transfers_composite_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20181024172010_token_transfers_composite_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TokenTransfersCompositePrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs b/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs index f2fb477a2a64..d83cd9fca548 100644 --- a/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20181026180921_create_address_current_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateAddressCurrentTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181029174420_update_tokens_table_decimals_from_bigint_to_numeric.exs b/apps/explorer/priv/repo/migrations/20181029174420_update_tokens_table_decimals_from_bigint_to_numeric.exs index 3f4f4df9d621..6f840a34017e 100644 --- a/apps/explorer/priv/repo/migrations/20181029174420_update_tokens_table_decimals_from_bigint_to_numeric.exs +++ b/apps/explorer/priv/repo/migrations/20181029174420_update_tokens_table_decimals_from_bigint_to_numeric.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.UpdateTokensTableDecimalsFromBigintToNumeric do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181106152300_add_nonce_to_addresses.exs b/apps/explorer/priv/repo/migrations/20181106152300_add_nonce_to_addresses.exs index 871c1daf5789..b6e9b905fdc3 100644 --- a/apps/explorer/priv/repo/migrations/20181106152300_add_nonce_to_addresses.exs +++ b/apps/explorer/priv/repo/migrations/20181106152300_add_nonce_to_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddNonceToAddresses do @moduledoc """ Use `priv/repo/migrations/scripts/20181126182700_migrate_address_nonce.sql` to migrate data. diff --git a/apps/explorer/priv/repo/migrations/20181107164103_eip6.exs b/apps/explorer/priv/repo/migrations/20181107164103_eip6.exs index 783561be1f25..5bc1b9d061b4 100644 --- a/apps/explorer/priv/repo/migrations/20181107164103_eip6.exs +++ b/apps/explorer/priv/repo/migrations/20181107164103_eip6.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.EIP6 do @moduledoc """ Use `priv/repo/migrations/scripts/20181107164103_eip6.sql` to migrate data and validate constraint. diff --git a/apps/explorer/priv/repo/migrations/20181108205650_additional_internal_transaction_constraints.exs b/apps/explorer/priv/repo/migrations/20181108205650_additional_internal_transaction_constraints.exs index 431e68144cf6..4a1268cf855d 100644 --- a/apps/explorer/priv/repo/migrations/20181108205650_additional_internal_transaction_constraints.exs +++ b/apps/explorer/priv/repo/migrations/20181108205650_additional_internal_transaction_constraints.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AdditionalInternalTransactionConstraints do @moduledoc """ Use `priv/repo/migrations/scripts/20181108205650_additional_internal_transaction_constraints.sql` to migrate data and diff --git a/apps/explorer/priv/repo/migrations/20181121170616_add_block_number_to_token_transfers.exs b/apps/explorer/priv/repo/migrations/20181121170616_add_block_number_to_token_transfers.exs index 60c426e304f3..eee4756b0195 100644 --- a/apps/explorer/priv/repo/migrations/20181121170616_add_block_number_to_token_transfers.exs +++ b/apps/explorer/priv/repo/migrations/20181121170616_add_block_number_to_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockNumberToTokenTransfers do @moduledoc """ Use `priv/repo/migrations/scripts/20181121170616_token_transfers_update_block_number_in_batches.sql` to migrate data. diff --git a/apps/explorer/priv/repo/migrations/20181126203826_add_index_to_addresses.exs b/apps/explorer/priv/repo/migrations/20181126203826_add_index_to_addresses.exs index 3c1b16dfec24..81ddc18a5825 100644 --- a/apps/explorer/priv/repo/migrations/20181126203826_add_index_to_addresses.exs +++ b/apps/explorer/priv/repo/migrations/20181126203826_add_index_to_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexToAddresses do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181206200140_rename_block_rewards_to_emission_rewards.exs b/apps/explorer/priv/repo/migrations/20181206200140_rename_block_rewards_to_emission_rewards.exs index 6ec5a8c4668e..1ae33448515f 100644 --- a/apps/explorer/priv/repo/migrations/20181206200140_rename_block_rewards_to_emission_rewards.exs +++ b/apps/explorer/priv/repo/migrations/20181206200140_rename_block_rewards_to_emission_rewards.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RenameBlockRewardsToEmissionRewards do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181206200312_create_new_block_rewards.exs b/apps/explorer/priv/repo/migrations/20181206200312_create_new_block_rewards.exs index 253b75c52820..10b5450f52d6 100644 --- a/apps/explorer/priv/repo/migrations/20181206200312_create_new_block_rewards.exs +++ b/apps/explorer/priv/repo/migrations/20181206200312_create_new_block_rewards.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateNewBlockRewards do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181212115448_add_indexes_to_token_transfers.exs b/apps/explorer/priv/repo/migrations/20181212115448_add_indexes_to_token_transfers.exs index 67d9ad5dc0bc..e3850e34b7f1 100644 --- a/apps/explorer/priv/repo/migrations/20181212115448_add_indexes_to_token_transfers.exs +++ b/apps/explorer/priv/repo/migrations/20181212115448_add_indexes_to_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexesToTokenTransfers do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181213111656_add_metadata_field_to_address_names.exs b/apps/explorer/priv/repo/migrations/20181213111656_add_metadata_field_to_address_names.exs index 593c381a298d..9f38da7e4f57 100644 --- a/apps/explorer/priv/repo/migrations/20181213111656_add_metadata_field_to_address_names.exs +++ b/apps/explorer/priv/repo/migrations/20181213111656_add_metadata_field_to_address_names.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddMetadataFieldToAddressNames do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181221143000_create_blocks_miner_hash_index.exs b/apps/explorer/priv/repo/migrations/20181221143000_create_blocks_miner_hash_index.exs index bd5993229527..2b23ad27f8cd 100644 --- a/apps/explorer/priv/repo/migrations/20181221143000_create_blocks_miner_hash_index.exs +++ b/apps/explorer/priv/repo/migrations/20181221143000_create_blocks_miner_hash_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateBlocksMinerHashIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20181221145054_add_contract_methods.exs b/apps/explorer/priv/repo/migrations/20181221145054_add_contract_methods.exs index f2cc50fa76f5..14b123cee1a2 100644 --- a/apps/explorer/priv/repo/migrations/20181221145054_add_contract_methods.exs +++ b/apps/explorer/priv/repo/migrations/20181221145054_add_contract_methods.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddContractMethods do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190102141900_index_current_token_holders.exs b/apps/explorer/priv/repo/migrations/20190102141900_index_current_token_holders.exs index 5332b0f09ebc..70204486b404 100644 --- a/apps/explorer/priv/repo/migrations/20190102141900_index_current_token_holders.exs +++ b/apps/explorer/priv/repo/migrations/20190102141900_index_current_token_holders.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.IndexCurrentTokenHolders do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190114204640_add_tokens_holder_count.exs b/apps/explorer/priv/repo/migrations/20190114204640_add_tokens_holder_count.exs index 502470dd6566..613d48c77c96 100644 --- a/apps/explorer/priv/repo/migrations/20190114204640_add_tokens_holder_count.exs +++ b/apps/explorer/priv/repo/migrations/20190114204640_add_tokens_holder_count.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokensHolderCount do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190116082843_add_created_contract_indexed_at_to_transactions.exs b/apps/explorer/priv/repo/migrations/20190116082843_add_created_contract_indexed_at_to_transactions.exs index 44aafd9cffca..8bae5272df6d 100644 --- a/apps/explorer/priv/repo/migrations/20190116082843_add_created_contract_indexed_at_to_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20190116082843_add_created_contract_indexed_at_to_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddCreatedContractIndexedAtToTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190118040301_create_tokens_primary_key.exs b/apps/explorer/priv/repo/migrations/20190118040301_create_tokens_primary_key.exs index 67ce6b0f2061..06f1f2ac0ce7 100644 --- a/apps/explorer/priv/repo/migrations/20190118040301_create_tokens_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20190118040301_create_tokens_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokensPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190118152240_block_second_degree_relations_composite_primary_key.exs b/apps/explorer/priv/repo/migrations/20190118152240_block_second_degree_relations_composite_primary_key.exs index 1bc0ae9d0602..865b5a7ad7d6 100644 --- a/apps/explorer/priv/repo/migrations/20190118152240_block_second_degree_relations_composite_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20190118152240_block_second_degree_relations_composite_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.BlockSecondDegreeRelationsCompositePrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190122125815_change_transaction_error_constraint.exs b/apps/explorer/priv/repo/migrations/20190122125815_change_transaction_error_constraint.exs index 9111ab4e874f..b63b16b38f5f 100644 --- a/apps/explorer/priv/repo/migrations/20190122125815_change_transaction_error_constraint.exs +++ b/apps/explorer/priv/repo/migrations/20190122125815_change_transaction_error_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ChangeTransactionErrorConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190124082812_add_index_on_transaction_nonce_and_from_address_hash.exs b/apps/explorer/priv/repo/migrations/20190124082812_add_index_on_transaction_nonce_and_from_address_hash.exs index 8d8848a2d1c9..31cd28c45af7 100644 --- a/apps/explorer/priv/repo/migrations/20190124082812_add_index_on_transaction_nonce_and_from_address_hash.exs +++ b/apps/explorer/priv/repo/migrations/20190124082812_add_index_on_transaction_nonce_and_from_address_hash.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexOnTransactionNonceAndFromAddressHash do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190208113202_add_unique_index_to_rewards.exs b/apps/explorer/priv/repo/migrations/20190208113202_add_unique_index_to_rewards.exs index cd306c79778d..40e03e9f532c 100644 --- a/apps/explorer/priv/repo/migrations/20190208113202_add_unique_index_to_rewards.exs +++ b/apps/explorer/priv/repo/migrations/20190208113202_add_unique_index_to_rewards.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddUniqueIndexToRewards do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190208143201_add_index_on_address_hash_block_number_and_token_contract_address_hash_for_current_token_balance.exs b/apps/explorer/priv/repo/migrations/20190208143201_add_index_on_address_hash_block_number_and_token_contract_address_hash_for_current_token_balance.exs index ba8576675609..fb27a33f47de 100644 --- a/apps/explorer/priv/repo/migrations/20190208143201_add_index_on_address_hash_block_number_and_token_contract_address_hash_for_current_token_balance.exs +++ b/apps/explorer/priv/repo/migrations/20190208143201_add_index_on_address_hash_block_number_and_token_contract_address_hash_for_current_token_balance.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexOnAddressHashBlockNumberAndTokenContractAddressHashForCurrentTokenBalance do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190213180502_add_earliest_processing_start_to_transactions.exs b/apps/explorer/priv/repo/migrations/20190213180502_add_earliest_processing_start_to_transactions.exs index cc895ae70b18..52071b315e84 100644 --- a/apps/explorer/priv/repo/migrations/20190213180502_add_earliest_processing_start_to_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20190213180502_add_earliest_processing_start_to_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddEarliestProcessingStartToTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190214135850_add_index_on_block_number_to_address_token_balances.exs b/apps/explorer/priv/repo/migrations/20190214135850_add_index_on_block_number_to_address_token_balances.exs index 356d49e096f9..55a0f4b7a150 100644 --- a/apps/explorer/priv/repo/migrations/20190214135850_add_index_on_block_number_to_address_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20190214135850_add_index_on_block_number_to_address_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexOnBlockNumberToAddressTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190215080049_add_index_on_fetched_coin_balance_to_addresses.exs b/apps/explorer/priv/repo/migrations/20190215080049_add_index_on_fetched_coin_balance_to_addresses.exs index 3e9b53487f4c..900cae312c68 100644 --- a/apps/explorer/priv/repo/migrations/20190215080049_add_index_on_fetched_coin_balance_to_addresses.exs +++ b/apps/explorer/priv/repo/migrations/20190215080049_add_index_on_fetched_coin_balance_to_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexOnFetchedCoinBalanceToAddresses do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190215093358_add_compound_index_address_token_balances.exs b/apps/explorer/priv/repo/migrations/20190215093358_add_compound_index_address_token_balances.exs index 3bf3582df0f9..be3f631ff545 100644 --- a/apps/explorer/priv/repo/migrations/20190215093358_add_compound_index_address_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20190215093358_add_compound_index_address_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddCompoundIndexAddressTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190215105501_add_constructor_arguments_to_smart_contracts.exs b/apps/explorer/priv/repo/migrations/20190215105501_add_constructor_arguments_to_smart_contracts.exs index 0abe45b09ad2..d611051889cb 100644 --- a/apps/explorer/priv/repo/migrations/20190215105501_add_constructor_arguments_to_smart_contracts.exs +++ b/apps/explorer/priv/repo/migrations/20190215105501_add_constructor_arguments_to_smart_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddConstructorArgumentsToSmartContracts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190219082636_add_indexes_for_block_reward_query.exs b/apps/explorer/priv/repo/migrations/20190219082636_add_indexes_for_block_reward_query.exs index 18f06b899992..8e5057bd8ce3 100644 --- a/apps/explorer/priv/repo/migrations/20190219082636_add_indexes_for_block_reward_query.exs +++ b/apps/explorer/priv/repo/migrations/20190219082636_add_indexes_for_block_reward_query.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexesForBlockRewardQuery do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190228102650_add_index_created_contract_address_hash.exs b/apps/explorer/priv/repo/migrations/20190228102650_add_index_created_contract_address_hash.exs index db46c4dc61ca..9d2fab9bb5a2 100644 --- a/apps/explorer/priv/repo/migrations/20190228102650_add_index_created_contract_address_hash.exs +++ b/apps/explorer/priv/repo/migrations/20190228102650_add_index_created_contract_address_hash.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexCreatedContractAddressHas do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190228152333_change_constructor_arguments_to_text.exs b/apps/explorer/priv/repo/migrations/20190228152333_change_constructor_arguments_to_text.exs index 6582a013d85d..10f64248276e 100644 --- a/apps/explorer/priv/repo/migrations/20190228152333_change_constructor_arguments_to_text.exs +++ b/apps/explorer/priv/repo/migrations/20190228152333_change_constructor_arguments_to_text.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ChangeConstructorArgumentsToText do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190228220746_add_internal_transactions_indexed_at_to_blocks.exs b/apps/explorer/priv/repo/migrations/20190228220746_add_internal_transactions_indexed_at_to_blocks.exs index 2e913f4d9897..40f1fde17e0b 100644 --- a/apps/explorer/priv/repo/migrations/20190228220746_add_internal_transactions_indexed_at_to_blocks.exs +++ b/apps/explorer/priv/repo/migrations/20190228220746_add_internal_transactions_indexed_at_to_blocks.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddInternalTransactionsIndexedAtToBlocks do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190301095620_remove_duplicated_indexes.exs b/apps/explorer/priv/repo/migrations/20190301095620_remove_duplicated_indexes.exs index f89d9a365298..b54d51ac5b70 100644 --- a/apps/explorer/priv/repo/migrations/20190301095620_remove_duplicated_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20190301095620_remove_duplicated_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveDuplicatedIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190301120328_add_index_to_consensus.exs b/apps/explorer/priv/repo/migrations/20190301120328_add_index_to_consensus.exs index 1a3f3ac5b630..ea4067c85952 100644 --- a/apps/explorer/priv/repo/migrations/20190301120328_add_index_to_consensus.exs +++ b/apps/explorer/priv/repo/migrations/20190301120328_add_index_to_consensus.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexToConsensus do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190305095926_add_index_to_value_fetched_at.exs b/apps/explorer/priv/repo/migrations/20190305095926_add_index_to_value_fetched_at.exs index 27b39a560101..a5c47cd8ea4a 100644 --- a/apps/explorer/priv/repo/migrations/20190305095926_add_index_to_value_fetched_at.exs +++ b/apps/explorer/priv/repo/migrations/20190305095926_add_index_to_value_fetched_at.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexToValueFetchedAt do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190313085740_add_index_symobl_in_tokens.exs b/apps/explorer/priv/repo/migrations/20190313085740_add_index_symobl_in_tokens.exs index ce6b8d24b45e..095027b899c9 100644 --- a/apps/explorer/priv/repo/migrations/20190313085740_add_index_symobl_in_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20190313085740_add_index_symobl_in_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexSymbolInTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190313103912_change_transactions_v_column_type.exs b/apps/explorer/priv/repo/migrations/20190313103912_change_transactions_v_column_type.exs index 73aaaad672eb..2e0d3a61287e 100644 --- a/apps/explorer/priv/repo/migrations/20190313103912_change_transactions_v_column_type.exs +++ b/apps/explorer/priv/repo/migrations/20190313103912_change_transactions_v_column_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ChangeTransactionsVColumnType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190314084907_add_index_to_to_address_hash.exs b/apps/explorer/priv/repo/migrations/20190314084907_add_index_to_to_address_hash.exs index a46822a17c9e..c3327e62c37f 100644 --- a/apps/explorer/priv/repo/migrations/20190314084907_add_index_to_to_address_hash.exs +++ b/apps/explorer/priv/repo/migrations/20190314084907_add_index_to_to_address_hash.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexToToAddressHash do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190318151809_add_inserted_at_index_to_accounts.exs b/apps/explorer/priv/repo/migrations/20190318151809_add_inserted_at_index_to_accounts.exs index f1ac25756528..e427ca2d4d71 100644 --- a/apps/explorer/priv/repo/migrations/20190318151809_add_inserted_at_index_to_accounts.exs +++ b/apps/explorer/priv/repo/migrations/20190318151809_add_inserted_at_index_to_accounts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddInsertedAtIndexToAccounts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190319081821_create_decompiled_smart_contracts.exs b/apps/explorer/priv/repo/migrations/20190319081821_create_decompiled_smart_contracts.exs index 4055f3242b6c..44d0e6fb363f 100644 --- a/apps/explorer/priv/repo/migrations/20190319081821_create_decompiled_smart_contracts.exs +++ b/apps/explorer/priv/repo/migrations/20190319081821_create_decompiled_smart_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateDecompiledSmartContracts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190321185644_add_old_value_for_current_token_balances.exs b/apps/explorer/priv/repo/migrations/20190321185644_add_old_value_for_current_token_balances.exs index 45cc50d7e444..20f9cbb165fe 100644 --- a/apps/explorer/priv/repo/migrations/20190321185644_add_old_value_for_current_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20190321185644_add_old_value_for_current_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOldValueForCurrentTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190325081658_remove_unique_address_hash_decompiled_contracts.exs b/apps/explorer/priv/repo/migrations/20190325081658_remove_unique_address_hash_decompiled_contracts.exs index eb4b94237e24..0aa729daf5a7 100644 --- a/apps/explorer/priv/repo/migrations/20190325081658_remove_unique_address_hash_decompiled_contracts.exs +++ b/apps/explorer/priv/repo/migrations/20190325081658_remove_unique_address_hash_decompiled_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveUniqueAddressHashDecompiledContracts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190403080447_add_full_text_search_tokens.exs b/apps/explorer/priv/repo/migrations/20190403080447_add_full_text_search_tokens.exs index a4d642f54ed5..98cda8e1db87 100644 --- a/apps/explorer/priv/repo/migrations/20190403080447_add_full_text_search_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20190403080447_add_full_text_search_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddFullTextSearchTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190421143300_add_index_to_bsdr.exs b/apps/explorer/priv/repo/migrations/20190421143300_add_index_to_bsdr.exs index dc8fc0422dfc..f111fde79b65 100644 --- a/apps/explorer/priv/repo/migrations/20190421143300_add_index_to_bsdr.exs +++ b/apps/explorer/priv/repo/migrations/20190421143300_add_index_to_bsdr.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexToBsdr do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190424170833_change_block_size_to_nullable.exs b/apps/explorer/priv/repo/migrations/20190424170833_change_block_size_to_nullable.exs index 90769c7233bf..7b6cdbe6ba70 100644 --- a/apps/explorer/priv/repo/migrations/20190424170833_change_block_size_to_nullable.exs +++ b/apps/explorer/priv/repo/migrations/20190424170833_change_block_size_to_nullable.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ChangeBlockSizeToNullable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190508152922_add_old_block_hash_for_transactions.exs b/apps/explorer/priv/repo/migrations/20190508152922_add_old_block_hash_for_transactions.exs index daf04c22f3fc..3213a3760850 100644 --- a/apps/explorer/priv/repo/migrations/20190508152922_add_old_block_hash_for_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20190508152922_add_old_block_hash_for_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddOldBlockHashForTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190513134025_add_refetch_needed_to_block.exs b/apps/explorer/priv/repo/migrations/20190513134025_add_refetch_needed_to_block.exs index 5380e2df142d..dc5019b1fbda 100644 --- a/apps/explorer/priv/repo/migrations/20190513134025_add_refetch_needed_to_block.exs +++ b/apps/explorer/priv/repo/migrations/20190513134025_add_refetch_needed_to_block.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddRefetchNeededToBlock do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190516140202_add_address_hash_index_to_decompiled_smart_contracts.exs b/apps/explorer/priv/repo/migrations/20190516140202_add_address_hash_index_to_decompiled_smart_contracts.exs index 689a3757d27a..c6f3d56893fa 100644 --- a/apps/explorer/priv/repo/migrations/20190516140202_add_address_hash_index_to_decompiled_smart_contracts.exs +++ b/apps/explorer/priv/repo/migrations/20190516140202_add_address_hash_index_to_decompiled_smart_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddAddressHashIndexToDecompiledSmartContracts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190516160535_add_decompiled_and_verified_flag_to_addresses.exs b/apps/explorer/priv/repo/migrations/20190516160535_add_decompiled_and_verified_flag_to_addresses.exs index cfc45092fd06..57f68c572fb5 100644 --- a/apps/explorer/priv/repo/migrations/20190516160535_add_decompiled_and_verified_flag_to_addresses.exs +++ b/apps/explorer/priv/repo/migrations/20190516160535_add_decompiled_and_verified_flag_to_addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddDecompiledAndVerifiedFlagToAddresses do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190521104412_create_staking_pools.exs b/apps/explorer/priv/repo/migrations/20190521104412_create_staking_pools.exs index 94483112f224..8b87e4730ab8 100644 --- a/apps/explorer/priv/repo/migrations/20190521104412_create_staking_pools.exs +++ b/apps/explorer/priv/repo/migrations/20190521104412_create_staking_pools.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateStakingPools do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190523112839_create_staking_pools_delegators.exs b/apps/explorer/priv/repo/migrations/20190523112839_create_staking_pools_delegators.exs index ccf9c8c3728e..3c1ac7a89662 100644 --- a/apps/explorer/priv/repo/migrations/20190523112839_create_staking_pools_delegators.exs +++ b/apps/explorer/priv/repo/migrations/20190523112839_create_staking_pools_delegators.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateStakingPoolsDelegator do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190613065856_add_transaction_hash_inserted_at_index.exs b/apps/explorer/priv/repo/migrations/20190613065856_add_transaction_hash_inserted_at_index.exs index 6402ee966706..fc85696aad67 100644 --- a/apps/explorer/priv/repo/migrations/20190613065856_add_transaction_hash_inserted_at_index.exs +++ b/apps/explorer/priv/repo/migrations/20190613065856_add_transaction_hash_inserted_at_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTransactionHashInsertedAtIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190619154943_reduce_transaction_status_constraint.exs b/apps/explorer/priv/repo/migrations/20190619154943_reduce_transaction_status_constraint.exs index 4964bc425073..eed4326387be 100644 --- a/apps/explorer/priv/repo/migrations/20190619154943_reduce_transaction_status_constraint.exs +++ b/apps/explorer/priv/repo/migrations/20190619154943_reduce_transaction_status_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ReduceTransactionStatusConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190625085852_add_additional_contract_fields.exs b/apps/explorer/priv/repo/migrations/20190625085852_add_additional_contract_fields.exs index 031593905ab1..55c00c62df52 100644 --- a/apps/explorer/priv/repo/migrations/20190625085852_add_additional_contract_fields.exs +++ b/apps/explorer/priv/repo/migrations/20190625085852_add_additional_contract_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddAdditionalContractFields do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190709043832_create_transaction_stats.exs b/apps/explorer/priv/repo/migrations/20190709043832_create_transaction_stats.exs index e358fc2e1225..cede553825f3 100644 --- a/apps/explorer/priv/repo/migrations/20190709043832_create_transaction_stats.exs +++ b/apps/explorer/priv/repo/migrations/20190709043832_create_transaction_stats.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTransactionStats do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190709103104_add_external_libraries_to_smart_contracts.exs b/apps/explorer/priv/repo/migrations/20190709103104_add_external_libraries_to_smart_contracts.exs index 5200253f84da..69e6b0cce041 100644 --- a/apps/explorer/priv/repo/migrations/20190709103104_add_external_libraries_to_smart_contracts.exs +++ b/apps/explorer/priv/repo/migrations/20190709103104_add_external_libraries_to_smart_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddExternalLibrariesToSmartContracts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190807111216_remove_duplicate_indexes.exs b/apps/explorer/priv/repo/migrations/20190807111216_remove_duplicate_indexes.exs index ae07c8b1e522..ae64de09b3c7 100644 --- a/apps/explorer/priv/repo/migrations/20190807111216_remove_duplicate_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20190807111216_remove_duplicate_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveDuplicateIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190807113117_create_suggested_indexes.exs b/apps/explorer/priv/repo/migrations/20190807113117_create_suggested_indexes.exs index d44d61092318..19a875d9bbb9 100644 --- a/apps/explorer/priv/repo/migrations/20190807113117_create_suggested_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20190807113117_create_suggested_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateSuggestedIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190827120224_add_index_on_token_transfer_token_id.exs b/apps/explorer/priv/repo/migrations/20190827120224_add_index_on_token_transfer_token_id.exs index b92f216fd44c..074fe8369a80 100644 --- a/apps/explorer/priv/repo/migrations/20190827120224_add_index_on_token_transfer_token_id.exs +++ b/apps/explorer/priv/repo/migrations/20190827120224_add_index_on_token_transfer_token_id.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexOnTokenTransferTokenId do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190905083522_create_token_instances.exs b/apps/explorer/priv/repo/migrations/20190905083522_create_token_instances.exs index 7ebf9c12e951..0f93daff681d 100644 --- a/apps/explorer/priv/repo/migrations/20190905083522_create_token_instances.exs +++ b/apps/explorer/priv/repo/migrations/20190905083522_create_token_instances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokenInstances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20190910170703_create_indexes_for_block_number_in_token_transfers_and_transactions.exs b/apps/explorer/priv/repo/migrations/20190910170703_create_indexes_for_block_number_in_token_transfers_and_transactions.exs index b71822462285..0e3c8bd691da 100644 --- a/apps/explorer/priv/repo/migrations/20190910170703_create_indexes_for_block_number_in_token_transfers_and_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20190910170703_create_indexes_for_block_number_in_token_transfers_and_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexesForBlockNumberInTokenTransfersAndTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191007082500_add_indexes_for_token_instances_query.exs b/apps/explorer/priv/repo/migrations/20191007082500_add_indexes_for_token_instances_query.exs index ee6f757544cd..e97060b909fc 100644 --- a/apps/explorer/priv/repo/migrations/20191007082500_add_indexes_for_token_instances_query.exs +++ b/apps/explorer/priv/repo/migrations/20191007082500_add_indexes_for_token_instances_query.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexesForTokenInstancesQuery do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191009121635_add_token_transfer_sorting_indexes.exs b/apps/explorer/priv/repo/migrations/20191009121635_add_token_transfer_sorting_indexes.exs index 65b4c004d26d..6a43c7b1c8bc 100644 --- a/apps/explorer/priv/repo/migrations/20191009121635_add_token_transfer_sorting_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20191009121635_add_token_transfer_sorting_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenTransferSortingIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191010075740_add_error_to_token_instances.exs b/apps/explorer/priv/repo/migrations/20191010075740_add_error_to_token_instances.exs index a2a9e6a8a59c..f776631006df 100644 --- a/apps/explorer/priv/repo/migrations/20191010075740_add_error_to_token_instances.exs +++ b/apps/explorer/priv/repo/migrations/20191010075740_add_error_to_token_instances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddErrorToTokenInstances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191018120546_create_pending_block_operations.exs b/apps/explorer/priv/repo/migrations/20191018120546_create_pending_block_operations.exs index 3e917122a681..b33f01b67741 100644 --- a/apps/explorer/priv/repo/migrations/20191018120546_create_pending_block_operations.exs +++ b/apps/explorer/priv/repo/migrations/20191018120546_create_pending_block_operations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreatePendingBlockOperations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191018140054_add_pending_internal_transactions_operation.exs b/apps/explorer/priv/repo/migrations/20191018140054_add_pending_internal_transactions_operation.exs index d7a7234026f4..8d424a3eefce 100644 --- a/apps/explorer/priv/repo/migrations/20191018140054_add_pending_internal_transactions_operation.exs +++ b/apps/explorer/priv/repo/migrations/20191018140054_add_pending_internal_transactions_operation.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddPendingInternalTransactionsOperation do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191121064805_add_block_hash_and_block_index_to_logs.exs b/apps/explorer/priv/repo/migrations/20191121064805_add_block_hash_and_block_index_to_logs.exs index 94033d52d014..8d4f33443d15 100644 --- a/apps/explorer/priv/repo/migrations/20191121064805_add_block_hash_and_block_index_to_logs.exs +++ b/apps/explorer/priv/repo/migrations/20191121064805_add_block_hash_and_block_index_to_logs.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockHashAndBlockIndexToLogs do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191122062035_add_block_hash_to_token_transfers.exs b/apps/explorer/priv/repo/migrations/20191122062035_add_block_hash_to_token_transfers.exs index c10d3b271307..a655b8e7eefe 100644 --- a/apps/explorer/priv/repo/migrations/20191122062035_add_block_hash_to_token_transfers.exs +++ b/apps/explorer/priv/repo/migrations/20191122062035_add_block_hash_to_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockHashToTokenTransfers do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191128124415_remove_duplicate_indexes_token_entities.exs b/apps/explorer/priv/repo/migrations/20191128124415_remove_duplicate_indexes_token_entities.exs index 9a9415ada4c8..df6afda69d4c 100644 --- a/apps/explorer/priv/repo/migrations/20191128124415_remove_duplicate_indexes_token_entities.exs +++ b/apps/explorer/priv/repo/migrations/20191128124415_remove_duplicate_indexes_token_entities.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveDuplicateIndexesTokenEntities do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191203112646_internal_transactions_add_to_address_hash_index.exs b/apps/explorer/priv/repo/migrations/20191203112646_internal_transactions_add_to_address_hash_index.exs index bf9bc5ce1065..2fe35839a09f 100644 --- a/apps/explorer/priv/repo/migrations/20191203112646_internal_transactions_add_to_address_hash_index.exs +++ b/apps/explorer/priv/repo/migrations/20191203112646_internal_transactions_add_to_address_hash_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.InternalTransactionsAddToAddressHashIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191208135613_block_rewards_block_hash_partial_index.exs b/apps/explorer/priv/repo/migrations/20191208135613_block_rewards_block_hash_partial_index.exs index 0b0fa86a4a0e..44ac899d36f0 100644 --- a/apps/explorer/priv/repo/migrations/20191208135613_block_rewards_block_hash_partial_index.exs +++ b/apps/explorer/priv/repo/migrations/20191208135613_block_rewards_block_hash_partial_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.BlockRewardsBlockHashPartialIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191218120138_logs_block_number_index_index.exs b/apps/explorer/priv/repo/migrations/20191218120138_logs_block_number_index_index.exs index 735a835db934..71548f208562 100644 --- a/apps/explorer/priv/repo/migrations/20191218120138_logs_block_number_index_index.exs +++ b/apps/explorer/priv/repo/migrations/20191218120138_logs_block_number_index_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.LogsBlockNumberIndexIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20191220113006_pending_block_operations_block_hash_partial_index.exs b/apps/explorer/priv/repo/migrations/20191220113006_pending_block_operations_block_hash_partial_index.exs index 1b1a52e4edb7..a16238673503 100644 --- a/apps/explorer/priv/repo/migrations/20191220113006_pending_block_operations_block_hash_partial_index.exs +++ b/apps/explorer/priv/repo/migrations/20191220113006_pending_block_operations_block_hash_partial_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.PendingBlockOperationsBlockHashPartialIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200214152058_add_token_id_to_token_balances.exs b/apps/explorer/priv/repo/migrations/20200214152058_add_token_id_to_token_balances.exs index b009091f81fd..72f27ea470a9 100644 --- a/apps/explorer/priv/repo/migrations/20200214152058_add_token_id_to_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20200214152058_add_token_id_to_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenIdToTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200410115841_create_index_address_current_token_balances_token_contract_address_hash_value.exs b/apps/explorer/priv/repo/migrations/20200410115841_create_index_address_current_token_balances_token_contract_address_hash_value.exs index 31f02a146fd7..7e24036fde41 100644 --- a/apps/explorer/priv/repo/migrations/20200410115841_create_index_address_current_token_balances_token_contract_address_hash_value.exs +++ b/apps/explorer/priv/repo/migrations/20200410115841_create_index_address_current_token_balances_token_contract_address_hash_value.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexAddressCurrentTokenBalancesTokenContractAddressHashValue do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200410141202_create_index_token_transfers_token_contract_address_hash_block_number.exs b/apps/explorer/priv/repo/migrations/20200410141202_create_index_token_transfers_token_contract_address_hash_block_number.exs index 59c3c132e2b5..2d1a1f59d580 100644 --- a/apps/explorer/priv/repo/migrations/20200410141202_create_index_token_transfers_token_contract_address_hash_block_number.exs +++ b/apps/explorer/priv/repo/migrations/20200410141202_create_index_token_transfers_token_contract_address_hash_block_number.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexTokenTransfersTokenContractAddressHashBlockNumber do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200421102450_pending_transactions.exs b/apps/explorer/priv/repo/migrations/20200421102450_pending_transactions.exs index 93619991f0f6..4241ce5c2dd5 100644 --- a/apps/explorer/priv/repo/migrations/20200421102450_pending_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20200421102450_pending_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.PendingTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200424070607_drop_block_rewards_address_hash_address_type_block_hash_index.exs b/apps/explorer/priv/repo/migrations/20200424070607_drop_block_rewards_address_hash_address_type_block_hash_index.exs index 3761f419afec..4ffda6f9a417 100644 --- a/apps/explorer/priv/repo/migrations/20200424070607_drop_block_rewards_address_hash_address_type_block_hash_index.exs +++ b/apps/explorer/priv/repo/migrations/20200424070607_drop_block_rewards_address_hash_address_type_block_hash_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropBlockRewardsAddressHashAddressTypeBlockHashIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200518075748_create_index_blocks_miner_hash_number_index.exs b/apps/explorer/priv/repo/migrations/20200518075748_create_index_blocks_miner_hash_number_index.exs index f48ff9bbf099..e36f3aabbbfd 100644 --- a/apps/explorer/priv/repo/migrations/20200518075748_create_index_blocks_miner_hash_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20200518075748_create_index_blocks_miner_hash_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexBlocksMinerHashNumberIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200521090250_recreate_staking_tables.exs b/apps/explorer/priv/repo/migrations/20200521090250_recreate_staking_tables.exs index cb6fb80fc4be..ef1e3c2e3b74 100644 --- a/apps/explorer/priv/repo/migrations/20200521090250_recreate_staking_tables.exs +++ b/apps/explorer/priv/repo/migrations/20200521090250_recreate_staking_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RecreateStakingTables do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200521170002_create_token_transfers_token_contract_address_hash_token_id_block_number_index.exs b/apps/explorer/priv/repo/migrations/20200521170002_create_token_transfers_token_contract_address_hash_token_id_block_number_index.exs index eb136b3f22ba..556698fc0936 100644 --- a/apps/explorer/priv/repo/migrations/20200521170002_create_token_transfers_token_contract_address_hash_token_id_block_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20200521170002_create_token_transfers_token_contract_address_hash_token_id_block_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokenTransfersTokenContractAddressHashTokenIdBlockNumberIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200525115811_address_coin_balances_daily.exs b/apps/explorer/priv/repo/migrations/20200525115811_address_coin_balances_daily.exs index dc03899679fb..bc221d92bdfc 100644 --- a/apps/explorer/priv/repo/migrations/20200525115811_address_coin_balances_daily.exs +++ b/apps/explorer/priv/repo/migrations/20200525115811_address_coin_balances_daily.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressCoinBalancesDaily do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200527144742_add_counters_table.exs b/apps/explorer/priv/repo/migrations/20200527144742_add_counters_table.exs index e09b9ee1e75f..be7dd09ab799 100644 --- a/apps/explorer/priv/repo/migrations/20200527144742_add_counters_table.exs +++ b/apps/explorer/priv/repo/migrations/20200527144742_add_counters_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddCountersTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200608075122_alter_transactions_add_error_reason.exs b/apps/explorer/priv/repo/migrations/20200608075122_alter_transactions_add_error_reason.exs index 6f0ab6320e1e..06cd7d80740b 100644 --- a/apps/explorer/priv/repo/migrations/20200608075122_alter_transactions_add_error_reason.exs +++ b/apps/explorer/priv/repo/migrations/20200608075122_alter_transactions_add_error_reason.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AlterTransactionsAddErrorReason do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200806125649_token_add_bridged_column.exs b/apps/explorer/priv/repo/migrations/20200806125649_token_add_bridged_column.exs index ee059af16c97..3a2b53b0c928 100644 --- a/apps/explorer/priv/repo/migrations/20200806125649_token_add_bridged_column.exs +++ b/apps/explorer/priv/repo/migrations/20200806125649_token_add_bridged_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TokenAddBridgedColumn do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200807064700_bridged_tokens_table.exs b/apps/explorer/priv/repo/migrations/20200807064700_bridged_tokens_table.exs index a601379fcdd7..f38fbef9b342 100644 --- a/apps/explorer/priv/repo/migrations/20200807064700_bridged_tokens_table.exs +++ b/apps/explorer/priv/repo/migrations/20200807064700_bridged_tokens_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.BridgedTokensTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200812143050_add_addresses_contract_code_index.exs b/apps/explorer/priv/repo/migrations/20200812143050_add_addresses_contract_code_index.exs index d6f5bb378b5a..3e1af8ef582d 100644 --- a/apps/explorer/priv/repo/migrations/20200812143050_add_addresses_contract_code_index.exs +++ b/apps/explorer/priv/repo/migrations/20200812143050_add_addresses_contract_code_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddAddressesContractCodeIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200904075501_add_bridged_token_custom_metadata.exs b/apps/explorer/priv/repo/migrations/20200904075501_add_bridged_token_custom_metadata.exs index 418a07962cd2..76985ce4e726 100644 --- a/apps/explorer/priv/repo/migrations/20200904075501_add_bridged_token_custom_metadata.exs +++ b/apps/explorer/priv/repo/migrations/20200904075501_add_bridged_token_custom_metadata.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBridgedTokenCustomMetadata do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20200929075625_add_bridged_token_type.exs b/apps/explorer/priv/repo/migrations/20200929075625_add_bridged_token_type.exs index b8a6c30f459e..3db5eb303987 100644 --- a/apps/explorer/priv/repo/migrations/20200929075625_add_bridged_token_type.exs +++ b/apps/explorer/priv/repo/migrations/20200929075625_add_bridged_token_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBridgedTokenType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20201026093652_transactions_stat_add_gas_usage_column.exs b/apps/explorer/priv/repo/migrations/20201026093652_transactions_stat_add_gas_usage_column.exs index 2c57675f4e98..c0efc510b631 100644 --- a/apps/explorer/priv/repo/migrations/20201026093652_transactions_stat_add_gas_usage_column.exs +++ b/apps/explorer/priv/repo/migrations/20201026093652_transactions_stat_add_gas_usage_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TransactionsStatAddGasUsageColumn do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20201214203532_support_sourcify.exs b/apps/explorer/priv/repo/migrations/20201214203532_support_sourcify.exs index 97854cd8c52f..4e08d419f074 100644 --- a/apps/explorer/priv/repo/migrations/20201214203532_support_sourcify.exs +++ b/apps/explorer/priv/repo/migrations/20201214203532_support_sourcify.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SupportSourcify do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210219080523_add_tags.exs b/apps/explorer/priv/repo/migrations/20210219080523_add_tags.exs index 79e83f808ce6..9e976edb8e36 100644 --- a/apps/explorer/priv/repo/migrations/20210219080523_add_tags.exs +++ b/apps/explorer/priv/repo/migrations/20210219080523_add_tags.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTags do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210226154732_add_exchange_rate_column_to_bridged_tokens.exs b/apps/explorer/priv/repo/migrations/20210226154732_add_exchange_rate_column_to_bridged_tokens.exs index 01fb6aac2b2e..9b51d3299ae0 100644 --- a/apps/explorer/priv/repo/migrations/20210226154732_add_exchange_rate_column_to_bridged_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20210226154732_add_exchange_rate_column_to_bridged_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddExchangeRateColumnToBridgedTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210309104122_add_bridged_token_custom_cap.exs b/apps/explorer/priv/repo/migrations/20210309104122_add_bridged_token_custom_cap.exs index c8214a655205..91b2eb9f98ee 100644 --- a/apps/explorer/priv/repo/migrations/20210309104122_add_bridged_token_custom_cap.exs +++ b/apps/explorer/priv/repo/migrations/20210309104122_add_bridged_token_custom_cap.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBridgedTokenCustomCap do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210331074008_add_pool_name_description.exs b/apps/explorer/priv/repo/migrations/20210331074008_add_pool_name_description.exs index 13e76efeb224..c4dc42e302f6 100644 --- a/apps/explorer/priv/repo/migrations/20210331074008_add_pool_name_description.exs +++ b/apps/explorer/priv/repo/migrations/20210331074008_add_pool_name_description.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddPoolNameDescription do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210422115740_add_token_id_to_current_token_balances.exs b/apps/explorer/priv/repo/migrations/20210422115740_add_token_id_to_current_token_balances.exs index 4d8d8705298d..1569fe69fdc6 100644 --- a/apps/explorer/priv/repo/migrations/20210422115740_add_token_id_to_current_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20210422115740_add_token_id_to_current_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenIdToCurrentTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210423084253_address_current_token_balances_add_token_id_to_unique_index.exs b/apps/explorer/priv/repo/migrations/20210423084253_address_current_token_balances_add_token_id_to_unique_index.exs index b71de0448ee1..aa07f7ea6143 100644 --- a/apps/explorer/priv/repo/migrations/20210423084253_address_current_token_balances_add_token_id_to_unique_index.exs +++ b/apps/explorer/priv/repo/migrations/20210423084253_address_current_token_balances_add_token_id_to_unique_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressCurrentTokenBalancesAddTokenIdToUniqueIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210423091652_address_token_balances_add_token_id_to_unique_index.exs b/apps/explorer/priv/repo/migrations/20210423091652_address_token_balances_add_token_id_to_unique_index.exs index 1968f52132f4..faf892dc64b4 100644 --- a/apps/explorer/priv/repo/migrations/20210423091652_address_token_balances_add_token_id_to_unique_index.exs +++ b/apps/explorer/priv/repo/migrations/20210423091652_address_token_balances_add_token_id_to_unique_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressTokenBalancesAddTokenIdToUniqueIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210423094801_address_token_balances_change_unfetched_token_balances_unique_index.exs b/apps/explorer/priv/repo/migrations/20210423094801_address_token_balances_change_unfetched_token_balances_unique_index.exs index 911a7affbb41..a3b1fb6a7570 100644 --- a/apps/explorer/priv/repo/migrations/20210423094801_address_token_balances_change_unfetched_token_balances_unique_index.exs +++ b/apps/explorer/priv/repo/migrations/20210423094801_address_token_balances_change_unfetched_token_balances_unique_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressTokenBalancesChangeUnfetchedTokenBalancesUniqueIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210423115108_extend_token_transfers_for_erc1155.exs b/apps/explorer/priv/repo/migrations/20210423115108_extend_token_transfers_for_erc1155.exs index 211524e95492..f806eef9f1c3 100644 --- a/apps/explorer/priv/repo/migrations/20210423115108_extend_token_transfers_for_erc1155.exs +++ b/apps/explorer/priv/repo/migrations/20210423115108_extend_token_transfers_for_erc1155.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ExtendTokenTransfersForErc1155 do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210524165427_min_missing_block_number.exs b/apps/explorer/priv/repo/migrations/20210524165427_min_missing_block_number.exs index 204ad4363a7e..1abab0b87efb 100644 --- a/apps/explorer/priv/repo/migrations/20210524165427_min_missing_block_number.exs +++ b/apps/explorer/priv/repo/migrations/20210524165427_min_missing_block_number.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.MinMissingBlockNumber do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210527093756_transaction_stats_add_total_fee_column.exs b/apps/explorer/priv/repo/migrations/20210527093756_transaction_stats_add_total_fee_column.exs index 3684c20ad126..5131404ea66a 100644 --- a/apps/explorer/priv/repo/migrations/20210527093756_transaction_stats_add_total_fee_column.exs +++ b/apps/explorer/priv/repo/migrations/20210527093756_transaction_stats_add_total_fee_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TransactionStatsAddTotalFeeColumn do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210616120552_smart_contracts_add_is_vyper_flag.exs b/apps/explorer/priv/repo/migrations/20210616120552_smart_contracts_add_is_vyper_flag.exs index 171a90d4e4a0..22781f55821e 100644 --- a/apps/explorer/priv/repo/migrations/20210616120552_smart_contracts_add_is_vyper_flag.exs +++ b/apps/explorer/priv/repo/migrations/20210616120552_smart_contracts_add_is_vyper_flag.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SmartContractsAddIsVyperFlag do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210701084814_support_partial_match.exs b/apps/explorer/priv/repo/migrations/20210701084814_support_partial_match.exs index 29f13234a1ab..e0e4749115e6 100644 --- a/apps/explorer/priv/repo/migrations/20210701084814_support_partial_match.exs +++ b/apps/explorer/priv/repo/migrations/20210701084814_support_partial_match.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SupportPartialMatch do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210811140837_add_1559_support.exs b/apps/explorer/priv/repo/migrations/20210811140837_add_1559_support.exs index b83f65fc08f6..d0de9e51191a 100644 --- a/apps/explorer/priv/repo/migrations/20210811140837_add_1559_support.exs +++ b/apps/explorer/priv/repo/migrations/20210811140837_add_1559_support.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.Add1559Support do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210823144531_tokens_add_metadata_fetch_flag.exs b/apps/explorer/priv/repo/migrations/20210823144531_tokens_add_metadata_fetch_flag.exs index 1b966d9a29c5..24395d4e48e7 100644 --- a/apps/explorer/priv/repo/migrations/20210823144531_tokens_add_metadata_fetch_flag.exs +++ b/apps/explorer/priv/repo/migrations/20210823144531_tokens_add_metadata_fetch_flag.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TokensAddMetadataFetchFlag do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20210916194004_add_file_path_for_sourcify_contracts.exs b/apps/explorer/priv/repo/migrations/20210916194004_add_file_path_for_sourcify_contracts.exs index ad10e3129c3f..9f8ccbcb805e 100644 --- a/apps/explorer/priv/repo/migrations/20210916194004_add_file_path_for_sourcify_contracts.exs +++ b/apps/explorer/priv/repo/migrations/20210916194004_add_file_path_for_sourcify_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddFilePathForSourcifyContracts do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211006121008_add_block_is_empty_flag.exs b/apps/explorer/priv/repo/migrations/20211006121008_add_block_is_empty_flag.exs index 609f342453bf..efaca37255ac 100644 --- a/apps/explorer/priv/repo/migrations/20211006121008_add_block_is_empty_flag.exs +++ b/apps/explorer/priv/repo/migrations/20211006121008_add_block_is_empty_flag.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockIsEmptyFlag do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211013190346_remove_duplicates_of_current_token_balances.exs b/apps/explorer/priv/repo/migrations/20211013190346_remove_duplicates_of_current_token_balances.exs index ec26db4b7364..5d90f04201e8 100644 --- a/apps/explorer/priv/repo/migrations/20211013190346_remove_duplicates_of_current_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20211013190346_remove_duplicates_of_current_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveDuplicatesOfCurrentTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211017135545_migrate_optimization_runs_to_int8.exs b/apps/explorer/priv/repo/migrations/20211017135545_migrate_optimization_runs_to_int8.exs index 0ff9a6d23d80..cc3316b61ac2 100644 --- a/apps/explorer/priv/repo/migrations/20211017135545_migrate_optimization_runs_to_int8.exs +++ b/apps/explorer/priv/repo/migrations/20211017135545_migrate_optimization_runs_to_int8.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.MigrateOptimizationRunsToInt8 do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211018072347_add_is_empty_index.exs b/apps/explorer/priv/repo/migrations/20211018072347_add_is_empty_index.exs index 8631b31307d7..06199fed179f 100644 --- a/apps/explorer/priv/repo/migrations/20211018072347_add_is_empty_index.exs +++ b/apps/explorer/priv/repo/migrations/20211018072347_add_is_empty_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIsEmptyIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20211018073652_add_token_balances_contract_address_hash_index.exs b/apps/explorer/priv/repo/migrations/20211018073652_add_token_balances_contract_address_hash_index.exs index ebfa8920e2f4..2cfeb9950aa2 100644 --- a/apps/explorer/priv/repo/migrations/20211018073652_add_token_balances_contract_address_hash_index.exs +++ b/apps/explorer/priv/repo/migrations/20211018073652_add_token_balances_contract_address_hash_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenBalancesContractAddressHashIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211018164843_transactions_block_number_index.exs b/apps/explorer/priv/repo/migrations/20211018164843_transactions_block_number_index.exs index bb561b7c2375..fe87b8e568b5 100644 --- a/apps/explorer/priv/repo/migrations/20211018164843_transactions_block_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20211018164843_transactions_block_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TransactionsBlockNumberIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211018170533_add_address_token_balances_address_hash_token_contract_address_hash_block_number_index.exs b/apps/explorer/priv/repo/migrations/20211018170533_add_address_token_balances_address_hash_token_contract_address_hash_block_number_index.exs index 4965726c5dc7..da59879dfd1d 100644 --- a/apps/explorer/priv/repo/migrations/20211018170533_add_address_token_balances_address_hash_token_contract_address_hash_block_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20211018170533_add_address_token_balances_address_hash_token_contract_address_hash_block_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddAddressTokenBalancesAddressHashTokenContractAddressHashBlockNumberIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211018170638_add_logs_address_hash_transaction_hash_index.exs b/apps/explorer/priv/repo/migrations/20211018170638_add_logs_address_hash_transaction_hash_index.exs index 10eafea095c6..1d6a57897e7d 100644 --- a/apps/explorer/priv/repo/migrations/20211018170638_add_logs_address_hash_transaction_hash_index.exs +++ b/apps/explorer/priv/repo/migrations/20211018170638_add_logs_address_hash_transaction_hash_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateLogsAddressHashTransactionHashIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211029085117_drop_block_rewards_block_hash_partial_index.exs b/apps/explorer/priv/repo/migrations/20211029085117_drop_block_rewards_block_hash_partial_index.exs index 98d18e70d4d7..3695e6823329 100644 --- a/apps/explorer/priv/repo/migrations/20211029085117_drop_block_rewards_block_hash_partial_index.exs +++ b/apps/explorer/priv/repo/migrations/20211029085117_drop_block_rewards_block_hash_partial_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropBlockRewardsBlockHashPartialIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211115164817_add_check_for_bytecode_actuality.exs b/apps/explorer/priv/repo/migrations/20211115164817_add_check_for_bytecode_actuality.exs index dfb86074be8a..a6cd37b8c413 100644 --- a/apps/explorer/priv/repo/migrations/20211115164817_add_check_for_bytecode_actuality.exs +++ b/apps/explorer/priv/repo/migrations/20211115164817_add_check_for_bytecode_actuality.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddCheckForBytecodeActuality do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211203115010_add_contract_verification_status_table.exs b/apps/explorer/priv/repo/migrations/20211203115010_add_contract_verification_status_table.exs index 79af9a6c54c6..58f1835dae7e 100644 --- a/apps/explorer/priv/repo/migrations/20211203115010_add_contract_verification_status_table.exs +++ b/apps/explorer/priv/repo/migrations/20211203115010_add_contract_verification_status_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddContractVerificationStatusTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211204184037_address_add_gas_used.exs b/apps/explorer/priv/repo/migrations/20211204184037_address_add_gas_used.exs index 0494e0a9e6e7..179c2648645f 100644 --- a/apps/explorer/priv/repo/migrations/20211204184037_address_add_gas_used.exs +++ b/apps/explorer/priv/repo/migrations/20211204184037_address_add_gas_used.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressAddGasUsed do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211206071033_modify_address_gas_used_bigint.exs b/apps/explorer/priv/repo/migrations/20211206071033_modify_address_gas_used_bigint.exs index 18158defba0b..4d1c20d8ea54 100644 --- a/apps/explorer/priv/repo/migrations/20211206071033_modify_address_gas_used_bigint.exs +++ b/apps/explorer/priv/repo/migrations/20211206071033_modify_address_gas_used_bigint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ModifyAddressGasUsedBigint do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211210184136_add_display_name_to_address_tag.exs b/apps/explorer/priv/repo/migrations/20211210184136_add_display_name_to_address_tag.exs index f07af29e10f9..17d048d2a8dc 100644 --- a/apps/explorer/priv/repo/migrations/20211210184136_add_display_name_to_address_tag.exs +++ b/apps/explorer/priv/repo/migrations/20211210184136_add_display_name_to_address_tag.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddDisplayNameToAddressTag do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20211217201759_add_has_error_in_internal_txs_field_to_transaction.exs b/apps/explorer/priv/repo/migrations/20211217201759_add_has_error_in_internal_txs_field_to_transaction.exs index 424f82e82b1d..b94e694caf07 100644 --- a/apps/explorer/priv/repo/migrations/20211217201759_add_has_error_in_internal_txs_field_to_transaction.exs +++ b/apps/explorer/priv/repo/migrations/20211217201759_add_has_error_in_internal_txs_field_to_transaction.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddHasErrorInInternalTxsFieldToTransaction do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220111085751_address_add_counters.exs b/apps/explorer/priv/repo/migrations/20220111085751_address_add_counters.exs index 15a7104582d5..c7918794c857 100644 --- a/apps/explorer/priv/repo/migrations/20220111085751_address_add_counters.exs +++ b/apps/explorer/priv/repo/migrations/20220111085751_address_add_counters.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressAddCounters do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220303083252_smart_contracts_contract_code_md5.exs b/apps/explorer/priv/repo/migrations/20220303083252_smart_contracts_contract_code_md5.exs index 7c8a66ddf43f..9a88522d37d1 100644 --- a/apps/explorer/priv/repo/migrations/20220303083252_smart_contracts_contract_code_md5.exs +++ b/apps/explorer/priv/repo/migrations/20220303083252_smart_contracts_contract_code_md5.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SmartContractsContractCodeMd5 do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220306091504_add_implementation_name.exs b/apps/explorer/priv/repo/migrations/20220306091504_add_implementation_name.exs index bbcf9702148c..6bb39df386cb 100644 --- a/apps/explorer/priv/repo/migrations/20220306091504_add_implementation_name.exs +++ b/apps/explorer/priv/repo/migrations/20220306091504_add_implementation_name.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddImplementationName do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220527131249_add_implementation_fields.exs b/apps/explorer/priv/repo/migrations/20220527131249_add_implementation_fields.exs index 5f3839dd4a4c..28e6e939fbe0 100644 --- a/apps/explorer/priv/repo/migrations/20220527131249_add_implementation_fields.exs +++ b/apps/explorer/priv/repo/migrations/20220527131249_add_implementation_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddImplementationFields do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220622114402_remove_staking_tables.exs b/apps/explorer/priv/repo/migrations/20220622114402_remove_staking_tables.exs index 53f9e9516642..c5803c8a4d63 100644 --- a/apps/explorer/priv/repo/migrations/20220622114402_remove_staking_tables.exs +++ b/apps/explorer/priv/repo/migrations/20220622114402_remove_staking_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveStakingTables do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220622140604_remove_bridged_tokens.exs b/apps/explorer/priv/repo/migrations/20220622140604_remove_bridged_tokens.exs index 3bcef596351f..ff989d421c01 100644 --- a/apps/explorer/priv/repo/migrations/20220622140604_remove_bridged_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20220622140604_remove_bridged_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveBridgedTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220706101103_address_coin_balances_daily_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20220706101103_address_coin_balances_daily_add_primary_key.exs index e13e2f897ade..b63c7c9a9bce 100644 --- a/apps/explorer/priv/repo/migrations/20220706101103_address_coin_balances_daily_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20220706101103_address_coin_balances_daily_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressCoinBalancesDailyAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220706102257_address_coin_balances_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20220706102257_address_coin_balances_add_primary_key.exs index c3d48b640d3b..6fc704087dd4 100644 --- a/apps/explorer/priv/repo/migrations/20220706102257_address_coin_balances_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20220706102257_address_coin_balances_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressCoinBalancesAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220706102504_transactions_forks_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20220706102504_transactions_forks_add_primary_key.exs index 242ee5c250f1..9db421468027 100644 --- a/apps/explorer/priv/repo/migrations/20220706102504_transactions_forks_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20220706102504_transactions_forks_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TransactionsForksAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220706102746_block_rewards_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20220706102746_block_rewards_add_primary_key.exs index a908a89367e8..ec779b359fa6 100644 --- a/apps/explorer/priv/repo/migrations/20220706102746_block_rewards_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20220706102746_block_rewards_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.BlockRewardsAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220706105925_emission_rewards_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20220706105925_emission_rewards_add_primary_key.exs index 877b0f1441c5..d5ba02b5f4b9 100644 --- a/apps/explorer/priv/repo/migrations/20220706105925_emission_rewards_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20220706105925_emission_rewards_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.EmissionRewardsAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220706111510_address_names_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20220706111510_address_names_add_primary_key.exs index 606eded6b58a..5e4e1153b652 100644 --- a/apps/explorer/priv/repo/migrations/20220706111510_address_names_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20220706111510_address_names_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressNamesAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220804114005_create_event_notifications.exs b/apps/explorer/priv/repo/migrations/20220804114005_create_event_notifications.exs index 953cfd4472e0..38dd567e40ef 100644 --- a/apps/explorer/priv/repo/migrations/20220804114005_create_event_notifications.exs +++ b/apps/explorer/priv/repo/migrations/20220804114005_create_event_notifications.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateEventNotifications do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220902083436_extend_token_name_type.exs b/apps/explorer/priv/repo/migrations/20220902083436_extend_token_name_type.exs index fee8460def3b..5887636f633d 100644 --- a/apps/explorer/priv/repo/migrations/20220902083436_extend_token_name_type.exs +++ b/apps/explorer/priv/repo/migrations/20220902083436_extend_token_name_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ExtendTokenNameType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220902103213_create_index_token_transfers_token_ids.exs b/apps/explorer/priv/repo/migrations/20220902103213_create_index_token_transfers_token_ids.exs index e7f30e9cc07f..0a5f99f04a99 100644 --- a/apps/explorer/priv/repo/migrations/20220902103213_create_index_token_transfers_token_ids.exs +++ b/apps/explorer/priv/repo/migrations/20220902103213_create_index_token_transfers_token_ids.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexTokenTransfersTokenIds do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220902103527_create_indexes_token_instances_token_contract_address_hash_token_id.exs b/apps/explorer/priv/repo/migrations/20220902103527_create_indexes_token_instances_token_contract_address_hash_token_id.exs index a91fc7d0fe3d..254a7b452c26 100644 --- a/apps/explorer/priv/repo/migrations/20220902103527_create_indexes_token_instances_token_contract_address_hash_token_id.exs +++ b/apps/explorer/priv/repo/migrations/20220902103527_create_indexes_token_instances_token_contract_address_hash_token_id.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexesTokenInstancesTokenContractAddressHashTokenId do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220919105140_add_method_id_index.exs b/apps/explorer/priv/repo/migrations/20220919105140_add_method_id_index.exs index daa96722b5b1..2fc72338f534 100644 --- a/apps/explorer/priv/repo/migrations/20220919105140_add_method_id_index.exs +++ b/apps/explorer/priv/repo/migrations/20220919105140_add_method_id_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddMethodIdIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20220926122620_extend_token_symbol_type.exs b/apps/explorer/priv/repo/migrations/20220926122620_extend_token_symbol_type.exs index e97255a160aa..3d1ebb6c7763 100644 --- a/apps/explorer/priv/repo/migrations/20220926122620_extend_token_symbol_type.exs +++ b/apps/explorer/priv/repo/migrations/20220926122620_extend_token_symbol_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ExtendTokenSymbolType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221104091552_add_transaction_actions_table.exs b/apps/explorer/priv/repo/migrations/20221104091552_add_transaction_actions_table.exs index 1922a08faa3f..d70e50e13c41 100644 --- a/apps/explorer/priv/repo/migrations/20221104091552_add_transaction_actions_table.exs +++ b/apps/explorer/priv/repo/migrations/20221104091552_add_transaction_actions_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTransactionActionsTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs b/apps/explorer/priv/repo/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs index 3bf19f8dfbda..b9cb6c5f012c 100644 --- a/apps/explorer/priv/repo/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs +++ b/apps/explorer/priv/repo/migrations/20221104104635_create_token_transfer_token_id_migrator_progress.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokenTransferTokenIdMigratorProgress do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221114113853_remove_not_null_constraint_from_abi.exs b/apps/explorer/priv/repo/migrations/20221114113853_remove_not_null_constraint_from_abi.exs index 81903e79e30c..88c25b0ee5eb 100644 --- a/apps/explorer/priv/repo/migrations/20221114113853_remove_not_null_constraint_from_abi.exs +++ b/apps/explorer/priv/repo/migrations/20221114113853_remove_not_null_constraint_from_abi.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveNotNullConstraintFromAbi do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221114121811_drop_internal_transactions_order_index.exs b/apps/explorer/priv/repo/migrations/20221114121811_drop_internal_transactions_order_index.exs index f59a416be856..0b69ebf27ab5 100644 --- a/apps/explorer/priv/repo/migrations/20221114121811_drop_internal_transactions_order_index.exs +++ b/apps/explorer/priv/repo/migrations/20221114121811_drop_internal_transactions_order_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropInternalTransactionsOrderIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221117075456_modify_address_token_balances_indexes.exs b/apps/explorer/priv/repo/migrations/20221117075456_modify_address_token_balances_indexes.exs index 9c21c4b40a89..ec0b9dd1deb5 100644 --- a/apps/explorer/priv/repo/migrations/20221117075456_modify_address_token_balances_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20221117075456_modify_address_token_balances_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ModifyAddressTokenBalancesIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221117080657_modify_address_current_token_balances_indexes.exs b/apps/explorer/priv/repo/migrations/20221117080657_modify_address_current_token_balances_indexes.exs index 295a7a5c3f15..b64c16c258a3 100644 --- a/apps/explorer/priv/repo/migrations/20221117080657_modify_address_current_token_balances_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20221117080657_modify_address_current_token_balances_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ModifyAddressCurrentTokenBalancesIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221120184715_add_json_compiler_settings.exs b/apps/explorer/priv/repo/migrations/20221120184715_add_json_compiler_settings.exs index e6f90d78648a..3ce667549639 100644 --- a/apps/explorer/priv/repo/migrations/20221120184715_add_json_compiler_settings.exs +++ b/apps/explorer/priv/repo/migrations/20221120184715_add_json_compiler_settings.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddJsonCompilerSettings do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221125074820_drop_required_output_constraint.exs b/apps/explorer/priv/repo/migrations/20221125074820_drop_required_output_constraint.exs index a524af715fc0..5759ddeb4978 100644 --- a/apps/explorer/priv/repo/migrations/20221125074820_drop_required_output_constraint.exs +++ b/apps/explorer/priv/repo/migrations/20221125074820_drop_required_output_constraint.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropRequiredOutputConstraint do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221126103223_add_transactions_indexes.exs b/apps/explorer/priv/repo/migrations/20221126103223_add_transactions_indexes.exs index 9cc38fb2e7aa..940385e555d0 100644 --- a/apps/explorer/priv/repo/migrations/20221126103223_add_transactions_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20221126103223_add_transactions_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTransactionsIndexes do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20221209123459_drop_unfetched_token_balances_index.exs b/apps/explorer/priv/repo/migrations/20221209123459_drop_unfetched_token_balances_index.exs index c4e89981297b..7017cead3a18 100644 --- a/apps/explorer/priv/repo/migrations/20221209123459_drop_unfetched_token_balances_index.exs +++ b/apps/explorer/priv/repo/migrations/20221209123459_drop_unfetched_token_balances_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropUnfetchedTokenBalancesIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221212093406_change_index_for_pending_block_operations.exs b/apps/explorer/priv/repo/migrations/20221212093406_change_index_for_pending_block_operations.exs index 550c6ce2b288..af2b0a1b0c18 100644 --- a/apps/explorer/priv/repo/migrations/20221212093406_change_index_for_pending_block_operations.exs +++ b/apps/explorer/priv/repo/migrations/20221212093406_change_index_for_pending_block_operations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ChangeIndexForPendingBlockOperations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221219151744_create_missing_block_ranges.exs b/apps/explorer/priv/repo/migrations/20221219151744_create_missing_block_ranges.exs index 2f61a85162d8..1e9564581759 100644 --- a/apps/explorer/priv/repo/migrations/20221219151744_create_missing_block_ranges.exs +++ b/apps/explorer/priv/repo/migrations/20221219151744_create_missing_block_ranges.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateMissingBlockRanges do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221223151234_add_block_number_to_pending_block_operations.exs b/apps/explorer/priv/repo/migrations/20221223151234_add_block_number_to_pending_block_operations.exs index 85229ecd2e7b..23d2531be2fe 100644 --- a/apps/explorer/priv/repo/migrations/20221223151234_add_block_number_to_pending_block_operations.exs +++ b/apps/explorer/priv/repo/migrations/20221223151234_add_block_number_to_pending_block_operations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockNumberToPendingBlockOperations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs b/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs index 407fc4ddc313..c6e33e74b7cc 100644 --- a/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs +++ b/apps/explorer/priv/repo/migrations/20221223214711_create_withdrawals.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateWithdrawals do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230126205627_add_fiat_value_and_circulating_market_cap_for_tokens.exs b/apps/explorer/priv/repo/migrations/20230126205627_add_fiat_value_and_circulating_market_cap_for_tokens.exs index 1b8887ada940..9e9f7fe0c84b 100644 --- a/apps/explorer/priv/repo/migrations/20230126205627_add_fiat_value_and_circulating_market_cap_for_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20230126205627_add_fiat_value_and_circulating_market_cap_for_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddFiatValueAndCirculatingMarketCapForTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230214104917_add_validators_and_constants_tables.exs b/apps/explorer/priv/repo/migrations/20230214104917_add_validators_and_constants_tables.exs index 95cce03e9fdf..b52c5601676c 100644 --- a/apps/explorer/priv/repo/migrations/20230214104917_add_validators_and_constants_tables.exs +++ b/apps/explorer/priv/repo/migrations/20230214104917_add_validators_and_constants_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddValidatorsAndConstantsTables do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230217095226_add_total_supply_updated_at_block.exs b/apps/explorer/priv/repo/migrations/20230217095226_add_total_supply_updated_at_block.exs index 5221ee3b3d71..7d02aa6b8396 100644 --- a/apps/explorer/priv/repo/migrations/20230217095226_add_total_supply_updated_at_block.exs +++ b/apps/explorer/priv/repo/migrations/20230217095226_add_total_supply_updated_at_block.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTotalSupplyUpdatedAtBlock do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230328100414_add_transaction_action_types.exs b/apps/explorer/priv/repo/migrations/20230328100414_add_transaction_action_types.exs index bb18503d152c..7e1dbcdb310f 100644 --- a/apps/explorer/priv/repo/migrations/20230328100414_add_transaction_action_types.exs +++ b/apps/explorer/priv/repo/migrations/20230328100414_add_transaction_action_types.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTransactionActionTypes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230417093914_allow_nil_transaction_gas_price.exs b/apps/explorer/priv/repo/migrations/20230417093914_allow_nil_transaction_gas_price.exs index 14dbd678fe48..cf17eb121093 100644 --- a/apps/explorer/priv/repo/migrations/20230417093914_allow_nil_transaction_gas_price.exs +++ b/apps/explorer/priv/repo/migrations/20230417093914_allow_nil_transaction_gas_price.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AllowNilTransactionGasPrice do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230425185941_add_token_icon_url.exs b/apps/explorer/priv/repo/migrations/20230425185941_add_token_icon_url.exs index 88e72b7fb209..73c8bb175fe7 100644 --- a/apps/explorer/priv/repo/migrations/20230425185941_add_token_icon_url.exs +++ b/apps/explorer/priv/repo/migrations/20230425185941_add_token_icon_url.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenIconUrl do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230522130735_withdrawals_gwei_amount_to_wei_amount.exs b/apps/explorer/priv/repo/migrations/20230522130735_withdrawals_gwei_amount_to_wei_amount.exs index e18bf417516e..39015d27211f 100644 --- a/apps/explorer/priv/repo/migrations/20230522130735_withdrawals_gwei_amount_to_wei_amount.exs +++ b/apps/explorer/priv/repo/migrations/20230522130735_withdrawals_gwei_amount_to_wei_amount.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.WithdrawalsGweiAmountToWeiAmount do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230530074105_market_history_add_market_cap.exs b/apps/explorer/priv/repo/migrations/20230530074105_market_history_add_market_cap.exs index b0ddaca92c21..bdd65983b6d9 100644 --- a/apps/explorer/priv/repo/migrations/20230530074105_market_history_add_market_cap.exs +++ b/apps/explorer/priv/repo/migrations/20230530074105_market_history_add_market_cap.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.MarketHistoryAddMarketCap do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230605080138_add_verified_via_eth_bytecode_db.exs b/apps/explorer/priv/repo/migrations/20230605080138_add_verified_via_eth_bytecode_db.exs index 9cef42d46938..be5bb0abe93f 100644 --- a/apps/explorer/priv/repo/migrations/20230605080138_add_verified_via_eth_bytecode_db.exs +++ b/apps/explorer/priv/repo/migrations/20230605080138_add_verified_via_eth_bytecode_db.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddVerifiedViaEthBytecodeDb do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230606091935_fix_contract_creation_transactions.exs b/apps/explorer/priv/repo/migrations/20230606091935_fix_contract_creation_transactions.exs index 86a088ae50b7..c10618dc493a 100644 --- a/apps/explorer/priv/repo/migrations/20230606091935_fix_contract_creation_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20230606091935_fix_contract_creation_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.FixContractCreationTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230613181244_address_coin_balances_block_number_index.exs b/apps/explorer/priv/repo/migrations/20230613181244_address_coin_balances_block_number_index.exs index f50a577455a3..c34961507480 100644 --- a/apps/explorer/priv/repo/migrations/20230613181244_address_coin_balances_block_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20230613181244_address_coin_balances_block_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressCoinBalancesBlockNumberIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230615130940_add_blocks_date_index.exs b/apps/explorer/priv/repo/migrations/20230615130940_add_blocks_date_index.exs index c0b415d676e4..0465cb97ddb2 100644 --- a/apps/explorer/priv/repo/migrations/20230615130940_add_blocks_date_index.exs +++ b/apps/explorer/priv/repo/migrations/20230615130940_add_blocks_date_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlocksDateIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230719160318_delete_erc_1155_tt_with_empty_token_ids.exs b/apps/explorer/priv/repo/migrations/20230719160318_delete_erc_1155_tt_with_empty_token_ids.exs index 190fdf8e8023..44108ef4251e 100644 --- a/apps/explorer/priv/repo/migrations/20230719160318_delete_erc_1155_tt_with_empty_token_ids.exs +++ b/apps/explorer/priv/repo/migrations/20230719160318_delete_erc_1155_tt_with_empty_token_ids.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DeleteErc1155TtWithEmptyTokenIds do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230809134253_add_is_verified_via_admin_panel.exs b/apps/explorer/priv/repo/migrations/20230809134253_add_is_verified_via_admin_panel.exs index 1cfa57cb6fca..c627ce4758ad 100644 --- a/apps/explorer/priv/repo/migrations/20230809134253_add_is_verified_via_admin_panel.exs +++ b/apps/explorer/priv/repo/migrations/20230809134253_add_is_verified_via_admin_panel.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIsVerifiedViaAdminPanel do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230815131151_drop_logs_address_hash_foreign_key.exs b/apps/explorer/priv/repo/migrations/20230815131151_drop_logs_address_hash_foreign_key.exs index 350c8a3a85ca..cebdb843cc4b 100644 --- a/apps/explorer/priv/repo/migrations/20230815131151_drop_logs_address_hash_foreign_key.exs +++ b/apps/explorer/priv/repo/migrations/20230815131151_drop_logs_address_hash_foreign_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropLogsAddressHashForeignKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230816061723_drop_token_transfers_and_transactions_address_foreign_key.exs b/apps/explorer/priv/repo/migrations/20230816061723_drop_token_transfers_and_transactions_address_foreign_key.exs index f5a1ef465fc5..3bb4a25cf6c4 100644 --- a/apps/explorer/priv/repo/migrations/20230816061723_drop_token_transfers_and_transactions_address_foreign_key.exs +++ b/apps/explorer/priv/repo/migrations/20230816061723_drop_token_transfers_and_transactions_address_foreign_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropTokenTransfersAndTransactionsAddressForeignKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230817061317_drop_address_foreign_keys.exs b/apps/explorer/priv/repo/migrations/20230817061317_drop_address_foreign_keys.exs index 275e7172aa9a..a20b0fa72b79 100644 --- a/apps/explorer/priv/repo/migrations/20230817061317_drop_address_foreign_keys.exs +++ b/apps/explorer/priv/repo/migrations/20230817061317_drop_address_foreign_keys.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropAddressForeignKeys do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230818094455_add_token_ids_to_address_token_balances.exs b/apps/explorer/priv/repo/migrations/20230818094455_add_token_ids_to_address_token_balances.exs index a141276ab529..ed4685a8e52b 100644 --- a/apps/explorer/priv/repo/migrations/20230818094455_add_token_ids_to_address_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20230818094455_add_token_ids_to_address_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenIdsToAddressTokenBalances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230821120625_drop_rest_address_foreign_keys.exs b/apps/explorer/priv/repo/migrations/20230821120625_drop_rest_address_foreign_keys.exs index 05d0afa05c28..3c658d66d1ce 100644 --- a/apps/explorer/priv/repo/migrations/20230821120625_drop_rest_address_foreign_keys.exs +++ b/apps/explorer/priv/repo/migrations/20230821120625_drop_rest_address_foreign_keys.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropRestAddressForeignKeys do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230831122819_drop_current_token_balances_tokens_foreign_key.exs b/apps/explorer/priv/repo/migrations/20230831122819_drop_current_token_balances_tokens_foreign_key.exs index 49f45e654f61..61c0650cf9e6 100644 --- a/apps/explorer/priv/repo/migrations/20230831122819_drop_current_token_balances_tokens_foreign_key.exs +++ b/apps/explorer/priv/repo/migrations/20230831122819_drop_current_token_balances_tokens_foreign_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropCurrentTokenBalancesTokensForeignKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20230905085809_drop_token_balances_tokens_foreign_key.exs b/apps/explorer/priv/repo/migrations/20230905085809_drop_token_balances_tokens_foreign_key.exs index 6af0d319b4af..27754c5dce5b 100644 --- a/apps/explorer/priv/repo/migrations/20230905085809_drop_token_balances_tokens_foreign_key.exs +++ b/apps/explorer/priv/repo/migrations/20230905085809_drop_token_balances_tokens_foreign_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropTokenBalancesTokensForeignKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231003093553_add_tvl_to_market_history_table.exs b/apps/explorer/priv/repo/migrations/20231003093553_add_tvl_to_market_history_table.exs index 5ff8dd795a3f..fb8a6e271022 100644 --- a/apps/explorer/priv/repo/migrations/20231003093553_add_tvl_to_market_history_table.exs +++ b/apps/explorer/priv/repo/migrations/20231003093553_add_tvl_to_market_history_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTvlToMarketHistoryTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231212101547_add_block_timestamp_and_consensus_to_transactions.exs b/apps/explorer/priv/repo/migrations/20231212101547_add_block_timestamp_and_consensus_to_transactions.exs index 458e29604383..f8d3cc69e6a8 100644 --- a/apps/explorer/priv/repo/migrations/20231212101547_add_block_timestamp_and_consensus_to_transactions.exs +++ b/apps/explorer/priv/repo/migrations/20231212101547_add_block_timestamp_and_consensus_to_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockTimestampAndConsensusToTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231212102127_create_migrations_status.exs b/apps/explorer/priv/repo/migrations/20231212102127_create_migrations_status.exs index 0b8bf54a5c3b..ec0c30ce84bd 100644 --- a/apps/explorer/priv/repo/migrations/20231212102127_create_migrations_status.exs +++ b/apps/explorer/priv/repo/migrations/20231212102127_create_migrations_status.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateMigrationsStatus do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231213085254_add_btree_gin_extension.exs b/apps/explorer/priv/repo/migrations/20231213085254_add_btree_gin_extension.exs index b34f9ee059cb..a49fc250c18a 100644 --- a/apps/explorer/priv/repo/migrations/20231213085254_add_btree_gin_extension.exs +++ b/apps/explorer/priv/repo/migrations/20231213085254_add_btree_gin_extension.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateBtreeGinExtension do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231213090140_add_token_transfers_token_contract_address_token_ids_index.exs b/apps/explorer/priv/repo/migrations/20231213090140_add_token_transfers_token_contract_address_token_ids_index.exs index 7636fc7b3c2f..f1f797d1c15e 100644 --- a/apps/explorer/priv/repo/migrations/20231213090140_add_token_transfers_token_contract_address_token_ids_index.exs +++ b/apps/explorer/priv/repo/migrations/20231213090140_add_token_transfers_token_contract_address_token_ids_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenTransfersTokenContractAddressTokenIdsIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20231213101235_drop_token_transfers_token_ids_index.exs b/apps/explorer/priv/repo/migrations/20231213101235_drop_token_transfers_token_ids_index.exs index 0065d2e26312..23d54399b7ed 100644 --- a/apps/explorer/priv/repo/migrations/20231213101235_drop_token_transfers_token_ids_index.exs +++ b/apps/explorer/priv/repo/migrations/20231213101235_drop_token_transfers_token_ids_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropTokenTransfersTokenIdsIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231213152332_alter_log_topic_columns_type.exs b/apps/explorer/priv/repo/migrations/20231213152332_alter_log_topic_columns_type.exs index 649c95d0dbf1..c53373ed8dd1 100644 --- a/apps/explorer/priv/repo/migrations/20231213152332_alter_log_topic_columns_type.exs +++ b/apps/explorer/priv/repo/migrations/20231213152332_alter_log_topic_columns_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AlterLogTopicColumnsType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231215094615_drop_token_transfers_token_id_column.exs b/apps/explorer/priv/repo/migrations/20231215094615_drop_token_transfers_token_id_column.exs index df8637071b31..e0d727e22714 100644 --- a/apps/explorer/priv/repo/migrations/20231215094615_drop_token_transfers_token_id_column.exs +++ b/apps/explorer/priv/repo/migrations/20231215094615_drop_token_transfers_token_id_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropTokenTransfersTokenIdColumn do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231215104320_drop_unused_actb_indexes.exs b/apps/explorer/priv/repo/migrations/20231215104320_drop_unused_actb_indexes.exs index 0f8ad39f3bc0..87d65a3083b9 100644 --- a/apps/explorer/priv/repo/migrations/20231215104320_drop_unused_actb_indexes.exs +++ b/apps/explorer/priv/repo/migrations/20231215104320_drop_unused_actb_indexes.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropUnusedActbIndexes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231215115638_drop_unused_logs_type_index.exs b/apps/explorer/priv/repo/migrations/20231215115638_drop_unused_logs_type_index.exs index fc7df4ddce8c..d8d0014ed0fb 100644 --- a/apps/explorer/priv/repo/migrations/20231215115638_drop_unused_logs_type_index.exs +++ b/apps/explorer/priv/repo/migrations/20231215115638_drop_unused_logs_type_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropUnusedLogsTypeIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231215132609_add_index_blocks_refetch_needed.exs b/apps/explorer/priv/repo/migrations/20231215132609_add_index_blocks_refetch_needed.exs index aef72e5ed81c..9e0137bda8cb 100644 --- a/apps/explorer/priv/repo/migrations/20231215132609_add_index_blocks_refetch_needed.exs +++ b/apps/explorer/priv/repo/migrations/20231215132609_add_index_blocks_refetch_needed.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIndexBlocksRefetchNeeded do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231225113850_transactions_asc_indices.exs b/apps/explorer/priv/repo/migrations/20231225113850_transactions_asc_indices.exs index b0f668e89cd5..1cdd6a2733b1 100644 --- a/apps/explorer/priv/repo/migrations/20231225113850_transactions_asc_indices.exs +++ b/apps/explorer/priv/repo/migrations/20231225113850_transactions_asc_indices.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TransactionsAscIndices do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231225115026_logs_asc_index.exs b/apps/explorer/priv/repo/migrations/20231225115026_logs_asc_index.exs index 7e7fc0963d6a..8b25b53d29bd 100644 --- a/apps/explorer/priv/repo/migrations/20231225115026_logs_asc_index.exs +++ b/apps/explorer/priv/repo/migrations/20231225115026_logs_asc_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.LogsAscIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231225115100_token_transfers_asc_index.exs b/apps/explorer/priv/repo/migrations/20231225115100_token_transfers_asc_index.exs index 084ce83adb35..fee538e1f235 100644 --- a/apps/explorer/priv/repo/migrations/20231225115100_token_transfers_asc_index.exs +++ b/apps/explorer/priv/repo/migrations/20231225115100_token_transfers_asc_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TokenTransfersAscIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231227170848_add_proxy_verification_status.exs b/apps/explorer/priv/repo/migrations/20231227170848_add_proxy_verification_status.exs index 0b20b0a914e2..99db72c1eec2 100644 --- a/apps/explorer/priv/repo/migrations/20231227170848_add_proxy_verification_status.exs +++ b/apps/explorer/priv/repo/migrations/20231227170848_add_proxy_verification_status.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddProxyVerificationStatus do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20231229120232_add_smart_contract_audit_reports_table.exs b/apps/explorer/priv/repo/migrations/20231229120232_add_smart_contract_audit_reports_table.exs index 3b5880acfb8a..139a11b42704 100644 --- a/apps/explorer/priv/repo/migrations/20231229120232_add_smart_contract_audit_reports_table.exs +++ b/apps/explorer/priv/repo/migrations/20231229120232_add_smart_contract_audit_reports_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddSmartContractAuditReportsTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240103094720_constrain_null_date_market_history.exs b/apps/explorer/priv/repo/migrations/20240103094720_constrain_null_date_market_history.exs index ab43538e4e1e..12349e2710d1 100644 --- a/apps/explorer/priv/repo/migrations/20240103094720_constrain_null_date_market_history.exs +++ b/apps/explorer/priv/repo/migrations/20240103094720_constrain_null_date_market_history.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ConstrainNullDateMarketHistory do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240114181404_enhanced_unfetched_token_balances_index.exs b/apps/explorer/priv/repo/migrations/20240114181404_enhanced_unfetched_token_balances_index.exs index dcab0dc92050..eef1b7dd20e4 100644 --- a/apps/explorer/priv/repo/migrations/20240114181404_enhanced_unfetched_token_balances_index.exs +++ b/apps/explorer/priv/repo/migrations/20240114181404_enhanced_unfetched_token_balances_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.EnhancedUnfetchedTokenBalancesIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240122102141_add_token_type_to_token_transfers.exs b/apps/explorer/priv/repo/migrations/20240122102141_add_token_type_to_token_transfers.exs index 7ba7ddff029b..9a0854b0b7b5 100644 --- a/apps/explorer/priv/repo/migrations/20240122102141_add_token_type_to_token_transfers.exs +++ b/apps/explorer/priv/repo/migrations/20240122102141_add_token_type_to_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenTypeToTokenTransfers do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240123102336_add_tokens_cataloged_index.exs b/apps/explorer/priv/repo/migrations/20240123102336_add_tokens_cataloged_index.exs index b0157fb6a9c5..e205b6abecf7 100644 --- a/apps/explorer/priv/repo/migrations/20240123102336_add_tokens_cataloged_index.exs +++ b/apps/explorer/priv/repo/migrations/20240123102336_add_tokens_cataloged_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokensCatalogedIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240129112623_add_smart_contract_license_type.exs b/apps/explorer/priv/repo/migrations/20240129112623_add_smart_contract_license_type.exs index 91bea9e1b861..418bafbecbf8 100644 --- a/apps/explorer/priv/repo/migrations/20240129112623_add_smart_contract_license_type.exs +++ b/apps/explorer/priv/repo/migrations/20240129112623_add_smart_contract_license_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddSmartContractLicenseType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240219143204_add_volume_24h_to_tokens.exs b/apps/explorer/priv/repo/migrations/20240219143204_add_volume_24h_to_tokens.exs index cf3c9ad7b3b8..ae70b602ddba 100644 --- a/apps/explorer/priv/repo/migrations/20240219143204_add_volume_24h_to_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20240219143204_add_volume_24h_to_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddVolume24hToTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240219152810_add_block_consensus_to_token_transfers.exs b/apps/explorer/priv/repo/migrations/20240219152810_add_block_consensus_to_token_transfers.exs index 253c952ba4ea..e1bd12b980ea 100644 --- a/apps/explorer/priv/repo/migrations/20240219152810_add_block_consensus_to_token_transfers.exs +++ b/apps/explorer/priv/repo/migrations/20240219152810_add_block_consensus_to_token_transfers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddBlockConsensusToTokenTransfers do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240224112210_create_index_pending_block_operations_block_number.exs b/apps/explorer/priv/repo/migrations/20240224112210_create_index_pending_block_operations_block_number.exs index ad590ae77aa6..18c2e0f865d7 100644 --- a/apps/explorer/priv/repo/migrations/20240224112210_create_index_pending_block_operations_block_number.exs +++ b/apps/explorer/priv/repo/migrations/20240224112210_create_index_pending_block_operations_block_number.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateIndexPendingBlockOperationsBlockNumber do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240226074456_create_massive_blocks.exs b/apps/explorer/priv/repo/migrations/20240226074456_create_massive_blocks.exs index 824a4f8f271c..17a10ff596f1 100644 --- a/apps/explorer/priv/repo/migrations/20240226074456_create_massive_blocks.exs +++ b/apps/explorer/priv/repo/migrations/20240226074456_create_massive_blocks.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateMassiveBlocks do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240226151331_add_secondary_coin_market_history.exs b/apps/explorer/priv/repo/migrations/20240226151331_add_secondary_coin_market_history.exs index 799cf62ab01c..c2666c87d3f1 100644 --- a/apps/explorer/priv/repo/migrations/20240226151331_add_secondary_coin_market_history.exs +++ b/apps/explorer/priv/repo/migrations/20240226151331_add_secondary_coin_market_history.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddSecondaryCoinMarketHistory do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240227115149_add_smart_contracts_name_text_index.exs b/apps/explorer/priv/repo/migrations/20240227115149_add_smart_contracts_name_text_index.exs index 8cf53911574d..46160332b556 100644 --- a/apps/explorer/priv/repo/migrations/20240227115149_add_smart_contracts_name_text_index.exs +++ b/apps/explorer/priv/repo/migrations/20240227115149_add_smart_contracts_name_text_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddSmartContractsNameTextIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240308123508_token_transfers_add_from_address_hash_block_number_index.exs b/apps/explorer/priv/repo/migrations/20240308123508_token_transfers_add_from_address_hash_block_number_index.exs index 5d7d27f14474..a24ecefce624 100644 --- a/apps/explorer/priv/repo/migrations/20240308123508_token_transfers_add_from_address_hash_block_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20240308123508_token_transfers_add_from_address_hash_block_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TokenTransfersAddFromAddressHashBlockNumberIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240313195728_token_transfers_add_to_address_hash_block_number_index.exs b/apps/explorer/priv/repo/migrations/20240313195728_token_transfers_add_to_address_hash_block_number_index.exs index cef07e10e777..fda787d5faa5 100644 --- a/apps/explorer/priv/repo/migrations/20240313195728_token_transfers_add_to_address_hash_block_number_index.exs +++ b/apps/explorer/priv/repo/migrations/20240313195728_token_transfers_add_to_address_hash_block_number_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.TokenTransfersAddToAddressHashBlockNumberIndex do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240322115647_create_address_contract_code_fetch_attempts_table.exs b/apps/explorer/priv/repo/migrations/20240322115647_create_address_contract_code_fetch_attempts_table.exs index 94799ad1d9b9..c443c0f7de46 100644 --- a/apps/explorer/priv/repo/migrations/20240322115647_create_address_contract_code_fetch_attempts_table.exs +++ b/apps/explorer/priv/repo/migrations/20240322115647_create_address_contract_code_fetch_attempts_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateAddressContractCodeFetchAttemptsTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240325195446_add_verified_via_verifier_alliance.exs b/apps/explorer/priv/repo/migrations/20240325195446_add_verified_via_verifier_alliance.exs index 8849626e98b2..b4a20cfe7478 100644 --- a/apps/explorer/priv/repo/migrations/20240325195446_add_verified_via_verifier_alliance.exs +++ b/apps/explorer/priv/repo/migrations/20240325195446_add_verified_via_verifier_alliance.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddVerifiedViaVerifierAlliance do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240403151125_enhance_index_for_token_transfers_list.exs b/apps/explorer/priv/repo/migrations/20240403151125_enhance_index_for_token_transfers_list.exs index 78fe6192e578..3f56745b8860 100644 --- a/apps/explorer/priv/repo/migrations/20240403151125_enhance_index_for_token_transfers_list.exs +++ b/apps/explorer/priv/repo/migrations/20240403151125_enhance_index_for_token_transfers_list.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.EnhanceIndexForTokenTransfersList do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240403151126_drop_outdated_index_for_token_transfers_list.exs b/apps/explorer/priv/repo/migrations/20240403151126_drop_outdated_index_for_token_transfers_list.exs index 4e9f524aebc6..46599951ab6e 100644 --- a/apps/explorer/priv/repo/migrations/20240403151126_drop_outdated_index_for_token_transfers_list.exs +++ b/apps/explorer/priv/repo/migrations/20240403151126_drop_outdated_index_for_token_transfers_list.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropOutdatedIndexForTokenTransfersList do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240404102510_enhance_index_for_token_holders_list.exs b/apps/explorer/priv/repo/migrations/20240404102510_enhance_index_for_token_holders_list.exs index b547e833734a..7fbf9b4f9198 100644 --- a/apps/explorer/priv/repo/migrations/20240404102510_enhance_index_for_token_holders_list.exs +++ b/apps/explorer/priv/repo/migrations/20240404102510_enhance_index_for_token_holders_list.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.EnhanceIndexForTokenHoldersList do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240404102511_drop_outdated_index_for_token_holders_list.exs b/apps/explorer/priv/repo/migrations/20240404102511_drop_outdated_index_for_token_holders_list.exs index 85eda9d17f71..1136205bb659 100644 --- a/apps/explorer/priv/repo/migrations/20240404102511_drop_outdated_index_for_token_holders_list.exs +++ b/apps/explorer/priv/repo/migrations/20240404102511_drop_outdated_index_for_token_holders_list.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropOutdatedIndexForTokenHoldersList do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240417141515_smart_contracts_add_certified_flag.exs b/apps/explorer/priv/repo/migrations/20240417141515_smart_contracts_add_certified_flag.exs index 581bdd9d3a5a..03f19045e022 100644 --- a/apps/explorer/priv/repo/migrations/20240417141515_smart_contracts_add_certified_flag.exs +++ b/apps/explorer/priv/repo/migrations/20240417141515_smart_contracts_add_certified_flag.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SmartContractsAddCertifiedFlag do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240418135458_fix_index_for_unfetched_token_balances.exs b/apps/explorer/priv/repo/migrations/20240418135458_fix_index_for_unfetched_token_balances.exs index 6b6f58f9cdca..bb5abd3ccaa5 100644 --- a/apps/explorer/priv/repo/migrations/20240418135458_fix_index_for_unfetched_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20240418135458_fix_index_for_unfetched_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.FixIndexForUnfetchedTokenBalances do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240418140425_drop_outdated_index_for_unfetched_token_balances.exs b/apps/explorer/priv/repo/migrations/20240418140425_drop_outdated_index_for_unfetched_token_balances.exs index 0b1a05f4fcc4..e9fd38b76d6d 100644 --- a/apps/explorer/priv/repo/migrations/20240418140425_drop_outdated_index_for_unfetched_token_balances.exs +++ b/apps/explorer/priv/repo/migrations/20240418140425_drop_outdated_index_for_unfetched_token_balances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropOutdatedIndexForUnfetchedTokenBalances do use Ecto.Migration @disable_ddl_transaction true diff --git a/apps/explorer/priv/repo/migrations/20240419095711_add_proxy_implementations_table.exs b/apps/explorer/priv/repo/migrations/20240419095711_add_proxy_implementations_table.exs index 5b60caa470ca..e6578dcb0bad 100644 --- a/apps/explorer/priv/repo/migrations/20240419095711_add_proxy_implementations_table.exs +++ b/apps/explorer/priv/repo/migrations/20240419095711_add_proxy_implementations_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddProxyImplementationsTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240419101821_migrate_proxy_implementations.exs b/apps/explorer/priv/repo/migrations/20240419101821_migrate_proxy_implementations.exs index 2210d341b7de..5fd66514a4ce 100644 --- a/apps/explorer/priv/repo/migrations/20240419101821_migrate_proxy_implementations.exs +++ b/apps/explorer/priv/repo/migrations/20240419101821_migrate_proxy_implementations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.MigrateProxyImplementations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240419102345_drop_implementation_fields_from_smart_contract_table.exs b/apps/explorer/priv/repo/migrations/20240419102345_drop_implementation_fields_from_smart_contract_table.exs index 3030a6bae266..ae9f318a718c 100644 --- a/apps/explorer/priv/repo/migrations/20240419102345_drop_implementation_fields_from_smart_contract_table.exs +++ b/apps/explorer/priv/repo/migrations/20240419102345_drop_implementation_fields_from_smart_contract_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.DropImplementationFieldsFromSmartContractTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240425091614_add_proxy_type_column.exs b/apps/explorer/priv/repo/migrations/20240425091614_add_proxy_type_column.exs index 3add30af7ac1..67957daff820 100644 --- a/apps/explorer/priv/repo/migrations/20240425091614_add_proxy_type_column.exs +++ b/apps/explorer/priv/repo/migrations/20240425091614_add_proxy_type_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddProxyTypeColumn do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240425185705_alter_proxy_type.exs b/apps/explorer/priv/repo/migrations/20240425185705_alter_proxy_type.exs index 91459cb8294c..e5f979a95350 100644 --- a/apps/explorer/priv/repo/migrations/20240425185705_alter_proxy_type.exs +++ b/apps/explorer/priv/repo/migrations/20240425185705_alter_proxy_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AlterProxyType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240501131140_new_proxy_type_clones_with_immutable_arguments.exs b/apps/explorer/priv/repo/migrations/20240501131140_new_proxy_type_clones_with_immutable_arguments.exs index 55d3d3b9a404..6844818789ab 100644 --- a/apps/explorer/priv/repo/migrations/20240501131140_new_proxy_type_clones_with_immutable_arguments.exs +++ b/apps/explorer/priv/repo/migrations/20240501131140_new_proxy_type_clones_with_immutable_arguments.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.NewProxyTypeClonesWithImmutableArguments do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240502064431_create_missing_balance_of_tokens.exs b/apps/explorer/priv/repo/migrations/20240502064431_create_missing_balance_of_tokens.exs index a75513c02749..cd2407a49b4b 100644 --- a/apps/explorer/priv/repo/migrations/20240502064431_create_missing_balance_of_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20240502064431_create_missing_balance_of_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateMissingBalanceOfTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240503091708_add_nft_instance_fetcher_aux_fields.exs b/apps/explorer/priv/repo/migrations/20240503091708_add_nft_instance_fetcher_aux_fields.exs index 29195625806f..d5109d50b82e 100644 --- a/apps/explorer/priv/repo/migrations/20240503091708_add_nft_instance_fetcher_aux_fields.exs +++ b/apps/explorer/priv/repo/migrations/20240503091708_add_nft_instance_fetcher_aux_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddNftInstanceFetcherAuxFields do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240509014500_smart_contracts_add_is_blueprint_flag.exs b/apps/explorer/priv/repo/migrations/20240509014500_smart_contracts_add_is_blueprint_flag.exs index f5d362e519a1..ebc45f3990b5 100644 --- a/apps/explorer/priv/repo/migrations/20240509014500_smart_contracts_add_is_blueprint_flag.exs +++ b/apps/explorer/priv/repo/migrations/20240509014500_smart_contracts_add_is_blueprint_flag.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SmartContractsAddIsBlueprintFlag do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240520075414_create_token_instance_metadata_refetch_attempts_table.exs b/apps/explorer/priv/repo/migrations/20240520075414_create_token_instance_metadata_refetch_attempts_table.exs index 5d15ef1db24f..12a2a732defd 100644 --- a/apps/explorer/priv/repo/migrations/20240520075414_create_token_instance_metadata_refetch_attempts_table.exs +++ b/apps/explorer/priv/repo/migrations/20240520075414_create_token_instance_metadata_refetch_attempts_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTokenInstanceMetadataRefetchAttemptsTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240527152734_add_currently_implemented_to_missing_balance_of_tokens.exs b/apps/explorer/priv/repo/migrations/20240527152734_add_currently_implemented_to_missing_balance_of_tokens.exs index 84b8e02cf431..fe0825a3bdcb 100644 --- a/apps/explorer/priv/repo/migrations/20240527152734_add_currently_implemented_to_missing_balance_of_tokens.exs +++ b/apps/explorer/priv/repo/migrations/20240527152734_add_currently_implemented_to_missing_balance_of_tokens.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddCurrentlyImplementedToMissingBalanceOfTokens do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240708152519_add_nft_media_urls.exs b/apps/explorer/priv/repo/migrations/20240708152519_add_nft_media_urls.exs index f39aec152f97..2017314b476d 100644 --- a/apps/explorer/priv/repo/migrations/20240708152519_add_nft_media_urls.exs +++ b/apps/explorer/priv/repo/migrations/20240708152519_add_nft_media_urls.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddNFTMediaUrls do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240718150123_add_no_overlap_index_to_missing_block_ranges.exs b/apps/explorer/priv/repo/migrations/20240718150123_add_no_overlap_index_to_missing_block_ranges.exs index 7f569610b1cb..bc257808cce0 100644 --- a/apps/explorer/priv/repo/migrations/20240718150123_add_no_overlap_index_to_missing_block_ranges.exs +++ b/apps/explorer/priv/repo/migrations/20240718150123_add_no_overlap_index_to_missing_block_ranges.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddNoOverlapIndexToMissingBlockRanges do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240806162644_add_contract_methods_inserted_at_index.exs b/apps/explorer/priv/repo/migrations/20240806162644_add_contract_methods_inserted_at_index.exs index b025f59ebe37..cc6458f47241 100644 --- a/apps/explorer/priv/repo/migrations/20240806162644_add_contract_methods_inserted_at_index.exs +++ b/apps/explorer/priv/repo/migrations/20240806162644_add_contract_methods_inserted_at_index.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddContractMethodsInsertedAtIndex do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240828140638_add_token_balance_retry_fields.exs b/apps/explorer/priv/repo/migrations/20240828140638_add_token_balance_retry_fields.exs index 3d1fddf8bb8f..29fe0e4ac956 100644 --- a/apps/explorer/priv/repo/migrations/20240828140638_add_token_balance_retry_fields.exs +++ b/apps/explorer/priv/repo/migrations/20240828140638_add_token_balance_retry_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenBalanceRetryFields do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240830142652_add_meta_to_migrations_status.exs b/apps/explorer/priv/repo/migrations/20240830142652_add_meta_to_migrations_status.exs index f17486215b9c..39b900b3a480 100644 --- a/apps/explorer/priv/repo/migrations/20240830142652_add_meta_to_migrations_status.exs +++ b/apps/explorer/priv/repo/migrations/20240830142652_add_meta_to_migrations_status.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddMetaToMigrationsStatus do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs b/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs index e37cd4297f50..80cdebf25a3f 100644 --- a/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs +++ b/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateSignedAuthorizations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240910095635_add_address_badges_tables.exs b/apps/explorer/priv/repo/migrations/20240910095635_add_address_badges_tables.exs index ba7bbc7db20d..42fb91198d7f 100644 --- a/apps/explorer/priv/repo/migrations/20240910095635_add_address_badges_tables.exs +++ b/apps/explorer/priv/repo/migrations/20240910095635_add_address_badges_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddAddressBadgesTables do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs b/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs index 85c1ef80c85b..6ca1a65e9733 100644 --- a/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs +++ b/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.NewProxyTypeEip7702 do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240923135258_reset_sanitize_missing_token_balances_migration_status.exs b/apps/explorer/priv/repo/migrations/20240923135258_reset_sanitize_missing_token_balances_migration_status.exs index d96bab5f2ace..91cca9286c3d 100644 --- a/apps/explorer/priv/repo/migrations/20240923135258_reset_sanitize_missing_token_balances_migration_status.exs +++ b/apps/explorer/priv/repo/migrations/20240923135258_reset_sanitize_missing_token_balances_migration_status.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ResetSanitizeMissingTokenBalancesMigrationStatus do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20240923173516_address_tags_add_primary_key.exs b/apps/explorer/priv/repo/migrations/20240923173516_address_tags_add_primary_key.exs index 97a93144c57a..4b4c9778c116 100644 --- a/apps/explorer/priv/repo/migrations/20240923173516_address_tags_add_primary_key.exs +++ b/apps/explorer/priv/repo/migrations/20240923173516_address_tags_add_primary_key.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddressTagsAddPrimaryKey do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241002125432_add_is_banned_to_token_instances.exs b/apps/explorer/priv/repo/migrations/20241002125432_add_is_banned_to_token_instances.exs index 2e67556f2089..bc126d63ad67 100644 --- a/apps/explorer/priv/repo/migrations/20241002125432_add_is_banned_to_token_instances.exs +++ b/apps/explorer/priv/repo/migrations/20241002125432_add_is_banned_to_token_instances.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddIsBannedToTokenInstances do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241015140214_rename_tx_related_field.exs b/apps/explorer/priv/repo/migrations/20241015140214_rename_tx_related_field.exs index 948ae164c51c..6e22d3b3aa01 100644 --- a/apps/explorer/priv/repo/migrations/20241015140214_rename_tx_related_field.exs +++ b/apps/explorer/priv/repo/migrations/20241015140214_rename_tx_related_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RenameTxRelatedField do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241022133006_add_aux_types_for_duplicated_log_index_logs_migration.exs b/apps/explorer/priv/repo/migrations/20241022133006_add_aux_types_for_duplicated_log_index_logs_migration.exs index f5684a9f45fc..fdfc91af5ed1 100644 --- a/apps/explorer/priv/repo/migrations/20241022133006_add_aux_types_for_duplicated_log_index_logs_migration.exs +++ b/apps/explorer/priv/repo/migrations/20241022133006_add_aux_types_for_duplicated_log_index_logs_migration.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddAuxTypesForDuplicatedLogIndexLogsMigration do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241031110127_create_pending_transaction_operations.exs b/apps/explorer/priv/repo/migrations/20241031110127_create_pending_transaction_operations.exs index 369dd2c280d9..e92c2b609aec 100644 --- a/apps/explorer/priv/repo/migrations/20241031110127_create_pending_transaction_operations.exs +++ b/apps/explorer/priv/repo/migrations/20241031110127_create_pending_transaction_operations.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreatePendingTransactionOperations do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241111200520_add_language_field.exs b/apps/explorer/priv/repo/migrations/20241111200520_add_language_field.exs index 28a811359dc8..aa6ea868f99d 100644 --- a/apps/explorer/priv/repo/migrations/20241111200520_add_language_field.exs +++ b/apps/explorer/priv/repo/migrations/20241111200520_add_language_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddLanguageField do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241216112656_create_types_for_composite_primary_keys.exs b/apps/explorer/priv/repo/migrations/20241216112656_create_types_for_composite_primary_keys.exs index bdf474d43380..d261041c4473 100644 --- a/apps/explorer/priv/repo/migrations/20241216112656_create_types_for_composite_primary_keys.exs +++ b/apps/explorer/priv/repo/migrations/20241216112656_create_types_for_composite_primary_keys.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.CreateTypesForCompositePrimaryKeys do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20241219102223_remove_large_decimals.exs b/apps/explorer/priv/repo/migrations/20241219102223_remove_large_decimals.exs index fa65540e395a..dacc3d32e638 100644 --- a/apps/explorer/priv/repo/migrations/20241219102223_remove_large_decimals.exs +++ b/apps/explorer/priv/repo/migrations/20241219102223_remove_large_decimals.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveLargeDecimals do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250119145532_add_metadata_tags_aux_type.exs b/apps/explorer/priv/repo/migrations/20250119145532_add_metadata_tags_aux_type.exs index 2a20459a835d..ffa91d1ae272 100644 --- a/apps/explorer/priv/repo/migrations/20250119145532_add_metadata_tags_aux_type.exs +++ b/apps/explorer/priv/repo/migrations/20250119145532_add_metadata_tags_aux_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddMetadataTagsAuxType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250121142849_add_tokens_metadata_updated_at_column.exs b/apps/explorer/priv/repo/migrations/20250121142849_add_tokens_metadata_updated_at_column.exs index 8eb71443cf4a..4893a8840af6 100644 --- a/apps/explorer/priv/repo/migrations/20250121142849_add_tokens_metadata_updated_at_column.exs +++ b/apps/explorer/priv/repo/migrations/20250121142849_add_tokens_metadata_updated_at_column.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokensMetadataUpdatedAtColumn do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250122185347_new_proxy_type_resolved_delegate_proxy.exs b/apps/explorer/priv/repo/migrations/20250122185347_new_proxy_type_resolved_delegate_proxy.exs index 8a7ba87bcff9..eaf1992015c8 100644 --- a/apps/explorer/priv/repo/migrations/20250122185347_new_proxy_type_resolved_delegate_proxy.exs +++ b/apps/explorer/priv/repo/migrations/20250122185347_new_proxy_type_resolved_delegate_proxy.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.NewProxyTypeResolvedDelegateProxy do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250128081221_add_token_instance_type.exs b/apps/explorer/priv/repo/migrations/20250128081221_add_token_instance_type.exs index 218c752334ea..4c683bf5e7f7 100644 --- a/apps/explorer/priv/repo/migrations/20250128081221_add_token_instance_type.exs +++ b/apps/explorer/priv/repo/migrations/20250128081221_add_token_instance_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddTokenInstanceType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250212182049_update_last_fetched_counters_keys.exs b/apps/explorer/priv/repo/migrations/20250212182049_update_last_fetched_counters_keys.exs index 4d0528749ab2..eaad9bc8f7e7 100644 --- a/apps/explorer/priv/repo/migrations/20250212182049_update_last_fetched_counters_keys.exs +++ b/apps/explorer/priv/repo/migrations/20250212182049_update_last_fetched_counters_keys.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.UpdateLastFetchedCountersKeys do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250214102221_remove_composite_id_types.exs b/apps/explorer/priv/repo/migrations/20250214102221_remove_composite_id_types.exs index d44f758fa24b..90b17fb0285e 100644 --- a/apps/explorer/priv/repo/migrations/20250214102221_remove_composite_id_types.exs +++ b/apps/explorer/priv/repo/migrations/20250214102221_remove_composite_id_types.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveCompositeIdTypes do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250217221717_change_tokens_icon_url_type.exs b/apps/explorer/priv/repo/migrations/20250217221717_change_tokens_icon_url_type.exs index 66ee2b853cd2..b61f1900ca16 100644 --- a/apps/explorer/priv/repo/migrations/20250217221717_change_tokens_icon_url_type.exs +++ b/apps/explorer/priv/repo/migrations/20250217221717_change_tokens_icon_url_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.ChangeTokensIconUrlType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250303080958_remove_decompiled_smart_contracts_table.exs b/apps/explorer/priv/repo/migrations/20250303080958_remove_decompiled_smart_contracts_table.exs index 517a97d9de35..d9294e130dd8 100644 --- a/apps/explorer/priv/repo/migrations/20250303080958_remove_decompiled_smart_contracts_table.exs +++ b/apps/explorer/priv/repo/migrations/20250303080958_remove_decompiled_smart_contracts_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.RemoveDecompiledSmartContractsTable do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250311090608_add_erc_7760_to_proxy_type.exs b/apps/explorer/priv/repo/migrations/20250311090608_add_erc_7760_to_proxy_type.exs index a99b9a8fcf51..60f48db465cd 100644 --- a/apps/explorer/priv/repo/migrations/20250311090608_add_erc_7760_to_proxy_type.exs +++ b/apps/explorer/priv/repo/migrations/20250311090608_add_erc_7760_to_proxy_type.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddErc7760ToProxyType do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250318091828_add_metadata_url.exs b/apps/explorer/priv/repo/migrations/20250318091828_add_metadata_url.exs index b53db5ad9c97..e323ff468227 100644 --- a/apps/explorer/priv/repo/migrations/20250318091828_add_metadata_url.exs +++ b/apps/explorer/priv/repo/migrations/20250318091828_add_metadata_url.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.AddMetadataURL do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250319163945_missing_block_ranges_add_priority.exs b/apps/explorer/priv/repo/migrations/20250319163945_missing_block_ranges_add_priority.exs index aaede59033e3..ecc19cb39f16 100644 --- a/apps/explorer/priv/repo/migrations/20250319163945_missing_block_ranges_add_priority.exs +++ b/apps/explorer/priv/repo/migrations/20250319163945_missing_block_ranges_add_priority.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.MissingBlockRangesAddPriority do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250328104924_signed_authorizations_nonce_change_type_to_numeric.exs b/apps/explorer/priv/repo/migrations/20250328104924_signed_authorizations_nonce_change_type_to_numeric.exs index 484cfeb5ea2f..876c55aa5831 100644 --- a/apps/explorer/priv/repo/migrations/20250328104924_signed_authorizations_nonce_change_type_to_numeric.exs +++ b/apps/explorer/priv/repo/migrations/20250328104924_signed_authorizations_nonce_change_type_to_numeric.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Migrations.SignedAuthorizationsNonceChangeTypeToNumeric do use Ecto.Migration diff --git a/apps/explorer/priv/repo/migrations/20250505123201_create_multichain_search_db_export_retry_queue_table.exs b/apps/explorer/priv/repo/migrations/20250505123201_create_multichain_search_db_export_retry_queue_table.exs new file mode 100644 index 000000000000..4a5592780856 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250505123201_create_multichain_search_db_export_retry_queue_table.exs @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateMultichainSearchDbExportRetryQueueTable do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE multichain_search_hash_type AS ENUM ('block', 'transaction', 'address')", + "DROP TYPE multichain_search_hash_type" + ) + + create table(:multichain_search_db_export_retry_queue, primary_key: false) do + add(:hash, :bytea, null: false, primary_key: true) + add(:hash_type, :multichain_search_hash_type, null: false, primary_key: true) + + timestamps(null: false, type: :utc_datetime_usec) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250512083932_add_authorization_status.exs b/apps/explorer/priv/repo/migrations/20250512083932_add_authorization_status.exs new file mode 100644 index 000000000000..f6d39b9ce5db --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250512083932_add_authorization_status.exs @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddAuthorizationStatus do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE signed_authorization_status AS ENUM ('ok', 'invalid_chain_id', 'invalid_signature', 'invalid_nonce')", + "DROP TYPE signed_authorization_status" + ) + + alter table(:signed_authorizations) do + add(:status, :signed_authorization_status, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250526111446_rename_multichain_search_db_export_retry_queue_table.exs b/apps/explorer/priv/repo/migrations/20250526111446_rename_multichain_search_db_export_retry_queue_table.exs new file mode 100644 index 000000000000..dfa14af6fce5 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250526111446_rename_multichain_search_db_export_retry_queue_table.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.RenameMultichainSearchDbExportRetryQueueTable do + use Ecto.Migration + + def change do + rename(table(:multichain_search_db_export_retry_queue), to: table(:multichain_search_db_main_export_queue)) + + execute( + "ALTER TABLE multichain_search_db_main_export_queue RENAME CONSTRAINT multichain_search_db_export_retry_queue_pkey TO multichain_search_db_main_export_queue_pkey" + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250526114340_add_block_range_and_retries_number_to_multichain_search_db_export_queue_table.exs b/apps/explorer/priv/repo/migrations/20250526114340_add_block_range_and_retries_number_to_multichain_search_db_export_queue_table.exs new file mode 100644 index 000000000000..ac1bc2fd641c --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250526114340_add_block_range_and_retries_number_to_multichain_search_db_export_queue_table.exs @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddBlockRangeAndRetriesNumberToMultichainSearchDbMainExportQueueTable do + use Ecto.Migration + + def change do + alter table(:multichain_search_db_main_export_queue) do + add(:block_range, :int8range, null: true) + add(:retries_number, :smallint, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250529150808_add_block_range_index_to_multichain_search_db_export_queue_table.exs b/apps/explorer/priv/repo/migrations/20250529150808_add_block_range_index_to_multichain_search_db_export_queue_table.exs new file mode 100644 index 000000000000..531155cd70a4 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250529150808_add_block_range_index_to_multichain_search_db_export_queue_table.exs @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddBlockRangeIndexToMultichainSearchDbMainExportQueueTable do + use Ecto.Migration + + def change do + execute( + """ + CREATE INDEX multichain_search_db_main_export_queue_upper_block_range_index ON multichain_search_db_main_export_queue (upper(block_range) DESC); + """, + """ + DROP INDEX multichain_search_db_main_export_queue_upper_block_range_index; + """ + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250609130618_add_multichain_search_db_balances_export_queue_table.exs b/apps/explorer/priv/repo/migrations/20250609130618_add_multichain_search_db_balances_export_queue_table.exs new file mode 100644 index 000000000000..227eb11c0edf --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250609130618_add_multichain_search_db_balances_export_queue_table.exs @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddMultichainSearchDbBalancesExportQueueTable do + use Ecto.Migration + + def change do + create table(:multichain_search_db_export_balances_queue, primary_key: false) do + add(:id, :serial, null: false, primary_key: true) + add(:address_hash, :bytea, null: false) + add(:token_contract_address_hash_or_native, :bytea, null: false) + add(:value, :numeric, precision: 100, scale: 0, null: true) + add(:token_id, :numeric, precision: 78, scale: 0, null: true) + add(:retries_number, :smallint, null: true) + + timestamps(null: false, type: :utc_datetime_usec) + end + + create_if_not_exists( + unique_index( + :multichain_search_db_export_balances_queue, + [:address_hash, :token_contract_address_hash_or_native, "COALESCE(token_id, -1)"], + name: :unique_multichain_search_db_current_token_balances + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250613144606_drop_address_coin_balances_value_fetched_at_index.exs b/apps/explorer/priv/repo/migrations/20250613144606_drop_address_coin_balances_value_fetched_at_index.exs new file mode 100644 index 000000000000..7dfc37f3abbc --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250613144606_drop_address_coin_balances_value_fetched_at_index.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.DropAddressCoinBalancesValueFetchedAtIndex do + use Ecto.Migration + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + drop_if_exists(index(:address_coin_balances, [:value_fetched_at], concurrently: true)) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250704124014_add_timestamp_to_event_notifications.exs b/apps/explorer/priv/repo/migrations/20250704124014_add_timestamp_to_event_notifications.exs new file mode 100644 index 000000000000..72e567c458b1 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250704124014_add_timestamp_to_event_notifications.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddTimestampToEventNotifications do + use Ecto.Migration + + def change do + execute("TRUNCATE event_notifications;") + + alter table(:event_notifications) do + timestamps() + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250714093329_add_multichain_search_db_token_info_export_queue_table.exs b/apps/explorer/priv/repo/migrations/20250714093329_add_multichain_search_db_token_info_export_queue_table.exs new file mode 100644 index 000000000000..9c92833abacf --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250714093329_add_multichain_search_db_token_info_export_queue_table.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddMultichainSearchDbTokenInfoExportQueueTable do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE multichain_search_token_data_type AS ENUM ('metadata', 'total_supply', 'counters', 'market_data')", + "DROP TYPE multichain_search_token_data_type" + ) + + create table(:multichain_search_db_export_token_info_queue, primary_key: false) do + add(:address_hash, :bytea, null: false, primary_key: true) + add(:data_type, :multichain_search_token_data_type, null: false, primary_key: true) + add(:data, :jsonb, null: false) + add(:retries_number, :smallint, null: true) + + timestamps(null: false, type: :utc_datetime_usec) + end + + alter table(:tokens) do + add(:transfer_count, :integer, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250718092418_refactor_proxy_implementations.exs b/apps/explorer/priv/repo/migrations/20250718092418_refactor_proxy_implementations.exs new file mode 100644 index 000000000000..1abcfbba5122 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250718092418_refactor_proxy_implementations.exs @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.RefactorProxyImplementations do + use Ecto.Migration + + def change do + execute(""" + UPDATE proxy_implementations + SET proxy_type = NULL, + address_hashes = ARRAY []::bytea[], + names = ARRAY []::bytea[] + WHERE proxy_type IN ('eip930', 'unknown') + OR address_hashes = ARRAY ['\\x0000000000000000000000000000000000000000'::bytea] + """) + + alter table(:proxy_implementations) do + add(:conflicting_proxy_types, {:array, :proxy_type}) + add(:conflicting_address_hashes, {:array, {:array, :bytea}}) + end + + execute( + "ALTER TYPE proxy_type RENAME VALUE 'eip930' TO 'eip1967_oz'", + "ALTER TYPE proxy_type RENAME VALUE 'eip1967_oz' TO 'eip930'" + ) + + execute( + "ALTER TYPE proxy_type RENAME VALUE 'unknown' TO 'eip1967_beacon'", + "ALTER TYPE proxy_type RENAME VALUE 'eip1967_beacon' TO 'unknown'" + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250725162308_reset_tokens_skip_metadata.exs b/apps/explorer/priv/repo/migrations/20250725162308_reset_tokens_skip_metadata.exs new file mode 100644 index 000000000000..9bc7c53d7a7a --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250725162308_reset_tokens_skip_metadata.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ResetTokensSkipMetadata do + use Ecto.Migration + + def change do + execute(""" + UPDATE tokens SET skip_metadata = null WHERE skip_metadata IS TRUE AND decimals IS NULL AND name IS NULL AND symbol IS NULL + """) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250812000000_internal_transactions_drop_not_null_constraints.exs b/apps/explorer/priv/repo/migrations/20250812000000_internal_transactions_drop_not_null_constraints.exs new file mode 100644 index 000000000000..f407ad8be772 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250812000000_internal_transactions_drop_not_null_constraints.exs @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.InternalTransactionsDropNotNullConstraints do + use Ecto.Migration + + def up do + alter table(:internal_transactions) do + modify(:trace_address, {:array, :integer}, null: true) + modify(:value, :numeric, precision: 100, null: true) + end + end + + def down do + alter table(:internal_transactions) do + modify(:trace_address, {:array, :integer}, null: false) + modify(:value, :numeric, precision: 100, null: false) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250814065910_add_multichain_search_db_counters_export_queue_table.exs b/apps/explorer/priv/repo/migrations/20250814065910_add_multichain_search_db_counters_export_queue_table.exs new file mode 100644 index 000000000000..65b79ebe8470 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250814065910_add_multichain_search_db_counters_export_queue_table.exs @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddMultichainSearchDbCountersExportQueueTable do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE multichain_search_counter_type AS ENUM ('global')", + "DROP TYPE multichain_search_counter_type" + ) + + create table(:multichain_search_db_export_counters_queue, primary_key: false) do + add(:timestamp, :utc_datetime_usec, primary_key: true) + add(:counter_type, :multichain_search_counter_type, null: false, primary_key: true) + add(:data, :jsonb, null: false) + add(:retries_number, :smallint, null: true) + + timestamps(null: false, type: :utc_datetime_usec) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250820121833_change_chain_id_type_in_signed_authorization.exs b/apps/explorer/priv/repo/migrations/20250820121833_change_chain_id_type_in_signed_authorization.exs new file mode 100644 index 000000000000..cb6e91f1269d --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250820121833_change_chain_id_type_in_signed_authorization.exs @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ChangeChainIdTypeInSignedAuthorization do + use Ecto.Migration + + def up do + alter table(:signed_authorizations) do + modify(:chain_id, :numeric, precision: 78, scale: 0) + end + end + + def down do + alter table(:signed_authorizations) do + modify(:chain_id, :bigint) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250901144042_create_internal_transaction_delete_queue.exs b/apps/explorer/priv/repo/migrations/20250901144042_create_internal_transaction_delete_queue.exs new file mode 100644 index 000000000000..e35a9a358c8e --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250901144042_create_internal_transaction_delete_queue.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateInternalTransactionDeleteQueue do + use Ecto.Migration + + def change do + create table(:internal_transactions_delete_queue, primary_key: false) do + add(:block_number, :bigint, primary_key: true) + + timestamps() + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20250908062439_add_call_type_enum_to_internal_transactions.exs b/apps/explorer/priv/repo/migrations/20250908062439_add_call_type_enum_to_internal_transactions.exs new file mode 100644 index 000000000000..87dd5fa42ef2 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250908062439_add_call_type_enum_to_internal_transactions.exs @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddCallTypeEnumToInternalTransactions do + use Ecto.Migration + + def change do + execute( + "CREATE TYPE internal_transactions_call_type AS ENUM ('call', 'callcode', 'delegatecall', 'staticcall', 'invalid')", + "DROP TYPE internal_transactions_call_type" + ) + + alter table(:internal_transactions) do + add(:call_type_enum, :internal_transactions_call_type, null: true) + end + + create( + constraint(:internal_transactions, :call_has_call_type_enum, + check: "type != 'call' OR call_type IS NOT NULL OR call_type_enum IS NOT NULL", + validate: false + ) + ) + + drop(constraint(:internal_transactions, :call_has_call_type, check: "type != 'call' OR call_type IS NOT NULL"), + validate: false + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20250915135943_create_transaction_errors.exs b/apps/explorer/priv/repo/migrations/20250915135943_create_transaction_errors.exs new file mode 100644 index 000000000000..ddddef86a017 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20250915135943_create_transaction_errors.exs @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateTransactionErrors do + use Ecto.Migration + + def change do + create table(:transaction_errors, primary_key: false) do + add(:id, :smallserial, primary_key: true) + add(:message, :string, null: false) + + timestamps(updated_at: false) + end + + create(unique_index(:transaction_errors, [:message])) + + alter table(:internal_transactions) do + add(:error_id, :smallint) + end + + drop_if_exists( + constraint( + :internal_transactions, + :create_has_error_or_result, + check: """ + type != 'create' OR + (gas IS NOT NULL AND + ((error IS NULL AND created_contract_address_hash IS NOT NULL AND created_contract_code IS NOT NULL AND gas_used IS NOT NULL) OR + (error IS NOT NULL AND created_contract_address_hash IS NULL AND created_contract_code IS NULL AND gas_used IS NULL))) + """, + validate: false + ) + ) + + create( + constraint( + :internal_transactions, + :create_has_error_id_or_result, + check: """ + type != 'create' OR + (gas IS NOT NULL AND + ((error_id IS NULL AND created_contract_address_hash IS NOT NULL AND created_contract_code IS NOT NULL AND gas_used IS NOT NULL) OR + (error_id IS NOT NULL AND created_contract_address_hash IS NULL AND created_contract_code IS NULL AND gas_used IS NULL))) + """, + validate: false + ) + ) + + drop_if_exists( + constraint( + :internal_transactions, + :call_has_error_or_result, + check: """ + type != 'call' OR + (gas IS NOT NULL AND + ((error IS NULL AND gas_used IS NOT NULL AND output IS NOT NULL) OR + (error IS NOT NULL AND output is NULL))) + """, + validate: false + ) + ) + + create( + constraint( + :internal_transactions, + :call_has_error_id_or_result, + check: """ + type != 'call' OR + (gas IS NOT NULL AND + ((error_id IS NULL AND gas_used IS NOT NULL AND output IS NOT NULL) OR + (error_id IS NOT NULL AND output is NULL))) + """, + validate: false + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20251020144203_add_hot_smart_contracts_daily.exs b/apps/explorer/priv/repo/migrations/20251020144203_add_hot_smart_contracts_daily.exs new file mode 100644 index 000000000000..be589f341a54 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251020144203_add_hot_smart_contracts_daily.exs @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddHotContractsDaily do + use Ecto.Migration + + def change do + create table(:hot_smart_contracts_daily, primary_key: false) do + add(:date, :date, null: false, primary_key: true) + add(:contract_address_hash, :bytea, null: false, primary_key: true) + add(:transactions_count, :integer, null: false) + add(:total_gas_used, :numeric, precision: 100, null: false) + + timestamps(null: false, type: :utc_datetime_usec) + end + + create( + index(:hot_smart_contracts_daily, ["date DESC", "total_gas_used DESC"], name: :idx_hot_smart_contracts_date_gas) + ) + + create( + index(:hot_smart_contracts_daily, ["date DESC", "transactions_count DESC"], + name: :idx_hot_smart_contracts_date_transactions_count + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20251104134603_create_address_ids_to_address_hashes.exs b/apps/explorer/priv/repo/migrations/20251104134603_create_address_ids_to_address_hashes.exs new file mode 100644 index 000000000000..14efd21f4088 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251104134603_create_address_ids_to_address_hashes.exs @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateAddressIdsToAddressHashes do + use Ecto.Migration + + def change do + create table(:address_ids_to_address_hashes, primary_key: false) do + add(:address_id, :bigserial, primary_key: true) + add(:address_hash, :bytea, null: false) + end + + create(unique_index(:address_ids_to_address_hashes, [:address_hash])) + end +end diff --git a/apps/explorer/priv/repo/migrations/20251104135728_create_internal_transactions_address_placeholders.exs b/apps/explorer/priv/repo/migrations/20251104135728_create_internal_transactions_address_placeholders.exs new file mode 100644 index 000000000000..cbcaf1a8bde0 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251104135728_create_internal_transactions_address_placeholders.exs @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateInternalTransactionsAddressPlaceholders do + use Ecto.Migration + + def change do + create table(:deleted_internal_transactions_address_placeholders, primary_key: false) do + add(:address_id, references(:address_ids_to_address_hashes, column: :address_id, type: :bigint), + primary_key: true + ) + + add(:block_number, :bigint, primary_key: true) + add(:count_tos, :smallint, null: false) + add(:count_froms, :smallint, null: false) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20251111085348_reset_sanitize_duplicated_logs_migration.exs b/apps/explorer/priv/repo/migrations/20251111085348_reset_sanitize_duplicated_logs_migration.exs new file mode 100644 index 000000000000..cea0c11cc9e9 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251111085348_reset_sanitize_duplicated_logs_migration.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ResetSanitizeDuplicatedLogsMigration do + use Ecto.Migration + + def change do + execute("DELETE FROM migrations_status WHERE migration_name = 'sanitize_duplicated_log_index_logs'") + end +end diff --git a/apps/explorer/priv/repo/migrations/20251115202635_drop_tokens_contract_address_hash_index.exs b/apps/explorer/priv/repo/migrations/20251115202635_drop_tokens_contract_address_hash_index.exs new file mode 100644 index 000000000000..c4b630a54d6e --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251115202635_drop_tokens_contract_address_hash_index.exs @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.DropTokensContractAddressHashIndex do + use Ecto.Migration + + def change do + execute(""" + DO $$ + BEGIN + -- 1. Check if the index exists + IF EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'tokens_contract_address_hash_index' + AND n.nspname = 'public' + ) THEN + RAISE NOTICE 'Index tokens_contract_address_hash_index exists. Proceeding...'; + + -- 2. Drop the token_instances_token_contract_address_hash_fkey FK constraint (only if it exists) + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'token_instances_token_contract_address_hash_fkey' + AND conrelid = 'public.token_instances'::regclass + ) THEN + RAISE NOTICE 'Dropping foreign key token_instances_token_contract_address_hash_fkey...'; + EXECUTE ' + ALTER TABLE public.token_instances + DROP CONSTRAINT token_instances_token_contract_address_hash_fkey + '; + END IF; + + -- 3. Drop the bridged_tokens_home_token_contract_address_hash_fkey FK constraint (only if it exists) + IF EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'bridged_tokens' AND n.nspname = 'public' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'bridged_tokens_home_token_contract_address_hash_fkey' + AND conrelid = 'public.bridged_tokens'::regclass + ) THEN + RAISE NOTICE 'Dropping foreign key bridged_tokens_home_token_contract_address_hash_fkey...'; + EXECUTE ' + ALTER TABLE public.bridged_tokens + DROP CONSTRAINT bridged_tokens_home_token_contract_address_hash_fkey + '; + END IF; + END IF; + + -- 4. Drop the redundant index + RAISE NOTICE 'Dropping index tokens_contract_address_hash_index...'; + EXECUTE 'DROP INDEX public.tokens_contract_address_hash_index'; + + -- 5. Recreate the token_instances_token_contract_address_hash_fkey FK constraint + RAISE NOTICE 'Recreating foreign key token_instances_token_contract_address_hash_fkey...'; + EXECUTE ' + ALTER TABLE public.token_instances + ADD CONSTRAINT token_instances_token_contract_address_hash_fkey + FOREIGN KEY (token_contract_address_hash) + REFERENCES public.tokens(contract_address_hash) + '; + + -- 6. Recreate the bridged_tokens_home_token_contract_address_hash_fkey FK constraint (only if table exists) + IF EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'bridged_tokens' AND n.nspname = 'public' + ) THEN + RAISE NOTICE 'Recreating foreign key bridged_tokens_home_token_contract_address_hash_fkey...'; + EXECUTE ' + ALTER TABLE public.bridged_tokens + ADD CONSTRAINT bridged_tokens_home_token_contract_address_hash_fkey + FOREIGN KEY (home_token_contract_address_hash) + REFERENCES public.tokens(contract_address_hash) + '; + END IF; + ELSE + RAISE NOTICE 'Index tokens_contract_address_hash_index does NOT exist. Nothing to do.'; + END IF; + END $$; + """) + end +end diff --git a/apps/explorer/priv/repo/migrations/20251128143146_create_internal_transactions_zero_value_delete_queue.exs b/apps/explorer/priv/repo/migrations/20251128143146_create_internal_transactions_zero_value_delete_queue.exs new file mode 100644 index 000000000000..51ba7ea59242 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251128143146_create_internal_transactions_zero_value_delete_queue.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateInternalTransactionsZeroValueDeleteQueue do + use Ecto.Migration + + def change do + create table(:internal_transactions_zero_value_delete_queue, primary_key: false) do + add(:block_number, :bigint, primary_key: true) + + timestamps() + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20251202092200_update_contract_methods_unique_index.exs b/apps/explorer/priv/repo/migrations/20251202092200_update_contract_methods_unique_index.exs new file mode 100644 index 000000000000..18f99bd60d73 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251202092200_update_contract_methods_unique_index.exs @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.UpdateContractMethodsUniqueIndex do + use Ecto.Migration + + def up do + create( + unique_index(:contract_methods, [:identifier, "md5(abi::text)"], name: :contract_methods_identifier_md5_abi_index) + ) + + drop(unique_index(:contract_methods, [:identifier, :abi], name: :contract_methods_identifier_abi_index)) + end + + def down do + create(unique_index(:contract_methods, [:identifier, :abi], name: :contract_methods_identifier_abi_index)) + + drop( + unique_index(:contract_methods, [:identifier, "md5(abi::text)"], name: :contract_methods_identifier_md5_abi_index) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20251214200315_create_fhe_operations.exs b/apps/explorer/priv/repo/migrations/20251214200315_create_fhe_operations.exs new file mode 100644 index 000000000000..f5d02df35f4c --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20251214200315_create_fhe_operations.exs @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.CreateFheOperations do + use Ecto.Migration + + def change do + create table(:fhe_operations, primary_key: false) do + add(:transaction_hash, references(:transactions, column: :hash, on_delete: :delete_all, type: :bytea), + null: false, + primary_key: true + ) + + add(:log_index, :integer, null: false, primary_key: true) + add(:block_hash, references(:blocks, column: :hash, on_delete: :delete_all, type: :bytea), null: false) + add(:block_number, :bigint, null: false) + + # Operation details + add(:operation, :string, size: 50, null: false) + add(:operation_type, :string, size: 20, null: false) + add(:fhe_type, :string, size: 10, null: false) + add(:is_scalar, :boolean, null: false) + + # HCU metrics + add(:hcu_cost, :integer, null: false) + add(:hcu_depth, :integer, null: false) + + # Addresses and handles + add(:caller, :bytea) + add(:result_handle, :bytea, null: false) + add(:input_handles, :map) + + timestamps(null: false, type: :utc_datetime_usec) + end + + # Indexes for efficient queries + create(index(:fhe_operations, [:transaction_hash])) + create(index(:fhe_operations, [:log_index])) + create(index(:fhe_operations, [:caller], where: "caller IS NOT NULL")) + create(index(:fhe_operations, [:operation_type])) + create(index(:fhe_operations, [:fhe_type])) + create(index(:fhe_operations, [:operation])) + + # Precomputed FHE operations count on transactions for list API performance + alter table(:transactions) do + add(:fhe_operations_count, :integer, default: 0, null: false) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260107090004_alter_contract_verification_status_table.exs b/apps/explorer/priv/repo/migrations/20260107090004_alter_contract_verification_status_table.exs new file mode 100644 index 000000000000..0840780d00a2 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260107090004_alter_contract_verification_status_table.exs @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AlterContractVerificationStatusTable do + use Ecto.Migration + + def up do + rename(table(:contract_verification_status), to: table(:smart_contract_verification_statuses)) + execute("ALTER INDEX contract_verification_status_pkey RENAME TO smart_contract_verification_statuses_pkey") + rename(table(:smart_contract_verification_statuses), :address_hash, to: :contract_address_hash) + end + + def down do + rename(table(:smart_contract_verification_statuses), to: table(:contract_verification_status)) + + execute("ALTER INDEX smart_contract_verification_statuses_pkey RENAME TO contract_verification_status_pkey") + + rename(table(:contract_verification_status), :contract_address_hash, to: :address_hash) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260114143222_re_run_sanitize_incorrect_nft_migration.exs b/apps/explorer/priv/repo/migrations/20260114143222_re_run_sanitize_incorrect_nft_migration.exs new file mode 100644 index 000000000000..75d273f3fd68 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260114143222_re_run_sanitize_incorrect_nft_migration.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ReRunSanitizeIncorrectNftMigration do + use Ecto.Migration + + def change do + execute( + "UPDATE migrations_status SET status = 'started', meta = '{\"step\": \"delete_erc_1155\"}' WHERE migration_name = 'sanitize_incorrect_nft' AND (meta IS NULL OR meta != '{\"step\": \"delete_erc_721\"}')" + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260121084059_reset_tokens_extended_skip_metadata.exs b/apps/explorer/priv/repo/migrations/20260121084059_reset_tokens_extended_skip_metadata.exs new file mode 100644 index 000000000000..ac2f164b95ec --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260121084059_reset_tokens_extended_skip_metadata.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ResetTokensExtendedSkipMetadata do + use Ecto.Migration + + def change do + execute(""" + UPDATE tokens SET skip_metadata = null WHERE skip_metadata IS TRUE AND decimals IS NOT NULL AND name IS NOT NULL AND symbol IS NOT NULL + """) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260128120316_drop_internal_transactions_zero_value_delete_queue.exs b/apps/explorer/priv/repo/migrations/20260128120316_drop_internal_transactions_zero_value_delete_queue.exs new file mode 100644 index 000000000000..26d0ca47620d --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260128120316_drop_internal_transactions_zero_value_delete_queue.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.DropInternalTransactionsZeroValueDeleteQueue do + use Ecto.Migration + + def change do + drop(table(:internal_transactions_zero_value_delete_queue)) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260128160608_add_current_token_balance_retry_fields.exs b/apps/explorer/priv/repo/migrations/20260128160608_add_current_token_balance_retry_fields.exs new file mode 100644 index 000000000000..ef3eff85aaca --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260128160608_add_current_token_balance_retry_fields.exs @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddCurrentTokenBalanceRetryFields do + use Ecto.Migration + + def change do + alter table(:address_current_token_balances) do + add(:refetch_after, :utc_datetime_usec) + add(:retries_count, :smallint) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260209173317_add_csv_export_requests.exs b/apps/explorer/priv/repo/migrations/20260209173317_add_csv_export_requests.exs new file mode 100644 index 000000000000..7b0fc74b6fd0 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260209173317_add_csv_export_requests.exs @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddCsvExportRequests do + use Ecto.Migration + + def change do + create table(:csv_export_requests, primary_key: false) do + add(:id, :uuid, primary_key: true, null: false) + add(:remote_ip_hash, :bytea, null: false) + add(:file_id, :string) + add(:status, :string, default: "pending", null: false) + add(:expires_at, :utc_datetime, null: true) + + timestamps(type: :utc_datetime_usec) + end + + create( + index(:csv_export_requests, [:remote_ip_hash], + where: "status = 'pending'", + name: :csv_export_requests_pending_per_ip + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260213092943_add_internal_transactions_pk_not_null_constraint.exs b/apps/explorer/priv/repo/migrations/20260213092943_add_internal_transactions_pk_not_null_constraint.exs new file mode 100644 index 000000000000..c3eec2b480bb --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260213092943_add_internal_transactions_pk_not_null_constraint.exs @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddInternalTransactionsPkNotNullConstraint do + use Ecto.Migration + + def change do + create( + constraint(:internal_transactions, :internal_transactions_block_number_not_null, + check: "block_number IS NOT NULL", + validate: false + ) + ) + + create( + constraint(:internal_transactions, :internal_transactions_transaction_index_not_null, + check: "transaction_index IS NOT NULL", + validate: false + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260217131711_reset_token_transfer_block_consensus_migration.exs b/apps/explorer/priv/repo/migrations/20260217131711_reset_token_transfer_block_consensus_migration.exs new file mode 100644 index 000000000000..4e9df3834ce8 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260217131711_reset_token_transfer_block_consensus_migration.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ResetTokenTransferBlockConsensusMigration do + use Ecto.Migration + + def change do + execute("DELETE FROM migrations_status WHERE migration_name = 'token_transfers_block_consensus'") + end +end diff --git a/apps/explorer/priv/repo/migrations/20260220073231_vacuum_full_multichain_search_db_main_export_queue.exs b/apps/explorer/priv/repo/migrations/20260220073231_vacuum_full_multichain_search_db_main_export_queue.exs new file mode 100644 index 000000000000..1c4f2a6a6ac6 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260220073231_vacuum_full_multichain_search_db_main_export_queue.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.VacuumFullMultichainSearchDbMainExportQueue do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + # Check estimated row count using pg_catalog.pg_class + result = + repo().query!(""" + SELECT c.reltuples::bigint + FROM pg_catalog.pg_class c + WHERE c.oid = 'multichain_search_db_main_export_queue'::regclass + """) + + row_count = result.rows |> List.first() |> List.first() || 0 + + # Only run VACUUM FULL if row count is less than 10000 + if row_count < 10_000 do + execute("VACUUM FULL public.multichain_search_db_main_export_queue") + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260220073239_alter_multichain_search_db_export_balances_queue_id_to_bigint.exs b/apps/explorer/priv/repo/migrations/20260220073239_alter_multichain_search_db_export_balances_queue_id_to_bigint.exs new file mode 100644 index 000000000000..67939a4ffcd1 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260220073239_alter_multichain_search_db_export_balances_queue_id_to_bigint.exs @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AlterMultichainSearchDbExportBalancesQueueIdToBigint do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def up do + # Check estimated row count using pg_catalog.pg_class + result = + repo().query!(""" + SELECT c.reltuples::bigint + FROM pg_catalog.pg_class c + WHERE c.oid = 'multichain_search_db_export_balances_queue'::regclass + """) + + row_count = result.rows |> List.first() |> List.first() || 0 + + # Only alter id column and sequence if row count is less than 10000 + if row_count < 10_000 do + execute("ALTER TABLE multichain_search_db_export_balances_queue ALTER COLUMN id TYPE bigint") + execute("ALTER SEQUENCE multichain_search_db_export_balances_queue_id_seq AS bigint") + + # Run VACUUM FULL on the table + execute("VACUUM FULL public.multichain_search_db_export_balances_queue") + end + end + + def down do + # Note: Reverting bigint to integer could cause data loss if values exceed integer range + # Only revert if safe to do so + execute("ALTER SEQUENCE multichain_search_db_export_balances_queue_id_seq AS integer") + execute("ALTER TABLE multichain_search_db_export_balances_queue ALTER COLUMN id TYPE integer") + end +end diff --git a/apps/explorer/priv/repo/migrations/20260220073248_vacuum_full_multichain_search_db_export_counters_queue.exs b/apps/explorer/priv/repo/migrations/20260220073248_vacuum_full_multichain_search_db_export_counters_queue.exs new file mode 100644 index 000000000000..871a419802b2 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260220073248_vacuum_full_multichain_search_db_export_counters_queue.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.VacuumFullMultichainSearchDbExportCountersQueue do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + # Check estimated row count using pg_catalog.pg_class + result = + repo().query!(""" + SELECT c.reltuples::bigint + FROM pg_catalog.pg_class c + WHERE c.oid = 'multichain_search_db_export_counters_queue'::regclass + """) + + row_count = result.rows |> List.first() |> List.first() || 0 + + # Only run VACUUM FULL if row count is less than 10000 + if row_count < 10_000 do + execute("VACUUM FULL public.multichain_search_db_export_counters_queue") + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260220073250_vacuum_full_multichain_search_db_export_token_info_queue.exs b/apps/explorer/priv/repo/migrations/20260220073250_vacuum_full_multichain_search_db_export_token_info_queue.exs new file mode 100644 index 000000000000..9ad99aadde97 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260220073250_vacuum_full_multichain_search_db_export_token_info_queue.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.VacuumFullMultichainSearchDbExportTokenInfoQueue do + use Ecto.Migration + + @disable_ddl_transaction true + @disable_migration_lock true + + def change do + # Check estimated row count using pg_catalog.pg_class + result = + repo().query!(""" + SELECT c.reltuples::bigint + FROM pg_catalog.pg_class c + WHERE c.oid = 'multichain_search_db_export_token_info_queue'::regclass + """) + + row_count = result.rows |> List.first() |> List.first() || 0 + + # Only run VACUUM FULL if row count is less than 10000 + if row_count < 10_000 do + execute("VACUUM FULL public.multichain_search_db_export_token_info_queue") + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260226120000_drop_is_vyper_contract_column.exs b/apps/explorer/priv/repo/migrations/20260226120000_drop_is_vyper_contract_column.exs new file mode 100644 index 000000000000..573f9a0e420b --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260226120000_drop_is_vyper_contract_column.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.DropIsVyperContractColumn do + use Ecto.Migration + + def change do + alter table(:smart_contracts) do + remove(:is_vyper_contract, :boolean) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260305162638_change_address_names_primary_key.exs b/apps/explorer/priv/repo/migrations/20260305162638_change_address_names_primary_key.exs new file mode 100644 index 000000000000..5af91eb00cb1 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260305162638_change_address_names_primary_key.exs @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.ChangeAddressNamesPrimaryKey do + use Ecto.Migration + + # This migration converts the address_names table to use a composite primary key + # on (address_hash, name) instead of the id column. + # + # The unique index "unique_address_names" on (address_hash, name) already exists + # (created in migration 20180821142139_create_address_names.exs). + # We will promote this existing index to be the primary key. + + def up do + # Drop the old primary key constraint on id + execute("ALTER TABLE address_names DROP CONSTRAINT address_names_pkey") + + # Remove the id column + alter table(:address_names) do + remove(:id) + end + + # Promote the existing unique_address_names index to be the primary key + execute("ALTER TABLE address_names ADD PRIMARY KEY USING INDEX unique_address_names") + end + + def down do + # Drop the composite primary key + execute("ALTER TABLE address_names DROP CONSTRAINT address_names_pkey") + + # Recreate the sequence for the id column (mimicking original SERIAL behavior) + execute("CREATE SEQUENCE address_names_id_seq") + + # Re-add the id column with sequence backing and NOT NULL + alter table(:address_names) do + add(:id, :integer, null: false, default: fragment("nextval('address_names_id_seq'::regclass)")) + end + + # Set sequence ownership to the id column + execute("ALTER SEQUENCE address_names_id_seq OWNED BY address_names.id") + + # Restore the original id-based primary key + execute("ALTER TABLE address_names ADD PRIMARY KEY (id)") + + # Recreate the unique index for (address_hash, name) that was used in upsert operations + create(unique_index(:address_names, [:address_hash, :name], name: :unique_address_names)) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260311085615_remove_internal_transactions_transaction_hash_not_null.exs b/apps/explorer/priv/repo/migrations/20260311085615_remove_internal_transactions_transaction_hash_not_null.exs new file mode 100644 index 000000000000..48dea8f67d9a --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260311085615_remove_internal_transactions_transaction_hash_not_null.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.RemoveInternalTransactionsTransactionHashNotNull do + use Ecto.Migration + + def change do + alter table(:internal_transactions) do + modify(:transaction_hash, :bytea, null: true) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260317081858_add_address_ids_to_internal_transactions.exs b/apps/explorer/priv/repo/migrations/20260317081858_add_address_ids_to_internal_transactions.exs new file mode 100644 index 000000000000..9042de90b01a --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260317081858_add_address_ids_to_internal_transactions.exs @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddAddressIdsToInternalTransactions do + use Ecto.Migration + + def change do + alter table(:internal_transactions) do + add(:from_address_id, :bigint) + add(:to_address_id, :bigint) + add(:created_contract_address_id, :bigint) + end + + drop_if_exists( + constraint( + :internal_transactions, + :selfdestruct_has_from_and_to_address, + check: + "type != 'selfdestruct' OR (from_address_hash IS NOT NULL AND gas IS NULL AND to_address_hash IS NOT NULL)", + validate: false + ) + ) + + create( + constraint( + :internal_transactions, + :selfdestruct_has_from_and_to_address, + check: "type != 'selfdestruct' OR (from_address_id IS NOT NULL AND gas IS NULL AND to_address_id IS NOT NULL)", + validate: false + ) + ) + + drop_if_exists( + constraint( + :internal_transactions, + :create_has_error_id_or_result, + check: """ + type != 'create' OR + (gas IS NOT NULL AND + ((error_id IS NULL AND created_contract_address_hash IS NOT NULL AND created_contract_code IS NOT NULL AND gas_used IS NOT NULL) OR + (error_id IS NOT NULL AND created_contract_address_hash IS NULL AND created_contract_code IS NULL AND gas_used IS NULL))) + """, + validate: false + ) + ) + + create( + constraint( + :internal_transactions, + :create_has_error_id_or_result, + check: """ + type != 'create' OR + (gas IS NOT NULL AND + ((error_id IS NULL AND created_contract_address_id IS NOT NULL AND created_contract_code IS NOT NULL AND gas_used IS NOT NULL) OR + (error_id IS NOT NULL AND created_contract_address_id IS NULL AND created_contract_code IS NULL AND gas_used IS NULL))) + """, + validate: false + ) + ) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260402093149_remove-tx-actions.exs b/apps/explorer/priv/repo/migrations/20260402093149_remove-tx-actions.exs new file mode 100644 index 000000000000..8bf850103f24 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260402093149_remove-tx-actions.exs @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule :"Elixir.Explorer.Repo.Migrations.Remove-tx-actions" do + use Ecto.Migration + + def change do + drop(table(:transaction_actions)) + + execute("DROP TYPE transaction_actions_protocol") + + execute("DROP TYPE transaction_actions_type") + end +end diff --git a/apps/explorer/priv/repo/migrations/20260406100324_add_oban_v13.exs b/apps/explorer/priv/repo/migrations/20260406100324_add_oban_v13.exs new file mode 100644 index 000000000000..b83d0bd512b7 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260406100324_add_oban_v13.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddObanV13 do + use Ecto.Migration + + def up, do: Oban.Migrations.up(version: 13) + + def down, do: Oban.Migrations.down(version: 1) +end diff --git a/apps/explorer/priv/repo/migrations/20260407120000_add_circulating_supply_to_tokens.exs b/apps/explorer/priv/repo/migrations/20260407120000_add_circulating_supply_to_tokens.exs new file mode 100644 index 000000000000..73cd342b1827 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260407120000_add_circulating_supply_to_tokens.exs @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddCirculatingSupplyToTokens do + use Ecto.Migration + + def change do + alter table(:tokens) do + add(:circulating_supply, :decimal) + end + end +end diff --git a/apps/explorer/priv/repo/migrations/20260506124737_add_priority_to_pending_operations.exs b/apps/explorer/priv/repo/migrations/20260506124737_add_priority_to_pending_operations.exs new file mode 100644 index 000000000000..832004ac7b79 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260506124737_add_priority_to_pending_operations.exs @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddPriorityToPendingOperations do + use Ecto.Migration + + def change do + alter table(:pending_block_operations) do + add(:priority, :smallint) + end + + alter table(:pending_transaction_operations) do + add(:priority, :smallint) + end + + create(index(:pending_block_operations, :priority)) + create(index(:pending_transaction_operations, :priority)) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260511114944_update_oban_to_v14.exs b/apps/explorer/priv/repo/migrations/20260511114944_update_oban_to_v14.exs new file mode 100644 index 000000000000..f8dbeaac997d --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260511114944_update_oban_to_v14.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.UpdateObanToV14 do + use Ecto.Migration + + def up, do: Oban.Migrations.up(version: 14) + + def down, do: Oban.Migrations.down(version: 13) +end diff --git a/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs b/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs new file mode 100644 index 000000000000..51be7d6dbae7 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260511153825_reset_nfts_skip_metadata.exs @@ -0,0 +1,9 @@ +defmodule Explorer.Repo.Migrations.ResetNftsSkipMetadata do + use Ecto.Migration + + def change do + execute(""" + UPDATE tokens SET skip_metadata = NULL WHERE skip_metadata = TRUE AND name IS NOT NULL AND symbol IS NOT NULL AND type='ERC-721'; + """) + end +end diff --git a/apps/explorer/priv/repo/migrations/20260602000000_add_minimal_proxy_to_proxy_type.exs b/apps/explorer/priv/repo/migrations/20260602000000_add_minimal_proxy_to_proxy_type.exs new file mode 100644 index 000000000000..840988787736 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260602000000_add_minimal_proxy_to_proxy_type.exs @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Migrations.AddMinimalProxyToProxyType do + use Ecto.Migration + + def change do + execute("ALTER TYPE proxy_type ADD VALUE 'minimal_proxy'") + end +end diff --git a/apps/explorer/priv/repo/migrations/20260602073932_reset_delete_zero_value_internal_transactions_migration.exs b/apps/explorer/priv/repo/migrations/20260602073932_reset_delete_zero_value_internal_transactions_migration.exs new file mode 100644 index 000000000000..86b96ac5a277 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20260602073932_reset_delete_zero_value_internal_transactions_migration.exs @@ -0,0 +1,7 @@ +defmodule Explorer.Repo.Migrations.ResetDeleteZeroValueInternalTransactionsMigration do + use Ecto.Migration + + def change do + execute("DELETE FROM migrations_status WHERE migration_name = 'delete_zero_value_internal_transactions'") + end +end diff --git a/apps/explorer/priv/repo/seeds.exs b/apps/explorer/priv/repo/seeds.exs index ba0f7b4f39d3..a960918527aa 100644 --- a/apps/explorer/priv/repo/seeds.exs +++ b/apps/explorer/priv/repo/seeds.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # Script for populating the database. You can run it as: # # mix run priv/repo/seeds.exs diff --git a/apps/explorer/priv/rsk/migrations/20230724094744_add_rootstock_fields_to_blocks.exs b/apps/explorer/priv/rsk/migrations/20230724094744_add_rootstock_fields_to_blocks.exs index 40bbbc79793b..5b26b9a05a35 100644 --- a/apps/explorer/priv/rsk/migrations/20230724094744_add_rootstock_fields_to_blocks.exs +++ b/apps/explorer/priv/rsk/migrations/20230724094744_add_rootstock_fields_to_blocks.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.RSK.Migrations.AddRootstockFieldsToBlocks do use Ecto.Migration diff --git a/apps/explorer/priv/rsk/migrations/20231004101922_populate_pending_block_ops_with_historic_blocks.exs b/apps/explorer/priv/rsk/migrations/20231004101922_populate_pending_block_ops_with_historic_blocks.exs index cef3c3968be8..e53dd7a2208e 100644 --- a/apps/explorer/priv/rsk/migrations/20231004101922_populate_pending_block_ops_with_historic_blocks.exs +++ b/apps/explorer/priv/rsk/migrations/20231004101922_populate_pending_block_ops_with_historic_blocks.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.RSK.Migrations.PopulatePendingBlockOpsWithHistoricBlocks do use Ecto.Migration diff --git a/apps/explorer/priv/scroll/migrations/20240710101931_add_fee_fields.exs b/apps/explorer/priv/scroll/migrations/20240710101931_add_fee_fields.exs index e3cbaa630f8a..19133d1e9b75 100644 --- a/apps/explorer/priv/scroll/migrations/20240710101931_add_fee_fields.exs +++ b/apps/explorer/priv/scroll/migrations/20240710101931_add_fee_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Scroll.Migrations.AddFeeFields do use Ecto.Migration diff --git a/apps/explorer/priv/scroll/migrations/20240807114346_add_queue_index_field.exs b/apps/explorer/priv/scroll/migrations/20240807114346_add_queue_index_field.exs index f49c6d9d85b3..8b44d4ba7fd8 100644 --- a/apps/explorer/priv/scroll/migrations/20240807114346_add_queue_index_field.exs +++ b/apps/explorer/priv/scroll/migrations/20240807114346_add_queue_index_field.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Scroll.Migrations.AddQueueIndexField do use Ecto.Migration diff --git a/apps/explorer/priv/scroll/migrations/20240815102318_add_bridge_table.exs b/apps/explorer/priv/scroll/migrations/20240815102318_add_bridge_table.exs index 44bcd091b58d..65d06f25204a 100644 --- a/apps/explorer/priv/scroll/migrations/20240815102318_add_bridge_table.exs +++ b/apps/explorer/priv/scroll/migrations/20240815102318_add_bridge_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Scroll.Migrations.AddBridgeTable do use Ecto.Migration diff --git a/apps/explorer/priv/scroll/migrations/20240913114630_add_batches_tables.exs b/apps/explorer/priv/scroll/migrations/20240913114630_add_batches_tables.exs index 139136987e6d..591bf6b85809 100644 --- a/apps/explorer/priv/scroll/migrations/20240913114630_add_batches_tables.exs +++ b/apps/explorer/priv/scroll/migrations/20240913114630_add_batches_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Scroll.Migrations.AddBatchesTables do use Ecto.Migration diff --git a/apps/explorer/priv/scroll/migrations/20241016105249_rename_fields.exs b/apps/explorer/priv/scroll/migrations/20241016105249_rename_fields.exs index 304d409c2ec6..d83124d3f109 100644 --- a/apps/explorer/priv/scroll/migrations/20241016105249_rename_fields.exs +++ b/apps/explorer/priv/scroll/migrations/20241016105249_rename_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Scroll.Migrations.RenameFields do use Ecto.Migration diff --git a/apps/explorer/priv/shibarium/migrations/20231024091228_add_bridge_table.exs b/apps/explorer/priv/shibarium/migrations/20231024091228_add_bridge_table.exs index 4fb2fe71c99a..2e5600d5a40d 100644 --- a/apps/explorer/priv/shibarium/migrations/20231024091228_add_bridge_table.exs +++ b/apps/explorer/priv/shibarium/migrations/20231024091228_add_bridge_table.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Shibarium.Migrations.AddBridgeTable do use Ecto.Migration diff --git a/apps/explorer/priv/shrunk_internal_transactions/migrations/20240717080512_remove_internal_transactions_constraints.exs b/apps/explorer/priv/shrunk_internal_transactions/migrations/20240717080512_remove_internal_transactions_constraints.exs index 05a7b5323d06..076942d3d9de 100644 --- a/apps/explorer/priv/shrunk_internal_transactions/migrations/20240717080512_remove_internal_transactions_constraints.exs +++ b/apps/explorer/priv/shrunk_internal_transactions/migrations/20240717080512_remove_internal_transactions_constraints.exs @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ShrunkInternalTransactions.Migrations.RemoveInternalTransactionsConstraints do use Ecto.Migration def change do drop(constraint(:internal_transactions, :call_has_input, check: "type != 'call' OR input IS NOT NULL")) - drop( + drop_if_exists( constraint(:internal_transactions, :call_has_error_or_result, check: """ type != 'call' OR diff --git a/apps/explorer/priv/shrunk_internal_transactions/migrations/20250919150023_drop_call_has_error_id_or_result_constraint.exs b/apps/explorer/priv/shrunk_internal_transactions/migrations/20250919150023_drop_call_has_error_id_or_result_constraint.exs new file mode 100644 index 000000000000..1012e0d88a05 --- /dev/null +++ b/apps/explorer/priv/shrunk_internal_transactions/migrations/20250919150023_drop_call_has_error_id_or_result_constraint.exs @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.ShrunkInternalTransactions.Migrations.DropCallHasErrorIdOrResultConstraint do + use Ecto.Migration + + def change do + drop_if_exists( + constraint( + :internal_transactions, + :call_has_error_id_or_result, + check: """ + type != 'call' OR + (gas IS NOT NULL AND + ((error_id IS NULL AND gas_used IS NOT NULL AND output IS NOT NULL) OR + (error_id IS NOT NULL AND output is NULL))) + """, + validate: false + ) + ) + end +end diff --git a/apps/explorer/priv/shrunk_internal_transactions/migrations/20260407115103_remove_call_has_error_id_or_result_constraint.exs b/apps/explorer/priv/shrunk_internal_transactions/migrations/20260407115103_remove_call_has_error_id_or_result_constraint.exs new file mode 100644 index 000000000000..7e953a2b3ddc --- /dev/null +++ b/apps/explorer/priv/shrunk_internal_transactions/migrations/20260407115103_remove_call_has_error_id_or_result_constraint.exs @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.ShrunkInternalTransactions.Migrations.RemoveCallHasErrorIdOrResultConstraint do + use Ecto.Migration + + def change do + drop_if_exists( + constraint(:internal_transactions, :call_has_error_id_or_result, + check: """ + type != 'call' OR + (gas IS NOT NULL AND + ((error_id IS NULL AND gas_used IS NOT NULL AND output IS NOT NULL) OR + (error_id IS NOT NULL AND output is NULL))) + """ + ) + ) + end +end diff --git a/apps/explorer/priv/stability/migrations/20240203091010_add_stability_validators.exs b/apps/explorer/priv/stability/migrations/20240203091010_add_stability_validators.exs index 67e5580fef13..6f06675012c9 100644 --- a/apps/explorer/priv/stability/migrations/20240203091010_add_stability_validators.exs +++ b/apps/explorer/priv/stability/migrations/20240203091010_add_stability_validators.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Stability.Migrations.AddStabilityValidators do use Ecto.Migration diff --git a/apps/explorer/priv/stability/migrations/20250602163043_add_validator_blocks_validated_counter.exs b/apps/explorer/priv/stability/migrations/20250602163043_add_validator_blocks_validated_counter.exs new file mode 100644 index 000000000000..428380242c28 --- /dev/null +++ b/apps/explorer/priv/stability/migrations/20250602163043_add_validator_blocks_validated_counter.exs @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Stability.Migrations.AddValidatorBlocksValidatedCounter do + use Ecto.Migration + + def change do + alter table(:validators_stability) do + add(:blocks_validated, :integer, null: true) + end + + execute(""" + UPDATE validators_stability v + SET blocks_validated = COALESCE( + (SELECT COUNT(*) + FROM blocks b + WHERE b.miner_hash = v.address_hash), + 0 + ); + """) + + alter table(:validators_stability) do + modify(:blocks_validated, :integer, null: false) + end + end +end diff --git a/apps/explorer/priv/suave/migrations/20230921120210_add_suave_transaction_fields.exs b/apps/explorer/priv/suave/migrations/20230921120210_add_suave_transaction_fields.exs index 2476d0b42fab..04d2b00bb97e 100644 --- a/apps/explorer/priv/suave/migrations/20230921120210_add_suave_transaction_fields.exs +++ b/apps/explorer/priv/suave/migrations/20230921120210_add_suave_transaction_fields.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Suave.Migrations.AddSuaveTransactionFields do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20240927123039_create_quorum_certificate.exs b/apps/explorer/priv/zilliqa/migrations/20240927123039_create_quorum_certificate.exs index f3156cae9bdb..a0d3e6dc0817 100644 --- a/apps/explorer/priv/zilliqa/migrations/20240927123039_create_quorum_certificate.exs +++ b/apps/explorer/priv/zilliqa/migrations/20240927123039_create_quorum_certificate.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.CreateQuorumCertificate do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20240927123101_create_aggregate_quorum_certificate.exs b/apps/explorer/priv/zilliqa/migrations/20240927123101_create_aggregate_quorum_certificate.exs index dd21c9cff3de..580da1e0e901 100644 --- a/apps/explorer/priv/zilliqa/migrations/20240927123101_create_aggregate_quorum_certificate.exs +++ b/apps/explorer/priv/zilliqa/migrations/20240927123101_create_aggregate_quorum_certificate.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.CreateAggregateQuorumCertificate do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20240927123113_create_nested_quorum_certificate.exs b/apps/explorer/priv/zilliqa/migrations/20240927123113_create_nested_quorum_certificate.exs index 01bd2c042238..4826fb8b65d8 100644 --- a/apps/explorer/priv/zilliqa/migrations/20240927123113_create_nested_quorum_certificate.exs +++ b/apps/explorer/priv/zilliqa/migrations/20240927123113_create_nested_quorum_certificate.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.CreateNestedQuorumCertificate do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20241015095021_add_view_to_block.exs b/apps/explorer/priv/zilliqa/migrations/20241015095021_add_view_to_block.exs index d988964daf89..e21c067d7cc9 100644 --- a/apps/explorer/priv/zilliqa/migrations/20241015095021_add_view_to_block.exs +++ b/apps/explorer/priv/zilliqa/migrations/20241015095021_add_view_to_block.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.AddViewToBlock do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20241027171945_modify_smart_contracts.exs b/apps/explorer/priv/zilliqa/migrations/20241027171945_modify_smart_contracts.exs index f181216a48f6..8c0d8be606f7 100644 --- a/apps/explorer/priv/zilliqa/migrations/20241027171945_modify_smart_contracts.exs +++ b/apps/explorer/priv/zilliqa/migrations/20241027171945_modify_smart_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.ModifySmartContracts do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20250106185108_create_zilliqa_stakers.exs b/apps/explorer/priv/zilliqa/migrations/20250106185108_create_zilliqa_stakers.exs index acd40cd7e6c5..136738ceeb29 100644 --- a/apps/explorer/priv/zilliqa/migrations/20250106185108_create_zilliqa_stakers.exs +++ b/apps/explorer/priv/zilliqa/migrations/20250106185108_create_zilliqa_stakers.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.CreateZilliqaStakers do use Ecto.Migration diff --git a/apps/explorer/priv/zilliqa/migrations/20250303201406_decrease_scilla_language_index_by_one.exs b/apps/explorer/priv/zilliqa/migrations/20250303201406_decrease_scilla_language_index_by_one.exs index bc7d4cb8742a..1167492e898e 100644 --- a/apps/explorer/priv/zilliqa/migrations/20250303201406_decrease_scilla_language_index_by_one.exs +++ b/apps/explorer/priv/zilliqa/migrations/20250303201406_decrease_scilla_language_index_by_one.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.Zilliqa.Migrations.DecreaseScillaLanguageIndexByOne do @moduledoc """ Migration to update the language identifier for Scilla smart contracts in Zilliqa. diff --git a/apps/explorer/priv/zilliqa/migrations/20251003112047_create_zrc2_token_tables.exs b/apps/explorer/priv/zilliqa/migrations/20251003112047_create_zrc2_token_tables.exs new file mode 100644 index 000000000000..5581e9fe6c6d --- /dev/null +++ b/apps/explorer/priv/zilliqa/migrations/20251003112047_create_zrc2_token_tables.exs @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Repo.Zilliqa.Migrations.CreateZRC2TokenTables do + use Ecto.Migration + + def up do + create table(:zilliqa_zrc2_token_adapters, primary_key: false) do + add(:zrc2_address_hash, references(:addresses, column: :hash, type: :bytea), null: false) + add(:adapter_address_hash, references(:addresses, column: :hash, type: :bytea), primary_key: true) + timestamps(type: :utc_datetime_usec) + end + + create(unique_index(:zilliqa_zrc2_token_adapters, :zrc2_address_hash)) + + create table(:zilliqa_zrc2_token_transfers, primary_key: false) do + add( + :transaction_hash, + references(:transactions, column: :hash, on_delete: :delete_all, type: :bytea), + primary_key: true + ) + + add(:log_index, :integer, primary_key: true) + add(:from_address_hash, references(:addresses, column: :hash, type: :bytea), null: false) + add(:to_address_hash, references(:addresses, column: :hash, type: :bytea), null: false) + add(:amount, :decimal, null: false) + add(:zrc2_address_hash, references(:addresses, column: :hash, type: :bytea), null: false) + add(:block_number, :integer, null: false) + add(:block_hash, references(:blocks, column: :hash, type: :bytea), primary_key: true) + + timestamps(type: :utc_datetime_usec) + end + + create(index(:zilliqa_zrc2_token_transfers, :zrc2_address_hash)) + create(index(:zilliqa_zrc2_token_transfers, [:block_number, :block_hash])) + end + + def down do + drop_if_exists(table(:zilliqa_zrc2_token_transfers)) + drop_if_exists(table(:zilliqa_zrc2_token_adapters)) + end +end diff --git a/apps/explorer/priv/zk_sync/migrations/20211202082101_make_tranaction_r_s_v_optional.exs b/apps/explorer/priv/zk_sync/migrations/20211202082101_make_tranaction_r_s_v_optional.exs index 7bf465fb5bda..f05f5f6d43f8 100644 --- a/apps/explorer/priv/zk_sync/migrations/20211202082101_make_tranaction_r_s_v_optional.exs +++ b/apps/explorer/priv/zk_sync/migrations/20211202082101_make_tranaction_r_s_v_optional.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.MakeTransactionRSVOptional do use Ecto.Migration diff --git a/apps/explorer/priv/zk_sync/migrations/20231213171043_create_zksync_tables.exs b/apps/explorer/priv/zk_sync/migrations/20231213171043_create_zksync_tables.exs index 1e7d02c1d7c0..031048713037 100644 --- a/apps/explorer/priv/zk_sync/migrations/20231213171043_create_zksync_tables.exs +++ b/apps/explorer/priv/zk_sync/migrations/20231213171043_create_zksync_tables.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.CreateZkSyncTables do use Ecto.Migration diff --git a/apps/explorer/priv/zk_sync/migrations/20240611091814_rename_field_in_batch_transactions.exs b/apps/explorer/priv/zk_sync/migrations/20240611091814_rename_field_in_batch_transactions.exs index 185a07079db0..b2c30a81e658 100644 --- a/apps/explorer/priv/zk_sync/migrations/20240611091814_rename_field_in_batch_transactions.exs +++ b/apps/explorer/priv/zk_sync/migrations/20240611091814_rename_field_in_batch_transactions.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.RenameFieldInBatchTransactions do use Ecto.Migration diff --git a/apps/explorer/priv/zk_sync/migrations/20240716095237_add_zk_compiler_version_to_smart_contracts.exs b/apps/explorer/priv/zk_sync/migrations/20240716095237_add_zk_compiler_version_to_smart_contracts.exs index dd1202656f33..4b04c62e9902 100644 --- a/apps/explorer/priv/zk_sync/migrations/20240716095237_add_zk_compiler_version_to_smart_contracts.exs +++ b/apps/explorer/priv/zk_sync/migrations/20240716095237_add_zk_compiler_version_to_smart_contracts.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.AddZkCompilerVersionToSmartContracts do use Ecto.Migration diff --git a/apps/explorer/priv/zk_sync/migrations/20241015093336_rename_tx_hash_field_zksync.exs b/apps/explorer/priv/zk_sync/migrations/20241015093336_rename_tx_hash_field_zksync.exs index e62c62816547..cfe635c77a41 100644 --- a/apps/explorer/priv/zk_sync/migrations/20241015093336_rename_tx_hash_field_zksync.exs +++ b/apps/explorer/priv/zk_sync/migrations/20241015093336_rename_tx_hash_field_zksync.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.RenameTxHashFieldArbitrum do use Ecto.Migration diff --git a/apps/explorer/priv/zk_sync/migrations/20241028102407_rename_tx_count_fields_zksync.exs b/apps/explorer/priv/zk_sync/migrations/20241028102407_rename_tx_count_fields_zksync.exs index dd89c64fce22..2f71a123dbba 100644 --- a/apps/explorer/priv/zk_sync/migrations/20241028102407_rename_tx_count_fields_zksync.exs +++ b/apps/explorer/priv/zk_sync/migrations/20241028102407_rename_tx_count_fields_zksync.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.RenameTxCountFieldsZksync do use Ecto.Migration diff --git a/apps/explorer/priv/zk_sync/migrations/20241028102853_add_contract_code_refetched.exs b/apps/explorer/priv/zk_sync/migrations/20241028102853_add_contract_code_refetched.exs index 7f24a305d115..4082367c526d 100644 --- a/apps/explorer/priv/zk_sync/migrations/20241028102853_add_contract_code_refetched.exs +++ b/apps/explorer/priv/zk_sync/migrations/20241028102853_add_contract_code_refetched.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ZkSync.Migrations.AddContractCodeRefetched do use Ecto.Migration diff --git a/apps/explorer/test/AGENTS.md b/apps/explorer/test/AGENTS.md new file mode 100644 index 000000000000..31d4f08dfbf6 --- /dev/null +++ b/apps/explorer/test/AGENTS.md @@ -0,0 +1,16 @@ +# Explorer Test Conventions + +## Application config in tests + +When overriding `Application` config in test `setup`, use `Keyword.merge/2` with the initial config — don't replace the entire keyword list. Replacing drops keys that other code paths rely on. + +```elixir +# Good +initial = Application.get_env(:explorer, SomeModule) +Application.put_env(:explorer, SomeModule, Keyword.merge(initial, key: :override)) + +# Bad — drops all other keys +Application.put_env(:explorer, SomeModule, key: :override) +``` + +Handle potentially nil initial configs with `initial || []`. diff --git a/apps/explorer/test/CLAUDE.md b/apps/explorer/test/CLAUDE.md new file mode 100644 index 000000000000..43c994c2d361 --- /dev/null +++ b/apps/explorer/test/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/explorer/test/explorer/account/identity_test.exs b/apps/explorer/test/explorer/account/identity_test.exs index 1acde2e96607..f2ec6da9f9aa 100644 --- a/apps/explorer/test/explorer/account/identity_test.exs +++ b/apps/explorer/test/explorer/account/identity_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.IdentityTest do use Explorer.DataCase @@ -34,13 +35,13 @@ defmodule Explorer.Account.IdentityTest do id: identity_id, email: "john@blockscout.com", uid: "github|666666" - } = Identity |> first |> Repo.account_repo().one() + } = Identity |> first() |> Repo.account_repo().one() %{ id: watchlist_id, identity_id: ^identity_id, name: "default" - } = Watchlist |> first |> Repo.account_repo().one() + } = Watchlist |> first() |> Repo.account_repo().one() assert {:ok, %{ @@ -80,13 +81,13 @@ defmodule Explorer.Account.IdentityTest do id: identity_id, email: "john@blockscout.com", uid: "google-oauth2|666666" - } = Identity |> first |> Repo.account_repo().one() + } = Identity |> first() |> Repo.account_repo().one() %{ id: watchlist_id, identity_id: ^identity_id, name: "default" - } = Watchlist |> first |> Repo.account_repo().one() + } = Watchlist |> first() |> Repo.account_repo().one() assert {:ok, %{ diff --git a/apps/explorer/test/explorer/account/notifier/email_test.exs b/apps/explorer/test/explorer/account/notifier/email_test.exs index 387aab7e6fab..a46c95ce8903 100644 --- a/apps/explorer/test/explorer/account/notifier/email_test.exs +++ b/apps/explorer/test/explorer/account/notifier/email_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.EmailTest do use ExUnit.Case @@ -11,7 +12,7 @@ defmodule Explorer.Account.Notifier.EmailTest do import Explorer.Chain, only: [ string_to_address_hash: 1, - string_to_transaction_hash: 1 + string_to_full_hash: 1 ] import Explorer.Account.Notifier.Email, @@ -21,6 +22,7 @@ defmodule Explorer.Account.Notifier.EmailTest do host = Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:host] path = Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:path] scheme = Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url][:scheme] + old_chain_id = Application.get_env(:block_scout_web, :chain_id) Application.put_env(:block_scout_web, BlockScoutWeb.Endpoint, url: [scheme: "https", host: "eth.blockscout.com", path: "/", port: 443] @@ -33,21 +35,22 @@ defmodule Explorer.Account.Notifier.EmailTest do ] ) - :ok + Application.put_env(:block_scout_web, :chain_id, "30") on_exit(fn -> Application.put_env(:block_scout_web, BlockScoutWeb.Endpoint, url: [scheme: scheme, host: host, path: path]) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) end) end describe "composing email" do test "compose_email" do {:ok, transaction_hash} = - string_to_transaction_hash("0x5d5ff210261f1b2d6e4af22ea494f428f9997d4ab614a629d4f1390004b3e80d") + string_to_full_hash("0x5d5ff210261f1b2d6e4af22ea494f428f9997d4ab614a629d4f1390004b3e80d") {:ok, from_hash} = string_to_address_hash("0x092D537737E767Dae48c28aE509f34094496f030") - {:ok, to_hash} = string_to_address_hash("0xE1F4dd38f00B0D8D4d2b4B5010bE53F2A0b934E5") + {:ok, to_hash} = string_to_address_hash("0x3078dd38f00B0D8D4d2b4B5010bE53F2A0b934E5") identity = %Identity{ uid: "foo|bar", @@ -93,19 +96,31 @@ defmodule Explorer.Account.Notifier.EmailTest do private: %{ send_grid_template: %{ dynamic_template_data: %{ - "address_hash" => "0xe1f4dd38f00b0d8d4d2b4b5010be53f2a0b934e5", + "address_hash" => + if(Application.get_env(:explorer, :chain_type) == :rsk, + do: "0x3078dd38F00B0d8D4D2B4b5010bE53F2A0b934E5", + else: "0x3078DD38f00B0d8D4D2B4B5010be53F2a0B934e5" + ), "address_name" => "wallet", - "address_url" => "https://eth.blockscout.com/address/0xe1f4dd38f00b0d8d4d2b4b5010be53f2a0b934e5", + "address_url" => "https://eth.blockscout.com/address/0x3078dd38f00b0d8d4d2b4b5010be53f2a0b934e5", "amount" => Decimal.new(1), "block_number" => 24_121_177, "block_url" => "https://eth.blockscout.com/block/24121177", "direction" => "received at", - "from_address_hash" => "0x092d537737e767dae48c28ae509f34094496f030", + "from_address_hash" => + if(Application.get_env(:explorer, :chain_type) == :rsk, + do: "0x092D537737e767dAE48C28aE509F34094496F030", + else: "0x092D537737E767Dae48c28aE509f34094496f030" + ), "from_url" => "https://eth.blockscout.com/address/0x092d537737e767dae48c28ae509f34094496f030", "method" => "transfer", "name" => "wallet", - "to_address_hash" => "0xe1f4dd38f00b0d8d4d2b4b5010be53f2a0b934e5", - "to_url" => "https://eth.blockscout.com/address/0xe1f4dd38f00b0d8d4d2b4b5010be53f2a0b934e5", + "to_address_hash" => + if(Application.get_env(:explorer, :chain_type) == :rsk, + do: "0x3078dd38F00B0d8D4D2B4b5010bE53F2A0b934E5", + else: "0x3078DD38f00B0d8D4D2B4B5010be53F2a0B934e5" + ), + "to_url" => "https://eth.blockscout.com/address/0x3078dd38f00b0d8d4d2b4b5010be53f2a0b934e5", "transaction_hash" => "0x5d5ff210261f1b2d6e4af22ea494f428f9997d4ab614a629d4f1390004b3e80d", "transaction_url" => "https://eth.blockscout.com/tx/0x5d5ff210261f1b2d6e4af22ea494f428f9997d4ab614a629d4f1390004b3e80d", diff --git a/apps/explorer/test/explorer/account/notifier/notify_test.exs b/apps/explorer/test/explorer/account/notifier/notify_test.exs index 5968c16dace5..2775b07186d5 100644 --- a/apps/explorer/test/explorer/account/notifier/notify_test.exs +++ b/apps/explorer/test/explorer/account/notifier/notify_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.NotifyTest do # use ExUnit.Case use Explorer.DataCase @@ -44,7 +45,7 @@ defmodule Explorer.Account.Notifier.NotifyTest do wn = WatchlistNotification - |> first + |> first() |> Repo.account_repo().one() assert notify == [[:ok]] @@ -74,7 +75,7 @@ defmodule Explorer.Account.Notifier.NotifyTest do wn = WatchlistNotification - |> first + |> first() |> Repo.account_repo().one() assert notify == [[:ok]] @@ -113,7 +114,7 @@ defmodule Explorer.Account.Notifier.NotifyTest do wn = WatchlistNotification - |> first + |> first() |> Repo.account_repo().one() assert notify == [[:ok]] @@ -137,7 +138,7 @@ defmodule Explorer.Account.Notifier.NotifyTest do Notify.call([transaction]) WatchlistNotification - |> first + |> first() |> Repo.account_repo().one!() Application.put_env(:explorer, Explorer.Account, old_envs) diff --git a/apps/explorer/test/explorer/account/notifier/summary_test.exs b/apps/explorer/test/explorer/account/notifier/summary_test.exs index c39b728d3efc..ce8e6c676410 100644 --- a/apps/explorer/test/explorer/account/notifier/summary_test.exs +++ b/apps/explorer/test/explorer/account/notifier/summary_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Account.Notifier.SummaryTest do use Explorer.DataCase @@ -447,5 +448,65 @@ defmodule Explorer.Account.Notifier.SummaryTest do } ] end + + test "ERC-7984 Confidential Token transfer" do + token = insert(:token, type: "ERC-7984", name: "Confidential Token") + + transaction = + %Transaction{ + from_address: transaction_from_address, + to_address: transaction_to_address, + block_number: block_number, + hash: transaction_hash + } = with_block(insert(:transaction)) + + transaction_amount = Wei.to(transaction.value, :ether) + + %TokenTransfer{ + block_number: _block_number, + from_address: from_address, + to_address: to_address + } = + :token_transfer + |> insert( + transaction: transaction, + token_type: "ERC-7984", + amount: nil, + token_ids: nil, + token_contract_address: token.contract_address, + token: token, + block: transaction.block, + block_number: transaction.block_number + ) + + {_, fee} = Transaction.fee(transaction, :gwei) + + assert Summary.process(transaction) == [ + %Summary{ + amount: transaction_amount, + block_number: block_number, + from_address_hash: transaction_from_address.hash, + method: "transfer", + name: "ETH", + subject: "Coin transaction", + to_address_hash: transaction_to_address.hash, + transaction_hash: transaction_hash, + transaction_fee: fee, + type: "COIN" + }, + %Summary{ + amount: Decimal.new(0), + block_number: block_number, + from_address_hash: from_address.hash, + method: "transfer", + name: "Confidential Token", + subject: "Confidential transfer", + to_address_hash: to_address.hash, + transaction_hash: transaction.hash, + transaction_fee: fee, + type: "ERC-7984" + } + ] + end end end diff --git a/apps/explorer/test/explorer/accounts/accounts_test.exs b/apps/explorer/test/explorer/accounts/accounts_test.exs index 37c61d414b47..8e54a628ab6d 100644 --- a/apps/explorer/test/explorer/accounts/accounts_test.exs +++ b/apps/explorer/test/explorer/accounts/accounts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.AccountsTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/accounts/user_contact_test.exs b/apps/explorer/test/explorer/accounts/user_contact_test.exs index a93a97911cc3..8b5c2d4244ac 100644 --- a/apps/explorer/test/explorer/accounts/user_contact_test.exs +++ b/apps/explorer/test/explorer/accounts/user_contact_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts.UserContactTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/accounts/user_test.exs b/apps/explorer/test/explorer/accounts/user_test.exs index adf1475a0597..2f91c88d233a 100644 --- a/apps/explorer/test/explorer/accounts/user_test.exs +++ b/apps/explorer/test/explorer/accounts/user_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Accounts.UserTest do use ExUnit.Case diff --git a/apps/explorer/test/explorer/admin/administrator/role_test.exs b/apps/explorer/test/explorer/admin/administrator/role_test.exs index c65b05904bf2..2787962ec846 100644 --- a/apps/explorer/test/explorer/admin/administrator/role_test.exs +++ b/apps/explorer/test/explorer/admin/administrator/role_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Admin.Administrator.RoleTest do use ExUnit.Case diff --git a/apps/explorer/test/explorer/admin/recovery_test.exs b/apps/explorer/test/explorer/admin/recovery_test.exs index 755a812df53e..883184e7e53c 100644 --- a/apps/explorer/test/explorer/admin/recovery_test.exs +++ b/apps/explorer/test/explorer/admin/recovery_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Admin.RecoveryTest do use ExUnit.Case, async: false diff --git a/apps/explorer/test/explorer/admin_test.exs b/apps/explorer/test/explorer/admin_test.exs index 9afb7b988715..09bf715e03dd 100644 --- a/apps/explorer/test/explorer/admin_test.exs +++ b/apps/explorer/test/explorer/admin_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.AdminTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/bloom_filter_test.exs b/apps/explorer/test/explorer/bloom_filter_test.exs index e9ec4a2c5106..148b5331248d 100644 --- a/apps/explorer/test/explorer/bloom_filter_test.exs +++ b/apps/explorer/test/explorer/bloom_filter_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.BloomFilterTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/address/coin_balance_test.exs b/apps/explorer/test/explorer/chain/address/coin_balance_test.exs index 171a2764c033..ab76e3fdb1db 100644 --- a/apps/explorer/test/explorer/chain/address/coin_balance_test.exs +++ b/apps/explorer/test/explorer/chain/address/coin_balance_test.exs @@ -1,9 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.CoinBalanceTest do use Explorer.DataCase alias Ecto.Changeset alias Explorer.Chain.Address.CoinBalance - alias Explorer.Chain.{Block, Wei} + alias Explorer.Chain.{Address, Block, Wei} alias Explorer.PagingOptions describe "changeset/2" do @@ -298,4 +299,394 @@ defmodule Explorer.Chain.Address.CoinBalanceTest do assert(value == Wei.from(Decimal.new(2000), :wei)) end end + + describe "stream_unfetched_balances/2" do + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.Block.t/0` `miner_hash`" do + %Address{hash: miner_hash} = miner = insert(:address) + %Block{number: block_number} = insert(:block, miner: miner) + balance = insert(:unfetched_balance, address_hash: miner_hash, block_number: block_number) + + assert {:ok, [%{address_hash: ^miner_hash, block_number: ^block_number}]} = + CoinBalance.stream_unfetched_balances([], &[&1 | &2]) + + update_balance_value(balance, 1) + + assert {:ok, []} = CoinBalance.stream_unfetched_balances([], &[&1 | &2]) + end + + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.Transaction.t/0` `from_address_hash`" do + %Address{hash: from_address_hash} = from_address = insert(:address) + %Block{number: block_number} = block = insert(:block) + + :transaction + |> insert(from_address: from_address) + |> with_block(block) + + balance = insert(:unfetched_balance, address_hash: from_address_hash, block_number: block_number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + assert %{address_hash: from_address_hash, block_number: block_number} in balance_fields_list + + update_balance_value(balance, 1) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + refute %{address_hash: from_address_hash, block_number: block_number} in balance_fields_list + end + + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.Transaction.t/0` `to_address_hash`" do + %Address{hash: to_address_hash} = to_address = insert(:address) + %Block{number: block_number} = block = insert(:block) + + :transaction + |> insert(to_address: to_address) + |> with_block(block) + + balance = insert(:unfetched_balance, address_hash: to_address_hash, block_number: block_number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + assert %{address_hash: to_address_hash, block_number: block_number} in balance_fields_list + + update_balance_value(balance, 1) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + refute %{address_hash: to_address_hash, block_number: block_number} in balance_fields_list + end + + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.Log.t/0` `address_hash`" do + address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:log, address: address, transaction: transaction, block: block, block_number: block.number) + + balance = insert(:unfetched_balance, address_hash: address.hash, block_number: block.number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + assert %{ + address_hash: address.hash, + block_number: block.number + } in balance_fields_list + + update_balance_value(balance, 1) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + refute %{ + address_hash: address.hash, + block_number: block.number + } in balance_fields_list + end + + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.InternalTransaction.t/0` `created_contract_address_hash`" do + created_contract_address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert( + :internal_transaction_create, + created_contract_address: created_contract_address, + index: 0, + transaction: transaction, + transaction_index: transaction.index, + block_number: transaction.block_number + ) + + balance = insert(:unfetched_balance, address_hash: created_contract_address.hash, block_number: block.number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + assert %{ + address_hash: created_contract_address.hash, + block_number: block.number + } in balance_fields_list + + update_balance_value(balance, 1) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + refute %{ + address_hash: created_contract_address.hash, + block_number: block.number + } in balance_fields_list + end + + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.InternalTransaction.t/0` `from_address_hash`" do + from_address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert( + :internal_transaction_create, + from_address: from_address, + index: 0, + transaction: transaction, + transaction_index: transaction.index, + block_number: transaction.block_number + ) + + balance = insert(:unfetched_balance, address_hash: from_address.hash, block_number: block.number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + assert %{address_hash: from_address.hash, block_number: block.number} in balance_fields_list + + update_balance_value(balance, 1) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + refute %{address_hash: from_address.hash, block_number: block.number} in balance_fields_list + end + + test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> + "does not return `t:Explorer.Chain.InternalTransaction.t/0` `to_address_hash`" do + to_address = insert(:address) + block = insert(:block) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert( + :internal_transaction_create, + to_address: to_address, + index: 0, + transaction: transaction, + transaction_index: transaction.index, + block_number: transaction.block_number + ) + + balance = insert(:unfetched_balance, address_hash: to_address.hash, block_number: block.number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + assert %{address_hash: to_address.hash, block_number: block.number} in balance_fields_list + + update_balance_value(balance, 1) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + refute %{address_hash: to_address.hash, block_number: block.number} in balance_fields_list + end + + test "an address_hash used for multiple block_numbers returns all block_numbers" do + miner = insert(:address) + mined_block = insert(:block, miner: miner) + + insert(:unfetched_balance, address_hash: miner.hash, block_number: mined_block.number) + + from_transaction_block = insert(:block) + + :transaction + |> insert(from_address: miner) + |> with_block(from_transaction_block) + + insert(:unfetched_balance, address_hash: miner.hash, block_number: from_transaction_block.number) + + to_transaction_block = insert(:block) + + :transaction + |> insert(to_address: miner) + |> with_block(to_transaction_block) + + insert(:unfetched_balance, address_hash: miner.hash, block_number: to_transaction_block.number) + + log_block = insert(:block) + + log_transaction = + :transaction + |> insert() + |> with_block(log_block) + + insert(:log, address: miner, transaction: log_transaction, block: log_block, block_number: log_block.number) + insert(:unfetched_balance, address_hash: miner.hash, block_number: log_block.number) + + from_internal_transaction_block = insert(:block) + + from_internal_transaction_transaction = + :transaction + |> insert() + |> with_block(from_internal_transaction_block) + + insert( + :internal_transaction_create, + from_address: miner, + index: 0, + transaction: from_internal_transaction_transaction, + transaction_index: from_internal_transaction_transaction.index, + block_number: from_internal_transaction_transaction.block_number + ) + + insert(:unfetched_balance, address_hash: miner.hash, block_number: from_internal_transaction_block.number) + + to_internal_transaction_block = insert(:block) + + to_internal_transaction_transaction = + :transaction + |> insert() + |> with_block(to_internal_transaction_block) + + insert( + :internal_transaction_create, + index: 0, + to_address: miner, + transaction: to_internal_transaction_transaction, + transaction_index: to_internal_transaction_transaction.index, + block_number: to_internal_transaction_transaction.block_number + ) + + insert(:unfetched_balance, address_hash: miner.hash, block_number: to_internal_transaction_block.number) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + balance_fields_list_by_address_hash = Enum.group_by(balance_fields_list, & &1.address_hash) + + assert balance_fields_list_by_address_hash[miner.hash] |> Enum.map(& &1.block_number) |> Enum.sort() == + Enum.sort([ + to_internal_transaction_block.number, + from_internal_transaction_block.number, + log_block.number, + to_transaction_block.number, + from_transaction_block.number, + mined_block.number + ]) + end + + test "an address_hash used for the same block_number is only returned once" do + miner = insert(:address) + block = insert(:block, miner: miner) + + insert(:unfetched_balance, address_hash: miner.hash, block_number: block.number) + + :transaction + |> insert(from_address: miner) + |> with_block(block) + + :transaction + |> insert(to_address: miner) + |> with_block(block) + + log_transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:log, address: miner, transaction: log_transaction, block: block, block_number: block.number) + + from_internal_transaction_transaction = + :transaction + |> insert() + |> with_block(block) + + insert( + :internal_transaction_create, + from_address: miner, + index: 0, + transaction: from_internal_transaction_transaction, + block_number: from_internal_transaction_transaction.block_number, + transaction_index: from_internal_transaction_transaction.index + ) + + to_internal_transaction_transaction = + :transaction + |> insert() + |> with_block(block) + + insert( + :internal_transaction_create, + to_address: miner, + index: 0, + transaction: to_internal_transaction_transaction, + block_number: to_internal_transaction_transaction.block_number, + transaction_index: to_internal_transaction_transaction.index + ) + + {:ok, balance_fields_list} = + CoinBalance.stream_unfetched_balances( + [], + fn balance_fields, acc -> [balance_fields | acc] end + ) + + balance_fields_list_by_address_hash = Enum.group_by(balance_fields_list, & &1.address_hash) + + assert balance_fields_list_by_address_hash[miner.hash] |> Enum.map(& &1.block_number) |> Enum.sort() == [ + block.number + ] + end + end end diff --git a/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs index 6ca3bd6f0092..874139efe3b1 100644 --- a/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs +++ b/apps/explorer/test/explorer/chain/address/current_token_balance_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.CurrentTokenBalanceTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/address/token_balance_test.exs b/apps/explorer/test/explorer/chain/address/token_balance_test.exs index 3e1a8018289a..9749c8b5d769 100644 --- a/apps/explorer/test/explorer/chain/address/token_balance_test.exs +++ b/apps/explorer/test/explorer/chain/address/token_balance_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.TokenBalanceTest do use Explorer.DataCase @@ -119,4 +120,15 @@ defmodule Explorer.Chain.Address.TokenBalanceTest do assert(result.value == token_balance_b.value) end end + + describe "stream_unfetched_token_balances/2" do + test "executes the given reducer with the query result" do + address = insert(:address, hash: "0xc45e4830dff873cf8b70de2b194d0ddd06ef651e") + token_balance = insert(:token_balance, value_fetched_at: nil, address: address) + insert(:token_balance) + + assert TokenBalance.stream_unfetched_token_balances([], &[&1.block_number | &2]) == + {:ok, [token_balance.block_number]} + end + end end diff --git a/apps/explorer/test/explorer/chain/address/token_test.exs b/apps/explorer/test/explorer/chain/address/token_test.exs index 501afcc77eda..852edaf31a2d 100644 --- a/apps/explorer/test/explorer/chain/address/token_test.exs +++ b/apps/explorer/test/explorer/chain/address/token_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.TokenTest do use Explorer.DataCase @@ -292,6 +293,15 @@ defmodule Explorer.Chain.Address.TokenTest do end describe "page_tokens/2" do + setup do + initial_value = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + + on_exit(fn -> + :persistent_term.put(:market_token_fetcher_enabled, initial_value) + end) + end + test "just bring the normal query when PagingOptions.key is nil" do options = %PagingOptions{key: nil} diff --git a/apps/explorer/test/explorer/chain/address_test.exs b/apps/explorer/test/explorer/chain/address_test.exs index 75af9de50b9e..f0853878fcd6 100644 --- a/apps/explorer/test/explorer/chain/address_test.exs +++ b/apps/explorer/test/explorer/chain/address_test.exs @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.AddressTest do use Explorer.DataCase import Mox alias Explorer.Chain.Address - alias Explorer.Repo + alias Explorer.{Factory, Repo} setup :verify_on_exit! @@ -129,4 +130,47 @@ defmodule Explorer.Chain.AddressTest do # assert tail_result == expected_tail # end end + + describe "find_contract_address/1" do + test "doesn't find an address that doesn't have a code" do + address = insert(:address, contract_code: nil) + + response = Address.find_contract_address(address.hash) + + assert {:error, :not_found} == response + end + + test "doesn't find a nonexistent address" do + nonexistent_address_hash = Factory.address_hash() + + response = Address.find_contract_address(nonexistent_address_hash) + + assert {:error, :not_found} == response + end + + test "finds a contract address" do + address = + insert(:address, contract_code: Factory.data("contract_code"), smart_contract: nil, names: []) + |> Repo.preload( + [ + :token, + [smart_contract: :smart_contract_additional_sources], + Explorer.Chain.SmartContract.Proxy.Models.Implementation.proxy_implementations_association() + ] ++ [Address.contract_creation_transaction_association()] + ) + + options = [ + necessity_by_association: %{ + :names => :optional, + :smart_contract => :optional, + :token => :optional, + Address.contract_creation_transaction_association() => :optional + } + ] + + response = Address.find_contract_address(address.hash, options) + + assert response == {:ok, address} + end + end end diff --git a/apps/explorer/test/explorer/chain/beacon/deposit_test.exs b/apps/explorer/test/explorer/chain/beacon/deposit_test.exs new file mode 100644 index 000000000000..a9b490a1606f --- /dev/null +++ b/apps/explorer/test/explorer/chain/beacon/deposit_test.exs @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Beacon.DepositTest do + use Explorer.DataCase + + alias Explorer.Chain.Beacon.Deposit + + describe "get_logs_with_deposits/4" do + @deposit_event_signature "0x649BBC62D0E31342AFEA4E5CD82D4049E7E1EE912FC0889AA790803BE39038C5" + + test "filter out reorged blocks" do + deposit_contract = insert(:address) + reorg = insert(:block, consensus: false) + actual_block = insert(:block, consensus: true, number: reorg.number) + transaction = insert(:transaction) |> with_block(actual_block) + + _reorged_logs = [ + insert(:log, + address: deposit_contract, + block: reorg, + transaction: transaction, + first_topic: @deposit_event_signature + ), + insert(:log, + address: deposit_contract, + block: reorg, + transaction: transaction, + first_topic: @deposit_event_signature + ) + ] + + actual_logs = [ + insert(:log, + address: deposit_contract, + block: actual_block, + transaction: transaction, + first_topic: @deposit_event_signature + ), + insert(:log, + address: deposit_contract, + block: actual_block, + transaction: transaction, + first_topic: @deposit_event_signature + ) + ] + + assert actual_logs |> Enum.map(&{&1.transaction_hash, &1.block_hash, &1.block_number, &1.data, &1.index}) == + Deposit.get_logs_with_deposits( + deposit_contract.hash, + -1, + -1, + 10 + ) + |> Enum.map(&{&1.transaction_hash, &1.block_hash, &1.block_number, &1.data, &1.index}) + end + end +end diff --git a/apps/explorer/test/explorer/chain/beacon/reader_test.exs b/apps/explorer/test/explorer/chain/beacon/reader_test.exs index 2a8abb6ccaae..c65a77200b5c 100644 --- a/apps/explorer/test/explorer/chain/beacon/reader_test.exs +++ b/apps/explorer/test/explorer/chain/beacon/reader_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Beacon.ReaderTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/block/range_test.exs b/apps/explorer/test/explorer/chain/block/range_test.exs index 8ffc612c4ce4..f55649378582 100644 --- a/apps/explorer/test/explorer/chain/block/range_test.exs +++ b/apps/explorer/test/explorer/chain/block/range_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.RangeTest do use ExUnit.Case diff --git a/apps/explorer/test/explorer/chain/block/reader/general_test.exs b/apps/explorer/test/explorer/chain/block/reader/general_test.exs index 0f78e9aba3eb..2d1737d49f37 100644 --- a/apps/explorer/test/explorer/chain/block/reader/general_test.exs +++ b/apps/explorer/test/explorer/chain/block/reader/general_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.Reader.GeneralTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/block/second_degree_relation_test.exs b/apps/explorer/test/explorer/chain/block/second_degree_relation_test.exs index 1059b061d6e9..f476b412ddc0 100644 --- a/apps/explorer/test/explorer/chain/block/second_degree_relation_test.exs +++ b/apps/explorer/test/explorer/chain/block/second_degree_relation_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Block.SecondDegreeRelationTest do use Explorer.DataCase, async: true diff --git a/apps/explorer/test/explorer/chain/block_test.exs b/apps/explorer/test/explorer/chain/block_test.exs index 932d3441cbd8..fd271313cc4e 100644 --- a/apps/explorer/test/explorer/chain/block_test.exs +++ b/apps/explorer/test/explorer/chain/block_test.exs @@ -1,8 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.BlockTest do use Explorer.DataCase alias Ecto.Changeset - alias Explorer.Chain.{Block, Wei} + alias Explorer.Chain.{Address, Block, PendingBlockOperation, Wei} + alias Explorer.Chain.InternalTransaction.DeleteQueue, as: InternalTransactionDeleteQueue + alias Explorer.PagingOptions describe "changeset/2" do test "with valid attributes" do @@ -143,4 +146,319 @@ defmodule Explorer.Chain.BlockTest do assert Block.next_block_base_fee_per_gas() == Decimal.new(1100) end end + + describe "block_combined_rewards/1" do + test "sums the block_rewards values" do + block = insert(:block) + + insert( + :reward, + address_hash: block.miner_hash, + block_hash: block.hash, + address_type: :validator, + reward: Decimal.new(1_000_000_000_000_000_000) + ) + + insert( + :reward, + address_hash: block.miner_hash, + block_hash: block.hash, + address_type: :emission_funds, + reward: Decimal.new(1_000_000_000_000_000_000) + ) + + insert( + :reward, + address_hash: block.miner_hash, + block_hash: block.hash, + address_type: :uncle, + reward: Decimal.new(1_000_000_000_000_000_000) + ) + + block = Repo.preload(block, :rewards) + + {:ok, expected_value} = Wei.cast(3_000_000_000_000_000_000) + + assert Block.block_combined_rewards(block) == expected_value + end + end + + describe "fetch_min_block_number/0" do + test "fetches min block numbers" do + for index <- 5..9 do + insert(:block, number: index) + Process.sleep(200) + end + + assert 5 = Block.fetch_min_block_number() + end + + test "fetches min when there are no blocks" do + assert 0 = Block.fetch_min_block_number() + end + end + + describe "fetch_max_block_number/0" do + test "fetches max block numbers" do + for index <- 5..9 do + insert(:block, number: index) + end + + assert 9 = Block.fetch_max_block_number() + end + + test "fetches max when there are no blocks" do + assert 0 = Block.fetch_max_block_number() + end + end + + describe "get_blocks_validated_by_address/2" do + test "returns nothing when there are no blocks" do + %Address{hash: address_hash} = insert(:address) + + assert [] = Block.get_blocks_validated_by_address(address_hash) + end + + test "returns the blocks validated by a specified address" do + %Address{hash: address_hash} = address = insert(:address) + another_address = insert(:address) + + block = insert(:block, miner: address, miner_hash: address.hash) + insert(:block, miner: another_address, miner_hash: another_address.hash) + + results = + address_hash + |> Block.get_blocks_validated_by_address() + |> Enum.map(& &1.hash) + + assert results == [block.hash] + end + + test "with blocks can be paginated" do + %Address{hash: address_hash} = address = insert(:address) + + first_page_block = insert(:block, miner: address, miner_hash: address.hash, number: 0) + second_page_block = insert(:block, miner: address, miner_hash: address.hash, number: 2) + + assert [first_page_block.number] == + [paging_options: %PagingOptions{key: {1}, page_size: 1}] + |> Block.get_blocks_validated_by_address(address_hash) + |> Enum.map(& &1.number) + |> Enum.reverse() + + assert [second_page_block.number] == + [paging_options: %PagingOptions{key: {3}, page_size: 1}] + |> Block.get_blocks_validated_by_address(address_hash) + |> Enum.map(& &1.number) + |> Enum.reverse() + end + end + + describe "stream_blocks_without_rewards/2" do + test "includes consensus blocks" do + %Block{hash: consensus_hash} = insert(:block, consensus: true) + + assert {:ok, [%Block{hash: ^consensus_hash}]} = Block.stream_blocks_without_rewards([], &[&1 | &2]) + end + + test "does not include consensus block that has a reward" do + %Block{hash: consensus_hash, miner_hash: miner_hash} = insert(:block, consensus: true) + insert(:reward, address_hash: miner_hash, block_hash: consensus_hash) + + assert {:ok, []} = Block.stream_blocks_without_rewards([], &[&1 | &2]) + end + + # https://github.com/poanetwork/blockscout/issues/1310 regression test + test "does not include non-consensus blocks" do + insert(:block, consensus: false) + + assert {:ok, []} = Block.stream_blocks_without_rewards([], &[&1 | &2]) + end + end + + describe "block_hash_by_number/1" do + test "without blocks returns empty map" do + assert Block.block_hash_by_number([]) == %{} + end + + test "with consensus block returns mapping" do + block = insert(:block) + + assert Block.block_hash_by_number([block.number]) == %{block.number => block.hash} + end + + test "with non-consensus block does not return mapping" do + block = insert(:block, consensus: false) + + assert Block.block_hash_by_number([block.number]) == %{} + end + end + + describe "stream_unfetched_uncles/2" do + test "does not return uncle hashes where t:Explorer.Chain.Block.SecondDegreeRelation.t/0 uncle_fetched_at is not nil" do + %Block.SecondDegreeRelation{nephew: %Block{}, nephew_hash: nephew_hash, index: index, uncle_hash: uncle_hash} = + insert(:block_second_degree_relation) + + assert {:ok, [%{nephew_hash: ^nephew_hash, index: ^index}]} = + Block.stream_unfetched_uncles([], &[&1 | &2]) + + query = from(bsdr in Block.SecondDegreeRelation, where: bsdr.uncle_hash == ^uncle_hash) + + assert {1, _} = Repo.update_all(query, set: [uncle_fetched_at: DateTime.utc_now()]) + + assert {:ok, []} = Block.stream_unfetched_uncles([], &[&1 | &2]) + end + end + + describe "remove_nonconsensus_blocks_from_pending_ops/0" do + test "removes pending ops for nonconsensus blocks" do + block = insert(:block) + insert(:pending_block_operation, block: block, block_number: block.number) + + nonconsensus_block = insert(:block, consensus: false) + insert(:pending_block_operation, block: nonconsensus_block, block_number: nonconsensus_block.number) + + config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) + + on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) + + :ok = Block.remove_nonconsensus_blocks_from_pending_ops() + + assert Repo.get(PendingBlockOperation, block.hash) + assert is_nil(Repo.get(PendingBlockOperation, nonconsensus_block.hash)) + end + + test "removes pending ops for nonconsensus blocks by block hashes" do + block = insert(:block) + insert(:pending_block_operation, block: block, block_number: block.number) + + nonconsensus_block = insert(:block, consensus: false) + insert(:pending_block_operation, block: nonconsensus_block, block_number: nonconsensus_block.number) + + nonconsensus_block1 = insert(:block, consensus: false) + insert(:pending_block_operation, block: nonconsensus_block1, block_number: nonconsensus_block1.number) + + config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) + + on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) + + :ok = Block.remove_nonconsensus_blocks_from_pending_ops([nonconsensus_block1.hash]) + + assert Repo.get(PendingBlockOperation, block.hash) + assert Repo.get(PendingBlockOperation, nonconsensus_block.hash) + assert is_nil(Repo.get(PendingBlockOperation, nonconsensus_block1.hash)) + end + end + + describe "gas_payment_by_block_hash/1" do + setup do + number = 1 + + block = insert(:block, number: number, consensus: true) + + %{consensus_block: block, number: number} + end + + test "without consensus block hash has key with 0 value", %{consensus_block: consensus_block, number: number} do + non_consensus_block = insert(:block, number: number, consensus: false) + + :transaction + |> insert(gas_price: 1, block_consensus: false) + |> with_block(consensus_block, gas_used: 1) + + :transaction + |> insert(gas_price: 1, block_consensus: false) + |> with_block(consensus_block, gas_used: 2) + + assert Block.gas_payment_by_block_hash([non_consensus_block.hash]) == %{ + non_consensus_block.hash => %Wei{value: Decimal.new(0)} + } + end + + test "with consensus block hash without transactions has key with 0 value", %{ + consensus_block: %Block{hash: consensus_block_hash} + } do + assert Block.gas_payment_by_block_hash([consensus_block_hash]) == %{ + consensus_block_hash => %Wei{value: Decimal.new(0)} + } + end + + test "with consensus block hash with transactions has key with value", %{ + consensus_block: %Block{hash: consensus_block_hash} = consensus_block + } do + :transaction + |> insert(gas_price: 1) + |> with_block(consensus_block, gas_used: 2) + + :transaction + |> insert(gas_price: 3) + |> with_block(consensus_block, gas_used: 4) + + assert Block.gas_payment_by_block_hash([consensus_block_hash]) == %{ + consensus_block_hash => %Wei{value: Decimal.new(14)} + } + end + end + + describe "full_refetch/1" do + test "with block numbers" do + Enum.each(1..5, fn _i -> + insert(:block) + end) + + blocks = Repo.all(Block) + + assert Enum.all?(blocks, &(&1.refetch_needed == false)) + assert [] = Repo.all(InternalTransactionDeleteQueue) + + block_numbers = Enum.map(blocks, & &1.number) + + Block.full_refetch(block_numbers) + + assert Enum.all?(Repo.all(Block), &(&1.refetch_needed == true)) + + delete_queue_entries = Repo.all(InternalTransactionDeleteQueue) + + assert Enum.count(delete_queue_entries) == 5 + assert Enum.map(delete_queue_entries, & &1.block_number) -- block_numbers == [] + end + + test "with block ranges" do + Enum.each(1..5, fn i -> + insert(:block, number: i) + end) + + blocks = Repo.all(Block) + + assert Enum.all?(blocks, &(&1.refetch_needed == false)) + assert [] = Repo.all(InternalTransactionDeleteQueue) + + Block.full_refetch("1..5") + + assert Enum.all?(Repo.all(Block), &(&1.refetch_needed == true)) + + delete_queue_entries = Repo.all(InternalTransactionDeleteQueue) + + assert Enum.count(delete_queue_entries) == 5 + assert Enum.map(delete_queue_entries, & &1.block_number) -- Enum.to_list(1..5) == [] + end + + test "with single block number" do + insert(:block) + + [block] = Repo.all(Block) + block_number = block.number + + assert block.refetch_needed == false + assert [] = Repo.all(InternalTransactionDeleteQueue) + + Block.full_refetch(block.number) + + assert Repo.one(Block).refetch_needed == true + + assert [%{block_number: ^block_number}] = Repo.all(InternalTransactionDeleteQueue) + end + end end diff --git a/apps/explorer/test/explorer/chain/cache/accounts_test.exs b/apps/explorer/test/explorer/chain/cache/accounts_test.exs index a1a49bd67b92..77e4561e3cd3 100644 --- a/apps/explorer/test/explorer/chain/cache/accounts_test.exs +++ b/apps/explorer/test/explorer/chain/cache/accounts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.AccountsTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/block_number_test.exs b/apps/explorer/test/explorer/chain/cache/block_number_test.exs index 87107fe45cec..7ac332e878e4 100644 --- a/apps/explorer/test/explorer/chain/cache/block_number_test.exs +++ b/apps/explorer/test/explorer/chain/cache/block_number_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.BlockNumberTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/blocks_test.exs b/apps/explorer/test/explorer/chain/cache/blocks_test.exs index 41fe344a1e3d..358bd7548443 100644 --- a/apps/explorer/test/explorer/chain/cache/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/cache/blocks_test.exs @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.BlocksTest do use Explorer.DataCase + alias Explorer.Chain.Block alias Explorer.Chain.Cache.Blocks alias Explorer.Repo @@ -16,7 +18,7 @@ defmodule Explorer.Chain.Cache.BlocksTest do Blocks.update(block) - assert Blocks.all() == [block] + assert Blocks.all() == [block |> Block.aggregate_transactions() |> Map.put(:transactions, [])] end test "adds a new elements removing the oldest one" do diff --git a/apps/explorer/test/explorer/chain/cache/celo_core_contracts_test.exs b/apps/explorer/test/explorer/chain/cache/celo_core_contracts_test.exs index 6a3284e46218..4da00718e5c9 100644 --- a/apps/explorer/test/explorer/chain/cache/celo_core_contracts_test.exs +++ b/apps/explorer/test/explorer/chain/cache/celo_core_contracts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.CeloCoreContractsTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/cache/counters/addresses_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/addresses_count_test.exs index 35b2058e176e..41900380bc53 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/addresses_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/addresses_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressesCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_minus_burnt.exs b/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_minus_burnt.exs index 8bbd06836b83..f1b6e3076bf0 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_minus_burnt.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_minus_burnt.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressesCoinBalanceSumMinusBurntTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_test.exs b/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_test.exs index 163361c32bb8..324873b0daad 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/addresses_sum_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressSumTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/addresses_tokens_usd_sum_counter_test.exs b/apps/explorer/test/explorer/chain/cache/counters/addresses_tokens_usd_sum_counter_test.exs index 6a22aa54a071..6fb31f11301f 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/addresses_tokens_usd_sum_counter_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/addresses_tokens_usd_sum_counter_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AddressTokensUsdSumTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/average_block_time_test.exs b/apps/explorer/test/explorer/chain/cache/counters/average_block_time_test.exs index ce7a9ee18956..0b3eed884ebd 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/average_block_time_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/average_block_time_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.AverageBlockTimeTest do use Explorer.DataCase @@ -183,5 +184,38 @@ defmodule Explorer.Chain.Cache.Counters.AverageBlockTimeTest do assert Timex.Duration.to_milliseconds(AverageBlockTime.average_block_time()) == 3000 end + + test "handles sub-second blocks" do + old_envs = Application.get_env(:explorer, AverageBlockTime) + Application.put_env(:explorer, AverageBlockTime, Keyword.merge(old_envs, num_of_blocks: 101)) + + on_exit(fn -> + Application.put_env(:explorer, AverageBlockTime, old_envs) + end) + + block_number = 99_999_999 + + first_timestamp = Timex.now() + + Enum.each(1..206//2, fn i -> + insert(:block, + number: block_number + i, + consensus: true, + timestamp: Timex.shift(first_timestamp, seconds: div(i + 1, 2)) + ) + + insert(:block, + number: block_number + i + 1, + consensus: true, + timestamp: Timex.shift(first_timestamp, seconds: div(i + 1, 2)) + ) + end) + + AverageBlockTime.refresh() + + avg_time = Timex.Duration.to_milliseconds(AverageBlockTime.average_block_time()) + + assert avg_time == 500 + end end end diff --git a/apps/explorer/test/explorer/chain/cache/counters/blocks_test.exs b/apps/explorer/test/explorer/chain/cache/counters/blocks_test.exs index 85b0dfe6fc30..5045397fda7b 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/blocks_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.BlocksTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/contracts_test.exs b/apps/explorer/test/explorer/chain/cache/counters/contracts_test.exs index 1946e2b0c78f..e012ba3997a4 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/contracts_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/contracts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.ContractsTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/new_contracts_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/new_contracts_count_test.exs index a740ae577e29..d12bad32ca08 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/new_contracts_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/new_contracts_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.NewContractsCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/new_pending_transactions_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/new_pending_transactions_count_test.exs index cf57ddc6aad7..2b5159b4abbd 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/new_pending_transactions_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/new_pending_transactions_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.NewPendingTransactionsCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/new_verified_contracts_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/new_verified_contracts_count_test.exs index 11c54033939e..d2db47df9086 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/new_verified_contracts_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/new_verified_contracts_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.NewVerifiedContractsCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/optimism/last_output_root_size_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/optimism/last_output_root_size_count_test.exs index fe5716cf09d9..6aae67bd2891 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/optimism/last_output_root_size_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/optimism/last_output_root_size_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Optimism.LastOutputRootSizeCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/rootstock/rootstock_locked_btc_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/rootstock/rootstock_locked_btc_count_test.exs index 08e69bc89334..ed431677faef 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/rootstock/rootstock_locked_btc_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/rootstock/rootstock_locked_btc_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Rootstock.LockedBTCCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/counters/transactions_24h_count_test.exs b/apps/explorer/test/explorer/chain/cache/counters/transactions_24h_count_test.exs index 042ea2c818bc..415905f04bb6 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/transactions_24h_count_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/transactions_24h_count_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.Transactions24hCountTest do use Explorer.DataCase @@ -50,11 +51,11 @@ defmodule Explorer.Chain.Cache.Counters.Transactions24hCountTest do start_supervised!(Transactions24hCount) Transactions24hCount.consolidate() - transaction_count = Transactions24hCount.fetch_count([]) + transactions_count = Transactions24hCount.fetch_count([]) transaction_fee_sum = Transactions24hCount.fetch_fee_sum([]) transaction_fee_average = Transactions24hCount.fetch_fee_average([]) - assert transaction_count == Decimal.new("3") + assert transactions_count == Decimal.new("3") assert transaction_fee_sum == Decimal.new("35000") assert transaction_fee_average == Decimal.new("11667") end diff --git a/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs b/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs index d3ed4244e9f3..0bc082af09bf 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/transactions_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.TransactionsTest do use Explorer.DataCase alias Explorer.Chain.Cache.Counters.TransactionsCount diff --git a/apps/explorer/test/explorer/chain/cache/counters/verified_contracts_test.exs b/apps/explorer/test/explorer/chain/cache/counters/verified_contracts_test.exs index d5aa381ec08c..407c72faa32d 100644 --- a/apps/explorer/test/explorer/chain/cache/counters/verified_contracts_test.exs +++ b/apps/explorer/test/explorer/chain/cache/counters/verified_contracts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.Counters.VerifiedContractsCountTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/cache/gas_price_oracle_test.exs b/apps/explorer/test/explorer/chain/cache/gas_price_oracle_test.exs index 59c14b1847ff..9156ab096c14 100644 --- a/apps/explorer/test/explorer/chain/cache/gas_price_oracle_test.exs +++ b/apps/explorer/test/explorer/chain/cache/gas_price_oracle_test.exs @@ -1,6 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.GasPriceOracleTest do use Explorer.DataCase, async: false + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + alias Explorer.Chain.Cache.GasPriceOracle alias Explorer.Chain.Wei alias Explorer.Chain.Cache.Counters.AverageBlockTime @@ -353,7 +357,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do gas_price: 1_000_000_000, max_priority_fee_per_gas: 1_000_000_000, max_fee_per_gas: 1_000_000_000, - hash: "0xac2a7dab94d965893199e7ee01649e2d66f0787a4c558b3118c09e80d4df8269" + hash: "0xac2a7dab94d965893199e7ee01649e2d66f0787a4c558b3118c09e80d4df8269", + type: 2 ) :transaction @@ -367,7 +372,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do gas_price: 1_000_000_000, max_priority_fee_per_gas: 1_000_000_000, max_fee_per_gas: 1_000_000_000, - hash: "0x5d5c2776f96704e7845f7d3c1fbba6685ab6efd6f82b6cd11d549f3b3a46bd03" + hash: "0x5d5c2776f96704e7845f7d3c1fbba6685ab6efd6f82b6cd11d549f3b3a46bd03", + type: 2 ) :transaction @@ -381,7 +387,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do gas_price: 3_000_000_000, max_priority_fee_per_gas: 3_000_000_000, max_fee_per_gas: 3_000_000_000, - hash: "0x906b80861b4a0921acfbb91a7b527227b0d32adabc88bc73e8c52ff714e55016" + hash: "0x906b80861b4a0921acfbb91a7b527227b0d32adabc88bc73e8c52ff714e55016", + type: 2 ) assert {{:ok, @@ -423,7 +430,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do max_priority_fee_per_gas: 1_000_000_000, max_fee_per_gas: 1_000_000_000, hash: "0xac2a7dab94d965893199e7ee01649e2d66f0787a4c558b3118c09e80d4df8269", - earliest_processing_start: ~U[2023-12-12 12:12:00.000000Z] + earliest_processing_start: ~U[2023-12-12 12:12:00.000000Z], + type: 2 ) :transaction @@ -439,7 +447,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do max_priority_fee_per_gas: 1_000_000_000, max_fee_per_gas: 1_000_000_000, hash: "0x5d5c2776f96704e7845f7d3c1fbba6685ab6efd6f82b6cd11d549f3b3a46bd03", - earliest_processing_start: ~U[2023-12-12 12:12:00.000000Z] + earliest_processing_start: ~U[2023-12-12 12:12:00.000000Z], + type: 2 ) :transaction @@ -455,7 +464,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do max_priority_fee_per_gas: 3_000_000_000, max_fee_per_gas: 3_000_000_000, hash: "0x906b80861b4a0921acfbb91a7b527227b0d32adabc88bc73e8c52ff714e55016", - earliest_processing_start: ~U[2023-12-12 12:12:55.000000Z] + earliest_processing_start: ~U[2023-12-12 12:12:55.000000Z], + type: 2 ) assert {{ @@ -527,7 +537,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do gas_price: 1_000_000_000, max_priority_fee_per_gas: 1_000_000_000, max_fee_per_gas: 1_000_000_000, - hash: "0xac2a7dab94d965893199e7ee01649e2d66f0787a4c558b3118c09e80d4df8269" + hash: "0xac2a7dab94d965893199e7ee01649e2d66f0787a4c558b3118c09e80d4df8269", + type: 2 ) :transaction @@ -541,7 +552,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do gas_price: 1_000_000_000, max_priority_fee_per_gas: 1_000_000_000, max_fee_per_gas: 1_000_000_000, - hash: "0x5d5c2776f96704e7845f7d3c1fbba6685ab6efd6f82b6cd11d549f3b3a46bd03" + hash: "0x5d5c2776f96704e7845f7d3c1fbba6685ab6efd6f82b6cd11d549f3b3a46bd03", + type: 2 ) :transaction @@ -555,7 +567,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do gas_price: 3_000_000_000, max_priority_fee_per_gas: 3_000_000_000, max_fee_per_gas: 3_000_000_000, - hash: "0x906b80861b4a0921acfbb91a7b527227b0d32adabc88bc73e8c52ff714e55016" + hash: "0x906b80861b4a0921acfbb91a7b527227b0d32adabc88bc73e8c52ff714e55016", + type: 2 ) AverageBlockTime.refresh() @@ -595,7 +608,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do index: 0, gas_price: 1_000_000_000, max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 1_000_000_000 + max_fee_per_gas: 1_000_000_000, + type: 2 ) :transaction @@ -608,7 +622,8 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do index: 0, gas_price: 1_000_000_000, max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 1_000_000_000 + max_fee_per_gas: 1_000_000_000, + type: 2 ) :transaction @@ -640,5 +655,57 @@ defmodule Explorer.Chain.Cache.GasPriceOracleTest do assert {{:ok, %{average: nil, fast: nil, slow: nil}}, _} = GasPriceOracle.get_average_gas_price(3, 35, 60, 90) end + + test "does take into account EIP_1559_BASE_FEE_LOWER_BOUND_WEI env" do + old_config = Application.get_env(:explorer, :base_fee_lower_bound) + + Application.put_env(:explorer, :base_fee_lower_bound, 1_000_000_000) + + on_exit(fn -> + Application.put_env(:explorer, :base_fee_lower_bound, old_config) + end) + + insert(:block, + number: 1, + base_fee_per_gas: Wei.from(Decimal.new(1), :gwei), + gas_used: Decimal.new(0), + gas_limit: Decimal.new(1000) + ) + + assert {{:ok, %{average: %{price: 1.0}, fast: %{base_fee: 1.0}, slow: %{base_fee: 1.0}}}, _} = + GasPriceOracle.get_average_gas_price(1, 35, 60, 90) + end + + if @chain_identity == {:optimism, :celo} do + test "ignores transactions with unsupported types" do + block = + insert(:block, + number: 200, + hash: "0xfeedface00000000000000000000000000000000000000000000000000000000", + base_fee_per_gas: nil + ) + + :transaction + |> insert( + status: :ok, + block_hash: block.hash, + block_number: block.number, + index: 0, + # 1 Gwei + gas_price: 1_000_000_000, + type: 123, + hash: "0xcafe010000000000000000000000000000000000000000000000000000000000", + cumulative_gas_used: 884_322, + gas_used: 106_025 + ) + + assert {{:ok, + %{ + slow: nil, + average: nil, + fast: nil + }}, []} = GasPriceOracle.get_average_gas_price(1, 35, 60, 90) + end + end end end diff --git a/apps/explorer/test/explorer/chain/cache/transactions_test.exs b/apps/explorer/test/explorer/chain/cache/transactions_test.exs index 8a768b7daef3..0ebd8dfb831c 100644 --- a/apps/explorer/test/explorer/chain/cache/transactions_test.exs +++ b/apps/explorer/test/explorer/chain/cache/transactions_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.TransactionsTest do use Explorer.DataCase @@ -6,6 +7,13 @@ defmodule Explorer.Chain.Cache.TransactionsTest do @size 51 + setup do + old_value = Application.get_env(:explorer, :mode) + Application.put_env(:explorer, :mode, :all) + on_exit(fn -> Application.put_env(:explorer, :mode, old_value) end) + :ok + end + describe "update/1" do test "adds a new value to a new cache with preloads" do transaction = insert(:transaction) |> preload_all() @@ -77,6 +85,33 @@ defmodule Explorer.Chain.Cache.TransactionsTest do end end + describe "indexer mode" do + setup do + old_mode = Application.get_env(:explorer, :mode) + Application.put_env(:explorer, :mode, :indexer) + + Supervisor.terminate_child(Explorer.Supervisor, Transactions.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Transactions.child_id()) + + on_exit(fn -> Application.put_env(:explorer, :mode, old_mode) end) + + :ok + end + + test "update/1 writes to local ConCache without connected API nodes" do + assert Explorer.mode() == :indexer + # Single-node test env: propagation multicasts to an empty list, so only the + # local-first write in do_raw_update/2 populates the cache. + assert Node.list() == [] + + transaction = insert(:transaction) |> preload_all() + + Transactions.update(transaction) + + assert Transactions.take(1) == [transaction] + end + end + defp preload_all(transactions) when is_list(transactions) do Enum.map(transactions, &preload_all(&1)) end @@ -86,10 +121,7 @@ defmodule Explorer.Chain.Cache.TransactionsTest do :block, created_contract_address: :names, from_address: :names, - to_address: :names, - token_transfers: :token, - token_transfers: :from_address, - token_transfers: :to_address + to_address: :names ]) end end diff --git a/apps/explorer/test/explorer/chain/cache/uncles_test.exs b/apps/explorer/test/explorer/chain/cache/uncles_test.exs index 7b89718e752e..7e972b107047 100644 --- a/apps/explorer/test/explorer/chain/cache/uncles_test.exs +++ b/apps/explorer/test/explorer/chain/cache/uncles_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Cache.UnclesTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/celo/helper_test.exs b/apps/explorer/test/explorer/chain/celo/helper_test.exs index f4062cd6ad26..2612316b999b 100644 --- a/apps/explorer/test/explorer/chain/celo/helper_test.exs +++ b/apps/explorer/test/explorer/chain/celo/helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Celo.HelperTest do use ExUnit.Case, async: true doctest Explorer.Chain.Celo.Helper, import: true diff --git a/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs b/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs index 0c148cf3e77d..5a5cd0bfd089 100644 --- a/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/address/logs_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.LogsTest do use Explorer.DataCase @@ -44,7 +45,7 @@ defmodule Explorer.Chain.Address.LogsTest do [result] = address.hash - |> AddressLogsCsvExporter.export(from_period, to_period, []) + |> AddressLogsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) |> Enum.map(fn [ @@ -122,7 +123,7 @@ defmodule Explorer.Chain.Address.LogsTest do result = address.hash - |> AddressLogsCsvExporter.export(from_period, to_period, []) + |> AddressLogsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) diff --git a/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs b/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs index 3e23825981c8..1f4e8ca41291 100644 --- a/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/address/token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.TokenTransfersTest do use Explorer.DataCase @@ -24,7 +25,7 @@ defmodule Explorer.Chain.Address.TokenTransfersTest do [result] = address.hash - |> AddressTokenTransfersCsvExporter.export(from_period, to_period, []) + |> AddressTokenTransfersCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) |> Enum.map(fn [ @@ -125,7 +126,7 @@ defmodule Explorer.Chain.Address.TokenTransfersTest do result = address.hash - |> AddressTokenTransfersCsvExporter.export(from_period, to_period, []) + |> AddressTokenTransfersCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) diff --git a/apps/explorer/test/explorer/chain/csv_export/address/transactions_test.exs b/apps/explorer/test/explorer/chain/csv_export/address/transactions_test.exs index f1cdd8c968ba..27a0c9c6ccb2 100644 --- a/apps/explorer/test/explorer/chain/csv_export/address/transactions_test.exs +++ b/apps/explorer/test/explorer/chain/csv_export/address/transactions_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Address.TransactionsTest do use Explorer.DataCase @@ -46,7 +47,7 @@ defmodule Explorer.Chain.Address.TransactionsTest do [result] = address.hash - |> AddressTransactionsCsvExporter.export(from_period, to_period, []) + |> AddressTransactionsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) |> Enum.map(fn [ @@ -151,7 +152,7 @@ defmodule Explorer.Chain.Address.TransactionsTest do result = address.hash - |> AddressTransactionsCsvExporter.export(from_period, to_period, []) + |> AddressTransactionsCsvExporter.export(from_period, to_period, [], nil, nil) |> Enum.to_list() |> Enum.drop(1) diff --git a/apps/explorer/test/explorer/chain/csv_export/async_helper_test.exs b/apps/explorer/test/explorer/chain/csv_export/async_helper_test.exs new file mode 100644 index 000000000000..0419d178cc8c --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/async_helper_test.exs @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.AsyncHelperTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.CsvExport.{AsyncHelper, Request} + + setup do + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + + config = + (original_config || []) + |> Keyword.put(:tmp_dir, System.tmp_dir!() <> "/csv_export_async_test_#{:rand.uniform(100_000)}") + + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + + on_exit(fn -> + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + :ok + end + + describe "actualize_csv_export_request/1" do + test "returns request as-is when file_id is nil (pending)" do + {:ok, request} = + Request.create("127.0.0.1", %{ + address_hash: to_string(insert(:address).hash), + from_period: nil, + to_period: nil, + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions", + filter_type: nil, + filter_value: nil + }) + + assert actualized = AsyncHelper.actualize_csv_export_request(request) + assert actualized.id == request.id + assert actualized.file_id == nil + end + + test "returns nil when input is nil" do + assert nil == AsyncHelper.actualize_csv_export_request(nil) + end + end + + describe "stream_to_temp_file/2" do + test "writes stream content to temp file and returns file path" do + uuid = Ecto.UUID.generate() + stream = Stream.map(["a", "b", "c"], & &1) + + path = AsyncHelper.stream_to_temp_file(stream, uuid) + + assert path =~ "csv_export_#{uuid}.csv" + assert File.exists?(path) + assert File.read!(path) == "abc" + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/helper_test.exs b/apps/explorer/test/explorer/chain/csv_export/helper_test.exs new file mode 100644 index 000000000000..777c1f3299ae --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/helper_test.exs @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.HelperTest do + use Explorer.DataCase, async: true + + alias Explorer.Chain.CsvExport.Helper + + setup do + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + + Application.put_env(:explorer, Explorer.Chain.CsvExport, [ + {:async?, false} + | (original_config || []) |> Keyword.drop([:async?]) + ]) + + on_exit(fn -> + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + :ok + end + + describe "valid_filter?/3" do + test "returns true for valid address filter to" do + assert Helper.valid_filter?("address", "to", "transactions") == true + assert Helper.valid_filter?("address", "to", "token-transfers") == true + assert Helper.valid_filter?("address", "to", "internal-transactions") == true + end + + test "returns true for valid address filter from" do + assert Helper.valid_filter?("address", "from", "transactions") == true + end + + test "returns false for invalid filter type" do + assert Helper.valid_filter?("invalid", "to", "transactions") == false + end + + test "returns false for invalid filter value for address" do + assert Helper.valid_filter?("address", "invalid", "transactions") == false + end + + test "returns false for nil or empty filter value" do + refute Helper.valid_filter?("address", nil, "transactions") + refute Helper.valid_filter?("address", "", "transactions") + end + end + + describe "supported_filters/1" do + test "returns correct filters for each type" do + assert Helper.supported_filters("internal-transactions") == ["address"] + assert Helper.supported_filters("transactions") == ["address"] + assert Helper.supported_filters("token-transfers") == ["address"] + assert Helper.supported_filters("logs") == ["topic"] + assert Helper.supported_filters("unknown") == [] + end + end + + describe "async_enabled?/0" do + test "returns config value" do + Application.put_env(:explorer, Explorer.Chain.CsvExport, [async?: false] ++ []) + assert Helper.async_enabled?() == false + + Application.put_env(:explorer, Explorer.Chain.CsvExport, [async?: true] ++ []) + assert Helper.async_enabled?() == true + end + end + + describe "dump_to_stream/1" do + test "produces CSV-formatted stream" do + rows = [["a", "b", "c"], ["1", "2", "3"]] + stream = Helper.dump_to_stream(rows) + + result = stream |> Enum.to_list() |> Enum.join("") + assert result =~ "a,b,c" + assert result =~ "1,2,3" + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/request_test.exs b/apps/explorer/test/explorer/chain/csv_export/request_test.exs new file mode 100644 index 000000000000..06bd2dcf2bfd --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/request_test.exs @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.RequestTest do + use Explorer.DataCase, async: false + use Oban.Testing, repo: Explorer.Repo + + alias Explorer.Chain.CsvExport.Request + + setup do + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + + config = + (original_config || []) + |> Keyword.put(:max_pending_tasks_per_ip, 2) + |> Keyword.put(:async?, true) + + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + + on_exit(fn -> + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + :ok + end + + defp address_export_args do + address = insert(:address) + + %{ + address_hash: to_string(address.hash), + from_period: nil, + to_period: nil, + filter_type: nil, + filter_value: nil, + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions" + } + end + + describe "create/2" do + test "creates a request with pending status and enqueues Oban job" do + args = address_export_args() + assert {:ok, request} = Request.create("127.0.0.1", args) + + assert %Request{} = request + assert request.id != nil + assert request.status == :pending + assert request.file_id == nil + assert request.remote_ip_hash != nil + assert byte_size(request.remote_ip_hash) == 32 + + assert [job] = all_enqueued(worker: Explorer.Chain.CsvExport.Worker) + assert job.args["request_id"] == request.id + assert job.args["address_hash"] == args.address_hash + end + + test "allows up to max_pending_tasks_per_ip concurrent requests from same IP" do + args = address_export_args() + + assert {:ok, _} = Request.create("127.0.0.1", args) + assert {:ok, _} = Request.create("127.0.0.1", args) + + assert {:error, :too_many_pending_requests} = Request.create("127.0.0.1", args) + end + + test "returns too_many_pending_requests when limit is reached" do + args = address_export_args() + + Request.create("192.168.1.1", args) + Request.create("192.168.1.1", args) + + assert {:error, :too_many_pending_requests} = Request.create("192.168.1.1", args) + end + + test "allows new requests from different IPs" do + args = address_export_args() + + assert {:ok, _} = Request.create("127.0.0.1", args) + assert {:ok, _} = Request.create("127.0.0.1", args) + assert {:ok, _} = Request.create("10.0.0.1", args) + end + end + + describe "update_file_id_and_expires_at/3" do + test "sets file_id and transitions status to completed" do + {:ok, request} = Request.create("127.0.0.1", address_export_args()) + file_id = "gokapi-file-123" + + assert {1, nil} = + Request.update_file_id_and_expires_at( + request.id, + file_id, + DateTime.utc_now() |> DateTime.truncate(:second) + ) + + updated = Request.get_by_uuid(request.id) + assert updated.file_id == file_id + assert updated.status == :completed + assert updated.expires_at != nil + end + end + + describe "mark_failed/1" do + test "transitions status to failed" do + {:ok, request} = Request.create("127.0.0.1", address_export_args()) + + assert {1, nil} = Request.mark_failed(request.id) + + updated = Request.get_by_uuid(request.id) + assert updated.status == :failed + end + end + + describe "get_by_uuid/2" do + test "returns the request by UUID" do + {:ok, request} = Request.create("127.0.0.1", address_export_args()) + + found = Request.get_by_uuid(request.id) + assert found.id == request.id + end + + test "returns nil for non-existent UUID" do + uuid = Ecto.UUID.generate() + assert nil == Request.get_by_uuid(uuid) + end + end + + describe "delete/1" do + test "removes the request" do + {:ok, request} = Request.create("127.0.0.1", address_export_args()) + + assert {1, nil} = Request.delete(request.id) + assert nil == Request.get_by_uuid(request.id) + end + + test "returns {0, nil} for non-existent UUID" do + uuid = Ecto.UUID.generate() + assert {0, nil} = Request.delete(uuid) + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/requests_sanitizer_test.exs b/apps/explorer/test/explorer/chain/csv_export/requests_sanitizer_test.exs new file mode 100644 index 000000000000..030c7be667e9 --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/requests_sanitizer_test.exs @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.RequestsSanitizerTest do + use Explorer.DataCase, async: false + use Oban.Testing, repo: Explorer.Repo + + alias Explorer.Chain.CsvExport.{Request, RequestsSanitizer} + + setup do + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + + config = + (original_config || []) + |> Keyword.put(:max_pending_tasks_per_ip, 5) + |> Keyword.put(:async?, true) + |> Keyword.put(:gokapi_upload_expiry_days, 1) + + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + + on_exit(fn -> + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + :ok + end + + describe "perform/1" do + test "deletes completed requests older than gokapi_upload_expiry_days" do + {:ok, request1} = + Request.create("127.0.0.1", %{ + address_hash: to_string(insert(:address).hash), + from_period: nil, + to_period: nil, + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions", + filter_type: nil, + filter_value: nil + }) + + Request.update_file_id_and_expires_at(request1.id, "file-123", DateTime.utc_now() |> DateTime.truncate(:second)) + + past_time = DateTime.add(DateTime.utc_now(), -2, :day) + + Request + |> Ecto.Query.where([r], r.id == ^request1.id) + |> Explorer.Repo.update_all(set: [updated_at: past_time]) + + assert :ok = perform_job(RequestsSanitizer, %{}) + + assert nil == Request.get_by_uuid(request1.id) + end + + test "does not delete pending requests" do + {:ok, request} = + Request.create("127.0.0.1", %{ + address_hash: to_string(insert(:address).hash), + from_period: nil, + to_period: nil, + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions", + filter_type: nil, + filter_value: nil + }) + + assert :ok = perform_job(RequestsSanitizer, %{}) + + assert Request.get_by_uuid(request.id) != nil + end + + test "does not delete recently completed requests" do + {:ok, request} = + Request.create("127.0.0.1", %{ + address_hash: to_string(insert(:address).hash), + from_period: nil, + to_period: nil, + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions", + filter_type: nil, + filter_value: nil + }) + + Request.update_file_id_and_expires_at(request.id, "recent-file", DateTime.utc_now() |> DateTime.truncate(:second)) + + assert :ok = perform_job(RequestsSanitizer, %{}) + + assert Request.get_by_uuid(request.id) != nil + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs b/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs new file mode 100644 index 000000000000..430a967bd9fe --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/token/holders_test.exs @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.Token.HoldersTest do + use Explorer.DataCase + + alias Explorer.Chain.Address + alias Explorer.Chain.CsvExport.Token.Holders, as: TokenHoldersExporter + + setup do + original_limit = Application.get_env(:explorer, :csv_export_limit) + Application.put_env(:explorer, :csv_export_limit, 150) + + on_exit(fn -> + if original_limit do + Application.put_env(:explorer, :csv_export_limit, original_limit) + else + Application.delete_env(:explorer, :csv_export_limit) + end + end) + + :ok + end + + describe "export/6" do + test "exports token holders to csv with header and holder rows" do + token = insert(:token, type: "ERC-20", decimals: 18) + + holder1 = + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 100_000_000_000_000_000_000 + ) + + holder2 = + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 50_000_000_000_000_000_000 + ) + + csv_string = + token.contract_address_hash + |> TokenHoldersExporter.export("2020-01-01", "2025-12-31", [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [header | rows] = String.split(csv_string, "\r\n", trim: true) + assert header =~ "HolderAddress" + assert header =~ "Balance" + + assert length(rows) == 2 + + assert Enum.any?(rows, fn row -> + row =~ Address.checksum(holder1.address_hash) and row =~ "100" + end) + + assert Enum.any?(rows, fn row -> + row =~ Address.checksum(holder2.address_hash) and row =~ "50" + end) + end + + test "formats holder address as checksummed and balance with correct decimals" do + token = insert(:token, type: "ERC-20", decimals: 6) + + holder = + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 1_234_567_890 + ) + + csv_string = + token.contract_address_hash + |> TokenHoldersExporter.export("2020-01-01", "2025-12-31", [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [header | rows] = String.split(csv_string, "\r\n", trim: true) + + assert header =~ "HolderAddress" + assert header =~ "Balance" + + assert length(rows) == 1 + row = hd(rows) + assert row =~ Address.checksum(holder.address_hash) + assert row =~ "1234.56789" + end + + test "respects pagination with many holders" do + token = insert(:token, type: "ERC-20", decimals: 18) + + Enum.each(1..200, fn _ -> + insert(:address_current_token_balance, + token_contract_address_hash: token.contract_address_hash, + address: insert(:address), + value: 1_000_000_000_000_000_000 + ) + end) + + csv_string = + token.contract_address_hash + |> TokenHoldersExporter.export("2020-01-01", "2025-12-31", [], nil, nil) + |> Enum.to_list() + |> IO.iodata_to_binary() + + [header | rows] = String.split(csv_string, "\r\n", trim: true) + + assert header =~ "HolderAddress" + assert header =~ "Balance" + assert length(rows) == 150 + end + end +end diff --git a/apps/explorer/test/explorer/chain/csv_export/worker_test.exs b/apps/explorer/test/explorer/chain/csv_export/worker_test.exs new file mode 100644 index 000000000000..4b05c2706cba --- /dev/null +++ b/apps/explorer/test/explorer/chain/csv_export/worker_test.exs @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.CsvExport.WorkerTest do + use Explorer.DataCase, async: false + use Oban.Testing, repo: Explorer.Repo + + alias Explorer.Chain.CsvExport.{Request, Worker} + alias Plug.Conn + + setup do + bypass = Bypass.open() + original_config = Application.get_env(:explorer, Explorer.Chain.CsvExport) + original_tesla = Application.get_env(:tesla, :adapter) + + config = + (original_config || []) + |> Keyword.put(:max_pending_tasks_per_ip, 5) + |> Keyword.put(:async?, true) + |> Keyword.put(:tmp_dir, System.tmp_dir!() <> "/csv_export_test_#{:rand.uniform(100_000)}") + |> Keyword.put(:gokapi_url, "http://localhost:#{bypass.port}") + |> Keyword.put(:gokapi_api_key, "test-api-key") + |> Keyword.put(:chunk_size, 1024) + |> Keyword.put(:gokapi_upload_expiry_days, 1) + |> Keyword.put(:gokapi_upload_allowed_downloads, 1) + |> Keyword.put(:db_timeout, 60_000) + + Application.put_env(:explorer, Explorer.Chain.CsvExport, config) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Hackney) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, original_tesla) + + if original_config do + Application.put_env(:explorer, Explorer.Chain.CsvExport, original_config) + else + Application.delete_env(:explorer, Explorer.Chain.CsvExport) + end + end) + + {:ok, bypass: bypass} + end + + describe "perform/1 address-based export" do + test "processes address-based export and updates request on success", %{bypass: bypass} do + address = insert(:address) + + :transaction + |> insert(from_address: address) + |> with_block() + + args = %{ + address_hash: to_string(address.hash), + from_period: DateTime.utc_now() |> DateTime.to_iso8601(), + to_period: DateTime.utc_now() |> DateTime.to_iso8601(), + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions", + filter_type: nil, + filter_value: nil + } + + {:ok, request} = Request.create("127.0.0.1", args) + date_time_to_expect = DateTime.utc_now() |> DateTime.truncate(:second) + + Bypass.expect(bypass, fn conn -> + [path | _] = conn.request_path |> String.split("?") + + cond do + path == "/api/chunk/add" -> + Conn.resp(conn, 200, "") + + path == "/api/chunk/complete" -> + Conn.resp( + conn, + 200, + Jason.encode!(%{ + "FileInfo" => %{"Id" => "test-file-id", "ExpireAt" => date_time_to_expect |> DateTime.to_unix()} + }) + ) + + true -> + Conn.resp(conn, 404, "Not found") + end + end) + + job_args = %{ + "request_id" => request.id, + "address_hash" => args.address_hash, + "from_period" => DateTime.utc_now() |> DateTime.to_iso8601(), + "to_period" => DateTime.utc_now() |> DateTime.to_iso8601(), + "show_scam_tokens?" => nil, + "module" => args.module, + "filter_type" => nil, + "filter_value" => nil + } + + assert :ok = perform_job(Worker, job_args) + + updated = Request.get_by_uuid(request.id) + assert updated.status == :completed + assert updated.file_id == "test-file-id" + assert updated.expires_at == date_time_to_expect + end + end + + describe "perform/1 failure handling" do + test "marks request as failed when upload fails", %{bypass: bypass} do + address = insert(:address) + + :transaction + |> insert(from_address: address) + |> with_block() + + args = %{ + address_hash: to_string(address.hash), + from_period: DateTime.utc_now() |> DateTime.to_iso8601(), + to_period: DateTime.utc_now() |> DateTime.to_iso8601(), + show_scam_tokens?: nil, + module: "Elixir.Explorer.Chain.CsvExport.Address.Transactions", + filter_type: nil, + filter_value: nil + } + + {:ok, request} = Request.create("127.0.0.1", args) + + Bypass.expect(bypass, fn conn -> + [path | _] = conn.request_path |> String.split("?") + + cond do + path == "/api/chunk/add" -> + Conn.resp(conn, 200, "") + + path == "/api/chunk/complete" -> + Conn.resp(conn, 500, "Internal Server Error") + + true -> + Conn.resp(conn, 404, "") + end + end) + + job_args = %{ + "request_id" => request.id, + "address_hash" => args.address_hash, + "from_period" => DateTime.utc_now() |> DateTime.to_iso8601(), + "to_period" => DateTime.utc_now() |> DateTime.to_iso8601(), + "show_scam_tokens?" => nil, + "module" => args.module, + "filter_type" => nil, + "filter_value" => nil + } + + assert {:error, _} = perform_job(Worker, job_args) + + updated = Request.get_by_uuid(request.id) + assert updated.status == :failed + end + end +end diff --git a/apps/explorer/test/explorer/chain/currency_helper_text.exs b/apps/explorer/test/explorer/chain/currency_helper_text.exs index 8ce8282911d2..68829ad79b80 100644 --- a/apps/explorer/test/explorer/chain/currency_helper_text.exs +++ b/apps/explorer/test/explorer/chain/currency_helper_text.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.CurrencyHelperTest do use ExUnit.Case diff --git a/apps/explorer/test/explorer/chain/data_test.exs b/apps/explorer/test/explorer/chain/data_test.exs index 95691f4bf161..54e8f9b87aeb 100644 --- a/apps/explorer/test/explorer/chain/data_test.exs +++ b/apps/explorer/test/explorer/chain/data_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.DataTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/events/publisher_test.exs b/apps/explorer/test/explorer/chain/events/publisher_test.exs index 3a4822e66d35..1c03bdbf260b 100644 --- a/apps/explorer/test/explorer/chain/events/publisher_test.exs +++ b/apps/explorer/test/explorer/chain/events/publisher_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.PublisherTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/events/subscriber_test.exs b/apps/explorer/test/explorer/chain/events/subscriber_test.exs index b26841d0efec..7ce16984d86e 100644 --- a/apps/explorer/test/explorer/chain/events/subscriber_test.exs +++ b/apps/explorer/test/explorer/chain/events/subscriber_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Events.SubscriberTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/fhe/fhe_contract_checker_test.exs b/apps/explorer/test/explorer/chain/fhe/fhe_contract_checker_test.exs new file mode 100644 index 000000000000..76ba14b04313 --- /dev/null +++ b/apps/explorer/test/explorer/chain/fhe/fhe_contract_checker_test.exs @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FheContractCheckerTest do + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.{Address, FheContractChecker, Hash} + alias Explorer.Tags.{AddressTag, AddressToTag} + alias Explorer.Repo + + setup :verify_on_exit! + setup :set_mox_global + + describe "fhe_contract?/1" do + test "returns true for FHE contract with non-zero protocol ID" do + address_hash = build(:address).hash + + # Mock RPC response with non-zero protocol ID + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:ok, "0x0000000000000000000000000000000000000000000000000000000000000001"} + end) + + assert {:ok, true} = FheContractChecker.fhe_contract?(address_hash) + end + + test "returns false for non-FHE contract with zero protocol ID" do + address_hash = build(:address).hash + + # Mock RPC response with zero protocol ID + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:ok, "0x0000000000000000000000000000000000000000000000000000000000000000"} + end) + + assert {:ok, false} = FheContractChecker.fhe_contract?(address_hash) + end + + test "returns false for RPC error" do + address_hash = build(:address).hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:error, :timeout} + end) + + assert {:ok, false} = FheContractChecker.fhe_contract?(address_hash) + end + + test "returns false for invalid response format" do + address_hash = build(:address).hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:ok, []} + end) + + assert {:ok, false} = FheContractChecker.fhe_contract?(address_hash) + end + + test "handles string address hash" do + address_string = "0x" <> String.duplicate("1", 40) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:ok, "0x0000000000000000000000000000000000000000000000000000000000000000"} + end) + + assert {:ok, false} = FheContractChecker.fhe_contract?(address_string) + end + + test "returns error for invalid address string" do + assert {:error, :invalid_hash} = FheContractChecker.fhe_contract?("invalid") + end + end + + describe "already_checked?/2" do + test "returns true when address is already tagged as FHE" do + address = insert(:address) + tag = insert(:address_tag, label: "fhe", display_name: "FHE") + insert(:address_to_tag, tag: tag, address: address) + + assert true == FheContractChecker.already_checked?(address.hash, []) + end + + test "returns false when address is not tagged" do + address = insert(:address) + _tag = insert(:address_tag, label: "fhe", display_name: "FHE") + + assert false == FheContractChecker.already_checked?(address.hash, []) + end + + test "returns false when FHE tag does not exist" do + address = insert(:address) + + assert false == FheContractChecker.already_checked?(address.hash, []) + end + end + + describe "check_and_save_fhe_status/2" do + test "saves FHE tag when contract is FHE" do + address = insert(:address, contract_code: "0x6080604052") + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:ok, "0x0000000000000000000000000000000000000000000000000000000000000001"} + end) + + assert :ok = FheContractChecker.check_and_save_fhe_status(address.hash, []) + + # Verify tag was created + tag = Repo.get_by(AddressTag, label: "fhe") + assert tag != nil + assert tag.display_name == "FHE" + + # Verify address is tagged + tag_id = AddressTag.get_id_by_label("fhe") + + assert Repo.exists?( + from(att in AddressToTag, + where: att.address_hash == ^Hash.to_string(address.hash) and att.tag_id == ^tag_id + ) + ) + end + + test "returns :ok when contract is not FHE" do + address = insert(:address, contract_code: "0x6080604052") + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:ok, "0x0000000000000000000000000000000000000000000000000000000000000000"} + end) + + assert :ok = FheContractChecker.check_and_save_fhe_status(address.hash, []) + end + + test "returns :already_checked when address is already tagged" do + address = insert(:address, contract_code: "0x6080604052") + tag = insert(:address_tag, label: "fhe", display_name: "FHE") + insert(:address_to_tag, tag: tag, address: address) + + assert :already_checked = FheContractChecker.check_and_save_fhe_status(address.hash, []) + end + + test "returns :empty when address is not a contract" do + address = insert(:address, contract_code: nil) + + assert :empty = FheContractChecker.check_and_save_fhe_status(address.hash, []) + end + + test "returns :empty when address_hash is nil" do + assert :empty = FheContractChecker.check_and_save_fhe_status(nil, []) + end + + test "returns :ok when RPC call fails (treats as non-FHE to avoid retry loops)" do + address = insert(:address, contract_code: "0x6080604052") + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _request, _options -> + {:error, :timeout} + end) + + # The implementation treats RPC errors as "not FHE" (returns :ok) to avoid retry loops + assert :ok = FheContractChecker.check_and_save_fhe_status(address.hash, []) + end + end +end diff --git a/apps/explorer/test/explorer/chain/fhe/fhe_operation_test.exs b/apps/explorer/test/explorer/chain/fhe/fhe_operation_test.exs new file mode 100644 index 000000000000..76152b03606f --- /dev/null +++ b/apps/explorer/test/explorer/chain/fhe/fhe_operation_test.exs @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FheOperationTest do + use Explorer.DataCase + + alias Explorer.Chain.{Block, FheOperation, Hash, Transaction} + + describe "by_transaction_hash/1" do + test "returns empty list when no operations exist" do + transaction = insert(:transaction) |> with_block() + + assert [] == FheOperation.by_transaction_hash(transaction.hash) + end + + test "returns operations ordered by log_index" do + transaction = insert(:transaction) |> with_block() + block = transaction.block + + operation_3 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 3, + block_hash: block.hash, + block_number: block.number + ) + + operation_1 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: block.hash, + block_number: block.number + ) + + operation_2 = + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 2, + block_hash: block.hash, + block_number: block.number + ) + + operations = FheOperation.by_transaction_hash(transaction.hash) + + assert length(operations) == 3 + assert Enum.at(operations, 0).log_index == operation_1.log_index + assert Enum.at(operations, 1).log_index == operation_2.log_index + assert Enum.at(operations, 2).log_index == operation_3.log_index + end + + test "only returns operations for specified transaction" do + transaction_1 = insert(:transaction) |> with_block() + transaction_2 = insert(:transaction) |> with_block() + + insert(:fhe_operation, + transaction_hash: transaction_1.hash, + log_index: 1, + block_hash: transaction_1.block.hash, + block_number: transaction_1.block_number + ) + + insert(:fhe_operation, + transaction_hash: transaction_2.hash, + log_index: 1, + block_hash: transaction_2.block.hash, + block_number: transaction_2.block_number + ) + + operations = FheOperation.by_transaction_hash(transaction_1.hash) + + assert length(operations) == 1 + assert Enum.at(operations, 0).transaction_hash == transaction_1.hash + end + end + + describe "transaction_metrics/1" do + test "returns zero metrics when no operations exist" do + transaction = insert(:transaction) |> with_block() + + metrics = FheOperation.transaction_metrics(transaction.hash) + + assert metrics.operation_count == 0 + assert metrics.total_hcu == 0 + assert metrics.max_depth_hcu == 0 + end + + test "calculates correct metrics for single operation" do + transaction = insert(:transaction) |> with_block() + + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + hcu_cost: 100, + hcu_depth: 1 + ) + + metrics = FheOperation.transaction_metrics(transaction.hash) + + assert metrics.operation_count == 1 + assert metrics.total_hcu == 100 + assert metrics.max_depth_hcu == 1 + end + + test "calculates correct metrics for multiple operations" do + transaction = insert(:transaction) |> with_block() + + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + hcu_cost: 100, + hcu_depth: 1 + ) + + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 2, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + hcu_cost: 200, + hcu_depth: 3 + ) + + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 3, + block_hash: transaction.block.hash, + block_number: transaction.block_number, + hcu_cost: 150, + hcu_depth: 2 + ) + + metrics = FheOperation.transaction_metrics(transaction.hash) + + assert metrics.operation_count == 3 + assert metrics.total_hcu == 450 + assert metrics.max_depth_hcu == 3 + end + end +end diff --git a/apps/explorer/test/explorer/chain/fhe/fhe_operator_prices_test.exs b/apps/explorer/test/explorer/chain/fhe/fhe_operator_prices_test.exs new file mode 100644 index 000000000000..17ba13a2b467 --- /dev/null +++ b/apps/explorer/test/explorer/chain/fhe/fhe_operator_prices_test.exs @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.FheOperatorPricesTest do + use ExUnit.Case, async: true + + alias Explorer.Chain.FheOperatorPrices + + describe "get_price/3" do + test "returns correct price for scalar FheAdd operation" do + assert 84_000 == FheOperatorPrices.get_price("fheAdd", "Uint8", true) + assert 93_000 == FheOperatorPrices.get_price("fheAdd", "Uint16", true) + assert 95_000 == FheOperatorPrices.get_price("fheAdd", "Uint32", true) + end + + test "returns correct price for non-scalar FheAdd operation" do + assert 88_000 == FheOperatorPrices.get_price("fheAdd", "Uint8", false) + assert 93_000 == FheOperatorPrices.get_price("fheAdd", "Uint16", false) + assert 125_000 == FheOperatorPrices.get_price("fheAdd", "Uint32", false) + end + + test "returns correct price for FheMul operation" do + assert 122_000 == FheOperatorPrices.get_price("fheMul", "Uint8", true) + assert 150_000 == FheOperatorPrices.get_price("fheMul", "Uint8", false) + assert 1_686_000 == FheOperatorPrices.get_price("fheMul", "Uint128", false) + end + + test "returns correct price for FheDiv operation" do + assert 210_000 == FheOperatorPrices.get_price("fheDiv", "Uint8", true) + assert 1_225_000 == FheOperatorPrices.get_price("fheDiv", "Uint128", true) + end + + test "returns correct price for bitwise operations" do + assert 22_000 == FheOperatorPrices.get_price("fheBitAnd", "Bool", true) + assert 25_000 == FheOperatorPrices.get_price("fheBitAnd", "Bool", false) + assert 31_000 == FheOperatorPrices.get_price("fheBitAnd", "Uint8", true) + end + + test "returns 0 for unknown operation" do + assert 0 == FheOperatorPrices.get_price("unknown", "Uint8", true) + end + + test "returns 0 for unknown FHE type" do + assert 0 == FheOperatorPrices.get_price("fheAdd", "Unknown", true) + end + + test "handles operations that only have scalar prices" do + # Operations that only have scalar prices return scalar price regardless of is_scalar flag + assert 210_000 == FheOperatorPrices.get_price("fheDiv", "Uint8", true) + assert 210_000 == FheOperatorPrices.get_price("fheDiv", "Uint8", false) + assert 1_225_000 == FheOperatorPrices.get_price("fheDiv", "Uint128", false) + end + + test "handles operations with types structure (fheRand)" do + assert 19_000 == FheOperatorPrices.get_price("fheRand", "Bool", false) + assert 23_000 == FheOperatorPrices.get_price("fheRand", "Uint8", false) + assert 25_000 == FheOperatorPrices.get_price("fheRand", "Uint128", false) + end + + test "handles fheRandBounded operation" do + assert 23_000 == FheOperatorPrices.get_price("fheRandBounded", "Uint8", false) + assert 30_000 == FheOperatorPrices.get_price("fheRandBounded", "Uint256", false) + end + end + + describe "get_type_name/1" do + test "returns correct type name for type bytes" do + # Mapping matches fhevm ALL_FHE_TYPE_INFOS (fheTypeInfos.ts) + assert "Bool" == FheOperatorPrices.get_type_name(0) + assert "Uint4" == FheOperatorPrices.get_type_name(1) + assert "Uint8" == FheOperatorPrices.get_type_name(2) + assert "Uint16" == FheOperatorPrices.get_type_name(3) + assert "Uint32" == FheOperatorPrices.get_type_name(4) + assert "Uint64" == FheOperatorPrices.get_type_name(5) + assert "Uint128" == FheOperatorPrices.get_type_name(6) + assert "Uint160" == FheOperatorPrices.get_type_name(7) + assert "Uint256" == FheOperatorPrices.get_type_name(8) + assert "Uint512" == FheOperatorPrices.get_type_name(9) + assert "Uint1024" == FheOperatorPrices.get_type_name(10) + end + + test "returns Unknown for invalid type byte" do + assert "Unknown" == FheOperatorPrices.get_type_name(99) + assert "Unknown" == FheOperatorPrices.get_type_name(-1) + end + end +end diff --git a/apps/explorer/test/explorer/chain/fhe/parser_test.exs b/apps/explorer/test/explorer/chain/fhe/parser_test.exs new file mode 100644 index 000000000000..908d1dcf8a47 --- /dev/null +++ b/apps/explorer/test/explorer/chain/fhe/parser_test.exs @@ -0,0 +1,799 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Fhe.ParserTest do + use Explorer.DataCase + + alias ABI.TypeEncoder + alias Explorer.Chain.{Fhe.Parser, Hash, Log} + alias Explorer.Helper + + describe "get_event_name/1" do + test "returns correct event name for FheAdd" do + assert "FheAdd" == Parser.get_event_name("0xdb9050d65240431621d61d6f94b970e63f53a67a5766614ee6e5c5bbd41c8e2e") + end + + test "returns correct event name for TrivialEncrypt" do + assert "TrivialEncrypt" == + Parser.get_event_name("0x063ccd1bba45151d91f6a418065047a3d048d058a922535747bb2b575a01d236") + end + + test "returns Unknown for invalid event signature" do + assert "Unknown" == Parser.get_event_name("0xinvalid") + end + + test "handles case-insensitive topic" do + assert "FheAdd" == Parser.get_event_name("0xDB9050D65240431621D61D6F94B970E63F53A67A5766614EE6E5C5BBD41C8E2E") + end + end + + describe "extract_caller/1" do + test "extracts caller from Hash struct" do + address_bytes = <<1::160>> + # Create a 32-byte hash (12 bytes padding + 20 bytes address) + full_hash_bytes = <<0::96, address_bytes::binary>> + {:ok, hash} = Hash.cast(Hash.Full, "0x" <> Base.encode16(full_hash_bytes, case: :lower)) + + result = Parser.extract_caller(hash) + expected = "0x" <> Base.encode16(address_bytes, case: :lower) + assert expected == result + end + + test "extracts caller from binary topic" do + address_bytes = <<1::160>> + topic = <<0::96, address_bytes::binary>> + + result = Parser.extract_caller(topic) + assert "0x" <> Base.encode16(address_bytes, case: :lower) == result + end + + test "extracts caller from 32-byte binary topic" do + # Test with a 32-byte binary (what a topic actually is) + address_bytes = + <<0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, + 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8, 0x11::8>> + + topic = <<0::96, address_bytes::binary>> + + result = Parser.extract_caller(topic) + expected = "0x" <> Base.encode16(address_bytes, case: :lower) + assert expected == result + end + + test "returns nil for nil input" do + assert nil == Parser.extract_caller(nil) + end + end + + describe "decode_event_data/2" do + test "decodes binary operation (FheAdd)" do + lhs = <<1::256>> + rhs = <<2::256>> + scalar_byte = <<0>> + result = <<3::256>> + + data = + "0x" <> + Base.encode16( + TypeEncoder.encode([lhs, rhs, scalar_byte, result], [{:bytes, 32}, {:bytes, 32}, {:bytes, 1}, {:bytes, 32}]), + case: :lower + ) + + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "FheAdd") + + assert decoded.lhs == lhs + assert decoded.rhs == rhs + assert decoded.scalar_byte == scalar_byte + assert decoded.result == result + end + + test "decodes unary operation (FheNeg)" do + ct = <<1::256>> + result = <<2::256>> + + data = "0x" <> Base.encode16(TypeEncoder.encode([ct, result], [{:bytes, 32}, {:bytes, 32}]), case: :lower) + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "FheNeg") + + assert decoded.ct == ct + assert decoded.result == result + end + + test "decodes TrivialEncrypt operation" do + pt = 123 + to_type = 1 + result = <<3::256>> + + data = + "0x" <> + Base.encode16(TypeEncoder.encode([pt, to_type, result], [{:uint, 256}, {:uint, 8}, {:bytes, 32}]), + case: :lower + ) + + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "TrivialEncrypt") + + assert decoded.plaintext == pt + assert decoded.to_type == to_type + assert decoded.result == result + end + + test "decodes Cast operation" do + ct = <<1::256>> + to_type = 2 + result = <<3::256>> + + data = + "0x" <> + Base.encode16(TypeEncoder.encode([ct, to_type, result], [{:bytes, 32}, {:uint, 8}, {:bytes, 32}]), + case: :lower + ) + + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "Cast") + + assert decoded.ct == ct + assert decoded.to_type == to_type + assert decoded.result == result + end + + test "decodes FheIfThenElse operation" do + control = <<1::256>> + if_true = <<2::256>> + if_false = <<3::256>> + result = <<4::256>> + + data = + "0x" <> + Base.encode16( + TypeEncoder.encode([control, if_true, if_false, result], [ + {:bytes, 32}, + {:bytes, 32}, + {:bytes, 32}, + {:bytes, 32} + ]), + case: :lower + ) + + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "FheIfThenElse") + + assert decoded.control == control + assert decoded.if_true == if_true + assert decoded.if_false == if_false + assert decoded.result == result + end + + test "decodes FheRand operation" do + rand_type = 1 + seed = <<1::128>> + result = <<2::256>> + + data = + "0x" <> + Base.encode16(TypeEncoder.encode([rand_type, seed, result], [{:uint, 8}, {:bytes, 16}, {:bytes, 32}]), + case: :lower + ) + + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "FheRand") + + assert decoded.rand_type == rand_type + assert decoded.seed == seed + assert decoded.result == result + end + + test "decodes FheRandBounded operation" do + upper_bound = 100 + rand_type = 1 + seed = <<1::128>> + result = <<2::256>> + + data = + "0x" <> + Base.encode16( + TypeEncoder.encode([upper_bound, rand_type, seed, result], [ + {:uint, 256}, + {:uint, 8}, + {:bytes, 16}, + {:bytes, 32} + ]), + case: :lower + ) + + log = %Log{data: data} + + decoded = Parser.decode_event_data(log, "FheRandBounded") + + assert decoded.upper_bound == upper_bound + assert decoded.rand_type == rand_type + assert decoded.seed == seed + assert decoded.result == result + end + + test "returns default result for unknown event" do + log = %Log{data: <<>>} + decoded = Parser.decode_event_data(log, "UnknownEvent") + + assert decoded.result == <<0::256>> + end + end + + describe "extract_fhe_type/2" do + test "extracts type from TrivialEncrypt to_type" do + operation_data = %{to_type: 2, result: <<0::256>>} + assert "Uint8" == Parser.extract_fhe_type(operation_data, "TrivialEncrypt") + end + + test "extracts all types from TrivialEncrypt to_type" do + # Test all main types + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + operation_data = %{to_type: type_index, result: <<0::256>>} + + assert expected_type == Parser.extract_fhe_type(operation_data, "TrivialEncrypt"), + "Failed for type_index #{type_index}, expected #{expected_type}" + end) + end + + test "extracts type from Cast input handle (ct)" do + # Cast extracts type from input handle (ct), not to_type parameter + # Type byte at position 30 (0-indexed) in the ct handle + # ct handle with type 3 (Uint16) at byte 30 + ct = <<0::240, 3::8, 0::8>> + operation_data = %{ct: ct, to_type: 5, result: <<0::256>>} + assert "Uint16" == Parser.extract_fhe_type(operation_data, "Cast") + end + + test "extracts all types from Cast input handle" do + # Test all main types in Cast + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + ct = <<0::240, type_index::8, 0::8>> + operation_data = %{ct: ct, to_type: 0, result: <<0::256>>} + + assert expected_type == Parser.extract_fhe_type(operation_data, "Cast"), + "Failed for type_index #{type_index}, expected #{expected_type}" + end) + end + + test "extracts type from comparison operations LHS handle" do + # Comparison operations (FheEq, FheNe, etc.) extract type from LHS handle, not result + # LHS handle with type 2 (Uint8) at byte 30 + lhs = <<0::240, 2::8, 0::8>> + operation_data = %{lhs: lhs, rhs: <<0::256>>, result: <<0::240, 6::8, 0::8>>} + assert "Uint8" == Parser.extract_fhe_type(operation_data, "FheEq") + end + + test "extracts all types from comparison operations LHS handle" do + # Test all main types in comparison operations + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + lhs = <<0::240, type_index::8, 0::8>> + operation_data = %{lhs: lhs, rhs: <<0::256>>, result: <<0::256>>} + + assert expected_type == Parser.extract_fhe_type(operation_data, "FheEq"), + "Failed for type_index #{type_index}, expected #{expected_type}" + end) + end + + test "extracts type from result handle for most operations" do + # Most operations extract type from result handle + # Type byte at position 30 (0-indexed) + # Result handle with type 2 (Uint8) at byte 30 + result = <<0::240, 2::8, 0::8>> + operation_data = %{result: result} + assert "Uint8" == Parser.extract_fhe_type(operation_data, "FheAdd") + end + + test "extracts all types from result handle for binary operations" do + # Test all main types in result handle + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + result = <<0::240, type_index::8, 0::8>> + operation_data = %{result: result} + + assert expected_type == Parser.extract_fhe_type(operation_data, "FheAdd"), + "Failed for type_index #{type_index}, expected #{expected_type}" + end) + end + + test "extracts type from unary operation input handle" do + # Unary operations (Cast, FheNot, FheNeg) extract type from input handle (ct) + # ct handle with type 4 (Uint32) at byte 30 + ct = <<0::240, 4::8, 0::8>> + operation_data = %{ct: ct, result: <<0::256>>} + assert "Uint32" == Parser.extract_fhe_type(operation_data, "FheNeg") + end + + test "extracts all types from unary operation input handle" do + # Test all main types in unary operations + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + ct = <<0::240, type_index::8, 0::8>> + operation_data = %{ct: ct, result: <<0::256>>} + + assert expected_type == Parser.extract_fhe_type(operation_data, "FheNeg"), + "Failed for type_index #{type_index}, expected #{expected_type}" + end) + end + + test "extracts type from FheRand randType parameter" do + # rand_type: 2 = Uint8 + operation_data = %{rand_type: 2, seed: <<0::128>>, result: <<0::256>>} + assert "Uint8" == Parser.extract_fhe_type(operation_data, "FheRand") + end + + test "extracts all types from FheRand randType parameter" do + # Test all main types in FheRand + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + operation_data = %{rand_type: type_index, seed: <<0::128>>, result: <<0::256>>} + + assert expected_type == Parser.extract_fhe_type(operation_data, "FheRand"), + "Failed for type_index #{type_index}, expected #{expected_type}" + end) + end + + test "extracts type from FheRandBounded randType parameter" do + # rand_type: 2 = Uint8 + operation_data = %{rand_type: 2, upper_bound: 100, seed: <<0::128>>, result: <<0::256>>} + assert "Uint8" == Parser.extract_fhe_type(operation_data, "FheRandBounded") + end + + test "returns Unknown for invalid result" do + operation_data = %{result: <<>>} + assert "Unknown" == Parser.extract_fhe_type(operation_data, "FheAdd") + end + + test "returns Unknown for missing required data" do + # TrivialEncrypt without to_type + assert "Unknown" == Parser.extract_fhe_type(%{}, "TrivialEncrypt") + + # Cast without ct + assert "Unknown" == Parser.extract_fhe_type(%{}, "Cast") + + # Comparison without lhs + assert "Unknown" == Parser.extract_fhe_type(%{}, "FheEq") + + # FheRand without rand_type + assert "Unknown" == Parser.extract_fhe_type(%{}, "FheRand") + end + end + + describe "extract_inputs/2" do + test "extracts inputs for binary operation" do + operation_data = %{lhs: <<1::256>>, rhs: <<2::256>>} + inputs = Parser.extract_inputs(operation_data, "FheAdd") + + assert inputs.lhs == Base.encode16(<<1::256>>, case: :lower) + assert inputs.rhs == Base.encode16(<<2::256>>, case: :lower) + end + + test "extracts inputs for unary operation" do + operation_data = %{ct: <<1::256>>} + inputs = Parser.extract_inputs(operation_data, "FheNeg") + + assert inputs.ct == Base.encode16(<<1::256>>, case: :lower) + end + + test "extracts inputs for FheIfThenElse" do + operation_data = %{control: <<1::256>>, if_true: <<2::256>>, if_false: <<3::256>>} + inputs = Parser.extract_inputs(operation_data, "FheIfThenElse") + + assert inputs.control == Base.encode16(<<1::256>>, case: :lower) + assert inputs.if_true == Base.encode16(<<2::256>>, case: :lower) + assert inputs.if_false == Base.encode16(<<3::256>>, case: :lower) + end + + test "extracts inputs for TrivialEncrypt" do + operation_data = %{plaintext: 123} + inputs = Parser.extract_inputs(operation_data, "TrivialEncrypt") + + assert inputs.plaintext == 123 + end + + test "returns empty map for unknown operation" do + operation_data = %{} + inputs = Parser.extract_inputs(operation_data, "Unknown") + + assert inputs == %{} + end + end + + describe "calculate_hcu_cost/3" do + test "calculates HCU cost for FheAdd operation" do + cost = Parser.calculate_hcu_cost("FheAdd", "Uint8", false) + assert is_integer(cost) + assert cost > 0 + end + + test "calculates different cost for scalar vs non-scalar" do + scalar_cost = Parser.calculate_hcu_cost("FheAdd", "Uint8", true) + non_scalar_cost = Parser.calculate_hcu_cost("FheAdd", "Uint8", false) + + assert scalar_cost != non_scalar_cost + end + + test "calculates HCU cost for all types in FheAdd" do + # Test all main types for FheAdd non-scalar + test_cases = [ + {"Uint8", 88_000}, + {"Uint16", 93_000}, + {"Uint32", 125_000}, + {"Uint64", 162_000}, + {"Uint128", 259_000} + ] + + Enum.each(test_cases, fn {type, expected_cost} -> + cost = Parser.calculate_hcu_cost("FheAdd", type, false) + + assert cost == expected_cost, + "FheAdd non-scalar #{type} should cost #{expected_cost}, got #{cost}" + end) + + # Test all main types for FheAdd scalar + scalar_cases = [ + {"Uint8", 84_000}, + {"Uint16", 93_000}, + {"Uint32", 95_000}, + {"Uint64", 133_000}, + {"Uint128", 172_000} + ] + + Enum.each(scalar_cases, fn {type, expected_cost} -> + cost = Parser.calculate_hcu_cost("FheAdd", type, true) + + assert cost == expected_cost, + "FheAdd scalar #{type} should cost #{expected_cost}, got #{cost}" + end) + end + + test "calculates HCU cost for FheGe all types" do + # Test FheGe non-scalar for all types + test_cases = [ + {"Uint8", 63_000}, + {"Uint16", 84_000}, + {"Uint32", 118_000}, + {"Uint64", 152_000}, + {"Uint128", 210_000} + ] + + Enum.each(test_cases, fn {type, expected_cost} -> + cost = Parser.calculate_hcu_cost("FheGe", type, false) + + assert cost == expected_cost, + "FheGe non-scalar #{type} should cost #{expected_cost}, got #{cost}" + end) + end + + test "calculates HCU cost for FheIfThenElse all types" do + # Test FheIfThenElse for all types + test_cases = [ + {"Bool", 55_000}, + {"Uint8", 55_000}, + {"Uint16", 55_000}, + {"Uint32", 55_000}, + {"Uint64", 55_000}, + {"Uint128", 57_000} + ] + + Enum.each(test_cases, fn {type, expected_cost} -> + cost = Parser.calculate_hcu_cost("FheIfThenElse", type, false) + + assert cost == expected_cost, + "FheIfThenElse #{type} should cost #{expected_cost}, got #{cost}" + end) + end + + test "calculates HCU cost for TrivialEncrypt all types" do + # Test TrivialEncrypt for all types + test_cases = [ + {"Bool", 32}, + {"Uint8", 32}, + {"Uint16", 32}, + {"Uint32", 32}, + {"Uint64", 32}, + {"Uint128", 32} + ] + + Enum.each(test_cases, fn {type, expected_cost} -> + cost = Parser.calculate_hcu_cost("TrivialEncrypt", type, false) + + assert cost == expected_cost, + "TrivialEncrypt #{type} should cost #{expected_cost}, got #{cost}" + end) + end + + test "returns 0 for unknown operation" do + cost = Parser.calculate_hcu_cost("UnknownOperation", "Uint8", false) + assert cost == 0 + end + + test "returns 0 for unknown type" do + cost = Parser.calculate_hcu_cost("FheAdd", "UnknownType", false) + assert cost == 0 + end + end + + describe "get_operation_type/1" do + test "returns arithmetic for arithmetic operations" do + assert "arithmetic" == Parser.get_operation_type("FheAdd") + assert "arithmetic" == Parser.get_operation_type("FheSub") + assert "arithmetic" == Parser.get_operation_type("FheMul") + assert "arithmetic" == Parser.get_operation_type("FheDiv") + assert "arithmetic" == Parser.get_operation_type("FheRem") + end + + test "returns bitwise for bitwise operations" do + assert "bitwise" == Parser.get_operation_type("FheBitAnd") + assert "bitwise" == Parser.get_operation_type("FheBitOr") + assert "bitwise" == Parser.get_operation_type("FheBitXor") + assert "bitwise" == Parser.get_operation_type("FheShl") + assert "bitwise" == Parser.get_operation_type("FheShr") + assert "bitwise" == Parser.get_operation_type("FheRotl") + assert "bitwise" == Parser.get_operation_type("FheRotr") + end + + test "returns comparison for comparison operations" do + assert "comparison" == Parser.get_operation_type("FheEq") + assert "comparison" == Parser.get_operation_type("FheNe") + assert "comparison" == Parser.get_operation_type("FheGe") + assert "comparison" == Parser.get_operation_type("FheGt") + assert "comparison" == Parser.get_operation_type("FheLe") + assert "comparison" == Parser.get_operation_type("FheLt") + assert "comparison" == Parser.get_operation_type("FheMin") + assert "comparison" == Parser.get_operation_type("FheMax") + end + + test "returns unary for unary operations" do + assert "unary" == Parser.get_operation_type("FheNeg") + assert "unary" == Parser.get_operation_type("FheNot") + end + + test "returns control for control operations" do + assert "control" == Parser.get_operation_type("FheIfThenElse") + end + + test "returns encryption for encryption operations" do + assert "encryption" == Parser.get_operation_type("TrivialEncrypt") + assert "encryption" == Parser.get_operation_type("Cast") + end + + test "returns random for random operations" do + assert "random" == Parser.get_operation_type("FheRand") + assert "random" == Parser.get_operation_type("FheRandBounded") + end + + test "returns other for unknown operations" do + assert "other" == Parser.get_operation_type("UnknownOperation") + end + end + + describe "FheOperatorPrices.get_type_name/1" do + test "returns correct type name for all main types" do + test_cases = [ + {0, "Bool"}, + {1, "Uint4"}, + {2, "Uint8"}, + {3, "Uint16"}, + {4, "Uint32"}, + {5, "Uint64"}, + {6, "Uint128"}, + {7, "Uint160"}, + {8, "Uint256"} + ] + + Enum.each(test_cases, fn {type_index, expected_type} -> + type_name = Explorer.Chain.FheOperatorPrices.get_type_name(type_index) + + assert type_name == expected_type, + "Type index #{type_index} should map to #{expected_type}, got #{type_name}" + end) + end + + test "returns Unknown for invalid type index" do + assert "Unknown" == Explorer.Chain.FheOperatorPrices.get_type_name(999) + assert "Unknown" == Explorer.Chain.FheOperatorPrices.get_type_name(-1) + end + end + + describe "build_hcu_depth_map/1" do + test "calculates depth for independent operations" do + operations = [ + %{result: <<1::256>>, inputs: %{lhs: "0x00", rhs: "0x00"}, hcu_cost: 100, is_scalar: false}, + %{result: <<2::256>>, inputs: %{lhs: "0x00", rhs: "0x00"}, hcu_cost: 200, is_scalar: false} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + result1 = Base.encode16(<<1::256>>, case: :lower) + result2 = Base.encode16(<<2::256>>, case: :lower) + + assert depth_map[result1] == 100 + assert depth_map[result2] == 200 + end + + test "calculates depth for dependent operations" do + result1 = Base.encode16(<<1::256>>, case: :lower) + result2 = Base.encode16(<<2::256>>, case: :lower) + + operations = [ + %{result: <<1::256>>, inputs: %{lhs: "0x00", rhs: "0x00"}, hcu_cost: 100, is_scalar: false}, + %{result: <<2::256>>, inputs: %{lhs: result1, rhs: "0x00"}, hcu_cost: 200, is_scalar: false} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + assert depth_map[result1] == 100 + # 100 (from result1) + 200 (current) + assert depth_map[result2] == 300 + end + + test "calculates depth for scalar operations (only LHS depth)" do + result1 = Base.encode16(<<1::256>>, case: :lower) + result2 = Base.encode16(<<2::256>>, case: :lower) + + operations = [ + %{result: <<1::256>>, inputs: %{lhs: "0x00", rhs: "0x00"}, hcu_cost: 100, is_scalar: false}, + # Scalar operation: RHS is plain value, so only use LHS depth + %{result: <<2::256>>, inputs: %{lhs: result1, rhs: "plain_value"}, hcu_cost: 200, is_scalar: true} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + assert depth_map[result1] == 100 + # Scalar: lhs_depth (100) + cost (200) = 300 (not max of lhs and rhs) + assert depth_map[result2] == 300 + end + + test "calculates depth for non-scalar operations (max of LHS and RHS)" do + result1 = Base.encode16(<<1::256>>, case: :lower) + result2 = Base.encode16(<<2::256>>, case: :lower) + result3 = Base.encode16(<<3::256>>, case: :lower) + + operations = [ + %{result: <<1::256>>, inputs: %{lhs: "0x00", rhs: "0x00"}, hcu_cost: 100, is_scalar: false}, + %{result: <<2::256>>, inputs: %{lhs: "0x00", rhs: "0x00"}, hcu_cost: 200, is_scalar: false}, + # Non-scalar: use max of both input depths + %{result: <<3::256>>, inputs: %{lhs: result1, rhs: result2}, hcu_cost: 300, is_scalar: false} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + assert depth_map[result1] == 100 + assert depth_map[result2] == 200 + # Non-scalar: max(100, 200) + 300 = 500 + assert depth_map[result3] == 500 + end + + test "calculates depth for unary operations" do + result1 = Base.encode16(<<1::256>>, case: :lower) + result2 = Base.encode16(<<2::256>>, case: :lower) + + operations = [ + %{result: <<1::256>>, inputs: %{}, hcu_cost: 100}, + %{result: <<2::256>>, inputs: %{ct: result1}, hcu_cost: 200} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + assert depth_map[result1] == 100 + # Unary: ct_depth (100) + cost (200) = 300 + assert depth_map[result2] == 300 + end + + test "calculates depth for FheIfThenElse" do + control = Base.encode16(<<1::256>>, case: :lower) + if_true = Base.encode16(<<2::256>>, case: :lower) + if_false = Base.encode16(<<3::256>>, case: :lower) + result = Base.encode16(<<4::256>>, case: :lower) + + operations = [ + %{result: <<1::256>>, inputs: %{}, hcu_cost: 50}, + %{result: <<2::256>>, inputs: %{}, hcu_cost: 100}, + %{result: <<3::256>>, inputs: %{}, hcu_cost: 150}, + %{result: <<4::256>>, inputs: %{control: control, if_true: if_true, if_false: if_false}, hcu_cost: 200} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + # max(50, 100, 150) + 200 + assert depth_map[result] == 350 + end + + test "calculates depth for operations with no inputs" do + result1 = Base.encode16(<<1::256>>, case: :lower) + + operations = [ + %{result: <<1::256>>, inputs: %{}, hcu_cost: 100} + ] + + depth_map = Parser.build_hcu_depth_map(operations) + + # Operations with no inputs (TrivialEncrypt, FheRand, etc.) have depth = cost only + assert depth_map[result1] == 100 + end + end + + describe "all_fhe_events/0" do + test "returns list of all FHE event signatures" do + events = Parser.all_fhe_events() + + assert is_list(events) + assert length(events) > 0 + assert "0xdb9050d65240431621d61d6f94b970e63f53a67a5766614ee6e5c5bbd41c8e2e" in events + end + end +end diff --git a/apps/explorer/test/explorer/chain/filecoin/native_address_test.exs b/apps/explorer/test/explorer/chain/filecoin/native_address_test.exs index 22325d39a5ba..55b35ba09cc5 100644 --- a/apps/explorer/test/explorer/chain/filecoin/native_address_test.exs +++ b/apps/explorer/test/explorer/chain/filecoin/native_address_test.exs @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Filecoin.NativeAddressTest do use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] if @chain_type == :filecoin do use ExUnit.Case, async: true - # TODO: remove when https://github.com/elixir-lang/elixir/issues/13975 comes to elixir release - alias Explorer.Chain.Hash, warn: false + + alias Explorer.Chain.Hash alias Explorer.Chain.Filecoin.{NativeAddress, IDAddress} doctest NativeAddress diff --git a/apps/explorer/test/explorer/chain/hash/address_test.exs b/apps/explorer/test/explorer/chain/hash/address_test.exs index d603c14d38e9..500a1f72389d 100644 --- a/apps/explorer/test/explorer/chain/hash/address_test.exs +++ b/apps/explorer/test/explorer/chain/hash/address_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash.AddressTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/hash/full_test.exs b/apps/explorer/test/explorer/chain/hash/full_test.exs index 00d02d83adde..c79b4ecaca44 100644 --- a/apps/explorer/test/explorer/chain/hash/full_test.exs +++ b/apps/explorer/test/explorer/chain/hash/full_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash.FullTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/hash/nonce_test.exs b/apps/explorer/test/explorer/chain/hash/nonce_test.exs index 31793c3d90b9..0b3f510f4312 100644 --- a/apps/explorer/test/explorer/chain/hash/nonce_test.exs +++ b/apps/explorer/test/explorer/chain/hash/nonce_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Hash.NonceTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/hash_test.exs b/apps/explorer/test/explorer/chain/hash_test.exs index 7565e9d067f5..9ed8c357111c 100644 --- a/apps/explorer/test/explorer/chain/hash_test.exs +++ b/apps/explorer/test/explorer/chain/hash_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.HashTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/health/helper_test.exs b/apps/explorer/test/explorer/chain/health/helper_test.exs index a12e4f9026d0..64a077c10d28 100644 --- a/apps/explorer/test/explorer/chain/health/helper_test.exs +++ b/apps/explorer/test/explorer/chain/health/helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Health.HelperTest do use Explorer.DataCase alias Explorer.Chain.Health.Helper, as: HealthHelper @@ -24,6 +25,78 @@ defmodule Explorer.Chain.Health.HelperTest do end end + describe "blocks_indexing_healthy?/1" do + setup do + Application.put_env(:explorer, Explorer.Chain.Health.Monitor, healthy_blocks_period: 300_000) + :ok + end + + defp unix_now, do: DateTime.to_unix(DateTime.utc_now()) + + defp base_status(overrides \\ %{}) do + now = unix_now() + + Map.merge( + %{ + health_latest_block_timestamp_from_db: Decimal.new(now), + health_latest_block_number_from_db: Decimal.new(100), + health_latest_block_timestamp_from_cache: Decimal.new(now), + health_latest_block_number_from_cache: Decimal.new(100), + health_latest_block_number_from_node: Decimal.new(100), + health_latest_batch_timestamp_from_db: nil, + health_latest_batch_number_from_db: nil, + health_latest_batch_average_time_from_db: nil + }, + overrides + ) + end + + test "returns true when nil" do + assert true == HealthHelper.blocks_indexing_healthy?(nil) + end + + test "returns error when db has no blocks" do + status = base_status(%{health_latest_block_timestamp_from_db: nil}) + assert {false, _, _} = HealthHelper.blocks_indexing_healthy?(status) + end + + test "returns true when db and cache are both current" do + assert true == HealthHelper.blocks_indexing_healthy?(base_status()) + end + + test "returns true when cache timestamps are absent" do + status = base_status(%{health_latest_block_timestamp_from_cache: nil}) + assert true == HealthHelper.blocks_indexing_healthy?(status) + end + + test "returns error when cache lags behind db beyond threshold" do + now = unix_now() + stale = now - 600 + + status = + base_status(%{ + health_latest_block_timestamp_from_db: Decimal.new(now), + health_latest_block_timestamp_from_cache: Decimal.new(stale) + }) + + assert {false, 5001, message} = HealthHelper.blocks_indexing_healthy?(status) + assert message =~ "Cache block is lagging" + end + + test "returns true when cache lag is within threshold" do + now = unix_now() + recent = now - 60 + + status = + base_status(%{ + health_latest_block_timestamp_from_db: Decimal.new(now), + health_latest_block_timestamp_from_cache: Decimal.new(recent) + }) + + assert true == HealthHelper.blocks_indexing_healthy?(status) + end + end + describe "last_db_block_status/0" do test "return no_blocks errors if db is empty" do assert {:error, :no_blocks} = HealthHelper.last_db_block_status() diff --git a/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs b/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs index 6789d37b5a8e..fc5442235195 100644 --- a/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/address/current_token_balances_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do use Explorer.DataCase @@ -273,6 +274,73 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do assert current_token_balance.value == Decimal.new(200) end + test "updates NFT when the new block number is greater", %{ + address: address, + token: token, + options: options + } do + insert( + :address_current_token_balance, + address: address, + block_number: 1, + token_contract_address_hash: token.contract_address_hash, + token_id: 123, + token_type: "ERC-1155", + value: 100 + ) + + run_changes( + %{ + address_hash: address.hash, + block_number: 2, + token_contract_address_hash: token.contract_address_hash, + token_id: 123, + token_type: "ERC-1155", + value: Decimal.new(200), + value_fetched_at: DateTime.utc_now() + }, + options + ) + + current_token_balance = Repo.get_by(CurrentTokenBalance, address_hash: address.hash) + + assert current_token_balance.block_number == 2 + assert current_token_balance.value == Decimal.new(200) + end + + test "updates when the new block number is greater even if value_fetched_at is not set", %{ + address: %Address{hash: address_hash} = address, + token: %Token{contract_address_hash: token_contract_address_hash}, + options: options + } do + insert( + :address_current_token_balance, + address: address, + block_number: 1, + token_contract_address_hash: token_contract_address_hash, + value: 200, + value_fetched_at: Timex.shift(Timex.now(), minutes: -2) + ) + + update_holder_count!(token_contract_address_hash, 1) + + assert {:ok, %{address_current_token_balances_update_token_holder_counts: []}} = + run_changes( + %{ + address_hash: address_hash, + token_contract_address_hash: token_contract_address_hash, + block_number: 2, + value: Decimal.new(100) + }, + options + ) + + current_token_balance = Repo.get_by(CurrentTokenBalance, address_hash: address_hash) + + assert current_token_balance.block_number == 2 + assert current_token_balance.value == Decimal.new(100) + end + test "ignores when the new block number is lesser", %{ address: %Address{hash: address_hash} = address, token: %Token{contract_address_hash: token_contract_address_hash}, @@ -283,7 +351,8 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do address: address, block_number: 2, token_contract_address_hash: token_contract_address_hash, - value: 200 + value: 200, + value_fetched_at: Timex.shift(Timex.now(), minutes: -2) ) update_holder_count!(token_contract_address_hash, 1) @@ -294,7 +363,8 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do address_hash: address_hash, token_contract_address_hash: token_contract_address_hash, block_number: 1, - value: Decimal.new(100) + value: Decimal.new(100), + value_fetched_at: Timex.shift(Timex.now(), minutes: -1) }, options ) @@ -305,6 +375,45 @@ defmodule Explorer.Chain.Import.Runner.Address.CurrentTokenBalancesTest do assert current_token_balance.value == Decimal.new(200) end + test "ignores when the new value_fetched_at is lower (values compared correctly)", %{ + address: %Address{hash: address_hash} = address, + token: %Token{contract_address_hash: token_contract_address_hash}, + options: options + } do + time_before = ~U[2024-12-31 00:00:00.000000Z] + time_after = ~U[2025-01-01 00:00:00.000000Z] + + assert time_before > time_after + + insert( + :address_current_token_balance, + address: address, + block_number: 1, + token_contract_address_hash: token_contract_address_hash, + value: 200, + value_fetched_at: time_after + ) + + update_holder_count!(token_contract_address_hash, 1) + + assert {:ok, %{address_current_token_balances: [], address_current_token_balances_update_token_holder_counts: []}} = + run_changes( + %{ + address_hash: address_hash, + token_contract_address_hash: token_contract_address_hash, + block_number: 1, + value: Decimal.new(100), + value_fetched_at: time_before + }, + options + ) + + current_token_balance = Repo.get_by(CurrentTokenBalance, address_hash: address_hash) + + assert Decimal.eq?(current_token_balance.value, 200) + assert Timex.equal?(current_token_balance.value_fetched_at, time_after) + end + test "a non-holder updating to a holder increases the holder_count", %{ address: %Address{hash: address_hash} = address, token: %Token{contract_address_hash: token_contract_address_hash}, diff --git a/apps/explorer/test/explorer/chain/import/runner/address/token_balances_test.exs b/apps/explorer/test/explorer/chain/import/runner/address/token_balances_test.exs index 4d7f24eec8d0..0412df419194 100644 --- a/apps/explorer/test/explorer/chain/import/runner/address/token_balances_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/address/token_balances_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.Address.TokenBalancesTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/import/runner/addresses_test.exs b/apps/explorer/test/explorer/chain/import/runner/addresses_test.exs index 7ef8292ee80e..ced45b16296a 100644 --- a/apps/explorer/test/explorer/chain/import/runner/addresses_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/addresses_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.AddressesTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs index 6d1edce350f0..f316123d7c71 100644 --- a/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/blocks_test.exs @@ -1,19 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.BlocksTest do use Explorer.DataCase + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import Ecto.Query, only: [from: 2, select: 2, where: 2] import Explorer.Chain.Import.RunnerCase, only: [insert_address_with_token_balances: 1, update_holder_count!: 2] alias Ecto.Multi alias Explorer.Chain.Import.Runner.{Blocks, Transactions} + alias Explorer.Chain.InternalTransaction.DeleteQueue, as: InternalTransactionDeleteQueue alias Explorer.Chain.{Address, Block, Transaction, PendingBlockOperation} - alias Explorer.Chain.Celo.PendingEpochBlockOperation + alias Explorer.Chain.Cache.BlockNumber alias Explorer.{Chain, Repo} alias Explorer.Utility.MissingBlockRange - alias Explorer.Chain.Celo.Helper, as: CeloHelper - describe "run/1" do setup do miner = insert(:address) @@ -236,6 +239,119 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do ) end + # The test checks that `derive_address_current_token_balances` function derives balances only for removed block numbers, + # and not for all possible (address_hash, token_contract_address_hash) pairs, which would be very inefficient. + # + # Here, we intentionally insert an Address.TokenBalance entry but omit corresponding Address.CurrentTokenBalance + # for the `_to_not_derive_address_hash`, as otherwise even if that Address.CurrentTokenBalance is derived it + # still won't be inserted and returned due to on_conflict statement. In production it should be impossible + # for the Address.CurrentTokenBalance to be missing if corresponding Address.TokenBalance exists. + test "derive_address_current_token_balances only updates rows removed by delete_address_current_token_balances", + %{consensus_block: %{number: block_number} = block, options: options} do + # Setup + + token = insert(:token) + token_contract_address_hash = token.contract_address_hash + + %Address.TokenBalance{ + address_hash: _to_not_derive_address_hash + } = + insert(:token_balance, + token_contract_address_hash: token_contract_address_hash, + block_number: block_number - 1, + value: 1 + ) + + %Address{hash: to_derive_address_hash} = + insert_address_with_token_balances(%{ + previous: %{value: 1}, + current: %{block_number: block_number, value: 2}, + token_contract_address_hash: token_contract_address_hash + }) + + assert count(Address.TokenBalance) == 3 + assert count(Address.CurrentTokenBalance) == 1 + + # Test and assert + + insert(:block, number: block_number, consensus: true) + previous_block_number = block_number - 1 + + assert {:ok, + %{ + delete_address_current_token_balances: [ + %{ + address_hash: ^to_derive_address_hash, + token_contract_address_hash: ^token_contract_address_hash + } + ], + delete_address_token_balances: [ + %{ + address_hash: ^to_derive_address_hash, + token_contract_address_hash: ^token_contract_address_hash, + block_number: ^block_number + } + ], + # the main assertion: should not be any other derived values + derive_address_current_token_balances: [ + %{ + address_hash: ^to_derive_address_hash, + token_contract_address_hash: ^token_contract_address_hash, + block_number: ^previous_block_number + } + ], + blocks_update_token_holder_counts: [] + }} = run_block_consensus_change(block, true, options) + + assert count(Address.TokenBalance) == 2 + assert count(Address.CurrentTokenBalance) == 1 + end + + test "derive_address_current_token_balances derives balances from latest token balances", + %{consensus_block: %{number: block_number} = block, options: options} do + # Setup + + token = insert(:token) + token_contract_address_hash = token.contract_address_hash + + address = + insert_address_with_token_balances(%{ + previous: %{value: 2}, + current: %{block_number: block_number, value: 3}, + token_contract_address_hash: token_contract_address_hash + }) + + insert(:token_balance, + address: address, + token_contract_address_hash: token_contract_address_hash, + block_number: block_number - 2, + value: 1 + ) + + assert count(Address.TokenBalance) == 3 + assert count(Address.CurrentTokenBalance) == 1 + + initial_value = Decimal.new("3") + + assert %Address.CurrentTokenBalance{block_number: ^block_number, value: ^initial_value} = + Repo.one(Address.CurrentTokenBalance) + + # Test and assert + + insert(:block, number: block_number, consensus: true) + + run_block_consensus_change(block, true, options) + + assert count(Address.TokenBalance) == 2 + assert count(Address.CurrentTokenBalance) == 1 + + previous_block_number = block_number - 1 + expected_value = Decimal.new("2") + + assert %Address.CurrentTokenBalance{block_number: ^previous_block_number, value: ^expected_value} = + Repo.one(Address.CurrentTokenBalance) + end + test "a non-holder reverting to a holder increases the holder_count", %{consensus_block: %{hash: block_hash, miner_hash: miner_hash, number: block_number}, options: options} do token = insert(:token) @@ -329,6 +445,18 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do }} = run_block_consensus_change(block, true, options) end + test "internal transactions are inserted to delete queue if some blocks lost consensus", + %{consensus_block: %{number: block_number} = block, options: options} do + insert(:block, number: block_number, consensus: true) + + assert {:ok, + %{ + save_internal_transactions_for_delete: [^block_number] + }} = run_block_consensus_change(block, true, options) + + assert %{block_number: ^block_number} = Repo.one(InternalTransactionDeleteQueue) + end + # Regression test for https://github.com/poanetwork/blockscout/issues/1644 test "discards neighboring blocks if they aren't related to the current one because of reorg and/or import timeout", %{consensus_block: %{number: block_number, hash: block_hash, miner_hash: miner_hash}, options: options} do @@ -407,109 +535,11 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do insert_block(block, options) insert_block(block2, options) - Process.sleep(100) + Process.sleep(200) assert %{from_number: ^block_number, to_number: ^block_number} = Repo.one(MissingBlockRange) end - test "inserts pending_block_operations only for consensus blocks", - %{consensus_block: %{miner_hash: miner_hash}, options: options} do - %{number: number, hash: hash} = new_block = params_for(:block, miner_hash: miner_hash, consensus: true) - new_block1 = params_for(:block, miner_hash: miner_hash, consensus: false) - - %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, new_block) - %Ecto.Changeset{valid?: true, changes: block_changes1} = Block.changeset(%Block{}, new_block1) - - config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) - Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) - - on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) - - Multi.new() - |> Blocks.run([block_changes, block_changes1], options) - |> Repo.transaction() - - assert %{block_number: ^number, block_hash: ^hash} = Repo.one(PendingBlockOperation) - end - - test "inserts pending_block_operations only for actually inserted blocks", - %{consensus_block: %{miner_hash: miner_hash}, options: options} do - %{number: number, hash: hash} = new_block = params_for(:block, miner_hash: miner_hash, consensus: true) - new_block1 = params_for(:block, miner_hash: miner_hash, consensus: true) - - miner = Repo.get_by(Address, hash: miner_hash) - - insert(:block, Map.put(new_block1, :miner, miner)) - - %Ecto.Changeset{valid?: true, changes: block_changes} = Block.changeset(%Block{}, new_block) - %Ecto.Changeset{valid?: true, changes: block_changes1} = Block.changeset(%Block{}, new_block1) - - config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) - Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) - - on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) - - Multi.new() - |> Blocks.run([block_changes, block_changes1], options) - |> Repo.transaction() - - assert %{block_number: ^number, block_hash: ^hash} = Repo.one(PendingBlockOperation) - end - - if Application.compile_env(:explorer, :chain_type) == :celo do - test "inserts pending_epoch_block_operations only for epoch blocks", - %{consensus_block: %{miner_hash: miner_hash}, options: options} do - epoch_block_number = CeloHelper.blocks_per_epoch() - - %{hash: hash} = - epoch_block_params = - params_for( - :block, - miner_hash: miner_hash, - consensus: true, - number: epoch_block_number - ) - - non_epoch_block_params = - params_for( - :block, - miner_hash: miner_hash, - consensus: true, - number: epoch_block_number + 1 - ) - - insert_block(epoch_block_params, options) - insert_block(non_epoch_block_params, options) - - assert %{block_hash: ^hash} = Repo.one(PendingEpochBlockOperation) - end - - test "inserts pending_epoch_block_operations only for consensus epoch blocks", - %{consensus_block: %{miner_hash: miner_hash}, options: options} do - %{hash: hash} = - first_epoch_block_params = - params_for( - :block, - miner_hash: miner_hash, - consensus: true, - number: CeloHelper.blocks_per_epoch() - ) - - second_epoch_block_params = - params_for( - :block, - miner_hash: miner_hash, - consensus: false, - number: CeloHelper.blocks_per_epoch() * 2 - ) - - insert_block(first_epoch_block_params, options) - insert_block(second_epoch_block_params, options) - - assert %{block_hash: ^hash} = Repo.one(PendingEpochBlockOperation) - end - end - test "change instance owner if was token transfer in older blocks", %{consensus_block: %{hash: block_hash, miner_hash: miner_hash, number: block_number}, options: options} do block_number = block_number + 2 @@ -674,6 +704,151 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do ] }} = Multi.new() |> Blocks.run(changes_list, options) |> Repo.transaction() end + + if @chain_identity == {:optimism, :celo} do + test "removes celo epoch rewards and sets fetched? = false when starting block loses consensus", %{ + consensus_block: %{miner_hash: miner_hash} = parent_block, + options: options + } do + start_processing_block = insert(:block, number: 1, consensus: true, parent_hash: parent_block.hash) + end_processing_block = insert(:block, number: 2, consensus: true, parent_hash: start_processing_block.hash) + address = insert(:address) + + epoch = %{ + number: 1, + start_processing_block_hash: start_processing_block.hash, + end_processing_block_hash: end_processing_block.hash, + fetched?: true + } + + election_reward = %{ + epoch_number: epoch.number, + account_address_hash: address.hash, + amount: 100, + associated_account_address_hash: address.hash, + type: :validator + } + + distribution = %{ + epoch_number: epoch.number, + community_transfer_log_index: 1 + } + + {:ok, _imported} = + Chain.import(%{ + celo_epochs: %{params: [epoch]}, + celo_election_rewards: %{params: [election_reward]}, + celo_epoch_rewards: %{params: [distribution]} + }) + + assert Repo.get_by(Explorer.Chain.Celo.Epoch, number: epoch.number) + assert Repo.get_by(Explorer.Chain.Celo.EpochReward, epoch_number: epoch.number) + assert Repo.get_by(Explorer.Chain.Celo.ElectionReward, epoch_number: epoch.number) + + block_params = + params_for(:block, + number: start_processing_block.number, + miner_hash: miner_hash, + parent_hash: parent_block.hash + ) + + start_processing_block_hash = start_processing_block.hash + start_processing_block_number = start_processing_block.number + end_processing_block_hash = end_processing_block.hash + end_processing_block_number = end_processing_block.number + + assert {:ok, + %{ + celo_delete_epoch_rewards: [ + %Explorer.Chain.Celo.Epoch{ + number: 1, + fetched?: false, + start_processing_block_hash: ^start_processing_block_hash, + end_processing_block_hash: ^end_processing_block_hash + } + ], + lose_consensus: [ + {^start_processing_block_number, ^start_processing_block_hash}, + {^end_processing_block_number, ^end_processing_block_hash} + ] + }} = insert_block(block_params, options) + + assert Repo.get_by(Explorer.Chain.Celo.Epoch, number: epoch.number).fetched? == false + assert Repo.get_by(Explorer.Chain.Celo.EpochReward, epoch_number: epoch.number) == nil + assert Repo.get_by(Explorer.Chain.Celo.ElectionReward, epoch_number: epoch.number) == nil + end + + test "removes celo epoch rewards and sets fetched? = false when ending block loses consensus", %{ + consensus_block: %{miner_hash: miner_hash}, + options: options + } do + start_processing_block = insert(:block, number: 0, consensus: true) + intermediate_block = insert(:block, number: 1, consensus: true) + end_processing_block = insert(:block, number: 2, consensus: true, parent_hash: intermediate_block.hash) + address = insert(:address) + + epoch = %{ + number: 1, + start_processing_block_hash: start_processing_block.hash, + end_processing_block_hash: end_processing_block.hash, + fetched?: true + } + + election_reward = %{ + epoch_number: epoch.number, + account_address_hash: address.hash, + amount: 100, + associated_account_address_hash: address.hash, + type: :validator + } + + distribution = %{ + epoch_number: epoch.number, + community_transfer_log_index: 1 + } + + {:ok, _imported} = + Chain.import(%{ + celo_epochs: %{params: [epoch]}, + celo_election_rewards: %{params: [election_reward]}, + celo_epoch_rewards: %{params: [distribution]} + }) + + assert Repo.get_by(Explorer.Chain.Celo.Epoch, number: epoch.number) + assert Repo.get_by(Explorer.Chain.Celo.EpochReward, epoch_number: epoch.number) + assert Repo.get_by(Explorer.Chain.Celo.ElectionReward, epoch_number: epoch.number) + + block_params = + params_for(:block, + number: end_processing_block.number, + miner_hash: miner_hash, + parent_hash: intermediate_block.hash + ) + + start_processing_block_hash = start_processing_block.hash + end_processing_block_hash = end_processing_block.hash + end_processing_block_number = end_processing_block.number + + assert {:ok, + %{ + celo_delete_epoch_rewards: [ + %Explorer.Chain.Celo.Epoch{ + number: 1, + fetched?: false, + start_processing_block_hash: ^start_processing_block_hash, + end_processing_block_hash: ^end_processing_block_hash + } + ], + lose_consensus: [ + {^end_processing_block_number, ^end_processing_block_hash} + ] + }} = insert_block(block_params, options) + + assert Repo.get_by(Explorer.Chain.Celo.Epoch, number: epoch.number).fetched? == false + assert Repo.get_by(Explorer.Chain.Celo.EpochReward, epoch_number: epoch.number) == nil + assert Repo.get_by(Explorer.Chain.Celo.ElectionReward, epoch_number: epoch.number) == nil + end + end end describe "lose_consensus/5" do @@ -692,7 +867,65 @@ defmodule Explorer.Chain.Import.Runner.BlocksTest do timestamps: %{updated_at: DateTime.utc_now()} } - assert {:ok, [{0, _}, {1, _}]} = Blocks.lose_consensus(Repo, [], [1], [new_block1_changes], opts) + assert {:ok, [{0, _}, {1, _}]} = Blocks.process_blocks_consensus([new_block1_changes], Repo, opts) + end + + test "does not trigger beacon deposit reorg handling on old blocks" do + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: true) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: false) + end) + + BlockNumber.set_max(100) + + Process.register(self(), Indexer.Fetcher.Beacon.Deposit) + + insert(:block, consensus: true, number: 0) + insert(:block, consensus: true, number: 1) + insert(:block, consensus: false, number: 2) + + new_block0 = params_for(:block, miner_hash: insert(:address).hash, number: 0) + new_block1 = params_for(:block, miner_hash: insert(:address).hash, parent_hash: new_block0.hash, number: 1) + + %Ecto.Changeset{valid?: true, changes: new_block1_changes} = Block.changeset(%Block{}, new_block1) + + opts = %{ + timeout: 60_000, + timestamps: %{updated_at: DateTime.utc_now()} + } + + Blocks.process_blocks_consensus([new_block1_changes], Repo, opts) + refute_received {:"$gen_cast", {:lost_consensus, _}} + end + + test "triggers beacon deposit reorg handling on fresh blocks" do + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: true) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.BlockNumber, enabled: false) + end) + + BlockNumber.set_max(50) + + Process.register(self(), Indexer.Fetcher.Beacon.Deposit) + + insert(:block, consensus: true, number: 0) + insert(:block, consensus: true, number: 1) + insert(:block, consensus: false, number: 2) + + new_block0 = params_for(:block, miner_hash: insert(:address).hash, number: 0) + new_block1 = params_for(:block, miner_hash: insert(:address).hash, parent_hash: new_block0.hash, number: 1) + + %Ecto.Changeset{valid?: true, changes: new_block1_changes} = Block.changeset(%Block{}, new_block1) + + opts = %{ + timeout: 60_000, + timestamps: %{updated_at: DateTime.utc_now()} + } + + Blocks.process_blocks_consensus([new_block1_changes], Repo, opts) + assert_received {:"$gen_cast", {:lost_consensus, _}} end end diff --git a/apps/explorer/test/explorer/chain/import/runner/fhe_operations_test.exs b/apps/explorer/test/explorer/chain/import/runner/fhe_operations_test.exs new file mode 100644 index 000000000000..7811c4868f24 --- /dev/null +++ b/apps/explorer/test/explorer/chain/import/runner/fhe_operations_test.exs @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Import.Runner.FheOperationsTest do + use Explorer.DataCase + + import Mox + + alias Ecto.Multi + alias Explorer.Chain.{Block, FheOperation, Hash, Transaction} + alias Explorer.Chain.Import.Runner.FheOperations + alias Explorer.Repo + + setup :verify_on_exit! + setup :set_mox_global + + describe "run/3" do + test "inserts FHE operations successfully" do + transaction = insert(:transaction) |> with_block() + block = transaction.block + caller = insert(:address) + + changes_list = [ + %{ + transaction_hash: transaction.hash, + log_index: 1, + block_hash: block.hash, + block_number: block.number, + operation: "FheAdd", + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: 100, + hcu_depth: 1, + caller: caller.hash, + result_handle: <<1::256>>, + input_handles: %{"lhs" => "0x00", "rhs" => "0x00"} + } + ] + + timestamp = DateTime.utc_now() + options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} + + # Multi.run unwraps the {:ok, value} tuple + assert {:ok, %{insert_fhe_operations: [inserted]}} = + Multi.new() + |> FheOperations.run(changes_list, options) + |> Repo.transaction() + + assert inserted.transaction_hash == transaction.hash + assert inserted.log_index == 1 + assert inserted.operation == "FheAdd" + end + + test "handles empty changes list" do + timestamp = DateTime.utc_now() + options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} + + # Multi.run unwraps the {:ok, value} tuple, so we get just the value + assert {:ok, %{insert_fhe_operations: []}} = + Multi.new() + |> FheOperations.run([], options) + |> Repo.transaction() + end + + test "handles conflict resolution on duplicate operations" do + transaction = insert(:transaction) |> with_block() + block = transaction.block + + # Insert first operation + insert(:fhe_operation, + transaction_hash: transaction.hash, + log_index: 1, + block_hash: block.hash, + block_number: block.number, + operation: "FheAdd", + hcu_cost: 100 + ) + + # Try to insert same operation with different data + changes_list = [ + %{ + transaction_hash: transaction.hash, + log_index: 1, + block_hash: block.hash, + block_number: block.number, + operation: "FheMul", + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: 200, + hcu_depth: 1, + # Include caller field (nil) to avoid KeyError + caller: nil, + result_handle: <<1::256>>, + input_handles: %{} + } + ] + + timestamp = DateTime.utc_now() + options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} + + # Multi.run unwraps the {:ok, value} tuple + assert {:ok, %{insert_fhe_operations: [updated]}} = + Multi.new() + |> FheOperations.run(changes_list, options) + |> Repo.transaction() + + # Should replace the existing operation + assert updated.operation == "FheMul" + assert updated.hcu_cost == 200 + + # Verify only one operation exists + operations = FheOperation.by_transaction_hash(transaction.hash) + assert length(operations) == 1 + end + + test "orders operations by transaction_hash and log_index" do + transaction = insert(:transaction) |> with_block() + block = transaction.block + + changes_list = [ + %{ + transaction_hash: transaction.hash, + log_index: 3, + block_hash: block.hash, + block_number: block.number, + operation: "FheAdd", + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: 100, + hcu_depth: 1, + caller: nil, + result_handle: <<3::256>>, + input_handles: %{} + }, + %{ + transaction_hash: transaction.hash, + log_index: 1, + block_hash: block.hash, + block_number: block.number, + operation: "FheSub", + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: 100, + hcu_depth: 1, + caller: nil, + result_handle: <<1::256>>, + input_handles: %{} + }, + %{ + transaction_hash: transaction.hash, + log_index: 2, + block_hash: block.hash, + block_number: block.number, + operation: "FheMul", + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: 100, + hcu_depth: 1, + caller: nil, + result_handle: <<2::256>>, + input_handles: %{} + } + ] + + timestamp = DateTime.utc_now() + options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} + + # Multi.run unwraps the {:ok, value} tuple + assert {:ok, %{insert_fhe_operations: inserted}} = + Multi.new() + |> FheOperations.run(changes_list, options) + |> Repo.transaction() + + # Verify ordering + assert length(inserted) == 3 + assert Enum.at(inserted, 0).log_index == 1 + assert Enum.at(inserted, 1).log_index == 2 + assert Enum.at(inserted, 2).log_index == 3 + end + + test "tags contracts from FHE operations" do + transaction = insert(:transaction) |> with_block() + block = transaction.block + caller = insert(:address, contract_code: "0x6080604052") + to_address = insert(:address, contract_code: "0x6080604052") + + # Set transaction to_address + transaction + |> Transaction.changeset(%{to_address_hash: to_address.hash}) + |> Repo.update!() + + changes_list = [ + %{ + transaction_hash: transaction.hash, + log_index: 1, + block_hash: block.hash, + block_number: block.number, + operation: "FheAdd", + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: 100, + hcu_depth: 1, + caller: caller.hash, + result_handle: <<1::256>>, + input_handles: %{} + } + ] + + timestamp = DateTime.utc_now() + options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} + + # Mock RPC calls for FHE checks + EthereumJSONRPC.Mox + |> expect(:json_rpc, 2, fn _request, _options -> + {:ok, "0x0000000000000000000000000000000000000000000000000000000000000000"} + end) + + assert {:ok, _} = + Multi.new() + |> FheOperations.run(changes_list, options) + |> Repo.transaction() + end + end +end diff --git a/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs b/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs index c4553d3e7ce3..9f06f36080ed 100644 --- a/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/internal_transactions_test.exs @@ -1,9 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do use Explorer.DataCase alias Ecto.Multi alias Explorer.Chain.{Block, Data, Wei, PendingBlockOperation, Transaction, InternalTransaction} alias Explorer.Chain.Import.Runner.InternalTransactions + alias Explorer.Migrator.DeleteZeroValueInternalTransactions setup do config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) @@ -139,53 +141,6 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert :ok == Repo.get(Transaction, transaction2.hash).status end - test "for block with simple coin transfer and method calls, method calls internal transactions have correct block_index" do - a_block = insert(:block, number: 1000) - transaction0 = insert(:transaction) |> with_block(a_block, status: :ok) - transaction1 = insert(:transaction) |> with_block(a_block, status: :ok) - transaction2 = insert(:transaction) |> with_block(a_block, status: :ok) - insert(:pending_block_operation, block_hash: a_block.hash, block_number: a_block.number) - - assert :ok == transaction0.status - assert :ok == transaction1.status - assert :ok == transaction2.status - - index = 0 - - internal_transaction_changes_0 = make_internal_transaction_changes(transaction0, index, nil) - internal_transaction_changes_0_1 = make_internal_transaction_changes(transaction0, 1, nil) - - internal_transaction_changes_1 = - make_internal_transaction_changes_for_simple_coin_transfers(transaction1, index, nil) - - internal_transaction_changes_2 = make_internal_transaction_changes(transaction2, index, nil) - internal_transaction_changes_2_1 = make_internal_transaction_changes(transaction2, 1, nil) - - assert {:ok, _} = - run_internal_transactions([ - internal_transaction_changes_0, - internal_transaction_changes_0_1, - internal_transaction_changes_1, - internal_transaction_changes_2, - internal_transaction_changes_2_1 - ]) - - # transaction with index 0 is ignored in Nethermind JSON RPC Variant and not ignored in case of Geth - - # assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction0.hash, where: i.index == 0) - # |> Repo.one() - # |> is_nil() - - assert 1 == Repo.get_by!(InternalTransaction, transaction_hash: transaction0.hash, index: 1).block_index - # assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction1.hash) |> Repo.one() |> is_nil() - - # assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction2.hash, where: i.index == 0) - # |> Repo.one() - # |> is_nil() - - assert 4 == Repo.get_by!(InternalTransaction, transaction_hash: transaction2.hash, index: 1).block_index - end - # test "simple coin transfer has no internal transaction inserted for Nethermind" do # transaction = insert(:transaction) |> with_block(status: :ok) # insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) @@ -219,11 +174,19 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert {:ok, _} = run_internal_transactions([transaction_changes, pending_changes]) - assert Repo.exists?(from(i in InternalTransaction, where: i.transaction_hash == ^transaction.hash)) + assert Repo.exists?( + from(i in InternalTransaction, + where: i.block_number == ^transaction.block_number and i.transaction_index == ^transaction.index + ) + ) assert PendingBlockOperation |> Repo.get(transaction.block_hash) |> is_nil() - assert from(i in InternalTransaction, where: i.transaction_hash == ^pending.hash) |> Repo.one() |> is_nil() + assert InternalTransaction + |> InternalTransaction.join_transaction_query() + |> where([_it, t], t.hash == ^pending.hash) + |> Repo.one() + |> is_nil() assert is_nil(Repo.get(Transaction, pending.hash).block_hash) end @@ -254,9 +217,17 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert {:ok, _} = run_internal_transactions([pending_transaction_changes, transaction_changes]) - assert from(i in InternalTransaction, where: i.transaction_hash == ^pending.hash) |> Repo.one() |> is_nil() + assert InternalTransaction + |> InternalTransaction.join_transaction_query() + |> where([_it, t], t.hash == ^pending.hash) + |> Repo.one() + |> is_nil() - assert from(i in InternalTransaction, where: i.transaction_hash == ^inserted.hash) |> Repo.one() |> is_nil() == + assert from(i in InternalTransaction, + where: i.block_number == ^inserted.block_number and i.transaction_index == ^inserted.index + ) + |> Repo.one() + |> is_nil() == false assert %{consensus: true} = Repo.get(Block, full_block.hash) @@ -275,11 +246,15 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert {:ok, _} = run_internal_transactions([transaction_a_changes]) - assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction_a.hash) + assert from(i in InternalTransaction, + where: i.block_number == ^transaction_a.block_number and i.transaction_index == ^transaction_a.index + ) |> Repo.one() |> is_nil() - assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction_b.hash) + assert from(i in InternalTransaction, + where: i.block_number == ^transaction_b.block_number and i.transaction_index == ^transaction_b.index + ) |> Repo.one() |> is_nil() @@ -302,11 +277,51 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert {:ok, _} = run_internal_transactions([transaction_a_changes]) - assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction_a.hash) + assert from(i in InternalTransaction, + where: i.block_number == ^transaction_a.block_number and i.transaction_index == ^transaction_a.index + ) + |> Repo.one() + |> is_nil() + + assert from(i in InternalTransaction, + where: i.block_number == ^transaction_b.block_number and i.transaction_index == ^transaction_b.index + ) + |> Repo.one() + |> is_nil() + + assert %{consensus: true, refetch_needed: false} = Repo.get(Block, full_block.hash) + + on_exit(fn -> Application.put_env(:indexer, :trace_block_ranges, original_config) end) + end + + test "does not set refetch_needed=true for non-traceable blocks and multiple ranges" do + original_config = Application.get_env(:indexer, :trace_block_ranges) + + full_block = insert(:block) + transaction_a = insert(:transaction) |> with_block(full_block) + transaction_b = insert(:transaction) |> with_block(full_block) + + Application.put_env( + :indexer, + :trace_block_ranges, + "#{full_block.number - 2}..#{full_block.number - 1},#{full_block.number + 1}..latest" + ) + + insert(:pending_block_operation, block_hash: full_block.hash, block_number: full_block.number) + + transaction_a_changes = make_internal_transaction_changes(transaction_a, 0, nil) + + assert {:ok, _} = run_internal_transactions([transaction_a_changes]) + + assert from(i in InternalTransaction, + where: i.block_number == ^transaction_a.block_number and i.transaction_index == ^transaction_a.index + ) |> Repo.one() |> is_nil() - assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction_b.hash) + assert from(i in InternalTransaction, + where: i.block_number == ^transaction_b.block_number and i.transaction_index == ^transaction_b.index + ) |> Repo.one() |> is_nil() @@ -334,7 +349,11 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert {:ok, _} = run_internal_transactions([transaction_changes]) - assert from(i in InternalTransaction, where: i.transaction_hash == ^transaction.hash) |> Repo.one() |> is_nil() + assert from(i in InternalTransaction, + where: i.block_number == ^transaction.block_number and i.transaction_index == ^transaction.index + ) + |> Repo.one() + |> is_nil() assert %{consensus: true, refetch_needed: false} = Repo.get(Block, full_block.hash) @@ -365,12 +384,18 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert %{consensus: true} = Repo.get(Block, empty_block.hash) assert PendingBlockOperation |> Repo.get(empty_block.hash) |> is_nil() - assert from(i in InternalTransaction, where: i.transaction_hash == ^inserted.hash, where: i.index == 1) + assert from(i in InternalTransaction, + where: i.block_number == ^inserted.block_number and i.transaction_index == ^inserted.index, + where: i.index == 1 + ) |> Repo.one() |> is_nil() == false - assert from(i in InternalTransaction, where: i.transaction_hash == ^inserted.hash, where: i.index == 2) + assert from(i in InternalTransaction, + where: i.block_number == ^inserted.block_number and i.transaction_index == ^inserted.index, + where: i.index == 2 + ) |> Repo.one() |> is_nil() == false @@ -398,7 +423,6 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do index: 0, input: input, trace_address: [], - transaction_hash: transaction.hash, transaction_index: 0, type: :stop, value: Wei.from(Decimal.new(0), :wei) @@ -406,11 +430,331 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do assert {:ok, _} = run_internal_transactions([internal_transaction_changes]) end + + test "empties contract_code for addresses selfdestructed in different transaction than creation" do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block, status: :ok) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # Create a contract address with some bytecode + contract_address = insert(:address, contract_code: "0x6080604052") + + # Create a selfdestruct internal transaction + selfdestruct_changes = %{ + block_number: block.number, + from_address_hash: contract_address.hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 0, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + assert {:ok, _} = run_internal_transactions([selfdestruct_changes]) + + # Verify the contract_code was emptied + updated_address = Repo.get(Explorer.Chain.Address, contract_address.hash) + assert %Data{bytes: <<>>} = updated_address.contract_code + end + + test "does not empty contract_code when contract is created and selfdestructed in same transaction" do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block, status: :ok) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # Create a contract address + contract_address = insert(:address, contract_code: "0x6080604052") + + # Create internal transaction: first create the contract + create_changes = %{ + block_number: block.number, + created_contract_address_hash: contract_address.hash, + created_contract_code: %Data{bytes: <<0x60, 0x80, 0x60, 0x40, 0x52>>}, + from_address_hash: insert(:address).hash, + gas: 50000, + gas_used: 25000, + init: %Data{bytes: <<1, 2, 3>>}, + trace_address: [], + transaction_index: transaction.index, + index: 0, + type: :create, + value: Wei.from(Decimal.new(0), :wei) + } + + # Then selfdestruct it in the same transaction + selfdestruct_changes = %{ + block_number: block.number, + from_address_hash: contract_address.hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 1, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + assert {:ok, _} = run_internal_transactions([create_changes, selfdestruct_changes]) + + # Verify the contract_code was NOT emptied (contract created and destroyed in same tx) + updated_address = Repo.get(Explorer.Chain.Address, contract_address.hash) + assert updated_address.contract_code != %Data{bytes: <<>>} + end + + test "empties contract_code for multiple selfdestructed contracts" do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block, status: :ok) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # Create multiple contract addresses with bytecode + contract_address_1 = insert(:address, contract_code: "0x6080604052") + contract_address_2 = insert(:address, contract_code: "0x608060405260") + contract_address_3 = insert(:address, contract_code: "0x60806040") + + # Create selfdestruct internal transactions for multiple contracts + selfdestruct_changes_1 = %{ + block_number: block.number, + from_address_hash: contract_address_1.hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 0, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + selfdestruct_changes_2 = %{ + block_number: block.number, + from_address_hash: contract_address_2.hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 1, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + selfdestruct_changes_3 = %{ + block_number: block.number, + from_address_hash: contract_address_3.hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 2, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + assert {:ok, _} = + run_internal_transactions([ + selfdestruct_changes_1, + selfdestruct_changes_2, + selfdestruct_changes_3 + ]) + + # Verify all contract_codes were emptied + assert %Data{bytes: <<>>} = Repo.get(Explorer.Chain.Address, contract_address_1.hash).contract_code + assert %Data{bytes: <<>>} = Repo.get(Explorer.Chain.Address, contract_address_2.hash).contract_code + assert %Data{bytes: <<>>} = Repo.get(Explorer.Chain.Address, contract_address_3.hash).contract_code + end + + test "does not empty contract_code when only create2 and selfdestruct in same transaction" do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block, status: :ok) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # Create a contract address + contract_address = insert(:address, contract_code: "0x6080604052") + + # Create internal transaction: first create2 the contract + create2_changes = %{ + block_number: block.number, + created_contract_address_hash: contract_address.hash, + created_contract_code: %Data{bytes: <<0x60, 0x80, 0x60, 0x40, 0x52>>}, + from_address_hash: insert(:address).hash, + gas: 50000, + gas_used: 25000, + index: 0, + trace_address: [], + transaction_index: transaction.index, + init: %Data{bytes: <<1, 2, 3>>}, + type: :create2, + value: Wei.from(Decimal.new(0), :wei) + } + + # Then selfdestruct it in the same transaction + selfdestruct_changes = %{ + block_number: block.number, + from_address_hash: contract_address.hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 1, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + assert {:ok, _} = run_internal_transactions([create2_changes, selfdestruct_changes]) + + # Verify the contract_code was NOT emptied (contract created and destroyed in same tx) + updated_address = Repo.get(Explorer.Chain.Address, contract_address.hash) + assert updated_address.contract_code != %Data{bytes: <<>>} + end + + test "handles selfdestruct with no matching address gracefully" do + block = insert(:block) + transaction = insert(:transaction) |> with_block(block, status: :ok) + insert(:pending_block_operation, block_hash: transaction.block_hash, block_number: transaction.block_number) + + # Create a selfdestruct for a non-existent address + non_existent_address_hash = insert(:address).hash + + selfdestruct_changes = %{ + block_number: block.number, + from_address_hash: non_existent_address_hash, + to_address_hash: insert(:address).hash, + gas: nil, + trace_address: [], + transaction_index: transaction.index, + index: 0, + type: :selfdestruct, + value: Wei.from(Decimal.new(0), :wei) + } + + # Should not raise an error + assert {:ok, _} = run_internal_transactions([selfdestruct_changes]) + end + end + + describe "prepare_data/1 zero value filtering" do + setup do + original_config = Application.get_env(:explorer, DeleteZeroValueInternalTransactions) + + on_exit(fn -> + Application.put_env(:explorer, DeleteZeroValueInternalTransactions, original_config) + end) + + :ok + end + + test "removes zero-value call internal transactions before border block" do + Application.put_env(:explorer, DeleteZeroValueInternalTransactions, enabled: true, storage_period: 0) + block = insert(:block) + + params = [ + %{ + type: :call, + block_number: block.number - 1, + value: Wei.from(Decimal.new(0), :wei) + }, + %{ + type: :call, + block_number: block.number - 1, + value: Wei.from(Decimal.new(1), :wei) + }, + %{ + type: "call", + block_number: block.number - 1, + value: 0 + } + ] + + result = InternalTransactions.prepare_data(params) + + assert length(result) == 1 + assert hd(result).value.value == Decimal.new(1) + end + + test "does not remove zero-value calls after border block" do + Application.put_env(:explorer, DeleteZeroValueInternalTransactions, enabled: true, storage_period: 0) + block = insert(:block) + + params = [ + %{ + type: :call, + block_number: block.number + 1, + value: Wei.from(Decimal.new(0), :wei) + }, + %{ + type: "call", + block_number: block.number + 1, + value: 0 + } + ] + + assert [_, _] = InternalTransactions.prepare_data(params) + end + + test "does not remove non-call internal transactions" do + Application.put_env(:explorer, DeleteZeroValueInternalTransactions, enabled: true, storage_period: 0) + block = insert(:block) + + params = [ + %{ + type: :create, + block_number: block.number - 1, + value: Wei.from(Decimal.new(0), :wei) + }, + %{ + type: "create", + block_number: block.number - 1, + value: 0 + } + ] + + assert [_, _] = InternalTransactions.prepare_data(params) + end + + test "treats nil value as zero" do + Application.put_env(:explorer, DeleteZeroValueInternalTransactions, enabled: true, storage_period: 0) + block = insert(:block) + + params = [ + %{ + type: :call, + block_number: block.number - 1, + value: nil + }, + %{ + type: "call", + block_number: block.number - 1, + value: nil + } + ] + + assert [] == InternalTransactions.prepare_data(params) + end + + test "does not filter anything when feature is disabled" do + Application.put_env(:explorer, DeleteZeroValueInternalTransactions, enabled: false, storage_period: 0) + block = insert(:block) + + params = [ + %{ + type: :call, + block_number: block.number - 1, + value: Wei.from(Decimal.new(0), :wei) + }, + %{ + type: "call", + block_number: block.number - 1, + value: 0 + } + ] + + assert [_, _] = InternalTransactions.prepare_data(params) + end end defp run_internal_transactions(changes_list, multi \\ Multi.new()) when is_list(changes_list) do multi - |> InternalTransactions.run(changes_list, %{ + |> InternalTransactions.run(InternalTransactions.prepare_data(changes_list), %{ timeout: :infinity, timestamps: %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} }) @@ -440,7 +784,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do end, index: index, trace_address: [], - transaction_hash: transaction.hash, + transaction_index: transaction.index, type: :call, value: Wei.from(Decimal.new(1), :wei), error: error, @@ -469,7 +813,7 @@ defmodule Explorer.Chain.Import.Runner.InternalTransactionsTest do end, index: index, trace_address: [], - transaction_hash: transaction.hash, + transaction_index: transaction.index, type: :call, value: Wei.from(Decimal.new(1), :wei), error: error, diff --git a/apps/explorer/test/explorer/chain/import/runner/stability/validators_test.exs b/apps/explorer/test/explorer/chain/import/runner/stability/validators_test.exs new file mode 100644 index 000000000000..d1caff9a5d22 --- /dev/null +++ b/apps/explorer/test/explorer/chain/import/runner/stability/validators_test.exs @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +if Application.compile_env(:explorer, :chain_type) == :stability do + defmodule Explorer.Chain.Import.Runner.Stability.ValidatorsTest do + use Explorer.DataCase + + alias Ecto.Multi + alias Explorer.Chain.Stability.Validator + alias Explorer.Chain.Import.Runner.Stability.Validators + + describe "run/1" do + test "updates blocks_validated counter for existing validators" do + # Insert some validators first + %Validator{address_hash: validator1_hash} = + insert(:validator_stability, blocks_validated: 5) + + %Validator{address_hash: validator2_hash} = + insert(:validator_stability, blocks_validated: 3) + + changes = [ + %{ + address_hash: validator1_hash, + blocks_validated: 2 + }, + %{ + address_hash: validator2_hash, + blocks_validated: 1 + } + ] + + assert {:ok, %{stability_validators: updated_validators}} = run_changes(changes) + + assert length(updated_validators) == 2 + + # Verify counters were updated in database + updated_validator1 = Repo.get(Validator, validator1_hash) + updated_validator2 = Repo.get(Validator, validator2_hash) + + # 5 + 2 + assert updated_validator1.blocks_validated == 7 + # 3 + 1 + assert updated_validator2.blocks_validated == 4 + end + + test "skips non-existent validators" do + # Insert one validator + %Validator{address_hash: existing_validator_hash} = + insert(:validator_stability, blocks_validated: 2) + + # Try to update both existing and non-existing validator + non_existing_hash = "0x1111111111111111111111111111111111111111" + + changes = [ + %{ + address_hash: existing_validator_hash, + blocks_validated: 3 + }, + %{ + address_hash: non_existing_hash, + blocks_validated: 5 + } + ] + + assert {:ok, %{stability_validators: updated_validators}} = run_changes(changes) + + # Only the existing validator should be in the result + assert length(updated_validators) == 1 + [updated_validator] = updated_validators + assert updated_validator.address_hash == existing_validator_hash + + # Verify the existing validator was updated + updated_validator_db = Repo.get(Validator, existing_validator_hash) + # 2 + 3 + assert updated_validator_db.blocks_validated == 5 + + # Verify non-existing validator wasn't created + assert Repo.get(Validator, non_existing_hash) == nil + end + + test "handles empty changes list" do + assert {:ok, %{stability_validators: []}} = run_changes([]) + end + + test "handles multiple increments for the same validator" do + %Validator{address_hash: validator_hash} = + insert(:validator_stability, blocks_validated: 10) + + changes = [ + %{ + address_hash: validator_hash, + blocks_validated: 2 + }, + %{ + address_hash: validator_hash, + blocks_validated: 3 + } + ] + + assert {:ok, %{stability_validators: updated_validators}} = run_changes(changes) + + # Should have 2 entries in the result (one for each update) + assert length(updated_validators) == 2 + + # Verify the counter was incremented twice + updated_validator = Repo.get(Validator, validator_hash) + # 10 + 2 + 3 + assert updated_validator.blocks_validated == 15 + end + + test "handles zero increment" do + %Validator{address_hash: validator_hash} = + insert(:validator_stability, blocks_validated: 7) + + changes = [ + %{ + address_hash: validator_hash, + blocks_validated: 0 + } + ] + + assert {:ok, %{stability_validators: updated_validators}} = run_changes(changes) + + assert length(updated_validators) == 1 + + # Verify the counter remained the same + updated_validator = Repo.get(Validator, validator_hash) + # 7 + 0 + assert updated_validator.blocks_validated == 7 + end + + test "handles large increment values" do + %Validator{address_hash: validator_hash} = + insert(:validator_stability, blocks_validated: 1000) + + changes = [ + %{ + address_hash: validator_hash, + blocks_validated: 999_999 + } + ] + + assert {:ok, %{stability_validators: updated_validators}} = run_changes(changes) + + assert length(updated_validators) == 1 + + # Verify the counter was updated with large value + updated_validator = Repo.get(Validator, validator_hash) + # 1000 + 999999 + assert updated_validator.blocks_validated == 1_000_999 + end + + test "is atomic - all updates succeed or all fail" do + # This test would be more complex to implement as it requires + # simulating database errors, but the Multi transaction + # ensures atomicity by design + %Validator{address_hash: validator_hash} = + insert(:validator_stability, blocks_validated: 5) + + changes = [ + %{ + address_hash: validator_hash, + blocks_validated: 2 + } + ] + + # Normal case - should succeed + assert {:ok, %{stability_validators: _}} = run_changes(changes) + end + end + + defp run_changes(changes) when is_list(changes) do + Multi.new() + |> Validators.run(changes, %{ + timeout: :infinity, + timestamps: %{inserted_at: DateTime.utc_now(), updated_at: DateTime.utc_now()} + }) + |> Repo.transaction() + end + end +end diff --git a/apps/explorer/test/explorer/chain/import/runner/tokens_test.exs b/apps/explorer/test/explorer/chain/import/runner/tokens_test.exs index 67d6e47ad4aa..208c1d6e7f63 100644 --- a/apps/explorer/test/explorer/chain/import/runner/tokens_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/tokens_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.TokensTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/import/runner/transactions_test.exs b/apps/explorer/test/explorer/chain/import/runner/transactions_test.exs index a6ab5af0a21e..64a50d2b7b46 100644 --- a/apps/explorer/test/explorer/chain/import/runner/transactions_test.exs +++ b/apps/explorer/test/explorer/chain/import/runner/transactions_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.Runner.TransactionsTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/import_test.exs b/apps/explorer/test/explorer/chain/import_test.exs index dad0d7adfd22..dc39d793f8bb 100644 --- a/apps/explorer/test/explorer/chain/import_test.exs +++ b/apps/explorer/test/explorer/chain/import_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.ImportTest do use Explorer.DataCase @@ -77,7 +78,7 @@ defmodule Explorer.Chain.ImportTest do }, %{ block_number: 37, - transaction_index: 1, + transaction_index: 0, transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", index: 2, trace_address: [0], @@ -268,21 +269,11 @@ defmodule Explorer.Chain.ImportTest do internal_transactions: [ %{ index: 1, - transaction_hash: %Hash{ - byte_count: 32, - bytes: - <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, - 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> - } + transaction_index: 0 }, %{ index: 2, - transaction_hash: %Hash{ - byte_count: 32, - bytes: - <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, - 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> - } + transaction_index: 0 } ], logs: [ @@ -491,7 +482,14 @@ defmodule Explorer.Chain.ImportTest do } } - Import.all(params) + ctb_chunk_size = Application.get_env(:explorer, :token_balances_import_chunk_size) + Application.put_env(:explorer, :token_balances_import_chunk_size, 1) + + on_exit(fn -> + Application.put_env(:explorer, :token_balances_import_chunk_size, ctb_chunk_size) + end) + + assert {:ok, %{address_current_token_balances: [_, _]}} = Import.all(params) count = CurrentTokenBalance @@ -502,7 +500,7 @@ defmodule Explorer.Chain.ImportTest do end test "with empty map" do - assert {:ok, %{}} == Import.all(%{}) + assert {:ok, %{set_statement_timeout: :done}} == Import.all(%{}) end test "with invalid data" do @@ -510,7 +508,7 @@ defmodule Explorer.Chain.ImportTest do update_in(@import_data, [:internal_transactions, :params, Access.at(0)], &Map.delete(&1, :call_type)) assert {:error, [changeset]} = Import.all(invalid_import_data) - assert changeset_errors(changeset)[:call_type] == ["can't be blank"] + assert changeset_errors(changeset)[:call_type_enum] == ["can't be blank"] end test "publishes addresses with updated fetched_coin_balance data to subscribers on insert" do @@ -992,7 +990,7 @@ defmodule Explorer.Chain.ImportTest do type: "create", value: 0, block_number: 37, - transaction_index: 1 + transaction_index: 0 } ], timeout: 5, @@ -1637,7 +1635,12 @@ defmodule Explorer.Chain.ImportTest do }, blocks: %{ params: [ - params_for(:block, hash: block_hash, consensus: true, miner_hash: miner_hash, number: block_number), + params_for(:block, + hash: block_hash, + consensus: true, + miner_hash: miner_hash, + number: block_number + ), params_for(:block, hash: uncle_hash, consensus: false, @@ -1852,7 +1855,7 @@ defmodule Explorer.Chain.ImportTest do blocks: %{ params: [] } - }) == {:ok, %{}} + }) == {:ok, %{set_statement_timeout: :done}} end # https://github.com/poanetwork/blockscout/issues/868 regression test diff --git a/apps/explorer/test/explorer/chain/internal_transaction/call_type_test.exs b/apps/explorer/test/explorer/chain/internal_transaction/call_type_test.exs index 85a44a58c642..5ed95a008c3c 100644 --- a/apps/explorer/test/explorer/chain/internal_transaction/call_type_test.exs +++ b/apps/explorer/test/explorer/chain/internal_transaction/call_type_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction.CallTypeTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/internal_transaction/type_test.exs b/apps/explorer/test/explorer/chain/internal_transaction/type_test.exs index 8c3cf56f486d..0db59bcc570b 100644 --- a/apps/explorer/test/explorer/chain/internal_transaction/type_test.exs +++ b/apps/explorer/test/explorer/chain/internal_transaction/type_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransaction.TypeTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/internal_transaction_test.exs b/apps/explorer/test/explorer/chain/internal_transaction_test.exs index e2b5a3bde073..12f00dc2f596 100644 --- a/apps/explorer/test/explorer/chain/internal_transaction_test.exs +++ b/apps/explorer/test/explorer/chain/internal_transaction_test.exs @@ -1,35 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.InternalTransactionTest do use Explorer.DataCase + use EthereumJSONRPC.Case + import EthereumJSONRPC, only: [integer_to_quantity: 1] + import Mox + + alias Explorer.Chain alias Explorer.Chain.{Address, Block, Data, InternalTransaction, Transaction, Wei} - alias Explorer.Factory + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Chain.InternalTransaction.Type alias Explorer.PagingOptions - import EthereumJSONRPC, only: [integer_to_quantity: 1] - doctest InternalTransaction describe "changeset/2" do test "with valid attributes" do - transaction = insert(:transaction) - changeset = InternalTransaction.changeset(%InternalTransaction{}, %{ call_type: "call", - from_address_hash: "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + from_address_id: 1, gas: 100, gas_used: 100, index: 0, input: "0x70696e746f73", output: "0x72656672696564", - to_address_hash: "0x6295ee1b4f6dd65047762f924ecd367c17eabf8f", + to_address_id: 2, trace_address: [0, 1], - transaction_hash: transaction.hash, + transaction_index: 0, type: "call", value: 100, block_number: 35, - block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - block_index: 0 + block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd" }) assert changeset.valid? @@ -41,8 +43,6 @@ defmodule Explorer.Chain.InternalTransactionTest do end test "that a valid changeset is persistable" do - transaction = insert(:transaction) - changeset = InternalTransaction.changeset(%InternalTransaction{}, %{ call_type: "call", @@ -52,7 +52,6 @@ defmodule Explorer.Chain.InternalTransactionTest do input: "thin-mints", output: "munchos", trace_address: [0, 1], - transaction: transaction, type: "call", value: 100 }) @@ -61,24 +60,20 @@ defmodule Explorer.Chain.InternalTransactionTest do end test "with stop type" do - transaction = insert(:transaction) - changeset = InternalTransaction.changeset(%InternalTransaction{}, %{ - from_address_hash: "0x0000000000000000000000000000000000000000", + from_address_id: 1, gas: 0, gas_used: 22234, index: 0, input: "0x", trace_address: [], - transaction_hash: transaction.hash, transaction_index: 0, type: "stop", error: "execution stopped", value: 0, block_number: 35, - block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", - block_index: 0 + block_hash: "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd" }) assert changeset.valid? @@ -105,8 +100,6 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -114,8 +107,6 @@ defmodule Explorer.Chain.InternalTransactionTest do insert(:internal_transaction, transaction: transaction, index: 1, - block_hash: transaction.block_hash, - block_index: 1, block_number: transaction.block_number, transaction_index: transaction.index ) @@ -125,54 +116,43 @@ defmodule Explorer.Chain.InternalTransactionTest do # excluding of internal transactions with type=call and index=0 assert 1 == length(results) - assert Enum.all?( - results, - &({&1.transaction_hash, &1.index} in [ - {first.transaction_hash, first.index}, - {second.transaction_hash, second.index} - ]) - ) + assert [{second.block_number, second.transaction_index, second.index}] == + Enum.map(results, &{&1.block_number, &1.transaction_index, &1.index}) assert internal_transaction.block_number == block.number end - test "with transaction with internal transactions loads associations with in necessity_by_association" do + test "with transaction with internal transactions loads associations with in address_preloads" do transaction = :transaction |> insert() |> with_block() insert(:internal_transaction_create, - transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) assert [ %InternalTransaction{ - from_address: %Ecto.Association.NotLoaded{}, - to_address: %Ecto.Association.NotLoaded{}, - transaction: %Ecto.Association.NotLoaded{} + from_address: %{names: %Ecto.Association.NotLoaded{}}, + to_address: nil } ] = InternalTransaction.transaction_to_internal_transactions(transaction.hash) assert [ %InternalTransaction{ - from_address: %Address{}, - to_address: nil, - transaction: %Transaction{block: %Block{}} + from_address: %Address{names: []}, + to_address: nil } ] = InternalTransaction.transaction_to_internal_transactions( transaction.hash, - necessity_by_association: %{ - :from_address => :optional, - :to_address => :optional, - [transaction: :block] => :optional - } + address_preloads: [ + from_address: [:names], + to_address: [:names] + ] ) end @@ -186,8 +166,6 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -207,14 +185,13 @@ defmodule Explorer.Chain.InternalTransactionTest do index: 0, transaction: transaction, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) actual = Enum.at(InternalTransaction.transaction_to_internal_transactions(transaction.hash), 0) - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} end test "includes internal transactions of type `reward` even when they are alone in the parent transaction" do @@ -229,14 +206,13 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, type: :reward, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) actual = Enum.at(InternalTransaction.transaction_to_internal_transactions(transaction.hash), 0) - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} end test "includes internal transactions of type `selfdestruct` even when they are alone in the parent transaction" do @@ -252,14 +228,13 @@ defmodule Explorer.Chain.InternalTransactionTest do gas: nil, type: :selfdestruct, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) actual = Enum.at(InternalTransaction.transaction_to_internal_transactions(transaction.hash), 0) - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} end test "returns the internal transactions in ascending index order" do @@ -268,43 +243,34 @@ defmodule Explorer.Chain.InternalTransactionTest do |> insert() |> with_block() - %InternalTransaction{transaction_hash: _, index: _} = + %InternalTransaction{transaction_index: _, index: _} = insert(:internal_transaction, - transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: transaction_hash_1, index: index_1} = + %InternalTransaction{transaction_index: transaction_index_1, index: index_1} = insert(:internal_transaction, - transaction: transaction, index: 1, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: transaction_hash_2, index: index_2} = + %InternalTransaction{transaction_index: transaction_index_2, index: index_2} = insert(:internal_transaction, - transaction: transaction, index: 2, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 2, transaction_index: transaction.index ) result = transaction.hash |> InternalTransaction.transaction_to_internal_transactions() - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) # excluding of internal transactions with type=call and index=0 - assert [{transaction_hash_1, index_1}, {transaction_hash_2, index_2}] == result + assert [{transaction_index_1, index_1}, {transaction_index_2, index_2}] == result end test "pages by index" do @@ -313,56 +279,47 @@ defmodule Explorer.Chain.InternalTransactionTest do |> insert() |> with_block() - %InternalTransaction{transaction_hash: _, index: _} = + %InternalTransaction{transaction_index: _, index: _} = insert(:internal_transaction, - transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: second_transaction_hash, index: second_index} = + %InternalTransaction{transaction_index: second_transaction_index, index: second_index} = insert(:internal_transaction, - transaction: transaction, index: 1, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: third_transaction_hash, index: third_index} = + %InternalTransaction{transaction_index: third_transaction_index, index: third_index} = insert(:internal_transaction, - transaction: transaction, index: 2, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 2, transaction_index: transaction.index ) - assert [{second_transaction_hash, second_index}, {third_transaction_hash, third_index}] == + assert [{second_transaction_index, second_index}, {third_transaction_index, third_index}] == transaction.hash |> InternalTransaction.transaction_to_internal_transactions( paging_options: %PagingOptions{key: {-1}, page_size: 2} ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) - assert [{second_transaction_hash, second_index}] == + assert [{second_transaction_index, second_index}] == transaction.hash |> InternalTransaction.transaction_to_internal_transactions( paging_options: %PagingOptions{key: {-1}, page_size: 1} ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) - assert [{third_transaction_hash, third_index}] == + assert [{third_transaction_index, third_index}] == transaction.hash |> InternalTransaction.transaction_to_internal_transactions( paging_options: %PagingOptions{key: {1}, page_size: 2} ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) end end @@ -386,8 +343,6 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -395,8 +350,6 @@ defmodule Explorer.Chain.InternalTransactionTest do insert(:internal_transaction, transaction: transaction, index: 1, - block_hash: transaction.block_hash, - block_index: 1, block_number: transaction.block_number, transaction_index: transaction.index ) @@ -408,16 +361,16 @@ defmodule Explorer.Chain.InternalTransactionTest do assert Enum.all?( results, - &({&1.transaction_hash, &1.index} in [ - {first.transaction_hash, first.index}, - {second.transaction_hash, second.index} + &({&1.block_number, &1.transaction_index, &1.index} in [ + {first.block_number, first.transaction_index, first.index}, + {second.block_number, second.transaction_index, second.index} ]) ) assert internal_transaction.block_number == block.number end - test "with transaction with internal transactions loads associations with in necessity_by_association" do + test "with transaction with internal transactions loads associations with in address_preloads" do transaction = :transaction |> insert() @@ -427,33 +380,28 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) assert [ %InternalTransaction{ - from_address: %Ecto.Association.NotLoaded{}, - to_address: %Ecto.Association.NotLoaded{}, - transaction: %Ecto.Association.NotLoaded{} + from_address: %{names: %Ecto.Association.NotLoaded{}}, + to_address: nil } ] = InternalTransaction.all_transaction_to_internal_transactions(transaction.hash) assert [ %InternalTransaction{ - from_address: %Address{}, - to_address: nil, - transaction: %Transaction{block: %Block{}} + from_address: %Address{names: []}, + to_address: nil } ] = InternalTransaction.all_transaction_to_internal_transactions( transaction.hash, - necessity_by_association: %{ - :from_address => :optional, - :to_address => :optional, - [transaction: :block] => :optional - } + address_preloads: [ + from_address: [:names], + to_address: [:names] + ] ) end @@ -467,8 +415,6 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -488,14 +434,13 @@ defmodule Explorer.Chain.InternalTransactionTest do index: 0, transaction: transaction, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) actual = Enum.at(InternalTransaction.all_transaction_to_internal_transactions(transaction.hash), 0) - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} end test "includes internal transactions of type `reward` even when they are alone in the parent transaction" do @@ -510,14 +455,13 @@ defmodule Explorer.Chain.InternalTransactionTest do transaction: transaction, type: :reward, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) actual = Enum.at(InternalTransaction.all_transaction_to_internal_transactions(transaction.hash), 0) - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} end test "includes internal transactions of type `selfdestruct` even when they are alone in the parent transaction" do @@ -533,14 +477,13 @@ defmodule Explorer.Chain.InternalTransactionTest do gas: nil, type: :selfdestruct, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) actual = Enum.at(InternalTransaction.all_transaction_to_internal_transactions(transaction.hash), 0) - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} end test "returns the internal transactions in ascending index order" do @@ -549,32 +492,26 @@ defmodule Explorer.Chain.InternalTransactionTest do |> insert() |> with_block() - %InternalTransaction{transaction_hash: transaction_hash, index: index} = + %InternalTransaction{transaction_index: transaction_index, index: index} = insert(:internal_transaction, - transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: second_transaction_hash, index: second_index} = + %InternalTransaction{transaction_index: second_transaction_index, index: second_index} = insert(:internal_transaction, - transaction: transaction, index: 1, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index ) result = transaction.hash |> InternalTransaction.all_transaction_to_internal_transactions() - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) - assert [{transaction_hash, index}, {second_transaction_hash, second_index}] == result + assert [{transaction_index, index}, {second_transaction_index, second_index}] == result end test "pages by index" do @@ -583,237 +520,622 @@ defmodule Explorer.Chain.InternalTransactionTest do |> insert() |> with_block() - %InternalTransaction{transaction_hash: transaction_hash, index: index} = + %InternalTransaction{transaction_index: transaction_index, index: index} = insert(:internal_transaction, - transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: second_transaction_hash, index: second_index} = + %InternalTransaction{transaction_index: second_transaction_index, index: second_index} = insert(:internal_transaction, - transaction: transaction, index: 1, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index ) - %InternalTransaction{transaction_hash: third_transaction_hash, index: third_index} = + %InternalTransaction{transaction_index: third_transaction_index, index: third_index} = insert(:internal_transaction, - transaction: transaction, index: 2, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 2, transaction_index: transaction.index ) - assert [{transaction_hash, index}, {second_transaction_hash, second_index}] == + assert [{transaction_index, index}, {second_transaction_index, second_index}] == transaction.hash |> InternalTransaction.all_transaction_to_internal_transactions( paging_options: %PagingOptions{key: {-1}, page_size: 2} ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) - assert [{transaction_hash, index}] == + assert [{transaction_index, index}] == transaction.hash |> InternalTransaction.all_transaction_to_internal_transactions( paging_options: %PagingOptions{key: {-1}, page_size: 1} ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) - assert [{third_transaction_hash, third_index}] == + assert [{third_transaction_index, third_index}] == transaction.hash |> InternalTransaction.all_transaction_to_internal_transactions( paging_options: %PagingOptions{key: {1}, page_size: 2} ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + |> Enum.map(&{&1.transaction_index, &1.index}) end end - defp call_type(opts) do - defaults = [ - type: :call, - call_type: :call, - to_address_hash: Factory.address_hash(), - from_address_hash: Factory.address_hash(), - input: Factory.transaction_input(), - output: Factory.transaction_input(), - gas: Decimal.new(50_000), - gas_used: Decimal.new(25_000), - value: %Wei{value: 100}, - index: 0, - trace_address: [] - ] - - struct!(InternalTransaction, Keyword.merge(defaults, opts)) - end + describe "address_to_internal_transactions/1" do + test "with single transaction containing two internal transactions" do + address = insert(:address) - defp create_type(opts) do - defaults = [ - type: :create, - from_address_hash: Factory.address_hash(), - gas: Decimal.new(50_000), - gas_used: Decimal.new(25_000), - value: %Wei{value: 100}, - index: 0, - init: Factory.transaction_input(), - trace_address: [] - ] - - struct!(InternalTransaction, Keyword.merge(defaults, opts)) - end + block = insert(:block, number: 2000) - defp selfdestruct_type(opts) do - defaults = [ - type: :selfdestruct, - from_address_hash: Factory.address_hash(), - to_address_hash: Factory.address_hash(), - gas: Decimal.new(50_000), - gas_used: Decimal.new(25_000), - value: %Wei{value: 100}, - index: 0, - trace_address: [] - ] - - struct!(InternalTransaction, Keyword.merge(defaults, opts)) - end + transaction = + :transaction + |> insert() + |> with_block(block) + + %InternalTransaction{transaction_index: first_transaction_index, index: first_index} = + insert(:internal_transaction, + index: 1, + to_address: address, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + %InternalTransaction{transaction_index: second_transaction_index, index: second_index} = + insert(:internal_transaction, + index: 2, + to_address: address, + block_number: transaction.block_number, + transaction_index: transaction.index + ) - describe "internal_transactions_to_raw" do - test "it adds subtrace count" do - transactions = [ - call_type(trace_address: []), - call_type(trace_address: [0]), - call_type(trace_address: [1]), - call_type(trace_address: [2]), - call_type(trace_address: [0, 0]), - call_type(trace_address: [0, 1]), - call_type(trace_address: [1, 0]), - call_type(trace_address: [0, 0, 0]), - call_type(trace_address: [0, 0, 1]), - call_type(trace_address: [0, 0, 2]), - call_type(trace_address: [0, 1, 0]), - call_type(trace_address: [0, 1, 1]) - ] - - subtraces = - transactions - |> InternalTransaction.internal_transactions_to_raw() - |> Enum.map(&Map.get(&1, "subtraces")) - - assert subtraces == [3, 2, 1, 0, 3, 2, 0, 0, 0, 0, 0, 0] + result = + address.hash + |> InternalTransaction.address_to_internal_transactions() + |> Enum.map(&{&1.transaction_index, &1.index}) + + assert Enum.member?(result, {first_transaction_index, first_index}) + assert Enum.member?(result, {second_transaction_index, second_index}) end - test "it correctly formats a call" do - from = Factory.address_hash() - to = Factory.address_hash() - gas = 50_000 - gas_used = 25_000 - input = Factory.transaction_input() - value = 50 - output = Factory.transaction_input() - - call_transaction = - call_type( - from_address_hash: from, - to_address_hash: to, - gas: Decimal.new(gas), - gas_used: Decimal.new(gas_used), - input: input, - value: %Wei{value: value}, - output: output - ) - - [call] = InternalTransaction.internal_transactions_to_raw([call_transaction]) - - assert call == %{ - "action" => %{ - "callType" => "call", - "from" => to_string(from), - "gas" => integer_to_quantity(gas), - "input" => to_string(input), - "to" => to_string(to), - "value" => integer_to_quantity(value) - }, - "result" => %{ - "gasUsed" => integer_to_quantity(gas_used), - "output" => to_string(output) - }, - "subtraces" => 0, - "traceAddress" => [], - "type" => "call" - } + test "loads associations in address_preloads" do + %Address{hash: address_hash} = address = insert(:address) + block = insert(:block, number: 2000) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + to_address: address, + index: 0, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + insert(:internal_transaction, + transaction: transaction, + to_address: address, + index: 1, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + assert [ + %InternalTransaction{ + from_address: %{names: %Ecto.Association.NotLoaded{}}, + to_address: %{names: %Ecto.Association.NotLoaded{}} + } + | _ + ] = InternalTransaction.address_to_internal_transactions(address_hash) + + assert [ + %InternalTransaction{ + from_address: %Address{names: []}, + to_address: %Address{names: []} + } + | _ + ] = + InternalTransaction.address_to_internal_transactions( + address_hash, + address_preloads: [ + from_address: [:names], + to_address: [:names] + ] + ) end - test "it correctly formats a create" do - {:ok, contract_code} = Data.cast(Factory.contract_code_info().bytecode) - contract_address = Factory.address_hash() - from = Factory.address_hash() - gas = 50_000 - gas_used = 25_000 - init = Factory.transaction_input() - value = 50 - - create_transaction = - create_type( - from_address_hash: from, - created_contract_code: contract_code, - created_contract_address_hash: contract_address, - gas: Decimal.new(gas), - gas_used: Decimal.new(gas_used), - init: init, - value: %Wei{value: value} - ) - - [create] = InternalTransaction.internal_transactions_to_raw([create_transaction]) - - assert create == %{ - "action" => %{ - "from" => to_string(from), - "gas" => integer_to_quantity(gas), - "init" => to_string(init), - "value" => integer_to_quantity(value) - }, - "result" => %{ - "address" => to_string(contract_address), - "code" => to_string(contract_code), - "gasUsed" => integer_to_quantity(gas_used) - }, - "subtraces" => 0, - "traceAddress" => [], - "type" => "create" - } + test "returns results in reverse chronological order by block number, transaction index, internal transaction index" do + address = insert(:address) + + block = insert(:block, number: 7000) + + pending_transaction = + :transaction + |> insert() + |> with_block(block) + + %InternalTransaction{ + block_number: first_pending_block_number, + transaction_index: first_pending_transaction_index, + index: first_pending_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: pending_transaction.block_number, + transaction_index: pending_transaction.index + ) + + %InternalTransaction{ + block_number: second_pending_block_number, + transaction_index: second_pending_transaction_index, + index: second_pending_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: pending_transaction.block_number, + transaction_index: pending_transaction.index + ) + + a_block = insert(:block, number: 2000) + + first_a_transaction = + :transaction + |> insert() + |> with_block(a_block) + + %InternalTransaction{ + block_number: first_block_number, + transaction_index: first_transaction_index, + index: first_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: first_a_transaction.block_number, + transaction_index: first_a_transaction.index + ) + + %InternalTransaction{ + block_number: second_block_number, + transaction_index: second_transaction_index, + index: second_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: first_a_transaction.block_number, + transaction_index: first_a_transaction.index + ) + + second_a_transaction = + :transaction + |> insert() + |> with_block(a_block) + + %InternalTransaction{ + block_number: third_block_number, + transaction_index: third_transaction_index, + index: third_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: second_a_transaction.block_number, + transaction_index: second_a_transaction.index + ) + + %InternalTransaction{ + block_number: fourth_block_number, + transaction_index: fourth_transaction_index, + index: fourth_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: second_a_transaction.block_number, + transaction_index: second_a_transaction.index + ) + + b_block = insert(:block, number: 6000) + + first_b_transaction = + :transaction + |> insert() + |> with_block(b_block) + + %InternalTransaction{ + block_number: fifth_block_number, + transaction_index: fifth_transaction_index, + index: fifth_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: first_b_transaction.block_number, + transaction_index: first_b_transaction.index + ) + + %InternalTransaction{ + block_number: sixth_block_number, + transaction_index: sixth_transaction_index, + index: sixth_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: first_b_transaction.block_number, + transaction_index: first_b_transaction.index + ) + + result = + address.hash + |> InternalTransaction.address_to_internal_transactions() + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.index}) + + assert [ + {second_pending_block_number, second_pending_transaction_index, second_pending_index}, + {first_pending_block_number, first_pending_transaction_index, first_pending_index}, + {sixth_block_number, sixth_transaction_index, sixth_index}, + {fifth_block_number, fifth_transaction_index, fifth_index}, + {fourth_block_number, fourth_transaction_index, fourth_index}, + {third_block_number, third_transaction_index, third_index}, + {second_block_number, second_transaction_index, second_index}, + {first_block_number, first_transaction_index, first_index} + ] == result end - test "it correctly formats a selfdestruct" do - from_address = Factory.address_hash() - to_address = Factory.address_hash() + test "pages by {block_number, transaction_index, index}" do + address = insert(:address) - value = 50 + pending_transaction = insert(:transaction) - selfdestruct_transaction = - selfdestruct_type( - to_address_hash: to_address, - from_address_hash: from_address, - value: %Wei{value: value} + a_block = insert(:block, number: 2000) + + first_a_transaction = + :transaction + |> insert() + |> with_block(a_block) + + %InternalTransaction{ + block_number: first_block_number, + transaction_index: first_transaction_index, + index: first_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: first_a_transaction.block_number, + transaction_index: first_a_transaction.index + ) + + %InternalTransaction{ + block_number: second_block_number, + transaction_index: second_transaction_index, + index: second_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: first_a_transaction.block_number, + transaction_index: first_a_transaction.index + ) + + second_a_transaction = + :transaction + |> insert() + |> with_block(a_block) + + %InternalTransaction{ + block_number: third_block_number, + transaction_index: third_transaction_index, + index: third_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: second_a_transaction.block_number, + transaction_index: second_a_transaction.index ) - [selfdestruct] = InternalTransaction.internal_transactions_to_raw([selfdestruct_transaction]) + %InternalTransaction{ + block_number: fourth_block_number, + transaction_index: fourth_transaction_index, + index: fourth_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: second_a_transaction.block_number, + transaction_index: second_a_transaction.index + ) + + b_block = insert(:block, number: 6000) + + first_b_transaction = + :transaction + |> insert() + |> with_block(b_block) + + %InternalTransaction{ + block_number: fifth_block_number, + transaction_index: fifth_transaction_index, + index: fifth_index + } = + insert( + :internal_transaction, + to_address: address, + index: 1, + block_number: first_b_transaction.block_number, + transaction_index: first_b_transaction.index + ) + + %InternalTransaction{ + block_number: sixth_block_number, + transaction_index: sixth_transaction_index, + index: sixth_index + } = + insert( + :internal_transaction, + to_address: address, + index: 2, + block_number: first_b_transaction.block_number, + transaction_index: first_b_transaction.index + ) - assert selfdestruct == %{ - "action" => %{ - "address" => to_string(from_address), - "balance" => integer_to_quantity(value), - "refundAddress" => to_string(to_address) - }, - "subtraces" => 0, - "traceAddress" => [], - "type" => "suicide" + # When paged, internal transactions need an associated block number, so `second_pending` and `first_pending` are + # excluded. + assert [ + {sixth_block_number, sixth_transaction_index, sixth_index}, + {fifth_block_number, fifth_transaction_index, fifth_index}, + {fourth_block_number, fourth_transaction_index, fourth_index}, + {third_block_number, third_transaction_index, third_index}, + {second_block_number, second_transaction_index, second_index}, + {first_block_number, first_transaction_index, first_index} + ] == + address.hash + |> InternalTransaction.address_to_internal_transactions( + paging_options: %PagingOptions{key: {6001, 3, 2}, page_size: 8} + ) + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.index}) + + # block number ==, transaction index ==, internal transaction index < + assert [ + {fourth_block_number, fourth_transaction_index, fourth_index}, + {third_block_number, third_transaction_index, third_index}, + {second_block_number, second_transaction_index, second_index}, + {first_block_number, first_transaction_index, first_index} + ] == + address.hash + |> InternalTransaction.address_to_internal_transactions( + paging_options: %PagingOptions{key: {6000, 0, 1}, page_size: 8} + ) + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.index}) + + # block number ==, transaction index < + assert [ + {fourth_block_number, fourth_transaction_index, fourth_index}, + {third_block_number, third_transaction_index, third_index}, + {second_block_number, second_transaction_index, second_index}, + {first_block_number, first_transaction_index, first_index} + ] == + address.hash + |> InternalTransaction.address_to_internal_transactions( + paging_options: %PagingOptions{key: {6000, -1, -1}, page_size: 8} + ) + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.index}) + + # block number < + assert [] == + address.hash + |> InternalTransaction.address_to_internal_transactions( + paging_options: %PagingOptions{key: {2000, -1, -1}, page_size: 8} + ) + |> Enum.map(&{&1.block_number, &1.transaction_index, &1.index}) + end + + test "excludes internal transactions of type `call` when they are alone in the parent transaction" do + %Address{hash: address_hash} = address = insert(:address) + + transaction = + :transaction + |> insert(to_address: address) + |> with_block() + + insert(:internal_transaction, + index: 0, + to_address: address, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + assert Enum.empty?(InternalTransaction.address_to_internal_transactions(address_hash)) + end + + test "includes internal transactions of type `create` even when they are alone in the parent transaction" do + %Address{hash: address_hash} = address = insert(:address) + + transaction = + :transaction + |> insert(to_address: address) + |> with_block() + + expected = + insert( + :internal_transaction_create, + index: 0, + from_address: address, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + + actual = Enum.at(InternalTransaction.address_to_internal_transactions(address_hash), 0) + + assert {actual.block_number, actual.transaction_index, actual.index} == + {expected.block_number, expected.transaction_index, expected.index} + end + end + + describe "fetch/1" do + # todo: This test is temporarily disabled because this check is removed for the sake of performance: + # |> where([internal_transaction, transaction], transaction.block_hash == internal_transaction.block_hash) + # Return the test back when reorg data will be moved out from the main tables. + # test "with consensus transactions and blocks only" do + # BackgroundMigrations.set_transactions_denormalization_finished(true) + # block_non_consensus = insert(:block, number: 2000, consensus: false) + # block_consensus = insert(:block, number: 3000) + + # transaction = + # :transaction + # |> insert() + # |> with_block(block_consensus) + + # insert(:internal_transaction, + # index: 1, + # transaction: transaction, + # block_number: transaction.block_number, + # block_hash: block_non_consensus.hash, + # transaction_index: transaction.index + # ) + + # consensus_it = + # insert(:internal_transaction, + # index: 2, + # transaction: transaction, + # block_number: transaction.block_number, + # block_hash: block_consensus.hash, + # transaction_index: transaction.index + # ) + + # assert [{consensus_it.transaction_hash, consensus_it.index, consensus_it.block_hash}] == + # [] + # |> InternalTransaction.fetch() + # |> Enum.map(&{&1.transaction_hash, &1.index, &1.block_hash}) + # end + end + + describe "fetch_first_trace/2" do + test "fetched first trace", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + from_address_hash = "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" + gas = 4_533_872 + + init = + "0x6060604052341561000f57600080fd5b60405160208061071a83398101604052808051906020019091905050806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600160006001600281111561007e57fe5b60ff1660ff168152602001908152602001600020819055506002600160006002808111156100a857fe5b60ff1660ff168152602001908152602001600020819055505061064a806100d06000396000f30060606040526004361061008e576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff168063247b3210146100935780632ffdfc8a146100bc57806374294144146100f6578063ae4b1b5b14610125578063bf7370d11461017a578063d1104cb2146101a3578063eecd1079146101f8578063fcff021c14610221575b600080fd5b341561009e57600080fd5b6100a661024a565b6040518082815260200191505060405180910390f35b34156100c757600080fd5b6100e0600480803560ff16906020019091905050610253565b6040518082815260200191505060405180910390f35b341561010157600080fd5b610123600480803590602001909190803560ff16906020019091905050610276565b005b341561013057600080fd5b61013861037a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561018557600080fd5b61018d61039f565b6040518082815260200191505060405180910390f35b34156101ae57600080fd5b6101b66104d9565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561020357600080fd5b61020b610588565b6040518082815260200191505060405180910390f35b341561022c57600080fd5b6102346105bd565b6040518082815260200191505060405180910390f35b600060c8905090565b6000600160008360ff1660ff168152602001908152602001600020549050919050565b61027e6104d9565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156102b757600080fd5b60008160ff161115156102c957600080fd5b6002808111156102d557fe5b60ff168160ff16111515156102e957600080fd5b6000821180156103125750600160008260ff1660ff168152602001908152602001600020548214155b151561031d57600080fd5b81600160008360ff1660ff168152602001908152602001600020819055508060ff167fe868bbbdd6cd2efcd9ba6e0129d43c349b0645524aba13f8a43bfc7c5ffb0889836040518082815260200191505060405180910390a25050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000806000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16638b8414c46000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561042f57600080fd5b6102c65a03f1151561044057600080fd5b5050506040518051905090508073ffffffffffffffffffffffffffffffffffffffff16630eaba26a6000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b15156104b857600080fd5b6102c65a03f115156104c957600080fd5b5050506040518051905091505090565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663a3b3fff16000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561056857600080fd5b6102c65a03f1151561057957600080fd5b50505060405180519050905090565b60006105b860016105aa600261059c61039f565b6105e590919063ffffffff16565b61060090919063ffffffff16565b905090565b60006105e06105ca61039f565b6105d261024a565b6105e590919063ffffffff16565b905090565b60008082848115156105f357fe5b0490508091505092915050565b600080828401905083811015151561061457fe5b80915050929150505600a165627a7a723058206b7eef2a57eb659d5e77e45ab5bc074e99c6a841921038cdb931e119c6aac46c0029000000000000000000000000862d67cb0773ee3f8ce7ea89b328ffea861ab3ef" + + value = 0 + block_number = 39 + block_hash = "0x74c72ccabcb98b7ebbd7b31de938212b7e8814a002263b6569564e944d88f51f" + index = 0 + created_contract_address_hash = "0x1e0eaa06d02f965be2dfe0bc9ff52b2d82133461" + + created_contract_code = + "0x60606040526004361061008e576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff168063247b3210146100935780632ffdfc8a146100bc57806374294144146100f6578063ae4b1b5b14610125578063bf7370d11461017a578063d1104cb2146101a3578063eecd1079146101f8578063fcff021c14610221575b600080fd5b341561009e57600080fd5b6100a661024a565b6040518082815260200191505060405180910390f35b34156100c757600080fd5b6100e0600480803560ff16906020019091905050610253565b6040518082815260200191505060405180910390f35b341561010157600080fd5b610123600480803590602001909190803560ff16906020019091905050610276565b005b341561013057600080fd5b61013861037a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561018557600080fd5b61018d61039f565b6040518082815260200191505060405180910390f35b34156101ae57600080fd5b6101b66104d9565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561020357600080fd5b61020b610588565b6040518082815260200191505060405180910390f35b341561022c57600080fd5b6102346105bd565b6040518082815260200191505060405180910390f35b600060c8905090565b6000600160008360ff1660ff168152602001908152602001600020549050919050565b61027e6104d9565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156102b757600080fd5b60008160ff161115156102c957600080fd5b6002808111156102d557fe5b60ff168160ff16111515156102e957600080fd5b6000821180156103125750600160008260ff1660ff168152602001908152602001600020548214155b151561031d57600080fd5b81600160008360ff1660ff168152602001908152602001600020819055508060ff167fe868bbbdd6cd2efcd9ba6e0129d43c349b0645524aba13f8a43bfc7c5ffb0889836040518082815260200191505060405180910390a25050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000806000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16638b8414c46000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561042f57600080fd5b6102c65a03f1151561044057600080fd5b5050506040518051905090508073ffffffffffffffffffffffffffffffffffffffff16630eaba26a6000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b15156104b857600080fd5b6102c65a03f115156104c957600080fd5b5050506040518051905091505090565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663a3b3fff16000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561056857600080fd5b6102c65a03f1151561057957600080fd5b50505060405180519050905090565b60006105b860016105aa600261059c61039f565b6105e590919063ffffffff16565b61060090919063ffffffff16565b905090565b60006105e06105ca61039f565b6105d261024a565b6105e590919063ffffffff16565b905090565b60008082848115156105f357fe5b0490508091505092915050565b600080828401905083811015151561061457fe5b80915050929150505600a165627a7a723058206b7eef2a57eb659d5e77e45ab5bc074e99c6a841921038cdb931e119c6aac46c0029" + + gas_used = 382_953 + trace_address = [] + transaction_hash = "0x0fa6f723216dba694337f9bb37d8870725655bdf2573526a39454685659e39b1" + transaction_index = 0 + type = "create" + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + expect(EthereumJSONRPC.Mox, :json_rpc, fn _json, _options -> + {:ok, + [ + %{ + id: 0, + result: %{ + "output" => "0x", + "stateDiff" => nil, + "trace" => [ + %{ + "action" => %{ + "from" => from_address_hash, + "gas" => integer_to_quantity(gas), + "init" => init, + "value" => integer_to_quantity(value) + }, + "blockNumber" => block_number, + "index" => index, + "result" => %{ + "address" => created_contract_address_hash, + "code" => created_contract_code, + "gasUsed" => integer_to_quantity(gas_used) + }, + "traceAddress" => trace_address, + "type" => type + } + ], + "transactionHash" => transaction_hash + } + } + ]} + end) + end + + {:ok, created_contract_address_hash_bytes} = Chain.string_to_address_hash(created_contract_address_hash) + {:ok, from_address_hash_bytes} = Chain.string_to_address_hash(from_address_hash) + {:ok, created_contract_code_bytes} = Data.cast(created_contract_code) + {:ok, init_bytes} = Data.cast(init) + {:ok, transaction_hash_bytes} = Chain.string_to_full_hash(transaction_hash) + {:ok, type_bytes} = Type.load(type) + value_wei = %Wei{value: Decimal.new(value)} + + assert InternalTransaction.fetch_first_trace( + [ + %{ + hash_data: transaction_hash, + block_hash: block_hash, + block_number: block_number, + transaction_index: transaction_index + } + ], + json_rpc_named_arguments + ) == { + :ok, + [ + %{ + block_number: block_number, + block_hash: block_hash, + call_type: nil, + created_contract_address_hash: created_contract_address_hash_bytes, + created_contract_code: created_contract_code_bytes, + from_address_hash: from_address_hash_bytes, + gas: gas, + gas_used: gas_used, + index: index, + init: init_bytes, + input: nil, + output: nil, + to_address_hash: nil, + trace_address: trace_address, + transaction_hash: transaction_hash_bytes, + type: type_bytes, + value: value_wei, + transaction_index: transaction_index + } + ] } end end diff --git a/apps/explorer/test/explorer/chain/log_test.exs b/apps/explorer/test/explorer/chain/log_test.exs index 853d327880d7..306a727f6311 100644 --- a/apps/explorer/test/explorer/chain/log_test.exs +++ b/apps/explorer/test/explorer/chain/log_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.LogTest do use Explorer.DataCase @@ -5,7 +6,6 @@ defmodule Explorer.Chain.LogTest do alias Ecto.Changeset alias Explorer.Chain.{Log, SmartContract} - alias Explorer.{Repo, TestHelper} @first_topic_hex_string_1 "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65" @@ -70,25 +70,27 @@ defmodule Explorer.Chain.LogTest do log = insert(:log, transaction: transaction) - assert {{:error, :could_not_decode}, _, _} = Log.decode(log, transaction, [], false, false) + assert {{:error, :could_not_decode}, _} = Log.decode(log, transaction, [], false, false, []) end test "that a contract call transaction that has a verified contract returns the decoded input data" do to_address = insert(:address, contract_code: "0x") + abi = [ + %{ + "anonymous" => false, + "inputs" => [ + %{"indexed" => true, "name" => "_from_human", "type" => "string"}, + %{"indexed" => false, "name" => "_number", "type" => "uint256"}, + %{"indexed" => true, "name" => "_belly", "type" => "bool"} + ], + "name" => "WantsPets", + "type" => "event" + } + ] + insert(:smart_contract, - abi: [ - %{ - "anonymous" => false, - "inputs" => [ - %{"indexed" => true, "name" => "_from_human", "type" => "string"}, - %{"indexed" => false, "name" => "_number", "type" => "uint256"}, - %{"indexed" => true, "name" => "_belly", "type" => "bool"} - ], - "name" => "WantsPets", - "type" => "event" - } - ], + abi: abi, address_hash: to_address.hash, contract_code_md5: "123" ) @@ -116,8 +118,6 @@ defmodule Explorer.Chain.LogTest do data: data ) - TestHelper.get_all_proxies_implementation_zero_addresses() - assert {{:ok, "eb9b3c4c", "WantsPets(string indexed _from_human, uint256 _number, bool indexed _belly)", [ {"_from_human", "string", true, @@ -126,25 +126,27 @@ defmodule Explorer.Chain.LogTest do 152, 206, 227, 92, 181, 213, 23, 242, 210, 150, 162>>}}, {"_number", "uint256", false, 0}, {"_belly", "bool", true, true} - ]}, _, _} = Log.decode(log, transaction, [], false, false) + ]}, _} = Log.decode(log, transaction, [], false, false, abi) end test "replace arg names with argN if it's empty string" do to_address = insert(:address, contract_code: "0x") + abi = [ + %{ + "anonymous" => false, + "inputs" => [ + %{"indexed" => true, "name" => "", "type" => "string"}, + %{"indexed" => false, "name" => "", "type" => "uint256"}, + %{"indexed" => true, "name" => "", "type" => "bool"} + ], + "name" => "WantsPets", + "type" => "event" + } + ] + insert(:smart_contract, - abi: [ - %{ - "anonymous" => false, - "inputs" => [ - %{"indexed" => true, "name" => "", "type" => "string"}, - %{"indexed" => false, "name" => "", "type" => "uint256"}, - %{"indexed" => true, "name" => "", "type" => "bool"} - ], - "name" => "WantsPets", - "type" => "event" - } - ], + abi: abi, address_hash: to_address.hash, contract_code_md5: "123" ) @@ -172,8 +174,6 @@ defmodule Explorer.Chain.LogTest do data: data ) - TestHelper.get_all_proxies_implementation_zero_addresses() - assert {{:ok, "eb9b3c4c", "WantsPets(string indexed arg0, uint256 arg1, bool indexed arg2)", [ {"arg0", "string", true, @@ -182,24 +182,26 @@ defmodule Explorer.Chain.LogTest do 152, 206, 227, 92, 181, 213, 23, 242, 210, 150, 162>>}}, {"arg1", "uint256", false, 0}, {"arg2", "bool", true, true} - ]}, _, _} = Log.decode(log, transaction, [], false, false) + ]}, _} = Log.decode(log, transaction, [], false, false, abi) end test "finds decoding candidates" do + abi = [ + %{ + "anonymous" => false, + "inputs" => [ + %{"indexed" => true, "name" => "_from_human", "type" => "string"}, + %{"indexed" => false, "name" => "_number", "type" => "uint256"}, + %{"indexed" => true, "name" => "_belly", "type" => "bool"} + ], + "name" => "WantsPets", + "type" => "event" + } + ] + params = params_for(:smart_contract, %{ - abi: [ - %{ - "anonymous" => false, - "inputs" => [ - %{"indexed" => true, "name" => "_from_human", "type" => "string"}, - %{"indexed" => false, "name" => "_number", "type" => "uint256"}, - %{"indexed" => true, "name" => "_belly", "type" => "bool"} - ], - "name" => "WantsPets", - "type" => "event" - } - ] + abi: abi }) # changeset has a callback to insert contract methods @@ -226,18 +228,15 @@ defmodule Explorer.Chain.LogTest do data: data ) - assert {{:error, :contract_not_verified, + assert {{:ok, "eb9b3c4c", "WantsPets(string indexed _from_human, uint256 _number, bool indexed _belly)", [ - {:ok, "eb9b3c4c", "WantsPets(string indexed _from_human, uint256 _number, bool indexed _belly)", - [ - {"_from_human", "string", true, - {:dynamic, - <<56, 228, 122, 123, 113, 157, 206, 99, 102, 42, 234, 244, 52, 64, 50, 111, 85, 27, 138, 126, 225, - 152, 206, 227, 92, 181, 213, 23, 242, 210, 150, 162>>}}, - {"_number", "uint256", false, 0}, - {"_belly", "bool", true, true} - ]} - ]}, _, _} = Log.decode(log, transaction, [], false, false) + {"_from_human", "string", true, + {:dynamic, + <<56, 228, 122, 123, 113, 157, 206, 99, 102, 42, 234, 244, 52, 64, 50, 111, 85, 27, 138, 126, 225, + 152, 206, 227, 92, 181, 213, 23, 242, 210, 150, 162>>}}, + {"_number", "uint256", false, 0}, + {"_belly", "bool", true, true} + ]}, _} = Log.decode(log, transaction, [], false, false, abi) end end end diff --git a/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs b/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs new file mode 100644 index 000000000000..09d6cdc5b56a --- /dev/null +++ b/apps/explorer/test/explorer/chain/metrics/indexer_metrics_test.exs @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Metrics.Queries.IndexerMetricsTest do + use Explorer.DataCase, async: false + + import Explorer.Factory + + alias Explorer.Chain.Metrics.Queries.IndexerMetrics + + describe "missing_blocks_count/0" do + test "counts only within configured ranges and latest tail" do + previous_block_ranges = Application.get_env(:indexer, :block_ranges) + on_exit(fn -> Application.put_env(:indexer, :block_ranges, previous_block_ranges) end) + + Application.put_env(:indexer, :block_ranges, "1..3,5..latest") + + Enum.each([1, 2, 3, 5, 7, 8], fn number -> + insert(:block, number: number, consensus: true) + end) + + assert IndexerMetrics.missing_blocks_count() == 1 + end + + test "counts only within finite ranges" do + previous_block_ranges = Application.get_env(:indexer, :block_ranges) + on_exit(fn -> Application.put_env(:indexer, :block_ranges, previous_block_ranges) end) + + Application.put_env(:indexer, :block_ranges, "10..12,20..22") + + Enum.each([10, 11, 12, 20, 22], fn number -> + insert(:block, number: number, consensus: true) + end) + + assert IndexerMetrics.missing_blocks_count() == 1 + end + end + + describe "missing_internal_transactions_count/0" do + test "counts pending block operations when pending_operations_type is blocks" do + previous_explorer_config = Application.get_env(:explorer, :json_rpc_named_arguments) + previous_geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + previous_trace_first_block = Application.get_env(:indexer, :trace_first_block) + + on_exit(fn -> + Application.put_env(:explorer, :json_rpc_named_arguments, previous_explorer_config) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, previous_geth_config) + Application.put_env(:indexer, :trace_first_block, previous_trace_first_block) + end) + + # Set configuration to use "blocks" mode (non-Geth or Geth with block_traceable) + Application.put_env(:explorer, :json_rpc_named_arguments, variant: EthereumJSONRPC.Nethermind) + + block1 = insert(:block) + block2 = insert(:block) + block3 = insert(:block) + + insert(:pending_block_operation, block_hash: block1.hash, block_number: block1.number) + insert(:pending_block_operation, block_hash: block2.hash, block_number: block2.number) + insert(:pending_block_operation, block_hash: block3.hash, block_number: block3.number) + + assert IndexerMetrics.missing_internal_transactions_count() == 3 + end + + test "respects trace_first_block when counting pending block operations" do + previous_explorer_config = Application.get_env(:explorer, :json_rpc_named_arguments) + previous_geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + previous_trace_first_block = Application.get_env(:indexer, :trace_first_block) + + on_exit(fn -> + Application.put_env(:explorer, :json_rpc_named_arguments, previous_explorer_config) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, previous_geth_config) + Application.put_env(:indexer, :trace_first_block, previous_trace_first_block) + end) + + Application.put_env(:explorer, :json_rpc_named_arguments, variant: EthereumJSONRPC.Nethermind) + Application.put_env(:indexer, :trace_first_block, 10) + + block1 = insert(:block, number: 8) + block2 = insert(:block, number: 10) + block3 = insert(:block, number: 12) + + insert(:pending_block_operation, block_hash: block1.hash, block_number: block1.number) + insert(:pending_block_operation, block_hash: block2.hash, block_number: block2.number) + insert(:pending_block_operation, block_hash: block3.hash, block_number: block3.number) + + assert IndexerMetrics.missing_internal_transactions_count() == 2 + end + + test "counts pending transaction operations when pending_operations_type is transactions" do + previous_explorer_config = Application.get_env(:explorer, :json_rpc_named_arguments) + previous_geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + previous_trace_first_block = Application.get_env(:indexer, :trace_first_block) + + on_exit(fn -> + Application.put_env(:explorer, :json_rpc_named_arguments, previous_explorer_config) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, previous_geth_config) + Application.put_env(:indexer, :trace_first_block, previous_trace_first_block) + end) + + # Set configuration to use "transactions" mode (Geth with block_traceable? = false) + Application.put_env(:explorer, :json_rpc_named_arguments, variant: EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, block_traceable?: false) + + transaction1 = insert(:transaction) + transaction2 = insert(:transaction) + + insert(:pending_transaction_operation, transaction_hash: transaction1.hash) + insert(:pending_transaction_operation, transaction_hash: transaction2.hash) + + assert IndexerMetrics.missing_internal_transactions_count() == 2 + end + end + + describe "missing_archival_token_balances_count/0" do + test "returns 0 when archival token balances fetcher is disabled" do + previous_config = + Application.get_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, previous_config) + end) + + Application.put_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, disabled?: true) + + insert(:token_balance, value_fetched_at: nil) + insert(:token_balance, value_fetched_at: nil) + + assert IndexerMetrics.missing_archival_token_balances_count() == 0 + end + + test "counts token balances with missing values when fetcher is enabled" do + previous_config = + Application.get_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, previous_config) + end) + + Application.put_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, disabled?: false) + + insert(:token_balance, value_fetched_at: nil) + insert(:token_balance, value_fetched_at: nil) + insert(:token_balance, value_fetched_at: DateTime.utc_now()) + + assert IndexerMetrics.missing_archival_token_balances_count() == 2 + end + + test "returns 0 when all token balances are fetched" do + previous_config = + Application.get_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, previous_config) + end) + + Application.put_env(:indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, disabled?: false) + + insert(:token_balance, value_fetched_at: DateTime.utc_now()) + insert(:token_balance, value_fetched_at: DateTime.utc_now()) + + assert IndexerMetrics.missing_archival_token_balances_count() == 0 + end + end + + describe "multichain_search_db_export_balances_queue_count/0" do + test "returns 0 when multichain search is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, service_url: nil) + + insert(:multichain_search_db_export_balances_queue) + insert(:multichain_search_db_export_balances_queue) + + assert IndexerMetrics.multichain_search_db_export_balances_queue_count() == 0 + end + + test "returns 0 when balances export queue supervisor is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor, disabled?: true) + + insert(:multichain_search_db_export_balances_queue) + insert(:multichain_search_db_export_balances_queue) + + assert IndexerMetrics.multichain_search_db_export_balances_queue_count() == 0 + end + + test "counts queue entries when both enabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor, disabled?: false) + + insert(:multichain_search_db_export_balances_queue) + insert(:multichain_search_db_export_balances_queue) + + assert IndexerMetrics.multichain_search_db_export_balances_queue_count() == 2 + end + end + + describe "multichain_search_db_export_counters_queue_count/0" do + test "returns 0 when multichain search is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, service_url: nil) + + insert(:multichain_search_db_export_counters_queue) + + assert IndexerMetrics.multichain_search_db_export_counters_queue_count() == 0 + end + + test "returns 0 when counters export queue supervisor is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor, disabled?: true) + + insert(:multichain_search_db_export_counters_queue) + + assert IndexerMetrics.multichain_search_db_export_counters_queue_count() == 0 + end + + test "counts queue entries when both enabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor, disabled?: false) + + insert(:multichain_search_db_export_counters_queue) + + assert IndexerMetrics.multichain_search_db_export_counters_queue_count() == 1 + end + end + + describe "multichain_search_db_export_token_info_queue_count/0" do + test "returns 0 when multichain search is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, service_url: nil) + + insert(:multichain_search_db_export_token_info_queue) + + assert IndexerMetrics.multichain_search_db_export_token_info_queue_count() == 0 + end + + test "returns 0 when token info export queue supervisor is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor, disabled?: true) + + insert(:multichain_search_db_export_token_info_queue) + + assert IndexerMetrics.multichain_search_db_export_token_info_queue_count() == 0 + end + + test "counts queue entries when both enabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor, + disabled?: false + ) + + insert(:multichain_search_db_export_token_info_queue) + + assert IndexerMetrics.multichain_search_db_export_token_info_queue_count() == 1 + end + end + + describe "multichain_search_db_main_export_queue_count/0" do + test "returns 0 when multichain search is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, service_url: nil) + + insert(:multichain_search_db_main_export_queue) + + assert IndexerMetrics.multichain_search_db_main_export_queue_count() == 0 + end + + test "returns 0 when main export queue supervisor is disabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor, disabled?: true) + + insert(:multichain_search_db_main_export_queue) + + assert IndexerMetrics.multichain_search_db_main_export_queue_count() == 0 + end + + test "counts queue entries when both enabled" do + previous_enabled = + Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) + + previous_supervisor_config = + Application.get_env(:indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, previous_enabled) + + Application.put_env( + :indexer, + Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:8080" + ) + + Application.put_env(:indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor, disabled?: false) + + insert(:multichain_search_db_main_export_queue) + + assert IndexerMetrics.multichain_search_db_main_export_queue_count() == 1 + end + end +end diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments_test.exs new file mode 100644 index 000000000000..1092228ca407 --- /dev/null +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy/clone_with_immutable_arguments_test.exs @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.Proxy.CloneWithImmutableArgumentsTest do + use Explorer.DataCase + + alias Explorer.Chain.SmartContract.Proxy.CloneWithImmutableArguments + + # Test address hex (20 bytes = 40 hex chars) + @test_impl_address "1234567890123456789012345678901234567890" + @test_impl_address_2 "AABBCCDDEE11223344556677889900112233AABB" + + describe "quick_resolve_implementations/2 with wighawag variant" do + test "returns implementation address hash for valid wighawag proxy address" do + # wighawag variant bytecode pattern + # 0x3D3D3D3D363D3D3761 (9 bytes) + 2 bytes + 0x603736393661 (6 bytes) + 2 bytes + 0x013D73 (3 bytes) + implementation address (20 bytes) + rest + bytecode = + "0x3D3D3D3D363D3D3761" <> + "AABB" <> + "603736393661" <> + "CCDD" <> + "013D73" <> + @test_impl_address <> + "5AF43D82803E903D91602B57FD5BF3" + + proxy_address = insert(:address, contract_code: bytecode) + {:ok, impl_hash} = Explorer.Chain.Hash.Address.cast("0x" <> @test_impl_address) + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + {:ok, [impl_hash]} + end + + test "returns implementation address hash for valid wighawag proxy with additional bytecode" do + bytecode = + "0x3D3D3D3D363D3D3761" <> + "AABB" <> + "603736393661" <> + "CCDD" <> + "013D73" <> + @test_impl_address <> + "5AF43D82803E903D91602B57FD5BF3" <> + "DEADBEEF" <> + "1234567890" + + proxy_address = insert(:address, contract_code: bytecode) + {:ok, impl_hash} = Explorer.Chain.Hash.Address.cast("0x" <> @test_impl_address) + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + {:ok, [impl_hash]} + end + + test "returns nil for invalid wighawag proxy address" do + proxy_address = insert(:address, contract_code: "0x60806040") + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + nil + end + + test "returns nil for proxy address without contract code" do + proxy_address = insert(:address, contract_code: nil) + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + nil + end + end + + describe "quick_resolve_implementations/2 with solady variant" do + test "returns implementation address hash for valid solady proxy address" do + # solady variant bytecode pattern + # 0x363d3d373d3d3d363d73 (10 bytes) + implementation address (20 bytes) + 0x5af43d3d93803e602a57fd5bf3 (13 bytes) + rest + bytecode = + "0x363d3d373d3d3d363d73" <> + @test_impl_address <> + "5af43d3d93803e602a57fd5bf3" + + proxy_address = insert(:address, contract_code: bytecode) + {:ok, impl_hash} = Explorer.Chain.Hash.Address.cast("0x" <> @test_impl_address) + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + {:ok, [impl_hash]} + end + + test "returns implementation address hash for valid solady proxy with immutable arguments" do + bytecode = + "0x363d3d373d3d3d363d73" <> + @test_impl_address <> + "5af43d3d93803e602a57fd5bf3" <> + "AABBCCDDEE11223344556677" <> + "8899" + + proxy_address = insert(:address, contract_code: bytecode) + {:ok, impl_hash} = Explorer.Chain.Hash.Address.cast("0x" <> @test_impl_address) + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + {:ok, [impl_hash]} + end + + test "returns nil for invalid solady proxy address" do + proxy_address = insert(:address, contract_code: "0x60806040") + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + nil + end + end + + describe "quick_resolve_implementations/2 edge cases" do + test "returns nil for empty contract code" do + proxy_address = insert(:address, contract_code: "0x") + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + nil + end + + test "returns nil for bytecode matching wighawag prefix but too short" do + proxy_address = insert(:address, contract_code: "0x3D3D3D3D363D3D3761AABB") + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + nil + end + + test "returns nil for bytecode matching solady prefix but too short" do + proxy_address = insert(:address, contract_code: "0x363d3d373d3d3d363d73") + + assert CloneWithImmutableArguments.quick_resolve_implementations(proxy_address, :clone_with_immutable_arguments) == + nil + end + + test "correctly distinguishes between wighawag and solady variants" do + # Wighawag variant + wighawag_bytecode = + "0x3D3D3D3D363D3D3761" <> + "AABB" <> + "603736393661" <> + "CCDD" <> + "013D73" <> + @test_impl_address <> + "5AF43D82803E903D91602B57FD5BF3" + + wighawag_proxy = insert(:address, contract_code: wighawag_bytecode) + + # Solady variant - use different address for distinction + solady_bytecode = + "0x363d3d373d3d3d363d73" <> + @test_impl_address_2 <> + "5af43d3d93803e602a57fd5bf3" + + solady_proxy = insert(:address, contract_code: solady_bytecode) + + {:ok, impl_hash_1} = Explorer.Chain.Hash.Address.cast("0x" <> @test_impl_address) + {:ok, impl_hash_2} = Explorer.Chain.Hash.Address.cast("0x" <> @test_impl_address_2) + + assert CloneWithImmutableArguments.quick_resolve_implementations(wighawag_proxy, :clone_with_immutable_arguments) == + {:ok, [impl_hash_1]} + + assert CloneWithImmutableArguments.quick_resolve_implementations(solady_proxy, :clone_with_immutable_arguments) == + {:ok, [impl_hash_2]} + end + end +end diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy/erc_7760_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy/erc_7760_test.exs index be09813a3f2d..8ad1a86e6814 100644 --- a/apps/explorer/test/explorer/chain/smart_contract/proxy/erc_7760_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy/erc_7760_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.ERC7760Test do use Explorer.DataCase @@ -5,44 +6,40 @@ defmodule Explorer.Chain.SmartContract.Proxy.ERC7760Test do alias Explorer.TestHelper @uups_basic_variant "363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6038573d6000fd5b3d6000f3" - @uups_l_variant "365814604357363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e603e573d6000fd5b3d6000f35b6020600f3d393d51543d52593df3" + @uups_i_variant "365814604357363d3d373d3d363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e603e573d6000fd5b3d6000f35b6020600f3d393d51543d52593df3" @beacon_basic_variant "363d3d373d3d363d602036600436635c60da1b60e01b36527fa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50545afa5036515af43d6000803e604d573d6000fd5b3d6000f3" - @beacon_l_variant "363d3d373d3d363d602036600436635c60da1b60e01b36527fa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50545afa361460525736515af43d600060013e6052573d6001fd5b3d6001f3" + @beacon_i_variant "363d3d373d3d363d602036600436635c60da1b60e01b36527fa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50545afa361460525736515af43d600060013e6052573d6001fd5b3d6001f3" @transparent_basic_variant_20_left "3d3d3373" @transparent_basic_variant_20_right "14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b3d356020355560408036111560525736038060403d373d3d355af43d6000803e6052573d6000fd" @transparent_basic_variant_14_left "3d3d336d" @transparent_basic_variant_14_right "14605157363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e604c573d6000fd5b3d6000f35b3d3560203555604080361115604c5736038060403d373d3d355af43d6000803e604c573d6000fd" - @transparent_l_variant_20_left "3658146083573d3d3373" - @transparent_l_variant_20_right "14605D57363d3d37363D7f360894a13ba1A3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6058573d6000fd5b3d6000f35b3d35602035556040360380156058578060403d373d3d355af43d6000803e6058573d6000fd5b602060293d393d51543d52593df3" - @transparent_l_variant_14_left "365814607d573d3d336d" - @transparent_l_variant_14_right "14605757363d3D37363d7F360894A13Ba1A3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b3d35602035556040360380156052578060403d373d3d355af43d6000803e6052573d6000fd5b602060233d393d51543d52593df3" + @transparent_i_variant_20_left "3658146083573d3d3373" + @transparent_i_variant_20_right "14605D57363d3d37363D7f360894a13ba1A3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6058573d6000fd5b3d6000f35b3d35602035556040360380156058578060403d373d3d355af43d6000803e6058573d6000fd5b602060293d393d51543d52593df3" + @transparent_i_variant_14_left "365814607d573d3d336d" + @transparent_i_variant_14_right "14605757363d3D37363d7F360894A13Ba1A3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b3d35602035556040360380156052578060403d373d3d355af43d6000803e6052573d6000fd5b602060233d393d51543d52593df3" - describe "get_implementation_address_hash_strings/2" do - test "returns implementation address hash string for valid proxy address with uups_basic_variant" do + describe "quick_resolve_implementations/2" do + test "returns implementation address hash for valid proxy address with uups_basic_variant" do proxy_address = insert(:address, contract_code: "0x" <> @uups_basic_variant) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with uups_basic_variant and offset" do + test "returns implementation address hash for valid proxy address with uups_basic_variant and offset" do proxy_address = insert(:address, contract_code: "0x" <> @uups_basic_variant <> "1234") implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_basic_variant_20" do + test "returns implementation address hash for valid proxy address with transparent_basic_variant_20" do proxy_address = insert(:address, contract_code: @@ -54,14 +51,12 @@ defmodule Explorer.Chain.SmartContract.Proxy.ERC7760Test do implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_basic_variant_20 and offset" do + test "returns implementation address hash for valid proxy address with transparent_basic_variant_20 and offset" do proxy_address = insert(:address, contract_code: @@ -73,14 +68,12 @@ defmodule Explorer.Chain.SmartContract.Proxy.ERC7760Test do implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_basic_variant_14" do + test "returns implementation address hash for valid proxy address with transparent_basic_variant_14" do proxy_address = insert(:address, contract_code: @@ -92,14 +85,12 @@ defmodule Explorer.Chain.SmartContract.Proxy.ERC7760Test do implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_basic_variant_14 and offset" do + test "returns implementation address hash for valid proxy address with transparent_basic_variant_14 and offset" do proxy_address = insert(:address, contract_code: @@ -111,157 +102,135 @@ defmodule Explorer.Chain.SmartContract.Proxy.ERC7760Test do implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_l_variant_20" do + test "returns implementation address hash for valid proxy address with transparent_i_variant_20" do proxy_address = insert(:address, contract_code: "0x" <> - @transparent_l_variant_20_left <> - "1234567890123456789012345678901234567890" <> @transparent_l_variant_20_right + @transparent_i_variant_20_left <> + "1234567890123456789012345678901234567890" <> @transparent_i_variant_20_right ) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_l_variant_20 and offset" do + test "returns implementation address hash for valid proxy address with transparent_i_variant_20 and offset" do proxy_address = insert(:address, contract_code: "0x" <> - @transparent_l_variant_20_left <> - "1234567890123456789012345678901234567890" <> @transparent_l_variant_20_right <> "1234" + @transparent_i_variant_20_left <> + "1234567890123456789012345678901234567890" <> @transparent_i_variant_20_right <> "1234" ) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_l_variant_14" do + test "returns implementation address hash for valid proxy address with transparent_i_variant_14" do proxy_address = insert(:address, contract_code: - "0x" <> @transparent_l_variant_14_left <> "1234567890123456789012341234" <> @transparent_l_variant_14_right + "0x" <> @transparent_i_variant_14_left <> "1234567890123456789012341234" <> @transparent_i_variant_14_right ) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with transparent_l_variant_14 and offset" do + test "returns implementation address hash for valid proxy address with transparent_i_variant_14 and offset" do proxy_address = insert(:address, contract_code: "0x" <> - @transparent_l_variant_14_left <> - "1234567890123456789012341234" <> @transparent_l_variant_14_right <> "1234" + @transparent_i_variant_14_left <> + "1234567890123456789012341234" <> @transparent_i_variant_14_right <> "1234" ) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with uups_l_variant" do - proxy_address = insert(:address, contract_code: "0x" <> @uups_l_variant) + test "returns implementation address hash for valid proxy address with uups_i_variant" do + proxy_address = insert(:address, contract_code: "0x" <> @uups_i_variant) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with uups_l_variant and offset" do - proxy_address = insert(:address, contract_code: "0x" <> @uups_l_variant <> "1234") + test "returns implementation address hash for valid proxy address with uups_i_variant and offset" do + proxy_address = insert(:address, contract_code: "0x" <> @uups_i_variant <> "1234") implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_basic_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with beacon_basic_variant" do + test "returns implementation address hash for valid proxy address with beacon_basic_variant" do proxy_address = insert(:address, contract_code: "0x" <> @beacon_basic_variant) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_beacon_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with beacon_basic_variant and offset" do + test "returns implementation address hash for valid proxy address with beacon_basic_variant and offset" do proxy_address = insert(:address, contract_code: "0x" <> @beacon_basic_variant <> "1234") implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_beacon_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with beacon_l_variant" do - proxy_address = insert(:address, contract_code: "0x" <> @beacon_l_variant) + test "returns implementation address hash for valid proxy address with beacon_i_variant" do + proxy_address = insert(:address, contract_code: "0x" <> @beacon_i_variant) implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_beacon_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end - test "returns implementation address hash string for valid proxy address with beacon_l_variant and offset" do - proxy_address = insert(:address, contract_code: "0x" <> @beacon_l_variant <> "1234") + test "returns implementation address hash for valid proxy address with beacon_i_variant and offset" do + proxy_address = insert(:address, contract_code: "0x" <> @beacon_i_variant <> "1234") implementation_address = insert(:address) EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, to_string(implementation_address.hash)) + |> TestHelper.mock_erc7760_beacon_requests(false, implementation_address.hash) - assert ERC7760.get_implementation_address_hash_strings(proxy_address.hash) == [ - to_string(implementation_address.hash) - ] + assert ERC7760.quick_resolve_implementations(proxy_address) == {:ok, [implementation_address.hash]} end end end diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy/minimal_proxy_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy/minimal_proxy_test.exs new file mode 100644 index 000000000000..e1b2e088fac1 --- /dev/null +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy/minimal_proxy_test.exs @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.Proxy.MinimalProxyTest do + use Explorer.DataCase + + alias Explorer.Chain.SmartContract.Proxy.MinimalProxy + + # Deployed bytecode from a real minimal proxy contract where the EIP-1167-like sequence + # appears in the middle of the bytecode. Implementation address: 1e2086a7e84a32482ac03000d56925f607ccb708 + @deployed_bytecode "0x36602c57343d527f9e4ac34f21c619cefc926c8bd93b54bf5a39c7ab2127a895af1cc0691d7e3dff593da1005b3d3d3d3d363d3d37363d731e2086a7e84a32482ac03000d56925f607ccb7085af43d3d93803e605757fd5bf3" + @expected_impl "0x1e2086a7e84a32482ac03000d56925f607ccb708" + + describe "quick_resolve_implementations/2" do + test "detects implementation address when pattern is in the middle of bytecode" do + proxy_address = insert(:address, contract_code: @deployed_bytecode) + {:ok, expected_hash} = Explorer.Chain.Hash.Address.cast(@expected_impl) + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == + {:ok, [expected_hash]} + end + + test "returns nil when pattern is absent" do + proxy_address = insert(:address, contract_code: "0x60806040526004361061") + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == nil + end + + test "returns nil for nil contract code" do + proxy_address = insert(:address, contract_code: nil) + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == nil + end + + test "returns nil for empty bytecode" do + proxy_address = insert(:address, contract_code: "0x") + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == nil + end + + test "returns nil when bytecode exceeds 100 bytes" do + impl_address = "AABBCCDDEE112233445566778899001122334455" + # over 100 bytes total: 60 bytes prefix + 11 bytes pattern + 20 bytes address + suffix + padding = String.duplicate("AA", 60) + + bytecode = + "0x" <> + padding <> + "3D3D3D3D363D3D37363D73" <> + impl_address <> + "5AF43D3D93803E605757FD5BF3DEADBEEF" + + proxy_address = insert(:address, contract_code: bytecode) + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == nil + end + + test "returns nil when pattern is found but there are fewer than 20 bytes following it" do + # pattern (11 bytes) + only 10 bytes after it — not enough for an address + short_bytecode = + "0x" <> + "AABBCCDD" <> + "3D3D3D3D363D3D37363D73" <> + "1e2086a7e84a32482ac03000d56925f607cc" + + proxy_address = insert(:address, contract_code: short_bytecode) + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == nil + end + + test "correctly extracts address when pattern appears after arbitrary leading bytes" do + impl_address = "AABBCCDDEE112233445566778899001122334455" + + bytecode = + "0x" <> + "DEADBEEF00112233" <> + "3D3D3D3D363D3D37363D73" <> + impl_address <> + "5AF43D3D93803E605757FD5BF3" + + proxy_address = insert(:address, contract_code: bytecode) + {:ok, expected_hash} = Explorer.Chain.Hash.Address.cast("0x" <> impl_address) + + assert MinimalProxy.quick_resolve_implementations(proxy_address, :minimal_proxy) == + {:ok, [expected_hash]} + end + end +end diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs index ac8709d1b7e1..804d8670aee9 100644 --- a/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do use Explorer.DataCase, async: false @@ -26,9 +27,13 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do refute_implementations(smart_contract.address_hash) - # fetch nil implementation and don't save it to db - TestHelper.get_all_proxies_implementation_zero_addresses() - assert is_nil(Implementation.get_implementation(smart_contract)) + # fetch nil implementation + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + + assert %Implementation{address_hashes: [], names: [], proxy_type: nil} = + Implementation.get_implementation(smart_contract) + verify!(EthereumJSONRPC.Mox) assert_empty_implementation(smart_contract.address_hash) @@ -40,15 +45,15 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do Application.put_env(:explorer, :proxy, proxy) - implementation_address_hash_string = to_string(implementation_smart_contract.address_hash) - - expect_address_in_oz_slot_response(implementation_address_hash_string) implementation_address_hash = implementation_smart_contract.address_hash + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967_oz: implementation_address_hash) + assert %Implementation{ address_hashes: [^implementation_address_hash], names: ["implementation"], - proxy_type: :eip1967 + proxy_type: :eip1967_oz } = Implementation.get_implementation(smart_contract) verify!(EthereumJSONRPC.Mox) @@ -59,12 +64,13 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do implementation_smart_contract.name ) - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: :error) assert %Implementation{ address_hashes: [^implementation_address_hash], names: ["implementation"], - proxy_type: :eip1967 + proxy_type: :eip1967_oz } = Implementation.get_implementation(smart_contract) verify!(EthereumJSONRPC.Mox) @@ -88,7 +94,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do assert %Implementation{ address_hashes: [^implementation_address_hash], names: ["implementation"], - proxy_type: :eip1967 + proxy_type: :eip1967_oz } = Implementation.get_implementation(smart_contract) {contract_2, _} = SmartContract.address_hash_to_smart_contract_with_bytecode_twin(smart_contract.address_hash) @@ -104,14 +110,14 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do Application.put_env(:explorer, :proxy, proxy) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() - assert is_nil(Implementation.get_implementation(smart_contract)) + assert %Implementation{address_hashes: [], names: [], proxy_type: nil} = + Implementation.get_implementation(smart_contract) verify!(EthereumJSONRPC.Mox) - - assert implementation_1.updated_at == implementation_2.updated_at && - contract_1.updated_at == contract_2.updated_at + assert_empty_implementation(smart_contract.address_hash) proxy = :explorer @@ -127,7 +133,9 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do smart_contract = insert(:smart_contract) twin_address = insert(:contract_address) - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + {bytecode_twin, _} = SmartContract.address_hash_to_smart_contract_with_bytecode_twin(twin_address.hash) implementation_smart_contract = insert(:smart_contract, name: "implementation") @@ -140,16 +148,18 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do Application.put_env(:explorer, :proxy, proxy) # fetch nil implementation - assert %Implementation{address_hashes: [], names: [], proxy_type: :unknown} = + assert %Implementation{address_hashes: [], names: [], proxy_type: nil} = Implementation.get_implementation(bytecode_twin) verify!(EthereumJSONRPC.Mox) + assert_empty_implementation(bytecode_twin.address_hash) refute_implementations(smart_contract.address_hash) - assert %Implementation{address_hashes: [], names: [], proxy_type: :unknown} = + assert %Implementation{address_hashes: [], names: [], proxy_type: nil} = Implementation.get_implementation(bytecode_twin) verify!(EthereumJSONRPC.Mox) + assert_empty_implementation(bytecode_twin.address_hash) refute_implementations(smart_contract.address_hash) proxy = @@ -160,11 +170,11 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do Application.put_env(:explorer, :proxy, proxy) - string_implementation_address_hash = to_string(implementation_smart_contract.address_hash) - - expect_address_in_oz_slot_response(string_implementation_address_hash) implementation_address_hash = implementation_smart_contract.address_hash + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address_hash) + assert %Implementation{ address_hashes: [^implementation_address_hash], names: ["implementation"], @@ -236,7 +246,8 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do # refute_implementations(smart_contract.address_hash) - # TestHelper.get_all_proxies_implementation_zero_addresses() + # EthereumJSONRPC.Mox + # |> TestHelper.mock_generic_proxy_requests() # {:ok, addr} = # Chain.find_contract_address( @@ -261,12 +272,14 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do # Application.put_env(:explorer, :proxy, proxy) # # fetch nil implementation - # TestHelper.get_all_proxies_implementation_zero_addresses() + # EthereumJSONRPC.Mox + # |> TestHelper.mock_generic_proxy_requests() # assert {[], [], nil} = Implementation.get_implementation(bytecode_twin) # verify!(EthereumJSONRPC.Mox) # refute_implementations(smart_contract.address_hash) - # TestHelper.get_all_proxies_implementation_zero_addresses() + # EthereumJSONRPC.Mox + # |> TestHelper.mock_generic_proxy_requests() # assert {[], [], nil} = Implementation.get_implementation(bytecode_twin) # verify!(EthereumJSONRPC.Mox) # refute_implementations(smart_contract.address_hash) @@ -281,7 +294,8 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do # refute_implementations(smart_contract.address_hash) - # TestHelper.get_all_proxies_implementation_zero_addresses() + # EthereumJSONRPC.Mox + # |> TestHelper.mock_generic_proxy_requests() # assert {[], [], nil} = Implementation.get_implementation(bytecode_twin) @@ -291,6 +305,89 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do end end + test "get_implementation/1 with conflicting implementations" do + smart_contract = insert(:smart_contract) + implementation_smart_contract1 = insert(:smart_contract, name: "implementation1") + implementation_smart_contract2 = insert(:smart_contract, name: "implementation2") + implementation_smart_contract3 = insert(:smart_contract, name: "implementation3") + + implementation_address_hash1 = implementation_smart_contract1.address_hash + implementation_address_hash2 = implementation_smart_contract2.address_hash + implementation_address_hash3 = implementation_smart_contract3.address_hash + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests( + eip1967: implementation_address_hash1, + eip1967_oz: implementation_address_hash1, + eip1822: implementation_address_hash1 + ) + + assert %Implementation{ + address_hashes: [^implementation_address_hash1], + names: ["implementation1"], + proxy_type: :eip1967, + conflicting_proxy_types: nil, + conflicting_address_hashes: nil + } = Implementation.get_implementation(smart_contract) + + verify!(EthereumJSONRPC.Mox) + + assert_exact_name_and_address( + smart_contract.address_hash, + implementation_address_hash1, + implementation_smart_contract1.name + ) + + assert %Implementation{ + conflicting_proxy_types: nil, + conflicting_address_hashes: nil + } = Implementation.get_proxy_implementations(smart_contract.address_hash) + + proxy = + :explorer + |> Application.get_env(:proxy) + |> Keyword.replace(:fallback_cached_implementation_data_ttl, :timer.seconds(0)) + |> Keyword.replace(:implementation_data_fetching_timeout, :timer.seconds(20)) + + Application.put_env(:explorer, :proxy, proxy) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests( + eip1967: implementation_address_hash1, + eip1967_oz: implementation_address_hash2, + eip1822: implementation_address_hash3 + ) + + assert %Implementation{ + address_hashes: [^implementation_address_hash1], + names: ["implementation1"], + proxy_type: :eip1967, + conflicting_proxy_types: [:eip1822, :eip1967_oz], + conflicting_address_hashes: [[^implementation_address_hash3], [^implementation_address_hash2]] + } = Implementation.get_implementation(smart_contract) + + verify!(EthereumJSONRPC.Mox) + + proxy = + :explorer + |> Application.get_env(:proxy) + |> Keyword.replace(:fallback_cached_implementation_data_ttl, :timer.seconds(20)) + |> Keyword.replace(:implementation_data_fetching_timeout, :timer.seconds(20)) + + Application.put_env(:explorer, :proxy, proxy) + + assert_exact_name_and_address( + smart_contract.address_hash, + implementation_address_hash1, + implementation_smart_contract1.name + ) + + assert %Implementation{ + conflicting_proxy_types: [:eip1822, :eip1967_oz], + conflicting_address_hashes: [[^implementation_address_hash3], [^implementation_address_hash2]] + } = Implementation.get_proxy_implementations(smart_contract.address_hash) + end + def assert_exact_name_and_address(address_hash, implementation_address_hash, implementation_name) do implementation = Implementation.get_proxy_implementations(address_hash) assert implementation.proxy_type @@ -308,13 +405,6 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do assert implementation.names end - defp expect_address_in_oz_slot_response(string_implementation_address_hash) do - EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false) - |> TestHelper.mock_beacon_storage_pointer_request(false) - |> TestHelper.mock_oz_storage_pointer_request(false, string_implementation_address_hash) - end - def refute_implementations(address_hash) do implementations = Implementation.get_proxy_implementations(address_hash) refute implementations @@ -322,9 +412,11 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do def assert_empty_implementation(address_hash) do implementation = Implementation.get_proxy_implementations(address_hash) - assert implementation.proxy_type == :unknown + assert is_nil(implementation.proxy_type) assert implementation.updated_at assert implementation.names == [] assert implementation.address_hashes == [] + assert is_nil(implementation.conflicting_proxy_types) + assert is_nil(implementation.conflicting_address_hashes) end end diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs index 5a3f74a04c56..bad2b1622334 100644 --- a/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContract.ProxyTest do use Explorer.DataCase, async: false import Mox + alias Explorer.Chain.Hash alias Explorer.Chain.SmartContract alias Explorer.Chain.SmartContract.Proxy alias Explorer.Chain.SmartContract.Proxy.Models.Implementation @@ -139,7 +141,8 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do end test "combine_proxy_implementation_abi/2 returns [] abi for unverified proxy" do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() proxy_contract_address = insert(:contract_address) @@ -150,7 +153,8 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do end test "combine_proxy_implementation_abi/2 returns proxy abi if implementation is not verified" do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() proxy_contract_address = insert(:contract_address) @@ -207,13 +211,14 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do smart_contract = insert(:smart_contract, address_hash: proxy_contract_address.hash, abi: @proxy_abi, contract_code_md5: "123") - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() assert Proxy.get_implementation_abi_from_proxy(smart_contract, []) == [] end - test "get_implementation_abi_from_proxy/2 returns implementation abi if implementation is verified" do - proxy_contract_address = insert(:contract_address) + test "get_implementation_abi_from_proxy/2 returns implementation abi if implementation is verified (basic_implementation proxy)" do + proxy_contract_address = insert(:contract_address, contract_code: "0xDEADBEEF5c60da1bDEADBEEF") smart_contract = insert(:smart_contract, address_hash: proxy_contract_address.hash, abi: @proxy_abi, contract_code_md5: "123") @@ -226,25 +231,8 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do contract_code_md5: "123" ) - implementation_contract_address_hash_string = - Base.encode16(implementation_contract_address.hash.bytes, case: :lower) - - TestHelper.get_all_proxies_implementation_zero_addresses() - - expect( - EthereumJSONRPC.Mox, - :json_rpc, - fn [%{id: id, method: _, params: [%{data: _, to: _}, _]}], _options -> - {:ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: "0x000000000000000000000000" <> implementation_contract_address_hash_string - } - ]} - end - ) + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(basic_implementation: implementation_contract_address.hash) implementation_abi = Proxy.get_implementation_abi_from_proxy(smart_contract, []) @@ -265,13 +253,8 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do contract_code_md5: "123" ) - implementation_contract_address_hash_string = - Base.encode16(implementation_contract_address.hash.bytes, case: :lower) - - response = "0x000000000000000000000000" <> implementation_contract_address_hash_string - EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false, response) + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_contract_address.hash) implementation_abi = Proxy.get_implementation_abi_from_proxy(smart_contract, []) @@ -302,8 +285,6 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do contract_code_md5: "123" ) - beacon_contract_address_hash_string = Base.encode16(beacon_contract_address.hash.bytes, case: :lower) - implementation_contract_address = insert(:contract_address) insert(:smart_contract, @@ -312,13 +293,10 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do contract_code_md5: "123" ) - implementation_contract_address_hash_string = - Base.encode16(implementation_contract_address.hash.bytes, case: :lower) - - eip_1967_beacon_proxy_mock_requests( - beacon_contract_address_hash_string, - implementation_contract_address_hash_string, - :full_32 + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests( + eip1967_beacon: beacon_contract_address.hash, + eip1967_beacon_implementation: implementation_contract_address.hash ) implementation_abi = Proxy.get_implementation_abi_from_proxy(smart_contract, []) @@ -327,82 +305,48 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do assert implementation_abi == @implementation_abi end - test "get_implementation_abi_from_proxy/2 returns implementation abi in case of EIP-1967 proxy pattern (beacon contract) when eth_getStorageAt returns 20 bytes address" do - proxy_contract_address = insert(:contract_address) - - smart_contract = - insert(:smart_contract, address_hash: proxy_contract_address.hash, abi: [], contract_code_md5: "123") - - beacon_contract_address = insert(:contract_address) - - insert(:smart_contract, - address_hash: beacon_contract_address.hash, - abi: @beacon_abi, - contract_code_md5: "123" - ) - - beacon_contract_address_hash_string = Base.encode16(beacon_contract_address.hash.bytes, case: :lower) - - implementation_contract_address = insert(:contract_address) - - insert(:smart_contract, - address_hash: implementation_contract_address.hash, - abi: @implementation_abi, - contract_code_md5: "123" - ) - - implementation_contract_address_hash_string = - Base.encode16(implementation_contract_address.hash.bytes, case: :lower) - - eip_1967_beacon_proxy_mock_requests( - beacon_contract_address_hash_string, - implementation_contract_address_hash_string, - :exact_20 - ) - - implementation_abi = Proxy.get_implementation_abi_from_proxy(smart_contract, []) - verify!(EthereumJSONRPC.Mox) - - assert implementation_abi == @implementation_abi - end - - test "get_implementation_abi_from_proxy/2 returns implementation abi in case of EIP-1967 proxy pattern (beacon contract) when eth_getStorageAt returns less 20 bytes address" do - proxy_contract_address = insert(:contract_address) - - smart_contract = - insert(:smart_contract, address_hash: proxy_contract_address.hash, abi: [], contract_code_md5: "123") - - beacon_contract_address = insert(:contract_address) - - insert(:smart_contract, - address_hash: beacon_contract_address.hash, - abi: @beacon_abi, - contract_code_md5: "123" - ) - - beacon_contract_address_hash_string = Base.encode16(beacon_contract_address.hash.bytes, case: :lower) - - implementation_contract_address = insert(:contract_address) - - insert(:smart_contract, - address_hash: implementation_contract_address.hash, - abi: @implementation_abi, - contract_code_md5: "123" - ) - - implementation_contract_address_hash_string = - Base.encode16(implementation_contract_address.hash.bytes, case: :lower) - - eip_1967_beacon_proxy_mock_requests( - beacon_contract_address_hash_string, - implementation_contract_address_hash_string, - :short - ) - - implementation_abi = Proxy.get_implementation_abi_from_proxy(smart_contract, []) - verify!(EthereumJSONRPC.Mox) - - assert implementation_abi == @implementation_abi + test "extract_address_hash/1" do + assert Proxy.extract_address_hash(nil) == nil + assert Proxy.extract_address_hash("0x") == nil + assert Proxy.extract_address_hash("0x0") == nil + assert Proxy.extract_address_hash("0x0000000000000000000000000000000000000000") == nil + assert Proxy.extract_address_hash("0x0000000000000000000000000000000000000000000000000000000000000000") == nil + assert Proxy.extract_address_hash("INVALID") == :error + + # 20-bytes value + assert Proxy.extract_address_hash("0x1111111111111111111111111111111111111111") == + {:ok, + %Hash{ + byte_count: 20, + bytes: <<0x1111111111111111111111111111111111111111::160>> + }} + + # 19-bytes value + assert Proxy.extract_address_hash("0x11111111111111111111111111111111111111") == + {:ok, + %Hash{ + byte_count: 20, + bytes: <<0x0011111111111111111111111111111111111111::160>> + }} + + # 21-bytes value + assert Proxy.extract_address_hash("0x001111111111111111111111111111111111111111") == + {:ok, + %Hash{ + byte_count: 20, + bytes: <<0x1111111111111111111111111111111111111111::160>> + }} + + # canonical 32-bytes value + assert Proxy.extract_address_hash("0x0000000000000000000000001111111111111111111111111111111111111111") == + {:ok, + %Hash{ + byte_count: 20, + bytes: <<0x1111111111111111111111111111111111111111::160>> + }} + + # 33-bytes value + assert Proxy.extract_address_hash("0x000000000000000000000000001111111111111111111111111111111111111111") == nil end test "check proxy_contract?/1 function" do @@ -419,7 +363,9 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do refute_implementations(smart_contract.address_hash) # fetch nil implementation and don't save it to db - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + refute Proxy.proxy_contract?(smart_contract) verify!(EthereumJSONRPC.Mox) assert_empty_implementation(smart_contract.address_hash) @@ -431,13 +377,17 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do Application.put_env(:explorer, :proxy, proxy) - TestHelper.get_eip1967_implementation_error_response() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: :error) + refute Proxy.proxy_contract?(smart_contract) verify!(EthereumJSONRPC.Mox) implementation_address = insert(:address) - implementation_address_hash_string = to_string(implementation_address.hash) - TestHelper.get_eip1967_implementation_non_zero_address(implementation_address_hash_string) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests(eip1967: implementation_address.hash) + assert Proxy.proxy_contract?(smart_contract) verify!(EthereumJSONRPC.Mox) assert_implementation_address(smart_contract.address_hash) @@ -476,48 +426,6 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do Application.put_env(:explorer, :proxy, proxy) end - defp eip_1967_beacon_proxy_mock_requests( - beacon_contract_address_hash_string, - implementation_contract_address_hash_string, - mode - ) do - response = - case mode do - :full_32 -> "0x000000000000000000000000" <> beacon_contract_address_hash_string - :exact_20 -> "0x" <> beacon_contract_address_hash_string - :short -> "0x" <> String.slice(beacon_contract_address_hash_string, 10..-1//1) - end - - EthereumJSONRPC.Mox - |> TestHelper.mock_logic_storage_pointer_request(false) - |> TestHelper.mock_beacon_storage_pointer_request(false, response) - |> expect( - :json_rpc, - fn [ - %{ - id: id, - method: "eth_call", - params: [ - %{data: "0x5c60da1b", to: "0x" <> ^beacon_contract_address_hash_string}, - "latest" - ] - } - ], - _options -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: "0x000000000000000000000000" <> implementation_contract_address_hash_string - } - ] - } - end - ) - end - def assert_implementation_address(address_hash) do implementation = Implementation.get_proxy_implementations(address_hash) assert implementation.proxy_type @@ -532,7 +440,7 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do def assert_empty_implementation(address_hash) do implementation = Implementation.get_proxy_implementations(address_hash) - assert implementation.proxy_type == :unknown + assert is_nil(implementation.proxy_type) assert implementation.updated_at assert implementation.names == [] assert implementation.address_hashes == [] diff --git a/apps/explorer/test/explorer/chain/smart_contract/verified_contract_addresses_query_test.exs b/apps/explorer/test/explorer/chain/smart_contract/verified_contract_addresses_query_test.exs new file mode 100644 index 000000000000..d50baae13af9 --- /dev/null +++ b/apps/explorer/test/explorer/chain/smart_contract/verified_contract_addresses_query_test.exs @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.SmartContract.VerifiedContractAddressesQueryTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.SmartContract.VerifiedContractAddressesQuery + alias Explorer.PagingOptions + + describe "list/1" do + test "orders by smart_contract id desc when sorting is absent" do + contracts = insert_list(4, :smart_contract) + + addresses = VerifiedContractAddressesQuery.list() + + assert Enum.map(addresses, & &1.smart_contract.id) == + contracts |> Enum.map(& &1.id) |> Enum.sort(:desc) + end + + test "pages with cursor key when sorting is present" do + for balance <- 0..5 do + address = insert(:address, fetched_coin_balance: balance, verified: true) + insert(:smart_contract, address_hash: address.hash, address: address) + end + + sorting = [{:asc_nulls_first, :fetched_coin_balance}] + page_size = 3 + + first_page = + VerifiedContractAddressesQuery.list( + sorting: sorting, + paging_options: %PagingOptions{page_size: page_size} + ) + + cursor = List.last(first_page) + + second_page = + VerifiedContractAddressesQuery.list( + sorting: sorting, + paging_options: %PagingOptions{ + page_size: page_size, + key: %{ + fetched_coin_balance: cursor.fetched_coin_balance, + hash: cursor.hash + } + } + ) + + assert Enum.map(first_page, &Decimal.to_integer(&1.fetched_coin_balance.value)) == [0, 1, 2] + assert Enum.map(second_page, &Decimal.to_integer(&1.fetched_coin_balance.value)) == [3, 4, 5] + end + + test "searches by contract name and address hash" do + insert_list(3, :smart_contract) + + # cspell:disable + target_name = "very-unique-smart-contract-name-1" + # cspell:enable + + name_contract = insert(:smart_contract, name: target_name) + hash_contract = insert(:smart_contract) + + [name_result] = VerifiedContractAddressesQuery.list(search: target_name) + + [hash_result] = + VerifiedContractAddressesQuery.list(search: String.downcase(to_string(hash_contract.address_hash))) + + assert name_result.smart_contract.id == name_contract.id + assert name_result.smart_contract.name == target_name + + assert hash_result.smart_contract.id == hash_contract.id + assert hash_result.hash == hash_contract.address_hash + end + + test "treats %, _ and \\ literally in search when sorting is absent" do + # cspell:disable + fixtures = [ + %{target: "literal%percent-contract", decoy: "literalXpercent-contract"}, + %{target: "literal_under_contract", decoy: "literalXunder_contract"}, + %{target: "literal\\backslash-contract", decoy: "literalbackslash-contract"} + ] + + # cspell:enable + + for %{target: target_name, decoy: decoy_name} <- fixtures do + target_contract = insert(:smart_contract, name: target_name) + decoy_contract = insert(:smart_contract, name: decoy_name) + + assert_search_matches_only(target_name, target_contract.id, decoy_contract.id) + end + end + + test "treats %, _ and \\ literally in search when sorting is present" do + options = [sorting: []] + + # cspell:disable + fixtures = [ + %{target: "sorted-literal%percent-contract", decoy: "sorted-literalXpercent-contract"}, + %{target: "sorted-literal_under_contract", decoy: "sorted-literalXunder_contract"}, + %{target: "sorted-literal\\backslash-contract", decoy: "sorted-literalbackslash-contract"} + ] + + # cspell:enable + + for %{target: target_name, decoy: decoy_name} <- fixtures do + target_contract = insert(:smart_contract, name: target_name) + decoy_contract = insert(:smart_contract, name: decoy_name) + + assert_search_matches_only(target_name, target_contract.id, decoy_contract.id, options) + end + end + end + + defp assert_search_matches_only( + search_string, + expected_contract_id, + unexpected_contract_id, + options \\ [] + ) do + [result] = VerifiedContractAddressesQuery.list(Keyword.merge(options, search: search_string)) + + assert result.smart_contract.id == expected_contract_id + refute result.smart_contract.id == unexpected_contract_id + end +end diff --git a/apps/explorer/test/explorer/chain/smart_contract_test.exs b/apps/explorer/test/explorer/chain/smart_contract_test.exs index a774c5354a9b..783c2054c427 100644 --- a/apps/explorer/test/explorer/chain/smart_contract_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.SmartContractTest do use Explorer.DataCase, async: false @@ -33,8 +34,6 @@ defmodule Explorer.Chain.SmartContractTest do created_contract_address: created_contract_address, created_contract_code: smart_contract_bytecode, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -135,8 +134,6 @@ defmodule Explorer.Chain.SmartContractTest do created_contract_address: created_contract_address, created_contract_code: smart_contract_bytecode, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) diff --git a/apps/explorer/test/explorer/chain/supply/proof_of_authority_test.exs b/apps/explorer/test/explorer/chain/supply/proof_of_authority_test.exs index 2b3968d7bc8e..d5931695598b 100644 --- a/apps/explorer/test/explorer/chain/supply/proof_of_authority_test.exs +++ b/apps/explorer/test/explorer/chain/supply/proof_of_authority_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Supply.ProofOfAuthorityTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/supply/rsk_test.exs b/apps/explorer/test/explorer/chain/supply/rsk_test.exs index 35e50cd0acd6..f142f7ad6726 100644 --- a/apps/explorer/test/explorer/chain/supply/rsk_test.exs +++ b/apps/explorer/test/explorer/chain/supply/rsk_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Supply.RSKTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/token/instance_test.exs b/apps/explorer/test/explorer/chain/token/instance_test.exs index 88d8c2d97668..ffaf92926aee 100644 --- a/apps/explorer/test/explorer/chain/token/instance_test.exs +++ b/apps/explorer/test/explorer/chain/token/instance_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Token.InstanceTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/token_test.exs b/apps/explorer/test/explorer/chain/token_test.exs index 2d4e69587296..29a2251cf726 100644 --- a/apps/explorer/test/explorer/chain/token_test.exs +++ b/apps/explorer/test/explorer/chain/token_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.TokenTest do use Explorer.DataCase @@ -148,4 +149,88 @@ defmodule Explorer.Chain.TokenTest do assert {:ok, _updated_token} = Token.update(token, update_params) end end + + describe "list_top/2" do + test "returns market data with enabled token fetcher" do + old_source_env = Application.get_env(:explorer, Explorer.Market.Source) + old_fetcher_env = Application.get_env(:explorer, Explorer.Market.Fetcher.Token) + + Application.put_env( + :explorer, + Explorer.Market.Source, + Keyword.merge(old_source_env, tokens_source: Explorer.Market.Source.OneCoinSource) + ) + + Application.put_env(:explorer, Explorer.Market.Fetcher.Token, Keyword.merge(old_fetcher_env, enabled: true)) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, old_source_env) + Application.put_env(:explorer, Explorer.Market.Fetcher.Token, old_fetcher_env) + end) + + start_supervised!(Explorer.Market.Fetcher.Token) + start_supervised!(Explorer.Market) + + insert_list(10, :token) + + market_data = Token.list_top(nil) + + assert Enum.all?(market_data, fn token -> not is_nil(token.fiat_value) end) + end + + test "ignores market data with disabled token fetcher" do + insert_list(10, :token) + + start_supervised!(Explorer.Market) + + market_data = Token.list_top(nil) + + assert Enum.all?(market_data, fn token -> is_nil(token.fiat_value) end) + end + + test "finds token by contract address hash" do + token = insert(:token, name: "Token that we search for", symbol: "ATK") + insert(:token) + insert(:token) + insert(:token) + + address_hash_string = to_string(token.contract_address_hash) + + results = Token.list_top(address_hash_string) + + assert [%{name: "Token that we search for"}] = results + end + + test "finds token by contract address hash when given mixed-case address" do + contract_address = insert(:contract_address, hash: "0xdAC17F958D2ee523a2206206994597C13D831ec7") + insert(:token, contract_address: contract_address, name: "Token that we search for", symbol: "ATK") + insert(:token) + insert(:token) + insert(:token) + + results = Token.list_top("0xDAC17F958D2EE523A2206206994597C13D831EC7") + + assert [%{name: "Token that we search for"}] = results + end + + test "returns empty when searching with a valid address hash format that has no token" do + insert(:token, name: "Some Token", symbol: "STK") + non_existent_hash = "0xf000000000000000000000000000000000000000" + + results = Token.list_top(non_existent_hash) + + assert Enum.empty?(results) + end + + test "falls back to full-text search for invalid address hash format" do + insert(:token, name: "0xINVALID_HEX Token", symbol: "IHT") + insert(:token) + insert(:token) + insert(:token) + + results = Token.list_top("0xINVALID_HEX") + + assert [%{name: "0xINVALID_HEX Token"}] = results + end + end end diff --git a/apps/explorer/test/explorer/chain/token_transfer_test.exs b/apps/explorer/test/explorer/chain/token_transfer_test.exs index 3139f318cb39..bd4b67a2c63b 100644 --- a/apps/explorer/test/explorer/chain/token_transfer_test.exs +++ b/apps/explorer/test/explorer/chain/token_transfer_test.exs @@ -1,6 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.TokenTransferTest do use Explorer.DataCase + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import Explorer.Factory alias Explorer.PagingOptions @@ -342,11 +346,96 @@ defmodule Explorer.Chain.TokenTransferTest do index: 0 ), block: block, - address_hash: address.hash + address_hash: address.hash, + address: address + ) + + block_number = log.block_number + assert {:ok, [^block_number]} = TokenTransfer.uncataloged_token_transfer_block_numbers() + end + end + + if @chain_identity == {:optimism, :celo} do + test "returns block numbers for Celo epoch blocks with nil transaction_hash" do + log = + insert(:token_transfer_log, + transaction: nil, + transaction_hash: nil ) block_number = log.block_number assert {:ok, [^block_number]} = TokenTransfer.uncataloged_token_transfer_block_numbers() end + + test "does not return block numbers when matching token transfer exists for Celo epoch blocks" do + log = + insert(:token_transfer_log, + transaction: nil, + transaction_hash: nil + ) + + from_address_hash = + log.second_topic + |> to_string() + |> String.replace_prefix("0x000000000000000000000000", "0x") + + to_address_hash = + log.third_topic + |> to_string() + |> String.replace_prefix("0x000000000000000000000000", "0x") + + token_contract_address = log.address + to_address = insert(:address, hash: to_address_hash) + from_address = insert(:address, hash: from_address_hash) + + insert(:token_transfer, + transaction: nil, + transaction_hash: nil, + block: log.block, + log_index: log.index, + token_contract_address: token_contract_address, + from_address: from_address, + to_address: to_address + ) + + assert {:ok, []} = TokenTransfer.uncataloged_token_transfer_block_numbers() + end + end + + describe "ERC-7984 token transfers" do + test "filters ERC-7984 token transfers correctly" do + erc7984_token = insert(:token, type: "ERC-7984") + erc20_token = insert(:token, type: "ERC-20") + + transaction = insert(:transaction) |> with_block() + + erc7984_transfer = + insert( + :token_transfer, + token_type: "ERC-7984", + amount: nil, + token_ids: nil, + token: erc7984_token, + token_contract_address: erc7984_token.contract_address, + transaction: transaction + ) + + _erc20_transfer = + insert( + :token_transfer, + token_type: "ERC-20", + token: erc20_token, + token_contract_address: erc20_token.contract_address, + transaction: transaction + ) + + # Test that ERC-7984 transfers can be queried + transfers = TokenTransfer.fetch_token_transfers_from_token_hash(erc7984_token.contract_address_hash, []) + + assert length(transfers) == 1 + assert hd(transfers).token_type == "ERC-7984" + assert hd(transfers).amount == nil + assert hd(transfers).token_ids == nil + end end end diff --git a/apps/explorer/test/explorer/chain/transaction/fork_test.exs b/apps/explorer/test/explorer/chain/transaction/fork_test.exs index f0ad0b1c4318..f097e59f8624 100644 --- a/apps/explorer/test/explorer/chain/transaction/fork_test.exs +++ b/apps/explorer/test/explorer/chain/transaction/fork_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.ForkTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain/transaction/history/historian_test.exs b/apps/explorer/test/explorer/chain/transaction/history/historian_test.exs index 6a8015dda77e..c11cf3870c01 100644 --- a/apps/explorer/test/explorer/chain/transaction/history/historian_test.exs +++ b/apps/explorer/test/explorer/chain/transaction/history/historian_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.History.HistorianTest do use Explorer.DataCase, async: false diff --git a/apps/explorer/test/explorer/chain/transaction/history/transaction_stats_test.exs b/apps/explorer/test/explorer/chain/transaction/history/transaction_stats_test.exs index 72401440fd02..7f2e415ca79b 100644 --- a/apps/explorer/test/explorer/chain/transaction/history/transaction_stats_test.exs +++ b/apps/explorer/test/explorer/chain/transaction/history/transaction_stats_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.History.TransactionStatsTest do use Explorer.DataCase, async: false diff --git a/apps/explorer/test/explorer/chain/transaction/status_test.exs b/apps/explorer/test/explorer/chain/transaction/status_test.exs index 566f6d35a358..4c0b0ca2994c 100644 --- a/apps/explorer/test/explorer/chain/transaction/status_test.exs +++ b/apps/explorer/test/explorer/chain/transaction/status_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Transaction.StatusTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/transaction_test.exs b/apps/explorer/test/explorer/chain/transaction_test.exs index 35b4d37ddb13..394396b90d76 100644 --- a/apps/explorer/test/explorer/chain/transaction_test.exs +++ b/apps/explorer/test/explorer/chain/transaction_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.TransactionTest do use Explorer.DataCase @@ -265,7 +266,8 @@ defmodule Explorer.Chain.TransactionTest do end test "that a contract call transaction that has a verified contract returns the decoded input data" do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() transaction = :transaction_to_verified_contract @@ -277,7 +279,8 @@ defmodule Explorer.Chain.TransactionTest do end test "that a contract call will look up a match in contract_methods table" do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() :transaction_to_verified_contract |> insert() @@ -300,7 +303,8 @@ defmodule Explorer.Chain.TransactionTest do end test "arguments name in function call replaced with argN if it's empty string" do - TestHelper.get_all_proxies_implementation_zero_addresses() + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() contract = insert(:smart_contract, @@ -535,11 +539,8 @@ defmodule Explorer.Chain.TransactionTest do %InternalTransaction{created_contract_address: address} = insert(:internal_transaction_create, - transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -867,4 +868,176 @@ defmodule Explorer.Chain.TransactionTest do ) end end + + describe "get_method_name/1" do + test "returns method name for transaction with input data starting with 0x" do + transaction = + :transaction |> insert(input: "0x3078f1140ab0ba") + + assert "0x3078f114" == Transaction.get_method_name(transaction) + end + end + + describe "recent_collated_transactions/1" do + test "with no collated transactions it returns an empty list" do + assert [] == Transaction.recent_collated_transactions(true) + end + + test "it excludes pending transactions" do + insert(:transaction) + assert [] == Transaction.recent_collated_transactions(true) + end + + test "returns a list of recent collated transactions" do + newest_first_transactions = + 50 + |> insert_list(:transaction) + |> with_block() + |> Enum.reverse() + + oldest_seen = Enum.at(newest_first_transactions, 9) + paging_options = %Explorer.PagingOptions{page_size: 10, key: {oldest_seen.block_number, oldest_seen.index}} + + recent_collated_transactions = + Transaction.recent_collated_transactions(true, paging_options: paging_options) + + assert length(recent_collated_transactions) == 10 + assert hd(recent_collated_transactions).hash == Enum.at(newest_first_transactions, 10).hash + end + + test "returns transactions with token_transfers preloaded" do + address = insert(:address) + token_contract_address = insert(:contract_address) + token = insert(:token, contract_address: token_contract_address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert_list( + 2, + :token_transfer, + to_address: address, + transaction: transaction, + token_contract_address: token_contract_address, + token: token, + block: transaction.block + ) + + fetched_transaction = List.first(Explorer.Chain.Transaction.recent_collated_transactions(true)) + assert fetched_transaction.hash == transaction.hash + assert length(fetched_transaction.token_transfers) == 2 + end + end + + describe "update_replaced_transactions/2" do + test "update replaced transactions" do + replaced_transaction_hash = "0x2a263224a95275d77bc30a7e131bc64d948777946a790c0915ab293791fbcb61" + + address = insert(:address, hash: "0xb7cffe2ac19b9d5705a24cbe14fef5663af905a6") + + insert(:transaction, + from_address: address, + nonce: 1, + block_hash: nil, + index: nil, + block_number: nil, + hash: replaced_transaction_hash + ) + + mined_transaction_hash = "0x1a263224a95275d77bc30a7e131bc64d948777946a790c0915ab293791fbcb61" + block = insert(:block) + + mined_transaction = + insert(:transaction, + from_address: address, + nonce: 1, + index: 0, + block_hash: block.hash, + block_number: block.number, + cumulative_gas_used: 1, + gas_used: 1, + hash: mined_transaction_hash + ) + + second_mined_transaction_hash = "0x3a263224a95275d77bc30a7e131bc64d948777946a790c0915ab293791fbcb61" + second_block = insert(:block) + + insert(:transaction, + from_address: address, + nonce: 1, + index: 0, + block_hash: second_block.hash, + block_number: second_block.number, + cumulative_gas_used: 1, + gas_used: 1, + hash: second_mined_transaction_hash + ) + + {1, _} = + Transaction.update_replaced_transactions([ + %{ + block_hash: mined_transaction.block_hash, + nonce: mined_transaction.nonce, + from_address_hash: mined_transaction.from_address_hash + } + ]) + + replaced_transaction = Repo.get(Transaction, replaced_transaction_hash) + + assert replaced_transaction.status == :error + assert replaced_transaction.error == "dropped/replaced" + + found_mined_transaction = Repo.get(Transaction, mined_transaction_hash) + + assert found_mined_transaction.status == nil + assert found_mined_transaction.error == nil + + second_mined_transaction = Repo.get(Transaction, second_mined_transaction_hash) + + assert second_mined_transaction.status == nil + assert second_mined_transaction.error == nil + end + end + + describe "pending_transactions/0" do + test "without transactions" do + assert [] = Transaction.recent_pending_transactions() + end + + test "with transactions" do + %Transaction{hash: hash} = insert(:transaction) + + assert [%Transaction{hash: ^hash}] = Transaction.recent_pending_transactions() + end + + test "with transactions can be paginated" do + second_page_hashes = + 50 + |> insert_list(:transaction) + |> Enum.map(& &1.hash) + + %Transaction{inserted_at: inserted_at, hash: hash} = insert(:transaction) + + assert second_page_hashes == + [paging_options: %PagingOptions{key: {inserted_at, hash}, page_size: 50}] + |> Transaction.recent_pending_transactions() + |> Enum.map(& &1.hash) + |> Enum.reverse() + end + end + + describe "filter_non_traceable_transactions/1" do + test "does not raise when transaction params do not include type on zetachain" do + chain_type = Application.get_env(:explorer, :chain_type) + Application.put_env(:explorer, :chain_type, :zetachain) + + on_exit(fn -> Application.put_env(:explorer, :chain_type, chain_type) end) + + transaction_params = %{block_number: 13_393_871, hash: "0x123", index: 427} + + assert [transaction_params] == Transaction.filter_non_traceable_transactions([transaction_params]) + end + end end diff --git a/apps/explorer/test/explorer/chain/wei_test.exs b/apps/explorer/test/explorer/chain/wei_test.exs index 1dd94e53f27e..280e6a845320 100644 --- a/apps/explorer/test/explorer/chain/wei_test.exs +++ b/apps/explorer/test/explorer/chain/wei_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.WeiTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/explorer/chain/withdrawal_test.exs b/apps/explorer/test/explorer/chain/withdrawal_test.exs index ab797f617219..d1715dc89354 100644 --- a/apps/explorer/test/explorer/chain/withdrawal_test.exs +++ b/apps/explorer/test/explorer/chain/withdrawal_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.WithdrawalTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs b/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs new file mode 100644 index 000000000000..3470e18aa934 --- /dev/null +++ b/apps/explorer/test/explorer/chain_spec/genesis_data_test.exs @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +# apps/explorer/test/explorer/chain_spec/geth/genesis_data.exs +defmodule Explorer.ChainSpec.GenesisDataTest do + use ExUnit.Case, async: false + + @besu_genesis "#{File.cwd!()}/test/support/fixture/chain_spec/qdai_genesis.json" + |> File.read!() + |> Jason.decode!() + setup do + # Patch application env + old_genesis_data = Application.get_env(:explorer, Explorer.ChainSpec.GenesisData) + + old_json_rpc_named_arguments = + Application.get_env(:indexer, :json_rpc_named_arguments) + + Application.put_env( + :explorer, + Explorer.ChainSpec.GenesisData, + Keyword.merge(old_genesis_data, + chain_spec_path: "#{File.cwd!()}/test/support/fixture/chain_spec/qdai_genesis.json", + precompiled_config_path: nil + ) + ) + + Application.put_env( + :indexer, + :json_rpc_named_arguments, + Keyword.merge(old_json_rpc_named_arguments, variant: EthereumJSONRPC.Besu) + ) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.ChainSpec.GenesisData, old_genesis_data) + Application.put_env(:indexer, :json_rpc_named_arguments, old_json_rpc_named_arguments) + :meck.unload() + end) + + :ok + end + + test "remaps Besu variant to Geth and calls GethImporter.import_genesis_accounts/1" do + test_pid = self() + + :meck.new(Explorer.ChainSpec.Geth.Importer, [:passthrough]) + + :meck.expect(Explorer.ChainSpec.Geth.Importer, :import_genesis_accounts, fn args -> + send(test_pid, {:import_called, args}) + {:ok, []} + end) + + Explorer.ChainSpec.GenesisData.fetch_genesis_data() + + assert_receive {:import_called, @besu_genesis}, 1000 + end +end diff --git a/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs b/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs index 0bb7be80166a..4916ada1c426 100644 --- a/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs +++ b/apps/explorer/test/explorer/chain_spec/geth/importer_test.exs @@ -1,13 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ChainSpec.Geth.ImporterTest do use Explorer.DataCase import Mox - import EthereumJSONRPC, only: [integer_to_quantity: 1] alias Explorer.Chain.Address.{CoinBalance, CoinBalanceDaily} alias Explorer.Chain.{Address, Hash} alias Explorer.ChainSpec.Geth.Importer - alias Explorer.Repo + alias Explorer.{Repo, TestHelper} setup :set_mox_global @@ -101,16 +101,7 @@ defmodule Explorer.ChainSpec.Geth.ImporterTest do describe "import_genesis_accounts/1" do test "imports accounts" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - _opts -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) {:ok, %{address_coin_balances: address_coin_balances}} = Importer.import_genesis_accounts(@geth_genesis) @@ -121,16 +112,7 @@ defmodule Explorer.ChainSpec.Geth.ImporterTest do end test "imports contract code" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - [] -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) code = "0x608060405234801561001057600080fd5b50600436106100cf5760003560e01c806391ad27b41161008c57806398d5fdca1161006657806398d5fdca14610262578063a97e5c9314610280578063df5dd1a5146102dc578063eebd48b014610320576100cf565b806391ad27b4146101e457806391b7f5ed14610202578063955d14cd14610244576100cf565b80630aa6f2fe146100d457806320ba81ee1461011657806322a90082146101345780634c2c987c14610176578063764cbcd1146101985780637837efdc146101da575b600080fd5b610100600480360360208110156100ea57600080fd5b8101908080359060200190929190505050610353565b6040518082815260200191505060405180910390f35b61011e6103c4565b6040518082815260200191505060405180910390f35b6101606004803603602081101561014a57600080fd5b81019080803590602001909291905050506103ce565b6040518082815260200191505060405180910390f35b61017e61043f565b604051808215151515815260200191505060405180910390f35b6101c4600480360360208110156101ae57600080fd5b8101908080359060200190929190505050610456565b6040518082815260200191505060405180910390f35b6101e26104c7565b005b6101ec6104d2565b6040518082815260200191505060405180910390f35b61022e6004803603602081101561021857600080fd5b81019080803590602001909291905050506104dc565b6040518082815260200191505060405180910390f35b61024c6106a2565b6040518082815260200191505060405180910390f35b61026a6106ac565b6040518082815260200191505060405180910390f35b6102c26004803603602081101561029657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506106b6565b604051808215151515815260200191505060405180910390f35b61031e600480360360208110156102f257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506106d3565b005b61032861073d565b6040518085815260200184815260200183815260200182815260200194505050505060405180910390f35b600061035e336106b6565b6103b3576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816004819055506004549050919050565b6000600454905090565b60006103d9336106b6565b61042e576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816003819055506003549050919050565b6000600560009054906101000a900460ff16905090565b6000610461336106b6565b6104b6576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816002819055506002549050919050565b6104d033610771565b565b6000600354905090565b60006104e7336106b6565b61053c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b600082116105b2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260098152602001807f7072696365203c3d30000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6105ba6104d2565b6105c26106a2565b01421015610638576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260148152602001807f54494d455f4c4f434b5f494e434f4d504c45544500000000000000000000000081525060200191505060405180910390fd5b610641826107cb565b5061064b42610456565b503373ffffffffffffffffffffffffffffffffffffffff167f95dce27040c59c8b1c445b284f81a3aaae6eecd7d08d5c7684faee64cdb514a1836040518082815260200191505060405180910390a2819050919050565b6000600254905090565b6000600154905090565b60006106cc82600061083c90919063ffffffff16565b9050919050565b6106dc336106b6565b610731576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b61073a8161091a565b50565b60008060008061074b6106ac565b6107536104d2565b61075b6103c4565b6107636106a2565b935093509350935090919293565b61078581600061097390919063ffffffff16565b8073ffffffffffffffffffffffffffffffffffffffff167f9c8e7d83025bef8a04c664b2f753f64b8814bdb7e27291d7e50935f18cc3c71260405160405180910390a250565b60006107d6336106b6565b61082b576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816001819055506001549050919050565b60008073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614156108c3576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526022815260200180610b2d6022913960400191505060405180910390fd5b8260000160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900460ff16905092915050565b61092e816000610a3090919063ffffffff16565b8073ffffffffffffffffffffffffffffffffffffffff167e47706786c922d17b39285dc59d696bafea72c0b003d3841ae1202076f4c2e460405160405180910390a250565b61097d828261083c565b6109d2576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526021815260200180610b0c6021913960400191505060405180910390fd5b60008260000160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548160ff0219169083151502179055505050565b610a3a828261083c565b15610aad576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040180806020018281038252601f8152602001807f526f6c65733a206163636f756e7420616c72656164792068617320726f6c650081525060200191505060405180910390fd5b60018260000160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548160ff021916908315150217905550505056fe526f6c65733a206163636f756e7420646f6573206e6f74206861766520726f6c65526f6c65733a206163636f756e7420697320746865207a65726f20616464726573734f7261636c65526f6c653a2063616c6c657220646f6573206e6f74206861766520746865204f7261636c6520726f6c65a265627a7a72315820df30730da57a5061c487e0b37e84e80308fa443e2e80ee9117a13fa8149caf4164736f6c634300050b0032" @@ -152,16 +134,7 @@ defmodule Explorer.ChainSpec.Geth.ImporterTest do end test "imports polygon accounts" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) {:ok, %{address_coin_balances: address_coin_balances}} = Importer.import_genesis_accounts(@polygon_genesis) @@ -172,16 +145,7 @@ defmodule Explorer.ChainSpec.Geth.ImporterTest do end test "imports optimism accounts" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) {:ok, %{address_coin_balances: address_coin_balances}} = Importer.import_genesis_accounts(@optimism_genesis) @@ -191,40 +155,4 @@ defmodule Explorer.ChainSpec.Geth.ImporterTest do assert Address |> Repo.all() |> Enum.count() == 2 end end - - defp eth_block_number_fake_response(block_quantity) do - %{ - id: 0, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] - } - } - end end diff --git a/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs b/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs index 24ce1da121b6..d711dcb2e088 100644 --- a/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs +++ b/apps/explorer/test/explorer/chain_spec/parity/importer_test.exs @@ -1,14 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ChainSpec.Parity.ImporterTest do use Explorer.DataCase import Mox - import EthereumJSONRPC, only: [integer_to_quantity: 1] alias Explorer.Chain.Address.{CoinBalance, CoinBalanceDaily} alias Explorer.Chain.Block.{EmissionReward, Range} alias Explorer.Chain.{Address, Hash, Wei} alias Explorer.ChainSpec.Parity.Importer - alias Explorer.Repo + alias Explorer.{Repo, TestHelper} setup :set_mox_global @@ -148,16 +148,7 @@ defmodule Explorer.ChainSpec.Parity.ImporterTest do describe "import_genesis_accounts/1" do test "imports accounts" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) {:ok, %{address_coin_balances: address_coin_balances}} = Importer.import_genesis_accounts(@chain_spec) @@ -168,16 +159,7 @@ defmodule Explorer.ChainSpec.Parity.ImporterTest do end test "imports contract code" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - [] -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) code = "0x608060405234801561001057600080fd5b50600436106100cf5760003560e01c806391ad27b41161008c57806398d5fdca1161006657806398d5fdca14610262578063a97e5c9314610280578063df5dd1a5146102dc578063eebd48b014610320576100cf565b806391ad27b4146101e457806391b7f5ed14610202578063955d14cd14610244576100cf565b80630aa6f2fe146100d457806320ba81ee1461011657806322a90082146101345780634c2c987c14610176578063764cbcd1146101985780637837efdc146101da575b600080fd5b610100600480360360208110156100ea57600080fd5b8101908080359060200190929190505050610353565b6040518082815260200191505060405180910390f35b61011e6103c4565b6040518082815260200191505060405180910390f35b6101606004803603602081101561014a57600080fd5b81019080803590602001909291905050506103ce565b6040518082815260200191505060405180910390f35b61017e61043f565b604051808215151515815260200191505060405180910390f35b6101c4600480360360208110156101ae57600080fd5b8101908080359060200190929190505050610456565b6040518082815260200191505060405180910390f35b6101e26104c7565b005b6101ec6104d2565b6040518082815260200191505060405180910390f35b61022e6004803603602081101561021857600080fd5b81019080803590602001909291905050506104dc565b6040518082815260200191505060405180910390f35b61024c6106a2565b6040518082815260200191505060405180910390f35b61026a6106ac565b6040518082815260200191505060405180910390f35b6102c26004803603602081101561029657600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506106b6565b604051808215151515815260200191505060405180910390f35b61031e600480360360208110156102f257600080fd5b81019080803573ffffffffffffffffffffffffffffffffffffffff1690602001909291905050506106d3565b005b61032861073d565b6040518085815260200184815260200183815260200182815260200194505050505060405180910390f35b600061035e336106b6565b6103b3576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816004819055506004549050919050565b6000600454905090565b60006103d9336106b6565b61042e576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816003819055506003549050919050565b6000600560009054906101000a900460ff16905090565b6000610461336106b6565b6104b6576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816002819055506002549050919050565b6104d033610771565b565b6000600354905090565b60006104e7336106b6565b61053c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b600082116105b2576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260098152602001807f7072696365203c3d30000000000000000000000000000000000000000000000081525060200191505060405180910390fd5b6105ba6104d2565b6105c26106a2565b01421015610638576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260148152602001807f54494d455f4c4f434b5f494e434f4d504c45544500000000000000000000000081525060200191505060405180910390fd5b610641826107cb565b5061064b42610456565b503373ffffffffffffffffffffffffffffffffffffffff167f95dce27040c59c8b1c445b284f81a3aaae6eecd7d08d5c7684faee64cdb514a1836040518082815260200191505060405180910390a2819050919050565b6000600254905090565b6000600154905090565b60006106cc82600061083c90919063ffffffff16565b9050919050565b6106dc336106b6565b610731576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b61073a8161091a565b50565b60008060008061074b6106ac565b6107536104d2565b61075b6103c4565b6107636106a2565b935093509350935090919293565b61078581600061097390919063ffffffff16565b8073ffffffffffffffffffffffffffffffffffffffff167f9c8e7d83025bef8a04c664b2f753f64b8814bdb7e27291d7e50935f18cc3c71260405160405180910390a250565b60006107d6336106b6565b61082b576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526030815260200180610b4f6030913960400191505060405180910390fd5b816001819055506001549050919050565b60008073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1614156108c3576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526022815260200180610b2d6022913960400191505060405180910390fd5b8260000160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060009054906101000a900460ff16905092915050565b61092e816000610a3090919063ffffffff16565b8073ffffffffffffffffffffffffffffffffffffffff167e47706786c922d17b39285dc59d696bafea72c0b003d3841ae1202076f4c2e460405160405180910390a250565b61097d828261083c565b6109d2576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526021815260200180610b0c6021913960400191505060405180910390fd5b60008260000160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548160ff0219169083151502179055505050565b610a3a828261083c565b15610aad576040517f08c379a000000000000000000000000000000000000000000000000000000000815260040180806020018281038252601f8152602001807f526f6c65733a206163636f756e7420616c72656164792068617320726f6c650081525060200191505060405180910390fd5b60018260000160008373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002060006101000a81548160ff021916908315150217905550505056fe526f6c65733a206163636f756e7420646f6573206e6f74206861766520726f6c65526f6c65733a206163636f756e7420697320746865207a65726f20616464726573734f7261636c65526f6c653a2063616c6c657220646f6573206e6f74206861766520746865204f7261636c6520726f6c65a265627a7a72315820df30730da57a5061c487e0b37e84e80308fa443e2e80ee9117a13fa8149caf4164736f6c634300050b0032" @@ -199,16 +181,7 @@ defmodule Explorer.ChainSpec.Parity.ImporterTest do end test "imports coin balances without 0x" do - block_quantity = integer_to_quantity(1) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - [] -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(1) {:ok, %{address_coin_balances: address_coin_balances}} = Importer.import_genesis_accounts(@chain_classic_spec) @@ -218,40 +191,4 @@ defmodule Explorer.ChainSpec.Parity.ImporterTest do assert Address |> Repo.all() |> Enum.count() == 8894 end end - - defp eth_block_number_fake_response(block_quantity) do - %{ - id: 0, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] - } - } - end end diff --git a/apps/explorer/test/explorer/chain_test.exs b/apps/explorer/test/explorer/chain_test.exs index 036a4f272eab..2d8a318b9750 100644 --- a/apps/explorer/test/explorer/chain_test.exs +++ b/apps/explorer/test/explorer/chain_test.exs @@ -1,11 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ChainTest do use Explorer.DataCase use EthereumJSONRPC.Case require Ecto.Query - import Ecto.Query - import EthereumJSONRPC, only: [integer_to_quantity: 1] import Explorer.Factory import Mox @@ -18,7 +17,6 @@ defmodule Explorer.ChainTest do Hash, InternalTransaction, Log, - PendingBlockOperation, Token, TokenTransfer, Transaction, @@ -26,13 +24,24 @@ defmodule Explorer.ChainTest do } alias Explorer.{Chain, Etherscan} - alias Explorer.Chain.Address.Counters - alias Explorer.Chain.Cache.Counters.{BlocksCount, TransactionsCount, PendingBlockOperationCount} - alias Explorer.Chain.InternalTransaction.Type + alias Explorer.Chain.Cache.ChainId + + alias Explorer.Chain.Cache.Counters.{ + BlocksCount, + TransactionsCount, + PendingBlockOperationCount, + PendingTransactionOperationCount + } + + alias Explorer.Chain.MultichainSearchDb.{BalancesExportQueue, MainExportQueue} alias Explorer.Chain.Supply.ProofOfAuthority alias Explorer.Chain.Cache.Counters.AddressesCount + alias Explorer.TestHelper + + alias Explorer.Utility.AddressIdToAddressHash + @first_topic_hex_string "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" @second_topic_hex_string "0x000000000000000000000000e8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" @third_topic_hex_string "0x000000000000000000000000515c09c5bba1ed566b02a5b0599ec5d5d0aee73d" @@ -43,48 +52,6 @@ defmodule Explorer.ChainTest do setup :verify_on_exit! - describe "remove_nonconsensus_blocks_from_pending_ops/0" do - test "removes pending ops for nonconsensus blocks" do - block = insert(:block) - insert(:pending_block_operation, block: block, block_number: block.number) - - nonconsensus_block = insert(:block, consensus: false) - insert(:pending_block_operation, block: nonconsensus_block, block_number: nonconsensus_block.number) - - config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) - Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) - - on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) - - :ok = Chain.remove_nonconsensus_blocks_from_pending_ops() - - assert Repo.get(PendingBlockOperation, block.hash) - assert is_nil(Repo.get(PendingBlockOperation, nonconsensus_block.hash)) - end - - test "removes pending ops for nonconsensus blocks by block hashes" do - block = insert(:block) - insert(:pending_block_operation, block: block, block_number: block.number) - - nonconsensus_block = insert(:block, consensus: false) - insert(:pending_block_operation, block: nonconsensus_block, block_number: nonconsensus_block.number) - - nonconsensus_block1 = insert(:block, consensus: false) - insert(:pending_block_operation, block: nonconsensus_block1, block_number: nonconsensus_block1.number) - - config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) - Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(config, :block_traceable?, true)) - - on_exit(fn -> Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, config) end) - - :ok = Chain.remove_nonconsensus_blocks_from_pending_ops([nonconsensus_block1.hash]) - - assert Repo.get(PendingBlockOperation, block.hash) - assert Repo.get(PendingBlockOperation, nonconsensus_block.hash) - assert is_nil(Repo.get(PendingBlockOperation, nonconsensus_block1.hash)) - end - end - describe "address_estimated_count/0" do test "returns the number of all addresses" do insert(:address, fetched_coin_balance: 0) @@ -561,7 +528,13 @@ defmodule Explorer.ChainTest do setup do Supervisor.terminate_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) Supervisor.restart_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) - on_exit(fn -> Supervisor.terminate_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) end) + Supervisor.terminate_child(Explorer.Supervisor, PendingTransactionOperationCount.child_id()) + Supervisor.restart_child(Explorer.Supervisor, PendingTransactionOperationCount.child_id()) + + on_exit(fn -> + Supervisor.terminate_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, PendingTransactionOperationCount.child_id()) + end) end test "finished indexing" do @@ -590,8 +563,12 @@ defmodule Explorer.ChainTest do configuration = Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor) Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor, disabled?: false) + geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(geth_config, :block_traceable?, true)) + on_exit(fn -> Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor, configuration) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, geth_config) end) refute Chain.finished_indexing_internal_transactions?() @@ -691,8 +668,6 @@ defmodule Explorer.ChainTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) @@ -701,8 +676,6 @@ defmodule Explorer.ChainTest do transaction: transaction, index: index, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index ) end) @@ -728,30 +701,6 @@ defmodule Explorer.ChainTest do end end - describe "token_contract_address_from_token_name/1" do - test "return not found if token doesn't exist" do - name = "AYR" - - assert {:error, :not_found} = Chain.token_contract_address_from_token_name(name) - end - - test "return the correct token if it exists" do - name = "AYR" - insert(:token, symbol: name) - - assert {:ok, _} = Chain.token_contract_address_from_token_name(name) - end - - test "return not found if multiple records are in the results" do - name = "TOKEN" - - insert(:token, symbol: name) - insert(:token, symbol: name) - - assert {:error, :not_found} = Chain.token_contract_address_from_token_name(name) - end - end - describe "find_or_insert_address_from_hash/1" do test "returns an address if it already exists" do address = insert(:address) @@ -888,6 +837,8 @@ defmodule Explorer.ChainTest do setup do Supervisor.terminate_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) Supervisor.restart_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, PendingTransactionOperationCount.child_id()) + Supervisor.restart_child(Explorer.Supervisor, PendingTransactionOperationCount.child_id()) configuration = Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor) Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor, disabled?: false) @@ -895,10 +846,11 @@ defmodule Explorer.ChainTest do Application.put_env(:indexer, :trace_first_block, 0) Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction.Supervisor, configuration) Supervisor.terminate_child(Explorer.Supervisor, PendingBlockOperationCount.child_id()) + Supervisor.terminate_child(Explorer.Supervisor, PendingTransactionOperationCount.child_id()) end) end - test "returns indexed ratio" do + test "returns indexed ratio (pending_block_operation)" do for index <- 0..9 do block = insert(:block, number: index) @@ -917,6 +869,41 @@ defmodule Explorer.ChainTest do assert Decimal.compare(Chain.indexed_ratio_internal_transactions(), Decimal.from_float(0.7)) == :eq end + test "returns indexed ratio (pending_transaction_operation)" do + for index <- 0..9 do + block = insert(:block, number: index) + + transaction_hashes = + 2 + |> insert_list(:transaction) + |> with_block(block) + |> Enum.map(& &1.hash) + + if index === 0 || index === 5 || index === 7 do + insert(:pending_transaction_operation, transaction_hash: List.first(transaction_hashes)) + end + end + + geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + json_rpc_config = Application.get_env(:explorer, :json_rpc_named_arguments) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(geth_config, :block_traceable?, false)) + + Application.put_env( + :explorer, + :json_rpc_named_arguments, + Keyword.put(json_rpc_config, :variant, EthereumJSONRPC.Geth) + ) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, geth_config) + Application.put_env(:explorer, :json_rpc_named_arguments, json_rpc_config) + end) + + Chain.indexed_ratio_internal_transactions() + + assert Decimal.compare(Chain.indexed_ratio_internal_transactions(), Decimal.from_float(0.7)) == :eq + end + test "returns 0 if no blocks" do assert Decimal.new(0) == Chain.indexed_ratio_internal_transactions() end @@ -940,35 +927,6 @@ defmodule Explorer.ChainTest do end end - describe "fetch_min_block_number/0" do - test "fetches min block numbers" do - for index <- 5..9 do - insert(:block, number: index) - Process.sleep(200) - end - - assert 5 = Chain.fetch_min_block_number() - end - - test "fetches min when there are no blocks" do - assert 0 = Chain.fetch_min_block_number() - end - end - - describe "fetch_max_block_number/0" do - test "fetches max block numbers" do - for index <- 5..9 do - insert(:block, number: index) - end - - assert 9 = Chain.fetch_max_block_number() - end - - test "fetches max when there are no blocks" do - assert 0 = Chain.fetch_max_block_number() - end - end - describe "fetch_sum_coin_total_supply/0" do test "fetches coin total supply" do for index <- 0..4 do @@ -1121,6 +1079,7 @@ defmodule Explorer.ChainTest do %{ block_number: 37, transaction_hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", + transaction_index: 1, # transaction with index 0 is ignored in Nethermind JSON RPC Variant and not ignored in case of Geth index: 1, trace_address: [], @@ -1163,7 +1122,7 @@ defmodule Explorer.ChainTest do gas_price: 100_000_000_000, gas_used: 50450, hash: "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", - index: 0, + index: 1, input: "0x10855269000000000000000000000000862d67cb0773ee3f8ce7ea89b328ffea861ab3ef", nonce: 4, public_key: @@ -1224,6 +1183,11 @@ defmodule Explorer.ChainTest do gas_int = Decimal.new("4677320") gas_used_int = Decimal.new("27770") + %{address_id: from_address_id} = + AddressIdToAddressHash.find_or_create("0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca") + + %{address_id: to_address_id} = AddressIdToAddressHash.find_or_create("0x8bf38d4764929064f2d4d3a56520a76ab3df415b") + assert {:ok, %{ addresses: [ @@ -1307,37 +1271,26 @@ defmodule Explorer.ChainTest do ], internal_transactions: [ %InternalTransaction{ - call_type: :call, + call_type_enum: :call, created_contract_code: nil, error: nil, gas: ^gas_int, gas_used: ^gas_used_int, index: 1, init: nil, - input: %Explorer.Chain.Data{ + input: %Data{ bytes: <<16, 133, 82, 105, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 134, 45, 103, 203, 7, 115, 238, 63, 140, 231, 234, 137, 179, 40, 255, 234, 134, 26, 179, 239>> }, - output: %Explorer.Chain.Data{bytes: ""}, - trace_address: [], + output: %Data{bytes: ""}, + trace_address: nil, type: :call, block_number: 37, - transaction_index: nil, - block_index: 0, + transaction_index: 1, created_contract_address_hash: nil, - from_address_hash: %Explorer.Chain.Hash{ - byte_count: 20, - bytes: - <<232, 221, 197, 199, 162, 210, 240, 215, 169, 121, 132, 89, 192, 16, 79, 223, 94, 152, 122, - 202>> - }, - to_address_hash: %Explorer.Chain.Hash{ - byte_count: 20, - bytes: - <<139, 243, 141, 71, 100, 146, 144, 100, 242, 212, 211, 165, 101, 32, 167, 106, 179, 223, 65, - 91>> - } + from_address_id: ^from_address_id, + to_address_id: ^to_address_id } ], logs: [ @@ -1371,7 +1324,7 @@ defmodule Explorer.ChainTest do transactions: [ %Transaction{ block_number: 37, - index: 0, + index: 1, hash: %Hash{ byte_count: 32, bytes: @@ -1425,113 +1378,235 @@ defmodule Explorer.ChainTest do } ] }} = Chain.import(@import_data) - end - end - describe "list_blocks/2" do - test "without blocks" do - assert [] = Chain.list_blocks() + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 end - test "with blocks" do - %Block{hash: hash} = insert(:block) + test "populates main multichain export queue, if the multichain service is enabled" do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + multichain_configuration = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) - assert [%Block{hash: ^hash}] = Chain.list_blocks() - end + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, multichain_configuration) + end) - test "with blocks can be paginated" do - second_page_block_ids = - 50 - |> insert_list(:block) - |> Enum.map(& &1.number) + bypass = Bypass.open() - block = insert(:block) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + addresses_chunk_size: 7_000 + ) - assert second_page_block_ids == - [paging_options: %PagingOptions{key: {block.number}, page_size: 50}] - |> Chain.list_blocks() - |> Enum.map(& &1.number) - |> Enum.reverse() - end - end + TestHelper.get_chain_id_mock() + Chain.import(@import_data) - describe "block_hash_by_number/1" do - test "without blocks returns empty map" do - assert Chain.block_hash_by_number([]) == %{} + # 3 addresses + 1 block + 1 transaction + assert Repo.aggregate(MainExportQueue, :count, :hash) == 5 end - test "with consensus block returns mapping" do - block = insert(:block) + test "doesn't populate main multichain export queue from on_demand fetcher, if the address is inactive on the chain" do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + multichain_configuration = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) - assert Chain.block_hash_by_number([block.number]) == %{block.number => block.hash} - end + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, multichain_configuration) + end) - test "with non-consensus block does not return mapping" do - block = insert(:block, consensus: false) + bypass = Bypass.open() - assert Chain.block_hash_by_number([block.number]) == %{} - end - end + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + addresses_chunk_size: 7_000 + ) - describe "stream_blocks_without_rewards/2" do - test "includes consensus blocks" do - %Block{hash: consensus_hash} = insert(:block, consensus: true) + import_data_1 = %{ + addresses: %{ + params: [ + %{ + hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + transactions_count: 0, + token_transfers_count: 0, + fetched_coin_balance: nil + } + ] + }, + broadcast: :on_demand + } - assert {:ok, [%Block{hash: ^consensus_hash}]} = Chain.stream_blocks_without_rewards([], &[&1 | &2]) - end + TestHelper.get_chain_id_mock() + Chain.import(import_data_1) + + # non-active address isn't imported from on-demand fetcher + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + + import_data_2 = %{ + addresses: %{ + params: [ + %{ + hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987acb", + transactions_count: 0, + token_transfers_count: 0, + fetched_coin_balance: nil + } + ] + } + } - test "does not include consensus block that has a reward" do - %Block{hash: consensus_hash, miner_hash: miner_hash} = insert(:block, consensus: true) - insert(:reward, address_hash: miner_hash, block_hash: consensus_hash) + Chain.import(import_data_2) + + # "non-active" address is imported from regular fetcher + assert Repo.aggregate(MainExportQueue, :count, :hash) == 1 + + import_data_3 = %{ + addresses: %{ + params: [ + %{ + hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987acd", + transactions_count: 0, + token_transfers_count: 0, + fetched_coin_balance: %Wei{value: Decimal.new(10)} + } + ] + }, + broadcast: :on_demand + } - assert {:ok, []} = Chain.stream_blocks_without_rewards([], &[&1 | &2]) - end + Chain.import(import_data_3) + + # address with a non-zero balance is imported from on-demand fetcher + assert Repo.aggregate(MainExportQueue, :count, :hash) == 2 + + import_data_4 = %{ + addresses: %{ + params: [ + %{ + hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987ace", + transactions_count: 1, + token_transfers_count: 0, + fetched_coin_balance: %Wei{value: Decimal.new(0)} + } + ] + }, + broadcast: :on_demand + } + + Chain.import(import_data_4) + + # address with non-zero transactions counter is imported from on-demand fetcher + assert Repo.aggregate(MainExportQueue, :count, :hash) == 3 + + import_data_5 = %{ + addresses: %{ + params: [ + %{ + hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987acc", + transactions_count: 1, + token_transfers_count: nil, + fetched_coin_balance: nil + } + ] + }, + broadcast: :on_demand + } - # https://github.com/poanetwork/blockscout/issues/1310 regression test - test "does not include non-consensus blocks" do - insert(:block, consensus: false) + Chain.import(import_data_5) - assert {:ok, []} = Chain.stream_blocks_without_rewards([], &[&1 | &2]) + # address with non-zero token transfers counter is imported from on-demand fetcher + assert Repo.aggregate(MainExportQueue, :count, :hash) == 4 end - end - describe "get_blocks_validated_by_address/2" do - test "returns nothing when there are no blocks" do - %Address{hash: address_hash} = insert(:address) + test "populates balances multichain export queue and updates it, if the multichain service is enabled" do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + multichain_configuration = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch) - assert [] = Chain.get_blocks_validated_by_address(address_hash) - end + on_exit(fn -> + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, multichain_configuration) + end) - test "returns the blocks validated by a specified address" do - %Address{hash: address_hash} = address = insert(:address) - another_address = insert(:address) + bypass = Bypass.open() - block = insert(:block, miner: address, miner_hash: address.hash) - insert(:block, miner: another_address, miner_hash: another_address.hash) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + addresses_chunk_size: 7_000 + ) - results = - address_hash - |> Chain.get_blocks_validated_by_address() - |> Enum.map(& &1.hash) + import_data_1 = %{ + address_current_token_balances: %{ + params: [ + %{ + address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + token_contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + block_number: "37", + value: 200, + value_fetched_at: DateTime.utc_now(), + token_type: "ERC-20", + token_id: nil + } + ] + } + } + + TestHelper.get_chain_id_mock() + Chain.import(import_data_1) + + # 1 token balance + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 1 + + [token_balance_export_item] = Repo.all(BalancesExportQueue) + assert token_balance_export_item.value == %Explorer.Chain.Wei{value: Decimal.new(200)} + + import_data_2 = %{ + address_current_token_balances: %{ + params: [ + %{ + address_hash: "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", + token_contract_address_hash: "0x8bf38d4764929064f2d4d3a56520a76ab3df415b", + block_number: "40", + value: 500, + value_fetched_at: DateTime.utc_now(), + token_type: "ERC-20", + token_id: nil + } + ] + } + } - assert results == [block.hash] + Chain.import(import_data_2) + + # 1 token balance + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 1 + + [token_balance_export_item] = Repo.all(BalancesExportQueue) + # token balance value has been updated + assert token_balance_export_item.value == %Explorer.Chain.Wei{value: Decimal.new(500)} end + end - test "with blocks can be paginated" do - %Address{hash: address_hash} = address = insert(:address) + describe "list_blocks/2" do + test "without blocks" do + assert [] = Chain.list_blocks() + end - first_page_block = insert(:block, miner: address, miner_hash: address.hash, number: 0) - second_page_block = insert(:block, miner: address, miner_hash: address.hash, number: 2) + test "with blocks" do + %Block{hash: hash} = insert(:block) - assert [first_page_block.number] == - [paging_options: %PagingOptions{key: {1}, page_size: 1}] - |> Chain.get_blocks_validated_by_address(address_hash) - |> Enum.map(& &1.number) - |> Enum.reverse() + assert [%Block{hash: ^hash}] = Chain.list_blocks() + end + + test "with blocks can be paginated" do + second_page_block_ids = + 50 + |> insert_list(:block) + |> Enum.map(& &1.number) + + block = insert(:block) - assert [second_page_block.number] == - [paging_options: %PagingOptions{key: {3}, page_size: 1}] - |> Chain.get_blocks_validated_by_address(address_hash) + assert second_page_block_ids == + [paging_options: %PagingOptions{key: {block.number}, page_size: 50}] + |> Chain.list_blocks() |> Enum.map(& &1.number) |> Enum.reverse() end @@ -1549,781 +1624,300 @@ defmodule Explorer.ChainTest do end end - describe "address_to_internal_transactions/1" do - test "with single transaction containing two internal transactions" do - address = insert(:address) + describe "transaction_estimated_count/1" do + setup do + Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Counters.TransactionsCount.child_id()) + Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Counters.TransactionsCount.child_id()) + :ok + end + + test "returns integer" do + assert is_integer(TransactionsCount.get()) + end + end + + describe "transaction_to_logs/3" do + test "without logs" do + transaction = insert(:transaction) - block = insert(:block, number: 2000) + assert [] = Chain.transaction_to_logs(transaction.hash) + end + test "with logs" do transaction = :transaction |> insert() - |> with_block(block) + |> with_block() - %InternalTransaction{transaction_hash: first_transaction_hash, index: first_index} = - insert(:internal_transaction, - index: 1, - transaction: transaction, - to_address: address, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, - transaction_index: transaction.index - ) + %Log{transaction_hash: transaction_hash, index: index} = + insert(:log, transaction: transaction, block: transaction.block, block_number: transaction.block_number) + + assert [%Log{transaction_hash: ^transaction_hash, index: ^index}] = Chain.transaction_to_logs(transaction.hash) + end + + test "with logs can be paginated" do + transaction = + :transaction + |> insert() + |> with_block() - %InternalTransaction{transaction_hash: second_transaction_hash, index: second_index} = - insert(:internal_transaction, - index: 2, + log = + insert(:log, transaction: transaction, - to_address: address, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 2, - transaction_index: transaction.index + index: 1, + block: transaction.block, + block_number: transaction.block_number ) - result = - address.hash - |> Chain.address_to_internal_transactions() - |> Enum.map(&{&1.transaction_hash, &1.index}) + second_page_indexes = + 2..51 + |> Enum.map(fn index -> + insert(:log, + transaction: transaction, + index: index, + block: transaction.block, + block_number: transaction.block_number + ) + end) + |> Enum.map(& &1.index) - assert Enum.member?(result, {first_transaction_hash, first_index}) - assert Enum.member?(result, {second_transaction_hash, second_index}) + assert second_page_indexes == + transaction.hash + |> Chain.transaction_to_logs(paging_options: %PagingOptions{key: {log.index}, page_size: 50}) + |> Enum.map(& &1.index) end - test "loads associations in necessity_by_association" do - %Address{hash: address_hash} = address = insert(:address) - block = insert(:block, number: 2000) - + test "with logs necessity_by_association loads associations" do transaction = :transaction |> insert() - |> with_block(block) + |> with_block() - insert(:internal_transaction, - transaction: transaction, - to_address: address, - index: 0, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, - transaction_index: transaction.index - ) + insert(:log, transaction: transaction, block: transaction.block, block_number: transaction.block_number) - insert(:internal_transaction, - transaction: transaction, - to_address: address, - index: 1, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, - transaction_index: transaction.index - ) + assert [%Log{address: %Address{}, transaction: %Transaction{}}] = + Chain.transaction_to_logs( + transaction.hash, + necessity_by_association: %{ + address: :optional, + transaction: :optional + } + ) assert [ - %InternalTransaction{ - from_address: %Ecto.Association.NotLoaded{}, - to_address: %Ecto.Association.NotLoaded{}, + %Log{ + address: %Ecto.Association.NotLoaded{}, transaction: %Ecto.Association.NotLoaded{} } - | _ - ] = Chain.address_to_internal_transactions(address_hash) - - assert [ - %InternalTransaction{ - from_address: %Address{}, - to_address: %Address{}, - transaction: %Transaction{} - } - | _ - ] = - Chain.address_to_internal_transactions( - address_hash, - necessity_by_association: %{ - [from_address: :names] => :optional, - [to_address: :names] => :optional, - :transaction => :optional - } - ) + ] = Chain.transaction_to_logs(transaction.hash) end + end - test "returns results in reverse chronological order by block number, transaction index, internal transaction index" do - address = insert(:address) + describe "transaction_to_token_transfers/2" do + test "without token transfers" do + transaction = insert(:transaction) - block = insert(:block, number: 7000) + assert [] = Chain.transaction_to_token_transfers(transaction.hash) + end - pending_transaction = + test "with token transfers" do + transaction = :transaction |> insert() - |> with_block(block) - - %InternalTransaction{transaction_hash: first_pending_transaction_hash, index: first_pending_index} = - insert( - :internal_transaction, - transaction: pending_transaction, - to_address: address, - index: 1, - block_number: pending_transaction.block_number, - block_hash: pending_transaction.block_hash, - block_index: 1, - transaction_index: pending_transaction.index - ) + |> with_block() - %InternalTransaction{transaction_hash: second_pending_transaction_hash, index: second_pending_index} = - insert( - :internal_transaction, - transaction: pending_transaction, - to_address: address, - index: 2, - block_number: pending_transaction.block_number, - block_hash: pending_transaction.block_hash, - block_index: 2, - transaction_index: pending_transaction.index + %TokenTransfer{transaction_hash: transaction_hash, log_index: log_index} = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number ) - a_block = insert(:block, number: 2000) + assert [%TokenTransfer{transaction_hash: ^transaction_hash, log_index: ^log_index}] = + Chain.transaction_to_token_transfers(transaction.hash) + end - first_a_transaction = + test "token transfers necessity_by_association loads associations" do + transaction = :transaction |> insert() - |> with_block(a_block) + |> with_block() - %InternalTransaction{transaction_hash: first_transaction_hash, index: first_index} = - insert( - :internal_transaction, - transaction: first_a_transaction, - to_address: address, - index: 1, - block_number: first_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 1, - transaction_index: first_a_transaction.index - ) + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) - %InternalTransaction{transaction_hash: second_transaction_hash, index: second_index} = - insert( - :internal_transaction, - transaction: first_a_transaction, - to_address: address, - index: 2, - block_number: first_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 2, - transaction_index: first_a_transaction.index - ) + assert [%TokenTransfer{token: %Token{}, transaction: %Transaction{}}] = + Chain.transaction_to_token_transfers( + transaction.hash, + necessity_by_association: %{ + token: :optional, + transaction: :optional + } + ) + + assert [ + %TokenTransfer{ + token: %Token{}, + transaction: %Ecto.Association.NotLoaded{} + } + ] = Chain.transaction_to_token_transfers(transaction.hash) + end - second_a_transaction = + test "token transfers ordered by ASC log_index" do + transaction = :transaction |> insert() - |> with_block(a_block) + |> with_block() - %InternalTransaction{transaction_hash: third_transaction_hash, index: third_index} = - insert( - :internal_transaction, - transaction: second_a_transaction, - to_address: address, - index: 1, - block_number: second_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 4, - transaction_index: second_a_transaction.index + token_transfer_0 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 0 ) - %InternalTransaction{transaction_hash: fourth_transaction_hash, index: fourth_index} = - insert( - :internal_transaction, - transaction: second_a_transaction, - to_address: address, - index: 2, - block_number: second_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 5, - transaction_index: second_a_transaction.index + token_transfer_4 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 4 ) - b_block = insert(:block, number: 6000) - - first_b_transaction = - :transaction - |> insert() - |> with_block(b_block) - - %InternalTransaction{transaction_hash: fifth_transaction_hash, index: fifth_index} = - insert( - :internal_transaction, - transaction: first_b_transaction, - to_address: address, - index: 1, - block_number: first_b_transaction.block_number, - block_hash: b_block.hash, - block_index: 1, - transaction_index: first_b_transaction.index + token_transfer_2 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 2 ) - %InternalTransaction{transaction_hash: sixth_transaction_hash, index: sixth_index} = - insert( - :internal_transaction, - transaction: first_b_transaction, - to_address: address, - index: 2, - block_number: first_b_transaction.block_number, - block_hash: b_block.hash, - block_index: 2, - transaction_index: first_b_transaction.index + token_transfer_1 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 1 ) - result = - address.hash - |> Chain.address_to_internal_transactions() - |> Enum.map(&{&1.transaction_hash, &1.index}) - - assert [ - {second_pending_transaction_hash, second_pending_index}, - {first_pending_transaction_hash, first_pending_index}, - {sixth_transaction_hash, sixth_index}, - {fifth_transaction_hash, fifth_index}, - {fourth_transaction_hash, fourth_index}, - {third_transaction_hash, third_index}, - {second_transaction_hash, second_index}, - {first_transaction_hash, first_index} - ] == result - end - - test "pages by {block_number, transaction_index, index}" do - address = insert(:address) - - pending_transaction = insert(:transaction) + token_transfer_3 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 3 + ) - old_block = insert(:block, consensus: false) + token_transfers_sorted = + [token_transfer_0, token_transfer_1, token_transfer_2, token_transfer_3, token_transfer_4] + |> Enum.map(&{&1.transaction_hash, &1.log_index}) - insert( - :internal_transaction, - transaction: pending_transaction, - to_address: address, - block_hash: old_block.hash, - block_index: 1, - index: 1 - ) + token_transfers_unsorted = + [token_transfer_1, token_transfer_0, token_transfer_2, token_transfer_3, token_transfer_4] + |> Enum.map(&{&1.transaction_hash, &1.log_index}) - insert( - :internal_transaction, - transaction: pending_transaction, - to_address: address, - block_hash: old_block.hash, - block_index: 2, - index: 2 - ) + assert token_transfers_sorted == + transaction.hash + |> Chain.transaction_to_token_transfers( + necessity_by_association: %{ + token: :optional, + transaction: :optional + } + ) + |> Enum.map(&{&1.transaction_hash, &1.log_index}) - a_block = insert(:block, number: 2000) + assert token_transfers_unsorted != + transaction.hash + |> Chain.transaction_to_token_transfers( + necessity_by_association: %{ + token: :optional, + transaction: :optional + } + ) + |> Enum.map(&{&1.transaction_hash, &1.log_index}) + end - first_a_transaction = + test "token transfers can be paginated" do + transaction = :transaction |> insert() - |> with_block(a_block) + |> with_block() - %InternalTransaction{transaction_hash: first_transaction_hash, index: first_index} = - insert( - :internal_transaction, - transaction: first_a_transaction, - to_address: address, - index: 1, - block_number: first_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 1, - transaction_index: first_a_transaction.index + token_transfer_0 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 0 ) - %InternalTransaction{transaction_hash: second_transaction_hash, index: second_index} = - insert( - :internal_transaction, - transaction: first_a_transaction, - to_address: address, - index: 2, - block_number: first_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 2, - transaction_index: first_a_transaction.index + token_transfer_4 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 4 ) - second_a_transaction = - :transaction - |> insert() - |> with_block(a_block) - - %InternalTransaction{transaction_hash: third_transaction_hash, index: third_index} = - insert( - :internal_transaction, - transaction: second_a_transaction, - to_address: address, - index: 1, - block_number: second_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 4, - transaction_index: second_a_transaction.index + token_transfer_2 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 2 ) - %InternalTransaction{transaction_hash: fourth_transaction_hash, index: fourth_index} = - insert( - :internal_transaction, - transaction: second_a_transaction, - to_address: address, - index: 2, - block_number: second_a_transaction.block_number, - block_hash: a_block.hash, - block_index: 5, - transaction_index: second_a_transaction.index + token_transfer_1 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 1 ) - b_block = insert(:block, number: 6000) - - first_b_transaction = - :transaction - |> insert() - |> with_block(b_block) - - %InternalTransaction{transaction_hash: fifth_transaction_hash, index: fifth_index} = - insert( - :internal_transaction, - transaction: first_b_transaction, - to_address: address, - index: 1, - block_number: first_b_transaction.block_number, - block_hash: b_block.hash, - block_index: 1, - transaction_index: first_b_transaction.index + token_transfer_3 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 3 ) - %InternalTransaction{transaction_hash: sixth_transaction_hash, index: sixth_index} = - insert( - :internal_transaction, - transaction: first_b_transaction, - to_address: address, - index: 2, - block_number: first_b_transaction.block_number, - block_hash: b_block.hash, - block_index: 2, - transaction_index: first_b_transaction.index + token_transfer_6 = + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + log_index: 6 ) - # When paged, internal transactions need an associated block number, so `second_pending` and `first_pending` are - # excluded. - assert [ - {sixth_transaction_hash, sixth_index}, - {fifth_transaction_hash, fifth_index}, - {fourth_transaction_hash, fourth_index}, - {third_transaction_hash, third_index}, - {second_transaction_hash, second_index}, - {first_transaction_hash, first_index} - ] == - address.hash - |> Chain.address_to_internal_transactions( - paging_options: %PagingOptions{key: {6001, 3, 2}, page_size: 8} - ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + token_transfers_first_page = + [token_transfer_0, token_transfer_1, token_transfer_2] |> Enum.map(&{&1.transaction_hash, &1.log_index}) - # block number ==, transaction index ==, internal transaction index < - assert [ - {fourth_transaction_hash, fourth_index}, - {third_transaction_hash, third_index}, - {second_transaction_hash, second_index}, - {first_transaction_hash, first_index} - ] == - address.hash - |> Chain.address_to_internal_transactions( - paging_options: %PagingOptions{key: {6000, 0, 1}, page_size: 8} - ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + token_transfers_second_page = + [token_transfer_2, token_transfer_3, token_transfer_4] |> Enum.map(&{&1.transaction_hash, &1.log_index}) - # block number ==, transaction index < - assert [ - {fourth_transaction_hash, fourth_index}, - {third_transaction_hash, third_index}, - {second_transaction_hash, second_index}, - {first_transaction_hash, first_index} - ] == - address.hash - |> Chain.address_to_internal_transactions( - paging_options: %PagingOptions{key: {6000, -1, -1}, page_size: 8} - ) - |> Enum.map(&{&1.transaction_hash, &1.index}) + token_transfers_third_page = + [token_transfer_4, token_transfer_6] |> Enum.map(&{&1.transaction_hash, &1.log_index}) - # block number < - assert [] == - address.hash - |> Chain.address_to_internal_transactions( - paging_options: %PagingOptions{key: {2000, -1, -1}, page_size: 8} + assert token_transfers_first_page == + transaction.hash + |> Chain.transaction_to_token_transfers( + necessity_by_association: %{ + token: :optional, + transaction: :optional + }, + paging_options: %PagingOptions{ + page_size: 3 + } ) - |> Enum.map(&{&1.transaction_hash, &1.index}) - end + |> Enum.map(&{&1.transaction_hash, &1.log_index}) - test "excludes internal transactions of type `call` when they are alone in the parent transaction" do - %Address{hash: address_hash} = address = insert(:address) - - transaction = - :transaction - |> insert(to_address: address) - |> with_block() - - insert(:internal_transaction, - index: 0, - to_address: address, - transaction: transaction, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, - transaction_index: transaction.index - ) - - assert Enum.empty?(Chain.address_to_internal_transactions(address_hash)) - end - - test "includes internal transactions of type `create` even when they are alone in the parent transaction" do - %Address{hash: address_hash} = address = insert(:address) - - transaction = - :transaction - |> insert(to_address: address) - |> with_block() - - expected = - insert( - :internal_transaction_create, - index: 0, - from_address: address, - transaction: transaction, - block_hash: transaction.block_hash, - block_index: 0, - block_number: transaction.block_number, - transaction_index: transaction.index - ) - - actual = Enum.at(Chain.address_to_internal_transactions(address_hash), 0) - - assert {actual.transaction_hash, actual.index} == {expected.transaction_hash, expected.index} - end - end - - describe "pending_transactions/0" do - test "without transactions" do - assert [] = Chain.recent_pending_transactions() - end - - test "with transactions" do - %Transaction{hash: hash} = insert(:transaction) - - assert [%Transaction{hash: ^hash}] = Chain.recent_pending_transactions() - end - - test "with transactions can be paginated" do - second_page_hashes = - 50 - |> insert_list(:transaction) - |> Enum.map(& &1.hash) - - %Transaction{inserted_at: inserted_at, hash: hash} = insert(:transaction) - - assert second_page_hashes == - [paging_options: %PagingOptions{key: {inserted_at, hash}, page_size: 50}] - |> Chain.recent_pending_transactions() - |> Enum.map(& &1.hash) - |> Enum.reverse() - end - end - - describe "transaction_estimated_count/1" do - setup do - Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Counters.TransactionsCount.child_id()) - Supervisor.restart_child(Explorer.Supervisor, Explorer.Chain.Cache.Counters.TransactionsCount.child_id()) - :ok - end - - test "returns integer" do - assert is_integer(TransactionsCount.get()) - end - end - - describe "transaction_to_logs/3" do - test "without logs" do - transaction = insert(:transaction) - - assert [] = Chain.transaction_to_logs(transaction.hash) - end - - test "with logs" do - transaction = - :transaction - |> insert() - |> with_block() - - %Log{transaction_hash: transaction_hash, index: index} = - insert(:log, transaction: transaction, block: transaction.block, block_number: transaction.block_number) - - assert [%Log{transaction_hash: ^transaction_hash, index: ^index}] = Chain.transaction_to_logs(transaction.hash) - end - - test "with logs can be paginated" do - transaction = - :transaction - |> insert() - |> with_block() - - log = - insert(:log, - transaction: transaction, - index: 1, - block: transaction.block, - block_number: transaction.block_number - ) - - second_page_indexes = - 2..51 - |> Enum.map(fn index -> - insert(:log, - transaction: transaction, - index: index, - block: transaction.block, - block_number: transaction.block_number - ) - end) - |> Enum.map(& &1.index) - - assert second_page_indexes == - transaction.hash - |> Chain.transaction_to_logs(paging_options: %PagingOptions{key: {log.index}, page_size: 50}) - |> Enum.map(& &1.index) - end - - test "with logs necessity_by_association loads associations" do - transaction = - :transaction - |> insert() - |> with_block() - - insert(:log, transaction: transaction, block: transaction.block, block_number: transaction.block_number) - - assert [%Log{address: %Address{}, transaction: %Transaction{}}] = - Chain.transaction_to_logs( - transaction.hash, - necessity_by_association: %{ - address: :optional, - transaction: :optional - } - ) - - assert [ - %Log{ - address: %Ecto.Association.NotLoaded{}, - transaction: %Ecto.Association.NotLoaded{} - } - ] = Chain.transaction_to_logs(transaction.hash) - end - end - - describe "transaction_to_token_transfers/2" do - test "without token transfers" do - transaction = insert(:transaction) - - assert [] = Chain.transaction_to_token_transfers(transaction.hash) - end - - test "with token transfers" do - transaction = - :transaction - |> insert() - |> with_block() - - %TokenTransfer{transaction_hash: transaction_hash, log_index: log_index} = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number - ) - - assert [%TokenTransfer{transaction_hash: ^transaction_hash, log_index: ^log_index}] = - Chain.transaction_to_token_transfers(transaction.hash) - end - - test "token transfers necessity_by_association loads associations" do - transaction = - :transaction - |> insert() - |> with_block() - - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number - ) - - assert [%TokenTransfer{token: %Token{}, transaction: %Transaction{}}] = - Chain.transaction_to_token_transfers( - transaction.hash, - necessity_by_association: %{ - token: :optional, - transaction: :optional - } - ) - - assert [ - %TokenTransfer{ - token: %Token{}, - transaction: %Ecto.Association.NotLoaded{} - } - ] = Chain.transaction_to_token_transfers(transaction.hash) - end - - test "token transfers ordered by ASC log_index" do - transaction = - :transaction - |> insert() - |> with_block() - - token_transfer_0 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 0 - ) - - token_transfer_4 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 4 - ) - - token_transfer_2 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 2 - ) - - token_transfer_1 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 1 - ) - - token_transfer_3 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 3 - ) - - token_transfers_sorted = - [token_transfer_0, token_transfer_1, token_transfer_2, token_transfer_3, token_transfer_4] - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - token_transfers_unsorted = - [token_transfer_1, token_transfer_0, token_transfer_2, token_transfer_3, token_transfer_4] - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - assert token_transfers_sorted == - transaction.hash - |> Chain.transaction_to_token_transfers( - necessity_by_association: %{ - token: :optional, - transaction: :optional - } - ) - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - assert token_transfers_unsorted != - transaction.hash - |> Chain.transaction_to_token_transfers( - necessity_by_association: %{ - token: :optional, - transaction: :optional - } - ) - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - end - - test "token transfers can be paginated" do - transaction = - :transaction - |> insert() - |> with_block() - - token_transfer_0 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 0 - ) - - token_transfer_4 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 4 - ) - - token_transfer_2 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 2 - ) - - token_transfer_1 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 1 - ) - - token_transfer_3 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 3 - ) - - token_transfer_6 = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number, - log_index: 6 - ) - - token_transfers_first_page = - [token_transfer_0, token_transfer_1, token_transfer_2] |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - token_transfers_second_page = - [token_transfer_2, token_transfer_3, token_transfer_4] |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - token_transfers_third_page = - [token_transfer_4, token_transfer_6] |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - assert token_transfers_first_page == - transaction.hash - |> Chain.transaction_to_token_transfers( - necessity_by_association: %{ - token: :optional, - transaction: :optional - }, - paging_options: %PagingOptions{ - page_size: 3 - } - ) - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - assert token_transfers_second_page == + assert token_transfers_second_page == transaction.hash |> Chain.transaction_to_token_transfers( necessity_by_association: %{ @@ -2333,850 +1927,204 @@ defmodule Explorer.ChainTest do paging_options: %PagingOptions{ key: {transaction.block_number, 1}, page_size: 3 - } - ) - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - - assert token_transfers_third_page == - transaction.hash - |> Chain.transaction_to_token_transfers( - necessity_by_association: %{ - token: :optional, - transaction: :optional - }, - paging_options: %PagingOptions{ - key: {transaction.block_number, 3}, - page_size: 3 - } - ) - |> Enum.map(&{&1.transaction_hash, &1.log_index}) - end - end - - describe "value/2" do - test "with InternalTransaction.t with :wei" do - assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new(1)}}, :wei) == Decimal.new(1) - end - - test "with InternalTransaction.t with :gwei" do - assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new(1)}}, :gwei) == Decimal.new("1e-9") - - assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new("1e9")}}, :gwei) == Decimal.new(1) - end - - test "with InternalTransaction.t with :ether" do - assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new(1)}}, :ether) == Decimal.new("1e-18") - - assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new("1e18")}}, :ether) == Decimal.new(1) - end - - test "with Transaction.t with :wei" do - assert Chain.value(%Transaction{value: %Wei{value: Decimal.new(1)}}, :wei) == Decimal.new(1) - end - - test "with Transaction.t with :gwei" do - assert Chain.value(%Transaction{value: %Wei{value: Decimal.new(1)}}, :gwei) == Decimal.new("1e-9") - assert Chain.value(%Transaction{value: %Wei{value: Decimal.new("1e9")}}, :gwei) == Decimal.new(1) - end - - test "with Transaction.t with :ether" do - assert Chain.value(%Transaction{value: %Wei{value: Decimal.new(1)}}, :ether) == Decimal.new("1e-18") - assert Chain.value(%Transaction{value: %Wei{value: Decimal.new("1e18")}}, :ether) == Decimal.new(1) - end - end - - describe "find_contract_address/1" do - test "doesn't find an address that doesn't have a code" do - address = insert(:address, contract_code: nil) - - response = Chain.find_contract_address(address.hash) - - assert {:error, :not_found} == response - end - - test "doesn't find a nonexistent address" do - nonexistent_address_hash = Factory.address_hash() - - response = Chain.find_contract_address(nonexistent_address_hash) - - assert {:error, :not_found} == response - end - - test "finds a contract address" do - address = - insert(:address, contract_code: Factory.data("contract_code"), smart_contract: nil, names: []) - |> Repo.preload( - [ - :token, - [smart_contract: :smart_contract_additional_sources], - Explorer.Chain.SmartContract.Proxy.Models.Implementation.proxy_implementations_association() - ] ++ Address.contract_creation_transaction_associations() - ) - - options = [ - necessity_by_association: %{ - :names => :optional, - :smart_contract => :optional, - :token => :optional, - Address.contract_creation_transaction_associations() => :optional - } - ] - - response = Chain.find_contract_address(address.hash, options) - - assert response == {:ok, address} - end - end - - describe "gas_payment_by_block_hash/1" do - setup do - number = 1 - - block = insert(:block, number: number, consensus: true) - - %{consensus_block: block, number: number} - end - - test "without consensus block hash has key with 0 value", %{consensus_block: consensus_block, number: number} do - non_consensus_block = insert(:block, number: number, consensus: false) - - :transaction - |> insert(gas_price: 1, block_consensus: false) - |> with_block(consensus_block, gas_used: 1) - - :transaction - |> insert(gas_price: 1, block_consensus: false) - |> with_block(consensus_block, gas_used: 2) - - assert Chain.gas_payment_by_block_hash([non_consensus_block.hash]) == %{ - non_consensus_block.hash => %Wei{value: Decimal.new(0)} - } - end - - test "with consensus block hash without transactions has key with 0 value", %{ - consensus_block: %Block{hash: consensus_block_hash} - } do - assert Chain.gas_payment_by_block_hash([consensus_block_hash]) == %{ - consensus_block_hash => %Wei{value: Decimal.new(0)} - } - end - - test "with consensus block hash with transactions has key with value", %{ - consensus_block: %Block{hash: consensus_block_hash} = consensus_block - } do - :transaction - |> insert(gas_price: 1) - |> with_block(consensus_block, gas_used: 2) - - :transaction - |> insert(gas_price: 3) - |> with_block(consensus_block, gas_used: 4) - - assert Chain.gas_payment_by_block_hash([consensus_block_hash]) == %{ - consensus_block_hash => %Wei{value: Decimal.new(14)} - } - end - end - - describe "missing_block_number_ranges/1" do - # 0000 - test "0..0 without blocks" do - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 0001 - test "0..0 with block 3" do - insert(:block, number: 3) - - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 0010 - test "0..0 with block 2" do - insert(:block, number: 2) - - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 0011 - test "0..0 with blocks 2,3" do - Enum.each([2, 3], &insert(:block, number: &1)) - - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 0100 - test "0..0 with block 1" do - insert(:block, number: 1) - - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 0101 - test "0..0 with blocks 1,3" do - Enum.each([1, 3], fn num -> - insert(:block, number: num) - Process.sleep(200) - end) - - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 0111 - test "0..0 with blocks 1..3" do - Enum.each(1..3, fn num -> - insert(:block, number: num) - Process.sleep(200) - end) - - assert Chain.missing_block_number_ranges(0..0) == [0..0] - end - - # 1000 - test "0..0 with block 0" do - insert(:block, number: 0) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1001 - test "0..0 with blocks 0,3" do - Enum.each([0, 3], &insert(:block, number: &1)) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1010 - test "0..0 with blocks 0,2" do - Enum.each([0, 2], &insert(:block, number: &1)) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1011 - test "0..0 with blocks 0,2,3" do - Enum.each([0, 2, 3], &insert(:block, number: &1)) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1100 - test "0..0 with blocks 0..1" do - Enum.each(0..1, &insert(:block, number: &1)) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1101 - test "0..0 with blocks 0,1,3" do - Enum.each([0, 1, 3], fn num -> - insert(:block, number: num) - Process.sleep(200) - end) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1110 - test "0..0 with blocks 0..2" do - Enum.each(0..2, &insert(:block, number: &1)) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - # 1111 - test "0..0 with blocks 0..3" do - Enum.each(0..2, fn num -> - insert(:block, number: num) - Process.sleep(200) - end) - - assert Chain.missing_block_number_ranges(0..0) == [] - end - - test "0..2 with block 1" do - insert(:block, number: 1) - - assert Chain.missing_block_number_ranges(0..2) == [0..0, 2..2] - end - end - - describe "recent_collated_transactions/1" do - test "with no collated transactions it returns an empty list" do - assert [] == Explorer.Chain.recent_collated_transactions(true) - end - - test "it excludes pending transactions" do - insert(:transaction) - assert [] == Explorer.Chain.recent_collated_transactions(true) - end - - test "returns a list of recent collated transactions" do - newest_first_transactions = - 50 - |> insert_list(:transaction) - |> with_block() - |> Enum.reverse() - - oldest_seen = Enum.at(newest_first_transactions, 9) - paging_options = %Explorer.PagingOptions{page_size: 10, key: {oldest_seen.block_number, oldest_seen.index}} - recent_collated_transactions = Explorer.Chain.recent_collated_transactions(true, paging_options: paging_options) - - assert length(recent_collated_transactions) == 10 - assert hd(recent_collated_transactions).hash == Enum.at(newest_first_transactions, 10).hash - end - - test "returns transactions with token_transfers preloaded" do - address = insert(:address) - token_contract_address = insert(:contract_address) - token = insert(:token, contract_address: token_contract_address) - - transaction = - :transaction - |> insert() - |> with_block() - - insert_list( - 2, - :token_transfer, - to_address: address, - transaction: transaction, - token_contract_address: token_contract_address, - token: token, - block: transaction.block - ) - - fetched_transaction = List.first(Explorer.Chain.recent_collated_transactions(true)) - assert fetched_transaction.hash == transaction.hash - assert length(fetched_transaction.token_transfers) == 2 - end - end - - describe "smart_contract_bytecode/1" do - test "fetches the smart contract bytecode" do - smart_contract_bytecode = - "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582040d82a7379b1ee1632ad4d8a239954fd940277b25628ead95259a85c5eddb2120029" - - created_contract_address = insert(:address, contract_code: smart_contract_bytecode) - - transaction = - :transaction - |> insert() - |> with_block() - - insert( - :internal_transaction_create, - transaction: transaction, - index: 0, - created_contract_address: created_contract_address, - created_contract_code: smart_contract_bytecode, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, - transaction_index: transaction.index - ) - - assert Chain.smart_contract_bytecode(created_contract_address.hash) == smart_contract_bytecode - end - end - - describe "stream_unfetched_balances/2" do - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.Block.t/0` `miner_hash`" do - %Address{hash: miner_hash} = miner = insert(:address) - %Block{number: block_number} = insert(:block, miner: miner) - balance = insert(:unfetched_balance, address_hash: miner_hash, block_number: block_number) - - assert {:ok, [%{address_hash: ^miner_hash, block_number: ^block_number}]} = - Chain.stream_unfetched_balances([], &[&1 | &2]) - - update_balance_value(balance, 1) - - assert {:ok, []} = Chain.stream_unfetched_balances([], &[&1 | &2]) - end - - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.Transaction.t/0` `from_address_hash`" do - %Address{hash: from_address_hash} = from_address = insert(:address) - %Block{number: block_number} = block = insert(:block) - - :transaction - |> insert(from_address: from_address) - |> with_block(block) - - balance = insert(:unfetched_balance, address_hash: from_address_hash, block_number: block_number) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - assert %{address_hash: from_address_hash, block_number: block_number} in balance_fields_list - - update_balance_value(balance, 1) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - refute %{address_hash: from_address_hash, block_number: block_number} in balance_fields_list - end - - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.Transaction.t/0` `to_address_hash`" do - %Address{hash: to_address_hash} = to_address = insert(:address) - %Block{number: block_number} = block = insert(:block) - - :transaction - |> insert(to_address: to_address) - |> with_block(block) - - balance = insert(:unfetched_balance, address_hash: to_address_hash, block_number: block_number) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - assert %{address_hash: to_address_hash, block_number: block_number} in balance_fields_list - - update_balance_value(balance, 1) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - refute %{address_hash: to_address_hash, block_number: block_number} in balance_fields_list - end - - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.Log.t/0` `address_hash`" do - address = insert(:address) - block = insert(:block) - - transaction = - :transaction - |> insert() - |> with_block(block) - - insert(:log, address: address, transaction: transaction, block: block, block_number: block.number) - - balance = insert(:unfetched_balance, address_hash: address.hash, block_number: block.number) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - assert %{ - address_hash: address.hash, - block_number: block.number - } in balance_fields_list - - update_balance_value(balance, 1) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - refute %{ - address_hash: address.hash, - block_number: block.number - } in balance_fields_list - end - - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.InternalTransaction.t/0` `created_contract_address_hash`" do - created_contract_address = insert(:address) - block = insert(:block) - - transaction = - :transaction - |> insert() - |> with_block(block) - - insert( - :internal_transaction_create, - created_contract_address: created_contract_address, - index: 0, - transaction: transaction, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, - transaction_index: transaction.index - ) - - balance = insert(:unfetched_balance, address_hash: created_contract_address.hash, block_number: block.number) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - assert %{ - address_hash: created_contract_address.hash, - block_number: block.number - } in balance_fields_list - - update_balance_value(balance, 1) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - refute %{ - address_hash: created_contract_address.hash, - block_number: block.number - } in balance_fields_list - end - - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.InternalTransaction.t/0` `from_address_hash`" do - from_address = insert(:address) - block = insert(:block) - - transaction = - :transaction - |> insert() - |> with_block(block) - - insert( - :internal_transaction_create, - from_address: from_address, - index: 0, - transaction: transaction, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, - transaction_index: transaction.index - ) - - balance = insert(:unfetched_balance, address_hash: from_address.hash, block_number: block.number) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - assert %{address_hash: from_address.hash, block_number: block.number} in balance_fields_list - - update_balance_value(balance, 1) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - refute %{address_hash: from_address.hash, block_number: block.number} in balance_fields_list - end - - test "with `t:Explorer.Chain.Address.CoinBalance.t/0` with value_fetched_at with same `address_hash` and `block_number` " <> - "does not return `t:Explorer.Chain.InternalTransaction.t/0` `to_address_hash`" do - to_address = insert(:address) - block = insert(:block) - - transaction = - :transaction - |> insert() - |> with_block(block) - - insert( - :internal_transaction_create, - to_address: to_address, - index: 0, - transaction: transaction, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, - transaction_index: transaction.index - ) - - balance = insert(:unfetched_balance, address_hash: to_address.hash, block_number: block.number) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - assert %{address_hash: to_address.hash, block_number: block.number} in balance_fields_list - - update_balance_value(balance, 1) - - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) - - refute %{address_hash: to_address.hash, block_number: block.number} in balance_fields_list - end - - test "an address_hash used for multiple block_numbers returns all block_numbers" do - miner = insert(:address) - mined_block = insert(:block, miner: miner) - - insert(:unfetched_balance, address_hash: miner.hash, block_number: mined_block.number) - - from_transaction_block = insert(:block) - - :transaction - |> insert(from_address: miner) - |> with_block(from_transaction_block) - - insert(:unfetched_balance, address_hash: miner.hash, block_number: from_transaction_block.number) - - to_transaction_block = insert(:block) - - :transaction - |> insert(to_address: miner) - |> with_block(to_transaction_block) - - insert(:unfetched_balance, address_hash: miner.hash, block_number: to_transaction_block.number) - - log_block = insert(:block) - - log_transaction = - :transaction - |> insert() - |> with_block(log_block) + } + ) + |> Enum.map(&{&1.transaction_hash, &1.log_index}) - insert(:log, address: miner, transaction: log_transaction, block: log_block, block_number: log_block.number) - insert(:unfetched_balance, address_hash: miner.hash, block_number: log_block.number) + assert token_transfers_third_page == + transaction.hash + |> Chain.transaction_to_token_transfers( + necessity_by_association: %{ + token: :optional, + transaction: :optional + }, + paging_options: %PagingOptions{ + key: {transaction.block_number, 3}, + page_size: 3 + } + ) + |> Enum.map(&{&1.transaction_hash, &1.log_index}) + end + end - from_internal_transaction_block = insert(:block) + describe "value/2" do + test "with InternalTransaction.t with :wei" do + assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new(1)}}, :wei) == Decimal.new(1) + end - from_internal_transaction_transaction = - :transaction - |> insert() - |> with_block(from_internal_transaction_block) + test "with InternalTransaction.t with :gwei" do + assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new(1)}}, :gwei) == Decimal.new("1e-9") - insert( - :internal_transaction_create, - from_address: miner, - index: 0, - transaction: from_internal_transaction_transaction, - block_number: from_internal_transaction_transaction.block_number, - block_hash: from_internal_transaction_transaction.block_hash, - block_index: 0, - transaction_index: from_internal_transaction_transaction.index - ) + assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new("1e9")}}, :gwei) == Decimal.new(1) + end - insert(:unfetched_balance, address_hash: miner.hash, block_number: from_internal_transaction_block.number) + test "with InternalTransaction.t with :ether" do + assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new(1)}}, :ether) == Decimal.new("1e-18") - to_internal_transaction_block = insert(:block) + assert Chain.value(%InternalTransaction{value: %Wei{value: Decimal.new("1e18")}}, :ether) == Decimal.new(1) + end - to_internal_transaction_transaction = - :transaction - |> insert() - |> with_block(to_internal_transaction_block) + test "with Transaction.t with :wei" do + assert Chain.value(%Transaction{value: %Wei{value: Decimal.new(1)}}, :wei) == Decimal.new(1) + end - insert( - :internal_transaction_create, - index: 0, - to_address: miner, - transaction: to_internal_transaction_transaction, - block_number: to_internal_transaction_transaction.block_number, - block_hash: to_internal_transaction_transaction.block_hash, - block_index: 0, - transaction_index: to_internal_transaction_transaction.index - ) + test "with Transaction.t with :gwei" do + assert Chain.value(%Transaction{value: %Wei{value: Decimal.new(1)}}, :gwei) == Decimal.new("1e-9") + assert Chain.value(%Transaction{value: %Wei{value: Decimal.new("1e9")}}, :gwei) == Decimal.new(1) + end - insert(:unfetched_balance, address_hash: miner.hash, block_number: to_internal_transaction_block.number) + test "with Transaction.t with :ether" do + assert Chain.value(%Transaction{value: %Wei{value: Decimal.new(1)}}, :ether) == Decimal.new("1e-18") + assert Chain.value(%Transaction{value: %Wei{value: Decimal.new("1e18")}}, :ether) == Decimal.new(1) + end + end - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) + describe "missing_block_number_ranges/1" do + # 0000 + test "0..0 without blocks" do + assert Chain.missing_block_number_ranges(0..0) == [0..0] + end - balance_fields_list_by_address_hash = Enum.group_by(balance_fields_list, & &1.address_hash) + # 0001 + test "0..0 with block 3" do + insert(:block, number: 3) - assert balance_fields_list_by_address_hash[miner.hash] |> Enum.map(& &1.block_number) |> Enum.sort() == - Enum.sort([ - to_internal_transaction_block.number, - from_internal_transaction_block.number, - log_block.number, - to_transaction_block.number, - from_transaction_block.number, - mined_block.number - ]) + assert Chain.missing_block_number_ranges(0..0) == [0..0] end - test "an address_hash used for the same block_number is only returned once" do - miner = insert(:address) - block = insert(:block, miner: miner) - - insert(:unfetched_balance, address_hash: miner.hash, block_number: block.number) + # 0010 + test "0..0 with block 2" do + insert(:block, number: 2) - :transaction - |> insert(from_address: miner) - |> with_block(block) + assert Chain.missing_block_number_ranges(0..0) == [0..0] + end - :transaction - |> insert(to_address: miner) - |> with_block(block) + # 0011 + test "0..0 with blocks 2,3" do + Enum.each([2, 3], &insert(:block, number: &1)) - log_transaction = - :transaction - |> insert() - |> with_block(block) + assert Chain.missing_block_number_ranges(0..0) == [0..0] + end - insert(:log, address: miner, transaction: log_transaction, block: block, block_number: block.number) + # 0100 + test "0..0 with block 1" do + insert(:block, number: 1) - from_internal_transaction_transaction = - :transaction - |> insert() - |> with_block(block) + assert Chain.missing_block_number_ranges(0..0) == [0..0] + end - insert( - :internal_transaction_create, - from_address: miner, - index: 0, - transaction: from_internal_transaction_transaction, - block_number: from_internal_transaction_transaction.block_number, - block_hash: from_internal_transaction_transaction.block_hash, - block_index: 0, - transaction_index: from_internal_transaction_transaction.index - ) + # 0101 + test "0..0 with blocks 1,3" do + Enum.each([1, 3], fn num -> + insert(:block, number: num) + Process.sleep(200) + end) - to_internal_transaction_transaction = - :transaction - |> insert() - |> with_block(block) + assert Chain.missing_block_number_ranges(0..0) == [0..0] + end - insert( - :internal_transaction_create, - to_address: miner, - index: 0, - transaction: to_internal_transaction_transaction, - block_number: to_internal_transaction_transaction.block_number, - block_hash: to_internal_transaction_transaction.block_hash, - block_index: 1, - transaction_index: to_internal_transaction_transaction.index - ) + # 0111 + test "0..0 with blocks 1..3" do + Enum.each(1..3, fn num -> + insert(:block, number: num) + Process.sleep(200) + end) - {:ok, balance_fields_list} = - Explorer.Chain.stream_unfetched_balances( - [], - fn balance_fields, acc -> [balance_fields | acc] end - ) + assert Chain.missing_block_number_ranges(0..0) == [0..0] + end - balance_fields_list_by_address_hash = Enum.group_by(balance_fields_list, & &1.address_hash) + # 1000 + test "0..0 with block 0" do + insert(:block, number: 0) - assert balance_fields_list_by_address_hash[miner.hash] |> Enum.map(& &1.block_number) |> Enum.sort() == [ - block.number - ] + assert Chain.missing_block_number_ranges(0..0) == [] end - end - describe "update_replaced_transactions/2" do - test "update replaced transactions" do - replaced_transaction_hash = "0x2a263224a95275d77bc30a7e131bc64d948777946a790c0915ab293791fbcb61" + # 1001 + test "0..0 with blocks 0,3" do + Enum.each([0, 3], &insert(:block, number: &1)) - address = insert(:address, hash: "0xb7cffe2ac19b9d5705a24cbe14fef5663af905a6") + assert Chain.missing_block_number_ranges(0..0) == [] + end - insert(:transaction, - from_address: address, - nonce: 1, - block_hash: nil, - index: nil, - block_number: nil, - hash: replaced_transaction_hash - ) + # 1010 + test "0..0 with blocks 0,2" do + Enum.each([0, 2], &insert(:block, number: &1)) - mined_transaction_hash = "0x1a263224a95275d77bc30a7e131bc64d948777946a790c0915ab293791fbcb61" - block = insert(:block) + assert Chain.missing_block_number_ranges(0..0) == [] + end - mined_transaction = - insert(:transaction, - from_address: address, - nonce: 1, - index: 0, - block_hash: block.hash, - block_number: block.number, - cumulative_gas_used: 1, - gas_used: 1, - hash: mined_transaction_hash - ) + # 1011 + test "0..0 with blocks 0,2,3" do + Enum.each([0, 2, 3], &insert(:block, number: &1)) - second_mined_transaction_hash = "0x3a263224a95275d77bc30a7e131bc64d948777946a790c0915ab293791fbcb61" - second_block = insert(:block) + assert Chain.missing_block_number_ranges(0..0) == [] + end - insert(:transaction, - from_address: address, - nonce: 1, - index: 0, - block_hash: second_block.hash, - block_number: second_block.number, - cumulative_gas_used: 1, - gas_used: 1, - hash: second_mined_transaction_hash - ) + # 1100 + test "0..0 with blocks 0..1" do + Enum.each(0..1, &insert(:block, number: &1)) - {1, _} = - Chain.update_replaced_transactions([ - %{ - block_hash: mined_transaction.block_hash, - nonce: mined_transaction.nonce, - from_address_hash: mined_transaction.from_address_hash - } - ]) + assert Chain.missing_block_number_ranges(0..0) == [] + end - replaced_transaction = Repo.get(Transaction, replaced_transaction_hash) + # 1101 + test "0..0 with blocks 0,1,3" do + Enum.each([0, 1, 3], fn num -> + insert(:block, number: num) + Process.sleep(200) + end) - assert replaced_transaction.status == :error - assert replaced_transaction.error == "dropped/replaced" + assert Chain.missing_block_number_ranges(0..0) == [] + end - found_mined_transaction = Repo.get(Transaction, mined_transaction_hash) + # 1110 + test "0..0 with blocks 0..2" do + Enum.each(0..2, &insert(:block, number: &1)) - assert found_mined_transaction.status == nil - assert found_mined_transaction.error == nil + assert Chain.missing_block_number_ranges(0..0) == [] + end - second_mined_transaction = Repo.get(Transaction, second_mined_transaction_hash) + # 1111 + test "0..0 with blocks 0..3" do + Enum.each(0..2, fn num -> + insert(:block, number: num) + Process.sleep(200) + end) - assert second_mined_transaction.status == nil - assert second_mined_transaction.error == nil + assert Chain.missing_block_number_ranges(0..0) == [] end - end - describe "stream_unfetched_token_balances/2" do - test "executes the given reducer with the query result" do - address = insert(:address, hash: "0xc45e4830dff873cf8b70de2b194d0ddd06ef651e") - token_balance = insert(:token_balance, value_fetched_at: nil, address: address) - insert(:token_balance) + test "0..2 with block 1" do + insert(:block, number: 1) - assert Chain.stream_unfetched_token_balances([], &[&1.block_number | &2]) == {:ok, [token_balance.block_number]} + assert Chain.missing_block_number_ranges(0..2) == [0..0, 2..2] end end - describe "stream_unfetched_uncles/2" do - test "does not return uncle hashes where t:Explorer.Chain.Block.SecondDegreeRelation.t/0 uncle_fetched_at is not nil" do - %Block.SecondDegreeRelation{nephew: %Block{}, nephew_hash: nephew_hash, index: index, uncle_hash: uncle_hash} = - insert(:block_second_degree_relation) + describe "smart_contract_bytecode/1" do + test "fetches the smart contract bytecode" do + smart_contract_bytecode = + "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a7230582040d82a7379b1ee1632ad4d8a239954fd940277b25628ead95259a85c5eddb2120029" - assert {:ok, [%{nephew_hash: ^nephew_hash, index: ^index}]} = - Explorer.Chain.stream_unfetched_uncles([], &[&1 | &2]) + created_contract_address = insert(:address, contract_code: smart_contract_bytecode) - query = from(bsdr in Block.SecondDegreeRelation, where: bsdr.uncle_hash == ^uncle_hash) + transaction = + :transaction + |> insert() + |> with_block() - assert {1, _} = Repo.update_all(query, set: [uncle_fetched_at: DateTime.utc_now()]) + insert( + :internal_transaction_create, + transaction: transaction, + index: 0, + created_contract_address: created_contract_address, + created_contract_code: smart_contract_bytecode, + block_number: transaction.block_number, + transaction_index: transaction.index + ) - assert {:ok, []} = Explorer.Chain.stream_unfetched_uncles([], &[&1 | &2]) + assert Chain.smart_contract_bytecode(created_contract_address.hash) == smart_contract_bytecode end end @@ -3465,42 +2413,6 @@ defmodule Explorer.ChainTest do # end end - describe "block_combined_rewards/1" do - test "sums the block_rewards values" do - block = insert(:block) - - insert( - :reward, - address_hash: block.miner_hash, - block_hash: block.hash, - address_type: :validator, - reward: Decimal.new(1_000_000_000_000_000_000) - ) - - insert( - :reward, - address_hash: block.miner_hash, - block_hash: block.hash, - address_type: :emission_funds, - reward: Decimal.new(1_000_000_000_000_000_000) - ) - - insert( - :reward, - address_hash: block.miner_hash, - block_hash: block.hash, - address_type: :uncle, - reward: Decimal.new(1_000_000_000_000_000_000) - ) - - block = Repo.preload(block, :rewards) - - {:ok, expected_value} = Wei.cast(3_000_000_000_000_000_000) - - assert Chain.block_combined_rewards(block) == expected_value - end - end - describe "transaction_token_transfer_type/1" do test "detects erc721 token transfer" do from_address_hash = "0x7a30272c902563b712245696f0a81c5a0e45ddc8" @@ -3550,177 +2462,6 @@ defmodule Explorer.ChainTest do end end - describe "contract_address?/2" do - test "returns true if address has contract code" do - code = %Data{ - bytes: <<1, 2, 3, 4, 5>> - } - - address = insert(:address, contract_code: code) - - assert Chain.contract_address?(to_string(address.hash), 1) - end - - test "returns false if address has not contract code" do - address = insert(:address) - - refute Chain.contract_address?(to_string(address.hash), 1) - end - - @tag :no_nethermind - @tag :no_geth - test "returns true if fetched code from json rpc", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - hash = "0x71300d93a8CdF93385Af9635388cF2D00b95a480" - - if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn _arguments, _options -> - {:ok, - [ - %{ - id: 0, - result: "0x0102030405" - } - ]} - end) - end - - assert Chain.contract_address?(to_string(hash), 1, json_rpc_named_arguments) - end - - @tag :no_nethermind - @tag :no_geth - test "returns false if no fetched code from json rpc", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - hash = "0x71300d93a8CdF93385Af9635388cF2D00b95a480" - - if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn _arguments, _options -> - {:ok, - [ - %{ - id: 0, - result: "0x" - } - ]} - end) - end - - refute Chain.contract_address?(to_string(hash), 1, json_rpc_named_arguments) - end - end - - describe "fetch_first_trace/2" do - test "fetched first trace", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - from_address_hash = "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca" - gas = 4_533_872 - - init = - "0x6060604052341561000f57600080fd5b60405160208061071a83398101604052808051906020019091905050806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055506003600160006001600281111561007e57fe5b60ff1660ff168152602001908152602001600020819055506002600160006002808111156100a857fe5b60ff1660ff168152602001908152602001600020819055505061064a806100d06000396000f30060606040526004361061008e576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff168063247b3210146100935780632ffdfc8a146100bc57806374294144146100f6578063ae4b1b5b14610125578063bf7370d11461017a578063d1104cb2146101a3578063eecd1079146101f8578063fcff021c14610221575b600080fd5b341561009e57600080fd5b6100a661024a565b6040518082815260200191505060405180910390f35b34156100c757600080fd5b6100e0600480803560ff16906020019091905050610253565b6040518082815260200191505060405180910390f35b341561010157600080fd5b610123600480803590602001909190803560ff16906020019091905050610276565b005b341561013057600080fd5b61013861037a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561018557600080fd5b61018d61039f565b6040518082815260200191505060405180910390f35b34156101ae57600080fd5b6101b66104d9565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561020357600080fd5b61020b610588565b6040518082815260200191505060405180910390f35b341561022c57600080fd5b6102346105bd565b6040518082815260200191505060405180910390f35b600060c8905090565b6000600160008360ff1660ff168152602001908152602001600020549050919050565b61027e6104d9565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156102b757600080fd5b60008160ff161115156102c957600080fd5b6002808111156102d557fe5b60ff168160ff16111515156102e957600080fd5b6000821180156103125750600160008260ff1660ff168152602001908152602001600020548214155b151561031d57600080fd5b81600160008360ff1660ff168152602001908152602001600020819055508060ff167fe868bbbdd6cd2efcd9ba6e0129d43c349b0645524aba13f8a43bfc7c5ffb0889836040518082815260200191505060405180910390a25050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000806000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16638b8414c46000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561042f57600080fd5b6102c65a03f1151561044057600080fd5b5050506040518051905090508073ffffffffffffffffffffffffffffffffffffffff16630eaba26a6000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b15156104b857600080fd5b6102c65a03f115156104c957600080fd5b5050506040518051905091505090565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663a3b3fff16000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561056857600080fd5b6102c65a03f1151561057957600080fd5b50505060405180519050905090565b60006105b860016105aa600261059c61039f565b6105e590919063ffffffff16565b61060090919063ffffffff16565b905090565b60006105e06105ca61039f565b6105d261024a565b6105e590919063ffffffff16565b905090565b60008082848115156105f357fe5b0490508091505092915050565b600080828401905083811015151561061457fe5b80915050929150505600a165627a7a723058206b7eef2a57eb659d5e77e45ab5bc074e99c6a841921038cdb931e119c6aac46c0029000000000000000000000000862d67cb0773ee3f8ce7ea89b328ffea861ab3ef" - - value = 0 - block_number = 39 - block_hash = "0x74c72ccabcb98b7ebbd7b31de938212b7e8814a002263b6569564e944d88f51f" - index = 0 - created_contract_address_hash = "0x1e0eaa06d02f965be2dfe0bc9ff52b2d82133461" - - created_contract_code = - "0x60606040526004361061008e576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff168063247b3210146100935780632ffdfc8a146100bc57806374294144146100f6578063ae4b1b5b14610125578063bf7370d11461017a578063d1104cb2146101a3578063eecd1079146101f8578063fcff021c14610221575b600080fd5b341561009e57600080fd5b6100a661024a565b6040518082815260200191505060405180910390f35b34156100c757600080fd5b6100e0600480803560ff16906020019091905050610253565b6040518082815260200191505060405180910390f35b341561010157600080fd5b610123600480803590602001909190803560ff16906020019091905050610276565b005b341561013057600080fd5b61013861037a565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561018557600080fd5b61018d61039f565b6040518082815260200191505060405180910390f35b34156101ae57600080fd5b6101b66104d9565b604051808273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200191505060405180910390f35b341561020357600080fd5b61020b610588565b6040518082815260200191505060405180910390f35b341561022c57600080fd5b6102346105bd565b6040518082815260200191505060405180910390f35b600060c8905090565b6000600160008360ff1660ff168152602001908152602001600020549050919050565b61027e6104d9565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff161415156102b757600080fd5b60008160ff161115156102c957600080fd5b6002808111156102d557fe5b60ff168160ff16111515156102e957600080fd5b6000821180156103125750600160008260ff1660ff168152602001908152602001600020548214155b151561031d57600080fd5b81600160008360ff1660ff168152602001908152602001600020819055508060ff167fe868bbbdd6cd2efcd9ba6e0129d43c349b0645524aba13f8a43bfc7c5ffb0889836040518082815260200191505060405180910390a25050565b6000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b6000806000809054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16638b8414c46000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561042f57600080fd5b6102c65a03f1151561044057600080fd5b5050506040518051905090508073ffffffffffffffffffffffffffffffffffffffff16630eaba26a6000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b15156104b857600080fd5b6102c65a03f115156104c957600080fd5b5050506040518051905091505090565b60008060009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663a3b3fff16000604051602001526040518163ffffffff167c0100000000000000000000000000000000000000000000000000000000028152600401602060405180830381600087803b151561056857600080fd5b6102c65a03f1151561057957600080fd5b50505060405180519050905090565b60006105b860016105aa600261059c61039f565b6105e590919063ffffffff16565b61060090919063ffffffff16565b905090565b60006105e06105ca61039f565b6105d261024a565b6105e590919063ffffffff16565b905090565b60008082848115156105f357fe5b0490508091505092915050565b600080828401905083811015151561061457fe5b80915050929150505600a165627a7a723058206b7eef2a57eb659d5e77e45ab5bc074e99c6a841921038cdb931e119c6aac46c0029" - - gas_used = 382_953 - trace_address = [] - transaction_hash = "0x0fa6f723216dba694337f9bb37d8870725655bdf2573526a39454685659e39b1" - transaction_index = 0 - type = "create" - - if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do - expect(EthereumJSONRPC.Mox, :json_rpc, fn _json, _options -> - {:ok, - [ - %{ - id: 0, - result: %{ - "output" => "0x", - "stateDiff" => nil, - "trace" => [ - %{ - "action" => %{ - "from" => from_address_hash, - "gas" => integer_to_quantity(gas), - "init" => init, - "value" => integer_to_quantity(value) - }, - "blockNumber" => block_number, - "index" => index, - "result" => %{ - "address" => created_contract_address_hash, - "code" => created_contract_code, - "gasUsed" => integer_to_quantity(gas_used) - }, - "traceAddress" => trace_address, - "type" => type - } - ], - "transactionHash" => transaction_hash - } - } - ]} - end) - end - - {:ok, created_contract_address_hash_bytes} = Chain.string_to_address_hash(created_contract_address_hash) - {:ok, from_address_hash_bytes} = Chain.string_to_address_hash(from_address_hash) - {:ok, created_contract_code_bytes} = Data.cast(created_contract_code) - {:ok, init_bytes} = Data.cast(init) - {:ok, transaction_hash_bytes} = Chain.string_to_transaction_hash(transaction_hash) - {:ok, type_bytes} = Type.load(type) - value_wei = %Wei{value: Decimal.new(value)} - - assert Chain.fetch_first_trace( - [ - %{ - hash_data: transaction_hash, - block_hash: block_hash, - block_number: block_number, - transaction_index: transaction_index - } - ], - json_rpc_named_arguments - ) == { - :ok, - [ - %{ - block_index: 0, - block_number: block_number, - block_hash: block_hash, - call_type: nil, - created_contract_address_hash: created_contract_address_hash_bytes, - created_contract_code: created_contract_code_bytes, - from_address_hash: from_address_hash_bytes, - gas: gas, - gas_used: gas_used, - index: index, - init: init_bytes, - input: nil, - output: nil, - to_address_hash: nil, - trace_address: trace_address, - transaction_hash: transaction_hash_bytes, - type: type_bytes, - value: value_wei, - transaction_index: transaction_index - } - ] - } - end - end - describe "transaction_to_revert_reason/1" do test "returns correct revert_reason from DB" do # Error("No credit of that type") @@ -3737,7 +2478,7 @@ defmodule Explorer.ChainTest do gas: 27319, gas_price: "0x1b31d2900", value: "0x86b3", - input: %Explorer.Chain.Data{bytes: <<1>>} + input: %Data{bytes: <<1>>} ) |> with_block(insert(:block, number: 1)) diff --git a/apps/explorer/test/explorer/config_helper_test.exs b/apps/explorer/test/explorer/config_helper_test.exs index 08fe9728ca8e..d7ac340b59bc 100644 --- a/apps/explorer/test/explorer/config_helper_test.exs +++ b/apps/explorer/test/explorer/config_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule ConfigHelperTest do use ExUnit.Case @@ -40,6 +41,59 @@ defmodule ConfigHelperTest do end end + describe "parse_path_env_var/2" do + test "common case" do + System.put_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "test") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER") == "/test" + end + + test "don't use defined default" do + System.put_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "test") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "default") == "/test" + end + + test "don't prepend / if path already has it" do + System.put_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "/test") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "default") == "/test" + end + + test "using defined fallback default" do + System.delete_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "default") == "/default" + end + + test "invalid path" do + System.put_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "//test") + + assert_raise RuntimeError, "Invalid path in environment variable NFT_MEDIA_HANDLER_BUCKET_FOLDER: //test", fn -> + ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER") + end + end + + test "invalid path with default" do + System.put_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "//test") + + assert_raise RuntimeError, "Invalid path in environment variable NFT_MEDIA_HANDLER_BUCKET_FOLDER: //test", fn -> + ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "default") + end + end + + test "empty path with default" do + System.delete_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "default") == "/default" + end + + test "nil path" do + System.delete_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER") == nil + end + + test "nil path with default" do + System.delete_env("NFT_MEDIA_HANDLER_BUCKET_FOLDER") + assert ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER", "default") == "/default" + end + end + defp clear_env_variables do System.delete_env("ETHEREUM_JSONRPC_HTTP_URLS") System.delete_env("ETHEREUM_JSONRPC_HTTP_URL") diff --git a/apps/explorer/test/explorer/etherscan/logs_test.exs b/apps/explorer/test/explorer/etherscan/logs_test.exs index 2df2a32b15c1..558e9d13a93b 100644 --- a/apps/explorer/test/explorer/etherscan/logs_test.exs +++ b/apps/explorer/test/explorer/etherscan/logs_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Etherscan.LogsTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/etherscan_test.exs b/apps/explorer/test/explorer/etherscan_test.exs index 37b6e685d618..0c9763bf2592 100644 --- a/apps/explorer/test/explorer/etherscan_test.exs +++ b/apps/explorer/test/explorer/etherscan_test.exs @@ -1,10 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.EtherscanTest do use Explorer.DataCase import Explorer.Factory alias Explorer.{Etherscan, Chain} - alias Explorer.Chain.Transaction + alias Explorer.Chain.{InternalTransaction, Transaction} + alias Explorer.Utility.AddressIdToAddressHash describe "list_transactions/2" do test "with empty db" do @@ -62,17 +64,18 @@ defmodule Explorer.EtherscanTest do |> with_contract_creation(contract_address) |> with_block() - %{created_contract_address_hash: contract_address_hash} = + %{created_contract_address_id: contract_address_id} = :internal_transaction_create |> insert( transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, transaction_index: transaction.index ) - |> with_contract_creation(contract_address) + + contract_address_hash = AddressIdToAddressHash.id_to_hash(contract_address_id) [found_transaction] = Etherscan.list_transactions(contract_address_hash) @@ -140,17 +143,18 @@ defmodule Explorer.EtherscanTest do |> with_contract_creation(contract_address) |> with_block() - %{created_contract_address_hash: contract_hash} = + %{created_contract_address_id: contract_id} = :internal_transaction_create |> insert( transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, transaction_index: transaction.index ) - |> with_contract_creation(contract_address) + + contract_hash = AddressIdToAddressHash.id_to_hash(contract_id) [found_transaction] = Etherscan.list_transactions(address.hash) @@ -615,7 +619,7 @@ defmodule Explorer.EtherscanTest do end end - describe "list_internal_transactions/1 with transaction hash" do + describe "list_internal_transactions/2 with transaction hash" do test "with empty db" do transaction = build(:transaction) @@ -639,13 +643,14 @@ defmodule Explorer.EtherscanTest do |> insert( transaction: transaction, index: 0, + value: 1, from_address: address, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) - |> with_contract_creation(contract_address) + |> InternalTransaction.preload_addresses() [found_internal_transaction] = Etherscan.list_internal_transactions(transaction.hash) @@ -662,7 +667,7 @@ defmodule Explorer.EtherscanTest do assert found_internal_transaction.type == internal_transaction.type assert found_internal_transaction.gas == internal_transaction.gas assert found_internal_transaction.gas_used == internal_transaction.gas_used - assert found_internal_transaction.error == internal_transaction.error + assert found_internal_transaction.error_id == internal_transaction.error_id end test "with transaction with 0 internal transactions" do @@ -684,9 +689,8 @@ defmodule Explorer.EtherscanTest do insert(:internal_transaction, transaction: transaction, index: index, + value: index + 1, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index ) end @@ -711,28 +715,25 @@ defmodule Explorer.EtherscanTest do insert(:internal_transaction, transaction: transaction1, index: 0, + value: 1, block_number: transaction1.block_number, - block_hash: transaction1.block_hash, - block_index: 0, transaction_index: transaction1.index ) insert(:internal_transaction, transaction: transaction1, index: 1, + value: 2, block_number: transaction1.block_number, - block_hash: transaction1.block_hash, - block_index: 1, transaction_index: transaction1.index ) insert(:internal_transaction, transaction: transaction2, index: 0, + value: 3, type: :reward, block_number: transaction2.block_number, - block_hash: transaction2.block_hash, - block_index: 2, transaction_index: transaction2.index ) @@ -746,6 +747,70 @@ defmodule Explorer.EtherscanTest do assert length(internal_transactions2) == 1 end + test "only non zero value internal transactions by default" do + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..2 do + insert(:internal_transaction, + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: index + 1, + block_number: transaction.block_number + ) + end + + for index <- 3..5 do + insert(:internal_transaction, + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: 0, + block_number: transaction.block_number + ) + end + + found_internal_transactions = Etherscan.list_internal_transactions(transaction.hash) + + assert length(found_internal_transactions) == 2 + end + + test "with zero value internal transactions when `include_zero_value: true` option is set" do + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..2 do + insert(:internal_transaction, + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: index + 1, + block_number: transaction.block_number + ) + end + + for index <- 3..5 do + insert(:internal_transaction, + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: 0, + block_number: transaction.block_number + ) + end + + options = %{include_zero_value: true} + + found_internal_transactions = Etherscan.list_internal_transactions(transaction.hash, options) + + assert length(found_internal_transactions) == 5 + end + # Note that `list_internal_transactions/1` relies on # `Chain.where_transaction_has_multiple_transactions/1` to ensure the # following behavior: @@ -770,7 +835,7 @@ defmodule Explorer.EtherscanTest do address = insert(:address) contract_address = insert(:contract_address) - block = insert(:block) + %{number: block_number, timestamp: block_timestamp} = block = insert(:block) transaction = :transaction @@ -778,39 +843,55 @@ defmodule Explorer.EtherscanTest do |> with_contract_creation(contract_address) |> with_block(block) - internal_transaction = + transaction_hash = transaction.hash + + %{ + from_address_hash: from_address_hash, + to_address_hash: to_address_hash, + value: value, + created_contract_address_hash: created_contract_address_hash, + input: input, + index: index, + transaction_index: transaction_index, + type: type, + call_type: call_type, + call_type_enum: call_type_enum, + gas: gas, + gas_used: gas_used, + error_id: error_id + } = :internal_transaction_create |> insert( transaction: transaction, index: 0, from_address: address, + created_contract_code: contract_address.contract_code, + created_contract_address: contract_address, block_number: transaction.block_number, - block_hash: block.hash, - block_index: 0, transaction_index: transaction.index ) - |> with_contract_creation(contract_address) + |> InternalTransaction.preload_addresses() [found_internal_transaction] = Etherscan.list_internal_transactions(address.hash) - expected = %{ - block_number: block.number, - block_timestamp: block.timestamp, - from_address_hash: internal_transaction.from_address_hash, - to_address_hash: internal_transaction.to_address_hash, - value: internal_transaction.value, - created_contract_address_hash: internal_transaction.created_contract_address_hash, - input: internal_transaction.input, - index: internal_transaction.index, - transaction_hash: internal_transaction.transaction_hash, - type: internal_transaction.type, - call_type: internal_transaction.call_type, - gas: internal_transaction.gas, - gas_used: internal_transaction.gas_used, - error: internal_transaction.error - } - - assert found_internal_transaction == expected + assert %{ + block_number: ^block_number, + block_timestamp: ^block_timestamp, + from_address_hash: ^from_address_hash, + to_address_hash: ^to_address_hash, + value: ^value, + created_contract_address_hash: ^created_contract_address_hash, + input: ^input, + index: ^index, + transaction_hash: ^transaction_hash, + transaction_index: ^transaction_index, + type: ^type, + call_type: ^call_type, + call_type_enum: ^call_type_enum, + gas: ^gas, + gas_used: ^gas_used, + error_id: ^error_id + } = found_internal_transaction end test "with address with 0 internal transactions" do @@ -836,8 +917,6 @@ defmodule Explorer.EtherscanTest do index: index, from_address: address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index } @@ -862,8 +941,6 @@ defmodule Explorer.EtherscanTest do transaction: transaction, index: 0, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index, created_contract_address: address1 ) @@ -872,8 +949,6 @@ defmodule Explorer.EtherscanTest do transaction: transaction, index: 1, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 1, transaction_index: transaction.index, from_address: address1 ) @@ -882,8 +957,6 @@ defmodule Explorer.EtherscanTest do transaction: transaction, index: 2, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 2, transaction_index: transaction.index, to_address: address1 ) @@ -892,8 +965,6 @@ defmodule Explorer.EtherscanTest do transaction: transaction, index: 3, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 3, transaction_index: transaction.index, from_address: address2 ) @@ -922,8 +993,6 @@ defmodule Explorer.EtherscanTest do index: index, from_address: address, block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, transaction_index: transaction.index } @@ -953,23 +1022,24 @@ defmodule Explorer.EtherscanTest do blocks = [_, second_block, third_block, _] = insert_list(4, :block) address = insert(:address) - for block <- blocks, index <- 0..1 do + for block <- blocks do transaction = :transaction |> insert() |> with_block(block) - internal_transaction_details = %{ - transaction: transaction, - index: index, - from_address: address, - block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: index, - transaction_index: transaction.index - } - - insert(:internal_transaction, internal_transaction_details) + for index <- 0..1 do + internal_transaction_details = %{ + transaction: transaction, + index: index, + from_address: address, + block_number: block.number, + transaction_index: transaction.index, + value: 1 + } + + insert(:internal_transaction, internal_transaction_details) + end end options = %{ @@ -989,6 +1059,86 @@ defmodule Explorer.EtherscanTest do end end + test "only non zero value internal transactions by default" do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..3 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: 0, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + for index <- 4..5 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: index, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + found_internal_transactions = Etherscan.list_internal_transactions(address.hash) + + assert length(found_internal_transactions) == 2 + end + + test "with zero value internal transactions when `include_zero_value: true` option is set" do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..3 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: 0, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + for index <- 4..5 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: index, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + options = %{include_zero_value: true} + + found_internal_transactions = Etherscan.list_internal_transactions(address.hash, options) + + assert length(found_internal_transactions) == 5 + end + # Note that `list_internal_transactions/2` relies on # `Chain.where_transaction_has_multiple_transactions/1` to ensure the # following behavior: @@ -1002,11 +1152,93 @@ defmodule Explorer.EtherscanTest do # These two requirements are tested in `Explorer.ChainTest`. end + describe "list_internal_transactions/2 without param" do + test "only non zero value internal transactions by default" do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..3 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: 0, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + for index <- 4..5 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: index, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + found_internal_transactions = Etherscan.list_internal_transactions(:all) + + assert length(found_internal_transactions) == 2 + end + + test "with zero value internal transactions when `include_zero_value: true` option is set" do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + for index <- 0..3 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: 0, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + for index <- 4..5 do + internal_transaction_details = %{ + transaction: transaction, + transaction_index: transaction.index, + index: index, + value: index, + from_address: address, + block_number: transaction.block_number + } + + insert(:internal_transaction, internal_transaction_details) + end + + options = %{include_zero_value: true} + + found_internal_transactions = Etherscan.list_internal_transactions(:all, options) + + assert length(found_internal_transactions) == 5 + end + end + describe "list_token_transfers/2" do test "with empty db" do address = build(:address) - assert Etherscan.list_token_transfers(address.hash, nil) == [] + assert Etherscan.list_token_transfers(:erc20, address.hash, nil, %{}) == [] end test "with from address" do @@ -1022,7 +1254,7 @@ defmodule Explorer.EtherscanTest do block_number: transaction.block_number ) - [found_token_transfer] = Etherscan.list_token_transfers(token_transfer.from_address_hash, nil) + [found_token_transfer] = Etherscan.list_token_transfers(:erc20, token_transfer.from_address_hash, nil, %{}) assert token_transfer.from_address_hash == found_token_transfer.from_address_hash end @@ -1040,7 +1272,7 @@ defmodule Explorer.EtherscanTest do block_number: transaction.block_number ) - [found_token_transfer] = Etherscan.list_token_transfers(token_transfer.to_address_hash, nil) + [found_token_transfer] = Etherscan.list_token_transfers(:erc20, token_transfer.to_address_hash, nil, %{}) assert token_transfer.to_address_hash == found_token_transfer.to_address_hash end @@ -1048,7 +1280,7 @@ defmodule Explorer.EtherscanTest do test "with address with 0 token transfers" do address = insert(:address) - assert Etherscan.list_token_transfers(address.hash, nil) == [] + assert Etherscan.list_token_transfers(:erc20, address.hash, nil, %{}) == [] end test "with address with multiple token transfers" do @@ -1081,7 +1313,7 @@ defmodule Explorer.EtherscanTest do block_number: transaction.block_number ) - found_token_transfers = Etherscan.list_token_transfers(address1.hash, nil) + found_token_transfers = Etherscan.list_token_transfers(:erc20, address1.hash, nil, %{}) assert length(found_token_transfers) == 2 @@ -1090,73 +1322,6 @@ defmodule Explorer.EtherscanTest do end end - test "confirmations value is calculated correctly" do - insert(:block) - - transaction = - :transaction - |> insert() - |> with_block() - - token_transfer = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number - ) - - insert(:block) - - [found_token_transfer] = Etherscan.list_token_transfers(token_transfer.from_address_hash, nil) - - block_height = Chain.block_height() - expected_confirmations = block_height - transaction.block_number - - assert found_token_transfer.confirmations == expected_confirmations - end - - test "returns all required fields" do - block = insert(:block) - - transaction = - %{block: block} = - :transaction - |> insert(block_timestamp: block.timestamp) - |> with_block(block) - - token_transfer = - insert(:token_transfer, - transaction: transaction, - block: transaction.block, - block_number: transaction.block_number - ) - - {:ok, token} = Chain.token_from_address_hash(token_transfer.token_contract_address_hash) - - [found_token_transfer] = Etherscan.list_token_transfers(token_transfer.from_address_hash, nil) - - assert found_token_transfer.block_number == transaction.block_number - assert found_token_transfer.block_timestamp == block.timestamp - assert found_token_transfer.transaction_hash == token_transfer.transaction_hash - assert found_token_transfer.transaction_nonce == transaction.nonce - assert found_token_transfer.block_hash == block.hash - assert found_token_transfer.from_address_hash == token_transfer.from_address_hash - assert found_token_transfer.token_contract_address_hash == token_transfer.token_contract_address_hash - assert found_token_transfer.to_address_hash == token_transfer.to_address_hash - assert found_token_transfer.amount == token_transfer.amount - assert found_token_transfer.token_name == token.name - assert found_token_transfer.token_symbol == token.symbol - assert found_token_transfer.token_decimals == token.decimals - assert found_token_transfer.transaction_index == transaction.index - assert found_token_transfer.transaction_gas == transaction.gas - assert found_token_transfer.transaction_gas_price == transaction.gas_price - assert found_token_transfer.transaction_gas_used == transaction.gas_used - assert found_token_transfer.transaction_cumulative_gas_used == transaction.cumulative_gas_used - assert found_token_transfer.transaction_input == transaction.input - # There is a separate test to ensure confirmations are calculated correctly. - assert found_token_transfer.confirmations - end - test "orders token transfers by block, in ascending order (default)" do address = insert(:address) @@ -1199,7 +1364,7 @@ defmodule Explorer.EtherscanTest do block_number: transaction3.block_number ) - found_token_transfers = Etherscan.list_token_transfers(address.hash, nil) + found_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, %{}) block_numbers_order = Enum.map(found_token_transfers, & &1.block_number) @@ -1251,7 +1416,7 @@ defmodule Explorer.EtherscanTest do options = %{order_by_direction: :desc} - found_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options) + found_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options) block_numbers_order = Enum.map(found_token_transfers, & &1.block_number) @@ -1307,7 +1472,7 @@ defmodule Explorer.EtherscanTest do options1 = %{page_number: 1, page_size: 2} - page1_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options1) + page1_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options1) page1_hashes = Enum.map(page1_token_transfers, & &1.transaction_hash) @@ -1319,7 +1484,7 @@ defmodule Explorer.EtherscanTest do options2 = %{page_number: 2, page_size: 2} - page2_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options2) + page2_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options2) page2_hashes = Enum.map(page2_token_transfers, & &1.transaction_hash) @@ -1331,7 +1496,7 @@ defmodule Explorer.EtherscanTest do options3 = %{page_number: 3, page_size: 2} - page3_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options3) + page3_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options3) page3_hashes = Enum.map(page3_token_transfers, & &1.transaction_hash) @@ -1343,7 +1508,7 @@ defmodule Explorer.EtherscanTest do options4 = %{page_number: 4, page_size: 2} - assert Etherscan.list_token_transfers(address.hash, nil, options4) == [] + assert Etherscan.list_token_transfers(:erc20, address.hash, nil, options4) == [] end test "with start and end block options" do @@ -1369,7 +1534,7 @@ defmodule Explorer.EtherscanTest do endblock: third_block.number } - found_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options) + found_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options) expected_block_numbers = [second_block.number, third_block.number] @@ -1400,7 +1565,7 @@ defmodule Explorer.EtherscanTest do options = %{startblock: third_block.number} - found_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options) + found_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options) expected_block_numbers = [third_block.number, fourth_block.number] @@ -1431,7 +1596,7 @@ defmodule Explorer.EtherscanTest do options = %{endblock: second_block.number} - found_token_transfers = Etherscan.list_token_transfers(address.hash, nil, options) + found_token_transfers = Etherscan.list_token_transfers(:erc20, address.hash, nil, options) expected_block_numbers = [first_block.number, second_block.number] @@ -1464,10 +1629,59 @@ defmodule Explorer.EtherscanTest do block_number: transaction.block_number ) - [found_token_transfer] = Etherscan.list_token_transfers(address.hash, contract_address.hash) + [found_token_transfer] = Etherscan.list_token_transfers(:erc20, address.hash, contract_address.hash, %{}) assert found_token_transfer.token_contract_address_hash == contract_address.hash end + + test "does not duplicate erc20 transfers when address is both sender and receiver" do + address = insert(:address) + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + from_address: address, + to_address: address, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + result = Etherscan.list_token_transfers(:erc20, address.hash, nil, %{}) + + assert length(result) == 1 + end + + test "does not duplicate erc1155 transfers when address is both sender and receiver" do + address = insert(:address) + + token_contract_address = insert(:contract_address) + insert(:token, contract_address: token_contract_address, type: "ERC-1155") + + transaction = + :transaction + |> insert() + |> with_block() + + insert(:token_transfer, + from_address: address, + to_address: address, + token_contract_address: token_contract_address, + token_type: "ERC-1155", + token_ids: [Decimal.new(1)], + amounts: [Decimal.new(10)], + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number + ) + + result = Etherscan.list_token_transfers(:erc1155, address.hash, nil, %{}) + + assert length(result) == 1 + end end describe "list_blocks/1" do diff --git a/apps/explorer/test/explorer/graphql_test.exs b/apps/explorer/test/explorer/graphql_test.exs index 4e6915e2f7e4..eb61e51872b4 100644 --- a/apps/explorer/test/explorer/graphql_test.exs +++ b/apps/explorer/test/explorer/graphql_test.exs @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.GraphQLTest do use Explorer.DataCase import Explorer.Factory alias Explorer.{GraphQL, Repo} - alias Explorer.Chain.Address + alias Explorer.Chain.{Address, InternalTransaction} describe "address_to_transactions_query/1" do test "with address hash with zero transactions" do @@ -97,16 +98,16 @@ defmodule Explorer.GraphQLTest do internal_transaction = insert(:internal_transaction, transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) clauses = %{transaction_hash: transaction.hash, index: internal_transaction.index} {:ok, found_internal_transaction} = GraphQL.get_internal_transaction(clauses) - assert found_internal_transaction.transaction_hash == transaction.hash + assert found_internal_transaction.transaction.hash == transaction.hash assert found_internal_transaction.index == internal_transaction.index end @@ -129,24 +130,25 @@ defmodule Explorer.GraphQLTest do internal_transaction = insert(:internal_transaction_create, transaction: transaction1, + transaction_index: transaction1.index, index: 0, - block_hash: transaction1.block_hash, - block_index: 0 + block_number: transaction1.block_number ) insert(:internal_transaction_create, transaction: transaction2, + transaction_index: transaction2.index, index: 0, - block_hash: transaction2.block_hash, - block_index: 0 + block_number: transaction2.block_number ) [found_internal_transaction] = transaction1 |> GraphQL.transaction_to_internal_transactions_query() |> Repo.replica().all() + |> InternalTransaction.preload_transaction() - assert found_internal_transaction.transaction_hash == transaction1.hash + assert found_internal_transaction.transaction.hash == transaction1.hash assert found_internal_transaction.index == internal_transaction.index end @@ -157,28 +159,29 @@ defmodule Explorer.GraphQLTest do for index <- 0..2 do insert(:internal_transaction_create, transaction: transaction1, + transaction_index: transaction1.index, index: index, - block_hash: transaction1.block_hash, - block_index: index + block_number: transaction1.block_number ) end insert(:internal_transaction_create, transaction: transaction2, + transaction_index: transaction2.index, index: 0, - block_hash: transaction2.block_hash, - block_index: 0 + block_number: transaction2.block_number ) found_internal_transactions = transaction1 |> GraphQL.transaction_to_internal_transactions_query() |> Repo.replica().all() + |> InternalTransaction.preload_transaction() assert length(found_internal_transactions) == 3 for found_internal_transaction <- found_internal_transactions do - assert found_internal_transaction.transaction_hash == transaction1.hash + assert found_internal_transaction.transaction.hash == transaction1.hash end end @@ -187,23 +190,23 @@ defmodule Explorer.GraphQLTest do insert(:internal_transaction_create, transaction: transaction, + transaction_index: transaction.index, index: 2, - block_hash: transaction.block_hash, - block_index: 2 + block_number: transaction.block_number ) insert(:internal_transaction_create, transaction: transaction, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) insert(:internal_transaction_create, transaction: transaction, + transaction_index: transaction.index, index: 1, - block_hash: transaction.block_hash, - block_index: 1 + block_number: transaction.block_number ) found_internal_transactions = diff --git a/apps/explorer/test/explorer/helper_test.exs b/apps/explorer/test/explorer/helper_test.exs new file mode 100644 index 000000000000..6b6673afe3a4 --- /dev/null +++ b/apps/explorer/test/explorer/helper_test.exs @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.HelperTest do + use ExUnit.Case + alias Explorer.Helper + alias Explorer.Chain.Data + + describe "decode_data/2" do + test "decodes 0x starting bytes" do + data = "0x3078f11400000000000000000000000000000000000000000000000000000000" + types = [{:uint, 32}] + + assert [21_924_702_239_838_702_630_355_123_058_721_243_447_914_074_382_437_861_546_344_133_536_048_966_795_264] == + Helper.decode_data(data, types) + end + + test "decodes 0x starting bytes with %Data{} struct" do + data = %Data{ + bytes: <<48, 120, 241, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>> + } + + types = [{:uint, 32}] + + assert [21_924_702_239_838_702_630_355_123_058_721_243_447_914_074_382_437_861_546_344_133_536_048_966_795_264] == + Helper.decode_data(data, types) + end + end +end diff --git a/apps/explorer/test/explorer/history/process_test.exs b/apps/explorer/test/explorer/history/process_test.exs index 890a007fc0f7..2f6782cc4674 100644 --- a/apps/explorer/test/explorer/history/process_test.exs +++ b/apps/explorer/test/explorer/history/process_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.History.ProcessTest do use Explorer.DataCase, async: false @@ -58,8 +59,8 @@ defmodule Explorer.History.ProcessTest do state = %{historian: TestHistorian} # interval should be short enough that it doesn't slow down testing... - # ...but long enough to detect. 16ms should be detectable on the slowest dev machines - history_fetch_interval = 16 + # ...but long enough to detect. 100ms should be detectable on the slowest dev machines + history_fetch_interval = 100 now = DateTime.to_time(DateTime.utc_now()) time_to_fetch_at = now |> Time.add(history_fetch_interval, :millisecond) @@ -69,7 +70,7 @@ defmodule Explorer.History.ProcessTest do assert {:noreply, state} == HistoryProcess.handle_info({nil, {1, 0, {:ok, [record]}}}, state) # Message isn't sent before interval is up - refute_receive {:compile_historical_records, 2}, history_fetch_interval - 1 + refute_receive {:compile_historical_records, 2}, history_fetch_interval - 50 # Now message is sent assert_receive {:compile_historical_records, 2} diff --git a/apps/explorer/test/explorer/market/fetcher/coin_test.exs b/apps/explorer/test/explorer/market/fetcher/coin_test.exs index 134eaeca17cb..49fd90fdb349 100644 --- a/apps/explorer/test/explorer/market/fetcher/coin_test.exs +++ b/apps/explorer/test/explorer/market/fetcher/coin_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Fetcher.CoinTest do use Explorer.DataCase @@ -7,7 +8,6 @@ defmodule Explorer.Market.Fetcher.CoinTest do alias Explorer.Market.Token alias Explorer.Market.Fetcher.Coin alias Explorer.Market.Source.TestSource - alias Plug.Conn @moduletag :capture_log @@ -78,7 +78,8 @@ defmodule Explorer.Market.Fetcher.CoinTest do symbol: "test_symbol", fiat_value: Decimal.new("1.0"), volume_24h: Decimal.new("1000.0"), - image_url: nil + image_url: nil, + circulating_supply: nil } assert {:noreply, ^state} = Coin.handle_info({nil, {{:ok, expected_token}, false}}, state) diff --git a/apps/explorer/test/explorer/market/fetcher/history_test.exs b/apps/explorer/test/explorer/market/fetcher/history_test.exs index ed7eccd8da69..4acc1dab4fae 100644 --- a/apps/explorer/test/explorer/market/fetcher/history_test.exs +++ b/apps/explorer/test/explorer/market/fetcher/history_test.exs @@ -1,7 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Fetcher.HistoryTest do use Explorer.DataCase, async: false - import Mox import Ecto.Query, only: [limit: 2, order_by: 2] alias Explorer.Market.{MarketHistory, Source} @@ -57,10 +57,12 @@ defmodule Explorer.Market.Fetcher.HistoryTest do Application.put_env(:explorer, Source, native_coin_history_source: CryptoCompare) Application.put_env(:explorer, CryptoCompare, base_url: "http://localhost:#{bypass.port}", coin_symbol: "TEST") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Application.put_env(:explorer, Source, source_configuration) Application.put_env(:explorer, CryptoCompare, crypto_compare_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) resp = @@ -170,7 +172,7 @@ defmodule Explorer.Market.Fetcher.HistoryTest do assert get_in(new_state.types_states, [:market_cap_history, :finished?]) assert get_in(new_state.types_states, [:market_cap_history, :records]) == market_cap_records - assert {:noreply, final_state} = + assert {:noreply, _final_state} = History.handle_info({nil, {:tvl_history, {:ok, tvl_records}}}, new_state) assert record2 = Repo.get_by(MarketHistory, date: Enum.at(price_records, 1).date) @@ -190,10 +192,12 @@ defmodule Explorer.Market.Fetcher.HistoryTest do Application.put_env(:explorer, Source, native_coin_history_source: CryptoCompare) Application.put_env(:explorer, CryptoCompare, base_url: "http://localhost:#{bypass.port}", coin_symbol: "TEST") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Application.put_env(:explorer, CryptoCompare, crypto_compare_configuration) Application.put_env(:explorer, Source, source_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) resp = @@ -232,7 +236,7 @@ defmodule Explorer.Market.Fetcher.HistoryTest do end end) - {:ok, pid} = History.start_link([]) + {:ok, _pid} = History.start_link([]) :timer.sleep(500) diff --git a/apps/explorer/test/explorer/market/fetcher/token_list_test.exs b/apps/explorer/test/explorer/market/fetcher/token_list_test.exs new file mode 100644 index 000000000000..1d9293f86fae --- /dev/null +++ b/apps/explorer/test/explorer/market/fetcher/token_list_test.exs @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.Fetcher.TokenListTest do + use Explorer.DataCase + + alias Explorer.Chain.Token + alias Explorer.Market.Fetcher.TokenList + + @moduletag :capture_log + + setup do + bypass = Bypass.open() + + original_config = Application.get_env(:explorer, TokenList) + original_chain_id = Application.get_env(:explorer, :chain_id) + + Application.put_env( + :explorer, + TokenList, + Keyword.merge(original_config || [], + enabled: true, + token_list_url: "http://localhost:#{bypass.port}/tokens.json", + refetch_interval: :timer.hours(24) + ) + ) + + Application.put_env(:explorer, :chain_id, "77") + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:explorer, TokenList, original_config) + Application.put_env(:explorer, :chain_id, original_chain_id) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + {:ok, %{bypass: bypass}} + end + + defp token_list_json(tokens) do + Jason.encode!(%{ + "name" => "Test Token List", + "tokens" => tokens + }) + end + + defp token_entry(address, opts) do + %{ + "address" => to_string(address), + "chainId" => opts[:chain_id] || 77, + "name" => opts[:name] || "Test Token", + "symbol" => opts[:symbol] || "TST", + "decimals" => opts[:decimals] || 18, + "logoURI" => opts[:logo_uri] || "https://example.com/logo.png" + } + end + + describe "init/1" do + test "returns :ignore when token_list_url is not set" do + Application.put_env(:explorer, TokenList, enabled: true, token_list_url: nil) + + assert :ignore = TokenList.init(:ok) + end + + test "starts when token_list_url is set" do + assert {:ok, %TokenList{url: url}} = TokenList.init(:ok) + assert url == Application.get_env(:explorer, TokenList)[:token_list_url] + end + end + + describe "fetch and import" do + test "imports tokens matching chain_id", %{bypass: bypass} do + token = insert(:token, icon_url: nil, name: nil, symbol: nil, decimals: nil) + other_token = insert(:token, icon_url: nil) + + body = + token_list_json([ + token_entry(token.contract_address_hash, + name: "Listed Token", + symbol: "LTK", + decimals: 18, + logo_uri: "https://example.com/listed.png" + ), + token_entry(other_token.contract_address_hash, + chain_id: 999, + name: "Other Chain Token", + logo_uri: "https://example.com/other.png" + ) + ]) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + + updated_token = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + assert updated_token.icon_url == "https://example.com/listed.png" + assert updated_token.name == "Listed Token" + assert updated_token.symbol == "LTK" + + # Token from chain_id 999 should NOT be updated + other_updated = Repo.get_by(Token, contract_address_hash: other_token.contract_address_hash) + assert is_nil(other_updated.icon_url) + end + + test "does not overwrite existing name, symbol, decimals", %{bypass: bypass} do + token = insert(:token, name: "Original Name", symbol: "OG", decimals: 8, icon_url: nil) + + body = + token_list_json([ + token_entry(token.contract_address_hash, + name: "New Name", + symbol: "NEW", + decimals: 18, + logo_uri: "https://example.com/new.png" + ) + ]) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + + updated_token = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + assert updated_token.name == "Original Name" + assert updated_token.symbol == "OG" + assert updated_token.decimals == Decimal.new(8) + assert updated_token.icon_url == "https://example.com/new.png" + end + + test "does not overwrite icon_url for admin-verified tokens", %{bypass: bypass} do + token = + insert(:token, + icon_url: "https://admin.example.com/icon.png", + is_verified_via_admin_panel: true + ) + + body = + token_list_json([ + token_entry(token.contract_address_hash, + logo_uri: "https://example.com/list-icon.png" + ) + ]) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + + updated_token = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + assert updated_token.icon_url == "https://admin.example.com/icon.png" + end + + test "overwrites icon_url for non-admin tokens", %{bypass: bypass} do + token = + insert(:token, + icon_url: "https://old.example.com/icon.png", + is_verified_via_admin_panel: false + ) + + body = + token_list_json([ + token_entry(token.contract_address_hash, + logo_uri: "https://example.com/new-icon.png" + ) + ]) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + + updated_token = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + assert updated_token.icon_url == "https://example.com/new-icon.png" + end + + test "handles HTTP error gracefully", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 500, "Internal Server Error") + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + end + + test "handles invalid JSON gracefully", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, "not json") + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + end + + test "handles empty token list", %{bypass: bypass} do + body = token_list_json([]) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + end + + test "skips tokens with missing address field", %{bypass: bypass} do + body = + Jason.encode!(%{ + "name" => "Test List", + "tokens" => [ + %{"chainId" => 77, "name" => "No Address", "symbol" => "NA", "decimals" => 18} + ] + }) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + end + + test "imports all tokens when CHAIN_ID is not set", %{bypass: bypass} do + Application.put_env(:explorer, :chain_id, nil) + + token = insert(:token, icon_url: nil) + + body = + token_list_json([ + token_entry(token.contract_address_hash, chain_id: 999, logo_uri: "https://example.com/any.png") + ]) + + Bypass.expect_once(bypass, "GET", "/tokens.json", fn conn -> + Plug.Conn.resp(conn, 200, body) + end) + + {:ok, pid} = GenServer.start_link(TokenList, :ok) + :timer.sleep(200) + GenServer.stop(pid) + + updated_token = Repo.get_by(Token, contract_address_hash: token.contract_address_hash) + assert updated_token.icon_url == "https://example.com/any.png" + end + end +end diff --git a/apps/explorer/test/explorer/market/fetcher/token_test.exs b/apps/explorer/test/explorer/market/fetcher/token_test.exs index 29f100fb3983..c8ab66da1fd9 100644 --- a/apps/explorer/test/explorer/market/fetcher/token_test.exs +++ b/apps/explorer/test/explorer/market/fetcher/token_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Fetcher.TokenTest do use Explorer.DataCase @@ -34,10 +35,13 @@ defmodule Explorer.Market.Fetcher.TokenTest do base_url: "http://localhost:#{bypass.port}" ) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + on_exit(fn -> Application.put_env(:explorer, Explorer.Market.Source, source_configuration) Application.put_env(:explorer, Explorer.Market.Fetcher.Token, fetcher_configuration) Application.put_env(:explorer, Explorer.Market.Source.CoinGecko, coin_gecko_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) [_token_with_no_exchange_rate | tokens] = @@ -221,4 +225,246 @@ defmodule Explorer.Market.Fetcher.TokenTest do |> Enum.each(fn %{fiat_value: fiat_value} -> assert is_nil(fiat_value) end) end end + + describe "stale market data cleanup" do + # We test the full cycle through handle_info since nullify_stale_market_data is private. + + setup do + bypass = Bypass.open() + + source_configuration = Application.get_env(:explorer, Explorer.Market.Source) + fetcher_configuration = Application.get_env(:explorer, Explorer.Market.Fetcher.Token) + coin_gecko_configuration = Application.get_env(:explorer, Explorer.Market.Source.CoinGecko) + + Application.put_env( + :explorer, + Explorer.Market.Source, + Keyword.merge(source_configuration || [], tokens_source: Explorer.Market.Source.CoinGecko) + ) + + Application.put_env( + :explorer, + Explorer.Market.Fetcher.Token, + Keyword.merge(fetcher_configuration || [], + enabled: true, + interval: 0, + refetch_interval: 10_000, + max_batch_size: 100 + ) + ) + + Application.put_env( + :explorer, + Explorer.Market.Source.CoinGecko, + Keyword.merge(coin_gecko_configuration || [], + platform: "ethereum", + currency: "usd", + base_url: "http://localhost:#{bypass.port}" + ) + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + previous_fetcher_enabled = :persistent_term.get(:market_token_fetcher_enabled, false) + :persistent_term.put(:market_token_fetcher_enabled, true) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, source_configuration) + Application.put_env(:explorer, Explorer.Market.Fetcher.Token, fetcher_configuration) + Application.put_env(:explorer, Explorer.Market.Source.CoinGecko, coin_gecko_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + :persistent_term.put(:market_token_fetcher_enabled, previous_fetcher_enabled) + end) + + {:ok, %{bypass: bypass}} + end + + test "nullifies market data for tokens not returned by source", %{bypass: bypass} do + # Token that source WILL return + token_returned = + insert(:token, + fiat_value: Decimal.new("5.0"), + circulating_market_cap: Decimal.new("1000"), + volume_24h: Decimal.new("500"), + circulating_supply: Decimal.new("2000") + ) + + # Token that source will NOT return — should be nullified across all market fields + token_stale = + insert(:token, + fiat_value: Decimal.new("3.0"), + circulating_market_cap: Decimal.new("800"), + volume_24h: Decimal.new("200"), + circulating_supply: Decimal.new("1500") + ) + + # Token with nil fiat_value — WHERE clause should leave it alone even if not in seen set + token_already_nil = + insert(:token, + fiat_value: nil, + circulating_market_cap: nil, + volume_24h: Decimal.new("999"), + circulating_supply: Decimal.new("3000") + ) + + coins_list = [ + %{ + "id" => "returned_token", + "symbol" => "RT", + "name" => "Returned Token", + "platforms" => %{"ethereum" => "#{token_returned.contract_address_hash}"} + } + ] + + Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> + Conn.resp(conn, 200, Jason.encode!(coins_list)) + end) + + token_exchange_rates = %{ + "returned_token" => %{ + "usd" => 5.0, + "usd_market_cap" => 1000.0, + "usd_24h_vol" => 500.0 + } + } + + Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> + Conn.resp(conn, 200, Jason.encode!(token_exchange_rates)) + end) + + GenServer.start_link(TokenFetcher, []) + :timer.sleep(200) + + # Returned token keeps market data + returned = Repo.get_by(Token, contract_address_hash: token_returned.contract_address_hash) + assert returned.fiat_value + assert returned.circulating_market_cap + + # Stale token has all market data fields nullified + stale = Repo.get_by(Token, contract_address_hash: token_stale.contract_address_hash) + assert is_nil(stale.fiat_value) + assert is_nil(stale.circulating_market_cap) + assert is_nil(stale.volume_24h) + assert is_nil(stale.circulating_supply) + + # Token with nil fiat_value is skipped by the WHERE clause — other fields untouched + skipped = Repo.get_by(Token, contract_address_hash: token_already_nil.contract_address_hash) + assert Decimal.equal?(skipped.volume_24h, Decimal.new("999")) + assert Decimal.equal?(skipped.circulating_supply, Decimal.new("3000")) + end + + test "does not nullify when source errors out (empty coin list)", %{bypass: bypass} do + # Empty /coins/list makes CoinGecko return {:error, "Tokens not found..."}, exercising + # the error branch in handle_info — nullify is never called. + token_with_data = insert(:token, fiat_value: Decimal.new("5.0"), circulating_market_cap: Decimal.new("1000")) + + Bypass.expect(bypass, "GET", "/coins/list", fn conn -> + Conn.resp(conn, 200, "[]") + end) + + GenServer.start_link(TokenFetcher, []) + :timer.sleep(200) + + token = Repo.get_by(Token, contract_address_hash: token_with_data.contract_address_hash) + assert token.fiat_value + end + + test "skips nullification when all returned tokens are filtered out (empty seen set)", %{bypass: bypass} do + # Source returns a token, but with $0 — zero_or_nil? filter drops it. + # seen_token_hashes ends up empty → `!Enum.empty?` guard prevents any DB writes. + token_existing = insert(:token, fiat_value: Decimal.new("5.0"), circulating_market_cap: Decimal.new("1000")) + + filtered_out_address = "0x00000000000000000000000000000000000000aa" + + coins_list = [ + %{ + "id" => "zero_token", + "symbol" => "ZT", + "name" => "Zero Token", + "platforms" => %{"ethereum" => filtered_out_address} + } + ] + + Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> + Conn.resp(conn, 200, Jason.encode!(coins_list)) + end) + + Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> + Conn.resp(conn, 200, Jason.encode!(%{"zero_token" => %{"usd" => 0, "usd_market_cap" => 0, "usd_24h_vol" => 0}})) + end) + + GenServer.start_link(TokenFetcher, []) + :timer.sleep(200) + + # Pre-existing token was NOT in the (empty) seen set, yet still retains data because + # the guard short-circuits before the UPDATE runs. + token = Repo.get_by(Token, contract_address_hash: token_existing.contract_address_hash) + assert Decimal.equal?(token.fiat_value, Decimal.new("5.0")) + assert Decimal.equal?(token.circulating_market_cap, Decimal.new("1000")) + end + + test "tokens with $0 fiat_value are filtered out and their market data is nullified", %{bypass: bypass} do + # Token that source returns with $0 price — filtered out, not added to seen set + token_zero_price = + insert(:token, + fiat_value: Decimal.new("2.0"), + circulating_market_cap: Decimal.new("500"), + volume_24h: Decimal.new("50"), + circulating_supply: Decimal.new("123") + ) + + # A second token with a real price — goes into seen set, triggering nullification of the $0 token + token_real_price = insert(:token, fiat_value: Decimal.new("1.0")) + + coins_list = [ + %{ + "id" => "zero_price_token", + "symbol" => "ZP", + "name" => "Zero Price Token", + "platforms" => %{"ethereum" => "#{token_zero_price.contract_address_hash}"} + }, + %{ + "id" => "real_price_token", + "symbol" => "RP", + "name" => "Real Price Token", + "platforms" => %{"ethereum" => "#{token_real_price.contract_address_hash}"} + } + ] + + Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> + Conn.resp(conn, 200, Jason.encode!(coins_list)) + end) + + token_exchange_rates = %{ + "zero_price_token" => %{ + "usd" => 0, + "usd_market_cap" => 0, + "usd_24h_vol" => 0 + }, + "real_price_token" => %{ + "usd" => 10.0, + "usd_market_cap" => 5000.0, + "usd_24h_vol" => 100.0 + } + } + + Bypass.expect_once(bypass, "GET", "/simple/price", fn conn -> + Conn.resp(conn, 200, Jason.encode!(token_exchange_rates)) + end) + + GenServer.start_link(TokenFetcher, []) + :timer.sleep(200) + + # $0-price token is not in seen set → gets nullified across all market fields + token = Repo.get_by(Token, contract_address_hash: token_zero_price.contract_address_hash) + assert is_nil(token.fiat_value) + assert is_nil(token.circulating_market_cap) + assert is_nil(token.volume_24h) + assert is_nil(token.circulating_supply) + + # Real-price token is in seen set → retains its data + real = Repo.get_by(Token, contract_address_hash: token_real_price.contract_address_hash) + assert real.fiat_value + end + end end diff --git a/apps/explorer/test/explorer/market/market_history_cache_test.exs b/apps/explorer/test/explorer/market/market_history_cache_test.exs index ad33e0076559..adb7266fc201 100644 --- a/apps/explorer/test/explorer/market/market_history_cache_test.exs +++ b/apps/explorer/test/explorer/market/market_history_cache_test.exs @@ -1,7 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.MarketHistoryCacheTest do use Explorer.DataCase - alias Explorer.Market alias Explorer.Market.MarketHistoryCache alias Explorer.Market.MarketHistory diff --git a/apps/explorer/test/explorer/market/market_history_test.exs b/apps/explorer/test/explorer/market/market_history_test.exs new file mode 100644 index 000000000000..1c18bcc9dbac --- /dev/null +++ b/apps/explorer/test/explorer/market/market_history_test.exs @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.MarketHistoryTest do + use Explorer.DataCase + + alias Explorer.Repo + alias Explorer.Market.MarketHistory + + describe "bulk_insert/1" do + test "replaces existing records" do + records = [ + %{date: ~D[2023-01-01], opening_price: Decimal.new("100.00"), closing_price: Decimal.new("110.00")}, + %{date: ~D[2023-01-02], opening_price: Decimal.new("7.50"), closing_price: Decimal.new("5.00")} + ] + + assert {:ok, {2, _}} = MarketHistory.bulk_insert(records) + + records = [ + %{date: ~D[2023-01-01], opening_price: Decimal.new("50.00"), closing_price: Decimal.new("55.00")}, + %{date: ~D[2023-01-02], opening_price: Decimal.new("10.00"), closing_price: Decimal.new("8.00")} + ] + + assert {:ok, {2, _}} = MarketHistory.bulk_insert(records) + + assert [date1, date2] = Repo.all(MarketHistory) + assert date1.opening_price == Decimal.new("50.00") + assert date1.closing_price == Decimal.new("55.00") + assert date2.opening_price == Decimal.new("10.00") + assert date2.closing_price == Decimal.new("8.00") + end + end + + test "inserts records" do + comparable_values = %{ + ~D[2018-04-01] => %{ + date: ~D[2018-04-01], + closing_price: Decimal.new(1), + opening_price: Decimal.new(1) + }, + ~D[2018-04-02] => %{ + date: ~D[2018-04-02], + closing_price: Decimal.new(1), + opening_price: Decimal.new(1) + }, + ~D[2018-04-03] => %{ + date: ~D[2018-04-03], + closing_price: Decimal.new(1), + opening_price: Decimal.new(1) + } + } + + insertable_records = Map.values(comparable_values) + MarketHistory.bulk_insert(insertable_records) + history = Repo.all(MarketHistory) + + missing_records = + Enum.reduce(history, comparable_values, fn record, acc -> + initial_record = Map.get(acc, record.date) + assert record.date == initial_record.date + assert record.closing_price == initial_record.closing_price + assert record.opening_price == initial_record.opening_price + Map.delete(acc, record.date) + end) + + assert missing_records == %{} + end + + test "doesn't replace existing records with zeros" do + date = ~D[2018-04-01] + + {:ok, old_record} = + Repo.insert(%MarketHistory{date: date, closing_price: Decimal.new(1), opening_price: Decimal.new(1)}) + + new_record = %{ + date: date, + closing_price: Decimal.new(0), + opening_price: Decimal.new(0) + } + + MarketHistory.bulk_insert([new_record]) + + fetched_record = Repo.get_by(MarketHistory, date: date) + assert fetched_record.closing_price == old_record.closing_price + assert fetched_record.opening_price == old_record.opening_price + end +end diff --git a/apps/explorer/test/explorer/market/market_test.exs b/apps/explorer/test/explorer/market/market_test.exs index bde06a3c3e3b..140c1260faaf 100644 --- a/apps/explorer/test/explorer/market/market_test.exs +++ b/apps/explorer/test/explorer/market/market_test.exs @@ -1,9 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.MarketTest do use Explorer.DataCase, async: false alias Explorer.Market alias Explorer.Market.MarketHistory - alias Explorer.Repo setup do Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.Blocks.child_id()) @@ -17,99 +17,123 @@ defmodule Explorer.MarketTest do :ok end - test "fetch_recent_history/1" do - ConCache.delete(:market_history, :last_update) + describe "fetch_recent_history/1" do + test "with enabled history fetcher" do + ConCache.delete(:market_history, :last_update) - today = Date.utc_today() + start_supervised!(Explorer.Market.Fetcher.History) + start_supervised!(Explorer.Market) - records = - for i <- 0..29 do - %{ - date: Timex.shift(today, days: i * -1), - closing_price: Decimal.new(1), - opening_price: Decimal.new(1) - } - end + today = Date.utc_today() - MarketHistory.bulk_insert(records) + records = + for i <- 0..29 do + %{ + date: Timex.shift(today, days: i * -1), + closing_price: Decimal.new(1), + opening_price: Decimal.new(1) + } + end - history = Market.fetch_recent_history() - assert length(history) == 30 - assert Enum.at(history, 0).date == Enum.at(records, 0).date - end + MarketHistory.bulk_insert(records) - describe "bulk_insert_history/1" do - test "inserts records" do - comparable_values = %{ - ~D[2018-04-01] => %{ - date: ~D[2018-04-01], - closing_price: Decimal.new(1), - opening_price: Decimal.new(1) - }, - ~D[2018-04-02] => %{ - date: ~D[2018-04-02], - closing_price: Decimal.new(1), - opening_price: Decimal.new(1) - }, - ~D[2018-04-03] => %{ - date: ~D[2018-04-03], - closing_price: Decimal.new(1), - opening_price: Decimal.new(1) - } - } - - insertable_records = Map.values(comparable_values) - MarketHistory.bulk_insert(insertable_records) - history = Repo.all(MarketHistory) - - missing_records = - Enum.reduce(history, comparable_values, fn record, acc -> - initial_record = Map.get(acc, record.date) - assert record.date == initial_record.date - assert record.closing_price == initial_record.closing_price - assert record.opening_price == initial_record.opening_price - Map.delete(acc, record.date) - end) - - assert missing_records == %{} + history = Market.fetch_recent_history() + assert length(history) == 30 + assert Enum.at(history, 0).date == Enum.at(records, 0).date end - test "doesn't replace existing records with zeros" do - date = ~D[2018-04-01] + test "with disabled history fetcher" do + ConCache.delete(:market_history, :last_update) + start_supervised!(Explorer.Market) + + old_env = Application.get_env(:explorer, Explorer.Market.Fetcher.History) + Application.put_env(:explorer, Explorer.Market.Fetcher.History, Keyword.merge(old_env, enabled: false)) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Fetcher.History, old_env) + end) - {:ok, old_record} = - Repo.insert(%MarketHistory{date: date, closing_price: Decimal.new(1), opening_price: Decimal.new(1)}) + today = Date.utc_today() - new_record = %{ - date: date, - closing_price: Decimal.new(0), - opening_price: Decimal.new(0) - } + records = + for i <- 0..29 do + %{ + date: Timex.shift(today, days: i * -1), + closing_price: Decimal.new(1), + opening_price: Decimal.new(1) + } + end - MarketHistory.bulk_insert([new_record]) + MarketHistory.bulk_insert(records) - fetched_record = Repo.get_by(MarketHistory, date: date) - assert fetched_record.closing_price == old_record.closing_price - assert fetched_record.opening_price == old_record.opening_price + history = Market.fetch_recent_history() + assert length(history) == 0 end + end + + describe "get_coin_exchange_rate/0" do + test "with enabled coin fetcher" do + old_source_env = Application.get_env(:explorer, Explorer.Market.Source) + old_fetcher_env = Application.get_env(:explorer, Explorer.Market.Fetcher.Coin) + + Application.put_env( + :explorer, + Explorer.Market.Source, + Keyword.merge(old_source_env, native_coin_source: Explorer.Market.Source.OneCoinSource) + ) - test "does not override existing records on date conflict" do - date = ~D[2018-04-01] + Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, Keyword.merge(old_fetcher_env, enabled: true)) - {:ok, old_record} = - Repo.insert(%MarketHistory{date: date, closing_price: Decimal.new(2), opening_price: Decimal.new(2)}) + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, old_source_env) + Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, old_fetcher_env) + end) - new_record = %{ - date: date, - closing_price: Decimal.new(1), - opening_price: Decimal.new(1) - } + start_supervised!(Explorer.Market.Fetcher.Coin) - MarketHistory.bulk_insert([new_record]) + :timer.sleep(100) + + exchange_rate = Market.get_coin_exchange_rate() + + assert exchange_rate.fiat_value == Decimal.new(1) + end + + test "with disabled coin fetcher" do + exchange_rate = Market.get_coin_exchange_rate() + assert exchange_rate.fiat_value == nil + end + end + + describe "get_secondary_coin_exchange_rate/0" do + test "with enabled coin fetcher" do + old_source_env = Application.get_env(:explorer, Explorer.Market.Source) + old_fetcher_env = Application.get_env(:explorer, Explorer.Market.Fetcher.Coin) + + Application.put_env( + :explorer, + Explorer.Market.Source, + Keyword.merge(old_source_env, secondary_coin_source: Explorer.Market.Source.OneCoinSource) + ) + + Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, Keyword.merge(old_fetcher_env, enabled: true)) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, old_source_env) + Application.put_env(:explorer, Explorer.Market.Fetcher.Coin, old_fetcher_env) + end) + + start_supervised!(Explorer.Market.Fetcher.Coin) + + :timer.sleep(100) + + exchange_rate = Market.get_secondary_coin_exchange_rate() + + assert exchange_rate.fiat_value == Decimal.new(1) + end - fetched_record = Repo.get_by(MarketHistory, date: date) - assert fetched_record.closing_price == old_record.closing_price - assert fetched_record.opening_price == old_record.opening_price + test "with disabled coin fetcher" do + exchange_rate = Market.get_secondary_coin_exchange_rate() + assert exchange_rate.fiat_value == nil end end end diff --git a/apps/explorer/test/explorer/market/source/coin_gecko_test.exs b/apps/explorer/test/explorer/market/source/coin_gecko_test.exs index 78d93167b8e9..43d0239d7670 100644 --- a/apps/explorer/test/explorer/market/source/coin_gecko_test.exs +++ b/apps/explorer/test/explorer/market/source/coin_gecko_test.exs @@ -1,8 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # cspell:disable defmodule Explorer.Market.Source.CoinGeckoTest do use ExUnit.Case - alias Explorer.Market.Token alias Explorer.Market.Source.CoinGecko alias Plug.Conn @@ -30,14 +30,32 @@ defmodule Explorer.Market.Source.CoinGeckoTest do platform: "test" ) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + on_exit(fn -> Application.put_env(:explorer, Explorer.Market.Source, source_configuration) Application.put_env(:explorer, CoinGecko, coin_gecko_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) {:ok, bypass: bypass} end + describe "native_coin_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CoinGecko.native_coin_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, CoinGecko) + Application.put_env(:explorer, CoinGecko, Keyword.merge(config || [], coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinGecko, config) end) + + refute CoinGecko.native_coin_fetching_enabled?() + end + end + describe "fetch_native_coin/0" do test "fetches native coin", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/coins/native_coin_id", fn conn -> @@ -59,11 +77,27 @@ defmodule Explorer.Market.Source.CoinGeckoTest do symbol: "ETH", fiat_value: Decimal.new("123"), volume_24h: Decimal.new("66154765984"), - image_url: "https://coin-images.coingecko.com/coins/images/279/small/ethereum.png?1696501628" + image_url: "https://coin-images.coingecko.com/coins/images/279/small/ethereum.png?1696501628", + circulating_supply: Decimal.new("120547760.0137619") }} == CoinGecko.fetch_native_coin() end end + describe "secondary_coin_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert CoinGecko.secondary_coin_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured" do + config = Application.get_env(:explorer, CoinGecko) + Application.put_env(:explorer, CoinGecko, Keyword.merge(config || [], secondary_coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinGecko, config) end) + + refute CoinGecko.secondary_coin_fetching_enabled?() + end + end + describe "fetch_secondary_coin/0" do test "fetches native coin", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/coins/secondary_coin_id", fn conn -> @@ -85,11 +119,27 @@ defmodule Explorer.Market.Source.CoinGeckoTest do symbol: "ETH", fiat_value: Decimal.new("324"), volume_24h: Decimal.new("66154765984"), - image_url: "https://coin-images.coingecko.com/coins/images/279/small/ethereum.png?1696501628" + image_url: "https://coin-images.coingecko.com/coins/images/279/small/ethereum.png?1696501628", + circulating_supply: Decimal.new("120547760.0137619") }} == CoinGecko.fetch_secondary_coin() end end + describe "tokens_fetching_enabled?" do + test "returns true if platform is configured" do + assert CoinGecko.tokens_fetching_enabled?() + end + + test "returns false if platform is not configured" do + config = Application.get_env(:explorer, CoinGecko) + Application.put_env(:explorer, CoinGecko, Keyword.merge(config || [], platform: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinGecko, config) end) + + refute CoinGecko.tokens_fetching_enabled?() + end + end + describe "fetch_tokens/2" do test "fetches list of tokens", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/coins/list", fn conn -> @@ -201,6 +251,21 @@ defmodule Explorer.Market.Source.CoinGeckoTest do end end + describe "native_coin_price_history_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CoinGecko.native_coin_price_history_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, CoinGecko) + Application.put_env(:explorer, CoinGecko, Keyword.merge(config || [], coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinGecko, config) end) + + refute CoinGecko.native_coin_price_history_fetching_enabled?() + end + end + describe "fetch_native_coin_price_history/1" do test "fetches native coin price history", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/coins/native_coin_id/market_chart", fn conn -> @@ -233,6 +298,21 @@ defmodule Explorer.Market.Source.CoinGeckoTest do end end + describe "secondary_coin_price_history_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert CoinGecko.secondary_coin_price_history_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured" do + config = Application.get_env(:explorer, CoinGecko) + Application.put_env(:explorer, CoinGecko, Keyword.merge(config || [], secondary_coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinGecko, config) end) + + refute CoinGecko.secondary_coin_price_history_fetching_enabled?() + end + end + describe "fetch_secondary_coin_price_history/1" do test "fetches secondary coin price history", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/coins/secondary_coin_id/market_chart", fn conn -> @@ -265,6 +345,33 @@ defmodule Explorer.Market.Source.CoinGeckoTest do end end + describe "market_cap_history_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CoinGecko.market_cap_history_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, CoinGecko) + Application.put_env(:explorer, CoinGecko, Keyword.merge(config || [], coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinGecko, config) end) + + refute CoinGecko.market_cap_history_fetching_enabled?() + end + end + + describe "tvl_history_fetching_enabled?" do + test "ignored" do + assert CoinGecko.tvl_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_tvl_history/1" do + test "ignored" do + assert CoinGecko.fetch_tvl_history(3) == :ignore + end + end + describe "fetch_market_cap_history/1" do test "fetches market cap history", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/coins/native_coin_id/market_chart", fn conn -> diff --git a/apps/explorer/test/explorer/market/source/coin_market_cap_test.exs b/apps/explorer/test/explorer/market/source/coin_market_cap_test.exs index 5f5eaed7dbdf..ba272f298c79 100644 --- a/apps/explorer/test/explorer/market/source/coin_market_cap_test.exs +++ b/apps/explorer/test/explorer/market/source/coin_market_cap_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.CoinMarketCapTest do use ExUnit.Case @@ -28,14 +29,59 @@ defmodule Explorer.Market.Source.CoinMarketCapTest do currency_id: "2781" ) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + on_exit(fn -> Application.put_env(:explorer, Explorer.Market.Source, source_configuration) Application.put_env(:explorer, CoinMarketCap, coin_market_cap_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) {:ok, bypass: bypass} end + describe "native_coin_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CoinMarketCap.native_coin_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, CoinMarketCap) + Application.put_env(:explorer, CoinMarketCap, Keyword.merge(config || [], coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinMarketCap, config) end) + + refute CoinMarketCap.native_coin_fetching_enabled?() + end + end + + describe "secondary_coin_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert CoinMarketCap.secondary_coin_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured" do + config = Application.get_env(:explorer, CoinMarketCap) + Application.put_env(:explorer, CoinMarketCap, Keyword.merge(config || [], secondary_coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinMarketCap, config) end) + + refute CoinMarketCap.secondary_coin_fetching_enabled?() + end + end + + describe "tokens_fetching_enabled?" do + test "ignored" do + assert CoinMarketCap.tokens_fetching_enabled?() == :ignore + end + end + + describe "fetch_tokens/2" do + test "ignored" do + assert CoinMarketCap.fetch_tokens(nil, 10) == :ignore + end + end + describe "fetch_native_coin/0" do test "fetches native coin", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/cryptocurrency/quotes/latest", fn conn -> @@ -55,7 +101,8 @@ defmodule Explorer.Market.Source.CoinMarketCapTest do symbol: "BTC", fiat_value: Decimal.new("10.1"), volume_24h: Decimal.new("28724591782.645985"), - image_url: nil + image_url: nil, + circulating_supply: Decimal.new("19824162") }} == CoinMarketCap.fetch_native_coin() end end @@ -79,11 +126,39 @@ defmodule Explorer.Market.Source.CoinMarketCapTest do symbol: "BTC", fiat_value: Decimal.new("20.2"), volume_24h: Decimal.new("28724591782.645985"), - image_url: nil + image_url: nil, + circulating_supply: Decimal.new("19824162") }} == CoinMarketCap.fetch_secondary_coin() end end + describe "market_cap_history_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CoinMarketCap.market_cap_history_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, CoinMarketCap) + Application.put_env(:explorer, CoinMarketCap, Keyword.merge(config || [], coin_id: nil)) + + on_exit(fn -> Application.put_env(:explorer, CoinMarketCap, config) end) + + refute CoinMarketCap.market_cap_history_fetching_enabled?() + end + end + + describe "tvl_history_fetching_enabled?" do + test "ignored" do + assert CoinMarketCap.tvl_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_tvl_history/1" do + test "ignored" do + assert CoinMarketCap.fetch_tvl_history(3) == :ignore + end + end + describe "fetch_native_coin_price_history/1" do test "fetches native coin price history", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "/cryptocurrency/quotes/historical", fn conn -> diff --git a/apps/explorer/test/explorer/market/source/crypto_compare_test.exs b/apps/explorer/test/explorer/market/source/crypto_compare_test.exs index 2ad70fe09b7b..edd6226fbcac 100644 --- a/apps/explorer/test/explorer/market/source/crypto_compare_test.exs +++ b/apps/explorer/test/explorer/market/source/crypto_compare_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.CryptoCompareTest do use ExUnit.Case, async: false @@ -30,15 +31,69 @@ defmodule Explorer.Market.Source.CryptoCompareTest do currency: "AED" ) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + on_exit(fn -> Application.put_env(:explorer, :coin, coin) Application.put_env(:explorer, Explorer.Market.Source, source_configuration) Application.put_env(:explorer, CryptoCompare, crypto_compare_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) {:ok, bypass: bypass} end + describe "native_coin_fetching_enabled?" do + test "ignored" do + assert CryptoCompare.native_coin_fetching_enabled?() == :ignore + end + end + + describe "fetch_native_coin/0" do + test "ignored" do + assert CryptoCompare.fetch_native_coin() == :ignore + end + end + + describe "secondary_coin_fetching_enabled?" do + test "ignored" do + assert CryptoCompare.secondary_coin_fetching_enabled?() == :ignore + end + end + + describe "fetch_secondary_coin/0" do + test "ignored" do + assert CryptoCompare.fetch_secondary_coin() == :ignore + end + end + + describe "tokens_fetching_enabled?" do + test "ignored" do + assert CryptoCompare.tokens_fetching_enabled?() == :ignore + end + end + + describe "fetch_tokens/2" do + test "ignored" do + assert CryptoCompare.fetch_tokens(nil, 10) == :ignore + end + end + + describe "native_coin_price_history_fetching_enabled?" do + test "returns true if coin_symbol is configured" do + assert CryptoCompare.native_coin_price_history_fetching_enabled?() + end + + test "returns false if coin_symbol is not configured" do + config = Application.get_env(:explorer, CryptoCompare) + Application.put_env(:explorer, CryptoCompare, Keyword.merge(config || [], coin_symbol: nil)) + + on_exit(fn -> Application.put_env(:explorer, CryptoCompare, config) end) + + refute CryptoCompare.native_coin_price_history_fetching_enabled?() + end + end + describe "fetch_native_coin_price_history/1" do test "fetches native coin price history", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "data/v2/histoday", fn conn -> @@ -72,6 +127,45 @@ defmodule Explorer.Market.Source.CryptoCompareTest do end end + describe "secondary_coin_price_history_fetching_enabled?" do + test "returns true if secondary_coin_symbol is configured" do + assert CryptoCompare.secondary_coin_price_history_fetching_enabled?() + end + + test "returns false if secondary_coin_symbol is not configured" do + config = Application.get_env(:explorer, CryptoCompare) + Application.put_env(:explorer, CryptoCompare, Keyword.merge(config, secondary_coin_symbol: nil)) + + on_exit(fn -> Application.put_env(:explorer, CryptoCompare, config) end) + + refute CryptoCompare.secondary_coin_price_history_fetching_enabled?() + end + end + + describe "market_cap_history_fetching_enabled?" do + test "ignored" do + assert CryptoCompare.market_cap_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_market_cap_history/1" do + test "ignored" do + assert CryptoCompare.fetch_market_cap_history(3) == :ignore + end + end + + describe "tvl_history_fetching_enabled?" do + test "ignored" do + assert CryptoCompare.tvl_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_tvl_history/1" do + test "ignored" do + assert CryptoCompare.fetch_tvl_history(3) == :ignore + end + end + describe "fetch_secondary_coin_price_history/1" do test "fetches secondary coin price history", %{bypass: bypass} do Bypass.expect_once(bypass, "GET", "data/v2/histoday", fn conn -> diff --git a/apps/explorer/test/explorer/market/source/crypto_rank_test.exs b/apps/explorer/test/explorer/market/source/crypto_rank_test.exs new file mode 100644 index 000000000000..940412081c84 --- /dev/null +++ b/apps/explorer/test/explorer/market/source/crypto_rank_test.exs @@ -0,0 +1,427 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.Source.CryptoRankTest do + use ExUnit.Case + + alias Explorer.Market.Source.CryptoRank + alias Plug.Conn + + setup do + bypass = Bypass.open() + old_env = Application.get_env(:explorer, CryptoRank, []) + + Application.put_env( + :explorer, + CryptoRank, + Keyword.merge( + old_env, + platform: 96, + base_url: "http://localhost:#{bypass.port}", + api_key: "api_key", + coin_id: "3", + secondary_coin_id: "4" + ) + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:explorer, CryptoRank, old_env) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + {:ok, old_env: old_env, bypass: bypass} + end + + describe "native_coin_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CryptoRank.native_coin_fetching_enabled?() + end + + test "returns false if coin_id is not configured", %{old_env: old_env} do + Application.put_env(:explorer, CryptoRank, Keyword.merge(old_env, coin_id: nil)) + + refute CryptoRank.native_coin_fetching_enabled?() + end + end + + describe "fetch_native_coin" do + test "fetches native coin", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/currencies/3", fn conn -> + assert conn.query_string == "api_key=api_key" + Conn.resp(conn, 200, json_coin(3, 2514.75325703684)) + end) + + assert {:ok, + %Explorer.Market.Token{ + available_supply: Decimal.new("120723694"), + btc_value: Decimal.new("0.0241643324256002"), + fiat_value: Decimal.new("2514.75325703684"), + image_url: "https://img.cryptorank.io/coins/60x60.ethereum1524754015525.png", + last_updated: ~U[2025-06-02 14:22:32.759Z], + market_cap: Decimal.new("303823300492.5147"), + name: "Ethereum", + symbol: "ETH", + total_supply: Decimal.new("120723694"), + tvl: nil, + volume_24h: Decimal.new("5826359746"), + circulating_supply: Decimal.new("120723694") + }} == + CryptoRank.fetch_native_coin() + end + end + + describe "secondary_coin_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert CryptoRank.secondary_coin_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured", %{old_env: old_env} do + Application.put_env(:explorer, CryptoRank, Keyword.merge(old_env, secondary_coin_id: nil)) + + refute CryptoRank.secondary_coin_fetching_enabled?() + end + end + + describe "fetch_secondary_coin" do + test "fetches secondary coin", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/currencies/4", fn conn -> + assert conn.query_string == "api_key=api_key" + Conn.resp(conn, 200, json_coin(4, 1000.123456789)) + end) + + assert {:ok, + %Explorer.Market.Token{ + available_supply: Decimal.new("120723694"), + btc_value: Decimal.new("0.0241643324256002"), + fiat_value: Decimal.new("1000.123456789"), + image_url: "https://img.cryptorank.io/coins/60x60.ethereum1524754015525.png", + last_updated: ~U[2025-06-02 14:22:32.759Z], + market_cap: Decimal.new("303823300492.5147"), + name: "Ethereum", + symbol: "ETH", + total_supply: Decimal.new("120723694"), + tvl: nil, + volume_24h: Decimal.new("5826359746"), + circulating_supply: Decimal.new("120723694") + }} == + CryptoRank.fetch_secondary_coin() + end + end + + describe "tokens_fetching_enabled?" do + test "returns true if platform is configured" do + assert CryptoRank.tokens_fetching_enabled?() + end + + test "returns false if platform is not configured", %{old_env: old_env} do + Application.put_env(:explorer, CryptoRank, Keyword.merge(old_env, platform: nil)) + + refute CryptoRank.tokens_fetching_enabled?() + end + end + + describe "fetch_tokens" do + test "fetches tokens", %{bypass: bypass} do + Bypass.expect(bypass, "GET", "/dedicated/blockscout/currencies/contracts/96", fn conn -> + case conn.query_string do + "api_key=api_key&limit=50&skip=0" -> + Conn.resp(conn, 200, json_tokens()) + + "api_key=api_key&limit=50&skip=50" -> + Conn.resp(conn, 200, json_tokens_2nd_page()) + + _ -> + raise "Unexpected query string: #{conn.query_string}" + end + end) + + assert {:ok, 50, false, first_batch} = CryptoRank.fetch_tokens(nil, 50) + assert length(first_batch) == 50 + + # cspell:disable + + assert { + :ok, + nil, + true, + [ + %{ + name: "Heurist", + type: "ERC-20", + symbol: "HEU", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: + <<171, 236, 94, 203, 224, 139, 108, 2, 245, 201, 162, 255, 130, 105, 110, 30, 125, 182, 249, + 191>> + }, + fiat_value: Decimal.new("0.019624517115"), + circulating_market_cap: Decimal.new("3046537.982244971760"), + volume_24h: Decimal.new("214644.7428230154531300"), + circulating_supply: Decimal.new("155241424") + }, + %{ + name: "Zyfi", + type: "ERC-20", + symbol: "ZFI", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<93, 13, 123, 202, 5, 14, 46, 152, 253, 74, 94, 141, 59, 168, 35, 180, 159, 57, 134, 141>> + }, + fiat_value: Decimal.new("0.004467522847"), + circulating_market_cap: Decimal.new("1019602.903569369320"), + volume_24h: Decimal.new("527.5318580293320804"), + circulating_supply: Decimal.new("228225560") + } + ] + } == CryptoRank.fetch_tokens(50, 50) + + # cspell:enable + end + end + + describe "native_coin_price_history_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert CryptoRank.native_coin_price_history_fetching_enabled?() + end + + test "returns false if coin_id is not configured", %{old_env: old_env} do + Application.put_env(:explorer, CryptoRank, Keyword.merge(old_env, coin_id: nil)) + + refute CryptoRank.native_coin_price_history_fetching_enabled?() + end + end + + describe "fetch_native_coin_price_history" do + test "fetches native coin price history", %{bypass: bypass} do + previous_days = 5 + from = Date.utc_today() |> Date.add(-previous_days) |> Date.to_iso8601() + to = Date.utc_today() |> Date.to_iso8601() + + Bypass.expect_once(bypass, "GET", "/currencies/3/sparkline", fn conn -> + assert conn.query_string == "api_key=api_key&interval=1d&from=#{from}&to=#{to}" + + Conn.resp(conn, 200, json_native_coin_history()) + end) + + assert {:ok, + [ + %{ + closing_price: Decimal.new("2681.794582393094"), + date: ~D[2025-05-28], + opening_price: Decimal.new("2662.609005038795"), + secondary_coin: false + }, + %{ + closing_price: Decimal.new("2631.742984069461"), + date: ~D[2025-05-29], + opening_price: Decimal.new("2681.794582393094"), + secondary_coin: false + }, + %{ + date: ~D[2025-05-30], + closing_price: Decimal.new("2532.024768716731"), + opening_price: Decimal.new("2631.742984069461"), + secondary_coin: false + }, + %{ + date: ~D[2025-05-31], + closing_price: Decimal.new("2528.507007933771"), + opening_price: Decimal.new("2532.024768716731"), + secondary_coin: false + }, + %{ + date: ~D[2025-06-01], + closing_price: Decimal.new("2528.507007933771"), + opening_price: Decimal.new("2528.507007933771"), + secondary_coin: false + } + ]} == + CryptoRank.fetch_native_coin_price_history(previous_days) + end + end + + describe "secondary_coin_price_history_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert CryptoRank.secondary_coin_price_history_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured", %{old_env: old_env} do + Application.put_env(:explorer, CryptoRank, Keyword.merge(old_env, secondary_coin_id: nil)) + + refute CryptoRank.secondary_coin_price_history_fetching_enabled?() + end + end + + describe "fetch_secondary_coin_price_history" do + test "fetches secondary coin price history", %{bypass: bypass} do + previous_days = 5 + from = Date.utc_today() |> Date.add(-previous_days) |> Date.to_iso8601() + to = Date.utc_today() |> Date.to_iso8601() + + Bypass.expect_once(bypass, "GET", "/currencies/4/sparkline", fn conn -> + assert conn.query_string == "api_key=api_key&interval=1d&from=#{from}&to=#{to}" + + Conn.resp(conn, 200, json_secondary_coin_history()) + end) + + assert {:ok, + [ + %{ + closing_price: Decimal.new("6.558096674134"), + date: ~D[2025-05-28], + opening_price: Decimal.new("6.669271727877"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("6.443746862121"), + date: ~D[2025-05-29], + opening_price: Decimal.new("6.558096674134"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("5.80657534093"), + date: ~D[2025-05-30], + opening_price: Decimal.new("6.443746862121"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("5.903371964672"), + date: ~D[2025-05-31], + opening_price: Decimal.new("5.80657534093"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("5.903371964672"), + date: ~D[2025-06-01], + opening_price: Decimal.new("5.903371964672"), + secondary_coin: true + } + ]} == + CryptoRank.fetch_secondary_coin_price_history(previous_days) + end + end + + describe "market_cap_history_fetching_enabled?" do + test "ignored" do + assert CryptoRank.market_cap_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_market_cap_history" do + test "ignored" do + assert CryptoRank.fetch_market_cap_history(0) == :ignore + end + end + + describe "tvl_history_fetching_enabled?" do + test "ignored" do + assert CryptoRank.tvl_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_tvl_history" do + test "ignored" do + assert CryptoRank.fetch_tvl_history(0) == :ignore + end + end + + defp json_coin(coin_id, fiat_value) do + """ + { + "data": { + "id": #{coin_id}, + "rank": 2, + "slug": "ethereum", + "name": "Ethereum", + "symbol": "ETH", + "category": "Chain", + "type": "coin", + "volume24hBase": 2315094.5634, + "circulatingSupply": 120723694, + "totalSupply": 120723694, + "images": { + "16x16": "https://img.cryptorank.io/coins/icon.ethereum1524754015525.png", + "200x200": "https://img.cryptorank.io/coins/ethereum1524754015525.png", + "60x60": "https://img.cryptorank.io/coins/60x60.ethereum1524754015525.png" + }, + "values": { + "USD": { + "price": #{fiat_value}, + "volume24h": 5826359746, + "high24h": 2546.560850604021, + "low24h": 2480.175296733773, + "marketCap": 303823300492.5147, + "percentChange24h": 0.4248, + "percentChange7d": -1.3799, + "percentChange30d": 37.1343, + "percentChange3m": 16.9385, + "percentChange6m": -30.9087 + }, + "BTC": { + "price": 0.0241643324256002, + "volume24h": 55942, + "high24h": 0.0241883039284151, + "low24h": 0.0236807597840912, + "marketCap": 2917207.4734624363, + "percentChange24h": 0.8852, + "percentChange7d": 3.8147, + "percentChange30d": 26.8718, + "percentChange3m": -3.0863, + "percentChange6m": -36.4735 + }, + "ETH": { + "price": 1, + "volume24h": 2315094, + "high24h": 1, + "low24h": 1, + "marketCap": 120723694, + "percentChange24h": 0, + "percentChange7d": 0, + "percentChange30d": 0, + "percentChange3m": 0, + "percentChange6m": 0 + } + }, + "lastUpdated": "2025-06-02T14:22:32.759Z", + "tokens": [] + }, + "status": { + "time": "2025-06-02T14:23:23.902Z", + "success": true, + "code": 200, + "message": "OK", + "responseTime": 1, + "creditsCost": 1 + } + } + """ + end + + # cspell:disable + defp json_tokens do + """ + { "data": [ { "id": 16, "slug": "tether", "symbol": "USDT", "name": "Tether", "priceUSD": "1.000312118824", "volume24hUSD": "34598333608.3056447616193232", "circulatingSupply": "153148231147", "contracts": [ { "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", "chainId": 3 }, { "address": "0x551a5dcac57c66aa010940c2dcff5da9c53aa53b", "chainId": 18 }, { "address": "0x493257fD37EDB34451f62EDf8D2a0C418852bA4C", "chainId": 96 }, { "address": "0x9636d3294e45823ec924c8d89dd1f1dffcf044e6", "chainId": 21 }, { "address": "0xfadbbf8ce7d5b7041be672561bba99f79c532e10", "chainId": 47 }, { "address": "0xe3f5a90f9cb311505cd691a46596599aa1a0ad7d", "chainId": 100 }, { "address": "0x5f0155d08ef4aae2b500aefb64a3419da8bb611a", "chainId": 121 }, { "address": "0x5eDCCFcAC24A89F3b87f2bfAB618a509FA6e3105", "chainId": 122 }, { "address": "0xd378634119d2f7b3cf3d60e0b0f5e048e74ce3cf", "chainId": 124 }, { "address": "0x398dcA951cD4fc18264d995DCD171aa5dEbDa129", "chainId": 134 }, { "address": "0x02f9bebf5e54968d8cc2562356c91ecde135801b", "chainId": 166 }, { "address": "0x900101d06a7426441ae63e9ab3b9b0f63be145f1", "chainId": 197 }, { "address": "0x3a337a6ada9d885b6ad95ec48f9b75f197b5ae35", "chainId": 487 }, { "address": "0x0709F39376dEEe2A2dfC94A58EdEb2Eb9DF012bD", "chainId": 489 }, { "address": "KT1XnTn74bUtxHfDtBmm2bGZAQfhPbvKWR8o", "chainId": 41 }, { "address": "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", "chainId": 95 }, { "address": "0xf0F161fDA2712DB8b566946122a5af183995e2eD", "chainId": 150 }, { "address": "0x28c9c7Fb3fE3104d2116Af26cC8eF7905547349c", "chainId": 210 }, { "address": "0x6fbcdc1169b5130c59e72e51ed68a84841c98cd1", "chainId": 43 }, { "address": "0x3795c36e7d12a8c252a20c5a7b455f7c57b60283", "chainId": 83 }, { "address": "0x919c1c267bc06a7039e03fcc2ef738525769109c", "chainId": 81 }, { "address": "0x201eba5cc46d216ce6dc03f6a759e8e766e956ae", "chainId": 103 }, { "address": "terra1ce06wkrdm4vl6t0hvc0g86rsy27pu8yadg3dva", "chainId": 2 }, { "address": "0x1e4a5963abfd975d8c9021ce480b42188849d41d", "chainId": 102 }, { "address": "0x6047828dc181963ba44974801FF68e538dA5eaF9", "chainId": 465 }, { "address": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", "chainId": 91 }, { "address": "0x0039f574ee5cc39bdd162e9a88e3eb1f111baf48", "chainId": 71 }, { "address": "0xa71edc38d189767582c38a3145b5873052c3e47a", "chainId": 14 }, { "address": "0x3c2b8be99c50593081eaa2a724f0b8285f5aba8f", "chainId": 12 }, { "address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", "chainId": 5 }, { "address": "0:a519f99bb5d6d51ef958ed24d337ad75a1c770885dcd42d51d6663f9fcdacfb2", "chainId": 93 }, { "address": "312769", "chainId": 57 }, { "address": null, "chainId": 90 }, { "address": "0x46dDa6a5a559d861c06EC9a95Fb395f5C3Db0742", "chainId": 450 }, { "address": "0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT", "chainId": 99 }, { "address": "0x01445c31581c354b7338ac35693ab2001b50b9ae", "chainId": 33 }, { "address": "0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e", "chainId": 13 }, { "address": "0xe936caa7f6d9f5c9e907111fcaf7c351c184cda7", "chainId": 11 }, { "address": "0xfe9f969faf8ad72a83b761138bf25de87eff9dd2", "chainId": 426 }, { "address": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", "chainId": 61 }, { "address": "0x66e428c3f67a68878562e79a0234c1f83c208770", "chainId": 75 }, { "address": "0xEf213441a85DF4d7acBdAe0Cf78004E1e486BB96", "chainId": 82 }, { "address": "0x382bb369d343125bfb2117af9c149795c6c65c50", "chainId": 45 }, { "address": "0xbb06dca3ae6887fabf931640f67cab3e3a16f4dc", "chainId": 17 }, { "address": "0x4988a896b1227218e4a686fde5eabdcabd91571f", "chainId": 31 }, { "address": "0xdc19a122e268128b5ee20366299fc7b5b199c8e3", "chainId": 85 }, { "address": "secret18wpjn83dayu4meu6wnn29khfkwdxs7kyrz9c8f", "chainId": 63 }, { "address": "zil1sxx29cshups269ahh5qjffyr58mxjv9ft78jqy", "chainId": 76 }, { "address": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "chainId": 1 }, { "address": "0xB75D0B03c06A926e488e2659DF1A861F860bD3d1", "chainId": 157 }, { "address": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", "chainId": 7 }, { "address": "0xC0F4b3a52B0532Bf48784d2202C812B1841d8812", "chainId": 411 }, { "address": "0x17270E5364f226Cd5D77a16957c1b2663dC9699B", "chainId": 382 }, { "address": "0xf417F5A458eC102B90352F697D6e2Ac3A3d2851f", "chainId": 143 }, { "address": "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", "chainId": 84 }, { "address": "0xefaeee334f0fd1712f9a8cc375f427d9cdd40d73", "chainId": 52 }, { "address": "0x55d398326f99059ff775485246999027b3197955", "chainId": 4 }, { "address": "0xfd36c336eb67a092dc80a063ff0644e13142d454", "chainId": 152 }, { "address": "e1d869a83212628ec82a4e039a95371a9ae4598c8459193ccfd0f2f2c689831f", "chainId": 138 }, { "address": "4Q89182juiadeFgGw3fupnrwnnDmBhf7e7fHWxnUP3S3", "chainId": 137 }, { "address": "0xfa9343c3897324496a05fc75abed6bac29f8a40f", "chainId": 125 }, { "address": "0x0cb6f5a34ad42ec934882a05265a7d5f59b51a2f", "chainId": 97 }, { "address": "usdt.tether-token.near", "chainId": 29 }, { "address": "32TLn1WLcu8LtfvweLzYUYU6ubc2YV9eZs", "chainId": 38 }, { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "chainId": 6 }, { "address": "0xA510432E4aa60B4acd476fb850EC84B7EE226b2d", "chainId": 469 }, { "address": "0xc2c527c0cacf457746bd31b2a698fe89de2b6d49", "chainId": 232 }, { "address": "0x6a2d262D56735DbA19Dd70682B39F6bE9a931D98", "chainId": 218 }, { "address": "0xfA9343C3897324496A05fC75abeD6bAC29f8A40f", "chainId": 217 }, { "address": "0x9350502a3af6c617e9a42fa9e306a385::BX_USDT::BX_USDT", "chainId": 215 }, { "address": "ibc/4ABBEF4C8926DDDB320AE5188CFD63267ABBCEFC0583E4AE05D6E5AA2401DDAB", "chainId": 141 }, { "address": "0x381B31409e4D220919B2cFF012ED94d70135A59e", "chainId": 48 }, { "address": "0x4ECaBa5870353805a9F068101A40E0f32ed605C6", "chainId": 78 }, { "address": "0x0Cf7c2A584988871b654Bd79f96899e4cd6C41C0", "chainId": 117 }, { "address": "0x68f5c6a61780768455de69077e07e89787839bf8166decfbf92b645209c0fb8", "chainId": 106 }, { "address": "0x80a16016cc4a2e6a2caca8a4a498b1699ff0f844", "chainId": 127 }, { "address": "0xfe97E85d13ABD9c1c33384E796F10B73905637cE", "chainId": 39 }, { "address": "0xA219439258ca9da29E9Cc4cE5596924745e12B93", "chainId": 101 }, { "address": "0xf55BEC9cafDbE8730f096Aa55dad6D22d44099Df", "chainId": 108 }, { "address": "0x9e5aac1ba1a2e6aed6b32689dfcf62a509ca96f3", "chainId": 110 }, { "address": "0x988a631caf24e14bb77ee0f5ca881e8b5dcfcec7", "chainId": 151 }, { "address": "0xa3200696761a0cf122de2a679f745b3f8cfa2623", "chainId": 155 }, { "address": "peggy0xdAC17F958D2ee523a2206206994597C13D831ec7", "chainId": 147 }, { "address": "A95EBDF88AC1E3FDECAA1D6E250B07C0C86475A3E2BFA2E7DC94A6CDE23DF6D6", "chainId": 255 }, { "address": "0x008D7923Fe2941Ceb549bf5646B1ddb1aC93C8a6", "chainId": 408 }, { "address": "0xefcAA73145B5e29eEfc47bcbaeFF9e870Fa6a610", "chainId": 130 }, { "address": "0x91Aa258324072dFf6F82408c2beB2F82D353b300", "chainId": 131 }, { "address": "0x3eee5d2ed0205f93969a59f7c8597fb614264436", "chainId": 114 } ] }, { "id": 72, "slug": "kyber-network", "symbol": "KNC", "name": "Kyber Network Crystal ", "priceUSD": "0.325609862550", "volume24hUSD": "10125280.0251828828477000", "circulatingSupply": "170152851", "contracts": [ { "address": "0x6ee46Cb7cD2f15Ee1ec9534cf29a5b51C83283e6", "chainId": 96 }, { "address": "0x6A80A465409ce8D36C513129C0FEEa61BEd579ba", "chainId": 102 }, { "address": "0x3b2F62d42DB19B30588648bf1c184865D4C3B1D6", "chainId": 101 }, { "address": "0x1e1085eFaA63EDFE74aaD7C05a28EAE4ef917C3F", "chainId": 10 }, { "address": "0x39fC9e94Caeacb435842FADeDeCB783589F50f5f", "chainId": 7 }, { "address": "0x316772cFEc9A3E976FDE42C3Ba21F5A13aAaFf12", "chainId": 61 }, { "address": "0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202", "chainId": 3 }, { "address": "0xfe56d5892bdffc7bf58f2e84be1b2c32d21c308b", "chainId": 4 }, { "address": "0x1c954e8fe737f99f68fa1ccda3e51ebdb291948c", "chainId": 5 } ] }, { "id": 1254, "slug": "weth", "symbol": "WETH", "name": "WETH", "priceUSD": "2523.982311036187", "volume24hUSD": "1061921578.9958917235204746", "circulatingSupply": "2652758", "contracts": [ { "address": "0:59b6b64ac6798aacf385ae9910008a525a84fc6dcf9f942ae81f8e8485fe160d", "chainId": 93 }, { "address": "0x2EAA73Bd0db20c64f53fEbeA7b5F5E5Bccc7fb8b", "chainId": 48 }, { "address": "0xf22bede237a07e121b56d91a491eb7bcdfd1f5907926a9e58338f964a01b17fa::asset::WETH", "chainId": 91 }, { "address": "0x722e8bdd2ce80a4422e880164f2079488e115365", "chainId": 89 }, { "address": "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", "chainId": 1 }, { "address": "0x2170Ed0880ac9A755fd29B2688956BD959F933F8", "chainId": 4 }, { "address": "0xE7798f023fC62146e8Aa1b36Da45fb70855a77Ea", "chainId": 247 }, { "address": "0xe44Fd7fCb2b1581822D0c862B68222998a0c299a", "chainId": 75 }, { "address": "0xab3f0245B83feB11d15AAffeFD7AD465a59817eD", "chainId": 52 }, { "address": "0xdEAddEaDdeadDEadDEADDEAddEADDEAddead1111", "chainId": 103 }, { "address": "zil19j33tapjje2xzng7svslnsjjjgge930jx0w09v", "chainId": 76 }, { "address": "terra14tl83xcwqjy0ken9peu4pjjuu755lrry2uy25r", "chainId": 2 }, { "address": "0xc99a6a985ed2cac1ef41640596c5a5f9f4e19ef5", "chainId": 34 }, { "address": "0x02DcdD04e3F455D838cd1249292C58f3B79e3C3C", "chainId": 97 }, { "address": "0xC9BdeEd33CD01541e1eeD10f90519d2C06Fe3feB", "chainId": 31 }, { "address": "0x420000000000000000000000000000000000000A", "chainId": 17 }, { "address": "0x0258866edaf84d6081df17660357ab20a07d0c80", "chainId": 43 }, { "address": "0x135cb19acde9ffb4654cace4189a0e0fb4b6954e", "chainId": 181 }, { "address": "0x1540020a94aA8bc189aA97639Da213a4ca49d9a7", "chainId": 18 }, { "address": "0x4300000000000000000000000000000000000004", "chainId": 221 }, { "address": "0x7339e5586280dfa2b1f315827392ce414a44b1c0", "chainId": 174 }, { "address": "0x4200000000000000000000000000000000000006", "chainId": 150 }, { "address": "0x4200000000000000000000000000000000000006", "chainId": 98 }, { "address": "0xCD3f9D5Cbf51016e0d2340FE909E99C006422A48", "chainId": 159 }, { "address": "0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f", "chainId": 101 }, { "address": "0xe7798f023fc62146e8aa1b36da45fb70855a77ea", "chainId": 110 }, { "address": "0x0Dc808adcE2099A9F62AA87D9670745AbA741746", "chainId": 143 }, { "address": "0x5300000000000000000000000000000000000004", "chainId": 108 }, { "address": "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", "chainId": 5 }, { "address": "0x8ed7d143Ef452316Ab1123d28Ab302dC3b80d3ce", "chainId": 105 }, { "address": "0x0ce35b0d42608ca54eb7bcc8044f7087c18e7717", "chainId": 202 }, { "address": "0x4200000000000000000000000000000000000006", "chainId": 487 }, { "address": "0x50c42dEAcD8Fc9773493ED674b675bE577f2634b", "chainId": 465 }, { "address": "0x3439153EB7AF838Ad19d56E1571FBD09333C2809", "chainId": 489 }, { "address": "0x2F6F07CDcf3588944Bf4C42aC74ff24bF56e7590", "chainId": 491 }, { "address": "0xa722c13135930332Eb3d749B2F0906559D2C5b99", "chainId": 47 }, { "address": "0xf55af137a98607f7ed2efefa4cd2dfe70e4253b1", "chainId": 71 }, { "address": "0x5aea5775959fbc2557cc8789bc1bf90a239d9a91", "chainId": 96 }, { "address": "0xa47f43DE2f9623aCb395CA4905746496D2014d57", "chainId": 39 }, { "address": "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", "chainId": 106 }, { "address": "0x7EbeF2A4b1B09381Ec5B9dF8C5c6f2dBECA59c73", "chainId": 128 }, { "address": "85219708c49aa701871ad330a94ea0f41dff24ca", "chainId": 33 }, { "address": "0xe3f5a90f9cb311505cd691a46596599aa1a0ad7d", "chainId": 127 }, { "address": "0x57eea49ec1087695274a9c4f341e414eb64328c2", "chainId": 119 }, { "address": "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", "chainId": null }, { "address": "0x695921034f0387eAc4e11620EE91b1b15A6A09fE", "chainId": 10 }, { "address": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "chainId": 61 }, { "address": "THb4CqiFdwNHsWsQCs4JhzwjMWys4aqCbF", "chainId": 6 }, { "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "chainId": 3 }, { "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "chainId": 3 }, { "address": "0x122013fd7dF1C6F636a5bb8f03108E876548b455", "chainId": 13 }, { "address": "0x160345fC359604fC6e70E3c5fAcbdE5F7A9342d8", "chainId": 157 }, { "address": "0xfa9343c3897324496a05fc75abed6bac29f8a40f", "chainId": 55 }, { "address": "0x3223f17957Ba502cbe71401D55A0DB26E5F7c68F", "chainId": 85 }, { "address": "0x81ECac0D6Be0550A00FF064a4f9dd2400585FE9c", "chainId": 83 }, { "address": "0x6983d1e6def3690c4d616b13597a09e6193ea013", "chainId": 12 }, { "address": "0x6a023ccd1ff6f2045c3309768ead9e68f978f6e1", "chainId": 78 }, { "address": "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB", "chainId": 7 }, { "address": "0x4200000000000000000000000000000000000006", "chainId": 84 } ] }, { "id": 1883, "slug": "wrapped-bitcoin", "symbol": "WBTC", "name": "Wrapped Bitcoin", "priceUSD": "104247.030463929140", "volume24hUSD": "282269194.4261377519110420", "circulatingSupply": "128815", "contracts": [ { "address": "EQDcBkGHmC4pTf34x3Gm05XvepO5w60DNxZ-XT4I6-UGG5L5", "chainId": 95 }, { "address": "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", "chainId": 157 }, { "address": "0x19df5689Cfce64bC2A55F7220B0Cd522659955EF", "chainId": 450 }, { "address": "0x3095c7557bcb296ccc6e363de01b760ba031f2d9", "chainId": 12 }, { "address": "0x8e5bbbb09ed1ebde8674cda39a0c169401db4252", "chainId": 78 }, { "address": "0:2ba32b75870d572e255809b7b423f30f36dd5dea075bd5f026863fceb81f2bcf", "chainId": 93 }, { "address": "0x68f180fcCe6836688e9084f035309E29Bf0A2095", "chainId": 84 }, { "address": "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", "chainId": 1 }, { "address": "io1c7unwg8h8vph89xwqru4f7zfa4yy5002wxvlrm", "chainId": 43 }, { "address": "0xF4eB217Ba2454613b15dBdea6e5f22276410e89e", "chainId": 31 }, { "address": "0xa5B55ab1dAF0F8e1EFc0eB1931a957fd89B918f4", "chainId": 17 }, { "address": "0x408d4cd0adb7cebd1f1a1c33a0ba2098e1295bab", "chainId": 7 }, { "address": "0x062E66477Faf219F25D27dCED647BF57C3107d52", "chainId": 75 }, { "address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", "chainId": 3 }, { "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", "chainId": 61 }, { "address": "0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6", "chainId": 5 }, { "address": "0x313dbD8e65C6499dE939e5317cA97Ccc6eD4c621", "chainId": 33 }, { "address": "0xE57eBd2d67B462E9926e04a8e33f01cD0D64346D", "chainId": 52 }, { "address": "0xfa93c12cd345c658bc4644d1d4e1b9615952258c", "chainId": 71 }, { "address": "2260fac5e5542a773aa44fbcfedf7c193bc2c599.factory.bridge.near", "chainId": 29 }, { "address": "0x1f545487c62e5acfea45dcadd9c627361d1616d8", "chainId": 39 }, { "address": "8LQW8f7P5d5PZM7GtZEBgaqRPGSzS3DfPuiXrURJ4AJS", "chainId": 69 }, { "address": "0x927B51f251480a681271180DA4de28D44EC4AfB8", "chainId": 492 }, { "address": "0xcabae6f6ea1ecab08ad02fe02ce9a44f09aebfa2", "chainId": 103 }, { "address": "0xb17d901469b9208b17d916112988a3fed19b5ca1", "chainId": 97 }, { "address": "0xEA034fb02eB1808C2cc3adbC15f447B93CbE08e1", "chainId": 102 }, { "address": "zil1wha8mzaxhm22dpm5cav2tepuldnr8kwkvmqtjq", "chainId": 76 }, { "address": "0x3C1BCa5a656e69edCD0D4E36BEbb3FcDAcA60Cf1", "chainId": 108 }, { "address": "0x3fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac", "chainId": 106 }, { "address": "0xF6D226f9Dc15d9bB51182815b320D3fBE324e1bA", "chainId": 220 }, { "address": "0xbbeb516fb02a01611cbbe0453fe3c580d7281011", "chainId": 96 }, { "address": "0x027792d9fed7f9844eb4839566001bb6f6cb4804f66aa2da6fe1ee242d896881::coin::COIN", "chainId": 99 }, { "address": "0xcDd475325D6F564d27247D1DddBb0DAc6fA0a5CF", "chainId": 150 }, { "address": "0x3aAB2285ddcDdaD8edf438C1bAB47e1a9D05a9b4", "chainId": 101 }, { "address": "0x503b2ddc059b81788fd1239561596614b27faade", "chainId": 48 }, { "address": "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", "chainId": 491 }, { "address": "0xcb011e86df014a46f4e3ac3f3cbb114a4eb80870", "chainId": 233 }, { "address": "0xf390830df829cf22c53c8840554b98eafc5dcbc2", "chainId": 55 }, { "address": "0x78F811A431D248c1EDcF6d95ec8551879B2897C3", "chainId": 11 } ] }, { "id": 2854, "slug": "venus", "symbol": "XVS", "name": "Venus", "priceUSD": "5.882048680590", "volume24hUSD": "2054331.9320935988631060", "circulatingSupply": "16655176", "contracts": [ { "address": "0xd3CC9d8f3689B83c91b7B59cAB4946B063EB894A", "chainId": 3 }, { "address": "0xc1Eb7689147C81aC840d4FF0D298489fc7986d52", "chainId": 61 }, { "address": "0xcf6bb5389c92bdda8a3747ddb454cb7a64626c63", "chainId": 4 }, { "address": "0x3E2e61F1c075881F3fB8dd568043d8c221fd5c61", "chainId": 110 }, { "address": "0xD78ABD81a3D57712a3af080dc4185b698Fe9ac5A", "chainId": 96 } ] }, { "id": 5487, "slug": "usdcoin", "symbol": "USDC", "name": "USDC", "priceUSD": "0.999819756251", "volume24hUSD": "5357299343.0449997080455914", "circulatingSupply": "62369678914", "contracts": [ { "address": "0x81ECac0D6Be0550A00FF064a4f9dd2400585FE9c", "chainId": 85 }, { "address": "0x818ec0a7fe18ff94269904fced6ae3dae6d6dc0b", "chainId": 52 }, { "address": "0xe2c120f188ebd5389f71cf4d9c16d05b62a58993", "chainId": 33 }, { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "chainId": 5 }, { "address": "0x28a92dde19D9989F39A49905d7C9C2FAc7799bDf", "chainId": 10 }, { "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "chainId": 61 }, { "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "chainId": 3 }, { "address": "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", "chainId": 99 }, { "address": "0xc21223249ca28397b4b6541dffaecc539bff0c59", "chainId": 75 }, { "address": "0xceba9300f2b948710d2653dd7b07f33a8b32118c", "chainId": 13 }, { "address": "io18v4l9dfr74xyu320pz4zsmgrz9d07vnvy20yrh", "chainId": 43 }, { "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "chainId": 1 }, { "address": "secret1h6z05y90gwm4sqxzhz4pkyp36cna9xtp7q0urv", "chainId": 63 }, { "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", "chainId": 4 }, { "address": "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", "chainId": 91 }, { "address": "0x980a5afef3d17ad98635f6c5aebcbaeded3c3430", "chainId": 71 }, { "address": "0x985458e523db3d53125813ed68c274899e9dfab4", "chainId": 12 }, { "address": "0x0b7007c13325c48911f73a2dad5fa5dcbf808adc", "chainId": 34 }, { "address": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", "chainId": 7 }, { "address": "0x52a9cea01c4cbdd669883e41758b8eb8e8e2b34b", "chainId": 44 }, { "address": "0xc946daf81b08146b1c7a8da2a851ddf2b3eaaf85", "chainId": 45 }, { "address": "0x620fd5fa44be6af63715ef4e65ddfa0387ad13f5", "chainId": 47 }, { "address": "0xea32a96608495e54156ae48931a7c20f0dcc1a21", "chainId": 17 }, { "address": "31566704", "chainId": 57 }, { "address": "0x6d1e7cde53ba9467b783cb7c530ce054", "chainId": 303 }, { "address": "0x6a2d262d56735dba19dd70682b39f6be9a931d98", "chainId": 90 }, { "address": "0xB12BFcA5A55806AaF64E99521918A4bf0fC40802", "chainId": 31 }, { "address": "0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83", "chainId": 78 }, { "address": "0xcca4e6302510d555b654b3eab9c0fcb223bcfdf0", "chainId": 48 }, { "address": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "chainId": 6 }, { "address": "0x078D782b760474a361dDA0AF3839290b0EF57AD6", "chainId": 492 }, { "address": "USDC-c76f1f", "chainId": 16 }, { "address": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", "chainId": 84 }, { "address": "0x275e916Ab1E93A6862a7b380751DdD87D6F66267", "chainId": 371 }, { "address": "0xd988097fb8612cc24eeC14542bC03424c656005f", "chainId": 150 }, { "address": "ibc/4A1C18CA7F50544760CF306189B810CE4C1CB156C7FC870143D401FE7280E591", "chainId": 223 }, { "address": "0xe3f5a90f9cb311505cd691a46596599aa1a0ad7d", "chainId": 168 }, { "address": "0x80b5a32e4f032b2a058b4f29ec95eefeeb87adcd", "chainId": 148 }, { "address": "0xb73603C5d87fA094B7314C74ACE2e64D165016fb", "chainId": 143 }, { "address": "0x06eFdBFf2a14a7c8E15944D1F4A48F9F95F663A4", "chainId": 108 }, { "address": "ibc/D189335C6E4A68B513C10AB227BF1C1D38C746766278BA3EEB4FB14124F1D858", "chainId": null }, { "address": "0x818ec0a7fe18ff94269904fced6ae3dae6d6dc0b", "chainId": 55 }, { "address": "0x00f0d8595797943c12605cd59bc0d9f63d750ccf", "chainId": 119 }, { "address": "0x540d90635a0C10CdD4D27fc8edCbf88c18DfB1eD", "chainId": 116 }, { "address": "0xE1aB220E37AC55A4E2dD5Ba148298A9c09fBD716", "chainId": 183 }, { "address": "0x640952e7984f2ecedead8fd97aa618ab1210a21c", "chainId": 171 }, { "address": "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", "chainId": 250 }, { "address": "17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1", "chainId": 29 }, { "address": "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", "chainId": 141 }, { "address": "A.b19436aae4d94622.FiatToken", "chainId": 68 }, { "address": "0xfa9343c3897324496a05fc75abed6bac29f8a40f", "chainId": 230 }, { "address": "0xFFD7510ca0a3279c7a5F50018A26c21d5bc1DBcF", "chainId": 107 }, { "address": "0.0.456858", "chainId": 94 }, { "address": "0x09bc4e0d864854c6afb6eb9a9cdf58ac190d0df9", "chainId": 103 }, { "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "chainId": 98 }, { "address": "0x53c91253bc9682c04929ca02ed00b3e423f6710d2ee7e0d5ebb06f3ecf368a8", "chainId": 106 }, { "address": "1337", "chainId": 54 }, { "address": "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", "chainId": 66 }, { "address": "e4b8164dccf3489f66124cace1570dc35b58fc90", "chainId": 209 }, { "address": "0xe2aa35C2039Bd0Ff196A6Ef99523CC0D3972ae3e", "chainId": 198 }, { "address": "0x3355df6d4c9c3035724fd0e3914de96a5a83aaf4", "chainId": 96 }, { "address": "0x6963EfED0aB40F6C3d7BdA44A05dcf1437C44372", "chainId": 39 } ] }, { "id": 7511, "slug": "binance-usd", "symbol": "BUSD", "name": "BUSD", "priceUSD": "0.999635670343", "volume24hUSD": "2398317.6519931188988041", "circulatingSupply": "57792684", "contracts": [ { "address": "0x4Fabb145d64652a948d72533023f6E7A623C7C53", "chainId": 3 }, { "address": "0x6ab6d61428fde76768d7b45d8bfeec19c6ef91a8", "chainId": 75 }, { "address": "0xe9e7cea3dedca5984780bafc599bd69add087d56", "chainId": 4 }, { "address": "0x19860ccb0a68fd4213ab9d8266f7bbf05a8dde98", "chainId": 7 }, { "address": "0x9c9e5fd8bbc25984b178fdce6117defa39d2db39", "chainId": 84 }, { "address": "0xa649325aa7c5093d12d6f98eb4378deae68ce23f", "chainId": 52 }, { "address": "0xc111c29a988ae0c0087d97b33c6e6766808a3bd3", "chainId": 33 }, { "address": "0x2039bb4116B4EFc145Ec4f0e2eA75012D6C0f181", "chainId": 96 }, { "address": "0x7d43AABC515C356145049227CeE54B608342c0ad", "chainId": 101 }, { "address": "0x5d9ab5522c64e1f6ef5e3627eccc093f56167818", "chainId": 11 }, { "address": "0x4bf769b05e832fcdc9053fffbc78ca889acb5e1e", "chainId": 90 }, { "address": "0xe176ebe47d621b984a73036b9da5d834411ef734", "chainId": 12 }, { "address": "0x332730a4f6e03d9c55829435f10360e13cfa41ff", "chainId": 45 }, { "address": "0x3444273afdf9e00fd0491c8a97738aca3ebb2a93", "chainId": 18 }, { "address": "0x4bf769b05e832fcdc9053fffbc78ca889acb5e1e", "chainId": 83 }, { "address": "0x9C9e5fD8bbc25984B178FdCE6117Defa39d2db39", "chainId": 5 }, { "address": "5RpUwQ8wtdPCZHhu6MERp2RGrpobsbZ6MH5dDHkUjs2", "chainId": 1 }, { "address": "0x84abcb2832be606341a50128aeb1db43aa017449", "chainId": 43 }, { "address": "0x7b37d0787a3424a0810e02b24743a45ebd5530b2", "chainId": 74 } ] }, { "id": 19793, "slug": "multicollateraldai", "symbol": "DAI", "name": "Dai", "priceUSD": "0.999911538842", "volume24hUSD": "43665636.5443962007704738", "circulatingSupply": "4015833934", "contracts": [ { "address": "0xef977d2f931c1978db5f6747666fa1eacb0d0339", "chainId": 12 }, { "address": "0x80a16016cc4a2e6a2caca8a4a498b1699ff0f844", "chainId": 11 }, { "address": "0x44fA8E6f47987339850636F88629646662444217", "chainId": 78 }, { "address": "terra1zmclyfepfmqvfqflu8r3lv6f75trmg05z7xq95", "chainId": 2 }, { "address": "0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3", "chainId": 4 }, { "address": "0xf2001b145b43032aaf5ee2884e456ccd805f677d", "chainId": 75 }, { "address": "0x6b175474e89094c44da98b954eedeac495271d0f", "chainId": 3 }, { "address": "0xd586e7f844cea2f87f50152665bcbc2c279d8d70", "chainId": 7 }, { "address": "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063", "chainId": 5 }, { "address": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", "chainId": 98 }, { "address": "0xda10009cbd5d07dd0cecc66161fc93d7c9000da1", "chainId": 89 }, { "address": "0xcA77eB3fEFe3725Dc33bccB54eDEFc3D9f764f97", "chainId": 108 }, { "address": "0x0ee5893f434017d8881750101Ea2F7c49c0eb503", "chainId": 107 }, { "address": "0xda114221cb83fa859dbdb4c44beeaa0bb37c7537ad5ae66fe5e0efd20e6eb3", "chainId": 106 }, { "address": "0x4C1f6fCBd233241bF2f4D02811E3bF8429BC27B8", "chainId": 149 }, { "address": "0x6cc8f0b5607e1f947e83667368881a1bccc3f1c4", "chainId": 212 }, { "address": "0xC5015b9d9161Dca7e18e32f6f25C4aD850731Fd4", "chainId": 102 }, { "address": "0x765277eebeca2e31912c9946eae1021199b39c61", "chainId": 52 }, { "address": "0x4b9eb6c0b6ea15176bbf62841c6b2a8a398cb656", "chainId": 96 }, { "address": "0xda10009cbd5d07dd0cecc66161fc93d7c9000da1", "chainId": 61 }, { "address": "0x0200060000000000000000000000000000000000000000000000000000000000", "chainId": 24 }, { "address": "0xe3520349f477a5f6eb06107066048508498a291b", "chainId": 31 }, { "address": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", "chainId": 84 }, { "address": "0x6de33698e9e9b787e09d3bd7771ef63557e148bb", "chainId": 90 }, { "address": "0x4651b38e7ec14bb3db731369bfe5b08f2466bd0a", "chainId": 17 }, { "address": "EjmyN6qEC1Tf1JxiG1ae7UTJhUxSwk1TCWNWqxWV4J6o", "chainId": 1 } ] }, { "id": 21076, "slug": "avalanche", "symbol": "AVAX", "name": "Avalanche", "priceUSD": "20.463198322952", "volume24hUSD": "234079380.7814911234932608", "circulatingSupply": "421534916", "contracts": [ { "address": "0x2C89bbc92BD86F8075d1DEcc58C7F4E0107f286b", "chainId": 5 }, { "address": "0x1CE0c2827e2eF14D5C4f29a091d735A204794041", "chainId": 4 }, { "address": "0x4792c1ecb969b036eb51330c63bd27899a13d84e", "chainId": 52 }, { "address": "0xcd8fe44a29db9159db36f96570d7a4d91986f528", "chainId": 19 }, { "address": "0x14a0243C333A5b238143068dC3A7323Ba4C30ECB", "chainId": 11 }, { "address": "0x65e66a61D0a8F1e686C2D6083ad611a10D84D97A", "chainId": 83 }, { "address": "0x6a5279e99ca7786fb13f827fc1fb4f61684933d6", "chainId": 96 } ] }, { "id": 23000, "slug": "pancakeswap", "symbol": "CAKE", "name": "PancakeSwap", "priceUSD": "2.371277962566", "volume24hUSD": "82354494.6215425524394026", "circulatingSupply": "321554264", "contracts": [ { "address": "0x0D1E753a25eBda689453309112904807625bEFBe", "chainId": 101 }, { "address": "0x3A287a06c66f9E95a56327185cA2BDF5f031cEcD", "chainId": 96 }, { "address": "0x1b896893dfc86bb67Cf57767298b9073D2c1bA2c", "chainId": 61 }, { "address": "0x2779106e4F4A8A28d77A24c18283651a2AE22D1C", "chainId": 110 }, { "address": "0x0d1e753a25ebda689453309112904807625befbe", "chainId": 102 }, { "address": "0x3055913c90Fcc1A6CE9a358911721eEb942013A1", "chainId": 98 }, { "address": "0x152649eA73beAb28c5b49B26eb48f7EAD6d4c898", "chainId": 3 }, { "address": "0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82", "chainId": 4 }, { "address": "0x159df6b7689437016108a019fd5bef736bac692b6d4a1f10c941f6fbb9a74ca6::oft::CakeOFT", "chainId": 91 } ] }, { "id": 25225, "slug": "dextf-protocol", "symbol": "DEXTF", "name": "Domani Protocol", "priceUSD": "0.144790927447", "volume24hUSD": "134748.9592184632356044", "circulatingSupply": "65807235", "contracts": [ { "address": "0x9929bcac4417a21d7e6fc86f6dae1cc7f27a2e41", "chainId": 96 }, { "address": "0x03E8D118A1864c7Dc53bf91e007ab7D91f5A06fA", "chainId": 7 }, { "address": "0x5F64Ab1544D28732F0A24F4713c2C8ec0dA089f0", "chainId": 3 } ] }, { "id": 25781, "slug": "wbnb", "symbol": "WBNB", "name": "Wrapped BNB", "priceUSD": "656.079994507931", "volume24hUSD": "2292132064.2050728573560094", "circulatingSupply": "1523844", "contracts": [ { "address": "0xfa9343c3897324496a05fc75abed6bac29f8a40f", "chainId": 75 }, { "address": "0x442F7f22b1EE2c842bEAFf52880d4573E9201158", "chainId": 7 }, { "address": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", "chainId": 4 }, { "address": "0xc9baa8cfdde8e328787e29b4b078abf2dadc2055", "chainId": 52 }, { "address": "0x7f27352D5F83Db87a5A3E00f4B07Cc2138D8ee52", "chainId": 83 }, { "address": "9gP2kCy3wA1ctvYWQk75guqXuHfrEomqydHLtcTCqiLa", "chainId": 1 }, { "address": "0x673d2ec54e0a6580fc7e098295b70e3ce0350d03", "chainId": 12 }, { "address": "0x7400793aad94c8ca801aa036357d10f5fd0ce08f", "chainId": 96 }, { "address": "0xeCDCB5B88F8e3C15f95c720C51c71c9E2080525d", "chainId": 5 }, { "address": "0xd7D045BFBa6Ea93b480F409DB3dd1729337C1d13", "chainId": 18 }, { "address": "0x97e6c48867fdc391a8dfe9d169ecd005d1d90283", "chainId": 43 }, { "address": "0x2bF9b864cdc97b08B6D79ad4663e71B8aB65c45c", "chainId": 31 }, { "address": "0x2c78f1b70ccf63cdee49f9233e9faa99d43aa07e", "chainId": 55 }, { "address": "terra1cetg5wruw2wsdjp7j46rj44xdel00z006e9yg8", "chainId": 2 }, { "address": "ibc/F4A070A6D78496D53127EA85C094A9EC87DFC1F36071B8CCDDBD020F933D213D", "chainId": null }, { "address": "0xf5c6825015280cdfd0b56903f9f8b5a2233476f5", "chainId": 101 }, { "address": "0x94bd7A37d2cE24cC597E158fACaa8d601083ffeC", "chainId": 39 }, { "address": "0xb848cce11ef3a8f62eccea6eb5b35a12c4c2b1ee1af7755d02d7bd6218e8226f::coin::COIN", "chainId": 99 }, { "address": "0xD67de0e0a0Fd7b15dC8348Bb9BE742F3c5850454", "chainId": 10 }, { "address": "0x418D75f65a02b3D53B2418FB8E1fe493759c7605", "chainId": 3 } ] }, { "id": 26062, "slug": "wootrade", "symbol": "WOO", "name": "WOO", "priceUSD": "0.075585907922", "volume24hUSD": "3381083.3045858390631228", "circulatingSupply": "1918868005", "contracts": [ { "address": "0xabc9547b534519ff73921b1fba6e672b5f58d083", "chainId": 7 }, { "address": "0x6626c47c00f1d87902fc13eecfac3ed06d5e8d8a", "chainId": 10 }, { "address": "0x4691937a7508860f876c9c0a2a617e7d9e945d4b", "chainId": 4 }, { "address": "0x3befb2308bce92da97264077faf37dcd6c8a75e6", "chainId": 14 }, { "address": "0x1B815d120B3eF02039Ee11dC2d33DE7aA4a8C603", "chainId": 5 }, { "address": "0x4691937a7508860f876c9c0a2a617e7d9e945d4b", "chainId": 3 }, { "address": "0xcafcd85d8ca7ad1e1c6f82f651fa15e33aefd07b", "chainId": 61 }, { "address": "E5rk3nmgLUuKUiS94gg4bpWwWwyjCMtddsAXkTFLtHEy", "chainId": 1 }, { "address": "4691937a7508860f876c9c0a2a617e7d9e945d4b.factory.bridge.near", "chainId": 29 }, { "address": "0x9E22D758629761FC5708c171d06c2faBB60B5159", "chainId": 96 } ] }, { "id": 28986, "slug": "govi", "symbol": "GOVI", "name": "CVI", "priceUSD": "0.014434503875", "volume24hUSD": "84854.0413822350425000", "circulatingSupply": "15439655", "contracts": [ { "address": "0xeeaa40b28a2d1b0b08f6f97bb1dd4b75316c6107", "chainId": 3 }, { "address": "0x43Df9c0a1156c96cEa98737b511ac89D0e2A1F46", "chainId": 5 }, { "address": "0x07e49d5de43dda6162fa28d24d5935c151875283", "chainId": 61 }, { "address": "0xD63eF5e9C628c8a0E8984CDfb7444AEE44B09044", "chainId": 96 } ] }, { "id": 29494, "slug": "mute", "symbol": "MUTE", "name": "Mute", "priceUSD": "0.016605279624", "volume24hUSD": "4.6335687763122456", "circulatingSupply": "40000000", "contracts": [ { "address": "0xa49d7499271ae71cd8ab9ac515e6694c755d400c", "chainId": 3 }, { "address": "0x0e97c7a0f8b2c9885c8ac9fc6136e829cbc21d42", "chainId": 96 } ] }, { "id": 78303, "slug": "wpol", "symbol": "WPOL", "name": "Wrapped POL", "priceUSD": "0.214828338580", "volume24hUSD": "7167694.6332244566141460", "circulatingSupply": "298839976", "contracts": [ { "address": "0xf2f13f0B7008ab2FA4A2418F4ccC3684E49D20Eb", "chainId": 7 }, { "address": "0xc836d8dC361E44DbE64c4862D55BA041F88Ddd39", "chainId": 4 }, { "address": "0x28a487240e4d45cff4a2980d334cc933b7483842", "chainId": 96 }, { "address": "0xdbe380b13a6d0f5cdedd58de8f04625263f113b3f9db32b3e1983f49e2841676::coin::COIN", "chainId": 99 }, { "address": "Gz7VkD4MacbEB6yC5XD3HcumEiYx2EtDYYrfikGsvopG", "chainId": 1 }, { "address": "0x6aB6d61428fde76768D7b45D8BFeec19c6eF91A8", "chainId": 31 }, { "address": "0x8e66c0d6b70c0b23d39f4b21a1eac52bba8ed89a", "chainId": 43 }, { "address": "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", "chainId": 5 } ] }, { "id": 169625, "slug": "idexo-token", "symbol": "IDO", "name": "Idexo Token", "priceUSD": "0.019806179953", "volume24hUSD": "28219.3104301756690000", "circulatingSupply": "81027500", "contracts": [ { "address": "0xF9c53268e9de692AE1b2ea5216E24e1c3ad7CB1E", "chainId": 3 }, { "address": "0xDea6d5161978d36b5C0FA6a491faA754f4BC809C", "chainId": 96 } ] }, { "id": 169760, "slug": "lido-finance-wsteth", "symbol": "wstETH", "name": "Wrapped stETH", "priceUSD": "3038.168722518680", "volume24hUSD": "6334397.0657931186642560", "circulatingSupply": "3545482", "contracts": [ { "address": "0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb", "chainId": 84 }, { "address": "0xf610A9dfB7C89644979b4A0f27063E9e7d7Cda32", "chainId": 108 }, { "address": "0x6C76971f98945AE98dD7d4DFcA8711ebea946eA6", "chainId": 78 }, { "address": "0x703b52f2b28febcb60e1372858af5b18849fe867", "chainId": 96 }, { "address": "0xB5beDd42000b71FddE22D3eE8a79Bd49A568fC8F", "chainId": 101 }, { "address": "0x458ed78EB972a369799fb278c0243b25e5242A83", "chainId": 103 }, { "address": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452", "chainId": 98 }, { "address": "0x03b54A6e9a984069379fae1a4fC4dBAE93B3bCCD", "chainId": 5 }, { "address": "0xc02fE7317D4eb8753a02c35fe019786854A92001", "chainId": 492 }, { "address": "0x5979D7b546E38E414F7E9822514be443A4800529", "chainId": 61 }, { "address": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "chainId": 3 } ] }, { "id": 169918, "slug": "nodle", "symbol": "NODL", "name": "Nodle", "priceUSD": "0.000305023780", "volume24hUSD": "116874.9633574116394000", "circulatingSupply": "935521185", "contracts": [ { "address": "0xBD4372e44c5eE654dd838304006E1f0f69983154", "chainId": 96 } ] }, { "id": 171109, "slug": "symbiosis-finance", "symbol": "SIS", "name": "Symbiosis Finance", "priceUSD": "0.060961263279", "volume24hUSD": "1632923.5396401369641178", "circulatingSupply": "65321769", "contracts": [ { "address": "0x1467b62A6AE5CdcB10A6a8173cfe187DD2C5a136", "chainId": 108 }, { "address": "0x9e758b8a98a42d612b3d38b66a22074dc03d7370", "chainId": 61 }, { "address": "0x6EF95B6f3b0F39508e3E04054Be96D5eE39eDE0d", "chainId": 101 }, { "address": "0xd38bb40815d2b0c2d2c866e0c72c5728ffc76dd9", "chainId": 3 }, { "address": "0xdd9f72afED3631a6C85b5369D84875e6c42f1827", "chainId": 96 }, { "address": "0xF98b660AdF2ed7d9d9D9dAACC2fb0CAce4F21835", "chainId": 4 } ] }, { "id": 171673, "slug": "izumi-finance", "symbol": "IZI", "name": "iZUMi Finance", "priceUSD": "0.004412784502", "volume24hUSD": "330746.6003961574641634", "circulatingSupply": "787400000", "contracts": [ { "address": "0x9ad37205d608B8b219e6a2573f922094CEc5c200", "chainId": 3 }, { "address": "0x60D01EC2D5E98Ac51C8B4cF84DfCCE98D527c747", "chainId": 61 }, { "address": "0x60D01EC2D5E98Ac51C8B4cF84DfCCE98D527c747", "chainId": 108 }, { "address": "0x16A9494e257703797D747540f01683952547EE5b", "chainId": 96 }, { "address": "0x60d01ec2d5e98ac51c8b4cf84dfcce98d527c747", "chainId": 103 }, { "address": null, "chainId": 4 }, { "address": "0x60D01EC2D5E98Ac51C8B4cF84DfCCE98D527c747", "chainId": 98 }, { "address": "0x91647632245cabf3d66121f86c387ae0ad295f9a", "chainId": 143 }, { "address": "0x60D01EC2D5E98Ac51C8B4cF84DfCCE98D527c747", "chainId": 101 }, { "address": "0x60D01EC2D5E98Ac51C8B4cF84DfCCE98D527c747", "chainId": 5 } ] }, { "id": 173442, "slug": "liquity-usd", "symbol": "LUSD", "name": "Liquity USD", "priceUSD": "0.995979718780", "volume24hUSD": "2758.7340448632429660", "circulatingSupply": "41318766", "contracts": [ { "address": "0x368181499736d0c0CC614DBB145E2EC1AC86b8c6", "chainId": 98 }, { "address": "0xc40F949F8a4e094D1b49a23ea9241D289B7b2819", "chainId": 84 }, { "address": "0x5f98805a4e8be255a32880fdec7f6728c6568ba0", "chainId": 3 }, { "address": "0x93b346b6BC2548dA6A1E7d98E9a421B42541425b", "chainId": 61 }, { "address": "0x503234F203fC7Eb888EEC8513210612a43Cf6115", "chainId": 96 }, { "address": "0x23001f892c0c82b79303edc9b9033cd190bb21c7", "chainId": 5 } ] }, { "id": 174671, "slug": "maverick-protocol", "symbol": "MAV", "name": "Maverick Protocol", "priceUSD": "0.055832135906", "volume24hUSD": "2049518.9217414230150462", "circulatingSupply": "603700522", "contracts": [ { "address": "0x7448c7456a97769F6cD04F1E83A4a23cCdC46aBD", "chainId": 3 }, { "address": "0x64b88c73A5DfA78D1713fE1b4c69a22d7E0faAa7", "chainId": 98 }, { "address": "0x787c09494Ec8Bcb24DcAf8659E7d5D69979eE508", "chainId": 96 }, { "address": "0xd691d9a68C887BDF34DA8c36f63487333ACfD103", "chainId": 4 } ] }, { "id": 174835, "slug": "usd", "symbol": "USD+", "name": "Overnight.fi USD+", "priceUSD": "1.000886021354", "volume24hUSD": "2318557.5607744404448812", "circulatingSupply": "85659194", "contracts": [ { "address": "0x236eeC6359fb44CCe8f97E99387aa7F8cd5cdE1f", "chainId": 5 }, { "address": "0xB79DD08EA68A908A97220C76d19A6aA9cBDE4376", "chainId": 101 }, { "address": "0xB79DD08EA68A908A97220C76d19A6aA9cBDE4376", "chainId": 98 }, { "address": "0x4fEE793d435c6D2c10C135983BB9d6D4fC7B9BBd", "chainId": 221 }, { "address": "0xe80772eaf6e2e18b651f160bc9158b2a5cafca65", "chainId": 61 }, { "address": "0xe80772eaf6e2e18b651f160bc9158b2a5cafca65", "chainId": 7 }, { "address": "0x8E86e46278518EFc1C5CEd245cBA2C7e3ef11557", "chainId": 96 }, { "address": "0x73cb180bf0521828d8849bc8cf2b920918e23032", "chainId": 84 }, { "address": "0xe80772eaf6e2e18b651f160bc9158b2a5cafca65", "chainId": 4 } ] }, { "id": 176076, "slug": "zksync", "symbol": "ZK", "name": "zkSync", "priceUSD": "0.053223305221", "volume24hUSD": "21534018.8609259087161898", "circulatingSupply": "3675000000", "contracts": [ { "address": "0x5A7d6b2F92C77FAD6CCaBd7EE0624E64907Eaf3E", "chainId": 96 } ] }, { "id": 177999, "slug": "metavault-trade", "symbol": "MVX", "name": "Metavault Trade", "priceUSD": "0.134441948770", "volume24hUSD": "55.7786201251853000", "circulatingSupply": "3158978", "contracts": [ { "address": "0x0018D96C579121a94307249d47F053E2D687b5e7", "chainId": 101 }, { "address": "0x0018D96C579121a94307249d47F053E2D687b5e7", "chainId": 108 }, { "address": "0xc8ac6191cdc9c7bf846ad6b52aaaa7a0757ee305", "chainId": 96 }, { "address": "0x2760e46d9bb43dafcbecaad1f64b93207f9f0ed7", "chainId": 5 } ] }, { "id": 178476, "slug": "paxo-finance", "symbol": "WEFI", "name": "WeFi", "priceUSD": "0.030589544594", "volume24hUSD": "6155.0057772154816200", "circulatingSupply": "41883332", "contracts": [ { "address": "0xfFA188493C15DfAf2C206c97D8633377847b6a52", "chainId": 5 }, { "address": "0x81E7186947fb59AAAAEb476a47daAc60680cbbaF", "chainId": 96 }, { "address": "0x60892e742d91d16Be2cB0ffE847e85445989e30B", "chainId": 101 }, { "address": "0xfFA188493C15DfAf2C206c97D8633377847b6a52", "chainId": 4 }, { "address": "0xfFA188493C15DfAf2C206c97D8633377847b6a52", "chainId": 61 }, { "address": "0xfFA188493C15DfAf2C206c97D8633377847b6a52", "chainId": 3 } ] }, { "id": 180336, "slug": "veno-finance", "symbol": "VNO", "name": "Veno Finance", "priceUSD": "0.018190374275", "volume24hUSD": "115437.6067161836517400", "circulatingSupply": "511160922", "contracts": [ { "address": "0xe75a17b4f5c4f844688d5670b684515d7c785e63", "chainId": 96 }, { "address": "0xdb7d0a1ec37de1de924f8e8adac6ed338d4404e9", "chainId": 75 } ] }, { "id": 181064, "slug": "hypercomic", "symbol": "HYCO", "name": "HYPERCOMIC", "priceUSD": "0.000056327575", "volume24hUSD": "94095.2908919698815000", "circulatingSupply": null, "contracts": [ { "address": "0x77F76483399Dc6328456105B1db23e2Aca455bf9", "chainId": 3 }, { "address": "0x45656c02Aae856443717C34159870b90D1288203", "chainId": 96 } ] }, { "id": 181939, "slug": "zkdoge", "symbol": "ZKDOGE", "name": "zkDoge", "priceUSD": "0.000003460380", "volume24hUSD": "9.8128693700984100", "circulatingSupply": null, "contracts": [ { "address": "0xbFB4b5616044Eded03e5b1AD75141f0D9Cb1499b", "chainId": 96 } ] }, { "id": 181986, "slug": "fulcrom-finance", "symbol": "FUL", "name": "Fulcrom", "priceUSD": "0.006995472668", "volume24hUSD": "15865.7646197202946152", "circulatingSupply": "2097500155", "contracts": [ { "address": "0xe593853b4d603d5b8f21036Bb4AD0D1880097a6e", "chainId": 96 }, { "address": "0x83aFB1C32E5637ACd0a452D87c3249f4a9F0013A", "chainId": 75 } ] }, { "id": 182038, "slug": "space-fi", "symbol": "SPACE", "name": "SpaceFi", "priceUSD": "0.008479370310", "volume24hUSD": "273.3965067737609730", "circulatingSupply": "7047643", "contracts": [ { "address": "0x47260090ce5e83454d5f05a0abbb2c953835f777", "chainId": 96 } ] }, { "id": 182572, "slug": "reactorfusion", "symbol": "RF", "name": "ReactorFusion", "priceUSD": "0.001274750675", "volume24hUSD": null, "circulatingSupply": null, "contracts": [ { "address": "0x5f7CBcb391d33988DAD74D6Fd683AadDA1123E4D", "chainId": 96 } ] }, { "id": 182821, "slug": "derp-coin", "symbol": "DERP", "name": "DerpDEX", "priceUSD": "0.000000000982", "volume24hUSD": "86527.3838211341400000", "circulatingSupply": "55600000000000", "contracts": [ { "address": "0x5DfC78C4D073fD343BC6661668948178522A0DE5", "chainId": 3 }, { "address": "0x0bf4CB727b3f8092534D793893B2cC3348963dbf", "chainId": 96 }, { "address": "0xEbb78043e29F4af24E6266A7D142f5A08443969E", "chainId": 98 }, { "address": "0xEbb78043e29F4af24E6266A7D142f5A08443969E", "chainId": 110 } ] }, { "id": 183061, "slug": "zkapes", "symbol": "ZAT", "name": "zkApe", "priceUSD": "0.000000002068", "volume24hUSD": "84267.4172353719200000", "circulatingSupply": "46426356238082", "contracts": [ { "address": "0x47EF4A5641992A72CFd57b9406c9D9cefEE8e0C4", "chainId": 96 } ] }, { "id": 183169, "slug": "vesync", "symbol": "VS", "name": "veSync", "priceUSD": "0.000177133079", "volume24hUSD": "17.5539080563713875", "circulatingSupply": null, "contracts": [ { "address": "0x5756A28E2aAe01F600FC2C01358395F5C1f8ad3A", "chainId": 96 } ] }, { "id": 183206, "slug": "gravita-protocol", "symbol": "GRAI", "name": "Gravita Protocol", "priceUSD": "1.038124398700", "volume24hUSD": "254.4263305692724900", "circulatingSupply": "115838", "contracts": [ { "address": "0x15f74458aE0bFdAA1a96CA1aa779D715Cc1Eefe4", "chainId": 3 }, { "address": "0x894134a25a5faC1c2C26F1d8fBf05111a3CB9487", "chainId": 61 }, { "address": "0x5fc44e95eaa48f9eb84be17bd3ac66b6a82af709", "chainId": 96 } ] }, { "id": 185576, "slug": "holdstation", "symbol": "HOLD", "name": "Holdstation", "priceUSD": "1.047597992039", "volume24hUSD": "566623.5207217100563030", "circulatingSupply": "7903700", "contracts": [ { "address": "0xed4040fd47629e7c8fbb7da76bb50b3e7695f0f2", "chainId": 96 } ] }, { "id": 185628, "slug": "metaelfland-new", "symbol": "MELD", "name": "MetaElfLand", "priceUSD": "0.000085906805", "volume24hUSD": "104532.7469887663634000", "circulatingSupply": "549580000", "contracts": [ { "address": "0xcd2cfa60f04f3421656d6eebee122b3973b3f60c", "chainId": 96 } ] }, { "id": 185653, "slug": "karat", "symbol": "KAT", "name": "Karat", "priceUSD": "0.000561575224", "volume24hUSD": "65780.8531208648736000", "circulatingSupply": "1090715670", "contracts": [ { "address": "0xCDb7D260c107499C80B4b748e8331c64595972a1", "chainId": 96 } ] }, { "id": 185915, "slug": "zkswap-finance", "symbol": "ZF", "name": "zkSwap Finance", "priceUSD": "0.002617039976", "volume24hUSD": "140497.8235385651772904", "circulatingSupply": "551289480", "contracts": [ { "address": "0x31c2c031fdc9d33e974f327ab0d9883eae06ca4a", "chainId": 96 } ] }, { "id": 186051, "slug": "wagmi-com", "symbol": "WAGMI", "name": "Wagmi", "priceUSD": "0.006931617803", "volume24hUSD": "1934561.2104832143165772", "circulatingSupply": "1816532138", "contracts": [ { "address": "0xaf20f5f19698f1D19351028cd7103B63D30DE7d7", "chainId": 4 }, { "address": "0xb1f795776cb9ddac6e7e162f31c7419dd3d48297", "chainId": 10 }, { "address": "0xaf20f5f19698f1d19351028cd7103b63d30de7d7", "chainId": 61 }, { "address": "0x07Ed33a242BD9C08CA3C198e01189e35265024Da", "chainId": 5 }, { "address": "0x92CC36D66e9d739D50673d1f27929a371FB83a67", "chainId": 3 }, { "address": "0xaf20f5f19698f1d19351028cd7103b63d30de7d7", "chainId": 7 }, { "address": "0x0e0Ce4D450c705F8a0B6Dd9d5123e3df2787D16B", "chainId": 465 }, { "address": "0xaf20f5f19698f1D19351028cd7103B63D30DE7d7", "chainId": 314 }, { "address": "0xaf20f5f19698f1D19351028cd7103B63D30DE7d7", "chainId": 98 }, { "address": "0xaf20f5f19698f1d19351028cd7103b63d30de7d7", "chainId": 84 }, { "address": "0xaf20f5f19698f1d19351028cd7103b63d30de7d7", "chainId": 17 }, { "address": "0x3613ad277df1d5935d41400a181aa9ec1dc2dc9e", "chainId": 96 } ] }, { "id": 187519, "slug": "tarot-v-2", "symbol": "TAROT", "name": "Tarot v2", "priceUSD": "0.102369568487", "volume24hUSD": "26514.2841985293372282", "circulatingSupply": "67508541", "contracts": [ { "address": "0xf544251d25f3d243a36b07e7e7962a678f952691", "chainId": 98 }, { "address": "0x7f2fd959013eec5144269ac6edd0015cb10968fc", "chainId": 96 }, { "address": "0x2e4c7bf66d0484e44fea0ec273b85a00af92b2e3", "chainId": 81 }, { "address": "0x981bd9f77c8aafc14ebc86769503f86a3cc29af5", "chainId": 103 }, { "address": "0x13278cd824d33a7adb9f0a9a84aca7c0d2deebf7", "chainId": 89 }, { "address": "0xb092e1bf50f518b3ebf7ed26a40015183ae36ac2", "chainId": 5 }, { "address": "0x5ecfec22aa950cb5a3b4fd7249dc30b2bd160f18", "chainId": 7 }, { "address": "0x1f514a61bcde34f94bc39731235690ab9da737f7", "chainId": 84 }, { "address": "0x982e609643794a31a07f5c5b142dd3a9cf0690be", "chainId": 4 }, { "address": "0xa10bf0aba0c7953f279c4cb8192d3b5de5ea56e8", "chainId": 3 }, { "address": "0xb7c2ddb1ebac1056231ef22c1b0a13988537a274", "chainId": 10 } ] }, { "id": 187575, "slug": "zero-lend", "symbol": "ZERO", "name": "ZeroLend", "priceUSD": "0.000055572630", "volume24hUSD": "1902730.6709149503654030", "circulatingSupply": "71671379638", "contracts": [ { "address": "0x78354f8dccb269a615a7e0a24f9b0718fdc3c7a7", "chainId": 101 }, { "address": "0x27d0A2b5316b98088294378692F4EAbfB3222e36", "chainId": 96 } ] }, { "id": 188057, "slug": "libertas-omnibus", "symbol": "LIBERTAS", "name": "LIBERTAS OMNIBUS", "priceUSD": "2.012875893051", "volume24hUSD": "18.4928946922274523", "circulatingSupply": null, "contracts": [ { "address": "0xC6DaC3A53D5d6dE9D1D05AA6e28B8e9E41722601", "chainId": 96 } ] }, { "id": 188523, "slug": "koi-2", "symbol": "KOI", "name": "Koi", "priceUSD": "0.002194806500", "volume24hUSD": "205.9992931609069500", "circulatingSupply": "500000000", "contracts": [ { "address": "0xa995ad25ce5eb76972ab356168f5e1d9257e4d05", "chainId": 96 }, { "address": "0x9d14bce1daddf408d77295bb1be9b343814f44de", "chainId": 3 } ] }, { "id": 188734, "slug": "autoair-ai", "symbol": "AAI", "name": "AutoAir AI", "priceUSD": "0.002541865297", "volume24hUSD": null, "circulatingSupply": "33250000", "contracts": [ { "address": "0x144b83555d8a3119b0a69a7bc2f0a0388308fee3", "chainId": 96 } ] }, { "id": 189041, "slug": "long-2", "symbol": "LONG", "name": "Long", "priceUSD": "0.000000694095", "volume24hUSD": "0.8418805348653165", "circulatingSupply": "783813835230", "contracts": [ { "address": "0x5165ec33b491d7b67260B3143f96Bb4aC4736398", "chainId": 96 } ] }, { "id": 189524, "slug": "tevaera-zk", "symbol": "TEVA", "name": "Tevaera", "priceUSD": "0.007110085092", "volume24hUSD": "859877.3327826079846800", "circulatingSupply": "425508223", "contracts": [ { "address": "0xdbFF7c6d368904680706804645cAfA4dEfa3c224", "chainId": 96 } ] }, { "id": 189990, "slug": "wrapped-rseth", "symbol": "wrsETH", "name": "Wrapped rsETH", "priceUSD": "2642.703746000547", "volume24hUSD": "47011.5854783529306924", "circulatingSupply": null, "contracts": [ { "address": "0x87eEE96D50Fb761AD85B1c982d28A042169d61b1", "chainId": 84 }, { "address": "0xD2671165570f41BBB3B0097893300b6EB6101E6C", "chainId": 101 }, { "address": "0xe7903B1F75C534Dd8159b313d92cDCfbC62cB3Cd", "chainId": 150 }, { "address": "0xe7903B1F75C534Dd8159b313d92cDCfbC62cB3Cd", "chainId": 221 }, { "address": "0xEDfa23602D0EC14714057867A78d01e94176BEA0", "chainId": 98 }, { "address": "0xd4169E045bcF9a86cC00101225d9ED61D2F51af2", "chainId": 96 }, { "address": "0xa25b25548B4C98B0c7d3d27dcA5D5ca743d68b7F", "chainId": 108 } ] } ], "meta": { "count": 52 }, "status": { "time": "2025-06-02T14:54:40.534Z", "success": true, "code": 200, "message": "OK", "responseTime": 12, "creditsCost": 1 }} + """ + end + + defp json_tokens_2nd_page do + """ + {"data":[{"id":190444,"slug":"zyfi","symbol":"ZFI","name":"Zyfi","priceUSD":"0.004467522847","volume24hUSD":"527.5318580293320804","circulatingSupply":"228225560","contracts":[{"address":"0x5d0d7BCa050e2E98Fd4A5e8d3bA823B49f39868d","chainId":96}]},{"id":193174,"slug":"heurist","symbol":"HEU","name":"Heurist","priceUSD":"0.019624517115","volume24hUSD":"214644.7428230154531300","circulatingSupply":"155241424","contracts":[{"address":"0xEF22cb48B8483dF6152e1423b19dF5553BbD818b","chainId":98},{"address":"0xAbEc5eCBe08b6c02F5c9A2fF82696e1E7dB6f9bf","chainId":96}]}],"meta":{"count":52},"status":{"time":"2025-06-02T15:11:56.798Z","success":true,"code":200,"message":"OK","responseTime":11,"creditsCost":1}} + """ + end + + # cspell:enable + + defp json_native_coin_history do + """ + {"data":{"dates":["2025-05-28T00:00:00.000Z","2025-05-29T00:00:00.000Z","2025-05-30T00:00:00.000Z","2025-05-31T00:00:00.000Z","2025-06-01T00:00:00.000Z"],"volumes":[10438545595.534193,7970411497.8339,11568734237.80682,10646850181.382534,5666095954.980677],"prices":[2662.609005038795,2681.794582393094,2631.742984069461,2532.024768716731,2528.507007933771],"currency":"USD"},"status":{"time":"2025-06-02T15:32:53.690Z","success":true,"code":200,"message":"OK","responseTime":21,"creditsCost":1}} + """ + end + + defp json_secondary_coin_history do + """ + {"data":{"dates":["2025-05-28T00:00:00.000Z","2025-05-29T00:00:00.000Z","2025-05-30T00:00:00.000Z","2025-05-31T00:00:00.000Z","2025-06-01T00:00:00.000Z"],"volumes":[16697342.091162598,15904676.379819755,18808755.81579478,31779868.78666319,21254600.77892847],"prices":[6.669271727877,6.558096674134,6.443746862121,5.80657534093,5.903371964672],"currency":"USD"},"status":{"time":"2025-06-02T15:34:36.792Z","success":true,"code":200,"message":"OK","responseTime":49,"creditsCost":1}} + """ + end +end diff --git a/apps/explorer/test/explorer/market/source/defillama_test.exs b/apps/explorer/test/explorer/market/source/defillama_test.exs new file mode 100644 index 000000000000..5c9ef01fe042 --- /dev/null +++ b/apps/explorer/test/explorer/market/source/defillama_test.exs @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.Source.DefiLlamaTest do + use ExUnit.Case + + alias Explorer.Market.Source.DefiLlama + alias Plug.Conn + + setup do + bypass = Bypass.open() + + defillama_configuration = Application.get_env(:explorer, DefiLlama) + source_configuration = Application.get_env(:explorer, Explorer.Market.Source) + + Application.put_env( + :explorer, + Explorer.Market.Source, + Keyword.merge(source_configuration || [], + native_coin_source: DefiLlama, + secondary_coin_source: DefiLlama, + tokens_source: DefiLlama, + native_coin_history_source: DefiLlama, + secondary_coin_history_source: DefiLlama, + market_cap_history_source: DefiLlama, + tvl_history_source: DefiLlama + ) + ) + + Application.put_env( + :explorer, + DefiLlama, + Keyword.merge(defillama_configuration || [], + base_url: "http://localhost:#{bypass.port}", + coin_id: "Ethereum" + ) + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, source_configuration) + Application.put_env(:explorer, DefiLlama, defillama_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + {:ok, bypass: bypass} + end + + describe "native_coin_fetching_enabled?" do + test "ignored" do + assert DefiLlama.native_coin_fetching_enabled?() == :ignore + end + end + + describe "fetch_native_coin/0" do + test "ignored" do + assert DefiLlama.fetch_native_coin() == :ignore + end + end + + describe "secondary_coin_fetching_enabled?" do + test "ignored" do + assert DefiLlama.secondary_coin_fetching_enabled?() == :ignore + end + end + + describe "fetch_secondary_coin/0" do + test "ignored" do + assert DefiLlama.fetch_secondary_coin() == :ignore + end + end + + describe "tokens_fetching_enabled?" do + test "ignored" do + assert DefiLlama.tokens_fetching_enabled?() == :ignore + end + end + + describe "fetch_tokens/2" do + test "ignored" do + assert DefiLlama.fetch_tokens(nil, 10) == :ignore + end + end + + describe "native_coin_price_history_fetching_enabled?" do + test "ignored" do + assert DefiLlama.native_coin_price_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_native_coin_price_history/1" do + test "ignored" do + assert DefiLlama.fetch_native_coin_price_history(3) == :ignore + end + end + + describe "secondary_coin_price_history_fetching_enabled?" do + test "ignored" do + assert DefiLlama.secondary_coin_price_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_secondary_coin_price_history/1" do + test "ignored" do + assert DefiLlama.fetch_secondary_coin_price_history(3) == :ignore + end + end + + describe "market_cap_history_fetching_enabled?" do + test "ignored" do + assert DefiLlama.market_cap_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_market_cap_history/1" do + test "ignored" do + assert DefiLlama.fetch_market_cap_history(3) == :ignore + end + end + + describe "tvl_history_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert DefiLlama.tvl_history_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, DefiLlama) + Application.put_env(:explorer, DefiLlama, Keyword.merge(config, coin_id: nil)) + + refute DefiLlama.tvl_history_fetching_enabled?() + end + end + + describe "fetch_tvl_history/1" do + test "fetches TVL history", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/historicalChainTvl/Ethereum", fn conn -> + Conn.resp(conn, 200, json_historical_chain_tvl()) + end) + + assert {:ok, + [ + %{ + date: ~D[2025-02-12], + tvl: Decimal.new("50000000000") + }, + %{ + date: ~D[2025-02-13], + tvl: Decimal.new("51000000000") + }, + %{ + date: ~D[2025-02-14], + tvl: Decimal.new("52000000000") + } + ]} == DefiLlama.fetch_tvl_history(3) + end + + test "returns error when coin_id not configured" do + config = Application.get_env(:explorer, DefiLlama) + Application.put_env(:explorer, DefiLlama, Keyword.merge(config, coin_id: nil)) + + assert {:error, "Coin ID not specified"} == DefiLlama.fetch_tvl_history(3) + end + end + + defp json_historical_chain_tvl do + """ + [ + {"date": 1739318400, "tvl": 50000000000}, + {"date": 1739404800, "tvl": 51000000000}, + {"date": 1739491200, "tvl": 52000000000} + ] + """ + end +end diff --git a/apps/explorer/test/explorer/market/source/dia_test.exs b/apps/explorer/test/explorer/market/source/dia_test.exs new file mode 100644 index 000000000000..890a42e5035b --- /dev/null +++ b/apps/explorer/test/explorer/market/source/dia_test.exs @@ -0,0 +1,440 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.Source.DIATest do + use ExUnit.Case + + alias Explorer.Market.Source.DIA + alias Plug.Conn + + @native_coin_history_json File.read!("./test/support/fixture/market/dia/native_coin_history.json") + @secondary_coin_history_json File.read!("./test/support/fixture/market/dia/secondary_coin_history.json") + + setup do + bypass = Bypass.open() + old_env = Application.get_env(:explorer, DIA, []) + + new_env = + Keyword.merge( + old_env, + blockchain: "Ethereum", + base_url: "http://localhost:#{bypass.port}", + coin_address_hash: "0x0000000000000000000000000000000000000000", + secondary_coin_address_hash: "0x0000000000000000000000000000000000000001" + ) + + Application.put_env( + :explorer, + DIA, + new_env + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:explorer, DIA, old_env) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + {:ok, env: new_env, bypass: bypass} + end + + describe "native_coin_fetching_enabled?" do + test "returns true if coin_address_hash is configured" do + assert DIA.native_coin_fetching_enabled?() + end + + test "returns false if coin_address_hash is not configured", %{env: env} do + Application.put_env(:explorer, DIA, Keyword.merge(env, coin_address_hash: nil)) + refute DIA.native_coin_fetching_enabled?() + end + end + + describe "fetch_native_coin" do + test "fetches native coin", %{bypass: bypass} do + Bypass.expect_once( + bypass, + "GET", + "/assetQuotation/Ethereum/0x0000000000000000000000000000000000000000", + fn conn -> + Conn.resp( + conn, + 200, + json_coin("0x0000000000000000000000000000000000000000", 3027.98578732332, "ETH", "Ethereum") + ) + end + ) + + assert {:ok, + %Explorer.Market.Token{ + available_supply: nil, + btc_value: nil, + fiat_value: Decimal.new("3027.98578732332"), + image_url: nil, + last_updated: ~U[2025-11-27 08:09:59Z], + market_cap: nil, + name: "Ethereum", + symbol: "ETH", + total_supply: nil, + tvl: nil, + volume_24h: Decimal.new("6577658642.868258"), + circulating_supply: nil + }} == + DIA.fetch_native_coin() + end + end + + describe "secondary_coin_fetching_enabled?" do + test "returns true if secondary_coin_address_hash is configured" do + assert DIA.secondary_coin_fetching_enabled?() + end + + test "returns false if secondary_coin_address_hash is not configured", %{env: env} do + Application.put_env(:explorer, DIA, Keyword.merge(env, secondary_coin_address_hash: nil)) + + refute DIA.secondary_coin_fetching_enabled?() + end + end + + describe "fetch_secondary_coin" do + test "fetches secondary coin", %{bypass: bypass} do + Bypass.expect_once( + bypass, + "GET", + "/assetQuotation/Ethereum/0x0000000000000000000000000000000000000001", + fn conn -> + Conn.resp( + conn, + 200, + json_coin("0x0000000000000000000000000000000000000001", 91169.97153780921, "WBTC", "Wrapped BTC") + ) + end + ) + + assert {:ok, + %Explorer.Market.Token{ + available_supply: nil, + btc_value: nil, + fiat_value: Decimal.new("91169.97153780921"), + image_url: nil, + last_updated: ~U[2025-11-27 08:09:59Z], + market_cap: nil, + name: "Wrapped BTC", + symbol: "WBTC", + total_supply: nil, + tvl: nil, + volume_24h: Decimal.new("6577658642.868258"), + circulating_supply: nil + }} == + DIA.fetch_secondary_coin() + end + end + + describe "tokens_fetching_enabled?" do + test "returns true if blockchain is configured" do + assert DIA.tokens_fetching_enabled?() + end + + test "returns false if blockchain is not configured", %{env: env} do + Application.put_env(:explorer, DIA, Keyword.merge(env, blockchain: nil)) + + refute DIA.tokens_fetching_enabled?() + end + end + + describe "fetch_tokens" do + test "fetches tokens", %{bypass: bypass} do + Bypass.expect(bypass, "GET", "/quotedAssets", fn conn -> + assert conn.query_string == "blockchain=Ethereum" + Conn.resp(conn, 200, json_tokens_list()) + end) + + Bypass.expect(bypass, "GET", "/assetQuotation/Ethereum/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", fn conn -> + Conn.resp( + conn, + 200, + json_coin("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 0.9997188333561603, "USDC", "USD Coin") + ) + end) + + Bypass.expect(bypass, "GET", "/assetQuotation/Ethereum/0xdac17f958d2ee523a2206206994597c13d831ec7", fn conn -> + Conn.resp( + conn, + 200, + json_coin("0xdAC17F958D2ee523a2206206994597C13D831ec7", 0.9999313493241913, "USDT", "Tether USD") + ) + end) + + usdt_and_usdc_to_fetch = [ + %{ + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: "\xDA\xC1\d\x95\x8D.\xE5#\xA2 b\x06\x99E\x97\xC1=\x83\x1E\xC7" + }, + decimals: 6 + }, + %{ + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: "\xA0\xB8i\x91\xC6!\x8B6\xC1ѝJ.\x9E\xB0\xCE6\x06\xEBH" + }, + decimals: 6 + } + ] + + usdt_and_usdc = [ + %{ + name: "Tether USD", + type: "ERC-20", + symbol: "USDT", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<218, 193, 127, 149, 141, 46, 229, 35, 162, 32, 98, 6, 153, 69, 151, 193, 61, 131, 30, 199>> + }, + fiat_value: Decimal.new("0.9999313493241913"), + volume_24h: Decimal.new("6577658642.868258"), + decimals: 6 + }, + %{ + name: "USD Coin", + type: "ERC-20", + symbol: "USDC", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<160, 184, 105, 145, 198, 33, 139, 54, 193, 209, 157, 74, 46, 158, 176, 206, 54, 6, 235, 72>> + }, + fiat_value: Decimal.new("0.9997188333561603"), + volume_24h: Decimal.new("6577658642.868258"), + decimals: 6 + } + ] + + assert {:ok, [usdt_or_usdc_to_fetch] = state, false, [usdc_or_usdt_a]} = DIA.fetch_tokens(nil, 1) + + assert usdt_or_usdc_to_fetch in usdt_and_usdc_to_fetch + + assert {:ok, [], true, [usdc_or_usdt_b]} = DIA.fetch_tokens(state, 1) + + assert usdc_or_usdt_a in usdt_and_usdc + assert usdc_or_usdt_b in usdt_and_usdc + assert usdc_or_usdt_a != usdc_or_usdt_b + end + end + + describe "native_coin_price_history_fetching_enabled?" do + test "returns true if coin_address_hash is configured" do + assert DIA.native_coin_price_history_fetching_enabled?() + end + + test "returns false if coin_address_hash is not configured", %{env: env} do + Application.put_env(:explorer, DIA, Keyword.merge(env, coin_address_hash: nil)) + + refute DIA.native_coin_price_history_fetching_enabled?() + end + end + + describe "fetch_native_coin_price_history" do + test "fetches native coin price history", %{bypass: bypass} do + previous_days = 5 + + Bypass.expect_once( + bypass, + "GET", + "/assetChartPoints/MA120/Ethereum/0x0000000000000000000000000000000000000000", + fn conn -> + "starttime=" <> from_str = conn.query_string + {from_int, "&endtime=" <> to_str} = Integer.parse(from_str) + {to_int, ""} = Integer.parse(to_str) + assert to_int - from_int == previous_days * 24 * 60 * 60 + Conn.resp(conn, 200, @native_coin_history_json) + end + ) + + assert {:ok, + [ + %{ + closing_price: Decimal.new("2769.8064113406717"), + date: ~D[2025-11-22], + opening_price: Decimal.new("2765.03927910001"), + secondary_coin: false + }, + %{ + closing_price: Decimal.new("2801.5141364778983"), + date: ~D[2025-11-23], + opening_price: Decimal.new("2768.2062977060264"), + secondary_coin: false + }, + %{ + closing_price: Decimal.new("2952.88025966878"), + date: ~D[2025-11-24], + opening_price: Decimal.new("2798.783002464827"), + secondary_coin: false + }, + %{ + closing_price: Decimal.new("2959.1210339664754"), + date: ~D[2025-11-25], + opening_price: Decimal.new("2949.9206856724004"), + secondary_coin: false + }, + %{ + closing_price: Decimal.new("3027.151520522141"), + date: ~D[2025-11-26], + opening_price: Decimal.new("2959.062301250391"), + secondary_coin: false + } + ]} == + DIA.fetch_native_coin_price_history(previous_days) + end + end + + describe "secondary_coin_price_history_fetching_enabled?" do + test "returns true if secondary_coin_address_hash is configured" do + assert DIA.secondary_coin_price_history_fetching_enabled?() + end + + test "returns false if secondary_coin_address_hash is not configured", %{env: env} do + Application.put_env(:explorer, DIA, Keyword.merge(env, secondary_coin_address_hash: nil)) + refute DIA.secondary_coin_price_history_fetching_enabled?() + end + end + + describe "fetch_secondary_coin_price_history" do + test "fetches secondary coin price history", %{bypass: bypass} do + previous_days = 5 + + Bypass.expect_once( + bypass, + "GET", + "/assetChartPoints/MA120/Ethereum/0x0000000000000000000000000000000000000001", + fn conn -> + "starttime=" <> from_str = conn.query_string + {from_int, "&endtime=" <> to_str} = Integer.parse(from_str) + {to_int, ""} = Integer.parse(to_str) + assert to_int - from_int == previous_days * 24 * 60 * 60 + Conn.resp(conn, 200, @secondary_coin_history_json) + end + ) + + assert {:ok, + [ + %{ + closing_price: Decimal.new("1.0014961501515858"), + date: ~D[2025-11-22], + opening_price: Decimal.new("0.9998295379999973"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("1.0003179715114685"), + date: ~D[2025-11-23], + opening_price: Decimal.new("0.9998108086782626"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("0.9999003120252966"), + date: ~D[2025-11-24], + opening_price: Decimal.new("1.0000636244141254"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("0.9997550066438543"), + date: ~D[2025-11-25], + opening_price: Decimal.new("1.0006631013107907"), + secondary_coin: true + }, + %{ + closing_price: Decimal.new("0.9997050546597661"), + date: ~D[2025-11-26], + opening_price: Decimal.new("0.9998251538809131"), + secondary_coin: true + } + ]} == + DIA.fetch_secondary_coin_price_history(previous_days) + end + end + + describe "market_cap_history_fetching_enabled?" do + test "ignored" do + assert DIA.market_cap_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_market_cap_history" do + test "ignored" do + assert DIA.fetch_market_cap_history(0) == :ignore + end + end + + describe "tvl_history_fetching_enabled?" do + test "ignored" do + assert DIA.tvl_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_tvl_history" do + test "ignored" do + assert DIA.fetch_tvl_history(0) == :ignore + end + end + + # cspell:disable + + defp json_coin(coin_address_hash, fiat_value, symbol, name) do + """ + { + "Symbol": "#{symbol}", + "Name": "#{name}", + "Address": "#{coin_address_hash}", + "Blockchain": "Ethereum", + "Price": #{fiat_value}, + "PriceYesterday": 2935.2004802640054, + "VolumeYesterdayUSD": 6577658642.868258, + "Time": "2025-11-27T08:09:59Z", + "Source": "diadata.org", + "Signature": "0x5b080262b6ee4f5303251ef65dc81b61dc6e5ceb6e2ff0d9ca8a6c9175c0bef472bbadfe4a3a339134ebc094b4336fb6ad0e50bac3622968bb03819789b6dbea01" + } + """ + end + + defp json_tokens_list do + """ + [ + { + "Asset": { + "Symbol": "ETH", + "Name": "Ether", + "Address": "0x0000000000000000000000000000000000000000", + "Decimals": 18, + "Blockchain": "Ethereum" + }, + "Volume": 6577658642.868258, + "VolumeUSD": 0, + "Index": 0 + }, + { + "Asset": { + "Symbol": "USDC", + "Name": "USD Coin", + "Address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Decimals": 6, + "Blockchain": "Ethereum" + }, + "Volume": 2756384237.21397, + "VolumeUSD": 0, + "Index": 0 + }, + { + "Asset": { + "Symbol": "USDT", + "Name": "Tether USD", + "Address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "Decimals": 6, + "Blockchain": "Ethereum" + }, + "Volume": 504424765.759824, + "VolumeUSD": 0, + "Index": 0 + } + ] + """ + end + + # cspell:enable +end diff --git a/apps/explorer/test/explorer/market/source/mobula_test.exs b/apps/explorer/test/explorer/market/source/mobula_test.exs new file mode 100644 index 000000000000..4c90396c0970 --- /dev/null +++ b/apps/explorer/test/explorer/market/source/mobula_test.exs @@ -0,0 +1,406 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +# cspell:disable +defmodule Explorer.Market.Source.MobulaTest do + use ExUnit.Case + + alias Explorer.Market.Source.Mobula + alias Plug.Conn + + setup do + bypass = Bypass.open() + + mobula_configuration = Application.get_env(:explorer, Mobula) + source_configuration = Application.get_env(:explorer, Explorer.Market.Source) + + Application.put_env( + :explorer, + Explorer.Market.Source, + Keyword.merge(source_configuration || [], + native_coin_source: Mobula, + secondary_coin_source: Mobula, + tokens_source: Mobula, + native_coin_history_source: Mobula, + secondary_coin_history_source: Mobula, + market_cap_history_source: Mobula, + tvl_history_source: Mobula + ) + ) + + Application.put_env( + :explorer, + Mobula, + Keyword.merge(mobula_configuration || [], + base_url: "http://localhost:#{bypass.port}", + coin_id: "native_coin", + secondary_coin_id: "secondary_coin", + platform: "test_platform" + ) + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Market.Source, source_configuration) + Application.put_env(:explorer, Mobula, mobula_configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + {:ok, bypass: bypass} + end + + describe "native_coin_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert Mobula.native_coin_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, coin_id: nil)) + + refute Mobula.native_coin_fetching_enabled?() + end + end + + describe "fetch_native_coin/0" do + test "fetches native coin", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/market/data", fn conn -> + assert conn.query_string == "asset=native_coin" + Conn.resp(conn, 200, json_market_data("native_coin", "2500.5")) + end) + + assert {:ok, + %Explorer.Market.Token{ + available_supply: Decimal.new("120000000"), + total_supply: Decimal.new("150000000"), + btc_value: nil, + last_updated: nil, + market_cap: Decimal.new("300000000000"), + tvl: nil, + name: "Ethereum", + symbol: "ETH", + fiat_value: Decimal.new("2500.5"), + volume_24h: Decimal.new("15000000000"), + image_url: "https://example.com/eth.png", + circulating_supply: Decimal.new("120000000") + }} == Mobula.fetch_native_coin() + end + + test "returns error when coin_id not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, coin_id: nil)) + + assert {:error, "Coin ID not specified"} == Mobula.fetch_native_coin() + end + end + + describe "secondary_coin_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert Mobula.secondary_coin_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, secondary_coin_id: nil)) + + refute Mobula.secondary_coin_fetching_enabled?() + end + end + + describe "fetch_secondary_coin/0" do + test "fetches secondary coin", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/market/data", fn conn -> + assert conn.query_string == "asset=secondary_coin" + Conn.resp(conn, 200, json_market_data("secondary_coin", "95000.0")) + end) + + assert {:ok, + %Explorer.Market.Token{ + available_supply: Decimal.new("120000000"), + total_supply: Decimal.new("150000000"), + btc_value: nil, + last_updated: nil, + market_cap: Decimal.new("300000000000"), + tvl: nil, + name: "Ethereum", + symbol: "ETH", + fiat_value: Decimal.new("9.5E+4"), + volume_24h: Decimal.new("15000000000"), + image_url: "https://example.com/eth.png", + circulating_supply: Decimal.new("120000000") + }} == Mobula.fetch_secondary_coin() + end + + test "returns error when secondary_coin_id not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, secondary_coin_id: nil)) + + assert {:error, "Secondary coin ID not specified"} == Mobula.fetch_secondary_coin() + end + end + + describe "tokens_fetching_enabled?" do + test "returns true if platform is configured" do + assert Mobula.tokens_fetching_enabled?() + end + + test "returns false if platform is not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, platform: nil)) + + refute Mobula.tokens_fetching_enabled?() + end + end + + describe "fetch_tokens/2" do + test "fetches tokens with nil state", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/market/query", fn conn -> + assert conn.query_string == "sortBy=market_cap&blockchain=test_platform&limit=5&offset=0" + Conn.resp(conn, 200, json_market_query()) + end) + + assert {:ok, nil, true, + [ + %{ + name: "Token B", + symbol: "TKB", + type: "ERC-20", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2>> + }, + fiat_value: Decimal.new("2.0"), + volume_24h: Decimal.new("200000"), + circulating_market_cap: Decimal.new("2000000"), + circulating_supply: Decimal.new("1000000"), + icon_url: "https://example.com/tkb.png" + }, + %{ + name: "Token A", + symbol: "TKA", + type: "ERC-20", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>> + }, + fiat_value: Decimal.new("1.5"), + volume_24h: Decimal.new("100000"), + circulating_market_cap: Decimal.new("1500000"), + circulating_supply: Decimal.new("1000000"), + icon_url: "https://example.com/tka.png" + } + ]} == Mobula.fetch_tokens(nil, 5) + end + + test "paginates with batch size", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/market/query", fn conn -> + assert conn.query_string == "sortBy=market_cap&blockchain=test_platform&limit=1&offset=0" + Conn.resp(conn, 200, json_market_query_single()) + end) + + assert {:ok, 1, false, + [ + %{ + name: "Token A", + symbol: "TKA", + type: "ERC-20", + contract_address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>> + }, + fiat_value: Decimal.new("1.5"), + volume_24h: Decimal.new("100000"), + circulating_market_cap: Decimal.new("1500000"), + circulating_supply: Decimal.new("1000000"), + icon_url: "https://example.com/tka.png" + } + ]} == Mobula.fetch_tokens(nil, 1) + end + end + + describe "native_coin_price_history_fetching_enabled?" do + test "returns true if coin_id is configured" do + assert Mobula.native_coin_price_history_fetching_enabled?() + end + + test "returns false if coin_id is not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, coin_id: nil)) + + refute Mobula.native_coin_price_history_fetching_enabled?() + end + end + + describe "fetch_native_coin_price_history/1" do + test "fetches native coin price history", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/market/history", fn conn -> + assert conn.query_string =~ "asset=native_coin&from=" + Conn.resp(conn, 200, json_market_history()) + end) + + assert {:ok, + [ + %{ + date: ~D[2025-02-12], + opening_price: Decimal.new("2.5E+3"), + closing_price: Decimal.new("2.5E+3"), + secondary_coin: false + }, + %{ + date: ~D[2025-02-13], + opening_price: Decimal.new("2.6E+3"), + closing_price: Decimal.new("2.6E+3"), + secondary_coin: false + }, + %{ + date: ~D[2025-02-14], + opening_price: Decimal.new("2.7E+3"), + closing_price: Decimal.new("2.7E+3"), + secondary_coin: false + } + ]} == Mobula.fetch_native_coin_price_history(3) + end + end + + describe "secondary_coin_price_history_fetching_enabled?" do + test "returns true if secondary_coin_id is configured" do + assert Mobula.secondary_coin_price_history_fetching_enabled?() + end + + test "returns false if secondary_coin_id is not configured" do + config = Application.get_env(:explorer, Mobula) + Application.put_env(:explorer, Mobula, Keyword.merge(config, secondary_coin_id: nil)) + + refute Mobula.secondary_coin_price_history_fetching_enabled?() + end + end + + describe "fetch_secondary_coin_price_history/1" do + test "fetches secondary coin price history", %{bypass: bypass} do + Bypass.expect_once(bypass, "GET", "/market/history", fn conn -> + assert conn.query_string =~ "asset=secondary_coin&from=" + Conn.resp(conn, 200, json_market_history()) + end) + + assert {:ok, + [ + %{ + date: ~D[2025-02-12], + opening_price: Decimal.new("2.5E+3"), + closing_price: Decimal.new("2.5E+3"), + secondary_coin: true + }, + %{ + date: ~D[2025-02-13], + opening_price: Decimal.new("2.6E+3"), + closing_price: Decimal.new("2.6E+3"), + secondary_coin: true + }, + %{ + date: ~D[2025-02-14], + opening_price: Decimal.new("2.7E+3"), + closing_price: Decimal.new("2.7E+3"), + secondary_coin: true + } + ]} == Mobula.fetch_secondary_coin_price_history(3) + end + end + + describe "market_cap_history_fetching_enabled?" do + test "ignored" do + assert Mobula.market_cap_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_market_cap_history/1" do + test "ignored" do + assert Mobula.fetch_market_cap_history(3) == :ignore + end + end + + describe "tvl_history_fetching_enabled?" do + test "ignored" do + assert Mobula.tvl_history_fetching_enabled?() == :ignore + end + end + + describe "fetch_tvl_history/1" do + test "ignored" do + assert Mobula.fetch_tvl_history(3) == :ignore + end + end + + defp json_market_data(_coin_id, price) do + """ + { + "data": { + "name": "Ethereum", + "symbol": "ETH", + "price": #{price}, + "market_cap": 300000000000, + "circulating_supply": 120000000, + "total_supply": 150000000, + "off_chain_volume": 15000000000, + "logo": "https://example.com/eth.png" + } + } + """ + end + + defp json_market_query do + """ + [ + { + "name": "Token A", + "symbol": "TKA", + "price": 1.5, + "market_cap": 1500000, + "circulating_supply": 1000000, + "off_chain_volume": 100000, + "logo": "https://example.com/tka.png", + "contracts": [{"address": "0x0000000000000000000000000000000000000001"}] + }, + { + "name": "Token B", + "symbol": "TKB", + "price": 2.0, + "market_cap": 2000000, + "circulating_supply": 1000000, + "off_chain_volume": 200000, + "logo": "https://example.com/tkb.png", + "contracts": [{"address": "0x0000000000000000000000000000000000000002"}] + } + ] + """ + end + + defp json_market_query_single do + """ + [ + { + "name": "Token A", + "symbol": "TKA", + "price": 1.5, + "market_cap": 1500000, + "circulating_supply": 1000000, + "off_chain_volume": 100000, + "logo": "https://example.com/tka.png", + "contracts": [{"address": "0x0000000000000000000000000000000000000001"}] + } + ] + """ + end + + defp json_market_history do + """ + { + "data": { + "price_history": [ + [1739318400000, 2500.0], + [1739404800000, 2600.0], + [1739491200000, 2700.0] + ] + } + } + """ + end +end diff --git a/apps/explorer/test/explorer/market/source_test.exs b/apps/explorer/test/explorer/market/source_test.exs new file mode 100644 index 000000000000..2d81e315b677 --- /dev/null +++ b/apps/explorer/test/explorer/market/source_test.exs @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.SourceTest do + use ExUnit.Case, async: true + + alias Explorer.Market.Source + + describe "zero_or_nil?/1" do + test "returns true for nil" do + assert Source.zero_or_nil?(nil) + end + + test "returns true for Decimal zero" do + assert Source.zero_or_nil?(Decimal.new(0)) + end + + test "returns true for Decimal zero with different representations" do + assert Source.zero_or_nil?(Decimal.new("0.0")) + assert Source.zero_or_nil?(Decimal.new("0.00")) + end + + test "returns false for positive Decimal" do + refute Source.zero_or_nil?(Decimal.new("1.5")) + end + + test "returns false for negative Decimal" do + refute Source.zero_or_nil?(Decimal.new("-1.5")) + end + end + + describe "to_decimal/1" do + test "returns nil for nil" do + assert Source.to_decimal(nil) == nil + end + + test "returns Decimal as-is" do + decimal = Decimal.new("1.23") + assert Source.to_decimal(decimal) == decimal + end + + test "converts float to Decimal" do + assert Source.to_decimal(3.14) == Decimal.from_float(3.14) + end + + test "converts integer to Decimal" do + assert Source.to_decimal(42) == Decimal.new(42) + end + + test "converts string to Decimal" do + assert Source.to_decimal("123.45") == Decimal.new("123.45") + end + + test "converts zero values" do + assert Source.to_decimal(0) == Decimal.new(0) + assert Source.to_decimal(0.0) == Decimal.from_float(0.0) + assert Source.to_decimal("0") == Decimal.new("0") + end + end + + describe "maybe_get_date/1" do + test "returns nil for nil" do + assert Source.maybe_get_date(nil) == nil + end + + test "parses valid ISO8601 date" do + assert Source.maybe_get_date("2025-02-14T05:40:07.774Z") == ~U[2025-02-14 05:40:07.774Z] + end + + test "returns nil for invalid date string" do + assert Source.maybe_get_date("not-a-date") == nil + end + + test "returns nil for empty string" do + assert Source.maybe_get_date("") == nil + end + end + + describe "handle_image_url/1" do + test "returns nil for nil" do + assert Source.handle_image_url(nil) == nil + end + + test "returns valid URL" do + url = "https://example.com/image.png" + assert Source.handle_image_url(url) == url + end + + test "returns nil for invalid URL without host" do + assert Source.handle_image_url("not-a-url") == nil + end + end + + describe "secondary_coin_string/1" do + test "returns 'Secondary coin' when true" do + assert Source.secondary_coin_string(true) == "Secondary coin" + end + + test "returns 'Coin' when false" do + assert Source.secondary_coin_string(false) == "Coin" + end + end + + describe "unexpected_response_error/2" do + test "formats error message with source and response" do + result = Source.unexpected_response_error("CoinGecko", %{"error" => "bad request"}) + assert result =~ "CoinGecko" + assert result =~ "bad request" + end + end +end diff --git a/apps/explorer/test/explorer/market/token_test.exs b/apps/explorer/test/explorer/market/token_test.exs new file mode 100644 index 000000000000..0bc1392aae43 --- /dev/null +++ b/apps/explorer/test/explorer/market/token_test.exs @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Market.TokenTest do + use ExUnit.Case, async: true + + alias Explorer.Market.Token + + describe "null/0" do + test "returns a Token struct with all nil fields" do + token = Token.null() + assert %Token{} = token + assert token.available_supply == nil + assert token.total_supply == nil + assert token.btc_value == nil + assert token.last_updated == nil + assert token.market_cap == nil + assert token.tvl == nil + assert token.name == nil + assert token.symbol == nil + assert token.fiat_value == nil + assert token.volume_24h == nil + assert token.circulating_supply == nil + assert token.image_url == nil + end + end + + describe "null?/1" do + test "returns true for null token" do + assert Token.null?(Token.null()) + end + + test "returns false for non-null token" do + token = %Token{ + available_supply: Decimal.new("100"), + total_supply: Decimal.new("100"), + btc_value: Decimal.new("1"), + last_updated: ~U[2025-01-01 00:00:00Z], + market_cap: Decimal.new("1000"), + tvl: nil, + name: "Test", + symbol: "TST", + fiat_value: Decimal.new("10"), + volume_24h: Decimal.new("500"), + image_url: "https://example.com/test.png", + circulating_supply: nil + } + + refute Token.null?(token) + end + + test "returns false for token with single non-nil field" do + token = %{Token.null() | name: "Test"} + refute Token.null?(token) + end + end +end diff --git a/apps/explorer/test/explorer/microservice_interfaces/bens_test.exs b/apps/explorer/test/explorer/microservice_interfaces/bens_test.exs new file mode 100644 index 000000000000..48605bd57300 --- /dev/null +++ b/apps/explorer/test/explorer/microservice_interfaces/bens_test.exs @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.MicroserviceInterfaces.BENSTest do + use ExUnit.Case, async: false + + alias Explorer.MicroserviceInterfaces.BENS + + setup do + old_bens_env = Application.get_env(:explorer, BENS, []) + + on_exit(fn -> + Application.put_env(:explorer, BENS, old_bens_env) + end) + + :ok + end + + describe "maybe_preload_ens_for_blocks/1" do + test "returns input as-is when blocks BENS preload is disabled" do + Application.put_env( + :explorer, + BENS, + Keyword.put(Application.get_env(:explorer, BENS, []), :disable_blocks_bens_preload, true) + ) + + blocks = [%{number: 1}, %{number: 2}] + + assert BENS.maybe_preload_ens_for_blocks(blocks) == blocks + end + end + + describe "maybe_preload_ens_for_transactions/1" do + test "returns input as-is when transactions BENS preload is disabled" do + Application.put_env( + :explorer, + BENS, + Keyword.put(Application.get_env(:explorer, BENS, []), :disable_transactions_bens_preload, true) + ) + + transactions = [%{hash: "0x1"}, %{hash: "0x2"}] + + assert BENS.maybe_preload_ens_for_transactions(transactions) == transactions + end + end + + describe "maybe_preload_ens_for_token_transfers/1" do + test "returns input as-is when token transfers BENS preload is disabled" do + Application.put_env( + :explorer, + BENS, + Keyword.put(Application.get_env(:explorer, BENS, []), :disable_token_transfers_bens_preload, true) + ) + + token_transfers = [%{token_id: "1"}, %{token_id: "2"}] + + assert BENS.maybe_preload_ens_for_token_transfers(token_transfers) == token_transfers + end + end +end diff --git a/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs b/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs new file mode 100644 index 000000000000..1ce2643cbe6f --- /dev/null +++ b/apps/explorer/test/explorer/microservice_interfaces/multichain_search_test.exs @@ -0,0 +1,1374 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.MicroserviceInterfaces.MultichainSearchTest do + use ExUnit.Case + use Explorer.DataCase + import Mox + + alias Explorer.Chain.Cache.ChainId + alias Explorer.Chain.MultichainSearchDb.{MainExportQueue, TokenInfoExportQueue} + alias Explorer.Chain.{Hash, Token, Wei} + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.{Repo, TestHelper} + alias Plug.Conn + + setup :verify_on_exit! + + describe "batch_import/1" do + setup do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + :ok + end + + test "returns {:ok, :service_disabled} when the service is disabled" do + params = %{ + addresses: [], + blocks: [], + transactions: [], + address_current_token_balances: [] + } + + assert MultichainSearch.batch_import(params) == {:ok, :service_disabled} + end + + test "processes chunks and returns {:ok, result} when the service is enabled" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{"status" => "ok"}) + ) + end) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + + block_1 = insert(:block) + block_2 = insert(:block) + + transaction_1 = insert(:transaction) + transaction_2 = insert(:transaction) + + address_1 = insert(:address) + address_2 = insert(:address) + + params = %{ + addresses: [address_1, address_2], + blocks: [block_1, block_2], + transactions: [transaction_1, transaction_2], + address_current_token_balances: [] + } + + assert {:ok, {:chunks_processed, _}} = MultichainSearch.batch_import(params) + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + end + + test "returns {:error, data_to_retry} when an error occurs during processing and 'multichain_search_db_main_export_queue' table is populated" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + + address_1 = insert(:address) + address_2 = insert(:address) + block_1 = insert(:block) + block_2 = insert(:block) + transaction_1 = insert(:transaction) |> with_block(block_1) + transaction_2 = insert(:transaction) |> with_block(block_2) + + params = %{ + addresses: [address_1, address_2], + blocks: [block_1, block_2], + transactions: [transaction_1, transaction_2], + address_current_token_balances: [] + } + + assert {:error, + %{ + addresses: [ + address_export_data(address_1), + address_export_data(address_2) + ], + block_ranges: [ + %{max_block_number: to_string(block_2.number), min_block_number: to_string(block_1.number)} + ], + hashes: [ + block_export_data(block_1), + block_export_data(block_2), + transaction_export_data(transaction_1), + transaction_export_data(transaction_2) + ], + address_coin_balances: [], + address_token_balances: [] + }} == MultichainSearch.batch_import(params) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 6 + records = Repo.all(MainExportQueue) + + assert Enum.all?(records, fn record -> + (record.hash == address_1.hash.bytes && record.hash_type == :address) || + (record.hash == address_2.hash.bytes && record.hash_type == :address) || + (record.hash == block_1.hash.bytes && record.hash_type == :block) || + (record.hash == block_2.hash.bytes && record.hash_type == :block) || + (record.hash == transaction_1.hash.bytes && record.hash_type == :transaction) || + (record.hash == transaction_2.hash.bytes && record.hash_type == :transaction) + end) + end + + test "returns {:error, data_to_retry} when at least one chunk is failed" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + end) + + TestHelper.get_chain_id_mock() + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + + block_1 = insert(:block) + block_2 = insert(:block) + + transaction_1 = insert(:transaction) + transaction_2 = insert(:transaction) + + # 7002 addresses (7000 in the first chunk and 2 in the second) + addresses = + for _ <- 0..7001 do + insert(:address) + end + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(%{"status" => "ok"}) + }} + end + ) + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", headers: [{"Content-Type", "application/json"}]}, + _opts -> + {:ok, + %Tesla.Env{ + status: 500, + body: Jason.encode!(%{"code" => 0, "message" => "Error"}) + }} + end + ) + + params = %{ + addresses: addresses, + blocks: [block_1, block_2], + transactions: [transaction_1, transaction_2], + address_current_token_balances: [] + } + + assert {:error, results} = MultichainSearch.batch_import(params) + assert Enum.count(results.addresses) == 7000 + assert Enum.count(results.block_ranges) == 1 + assert Enum.count(results.hashes) == 4 + # 7000 addresses + 2 blocks + 2 transactions + assert Repo.aggregate(MainExportQueue, :count, :hash) == 7004 + end + + test "returns {:error, data_to_retry} when an error occurs in all chunks during processing and 'multichain_search_db_main_export_queue' table is populated with all the input data" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 2 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + + addresses = 10 |> insert_list(:address) + + params = %{ + addresses: addresses, + blocks: [], + transactions: [], + address_current_token_balances: [] + } + + assert {:error, results} = MultichainSearch.batch_import(params) + assert Enum.count(results.addresses) == 10 + assert Enum.count(results.block_ranges) == 0 + assert Enum.count(results.hashes) == 0 + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 10 + end + + test "returns {:error, data_to_retry} when an error occurs in all chunks (and number of chunks more than @max_concurrency) during processing and 'multichain_search_db_main_export_queue' table is populated with all the input data" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 2 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 0 + + addresses = 15 |> insert_list(:address) + + params = %{ + addresses: addresses, + blocks: [], + transactions: [], + address_current_token_balances: [] + } + + assert {:error, results} = MultichainSearch.batch_import(params) + assert Enum.count(results.addresses) == 15 + assert Enum.count(results.block_ranges) == 0 + assert Enum.count(results.hashes) == 0 + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 15 + end + end + + describe "batch_export_token_info/1" do + setup do + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + :ok + end + + test "returns {:ok, :service_disabled} when the service is disabled" do + items_from_db_queue = [insert(:multichain_search_db_export_token_info_queue)] + assert MultichainSearch.batch_export_token_info(items_from_db_queue) == {:ok, :service_disabled} + end + + test "processes chunks and returns {:ok, {:chunks_processed, _}} when the service is enabled" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{"status" => "ok"}) + ) + end) + + token_info_item_1 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_2 = insert(:multichain_search_db_export_token_info_queue) + items_from_db_queue = [token_info_item_1, token_info_item_2] + + items_from_db_queue + |> Enum.each(fn item -> + item + |> TokenInfoExportQueue.delete_query() + |> Repo.delete_all() + end) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + assert {:ok, {:chunks_processed, _}} = MultichainSearch.batch_export_token_info(items_from_db_queue) + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + end + + test "returns {:error, data_to_retry} when an error occurs during processing and 'multichain_search_db_export_token_info_queue' table is populated" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + token_info_item_1 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_2 = insert(:multichain_search_db_export_token_info_queue) + items_from_db_queue = [token_info_item_1, token_info_item_2] + + items_from_db_queue + |> Enum.each(fn item -> + item + |> TokenInfoExportQueue.delete_query() + |> Repo.delete_all() + end) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + + assert {:error, + %{ + tokens: [ + MultichainSearch.token_info_queue_item_to_http_item(token_info_item_1), + MultichainSearch.token_info_queue_item_to_http_item(token_info_item_2) + ] + }} == MultichainSearch.batch_export_token_info(items_from_db_queue) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 2 + records = Repo.all(TokenInfoExportQueue) + + assert Enum.all?(records, fn record -> + (record.address_hash == token_info_item_1.address_hash && record.data_type == token_info_item_1.data_type) || + (record.address_hash == token_info_item_2.address_hash && + record.data_type == token_info_item_2.data_type) + end) + end + + test "returns {:error, data_to_retry} when an error occurs during processing and retries_number is increased" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + token_info_item_1 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_2 = insert(:multichain_search_db_export_token_info_queue) + items_from_db_queue = [token_info_item_1, token_info_item_2] + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 2 + assert is_nil(token_info_item_1.retries_number) + assert is_nil(token_info_item_2.retries_number) + + assert {:error, + %{ + tokens: [ + MultichainSearch.token_info_queue_item_to_http_item(token_info_item_1), + MultichainSearch.token_info_queue_item_to_http_item(token_info_item_2) + ] + }} == MultichainSearch.batch_export_token_info(items_from_db_queue) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 2 + records = Repo.all(TokenInfoExportQueue) + + assert Enum.all?(records, fn record -> + (record.address_hash == token_info_item_1.address_hash && record.data_type == token_info_item_1.data_type && + record.retries_number == 1) || + (record.address_hash == token_info_item_2.address_hash && + record.data_type == token_info_item_2.data_type && record.retries_number == 1) + end) + end + + test "returns {:error, data_to_retry} when at least one chunk is failed" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + TestHelper.get_chain_id_mock() + + # 1002 addresses (1000 in the first chunk and 2 in the second) + items_from_db_queue = + for _ <- 0..1001 do + insert(:multichain_search_db_export_token_info_queue) + end + + items_from_db_queue + |> Enum.each(fn item -> + item + |> TokenInfoExportQueue.delete_query() + |> Repo.delete_all() + end) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(%{"status" => "ok"}) + }} + end + ) + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", headers: [{"Content-Type", "application/json"}]}, + _opts -> + {:ok, + %Tesla.Env{ + status: 500, + body: Jason.encode!(%{"code" => 0, "message" => "Error"}) + }} + end + ) + + assert {:error, results} = MultichainSearch.batch_export_token_info(items_from_db_queue) + assert Enum.count(results.tokens) == 1000 + assert Repo.aggregate(TokenInfoExportQueue, :count) == 1000 + end + + test "returns {:error, data_to_retry} when an error occurs in all chunks during processing and 'multichain_search_db_export_token_info_queue' table is populated with all the input data" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + token_info_chunk_size: 2 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + items_from_db_queue = 10 |> insert_list(:multichain_search_db_export_token_info_queue) + + items_from_db_queue + |> Enum.each(fn item -> + item + |> TokenInfoExportQueue.delete_query() + |> Repo.delete_all() + end) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + assert {:error, results} = MultichainSearch.batch_export_token_info(items_from_db_queue) + assert Enum.count(results.tokens) == 10 + assert Repo.aggregate(TokenInfoExportQueue, :count) == 10 + end + + test "returns {:error, data_to_retry} when an error occurs in all chunks (and number of chunks more than @max_concurrency) during processing and 'multichain_search_db_export_token_info_queue' table is populated" do + bypass = Bypass.open() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + token_info_chunk_size: 2 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + Bypass.down(bypass) + end) + + TestHelper.get_chain_id_mock() + + Bypass.expect(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 500, + Jason.encode!(%{"code" => 0, "message" => "Error"}) + ) + end) + + items_from_db_queue = 15 |> insert_list(:multichain_search_db_export_token_info_queue) + + items_from_db_queue + |> Enum.each(fn item -> + item + |> TokenInfoExportQueue.delete_query() + |> Repo.delete_all() + end) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + assert {:error, results} = MultichainSearch.batch_export_token_info(items_from_db_queue) + assert Enum.count(results.tokens) == 15 + assert Repo.aggregate(TokenInfoExportQueue, :count) == 15 + end + end + + describe "token_info_queue_item_to_http_item/1" do + test "returns correct map to send to multichain service" do + address_hash_string = "0x000102030405060708090a0b0c0d0e0f10111213" + address_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111213", case: :mixed) + + assert MultichainSearch.token_info_queue_item_to_http_item(%{ + address_hash: address_hash_binary, + data_type: :metadata, + data: %{token_type: "ERC-20", name: "TestToken", symbol: "TEST", decimals: 18, total_supply: "1000"} + }) == %{ + address_hash: address_hash_string, + metadata: %{token_type: "ERC-20", name: "TestToken", symbol: "TEST", decimals: 18, total_supply: "1000"} + } + + assert MultichainSearch.token_info_queue_item_to_http_item(%{ + address_hash: address_hash_binary, + data_type: :total_supply, + data: %{total_supply: "1000"} + }) == %{ + address_hash: address_hash_string, + metadata: %{total_supply: "1000"} + } + + assert MultichainSearch.token_info_queue_item_to_http_item(%{ + address_hash: address_hash_binary, + data_type: :counters, + data: %{holders_count: "123", transfers_count: "456"} + }) == %{ + address_hash: address_hash_string, + counters: %{holders_count: "123", transfers_count: "456"} + } + + assert MultichainSearch.token_info_queue_item_to_http_item(%{ + address_hash: address_hash_binary, + data_type: :market_data, + data: %{fiat_value: "123.456", circulating_market_cap: "1000.0001"} + }) == %{ + address_hash: address_hash_string, + price_data: %{fiat_value: "123.456", circulating_market_cap: "1000.0001"} + } + end + end + + describe "token_info_http_item_to_queue_item/1" do + test "returns correct map to add to queue" do + address_hash_string = "0x000102030405060708090a0b0c0d0e0f10111213" + {:ok, address_hash} = Hash.Address.cast(address_hash_string) + + assert MultichainSearch.token_info_http_item_to_queue_item(%{ + address_hash: address_hash_string, + metadata: %{token_type: "ERC-20", name: "TestToken", symbol: "TEST", decimals: 18, total_supply: "1000"} + }) == %{ + address_hash: address_hash, + data_type: :metadata, + data: %{token_type: "ERC-20", name: "TestToken", symbol: "TEST", decimals: 18, total_supply: "1000"} + } + + assert MultichainSearch.token_info_http_item_to_queue_item(%{ + address_hash: address_hash_string, + metadata: %{token_type: "ERC-20"} + }) == %{ + address_hash: address_hash, + data_type: :metadata, + data: %{token_type: "ERC-20"} + } + + assert MultichainSearch.token_info_http_item_to_queue_item(%{ + address_hash: address_hash_string, + metadata: %{total_supply: "1000"} + }) == %{ + address_hash: address_hash, + data_type: :total_supply, + data: %{total_supply: "1000"} + } + + assert MultichainSearch.token_info_http_item_to_queue_item(%{ + address_hash: address_hash_string, + counters: %{holders_count: "123", transfers_count: "456"} + }) == %{ + address_hash: address_hash, + data_type: :counters, + data: %{holders_count: "123", transfers_count: "456"} + } + + assert MultichainSearch.token_info_http_item_to_queue_item(%{ + address_hash: address_hash_string, + price_data: %{fiat_value: "123.456", circulating_market_cap: "1000.0001"} + }) == %{ + address_hash: address_hash, + data_type: :market_data, + data: %{fiat_value: "123.456", circulating_market_cap: "1000.0001"} + } + end + end + + describe "prepare_token_metadata_for_queue/2" do + test "returns an empty map when the service is disabled" do + assert MultichainSearch.prepare_token_metadata_for_queue(%Token{type: "ERC-20"}, %{name: "TestToken"}) == %{} + end + + test "returns correct map to add to queue" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{ + type: "ERC-20", + icon_url: "http://localhost:1235/test.png", + name: "TestToken", + symbol: "TST" + }, + %{ + name: "TestToken2", + symbol: "TST2", + decimals: 18, + total_supply: 123 + } + ) == %{ + token_type: "ERC-20", + icon_url: "http://localhost:1235/test.png", + name: "TestToken2", + symbol: "TST2", + decimals: 18, + total_supply: "123" + } + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{ + type: "ERC-20", + name: "TestToken", + symbol: "TST" + }, + %{ + name: "TestToken2", + symbol: "TST2", + decimals: 18 + } + ) == %{ + token_type: "ERC-20", + name: "TestToken2", + symbol: "TST2", + decimals: 18 + } + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{ + type: "ERC-1155", + name: "TestToken", + symbol: "TST" + }, + %{ + name: "TestToken2", + symbol: "TST2" + } + ) == %{ + token_type: "ERC-1155", + name: "TestToken2", + symbol: "TST2" + } + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{ + type: "ERC-1155", + name: "TestToken", + symbol: "TST" + }, + %{ + name: "TestToken2" + } + ) == %{ + token_type: "ERC-1155", + name: "TestToken2" + } + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{ + type: "ERC-1155", + name: "TestToken", + symbol: "TST" + }, + %{} + ) == %{ + token_type: "ERC-1155" + } + + assert MultichainSearch.prepare_token_metadata_for_queue( + %Token{type: "ERC-1155"}, + %{} + ) == %{ + token_type: "ERC-1155" + } + end + end + + describe "prepare_token_total_supply_for_queue/1" do + test "returns nil when the service is disabled" do + assert is_nil(MultichainSearch.prepare_token_total_supply_for_queue(1000)) + end + + test "returns correct map to add to queue" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + assert MultichainSearch.prepare_token_total_supply_for_queue(1000) == %{total_supply: "1000"} + end + end + + describe "prepare_token_market_data_for_queue/1" do + test "returns an empty map when the service is disabled" do + assert MultichainSearch.prepare_token_market_data_for_queue(%{ + fiat_value: Decimal.new("100.5"), + circulating_market_cap: Decimal.new("2000.28") + }) == %{} + end + + test "returns correct map to add to queue" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + assert MultichainSearch.prepare_token_market_data_for_queue(%{ + fiat_value: Decimal.new("100.5"), + circulating_market_cap: Decimal.new("2000.28") + }) == %{fiat_value: "100.5", circulating_market_cap: "2000.28"} + + assert MultichainSearch.prepare_token_market_data_for_queue(%{ + fiat_value: Decimal.new("100.5"), + circulating_market_cap: Decimal.new("2000.28"), + name: "TestToken" + }) == %{fiat_value: "100.5", circulating_market_cap: "2000.28"} + + assert MultichainSearch.prepare_token_market_data_for_queue(%{fiat_value: Decimal.new("100.5")}) == %{ + fiat_value: "100.5" + } + + assert MultichainSearch.prepare_token_market_data_for_queue(%{circulating_market_cap: Decimal.new("2000.28")}) == + %{circulating_market_cap: "2000.28"} + + assert MultichainSearch.prepare_token_market_data_for_queue(%{name: "TestToken"}) == %{} + assert MultichainSearch.prepare_token_market_data_for_queue(%{}) == %{} + end + end + + describe "prepare_token_counters_for_queue/2" do + test "returns an empty map when the service is disabled" do + assert MultichainSearch.prepare_token_counters_for_queue(456, 123) == %{} + end + + test "returns correct map to add to queue" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + assert MultichainSearch.prepare_token_counters_for_queue(456, 123) == %{ + transfers_count: "456", + holders_count: "123" + } + + assert MultichainSearch.prepare_token_counters_for_queue(0, 0) == %{transfers_count: "0", holders_count: "0"} + end + end + + describe "send_token_info_to_queue/2" do + test "does nothing and returns :ignore when the service is disabled" do + address_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111213", case: :mixed) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + + assert MultichainSearch.send_token_info_to_queue(%{address_hash_binary => %{total_supply: "123"}}, :total_supply) == + :ignore + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + end + + test "adds an item to db queue and returns :ok" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + address_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111213", case: :mixed) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + + assert MultichainSearch.send_token_info_to_queue(%{address_hash_binary => %{total_supply: "123"}}, :total_supply) == + :ok + + [record] = Repo.all(TokenInfoExportQueue) + {:ok, address_hash} = Hash.Address.cast(address_hash_binary) + + assert record.address_hash == address_hash && record.data_type == :total_supply && + record.data == %{"total_supply" => "123"} + end + + test "adds all items to db queue" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 2 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + address1_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111213", case: :mixed) + address2_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111214", case: :mixed) + address3_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111215", case: :mixed) + address4_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111216", case: :mixed) + + entries = %{ + address1_hash_binary => %{total_supply: "123"}, + address2_hash_binary => %{total_supply: "124"}, + address3_hash_binary => %{total_supply: "125"}, + address4_hash_binary => %{total_supply: "126"} + } + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + assert MultichainSearch.send_token_info_to_queue(entries, :total_supply) == :ok + + records = Repo.all(TokenInfoExportQueue) + {:ok, address1_hash} = Hash.Address.cast(address1_hash_binary) + {:ok, address2_hash} = Hash.Address.cast(address2_hash_binary) + {:ok, address3_hash} = Hash.Address.cast(address3_hash_binary) + {:ok, address4_hash} = Hash.Address.cast(address4_hash_binary) + + assert Enum.all?(records, fn record -> + (record.address_hash == address1_hash && record.data_type == :total_supply && + record.data == %{"total_supply" => "123"}) || + (record.address_hash == address2_hash && record.data_type == :total_supply && + record.data == %{"total_supply" => "124"}) || + (record.address_hash == address3_hash && record.data_type == :total_supply && + record.data == %{"total_supply" => "125"}) || + (record.address_hash == address4_hash && record.data_type == :total_supply && + record.data == %{"total_supply" => "126"}) + end) + end + + test "replaces an existing item in the db queue and updates `updated_at` field" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + address_hash_binary = Base.decode16!("000102030405060708090a0b0c0d0e0f10111213", case: :mixed) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + + assert MultichainSearch.send_token_info_to_queue(%{address_hash_binary => %{total_supply: "123"}}, :total_supply) == + :ok + + [record] = Repo.all(TokenInfoExportQueue) + {:ok, address_hash} = Hash.Address.cast(address_hash_binary) + + assert record.address_hash == address_hash && record.data_type == :total_supply && + record.data == %{"total_supply" => "123"} + + assert MultichainSearch.send_token_info_to_queue(%{address_hash_binary => %{total_supply: "124"}}, :total_supply) == + :ok + + [record_new] = Repo.all(TokenInfoExportQueue) + + assert record_new.address_hash == address_hash && record_new.data_type == :total_supply && + record_new.data == %{"total_supply" => "124"} && + DateTime.compare(record_new.updated_at, record.updated_at) == :gt + end + end + + describe "extract_batch_import_params_into_chunks/1" do + setup do + TestHelper.get_chain_id_mock() + Application.put_env(:explorer, MultichainSearch, api_key: "12345", addresses_chunk_size: 7000) + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, api_key: nil, addresses_chunk_size: 7000) + end) + + :ok + end + + test "returns empty chunks when no data is provided" do + # filling chain_id cache + ChainId.get_id() + + assert MultichainSearch.extract_batch_import_params_into_chunks(%{ + addresses: [], + blocks: [], + transactions: [], + address_current_token_balances: [] + }) == [ + %{ + api_key: "12345", + addresses: [], + block_ranges: [], + chain_id: "1", + hashes: [], + address_coin_balances: [], + address_token_balances: [] + } + ] + end + + test "returns chunks with transactions and blocks when no addresses provided" do + block_1 = insert(:block) + block_2 = insert(:block) + transaction_1 = insert(:transaction) + transaction_2 = insert(:transaction) + + params = %{ + addresses: [], + blocks: [block_1, block_2], + transactions: [transaction_1, transaction_2], + address_current_token_balances: [] + } + + chunks = MultichainSearch.extract_batch_import_params_into_chunks(params) + + assert Enum.count(chunks) == 1 + + chunk = List.first(chunks) + + assert chunk[:api_key] == "12345" + assert chunk[:chain_id] == "1" + assert length(chunk[:addresses]) == 0 + + assert chunk[:block_ranges] == [ + %{ + max_block_number: to_string(max(block_1.number, block_2.number)), + min_block_number: to_string(min(block_1.number, block_2.number)) + } + ] + + assert chunk[:hashes] == [ + %{ + hash: "0x" <> Base.encode16(block_1.hash.bytes, case: :lower), + hash_type: "BLOCK" + }, + %{ + hash: "0x" <> Base.encode16(block_2.hash.bytes, case: :lower), + hash_type: "BLOCK" + }, + %{ + hash: "0x" <> Base.encode16(transaction_1.hash.bytes, case: :lower), + hash_type: "TRANSACTION" + }, + %{ + hash: "0x" <> Base.encode16(transaction_2.hash.bytes, case: :lower), + hash_type: "TRANSACTION" + } + ] + end + + test "returns chunks with the correct structure when all types of data is provided" do + address_1 = insert(:address, fetched_coin_balance: Decimal.new(100)) + address_2 = insert(:address, fetched_coin_balance: Decimal.new(200)) + block_1 = insert(:block) + block_2 = insert(:block) + transaction_1 = insert(:transaction) + transaction_2 = insert(:transaction) + + token = insert(:token, contract_address: address_1, type: "ERC-20", name: "Test Token") + + current_token_balance = + insert(:address_current_token_balance, + address: address_1, + token_type: "ERC-20", + token_id: nil, + token_contract_address_hash: token.contract_address_hash, + value: 30_000 + ) + + params = %{ + addresses: [address_1, address_2], + blocks: [block_1, block_2], + transactions: [transaction_1, transaction_2], + address_current_token_balances: [current_token_balance] + } + + chunks = MultichainSearch.extract_batch_import_params_into_chunks(params) + + assert Enum.count(chunks) == 1 + + chunk = List.first(chunks) + + assert chunk[:api_key] == "12345" + assert chunk[:chain_id] == "1" + + assert Enum.all?(chunk[:addresses], fn item -> + item.hash == "0x" <> Base.encode16(address_1.hash.bytes, case: :lower) || + item.hash == "0x" <> Base.encode16(address_2.hash.bytes, case: :lower) + end) + + assert chunk[:block_ranges] == [ + %{ + max_block_number: to_string(max(block_1.number, block_2.number)), + min_block_number: to_string(min(block_1.number, block_2.number)) + } + ] + + assert chunk[:hashes] == [ + %{ + hash: to_string(block_1.hash), + hash_type: "BLOCK" + }, + %{ + hash: to_string(block_2.hash), + hash_type: "BLOCK" + }, + %{ + hash: to_string(transaction_1.hash), + hash_type: "TRANSACTION" + }, + %{ + hash: to_string(transaction_2.hash), + hash_type: "TRANSACTION" + } + ] + + assert chunk[:address_coin_balances] == [ + %{value: %Wei{value: Decimal.new("100")}, address_hash: to_string(address_1.hash)}, + %{value: %Wei{value: Decimal.new("200")}, address_hash: to_string(address_2.hash)} + ] + + assert chunk[:address_token_balances] == [ + %{ + value: Decimal.new("30000"), + address_hash: to_string(address_1.hash), + token_id: nil, + token_address_hash: to_string(token.contract_address_hash) + } + ] + end + + test "returns multiple chunks with the correct structure when all types of data is provided" do + addresses = + for _ <- 0..7001 do + insert(:address) + end + + block_1 = insert(:block) + block_2 = insert(:block) + transaction_1 = insert(:transaction) + transaction_2 = insert(:transaction) + + params = %{ + addresses: addresses, + blocks: [block_1, block_2], + transactions: [transaction_1, transaction_2], + address_current_token_balances: [] + } + + chunks = MultichainSearch.extract_batch_import_params_into_chunks(params) + + assert Enum.count(chunks) == 2 + + first_chunk = List.first(chunks) + second_chunk = List.last(chunks) + + assert first_chunk[:api_key] == "12345" + assert first_chunk[:chain_id] == "1" + + assert Enum.count(first_chunk[:addresses]) == 7000 + assert Enum.count(second_chunk[:addresses]) == 2 + + random_index_in_first_chunk = Enum.random(0..6999) + + assert Enum.any?(first_chunk[:addresses], fn item -> + item.hash == + "0x" <> Base.encode16(Enum.at(addresses, random_index_in_first_chunk).hash.bytes, case: :lower) + end) + + assert first_chunk[:block_ranges] == [ + %{ + max_block_number: to_string(max(block_1.number, block_2.number)), + min_block_number: to_string(min(block_1.number, block_2.number)) + } + ] + + assert first_chunk[:hashes] == [ + %{ + hash: "0x" <> Base.encode16(block_1.hash.bytes, case: :lower), + hash_type: "BLOCK" + }, + %{ + hash: "0x" <> Base.encode16(block_2.hash.bytes, case: :lower), + hash_type: "BLOCK" + }, + %{ + hash: "0x" <> Base.encode16(transaction_1.hash.bytes, case: :lower), + hash_type: "TRANSACTION" + }, + %{ + hash: "0x" <> Base.encode16(transaction_2.hash.bytes, case: :lower), + hash_type: "TRANSACTION" + } + ] + + assert second_chunk[:api_key] == "12345" + assert second_chunk[:chain_id] == "1" + + assert second_chunk[:block_ranges] == [] + assert second_chunk[:hashes] == [] + + assert Enum.all?(second_chunk[:addresses], fn item -> + item.hash == "0x" <> Base.encode16(Enum.at(addresses, 7000).hash.bytes, case: :lower) || + item.hash == "0x" <> Base.encode16(Enum.at(addresses, 7001).hash.bytes, case: :lower) + end) + end + + test "returns chunks with the correct structure when only addresses are provided" do + address_1 = insert(:address) + address_2 = insert(:address) + + params = %{ + addresses: [address_1, address_2], + blocks: [], + transactions: [], + address_current_token_balances: [] + } + + chunks = MultichainSearch.extract_batch_import_params_into_chunks(params) + + assert Enum.count(chunks) == 1 + + chunk = List.first(chunks) + + assert chunk[:api_key] == "12345" + assert chunk[:chain_id] == "1" + + assert Enum.all?(chunk[:addresses], fn item -> + item.hash == "0x" <> Base.encode16(address_1.hash.bytes, case: :lower) || + item.hash == "0x" <> Base.encode16(address_2.hash.bytes, case: :lower) + end) + + assert chunk[:block_ranges] == [] + assert chunk[:hashes] == [] + end + + test "returns chunks with the correct structure of addresses" do + address_1 = insert(:address, ens_domain_name: "te.eth") + address_2 = insert(:address, contract_code: "0x1234") + address_3 = insert(:address, contract_code: "0x1234", verified: true) + insert(:smart_contract, address_hash: address_3.hash, contract_code_md5: "123") + insert(:token, %{contract_address: address_3, name: "Main Token", type: "ERC-721"}) + + address_3_with_preloads = address_3 |> Repo.preload([:smart_contract, :token]) + + params = %{ + addresses: [address_1, address_2, address_3_with_preloads], + blocks: [], + transactions: [], + address_current_token_balances: [] + } + + chunks = MultichainSearch.extract_batch_import_params_into_chunks(params) + + assert Enum.count(chunks) == 1 + + chunk = List.first(chunks) + + assert chunk[:api_key] == "12345" + assert chunk[:chain_id] == "1" + + assert chunk[:addresses] == [ + %{ + hash: "0x" <> Base.encode16(address_1.hash.bytes, case: :lower), + is_contract: false, + is_verified_contract: false, + contract_name: nil + }, + %{ + hash: "0x" <> Base.encode16(address_2.hash.bytes, case: :lower), + is_contract: true, + is_verified_contract: false, + contract_name: nil + }, + %{ + hash: "0x" <> Base.encode16(address_3.hash.bytes, case: :lower), + is_contract: true, + is_verified_contract: true, + contract_name: "SimpleStorage" + } + ] + + assert chunk[:block_ranges] == [] + assert chunk[:hashes] == [] + end + end + + defp transaction_export_data(transaction) do + %{ + hash: "0x" <> Base.encode16(transaction.hash.bytes, case: :lower), + hash_type: "TRANSACTION" + } + end + + defp block_export_data(block) do + %{ + hash: "0x" <> Base.encode16(block.hash.bytes, case: :lower), + hash_type: "BLOCK" + } + end + + defp address_export_data(address) do + %{ + hash: "0x" <> Base.encode16(address.hash.bytes, case: :lower), + is_contract: false, + contract_name: nil, + is_verified_contract: false + } + end +end diff --git a/apps/explorer/test/explorer/migrator/address_current_token_balance_token_type_test.exs b/apps/explorer/test/explorer/migrator/address_current_token_balance_token_type_test.exs index 27591d9c52c1..c0c123b8c200 100644 --- a/apps/explorer/test/explorer/migrator/address_current_token_balance_token_type_test.exs +++ b/apps/explorer/test/explorer/migrator/address_current_token_balance_token_type_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.AddressCurrentTokenBalanceTokenTypeTest do use Explorer.DataCase, async: false @@ -8,6 +9,8 @@ defmodule Explorer.Migrator.AddressCurrentTokenBalanceTokenTypeTest do describe "Migrate current token balances" do test "Set token_type for not processed current token balances" do + insert(:block) + Enum.each(0..10, fn _x -> current_token_balance = insert(:address_current_token_balance, token_type: nil) assert %{token_type: nil} = current_token_balance diff --git a/apps/explorer/test/explorer/migrator/address_token_balance_token_type_test.exs b/apps/explorer/test/explorer/migrator/address_token_balance_token_type_test.exs index 4bf09c57122c..af015c449054 100644 --- a/apps/explorer/test/explorer/migrator/address_token_balance_token_type_test.exs +++ b/apps/explorer/test/explorer/migrator/address_token_balance_token_type_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.AddressTokenBalanceTokenTypeTest do use Explorer.DataCase, async: false @@ -8,6 +9,8 @@ defmodule Explorer.Migrator.AddressTokenBalanceTokenTypeTest do describe "Migrate token balances" do test "Set token_type for not processed token balances" do + insert(:block) + Enum.each(0..10, fn _x -> token_balance = insert(:token_balance, token_type: nil) assert %{token_type: nil} = token_balance diff --git a/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs b/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs index 2a6a7a4b8964..34547ca7be51 100644 --- a/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs +++ b/apps/explorer/test/explorer/migrator/backfill_metadata_url_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.BackfillMetadataURLTest do use Explorer.DataCase, async: false use EthereumJSONRPC.Case, async: false @@ -30,6 +31,7 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do describe "BackfillMetadataURL" do test "complete migration" do + insert(:block) token = insert(:token, type: "ERC-721") insert(:token_instance, @@ -126,6 +128,7 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do end test "Resolve domain" do + insert(:block) token = insert(:token, type: "ERC-721") env = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) Application.put_env(:explorer, Explorer.Migrator.BackfillMetadataURL, batch_size: 1, concurrency: 1) @@ -278,6 +281,7 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do end test "drop metadata on invalid token uri response" do + insert(:block) token = insert(:token, type: "ERC-1155") env = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.Helper) Application.put_env(:explorer, Explorer.Migrator.BackfillMetadataURL, batch_size: 1, concurrency: 1) @@ -346,5 +350,102 @@ defmodule Explorer.Migrator.BackfillMetadataURLTest do assert MigrationStatus.get_status("backfill_metadata_url") == "completed" end + + test "regression for https://github.com/blockscout/blockscout/issues/12389" do + insert(:block) + token = insert(:token, type: "ERC-721") + + insert(:token_instance, + metadata: %{awesome: "metadata"}, + token_contract_address_hash: token.contract_address_hash, + token_id: 0 + ) + + insert(:token_instance, + metadata: %{awesome: "metadata"}, + token_contract_address_hash: token.contract_address_hash, + token_id: 1 + ) + + token_contract_address_hash_string = to_string(token.contract_address_hash) + + encoded_url_2 = + "0x" <> + (ABI.TypeEncoder.encode( + ["http://example.com/api/card/{id}/" <> String.duplicate("a", 3000)], + %ABI.FunctionSelector{ + function: nil, + types: [ + :string + ] + } + ) + |> Base.encode16(case: :lower)) + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [ + %{ + id: id_1, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: "0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000", + to: ^token_contract_address_hash_string + }, + "latest" + ] + }, + %{ + id: id_2, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: "0xc87b56dd0000000000000000000000000000000000000000000000000000000000000001", + to: ^token_contract_address_hash_string + }, + "latest" + ] + } + ], + _options -> + {:ok, + [ + %{ + id: id_1, + jsonrpc: "2.0", + result: + "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004768747470733a2f2f6170692e63727970746f736861636b2e636c75622f676f7068657273000000000000000000000000000000000000000000000000002f6a736f6e2f3235343300000000000000000000000000000000000000000000000000" + }, + %{id: id_2, jsonrpc: "2.0", result: encoded_url_2} + ]} + end + ) + + assert MigrationStatus.get_status("backfill_metadata_url") == nil + + BackfillMetadataURL.start_link([]) + Process.sleep(100) + + [instance_1, instance_2] = + Instance + |> order_by([i], asc: i.token_id) + |> Repo.all() + + assert instance_1.skip_metadata_url == false + assert instance_2.skip_metadata_url == false + + assert is_nil(instance_1.metadata) + assert !is_nil(instance_2.metadata) + + assert instance_2.metadata == %{"awesome" => "metadata"} + assert String.length(instance_2.metadata_url) == 2048 + assert instance_1.error == "not_printable" + + assert MigrationStatus.get_status("backfill_metadata_url") == "completed" + end end end diff --git a/apps/explorer/test/explorer/migrator/celo_accounts_test.exs b/apps/explorer/test/explorer/migrator/celo_accounts_test.exs new file mode 100644 index 000000000000..fa131c91210c --- /dev/null +++ b/apps/explorer/test/explorer/migrator/celo_accounts_test.exs @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.CeloAccountsTest do + use Explorer.DataCase, async: false + + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + if @chain_identity == {:optimism, :celo} do + alias Explorer.Factory + alias Explorer.Migrator.{CeloAccounts, MigrationStatus} + alias Explorer.Chain.{Address, Hash} + alias Explorer.Chain.Celo.{Account, PendingAccountOperation} + alias Explorer.Chain.Celo.Legacy.Events + alias Explorer.Repo + + describe "celo_accounts migration" do + test "enqueues pending operations for new celo addresses" do + new_address_hash = Factory.address_hash() + existing_pending_address = insert(:address) + existing_account_address = insert(:address) + + %PendingAccountOperation{} + |> PendingAccountOperation.changeset(%{address_hash: existing_pending_address.hash}) + |> Repo.insert!() + + %Account{} + |> Account.changeset(%{ + address_hash: existing_account_address.hash, + type: :regular, + locked_celo: 0, + nonvoting_locked_celo: 0 + }) + |> Repo.insert!() + + # take AccountCreated topic + event_topic = Events.account_events() |> Enum.at(3) + + insert(:log, first_topic: event_topic, second_topic: zero_pad(new_address_hash)) + insert(:log, first_topic: event_topic, second_topic: zero_pad(new_address_hash)) + insert(:log, first_topic: event_topic, second_topic: zero_pad(existing_pending_address.hash)) + insert(:log, first_topic: event_topic, second_topic: zero_pad(existing_account_address.hash)) + + assert MigrationStatus.set_status("celo_accounts", "started") + {:ok, pid} = CeloAccounts.start_link([]) + assert Repo.get(Address, new_address_hash) == nil + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 1_000 + + pending_addresses = + PendingAccountOperation + |> Repo.all() + |> Enum.map(&Hash.to_string(&1.address_hash)) + |> Enum.sort() + + expected_addresses = + [existing_pending_address.hash, new_address_hash] + |> Enum.map(&Hash.to_string/1) + |> Enum.sort() + + assert pending_addresses == expected_addresses + + assert Repo.get(Address, new_address_hash) + assert Repo.get(PendingAccountOperation, existing_account_address.hash) == nil + assert Repo.aggregate(PendingAccountOperation, :count, :address_hash) == 2 + assert MigrationStatus.get_status("celo_accounts") == "completed" + end + end + + defp zero_pad(address_hash) do + <<"0x", rest::binary>> = Hash.to_string(address_hash) + "0x" <> String.duplicate("0", 24) <> rest + end + end +end diff --git a/apps/explorer/test/explorer/migrator/celo_aggregated_election_rewards_test.exs b/apps/explorer/test/explorer/migrator/celo_aggregated_election_rewards_test.exs new file mode 100644 index 000000000000..50e95e4ce0c1 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/celo_aggregated_election_rewards_test.exs @@ -0,0 +1,529 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.CeloAggregatedElectionRewardsTest do + use Explorer.DataCase, async: false + + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + if @chain_identity == {:optimism, :celo} do + alias Explorer.Chain.Celo.{AggregatedElectionReward, Epoch} + alias Explorer.Migrator.{CeloAggregatedElectionRewards, MigrationStatus} + alias Explorer.Repo + + import Ecto.Query + + describe "celo_aggregated_election_rewards migration" do + setup do + # Clean up migration status before each test + Repo.delete_all(MigrationStatus) + :ok + end + + test "does not fail when there are no epochs" do + # Ensure no epochs exist + assert Repo.aggregate(Epoch, :count) == 0 + + # Run migration + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == nil + start_supervised!(CeloAggregatedElectionRewards) + + # Wait for migration to complete + Process.sleep(500) + + # Verify migration completed successfully + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == "completed" + + # Verify no aggregated rewards were created + assert Repo.aggregate(AggregatedElectionReward, :count) == 0 + end + + test "skips epochs without end_processing_block_hash (unfinalized epochs)" do + # Create finalized epoch + finalized_epoch = insert(:celo_epoch, number: 100, end_processing_block_hash: insert(:block).hash) + + # Create unfinalized epoch (no end_processing_block_hash) + _unfinalized_epoch = insert(:celo_epoch, number: 101, end_processing_block_hash: nil) + + # Create addresses for the rewards + account_address = insert(:address) + associated_address = insert(:address) + + # Add rewards for finalized epoch + insert(:celo_election_reward, + epoch_number: finalized_epoch.number, + type: :voter, + amount: 1000, + account_address_hash: account_address.hash, + associated_account_address_hash: associated_address.hash + ) + + # Add rewards for unfinalized epoch + insert(:celo_election_reward, + epoch_number: 101, + type: :voter, + amount: 2000, + account_address_hash: account_address.hash, + associated_account_address_hash: associated_address.hash + ) + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(500) + + # Verify only finalized epoch was processed + aggregated_rewards = Repo.all(AggregatedElectionReward) + # Only 1 reward type (voter) has data, so only 1 aggregate is saved (no zeros) + assert length(aggregated_rewards) == 1 + + epoch_numbers = Enum.map(aggregated_rewards, & &1.epoch_number) |> Enum.uniq() + assert epoch_numbers == [100] + end + + test "backfills aggregated rewards for a single epoch with all reward types" do + epoch = insert(:celo_epoch, number: 42, end_processing_block_hash: insert(:block).hash) + + # Create addresses for the rewards + account1 = insert(:address) + account2 = insert(:address) + associated1 = insert(:address) + associated2 = insert(:address) + + # Insert rewards for all types + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :voter, + amount: 1000, + account_address_hash: account1.hash, + associated_account_address_hash: associated1.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :voter, + amount: 1500, + account_address_hash: account2.hash, + associated_account_address_hash: associated1.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :validator, + amount: 2000, + account_address_hash: account1.hash, + associated_account_address_hash: associated1.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :group, + amount: 3000, + account_address_hash: account1.hash, + associated_account_address_hash: associated1.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :group, + amount: 3500, + account_address_hash: account1.hash, + associated_account_address_hash: associated2.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :group, + amount: 4000, + account_address_hash: account2.hash, + associated_account_address_hash: associated1.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :delegated_payment, + amount: 500, + account_address_hash: account1.hash, + associated_account_address_hash: associated1.hash + ) + + # Ensure migration has not run yet + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == nil + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + # Wait for migration to complete + Process.sleep(500) + + # Verify aggregated rewards were created correctly + aggregated_rewards = + from(aer in AggregatedElectionReward, + where: aer.epoch_number == ^epoch.number, + order_by: [asc: aer.type] + ) + |> Repo.all() + + assert length(aggregated_rewards) == 4 + + # Check voter rewards + voter_reward = Enum.find(aggregated_rewards, &(&1.type == :voter)) + assert voter_reward.count == 2 + assert Decimal.to_integer(voter_reward.sum.value) == 2500 + + # Check validator rewards + validator_reward = Enum.find(aggregated_rewards, &(&1.type == :validator)) + assert validator_reward.count == 1 + assert Decimal.to_integer(validator_reward.sum.value) == 2000 + + # Check group rewards + group_reward = Enum.find(aggregated_rewards, &(&1.type == :group)) + assert group_reward.count == 3 + assert Decimal.to_integer(group_reward.sum.value) == 10500 + + # Check delegated_payment rewards + delegated_reward = Enum.find(aggregated_rewards, &(&1.type == :delegated_payment)) + assert delegated_reward.count == 1 + assert Decimal.to_integer(delegated_reward.sum.value) == 500 + + # Confirm migration status is completed + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == "completed" + end + + test "only saves reward types with data (no zero values for types with actual rewards)" do + epoch = insert(:celo_epoch, number: 50, end_processing_block_hash: insert(:block).hash) + + # Create addresses + account = insert(:address) + associated = insert(:address) + + # Only insert voter rewards, leaving other types empty + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :voter, + amount: 5000, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(500) + + # Verify only reward types with data are saved (no zero values for other types) + aggregated_rewards = + from(aer in AggregatedElectionReward, + where: aer.epoch_number == ^epoch.number, + order_by: [asc: aer.type] + ) + |> Repo.all() + + # Only voter type has data, so only 1 aggregate is saved + assert length(aggregated_rewards) == 1 + + # Check voter has data + voter_reward = Enum.at(aggregated_rewards, 0) + assert voter_reward.type == :voter + assert voter_reward.count == 1 + assert Decimal.to_integer(voter_reward.sum.value) == 5000 + + # Verify other types are NOT saved (no zero-value entries for types with actual data) + for type <- [:validator, :group, :delegated_payment] do + reward = Enum.find(aggregated_rewards, &(&1.type == type)) + assert reward == nil + end + end + + test "backfills aggregated rewards for multiple epochs" do + # Create multiple finalized epochs + epoch1 = insert(:celo_epoch, number: 10, end_processing_block_hash: insert(:block).hash) + epoch2 = insert(:celo_epoch, number: 11, end_processing_block_hash: insert(:block).hash) + epoch3 = insert(:celo_epoch, number: 12, end_processing_block_hash: insert(:block).hash) + + # Create addresses + account = insert(:address) + associated = insert(:address) + + # Add rewards for epoch 1 + insert(:celo_election_reward, + epoch_number: epoch1.number, + type: :voter, + amount: 100, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch1.number, + type: :validator, + amount: 200, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + # Add rewards for epoch 2 + insert(:celo_election_reward, + epoch_number: epoch2.number, + type: :group, + amount: 300, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + # Add rewards for epoch 3 + account2 = insert(:address) + + insert(:celo_election_reward, + epoch_number: epoch3.number, + type: :delegated_payment, + amount: 400, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch3.number, + type: :delegated_payment, + amount: 500, + account_address_hash: account2.hash, + associated_account_address_hash: associated.hash + ) + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(1000) + + # Verify all epochs were processed + aggregated_rewards = Repo.all(AggregatedElectionReward) + + # Each epoch has different reward types with data: + # epoch1: voter, validator (2 types) + # epoch2: group (1 type) + # epoch3: delegated_payment (1 type) + # Total: 4 aggregated records (only non-zero values) + assert length(aggregated_rewards) == 4 + + epoch_numbers = Enum.map(aggregated_rewards, & &1.epoch_number) |> Enum.uniq() |> Enum.sort() + assert epoch_numbers == [10, 11, 12] + + # Verify epoch 1 + epoch1_rewards = + Enum.filter(aggregated_rewards, &(&1.epoch_number == epoch1.number)) + |> Enum.sort_by(& &1.type) + + voter1 = Enum.find(epoch1_rewards, &(&1.type == :voter)) + assert voter1.count == 1 + assert Decimal.to_integer(voter1.sum.value) == 100 + + validator1 = Enum.find(epoch1_rewards, &(&1.type == :validator)) + assert validator1.count == 1 + assert Decimal.to_integer(validator1.sum.value) == 200 + + # Verify epoch 3 + epoch3_rewards = Enum.filter(aggregated_rewards, &(&1.epoch_number == epoch3.number)) + delegated3 = Enum.find(epoch3_rewards, &(&1.type == :delegated_payment)) + assert delegated3.count == 2 + assert Decimal.to_integer(delegated3.sum.value) == 900 + + # Confirm migration status is completed + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == "completed" + end + + test "is idempotent - does not duplicate data when run multiple times" do + epoch = insert(:celo_epoch, number: 99, end_processing_block_hash: insert(:block).hash) + + account = insert(:address) + associated = insert(:address) + + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :voter, + amount: 7777, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + # Run migration first time + {:ok, pid} = CeloAggregatedElectionRewards.start_link([]) + + Process.sleep(500) + + # Verify process has stopped after migration completed + refute Process.alive?(pid) + + # Verify data was created (only 1 type has data, so only 1 aggregate) + assert Repo.aggregate(AggregatedElectionReward, :count) == 1 + + initial_rewards = + from(aer in AggregatedElectionReward, order_by: [asc: aer.type]) + |> Repo.all() + + # Reset migration status to simulate running again + MigrationStatus.set_status("celo_aggregated_election_rewards", nil) + + # Run migration second time + {:ok, pid} = CeloAggregatedElectionRewards.start_link([]) + + Process.sleep(500) + + # Verify second process has also stopped + refute Process.alive?(pid) + + # Verify data was not duplicated + assert Repo.aggregate(AggregatedElectionReward, :count) == 1 + + final_rewards = + from(aer in AggregatedElectionReward, order_by: [asc: aer.type]) + |> Repo.all() + + # Data should be identical + Enum.zip(initial_rewards, final_rewards) + |> Enum.each(fn {initial, final} -> + assert initial.epoch_number == final.epoch_number + assert initial.type == final.type + assert initial.count == final.count + assert Decimal.equal?(initial.sum.value, final.sum.value) + end) + end + + test "processes epochs in ascending order" do + # Create epochs out of order + epoch3 = insert(:celo_epoch, number: 33, end_processing_block_hash: insert(:block).hash) + epoch1 = insert(:celo_epoch, number: 31, end_processing_block_hash: insert(:block).hash) + epoch2 = insert(:celo_epoch, number: 32, end_processing_block_hash: insert(:block).hash) + + # Create addresses + account = insert(:address) + associated = insert(:address) + + # Add minimal rewards + for epoch <- [epoch1, epoch2, epoch3] do + insert(:celo_election_reward, + epoch_number: epoch.number, + type: :voter, + amount: 100 * epoch.number, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + end + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(1000) + + # Verify all epochs were processed + aggregated_rewards = + from(aer in AggregatedElectionReward, + where: aer.type == :voter, + order_by: [asc: aer.epoch_number] + ) + |> Repo.all() + + assert length(aggregated_rewards) == 3 + assert Enum.at(aggregated_rewards, 0).epoch_number == 31 + assert Enum.at(aggregated_rewards, 1).epoch_number == 32 + assert Enum.at(aggregated_rewards, 2).epoch_number == 33 + end + + test "skips epoch with no election rewards" do + # Create epoch with no rewards + epoch = insert(:celo_epoch, number: 999, end_processing_block_hash: insert(:block).hash) + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(500) + + # Verify no aggregates were created (epoch was skipped) + aggregated_rewards = + from(aer in AggregatedElectionReward, + where: aer.epoch_number == ^epoch.number + ) + |> Repo.all() + + assert length(aggregated_rewards) == 0 + + # Verify migration completed (no epochs with rewards to process) + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == "completed" + + # Verify unprocessed_data_query doesn't return the epoch without rewards + unprocessed_epochs = + CeloAggregatedElectionRewards.unprocessed_data_query() + |> select([e], e.number) + |> Repo.all() + + refute epoch.number in unprocessed_epochs + end + + test "completes migration even when all epochs have no rewards" do + # Create multiple finalized epochs with no rewards + _epoch1 = insert(:celo_epoch, number: 100, end_processing_block_hash: insert(:block).hash) + _epoch2 = insert(:celo_epoch, number: 101, end_processing_block_hash: insert(:block).hash) + _epoch3 = insert(:celo_epoch, number: 102, end_processing_block_hash: insert(:block).hash) + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(1000) + + # Verify no aggregates were created (all epochs skipped) + aggregated_rewards = Repo.all(AggregatedElectionReward) + assert length(aggregated_rewards) == 0 + + # Most importantly: verify migration completed and doesn't spin forever + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == "completed" + + # Verify unprocessed_data_query returns nothing (epochs with no rewards are skipped) + unprocessed_count = + CeloAggregatedElectionRewards.unprocessed_data_query() + |> Repo.aggregate(:count) + + assert unprocessed_count == 0 + end + + test "processes only epochs with rewards and skips epochs without rewards" do + # Create a mix of epochs with and without rewards + epoch_with_rewards = insert(:celo_epoch, number: 200, end_processing_block_hash: insert(:block).hash) + _epoch_without_rewards1 = insert(:celo_epoch, number: 201, end_processing_block_hash: insert(:block).hash) + epoch_with_rewards2 = insert(:celo_epoch, number: 202, end_processing_block_hash: insert(:block).hash) + _epoch_without_rewards2 = insert(:celo_epoch, number: 203, end_processing_block_hash: insert(:block).hash) + + # Create addresses + account = insert(:address) + associated = insert(:address) + + # Add rewards only for specific epochs + insert(:celo_election_reward, + epoch_number: epoch_with_rewards.number, + type: :voter, + amount: 1000, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + insert(:celo_election_reward, + epoch_number: epoch_with_rewards2.number, + type: :validator, + amount: 2000, + account_address_hash: account.hash, + associated_account_address_hash: associated.hash + ) + + # Run migration + start_supervised!(CeloAggregatedElectionRewards) + Process.sleep(1000) + + # Verify only epochs with rewards were processed + aggregated_rewards = Repo.all(AggregatedElectionReward) + assert length(aggregated_rewards) == 2 + + epoch_numbers = Enum.map(aggregated_rewards, & &1.epoch_number) |> Enum.sort() + assert epoch_numbers == [200, 202] + + # Verify epochs without rewards were not processed + for epoch_num <- [201, 203] do + rewards = Repo.all(from(aer in AggregatedElectionReward, where: aer.epoch_number == ^epoch_num)) + assert length(rewards) == 0 + end + + # Verify migration completed + assert MigrationStatus.get_status("celo_aggregated_election_rewards") == "completed" + end + end + end +end diff --git a/apps/explorer/test/explorer/migrator/celo_l2_epochs_test.exs b/apps/explorer/test/explorer/migrator/celo_l2_epochs_test.exs new file mode 100644 index 000000000000..26182247f12c --- /dev/null +++ b/apps/explorer/test/explorer/migrator/celo_l2_epochs_test.exs @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.CeloL2EpochsTest do + use Explorer.DataCase, async: false + + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + if @chain_identity == {:optimism, :celo} do + alias Explorer.Migrator.{CeloL2Epochs, MigrationStatus} + alias Explorer.Chain.Celo.Epoch + alias Explorer.Repo + + import Ecto.Query + + describe "celo_l2_epochs migration" do + test "backfills epochs from logs" do + epoch_manager_address = insert(:address, hash: "0x8d3436d48e1e3d915a0a6948049b0f58a79c9bbb") + + old_env = Application.get_env(:explorer, :celo) + + Application.put_env( + :explorer, + :celo, + Keyword.put( + old_env, + :epoch_manager_contract_address, + to_string(epoch_manager_address.hash) + ) + ) + + on_exit(fn -> + Application.put_env(:explorer, :celo, old_env) + end) + + # Create test logs for epoch processing events + epoch_number = 42 + epoch_number_hex = "0x000000000000000000000000000000000000000000000000000000000000002a" + + # Add epoch_processing_started event log + start_log = + insert(:log, + address: epoch_manager_address, + # epoch_processing_started + first_topic: "0xae58a33f8b8d696bcbaca9fa29d9fdc336c140e982196c2580db3d46f3e6d4b6", + second_topic: epoch_number_hex + ) + + # Add epoch_processing_ended event log + end_log = + insert(:log, + address: epoch_manager_address, + # epoch_processing_ended + first_topic: "0xc8e58d8e6979dd5e68bad79d4a4368a1091f6feb2323e612539b1b84e0663a8f", + second_topic: epoch_number_hex + ) + + # Ensure migration has not run yet + assert MigrationStatus.get_status("celo_l2_epochs") == nil + + # Run migration process + {:ok, _} = CeloL2Epochs.start_link([]) + + # Wait for migration to complete + Process.sleep(500) + + # Verify epoch was properly created + epochs = Repo.all(from(e in Epoch, where: e.number == ^epoch_number)) + + assert length(epochs) == 1 + epoch = List.first(epochs) + + # Check epoch data matches our logs + assert epoch.number == epoch_number + assert epoch.start_processing_block_hash == start_log.block_hash + assert epoch.end_processing_block_hash == end_log.block_hash + assert epoch.fetched? == false + + # Confirm migration status is completed + assert MigrationStatus.get_status("celo_l2_epochs") == "completed" + end + + test "backfills multiple epochs from logs" do + epoch_manager_address = insert(:address, hash: "0x8d3436d48e1e3d915a0a6948049b0f58a79c9bbb") + + old_env = Application.get_env(:explorer, :celo) + + Application.put_env(:explorer, :celo, [ + { + :epoch_manager_contract_address, + to_string(epoch_manager_address.hash) + } + | old_env + ]) + + on_exit(fn -> + Application.put_env(:explorer, :celo, old_env) + end) + + # Create data for 3 epochs with different patterns + epoch_data = [ + # Standard epoch with start and end events + %{ + number: 42, + number_hex: "0x000000000000000000000000000000000000000000000000000000000000002a" + }, + # Another complete epoch + %{ + number: 43, + number_hex: "0x000000000000000000000000000000000000000000000000000000000000002b" + }, + # Epoch with only a start event (incomplete) + %{ + number: 44, + number_hex: "0x000000000000000000000000000000000000000000000000000000000000002c" + } + ] + + # Insert logs for each epoch + start_logs = + Enum.map(epoch_data, fn epoch -> + insert(:log, + address: epoch_manager_address, + first_topic: "0xae58a33f8b8d696bcbaca9fa29d9fdc336c140e982196c2580db3d46f3e6d4b6", + second_topic: epoch.number_hex + ) + end) + + # Insert end logs for all except the last epoch + end_logs = + Enum.slice(epoch_data, 0..1) + |> Enum.map(fn epoch -> + insert(:log, + address: epoch_manager_address, + first_topic: "0xc8e58d8e6979dd5e68bad79d4a4368a1091f6feb2323e612539b1b84e0663a8f", + second_topic: epoch.number_hex + ) + end) + + # Ensure migration has not run yet + assert MigrationStatus.get_status("celo_l2_epochs") == nil + + # Run migration process + {:ok, _} = CeloL2Epochs.start_link([]) + + # Wait for migration to complete + Process.sleep(1000) + + # Verify all epochs were properly created + created_epochs = Repo.all(from(e in Epoch, order_by: [asc: e.number])) + + # Should have created all three epochs + assert length(created_epochs) == 3 + + # Check first epoch (complete) + first_epoch = Enum.at(created_epochs, 0) + assert first_epoch.number == 42 + assert first_epoch.start_processing_block_hash == Enum.at(start_logs, 0).block_hash + assert first_epoch.end_processing_block_hash == Enum.at(end_logs, 0).block_hash + assert first_epoch.fetched? == false + + # Check second epoch (complete) + second_epoch = Enum.at(created_epochs, 1) + assert second_epoch.number == 43 + assert second_epoch.start_processing_block_hash == Enum.at(start_logs, 1).block_hash + assert second_epoch.end_processing_block_hash == Enum.at(end_logs, 1).block_hash + assert second_epoch.fetched? == false + + # Check third epoch (incomplete - only has start) + third_epoch = Enum.at(created_epochs, 2) + assert third_epoch.number == 44 + assert third_epoch.start_processing_block_hash == Enum.at(start_logs, 2).block_hash + assert third_epoch.end_processing_block_hash == nil + assert third_epoch.fetched? == false + + # Confirm migration status is completed + assert MigrationStatus.get_status("celo_l2_epochs") == "completed" + end + end + end +end diff --git a/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs b/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs new file mode 100644 index 000000000000..5073d7a3d1b9 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/delete_zero_value_internal_transactions_test.exs @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.DeleteZeroValueInternalTransactionsTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.InternalTransaction + alias Explorer.Migrator.{DeleteZeroValueInternalTransactions, MigrationStatus} + alias Explorer.Repo + alias Explorer.Utility.{AddressIdToAddressHash, InternalTransactionsAddressPlaceholder} + + setup do + on_exit(fn -> + if pid = Process.whereis(DeleteZeroValueInternalTransactions) do + try do + GenServer.stop(pid, :normal, 5000) + catch + :exit, {:noproc, _} -> :ok + end + end + end) + + :ok + end + + test "Deletes zero value calls" do + address_1 = insert(:address) + address_2 = insert(:address) + address_3 = insert(:address) + + block = insert(:block, timestamp: Timex.shift(Timex.now(), days: -40)) + + Enum.map(1..3, fn i -> + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + from_address: address_1, + to_address: address_2, + transaction_index: transaction.index, + type: :call, + value: 0 + ) + end) + + Enum.map(1..4, fn i -> + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + from_address: address_2, + to_address: address_3, + transaction_index: transaction.index, + type: :call, + value: 0 + ) + end) + + Enum.map(1..5, fn i -> + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + from_address: address_3, + to_address: address_1, + transaction_index: transaction.index, + type: :call, + value: 0 + ) + end) + + Enum.map(1..4, fn i -> + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: 1 + ) + end) + + Enum.map(1..5, fn i -> + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction_create, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + value: 0 + ) + end) + + Enum.map(1..6, fn i -> + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + type: :call, + value: 0 + ) + end) + + assert MigrationStatus.get_status("delete_zero_value_internal_transactions") == nil + + DeleteZeroValueInternalTransactions.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: + ms.migration_name == ^"delete_zero_value_internal_transactions" and + ms.status == "completed" + ) + ) + end) + + all_internal_transactions = Repo.all(InternalTransaction) + + assert Enum.count(all_internal_transactions) == 15 + + non_zero_value_calls = + Enum.filter( + all_internal_transactions, + &(&1.type == :call and not Decimal.eq?(&1.value.value, 0)) + ) + + non_calls = Enum.filter(all_internal_transactions, &(&1.type != :call)) + + recent_zero_value_calls = + Enum.filter( + all_internal_transactions, + &(&1.type == :call and Decimal.eq?(&1.value.value, 0)) + ) + + assert Enum.count(non_zero_value_calls) == 4 + assert Enum.all?(non_zero_value_calls, &(not Decimal.eq?(&1.value.value, 0))) + + assert Enum.count(non_calls) == 5 + + assert Enum.count(recent_zero_value_calls) == 6 + + id_to_hashes = Repo.all(AddressIdToAddressHash) + + assert %{address_id: address_1_id} = Enum.find(id_to_hashes, &(&1.address_hash == address_1.hash)) + assert %{address_id: address_2_id} = Enum.find(id_to_hashes, &(&1.address_hash == address_2.hash)) + assert %{address_id: address_3_id} = Enum.find(id_to_hashes, &(&1.address_hash == address_3.hash)) + + placeholders = Repo.all(InternalTransactionsAddressPlaceholder) + + assert length(placeholders) == 3 + + assert Enum.any?(placeholders, fn p -> + p.address_id == address_1_id and p.block_number == block.number and p.count_tos == 5 and + p.count_froms == 3 + end) + + assert Enum.any?(placeholders, fn p -> + p.address_id == address_2_id and p.block_number == block.number and p.count_tos == 3 and + p.count_froms == 4 + end) + + assert Enum.any?(placeholders, fn p -> + p.address_id == address_3_id and p.block_number == block.number and p.count_tos == 4 and + p.count_froms == 5 + end) + end + + describe "ShrinkInternalTransactions migration dependency handling" do + setup do + original_shrink_config = + Application.get_env(:explorer, Explorer.Migrator.ShrinkInternalTransactions) + + original_delete_config = + Application.get_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions) + + # Set a short dependency check interval for tests + Application.put_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, + dependency_check_interval: 100 + ) + + on_exit(fn -> + if original_shrink_config do + Application.put_env( + :explorer, + Explorer.Migrator.ShrinkInternalTransactions, + original_shrink_config + ) + else + Application.delete_env(:explorer, Explorer.Migrator.ShrinkInternalTransactions) + end + + if original_delete_config do + Application.put_env( + :explorer, + Explorer.Migrator.DeleteZeroValueInternalTransactions, + original_delete_config + ) + else + Application.delete_env(:explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions) + end + end) + end + + test "Waits for ShrinkInternalTransactions migration to complete before starting" do + address_1 = insert(:address) + address_2 = insert(:address) + + block = insert(:block, timestamp: Timex.shift(Timex.now(), days: -40)) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + index: 10, + transaction: transaction, + block_number: transaction.block_number, + transaction_index: transaction.index, + from_address: address_1, + to_address: address_2, + type: :call, + value: 0 + ) + + assert MigrationStatus.get_status("delete_zero_value_internal_transactions") == nil + assert MigrationStatus.get_status("shrink_internal_transactions") == nil + + # Configure ShrinkInternalTransactions as enabled + Application.put_env(:explorer, Explorer.Migrator.ShrinkInternalTransactions, enabled: true) + + # Start the migration without ShrinkInternalTransactions being completed + DeleteZeroValueInternalTransactions.start_link([]) + + # Give it time to check for the dependency + Process.sleep(100) + + # Migration should not be initialized yet + migration_status = MigrationStatus.fetch("delete_zero_value_internal_transactions") + assert is_nil(migration_status) + + # Internal transactions should still exist (not deleted) + all_internal_transactions = Repo.all(InternalTransaction) + assert Enum.count(all_internal_transactions) == 1 + + # Now mark ShrinkInternalTransactions as completed + MigrationStatus.set_status("shrink_internal_transactions", "completed") + + # Wait for DeleteZeroValueInternalTransactions to detect completion and start + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: + ms.migration_name == ^"delete_zero_value_internal_transactions" and + ms.status == "completed" + ) + ) + end) + + # Now the internal transaction should be deleted + remaining_internal_transactions = Repo.all(InternalTransaction) + assert Enum.count(remaining_internal_transactions) == 0 + end + + test "Starts immediately when ShrinkInternalTransactions is disabled/not configured" do + address_1 = insert(:address) + address_2 = insert(:address) + + block = insert(:block, timestamp: Timex.shift(Timex.now(), days: -40)) + + transaction = + :transaction + |> insert() + |> with_block(block) + + insert(:internal_transaction, + index: 10, + block_number: transaction.block_number, + transaction_index: transaction.index, + from_address: address_1, + to_address: address_2, + type: :call, + value: 0 + ) + + assert MigrationStatus.get_status("delete_zero_value_internal_transactions") == nil + assert MigrationStatus.get_status("shrink_internal_transactions") == nil + + # Configure ShrinkInternalTransactions as disabled + Application.put_env(:explorer, Explorer.Migrator.ShrinkInternalTransactions, enabled: false) + + # Start the migration + DeleteZeroValueInternalTransactions.start_link([]) + + # Wait for DeleteZeroValueInternalTransactions to start and complete + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: + ms.migration_name == ^"delete_zero_value_internal_transactions" and + ms.status == "completed" + ) + ) + end) + + # Internal transaction should be deleted + remaining_internal_transactions = Repo.all(InternalTransaction) + assert Enum.count(remaining_internal_transactions) == 0 + end + end +end diff --git a/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs b/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs new file mode 100644 index 000000000000..bccff5a851da --- /dev/null +++ b/apps/explorer/test/explorer/migrator/empty_bytecode_for_selfdestructed_smart_contracts_test.exs @@ -0,0 +1,437 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContractsTest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.{Address, Data} + alias Explorer.Migrator.EmptyBytecodeForSelfdestructedSmartContracts + alias Explorer.Repo + + describe "migration_name/0" do + test "returns the correct migration name" do + assert EmptyBytecodeForSelfdestructedSmartContracts.migration_name() == + "empty_bytecode_for_selfdestructed_smart_contracts" + end + end + + describe "update_batch/1" do + test "returns {:ok, []} when block_numbers is empty" do + assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([]) + end + + test "returns {:ok, []} when no selfdestruct transactions exist in blocks" do + block = insert(:block, number: 100) + transaction = insert(:transaction) |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + index: 0, + transaction_index: transaction.index, + type: :call + ) + + assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + end + + test "empties contract_code for address with selfdestruct transaction" do + block = insert(:block, number: 200) + contract_code = %Data{bytes: <<1, 2, 3, 4, 5>>} + + # Create a contract with bytecode + contract_address = insert(:address, contract_code: contract_code) + recipient_address = insert(:address) + + # Create a transaction with selfdestruct + transaction = insert(:transaction) |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + index: 0, + transaction_index: transaction.index, + type: :selfdestruct, + from_address: contract_address, + to_address: recipient_address, + gas: nil + ) + + assert {:ok, 1} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify contract_code is now empty + updated_address = Repo.get(Address, contract_address.hash) + assert updated_address.contract_code == %Data{bytes: <<>>} + end + + test "does NOT empty contract_code when contract created and selfdestructed in same transaction" do + block = insert(:block, number: 300) + contract_code = %Data{bytes: <<1, 2, 3, 4, 5>>} + + # Create a contract with bytecode + contract_address = insert(:address, contract_code: contract_code) + recipient_address = insert(:address) + + # Create a transaction with both create and selfdestruct + transaction = insert(:transaction) |> with_block(block) + + insert(:internal_transaction_create, + transaction: transaction, + block_number: transaction.block_number, + index: 0, + transaction_index: transaction.index, + created_contract_address: contract_address, + created_contract_code: contract_code + ) + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + index: 1, + transaction_index: transaction.index, + type: :selfdestruct, + from_address: contract_address, + to_address: recipient_address, + gas: nil + ) + + assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify contract_code is still present + updated_address = Repo.get(Address, contract_address.hash) + assert updated_address.contract_code == contract_code + end + + test "does NOT empty contract_code when contract created with create2 and selfdestructed in same transaction" do + block = insert(:block, number: 350) + contract_code = %Data{bytes: <<1, 2, 3, 4, 5>>} + + # Create a contract with bytecode + contract_address = insert(:address, contract_code: contract_code) + recipient_address = insert(:address) + + # Create a transaction with both create2 and selfdestruct + transaction = insert(:transaction) |> with_block(block) + + insert(:internal_transaction_create, + transaction: transaction, + block_number: transaction.block_number, + index: 0, + transaction_index: transaction.index, + type: :create2, + created_contract_address: contract_address, + created_contract_code: contract_code + ) + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + index: 1, + transaction_index: transaction.index, + type: :selfdestruct, + from_address: contract_address, + to_address: recipient_address, + gas: nil + ) + + assert {:ok, []} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify contract_code is still present + updated_address = Repo.get(Address, contract_address.hash) + assert updated_address.contract_code == contract_code + end + + test "handles multiple selfdestruct transactions in same block" do + block = insert(:block, number: 400) + contract_code_1 = %Data{bytes: <<1, 2, 3>>} + contract_code_2 = %Data{bytes: <<4, 5, 6>>} + + # Create two contracts with bytecode + contract_address_1 = insert(:address, contract_code: contract_code_1) + contract_address_2 = insert(:address, contract_code: contract_code_2) + recipient_address_1 = insert(:address) + recipient_address_2 = insert(:address) + + # Create separate transactions for each selfdestruct + transaction_1 = insert(:transaction, gas: 21_000) |> with_block(block) + transaction_2 = insert(:transaction, gas: 22_000) |> with_block(block) + + insert(:internal_transaction, + transaction: transaction_1, + block_number: transaction_1.block_number, + index: 0, + transaction_index: transaction_1.index, + type: :selfdestruct, + from_address: contract_address_1, + to_address: recipient_address_1, + gas: nil + ) + + insert(:internal_transaction, + transaction: transaction_2, + block_number: transaction_2.block_number, + index: 0, + transaction_index: transaction_2.index, + type: :selfdestruct, + from_address: contract_address_2, + to_address: recipient_address_2, + gas: nil + ) + + assert {:ok, 2} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify both contracts have empty contract_code + updated_address_1 = Repo.get(Address, contract_address_1.hash) + updated_address_2 = Repo.get(Address, contract_address_2.hash) + assert updated_address_1.contract_code == %Data{bytes: <<>>} + assert updated_address_2.contract_code == %Data{bytes: <<>>} + end + + test "does NOT update address that already has empty contract_code" do + block = insert(:block, number: 500) + empty_contract_code = %Data{bytes: <<>>} + + # Create a contract with already empty bytecode + contract_address = insert(:address, contract_code: empty_contract_code) + recipient_address = insert(:address) + + # Create a transaction with selfdestruct + transaction = insert(:transaction) |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + index: 0, + transaction_index: transaction.index, + type: :selfdestruct, + from_address: contract_address, + to_address: recipient_address, + gas: nil + ) + + assert {:ok, 0} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify contract_code remains empty (no update occurred) + updated_address = Repo.get(Address, contract_address.hash) + assert updated_address.contract_code == empty_contract_code + end + + test "does NOT update address with nil contract_code" do + block = insert(:block, number: 550) + + # Create a regular address (not a contract, contract_code is nil) + address = insert(:address, contract_code: nil) + recipient_address = insert(:address) + + # Create a transaction with selfdestruct + transaction = insert(:transaction) |> with_block(block) + + insert(:internal_transaction, + transaction: transaction, + block_number: transaction.block_number, + index: 0, + transaction_index: transaction.index, + type: :selfdestruct, + from_address: address, + to_address: recipient_address, + gas: nil + ) + + assert {:ok, 0} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify contract_code remains nil + updated_address = Repo.get(Address, address.hash) + assert updated_address.contract_code == nil + end + + test "handles multiple blocks in one batch" do + block_1 = insert(:block, number: 600) + block_2 = insert(:block, number: 601) + contract_code_1 = %Data{bytes: <<1, 2, 3>>} + contract_code_2 = %Data{bytes: <<4, 5, 6>>} + + # Create two contracts with bytecode + contract_address_1 = insert(:address, contract_code: contract_code_1) + contract_address_2 = insert(:address, contract_code: contract_code_2) + recipient_address_1 = insert(:address) + recipient_address_2 = insert(:address) + + # Create selfdestruct in first block + transaction_1 = insert(:transaction, gas: 21_000) |> with_block(block_1) + + insert(:internal_transaction, + transaction: transaction_1, + block_number: transaction_1.block_number, + index: 0, + transaction_index: transaction_1.index, + type: :selfdestruct, + from_address: contract_address_1, + to_address: recipient_address_1, + gas: nil + ) + + # Create selfdestruct in second block + transaction_2 = insert(:transaction, gas: 22_000) |> with_block(block_2) + + insert(:internal_transaction, + transaction: transaction_2, + block_number: transaction_2.block_number, + index: 0, + transaction_index: transaction_2.index, + type: :selfdestruct, + from_address: contract_address_2, + to_address: recipient_address_2, + gas: nil + ) + + assert {:ok, 2} = + EmptyBytecodeForSelfdestructedSmartContracts.update_batch([ + block_1.number, + block_2.number + ]) + + # Verify both contracts have empty contract_code + updated_address_1 = Repo.get(Address, contract_address_1.hash) + updated_address_2 = Repo.get(Address, contract_address_2.hash) + assert updated_address_1.contract_code == %Data{bytes: <<>>} + assert updated_address_2.contract_code == %Data{bytes: <<>>} + end + + test "handles mixed scenario: one should empty, one should not" do + block = insert(:block, number: 700) + contract_code_1 = %Data{bytes: <<1, 2, 3>>} + contract_code_2 = %Data{bytes: <<4, 5, 6>>} + + # Create two contracts + contract_address_1 = insert(:address, contract_code: contract_code_1) + contract_address_2 = insert(:address, contract_code: contract_code_2) + recipient_address_1 = insert(:address) + recipient_address_2 = insert(:address) + + # First transaction: selfdestruct without create (should empty) + transaction_1 = insert(:transaction, gas: 21_000) |> with_block(block) + + insert(:internal_transaction, + transaction_hash: transaction_1.hash, + block_number: transaction_1.block_number, + index: 0, + transaction_index: transaction_1.index, + type: :selfdestruct, + from_address: contract_address_1, + to_address: recipient_address_1, + gas: nil + ) + + # Second transaction: create + selfdestruct (should NOT empty) + transaction_2 = insert(:transaction, gas: 22_000) |> with_block(block) + + insert(:internal_transaction_create, + transaction_hash: transaction_2.hash, + block_number: transaction_2.block_number, + index: 0, + transaction_index: transaction_2.index, + created_contract_address: contract_address_2, + created_contract_code: contract_code_2 + ) + + insert(:internal_transaction, + transaction_hash: transaction_2.hash, + block_number: transaction_2.block_number, + index: 1, + transaction_index: transaction_2.index, + type: :selfdestruct, + from_address: contract_address_2, + to_address: recipient_address_2, + gas: nil + ) + + assert {:ok, 1} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify first contract is empty, second is not + updated_address_1 = Repo.get(Address, contract_address_1.hash) + updated_address_2 = Repo.get(Address, contract_address_2.hash) + assert updated_address_1.contract_code == %Data{bytes: <<>>} + assert updated_address_2.contract_code == contract_code_2 + end + + test "handles same contract selfdestructed multiple times in different transactions" do + block = insert(:block, number: 800) + contract_code = %Data{bytes: <<1, 2, 3, 4, 5>>} + + # Create a contract with bytecode + contract_address = insert(:address, contract_code: contract_code) + recipient_address = insert(:address) + + # Create two different transactions with selfdestruct for the same address + transaction_1 = insert(:transaction, gas: 21_000) |> with_block(block) + transaction_2 = insert(:transaction, gas: 22_000) |> with_block(block) + + insert(:internal_transaction, + transaction_hash: transaction_1.hash, + block_number: transaction_1.block_number, + index: 0, + transaction_index: transaction_1.index, + type: :selfdestruct, + from_address: contract_address, + to_address: recipient_address, + gas: nil + ) + + insert(:internal_transaction, + transaction_hash: transaction_2.hash, + block_number: transaction_2.block_number, + index: 0, + transaction_index: transaction_2.index, + type: :selfdestruct, + from_address: contract_address, + to_address: recipient_address, + gas: nil + ) + + # Should still only update once since it's the same address + assert {:ok, 1} = EmptyBytecodeForSelfdestructedSmartContracts.update_batch([block.number]) + + # Verify contract_code is empty + updated_address = Repo.get(Address, contract_address.hash) + assert updated_address.contract_code == %Data{bytes: <<>>} + end + end + + describe "last_unprocessed_identifiers/1" do + test "returns empty list when min_block_number is negative" do + state = %{"min_block_number" => -1} + + assert {[], %{"min_block_number" => -1}} = + EmptyBytecodeForSelfdestructedSmartContracts.last_unprocessed_identifiers(state) + end + + test "returns block numbers in descending order" do + state = %{"min_block_number" => 100} + + {block_numbers, new_state} = + EmptyBytecodeForSelfdestructedSmartContracts.last_unprocessed_identifiers(state) + + # Should return blocks in descending order + assert length(block_numbers) > 0 + assert block_numbers == Enum.sort(block_numbers, :desc) + assert hd(block_numbers) == 100 + assert new_state["min_block_number"] < 100 + end + + test "handles blocks down to zero" do + # Set a small number to test boundary + state = %{"min_block_number" => 5} + + {block_numbers, new_state} = + EmptyBytecodeForSelfdestructedSmartContracts.last_unprocessed_identifiers(state) + + assert length(block_numbers) > 0 + assert Enum.min(block_numbers) >= 0 + assert new_state["min_block_number"] <= 0 + end + end + + describe "update_cache/0" do + test "returns :ok" do + assert :ok = EmptyBytecodeForSelfdestructedSmartContracts.update_cache() + end + end +end diff --git a/apps/explorer/test/explorer/migrator/fill_internal_transactions_address_ids_test.exs b/apps/explorer/test/explorer/migrator/fill_internal_transactions_address_ids_test.exs new file mode 100644 index 000000000000..bc9da6774221 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/fill_internal_transactions_address_ids_test.exs @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.FillInternalTransactionsAddressIdsTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.InternalTransaction + alias Explorer.Migrator.{FillInternalTransactionsAddressIds, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError + alias Explorer.Repo + alias Explorer.Utility.AddressIdToAddressHash + + test "clears address hashes" do + Enum.map(1..10, fn index -> + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction, + transaction: transaction, + index: index, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + end) + + Enum.map(1..10, fn index -> + transaction = + :transaction + |> insert() + |> with_block() + + insert(:internal_transaction_create, + transaction: transaction, + index: index, + block_number: transaction.block_number, + transaction_index: transaction.index + ) + end) + + MigrationStatus.set_status( + RemoveInternalTransactionsBlockHashTransactionHashBlockIndexError.migration_name(), + "completed" + ) + + assert MigrationStatus.get_status("fill_internal_transactions_address_ids") == nil + + Application.put_env(:explorer, FillInternalTransactionsAddressIds, batch_size: 100, timeout: 0) + + FillInternalTransactionsAddressIds.start_link([]) + + wait_for_results(fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"fill_internal_transactions_address_ids" and ms.status == "completed" + ) + ) + end) + + all_internal_transactions = Repo.all(InternalTransaction) + + assert Enum.all?( + all_internal_transactions, + &(is_nil(&1.from_address_hash) and is_nil(&1.to_address_hash) and is_nil(&1.created_contract_address_hash)) + ) + + assert Enum.all?( + all_internal_transactions, + &(not is_nil(&1.from_address_id) or not is_nil(&1.to_address_id) or + not is_nil(&1.created_contract_address_id)) + ) + + all_address_ids = + all_internal_transactions + |> Enum.flat_map(&[&1.from_address_id, &1.to_address_id, &1.created_contract_address_id]) + |> Enum.uniq() + |> Enum.reject(&is_nil/1) + + existing_address_ids = + AddressIdToAddressHash + |> Repo.all() + |> Enum.map(& &1.address_id) + + assert Enum.all?(all_address_ids, &Enum.member?(existing_address_ids, &1)) + end +end diff --git a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index_test.exs b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index_test.exs index c7145eb33130..89e696a67e79 100644 --- a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index_test.exs +++ b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_logs_address_hash_block_number_desc_index_desc_index_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashBlockNumberDescIndexDescIndexTest do use Explorer.DataCase, async: false @@ -37,10 +38,6 @@ defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateLogsAddressHashBlockNumb insert(:db_migration_status, migration_name: "heavy_indexes_create_logs_block_hash_index", status: "completed") - Process.sleep(150) - - assert MigrationStatus.get_status(migration_name) == "started" - Process.sleep(200) assert MigrationStatus.get_status(migration_name) == "completed" diff --git a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_transactions_created_contract_address_hash_w_pending_index_test.exs b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_transactions_created_contract_address_hash_w_pending_index_test.exs new file mode 100644 index 000000000000..a9b109de9043 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/create_transactions_created_contract_address_hash_w_pending_index_test.exs @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndexTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.Helper + alias Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex + alias Explorer.Repo + + describe "Creates heavy index `transactions_created_contract_address_hash_w_pending_index`" do + setup do + configuration = Application.get_env(:explorer, HeavyDbIndexOperation) + Application.put_env(:explorer, HeavyDbIndexOperation, check_interval: 200) + + migration_names = + [CreateTransactionsCreatedContractAddressHashWPendingIndex.migration_name()] ++ + CreateTransactionsCreatedContractAddressHashWPendingIndex.dependent_from_migrations() + + from(ms in MigrationStatus, where: ms.migration_name in ^migration_names) + |> Repo.delete_all() + + on_exit(fn -> + Application.put_env(:explorer, HeavyDbIndexOperation, configuration) + end) + + :ok + end + + if Application.compile_env(:explorer, :chain_type) != :optimism do + test "Creates heavy DB index with no dependencies" do + migration_name = "heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index" + index_name = "transactions_created_contract_address_hash_w_pending_index" + + assert MigrationStatus.get_status(migration_name) == nil + assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: false, valid?: nil} + + CreateTransactionsCreatedContractAddressHashWPendingIndex.start_link([]) + + Process.sleep(200) + + assert MigrationStatus.get_status(migration_name) == "completed" + + assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: true, valid?: true} + + assert BackgroundMigrations.get_heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished() == + true + end + end + + if Application.compile_env(:explorer, :chain_type) == :optimism do + test "waits for DropTransactionsOperatorFeeConstantIndex completion on optimism" do + [dependent_migration_name] = + CreateTransactionsCreatedContractAddressHashWPendingIndex.dependent_from_migrations() + + migration_name = "heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index" + index_name = "transactions_created_contract_address_hash_w_pending_index" + + assert MigrationStatus.get_status(migration_name) == nil + assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: false, valid?: nil} + + CreateTransactionsCreatedContractAddressHashWPendingIndex.start_link([]) + Process.sleep(100) + + # Should not start until dependency is completed. + assert MigrationStatus.get_status(migration_name) == nil + + insert(:db_migration_status, migration_name: dependent_migration_name, status: "completed") + + Process.sleep(200) + + assert MigrationStatus.get_status(migration_name) == "completed" + assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: true, valid?: true} + + assert BackgroundMigrations.get_heavy_indexes_create_transactions_created_contract_address_hash_w_pending_index_finished() == + true + end + end + end +end diff --git a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index_test.exs b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index_test.exs index 7654c01900d9..889e71eab652 100644 --- a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index_test.exs +++ b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_logs_block_number_asc_index_asc_index_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.DropLogsBlockNumberAscIndexAscIndexTest do use Explorer.DataCase, async: false @@ -26,9 +27,6 @@ defmodule Explorer.Migrator.DropLogsBlockNumberAscIndexAscIndexTest do assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: true, valid?: true} DropLogsBlockNumberAscIndexAscIndex.start_link([]) - Process.sleep(100) - - assert MigrationStatus.get_status(migration_name) == "started" Process.sleep(200) diff --git a/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index_a_test.exs b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index_a_test.exs new file mode 100644 index 000000000000..0e5f98fb5d51 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/heavy_db_index_operation/drop_transactions_created_contract_address_hash_with_pending_index_a_test.exs @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexATest do + use Explorer.DataCase, async: false + + alias Explorer.Chain.Cache.BackgroundMigrations + alias Explorer.Migrator.{HeavyDbIndexOperation, MigrationStatus} + alias Explorer.Migrator.HeavyDbIndexOperation.CreateTransactionsCreatedContractAddressHashWPendingIndex + alias Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsCreatedContractAddressHashWithPendingIndexA + alias Explorer.Migrator.HeavyDbIndexOperation.Helper + + describe "Drops heavy index `transactions_created_contract_address_hash_with_pending_index_a`" do + setup do + configuration = Application.get_env(:explorer, HeavyDbIndexOperation) + Application.put_env(:explorer, HeavyDbIndexOperation, check_interval: 200) + + on_exit(fn -> + Application.put_env(:explorer, HeavyDbIndexOperation, configuration) + end) + + :ok + end + + test "Drops heavy DB index with dependency on create migration" do + create_migration_name = CreateTransactionsCreatedContractAddressHashWPendingIndex.migration_name() + drop_migration_name = "heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a" + index_name = "transactions_created_contract_address_hash_with_pending_index_a" + + assert MigrationStatus.get_status(drop_migration_name) == nil + assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: true, valid?: true} + + DropTransactionsCreatedContractAddressHashWithPendingIndexA.start_link([]) + Process.sleep(100) + + # Should not start until dependency is completed + assert MigrationStatus.get_status(drop_migration_name) == nil + + # Mark create migration as completed + insert(:db_migration_status, migration_name: create_migration_name, status: "completed") + + Process.sleep(200) + + assert MigrationStatus.get_status(drop_migration_name) == "completed" + + assert Helper.db_index_exists_and_valid?(index_name) == %{exists?: false, valid?: nil} + + assert BackgroundMigrations.get_heavy_indexes_drop_transactions_created_contract_address_hash_with_pending_index_a_finished() == + true + end + end +end diff --git a/apps/explorer/test/explorer/migrator/merge_adjacent_missing_block_ranges_test.exs b/apps/explorer/test/explorer/migrator/merge_adjacent_missing_block_ranges_test.exs new file mode 100644 index 000000000000..5675d9dd35f9 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/merge_adjacent_missing_block_ranges_test.exs @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.MergeAdjacentMissingBlockRangesTest do + use Explorer.DataCase, async: false + + alias Explorer.Migrator.MergeAdjacentMissingBlockRanges + alias Explorer.Repo + alias Explorer.Utility.MissingBlockRange + + test "Merges adjacent ranges" do + insert(:block) + + Repo.delete_all(MissingBlockRange) + + insert(:missing_block_range, from_number: 100, to_number: 70, priority: nil) + insert(:missing_block_range, from_number: 69, to_number: 60, priority: nil) + insert(:missing_block_range, from_number: 59, to_number: 50, priority: 1) + insert(:missing_block_range, from_number: 45, to_number: 45, priority: nil) + insert(:missing_block_range, from_number: 40, to_number: 30, priority: 1) + insert(:missing_block_range, from_number: 29, to_number: 20, priority: 1) + insert(:missing_block_range, from_number: 19, to_number: 10, priority: nil) + insert(:missing_block_range, from_number: 9, to_number: 5, priority: nil) + insert(:missing_block_range, from_number: 4, to_number: 0, priority: nil) + + MergeAdjacentMissingBlockRanges.start_link([]) + Process.sleep(100) + + ranges = Repo.all(MissingBlockRange) + + assert length(ranges) == 5 + + assert Enum.any?(ranges, fn range -> + range.from_number == 100 and range.to_number == 60 and range.priority == nil + end) + + assert Enum.any?(ranges, fn range -> + range.from_number == 59 and range.to_number == 50 and range.priority == 1 + end) + + assert Enum.any?(ranges, fn range -> + range.from_number == 45 and range.to_number == 45 and range.priority == nil + end) + + assert Enum.any?(ranges, fn range -> + range.from_number == 40 and range.to_number == 20 and range.priority == 1 + end) + + assert Enum.any?(ranges, fn range -> + range.from_number == 19 and range.to_number == 0 and range.priority == nil + end) + + Repo.delete_all(MissingBlockRange) + end +end diff --git a/apps/explorer/test/explorer/migrator/reindex_blocks_with_missing_transactions_test.exs b/apps/explorer/test/explorer/migrator/reindex_blocks_with_missing_transactions_test.exs new file mode 100644 index 000000000000..d8058538eb46 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/reindex_blocks_with_missing_transactions_test.exs @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.ReindexBlocksWithMissingTransactionsTest do + use Explorer.DataCase, async: false + + import Mox + + alias Explorer.Chain.Block + alias Explorer.Migrator.{MigrationStatus, ReindexBlocksWithMissingTransactions} + alias Explorer.Repo + + setup :set_mox_global + setup :verify_on_exit! + + setup do + configuration = Application.get_env(:explorer, ReindexBlocksWithMissingTransactions) + + Application.put_env( + :explorer, + ReindexBlocksWithMissingTransactions, + Keyword.merge(configuration, batch_size: 1, concurrency: 1) + ) + + on_exit(fn -> + Application.put_env(:explorer, ReindexBlocksWithMissingTransactions, configuration) + end) + end + + test "Reindex blocks with missing transactions" do + %{block: %{number: block_number_correct}} = + :transaction + |> insert() + |> with_block() + + correct_block_number_quantity = EthereumJSONRPC.integer_to_quantity(block_number_correct) + + %{block: %{number: block_number_incorrect}} = + :transaction + |> insert() + |> with_block() + + incorrect_block_number_quantity = EthereumJSONRPC.integer_to_quantity(block_number_incorrect) + + expect(EthereumJSONRPC.Mox, :json_rpc, 2, fn + [ + %{ + id: id, + method: "eth_getBlockTransactionCountByNumber", + params: [^correct_block_number_quantity] + } + ], + _ -> + {:ok, [%{id: id, result: "0x1", jsonrpc: "2.0"}]} + + [ + %{ + id: id, + method: "eth_getBlockTransactionCountByNumber", + params: [^incorrect_block_number_quantity] + } + ], + _ -> + {:ok, [%{id: id, result: "0x2", jsonrpc: "2.0"}]} + end) + + assert MigrationStatus.get_status("reindex_blocks_with_missing_transactions") == nil + + ReindexBlocksWithMissingTransactions.start_link([]) + + wait_for_results( + fn -> + Repo.one!( + from(ms in MigrationStatus, + where: ms.migration_name == ^"reindex_blocks_with_missing_transactions" and ms.status == "completed" + ) + ) + end, + 60 + ) + + assert %{consensus: true, refetch_needed: false} = Repo.get_by(Block, number: block_number_correct) + assert %{consensus: true, refetch_needed: true} = Repo.get_by(Block, number: block_number_incorrect) + end +end diff --git a/apps/explorer/test/explorer/migrator/reindex_internal_transactions_with_incompatible_status_test.exs b/apps/explorer/test/explorer/migrator/reindex_internal_transactions_with_incompatible_status_test.exs index 77eb929fde9a..5d53dbab48f1 100644 --- a/apps/explorer/test/explorer/migrator/reindex_internal_transactions_with_incompatible_status_test.exs +++ b/apps/explorer/test/explorer/migrator/reindex_internal_transactions_with_incompatible_status_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatusTest do use Explorer.DataCase, async: false @@ -22,15 +23,17 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatusTes insert(:internal_transaction, index: 10, transaction: transaction, + transaction_index: transaction.index, block: block, block_number: block.number, - block_index: i, error: nil ) block.number end) + transaction_error = insert(:transaction_error, message: "error") + Enum.each(1..5, fn i -> block = insert(:block) transaction = :transaction |> insert() |> with_block(block, status: :error) @@ -38,10 +41,11 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatusTes insert(:internal_transaction, index: 10, transaction: transaction, + transaction_index: transaction.index, block: block, block_number: block.number, - block_index: i, error: "error", + error_id: transaction_error.id, output: nil ) end) @@ -53,9 +57,9 @@ defmodule Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleStatusTes insert(:internal_transaction, index: 10, transaction: transaction, + transaction_index: transaction.index, block: block, block_number: block.number, - block_index: i, error: nil ) end) diff --git a/apps/explorer/test/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources_test.exs b/apps/explorer/test/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources_test.exs new file mode 100644 index 000000000000..2c4683396994 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/sanitize_duplicate_smart_contract_additional_sources_test.exs @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.SanitizeDuplicateSmartContractAdditionalSourcesTest do + use Explorer.DataCase, async: false + + import Ecto.Query + + alias Explorer.Chain.SmartContractAdditionalSource + alias Explorer.Migrator.{MigrationStatus, SanitizeDuplicateSmartContractAdditionalSources} + alias Explorer.Repo + + describe "sanitize duplicates in smart_contracts_additional_sources" do + test "removes duplicate rows keeping a single record per (address_hash, file_name)" do + insert(:block) + + sc1 = insert(:smart_contract) + sc2 = insert(:smart_contract) + + # for sc1: create duplicates for FileA.sol and a unique FileB.sol + attrs_a = %{address_hash: sc1.address_hash, file_name: "FileA.sol", contract_source_code: "// A1"} + attrs_a_dup = %{address_hash: sc1.address_hash, file_name: "FileA.sol", contract_source_code: "// A2"} + attrs_b = %{address_hash: sc1.address_hash, file_name: "FileB.sol", contract_source_code: "// B"} + + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(attrs_a) |> Repo.insert!() + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(attrs_a_dup) |> Repo.insert!() + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(attrs_b) |> Repo.insert!() + + # for sc2: duplicates for the same file name, independent from sc1 + attrs_c = %{address_hash: sc2.address_hash, file_name: "Common.sol", contract_source_code: "// C1"} + attrs_c_dup = %{address_hash: sc2.address_hash, file_name: "Common.sol", contract_source_code: "// C2"} + + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(attrs_c) |> Repo.insert!() + %SmartContractAdditionalSource{} |> SmartContractAdditionalSource.changeset(attrs_c_dup) |> Repo.insert!() + + assert MigrationStatus.get_status("sanitize_duplicate_smart_contract_additional_sources") == nil + + SanitizeDuplicateSmartContractAdditionalSources.start_link([]) + Process.sleep(150) + + # Migration completes and duplicates are deleted + assert MigrationStatus.get_status("sanitize_duplicate_smart_contract_additional_sources") == "completed" + + remaining = + SmartContractAdditionalSource + |> order_by([s], asc: s.address_hash, asc: s.file_name, asc: s.id) + |> Repo.all() + + # Expect one per (address_hash, file_name) + grouped = Enum.group_by(remaining, &{&1.address_hash, &1.file_name}) + assert grouped |> Map.values() |> Enum.all?(fn list -> length(list) == 1 end) + + # Ensure the specific keys exist with a single row + assert Map.has_key?(grouped, {sc1.address_hash, "FileA.sol"}) + assert Map.has_key?(grouped, {sc1.address_hash, "FileB.sol"}) + assert Map.has_key?(grouped, {sc2.address_hash, "Common.sol"}) + end + + test "completes gracefully when there are no duplicates" do + insert(:block) + sc = insert(:smart_contract) + + %SmartContractAdditionalSource{} + |> SmartContractAdditionalSource.changeset(%{ + address_hash: sc.address_hash, + file_name: "Unique.sol", + contract_source_code: "// only" + }) + |> Repo.insert!() + + assert MigrationStatus.get_status("sanitize_duplicate_smart_contract_additional_sources") == nil + + SanitizeDuplicateSmartContractAdditionalSources.start_link([]) + Process.sleep(100) + + assert MigrationStatus.get_status("sanitize_duplicate_smart_contract_additional_sources") == "completed" + + count = Repo.aggregate(SmartContractAdditionalSource, :count) + assert count == 1 + end + end +end diff --git a/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs b/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs index 5fbdc25a1ad9..0c6749b05cfc 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_duplicated_log_index_logs_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogsTest do use Explorer.DataCase, async: false @@ -7,8 +8,22 @@ defmodule Explorer.Migrator.SanitizeDuplicatedLogIndexLogsTest do alias Explorer.Chain.Token.Instance alias Explorer.Migrator.{SanitizeDuplicatedLogIndexLogs, MigrationStatus} - if Application.compile_env(:explorer, :chain_type) in [:polygon_zkevm, :rsk, :filecoin] do + if Application.compile_env(:explorer, :chain_type) in [:rsk, :filecoin] do describe "Sanitize duplicated log index logs" do + setup do + configuration = Application.get_env(:explorer, SanitizeDuplicatedLogIndexLogs) + + Application.put_env( + :explorer, + SanitizeDuplicatedLogIndexLogs, + Keyword.merge(configuration, batch_size: 50_000, concurrency: 10) + ) + + on_exit(fn -> + Application.put_env(:explorer, SanitizeDuplicatedLogIndexLogs, configuration) + end) + end + test "correctly identifies and updates duplicated log index logs" do block = insert(:block) diff --git a/apps/explorer/test/explorer/migrator/sanitize_empty_contract_code_addresses_test.exs b/apps/explorer/test/explorer/migrator/sanitize_empty_contract_code_addresses_test.exs index 14fa500d579f..700f150fcf89 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_empty_contract_code_addresses_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_empty_contract_code_addresses_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeEmptyContractCodeAddressesTest do use Explorer.DataCase, async: false diff --git a/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs b/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs index 8ede4a09c853..5f51fc103ffe 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_incorrect_nft_token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeIncorrectNFTTokenTransfersTest do use Explorer.DataCase, async: false diff --git a/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs b/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs index 5d9b7246e260..d0d4daacaf34 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_incorrect_weth_token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do use Explorer.DataCase, async: false @@ -145,6 +146,7 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do token_address_hash = token_address.hash whitelisted_token_address_hash = whitelisted_token_address.hash + transfers = Repo.all(TokenTransfer, order_by: [asc: :block_number, asc: :log_index]) assert [ %{token_contract_address_hash: ^token_address_hash}, @@ -152,7 +154,7 @@ defmodule Explorer.Migrator.SanitizeIncorrectWETHTokenTransfersTest do %{token_contract_address_hash: ^whitelisted_token_address_hash}, %{token_contract_address_hash: ^whitelisted_token_address_hash}, %{token_contract_address_hash: ^whitelisted_token_address_hash} - ] = transfers = Repo.all(TokenTransfer, order_by: [asc: :block_number, asc: :log_index]) + ] = transfers withdrawal = Enum.at(transfers, 1) deposit = Enum.at(transfers, 2) diff --git a/apps/explorer/test/explorer/migrator/sanitize_missing_token_balances_test.exs b/apps/explorer/test/explorer/migrator/sanitize_missing_token_balances_test.exs index e8c990e2011e..c351fbd230e8 100644 --- a/apps/explorer/test/explorer/migrator/sanitize_missing_token_balances_test.exs +++ b/apps/explorer/test/explorer/migrator/sanitize_missing_token_balances_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SanitizeMissingTokenBalancesTest do use Explorer.DataCase, async: false @@ -7,6 +8,8 @@ defmodule Explorer.Migrator.SanitizeMissingTokenBalancesTest do describe "Migrate token balances" do test "Unset value and value_fetched_at for token balances related to not processed current token balances" do + insert(:block) + Enum.each(0..10, fn _x -> token_balance = insert(:token_balance) diff --git a/apps/explorer/test/explorer/migrator/smart_contract_language_test.exs b/apps/explorer/test/explorer/migrator/smart_contract_language_test.exs deleted file mode 100644 index 4fafc7285cb3..000000000000 --- a/apps/explorer/test/explorer/migrator/smart_contract_language_test.exs +++ /dev/null @@ -1,51 +0,0 @@ -defmodule Explorer.Migrator.SmartContractLanguageTest do - use Explorer.DataCase, async: false - - alias Explorer.Migrator.{MigrationStatus, SmartContractLanguage} - alias Explorer.Chain.{Cache.BackgroundMigrations, SmartContract} - alias Explorer.Repo - - defp create_contracts(attrs) do - for _ <- 1..10, do: insert(:smart_contract, attrs) - end - - describe "smart_contract_language migration" do - test "backfills language for vyper, yul, solidity" do - # Create groups of contracts with different attributes - vyper_contracts = - create_contracts(is_vyper_contract: true, language: nil) - - yul_contracts = - create_contracts(is_vyper_contract: false, abi: nil, language: nil) - - solidity_contracts = - create_contracts(is_vyper_contract: false, language: nil) - - # Ensure migration has not run yet - assert MigrationStatus.get_status("smart_contract_language") == nil - - # Start the migration process and wait briefly for it to complete - SmartContractLanguage.start_link([]) - Process.sleep(200) - - # Confirm that the contracts have been updated with the correct language - [ - {vyper_contracts, :vyper}, - {yul_contracts, :yul}, - {solidity_contracts, :solidity} - ] - |> Enum.each(fn {contracts, expected_language} -> - updated = - SmartContract - |> where([sc], sc.address_hash in ^Enum.map(contracts, & &1.address_hash)) - |> Repo.all() - - assert Enum.all?(updated, &(&1.language == expected_language)) - end) - - # Confirm the migration status has been marked as completed - assert MigrationStatus.get_status("smart_contract_language") == "completed" - assert BackgroundMigrations.get_smart_contract_language_finished() - end - end -end diff --git a/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs b/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs index 2123e1a1ad42..47ae88ddc32e 100644 --- a/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs +++ b/apps/explorer/test/explorer/migrator/switch_pending_operations_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.SwitchPendingOperationsTest do use Explorer.DataCase, async: false @@ -16,8 +17,6 @@ defmodule Explorer.Migrator.SwitchPendingOperationsTest do end) end - # TODO: remove tag after the migration of internal transactions PK to [:block_hash, :transaction_index, :index] - @tag :skip test "from pbo to pto" do first_block = insert(:block) second_block = insert(:block) @@ -50,6 +49,56 @@ defmodule Explorer.Migrator.SwitchPendingOperationsTest do assert [_, _, _, _, _] = Repo.all(PendingTransactionOperation) end + test "from pbo to pto handles parameter overflow and still completes" do + block = insert(:block) + insert(:pending_block_operation, block_number: block.number, block_hash: block.hash) + + 5 + |> insert_list(:transaction) + |> with_block(block) + + json_rpc_config = Application.get_env(:explorer, :json_rpc_named_arguments) + + Application.put_env( + :explorer, + :json_rpc_named_arguments, + Keyword.put(json_rpc_config, :variant, EthereumJSONRPC.Geth) + ) + + geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(geth_config, :block_traceable?, false)) + + overflow_raised? = :atomics.new(1, []) + + :meck.new(Repo, [:passthrough]) + + :meck.expect(Repo, :insert_all, fn kind, elements, opts -> + if kind == PendingTransactionOperation and :atomics.get(overflow_raised?, 1) == 0 do + :atomics.put(overflow_raised?, 1, 1) + + raise Postgrex.QueryError, + message: "postgresql protocol can not handle 135090 parameters, the maximum is 65535" + else + :meck.passthrough([kind, elements, opts]) + end + end) + + on_exit(fn -> + try do + :meck.unload(Repo) + catch + _, _ -> :ok + end + end) + + SwitchPendingOperations.start_link([]) + Process.sleep(100) + + assert :atomics.get(overflow_raised?, 1) == 1 + assert [] = Repo.all(PendingBlockOperation) + assert [_, _, _, _, _] = Repo.all(PendingTransactionOperation) + end + test "from pto to pbo" do first_block = insert(:block) second_block = insert(:block) @@ -64,7 +113,9 @@ defmodule Explorer.Migrator.SwitchPendingOperationsTest do |> insert_list(:transaction) |> with_block(second_block) - Enum.each(transactions_1 ++ transactions_2, fn %{hash: transaction_hash} -> + pending_transactions = insert_list(4, :transaction) + + Enum.each(transactions_1 ++ transactions_2 ++ pending_transactions, fn %{hash: transaction_hash} -> insert(:pending_transaction_operation, transaction_hash: transaction_hash) end) diff --git a/apps/explorer/test/explorer/migrator/token_transfer_token_type_test.exs b/apps/explorer/test/explorer/migrator/token_transfer_token_type_test.exs index b6cc0ee1c372..7ead866769b0 100644 --- a/apps/explorer/test/explorer/migrator/token_transfer_token_type_test.exs +++ b/apps/explorer/test/explorer/migrator/token_transfer_token_type_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.TokenTransferTokenTypeTest do use Explorer.DataCase, async: false diff --git a/apps/explorer/test/explorer/migrator/transactions_denormalization_migrator_test.exs b/apps/explorer/test/explorer/migrator/transactions_denormalization_migrator_test.exs index e47afe96da4d..ed50852b84f5 100644 --- a/apps/explorer/test/explorer/migrator/transactions_denormalization_migrator_test.exs +++ b/apps/explorer/test/explorer/migrator/transactions_denormalization_migrator_test.exs @@ -1,7 +1,7 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Migrator.TransactionsDenormalizationTest do use Explorer.DataCase, async: false - alias Explorer.Chain.Cache.BackgroundMigrations alias Explorer.Chain.Transaction alias Explorer.Migrator.{MigrationStatus, TransactionsDenormalization} alias Explorer.Repo diff --git a/apps/explorer/test/explorer/migrator/unescape_ampersands_in_tokens_test.exs b/apps/explorer/test/explorer/migrator/unescape_ampersands_in_tokens_test.exs new file mode 100644 index 000000000000..1c5c06f45502 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/unescape_ampersands_in_tokens_test.exs @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.UnescapeAmpersandsInTokensTest do + use Explorer.DataCase, async: false + + alias Explorer.Migrator.{UnescapeAmpersandsInTokens, MigrationStatus} + alias Explorer.Repo + + test "Unescapes ampersands in tokens" do + insert(:block) + + escaped_name_token = insert(:token, name: "Rock & Roll") + escaped_symbol_token = insert(:token, symbol: "R&R") + escaped_both_token = insert(:token, name: "Tom & Jerry", symbol: "T&J") + common_token = :token |> insert() |> Repo.reload() + + assert MigrationStatus.get_status("unescape_ampersands_in_tokens") == nil + UnescapeAmpersandsInTokens.start_link([]) + + Process.sleep(100) + + assert Repo.reload(escaped_name_token).name == "Rock & Roll" + assert Repo.reload(escaped_symbol_token).symbol == "R&R" + assert %{name: "Tom & Jerry", symbol: "T&J"} = Repo.reload(escaped_both_token) + assert ^common_token = Repo.reload(common_token) + + assert MigrationStatus.get_status("unescape_ampersands_in_tokens") == "completed" + end +end diff --git a/apps/explorer/test/explorer/migrator/unescape_quotes_in_tokens_test.exs b/apps/explorer/test/explorer/migrator/unescape_quotes_in_tokens_test.exs new file mode 100644 index 000000000000..fd68134fbcf3 --- /dev/null +++ b/apps/explorer/test/explorer/migrator/unescape_quotes_in_tokens_test.exs @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Migrator.UnescapeQuotesInTokensTest do + use Explorer.DataCase, async: false + + alias Explorer.Migrator.{UnescapeQuotesInTokens, MigrationStatus} + alias Explorer.Repo + + test "Unescapes quotes in tokens" do + insert(:block) + + escaped_name_token = insert(:token, name: "Smth's") + escaped_symbol_token = insert(:token, symbol: ""Double quoted"") + escaped_both_token = insert(:token, name: "Smth's", symbol: ""Double quoted"") + common_token = :token |> insert() |> Repo.reload() + + assert MigrationStatus.get_status("unescape_quotes_in_tokens") == nil + UnescapeQuotesInTokens.start_link([]) + + Process.sleep(100) + + assert Repo.reload(escaped_name_token).name == "Smth's" + assert Repo.reload(escaped_symbol_token).symbol == "\"Double quoted\"" + assert %{name: "Smth's", symbol: "\"Double quoted\""} = Repo.reload(escaped_both_token) + assert ^common_token = Repo.reload(common_token) + + assert MigrationStatus.get_status("unescape_quotes_in_tokens") == "completed" + end +end diff --git a/apps/explorer/test/explorer/module_queue_registry_test.exs b/apps/explorer/test/explorer/module_queue_registry_test.exs new file mode 100644 index 000000000000..0ccb2efcb224 --- /dev/null +++ b/apps/explorer/test/explorer/module_queue_registry_test.exs @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.ModuleQueueRegistryTest do + use ExUnit.Case, async: false + + alias Explorer.BoundQueue + alias Explorer.ModuleQueueRegistry + + defmodule Registry do + use ModuleQueueRegistry + + @impl true + def table_name(module) do + :"#{module}_module_queue_registry_test" + end + end + + defmodule ModuleA do + end + + defmodule ModuleB do + end + + setup do + for table_name <- [Registry.table_name(ModuleA), Registry.table_name(ModuleB)] do + if :ets.whereis(table_name) != :undefined do + :ets.delete(table_name) + end + end + + :ok + end + + test "pop/1 returns nil for an empty queue" do + assert Registry.pop(ModuleA) == nil + end + + test "push/2 returns true and pop/1 returns values in FIFO order" do + assert Registry.push(1, ModuleA) == true + assert Registry.push(2, ModuleA) == true + + assert Registry.pop(ModuleA) == 1 + assert Registry.pop(ModuleA) == 2 + assert Registry.pop(ModuleA) == nil + end + + test "queues are isolated by module table name" do + assert Registry.push(:a, ModuleA) == true + assert Registry.push(:b, ModuleB) == true + + assert Registry.pop(ModuleA) == :a + assert Registry.pop(ModuleB) == :b + end + + test "push/2 returns {:error, :maximum_size} when queue is full" do + table_name = Registry.table_name(ModuleA) + + :ets.new(table_name, [:set, :named_table, :public]) + :ets.insert(table_name, {:queue, %BoundQueue{queue: :queue.from_list([:existing]), size: 1, maximum_size: 1}}) + + assert Registry.push(:overflow, ModuleA) == {:error, :maximum_size} + assert Registry.pop(ModuleA) == :existing + assert Registry.pop(ModuleA) == nil + end +end diff --git a/apps/explorer/test/explorer/promo/autoscout_test.exs b/apps/explorer/test/explorer/promo/autoscout_test.exs new file mode 100644 index 000000000000..99f2e1c54e1d --- /dev/null +++ b/apps/explorer/test/explorer/promo/autoscout_test.exs @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Promo.AutoscoutTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureLog, only: [capture_log: 1] + + alias Explorer.Promo.Autoscout + + @promo_line "Deploy Blockscout explorer in 5 minutes at deploy.blockscout.com" + + @moduletag :capture_log + + describe "init/1" do + test "returns the initial state" do + parent = self() + + pid = + spawn(fn -> + send(parent, {:init_result, Autoscout.init(nil)}) + + receive do + :stop -> :ok + end + end) + + assert_receive {:init_result, {:ok, %{}}} + send(pid, :stop) + end + end + + describe "handle_info/2" do + test "logs promo and keeps state unchanged" do + state = %{example: :state} + + log = + capture_log(fn -> + assert {:noreply, ^state} = Autoscout.handle_info(:promo, state) + end) + + assert log =~ @promo_line + end + end +end diff --git a/apps/explorer/test/explorer/repo/config_helper_test.exs b/apps/explorer/test/explorer/repo/config_helper_test.exs index 71bb85077e31..768dde53ae4c 100644 --- a/apps/explorer/test/explorer/repo/config_helper_test.exs +++ b/apps/explorer/test/explorer/repo/config_helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Repo.ConfigHelperTest do use Explorer.DataCase @@ -128,4 +129,102 @@ defmodule Explorer.Repo.ConfigHelperTest do assert result[:database] == "test_database" end end + + describe "ecto_ssl_mode/2" do + test "defaults to require when mode is not set" do + assert ConfigHelper.ecto_ssl_mode(nil, fn _ -> nil end) == "require" + end + + test "reads sslmode from database url" do + database_url = "postgresql://test:test@localhost:5432/test_db?sslmode=verify-full" + + assert ConfigHelper.ecto_ssl_mode(database_url, fn _ -> nil end) == "verify-full" + end + + test "ECTO_SSL_MODE has precedence over database url sslmode" do + database_url = "postgresql://test:test@localhost:5432/test_db?sslmode=disable" + + env_func = fn + "ECTO_SSL_MODE" -> "verify-ca" + _ -> nil + end + + assert ConfigHelper.ecto_ssl_mode(database_url, env_func) == "verify-ca" + end + + test "raises on invalid ssl mode" do + env_func = fn + "ECTO_SSL_MODE" -> "invalid-mode" + _ -> nil + end + + assert_raise ArgumentError, ~r/Unsupported ECTO_SSL_MODE value/, fn -> + ConfigHelper.ecto_ssl_mode(nil, env_func) + end + end + end + + describe "ssl_options/2" do + test "defaults to require (verify_none) when mode is not set" do + assert ConfigHelper.ssl_options(nil, fn _ -> nil end) == [ssl: [verify: :verify_none]] + end + + test "maps disable to ssl false" do + env_func = fn + "ECTO_SSL_MODE" -> "disable" + _ -> nil + end + + assert ConfigHelper.ssl_options(nil, env_func) == [ssl: false] + end + + test "maps allow to verify_none SSL" do + env_func = fn + "ECTO_SSL_MODE" -> "allow" + _ -> nil + end + + assert ConfigHelper.ssl_options(nil, env_func) == [ssl: [verify: :verify_none]] + end + + test "maps prefer to verify_none SSL" do + env_func = fn + "ECTO_SSL_MODE" -> "prefer" + _ -> nil + end + + assert ConfigHelper.ssl_options(nil, env_func) == [ssl: [verify: :verify_none]] + end + + test "maps require to verify_none SSL" do + env_func = fn + "ECTO_SSL_MODE" -> "require" + _ -> nil + end + + assert ConfigHelper.ssl_options(nil, env_func) == [ssl: [verify: :verify_none]] + end + + test "maps verify-ca to peer verification without SNI" do + env_func = fn + "ECTO_SSL_MODE" -> "verify-ca" + _ -> nil + end + + ssl_options = ConfigHelper.ssl_options(nil, env_func)[:ssl] + + assert ssl_options[:verify] == :verify_peer + assert ssl_options[:server_name_indication] == :disable + assert ssl_options[:cacerts] == :public_key.cacerts_get() + end + + test "maps verify-full to secure Postgrex defaults" do + env_func = fn + "ECTO_SSL_MODE" -> "verify-full" + _ -> nil + end + + assert ConfigHelper.ssl_options(nil, env_func) == [ssl: true] + end + end end diff --git a/apps/explorer/test/explorer/repo_test.exs b/apps/explorer/test/explorer/repo_test.exs index e414f8745df4..5ff7bd31e8ed 100644 --- a/apps/explorer/test/explorer/repo_test.exs +++ b/apps/explorer/test/explorer/repo_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.RepoTest do use Explorer.DataCase @@ -17,11 +18,8 @@ defmodule Explorer.RepoTest do :internal_transaction, from_address_hash: insert(:address).hash, to_address_hash: insert(:address).hash, - transaction_hash: transaction.hash, index: 0, block_number: 35, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: 0 ) @@ -35,7 +33,7 @@ defmodule Explorer.RepoTest do Repo.safe_insert_all( InternalTransaction, [timestamped_changes, timestamped_changes], - conflict_target: [:block_hash, :block_index], + conflict_target: [:block_number, :transaction_index, :index], on_conflict: :replace_all ) end @@ -44,7 +42,8 @@ defmodule Explorer.RepoTest do assert log =~ "Chunk:\n" assert log =~ "index: 0" - assert log =~ "Options:\n\n[conflict_target: [:block_hash, :block_index], on_conflict: :replace_all]\n\n" + assert log =~ + "Options:\n\n[conflict_target: [:block_number, :transaction_index, :index], on_conflict: :replace_all]\n\n" assert log =~ "Exception:\n\n** (Postgrex.Error) ERROR 21000 (cardinality_violation) ON CONFLICT DO UPDATE command cannot affect row a second time\n" diff --git a/apps/explorer/test/explorer/smart_contract/certified_smart_contract_cataloger_test.exs b/apps/explorer/test/explorer/smart_contract/certified_smart_contract_cataloger_test.exs index b3cd088914a1..40a8e2ebd2ff 100644 --- a/apps/explorer/test/explorer/smart_contract/certified_smart_contract_cataloger_test.exs +++ b/apps/explorer/test/explorer/smart_contract/certified_smart_contract_cataloger_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.CertifiedSmartContractCatalogerTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/smart_contract/compiler_version_test.exs b/apps/explorer/test/explorer/smart_contract/compiler_version_test.exs index 86ec4c3d8b73..02906ca0b670 100644 --- a/apps/explorer/test/explorer/smart_contract/compiler_version_test.exs +++ b/apps/explorer/test/explorer/smart_contract/compiler_version_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.CompilerVersionTest do use ExUnit.Case @@ -19,6 +20,11 @@ defmodule Explorer.SmartContract.CompilerVersionTest do setup do bypass = Bypass.open() + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Application.put_env(:explorer, :solc_bin_api_url, "http://localhost:#{bypass.port}") {:ok, bypass: bypass} @@ -59,7 +65,7 @@ defmodule Explorer.SmartContract.CompilerVersionTest do test "returns error when there is server error", %{bypass: bypass} do Bypass.down(bypass) - assert {:error, :econnrefused} = CompilerVersion.fetch_versions(:solc) + assert {:error, %{reason: :econnrefused}} = CompilerVersion.fetch_versions(:solc) end end diff --git a/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs b/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs new file mode 100644 index 000000000000..52d4ab33fbca --- /dev/null +++ b/apps/explorer/test/explorer/smart_contract/geas/publisher_test.exs @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.SmartContract.Geas.PublisherTest do + use ExUnit.Case, async: true + + use Explorer.DataCase + + doctest Explorer.SmartContract.Geas.Publisher + + @moduletag timeout: :infinity + + alias Explorer.Chain.{SmartContract} + alias Explorer.SmartContract.Geas.Publisher + + setup do + configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) + Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + end) + end + + describe "process_rust_verifier_response/6" do + test "successfully processes a GEAS verification response" do + contract_address = insert(:contract_address) + + geas_response = %{ + "fileName" => "src/consolidations/main.eas", + "contractName" => "ConsolidationRequestPredeploy", + "compilerVersion" => "v0.2.2", + "compilerSettings" => "{}", + "sourceType" => "GEAS", + "sourceFiles" => %{ + "src/common/fake_expo.eas" => "// Test GEAS contract source code", + "src/consolidations/ctor.eas" => "// Test GEAS contract source code", + "src/consolidations/main.eas" => "// Test GEAS contract source code" + }, + "abi" => + Jason.encode!([ + %{ + "type" => "function", + "name" => "test_function", + "inputs" => [], + "outputs" => [%{"type" => "bool"}], + "stateMutability" => "view" + } + ]), + "constructorArguments" => nil, + "matchType" => "PARTIAL", + "compilationArtifacts" => "{}", + "creationInputArtifacts" => "{}", + "deployedBytecodeArtifacts" => "{}", + "isBlueprint" => false, + "libraries" => %{} + } + + response = + Publisher.process_rust_verifier_response( + geas_response, + contract_address.hash, + %{}, + # save_file_path? + true, + # is_standard_json? + true, + # automatically_verified? + true + ) + + assert {:ok, %SmartContract{} = smart_contract} = response + + assert smart_contract.address_hash == contract_address.hash + assert smart_contract.name == "ConsolidationRequestPredeploy" + assert smart_contract.compiler_version == "v0.2.2" + assert smart_contract.file_path == "src/consolidations/main.eas" + + assert smart_contract.contract_source_code == + "// Test GEAS contract source code" + + assert smart_contract.language == :geas + assert smart_contract.verified_via_eth_bytecode_db == true + assert smart_contract.partially_verified == true + assert is_list(smart_contract.abi) + assert length(smart_contract.abi) == 1 + end + end +end diff --git a/apps/explorer/test/explorer/smart_contract/helper_test.exs b/apps/explorer/test/explorer/smart_contract/helper_test.exs index 98f67b99f537..ed6b38f1490b 100644 --- a/apps/explorer/test/explorer/smart_contract/helper_test.exs +++ b/apps/explorer/test/explorer/smart_contract/helper_test.exs @@ -1,7 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.HelperTest do use ExUnit.Case, async: true - use Explorer.DataCase + + import Mox + setup :verify_on_exit! + alias Explorer.SmartContract.Helper describe "payable?" do @@ -150,4 +154,77 @@ defmodule Explorer.SmartContract.HelperTest do refute Helper.read_with_wallet_method?(function) end end + + describe "get_binary_string_from_contract_getter/4" do + # TODO: https://github.com/blockscout/blockscout/issues/12544 + # test "returns bytes starting from 0x" do + # abi = [ + # %{ + # "type" => "function", + # "stateMutability" => "view", + # "outputs" => [%{"type" => "bytes16", "name" => "data", "internalType" => "bytes16"}], + # "name" => "getData", + # "inputs" => [] + # } + # ] + + # expect( + # EthereumJSONRPC.Mox, + # :json_rpc, + # fn [ + # %{ + # id: id, + # method: "eth_call", + # params: [%{data: "0x3bc5de30", to: "0x0000000000000000000000000000000000000001"}, _] + # } + # ], + # _options -> + # {:ok, + # [%{id: id, jsonrpc: "2.0", result: "0x3078313233343536373839404142434400000000000000000000000000000000"}]} + # end + # ) + + # assert "0x30783132333435363738394041424344" == + # Helper.get_binary_string_from_contract_getter( + # "3bc5de30", + # "0x0000000000000000000000000000000000000001", + # abi + # ) + # end + + test "returns address" do + abi = [ + %{ + "type" => "function", + "stateMutability" => "view", + "outputs" => [%{"type" => "address", "name" => "data", "internalType" => "address"}], + "name" => "getAddress", + "inputs" => [] + } + ] + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [ + %{ + id: id, + method: "eth_call", + params: [%{data: "0x38cc4831", to: "0x0000000000000000000000000000000000000001"}, _] + } + ], + _options -> + {:ok, + [%{id: id, jsonrpc: "2.0", result: "0x0000000000000000000000003078000000000000000000000000000000000001"}]} + end + ) + + assert "0x3078000000000000000000000000000000000001" == + Helper.get_binary_string_from_contract_getter( + "38cc4831", + "0x0000000000000000000000000000000000000001", + abi + ) + end + end end diff --git a/apps/explorer/test/explorer/smart_contract/reader_test.exs b/apps/explorer/test/explorer/smart_contract/reader_test.exs index 184a28cbd861..dd98ec5f8696 100644 --- a/apps/explorer/test/explorer/smart_contract/reader_test.exs +++ b/apps/explorer/test/explorer/smart_contract/reader_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.ReaderTest do use EthereumJSONRPC.Case use Explorer.DataCase diff --git a/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs b/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs index cef3b5465ea7..bb8f0bfb16dc 100644 --- a/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs +++ b/apps/explorer/test/explorer/smart_contract/solidity/code_compiler_test.exs @@ -1,400 +1,408 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.Solidity.CodeCompilerTest do use ExUnit.Case, async: true - doctest Explorer.SmartContract.Solidity.CodeCompiler + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - @moduletag timeout: :infinity + if @chain_type == :default do + # This doctest is often flaky + # doctest Explorer.SmartContract.Solidity.CodeCompiler - alias Explorer.Factory - alias Explorer.SmartContract.Solidity.CodeCompiler + @moduletag timeout: :infinity - @compiler_tests "#{File.cwd!()}/test/support/fixture/smart_contract/compiler_tests.json" - |> File.read!() - |> Jason.decode!() + alias Explorer.Factory + alias Explorer.SmartContract.Solidity.CodeCompiler - describe "run/2" do - setup do - configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + @compiler_tests "#{File.cwd!()}/test/support/fixture/smart_contract/compiler_tests.json" + |> File.read!() + |> Jason.decode!() - on_exit(fn -> - Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) - end) + describe "run/2" do + setup do + configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) + Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) - {:ok, - contract_code_info: Factory.contract_code_info(), - contract_code_info_modern_compiler: Factory.contract_code_info_modern_compiler()} - end + on_exit(fn -> + Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) - test "compiles the latest solidity version", %{contract_code_info: contract_code_info} do - response = - CodeCompiler.run( - name: contract_code_info.name, - compiler_version: contract_code_info.version, - code: contract_code_info.source_code, - optimize: contract_code_info.optimized, - evm_version: "byzantium" - ) - - assert {:ok, - %{ - "abi" => _, - "bytecode" => _, - "name" => _ - }} = response - end + {:ok, + contract_code_info: Factory.contract_code_info(), + contract_code_info_modern_compiler: Factory.contract_code_info_modern_compiler()} + end - test "compiles a optimized smart contract", %{ - contract_code_info_modern_compiler: contract_code_info_modern_compiler - } do - optimize = true - - response = - CodeCompiler.run( - name: contract_code_info_modern_compiler.name, - compiler_version: contract_code_info_modern_compiler.version, - code: contract_code_info_modern_compiler.source_code, - optimize: optimize, - evm_version: "byzantium" - ) - - assert {:ok, - %{ - "abi" => _, - "bytecode" => _, - "name" => _ - }} = response - end + test "compiles the latest solidity version", %{contract_code_info: contract_code_info} do + response = + CodeCompiler.run( + name: contract_code_info.name, + compiler_version: contract_code_info.version, + code: contract_code_info.source_code, + optimize: contract_code_info.optimized, + evm_version: "byzantium" + ) - test "compiles smart contract with default evm version", %{ - contract_code_info_modern_compiler: contract_code_info_modern_compiler - } do - optimize = true - - response = - CodeCompiler.run( - name: contract_code_info_modern_compiler.name, - compiler_version: contract_code_info_modern_compiler.version, - code: contract_code_info_modern_compiler.source_code, - optimize: optimize, - evm_version: "default" - ) - - assert {:ok, - %{ - "abi" => _, - "bytecode" => _, - "name" => _ - }} = response - end + assert {:ok, + %{ + "abi" => _, + "bytecode" => _, + "name" => _ + }} = response + end - test "compiles code with external libraries" do - Enum.each(@compiler_tests, fn compiler_test -> - compiler_version = compiler_test["compiler_version"] - external_libraries = compiler_test["external_libraries"] - name = compiler_test["name"] - optimize = compiler_test["optimize"] - contract = compiler_test["contract"] + test "compiles a optimized smart contract", %{ + contract_code_info_modern_compiler: contract_code_info_modern_compiler + } do + optimize = true - {:ok, result} = + response = CodeCompiler.run( - name: name, - compiler_version: compiler_version, - code: contract, + name: contract_code_info_modern_compiler.name, + compiler_version: contract_code_info_modern_compiler.version, + code: contract_code_info_modern_compiler.source_code, optimize: optimize, - evm_version: "byzantium", - external_libs: external_libraries + evm_version: "byzantium" ) - clean_result = remove_init_data_and_whisper_data(result["bytecode"]) - expected_result = remove_init_data_and_whisper_data(compiler_test["tx_input"]) - - assert expected_result == clean_result - end) - end - - test "compiles with constantinople evm version" do - optimize = false - name = "MyTest" - - code = """ - pragma solidity 0.5.2; - - contract MyTest { - constructor() public { - } - - mapping(address => bytes32) public myMapping; - - function contractHash(address _addr) public { - bytes32 hash; - assembly { hash := extcodehash(_addr) } - myMapping[_addr] = hash; - } - - function justHash(bytes memory _bytes) - public - pure - returns (bytes32) - { - return keccak256(_bytes); - } - } - """ - - version = "v0.5.2+commit.1df8f40c" - - evm_version = "constantinople" - - response = - CodeCompiler.run( - name: name, - compiler_version: version, - code: code, - optimize: optimize, - evm_version: evm_version - ) - - assert {:ok, - %{ - "abi" => _, - "bytecode" => _, - "name" => _ - }} = response - end - - test "compiles in an older solidity version" do - optimize = false - name = "SimpleStorage" - - code = """ - contract SimpleStorage { - uint storedData; + assert {:ok, + %{ + "abi" => _, + "bytecode" => _, + "name" => _ + }} = response + end - function set(uint x) public { - storedData = x; - } + test "compiles smart contract with default evm version", %{ + contract_code_info_modern_compiler: contract_code_info_modern_compiler + } do + optimize = true - function get() public constant returns (uint) { - return storedData; - } - } - """ + response = + CodeCompiler.run( + name: contract_code_info_modern_compiler.name, + compiler_version: contract_code_info_modern_compiler.version, + code: contract_code_info_modern_compiler.source_code, + optimize: optimize, + evm_version: "default" + ) - version = "v0.1.3+commit.028f561d" + assert {:ok, + %{ + "abi" => _, + "bytecode" => _, + "name" => _ + }} = response + end + + test "compiles code with external libraries" do + Enum.each(@compiler_tests, fn compiler_test -> + compiler_version = compiler_test["compiler_version"] + external_libraries = compiler_test["external_libraries"] + name = compiler_test["name"] + optimize = compiler_test["optimize"] + contract = compiler_test["contract"] + + {:ok, result} = + CodeCompiler.run( + name: name, + compiler_version: compiler_version, + code: contract, + optimize: optimize, + evm_version: "byzantium", + external_libs: external_libraries + ) + + clean_result = remove_init_data_and_whisper_data(result["bytecode"]) + expected_result = remove_init_data_and_whisper_data(compiler_test["tx_input"]) + + assert expected_result == clean_result + end) + end + + test "compiles with constantinople evm version" do + optimize = false + name = "MyTest" + + code = """ + pragma solidity 0.5.2; + + contract MyTest { + constructor() public { + } + + mapping(address => bytes32) public myMapping; + + function contractHash(address _addr) public { + bytes32 hash; + assembly { hash := extcodehash(_addr) } + myMapping[_addr] = hash; + } + + function justHash(bytes memory _bytes) + public + pure + returns (bytes32) + { + return keccak256(_bytes); + } + } + """ + + version = "v0.5.2+commit.1df8f40c" + + evm_version = "constantinople" + + response = + CodeCompiler.run( + name: name, + compiler_version: version, + code: code, + optimize: optimize, + evm_version: evm_version + ) - response = CodeCompiler.run(name: name, compiler_version: version, code: code, optimize: optimize) + assert {:ok, + %{ + "abi" => _, + "bytecode" => _, + "name" => _ + }} = response + end - assert {:ok, - %{ - "abi" => _, - "bytecode" => _, - "name" => _ - }} = response - end + test "compiles in an older solidity version" do + optimize = false + name = "SimpleStorage" - test "returns compilation error when compilation isn't possible", %{ - contract_code_info: contract_code_info - } do - wrong_code = "pragma solidity ^0.4.24; cont SimpleStorage { " - - response = - CodeCompiler.run( - name: contract_code_info.name, - compiler_version: contract_code_info.version, - code: wrong_code, - optimize: contract_code_info.optimized - ) - - assert {:error, :compilation, "Expected pragma, import directive or contract/interface/library definition."} = - response - end + code = """ + contract SimpleStorage { + uint storedData; - test "returns constructor in abi" do - code = """ - pragma solidity ^0.4.22; - - contract OwnedToken { - // TokenCreator is a contract type that is defined below. - // It is fine to reference it as long as it is not used - // to create a new contract. - TokenCreator creator; - address owner; - bytes32 name; - - // This is the constructor which registers the - // creator and the assigned name. - constructor(bytes32 _name) public { - // State variables are accessed via their name - // and not via e.g. this.owner. This also applies - // to functions and especially in the constructors, - // you can only call them like that ("internally"), - // because the contract itself does not exist yet. - owner = msg.sender; - // We do an explicit type conversion from `address` - // to `TokenCreator` and assume that the type of - // the calling contract is TokenCreator, there is - // no real way to check that. - creator = TokenCreator(msg.sender); - name = _name; + function set(uint x) public { + storedData = x; } - function changeName(bytes32 newName) public { - // Only the creator can alter the name -- - // the comparison is possible since contracts - // are implicitly convertible to addresses. - if (msg.sender == address(creator)) - name = newName; - } - - function transfer(address newOwner) public { - // Only the current owner can transfer the token. - if (msg.sender != owner) return; - // We also want to ask the creator if the transfer - // is fine. Note that this calls a function of the - // contract defined below. If the call fails (e.g. - // due to out-of-gas), the execution here stops - // immediately. - if (creator.isTokenTransferOK(owner, newOwner)) - owner = newOwner; + function get() public constant returns (uint) { + return storedData; } } + """ - contract TokenCreator { - function createToken(bytes32 name) - public - returns (OwnedToken tokenAddress) - { - // Create a new Token contract and return its address. - // From the JavaScript side, the return type is simply - // `address`, as this is the closest type available in - // the ABI. - return new OwnedToken(name); - } + version = "v0.1.3+commit.028f561d" - function changeName(OwnedToken tokenAddress, bytes32 name) public { - // Again, the external type of `tokenAddress` is - // simply `address`. - tokenAddress.changeName(name); - } + response = CodeCompiler.run(name: name, compiler_version: version, code: code, optimize: optimize) - function isTokenTransferOK(address currentOwner, address newOwner) - public - view - returns (bool ok) - { - // Check some arbitrary condition. - address tokenAddress = msg.sender; - return (keccak256(newOwner) & 0xff) == (bytes20(tokenAddress) & 0xff); - } - } - """ + assert {:ok, + %{ + "abi" => _, + "bytecode" => _, + "name" => _ + }} = response + end - name = "OwnedToken" - compiler_version = "v0.4.22+commit.4cb486ee" + test "returns compilation error when compilation isn't possible", %{ + contract_code_info: contract_code_info + } do + wrong_code = "pragma solidity ^0.4.24; cont SimpleStorage { " - {:ok, %{"abi" => abi}} = - CodeCompiler.run( - name: name, - compiler_version: compiler_version, - code: code, - evm_version: "byzantium", - optimize: true - ) + response = + CodeCompiler.run( + name: contract_code_info.name, + compiler_version: contract_code_info.version, + code: wrong_code, + optimize: contract_code_info.optimized + ) - assert Enum.any?(abi, fn el -> el["type"] == "constructor" end) - end + assert {:error, :compilation, "Expected pragma, import directive or contract/interface/library definition."} = + response + end + + test "returns constructor in abi" do + code = """ + pragma solidity ^0.4.22; + + contract OwnedToken { + // TokenCreator is a contract type that is defined below. + // It is fine to reference it as long as it is not used + // to create a new contract. + TokenCreator creator; + address owner; + bytes32 name; + + // This is the constructor which registers the + // creator and the assigned name. + constructor(bytes32 _name) public { + // State variables are accessed via their name + // and not via e.g. this.owner. This also applies + // to functions and especially in the constructors, + // you can only call them like that ("internally"), + // because the contract itself does not exist yet. + owner = msg.sender; + // We do an explicit type conversion from `address` + // to `TokenCreator` and assume that the type of + // the calling contract is TokenCreator, there is + // no real way to check that. + creator = TokenCreator(msg.sender); + name = _name; + } + + function changeName(bytes32 newName) public { + // Only the creator can alter the name -- + // the comparison is possible since contracts + // are implicitly convertible to addresses. + if (msg.sender == address(creator)) + name = newName; + } + + function transfer(address newOwner) public { + // Only the current owner can transfer the token. + if (msg.sender != owner) return; + // We also want to ask the creator if the transfer + // is fine. Note that this calls a function of the + // contract defined below. If the call fails (e.g. + // due to out-of-gas), the execution here stops + // immediately. + if (creator.isTokenTransferOK(owner, newOwner)) + owner = newOwner; + } + } - test "can compile a large file" do - path = File.cwd!() <> "/test/support/fixture/smart_contract/large_smart_contract.sol" - contract = File.read!(path) - - assert {:ok, %{"abi" => _abi}} = - CodeCompiler.run( - name: "HomeWorkDeployer", - compiler_version: "v0.5.9+commit.e560f70d", - code: contract, - evm_version: "constantinople", - optimize: true - ) - end - end + contract TokenCreator { + function createToken(bytes32 name) + public + returns (OwnedToken tokenAddress) + { + // Create a new Token contract and return its address. + // From the JavaScript side, the return type is simply + // `address`, as this is the closest type available in + // the ABI. + return new OwnedToken(name); + } + + function changeName(OwnedToken tokenAddress, bytes32 name) public { + // Again, the external type of `tokenAddress` is + // simply `address`. + tokenAddress.changeName(name); + } + + function isTokenTransferOK(address currentOwner, address newOwner) + public + view + returns (bool ok) + { + // Check some arbitrary condition. + address tokenAddress = msg.sender; + return (keccak256(newOwner) & 0xff) == (bytes20(tokenAddress) & 0xff); + } + } + """ - describe "get_contract_info/1" do - test "return name error when the Contract name doesn't match" do - name = "Name" - different_name = "diff_name" + name = "OwnedToken" + compiler_version = "v0.4.22+commit.4cb486ee" - response = CodeCompiler.get_contract_info(%{name => %{}}, different_name) + {:ok, %{"abi" => abi}} = + CodeCompiler.run( + name: name, + compiler_version: compiler_version, + code: code, + evm_version: "byzantium", + optimize: true + ) - assert {:error, :name} == response + assert Enum.any?(abi, fn el -> el["type"] == "constructor" end) + end + + test "can compile a large file" do + path = File.cwd!() <> "/test/support/fixture/smart_contract/large_smart_contract.sol" + contract = File.read!(path) + + assert {:ok, %{"abi" => _abi}} = + CodeCompiler.run( + name: "HomeWorkDeployer", + compiler_version: "v0.5.9+commit.e560f70d", + code: contract, + evm_version: "constantinople", + optimize: true + ) + end end - test "returns compilation error for empty info" do - name = "Name" + describe "get_contract_info/1" do + test "return name error when the Contract name doesn't match" do + name = "Name" + different_name = "diff_name" - response = CodeCompiler.get_contract_info(%{}, name) + response = CodeCompiler.get_contract_info(%{name => %{}}, different_name) - assert {:error, :compilation} == response - end + assert {:error, :name} == response + end - test "the contract info is returned when the name matches" do - contract_inner_info = %{"abi" => %{}, "bytecode" => ""} - name = "Name" - contract_info = %{name => contract_inner_info} + test "returns compilation error for empty info" do + name = "Name" - response = CodeCompiler.get_contract_info(contract_info, name) + response = CodeCompiler.get_contract_info(%{}, name) - assert contract_inner_info == response - end + assert {:error, :compilation} == response + end + + test "the contract info is returned when the name matches" do + contract_inner_info = %{"abi" => %{}, "bytecode" => ""} + name = "Name" + contract_info = %{name => contract_inner_info} + + response = CodeCompiler.get_contract_info(contract_info, name) - test "the contract info is returned when the name matches with a `:` suffix" do - name = "Name" - name_with_suffix = ":Name" - contract_inner_info = %{"abi" => %{}, "bytecode" => ""} - contract_info = %{name_with_suffix => contract_inner_info} + assert contract_inner_info == response + end - response = CodeCompiler.get_contract_info(contract_info, name) + test "the contract info is returned when the name matches with a `:` suffix" do + name = "Name" + name_with_suffix = ":Name" + contract_inner_info = %{"abi" => %{}, "bytecode" => ""} + contract_info = %{name_with_suffix => contract_inner_info} - assert contract_inner_info == response + response = CodeCompiler.get_contract_info(contract_info, name) + + assert contract_inner_info == response + end end - end - # describe "allowed_solidity_evm_versions/0" do - # test "returns allowed evm versions defined by ALLOWED_EVM_VERSIONS env var" do - # Application.put_env(:explorer, :allowed_solidity_evm_versions, "CustomEVM1,CustomEVM2,CustomEVM3") - # response = CodeCompiler.evm_versions(:solidity) + # describe "allowed_solidity_evm_versions/0" do + # test "returns allowed evm versions defined by ALLOWED_EVM_VERSIONS env var" do + # Application.put_env(:explorer, :allowed_solidity_evm_versions, "CustomEVM1,CustomEVM2,CustomEVM3") + # response = CodeCompiler.evm_versions(:solidity) - # assert ["CustomEVM1", "CustomEVM2", "CustomEVM3"] = response - # end + # assert ["CustomEVM1", "CustomEVM2", "CustomEVM3"] = response + # end - # test "returns allowed evm versions defined by not trimmed ALLOWED_EVM_VERSIONS env var" do - # Application.put_env(:explorer, :allowed_solidity_evm_versions, "CustomEVM1, CustomEVM2, CustomEVM3") - # response = CodeCompiler.evm_versions(:solidity) + # test "returns allowed evm versions defined by not trimmed ALLOWED_EVM_VERSIONS env var" do + # Application.put_env(:explorer, :allowed_solidity_evm_versions, "CustomEVM1, CustomEVM2, CustomEVM3") + # response = CodeCompiler.evm_versions(:solidity) - # assert ["CustomEVM1", "CustomEVM2", "CustomEVM3"] = response - # end + # assert ["CustomEVM1", "CustomEVM2", "CustomEVM3"] = response + # end - # test "returns default_allowed_evm_versions" do - # Application.put_env( - # :explorer, - # :allowed_solidity_evm_versions, - # "homestead,tangerineWhistle,spuriousDragon,byzantium,constantinople,petersburg" - # ) + # test "returns default_allowed_evm_versions" do + # Application.put_env( + # :explorer, + # :allowed_solidity_evm_versions, + # "homestead,tangerineWhistle,spuriousDragon,byzantium,constantinople,petersburg" + # ) - # response = CodeCompiler.evm_versions(:solidity) + # response = CodeCompiler.evm_versions(:solidity) - # assert ["homestead", "tangerineWhistle", "spuriousDragon", "byzantium", "constantinople", "petersburg"] = response - # end - # end + # assert ["homestead", "tangerineWhistle", "spuriousDragon", "byzantium", "constantinople", "petersburg"] = response + # end + # end - defp remove_init_data_and_whisper_data(code) do - {res, _} = - code - |> String.split("0029") - |> List.first() - |> String.split_at(-64) + defp remove_init_data_and_whisper_data(code) do + {res, _} = + code + |> String.split("0029") + |> List.first() + |> String.split_at(-64) - res + res + end end end diff --git a/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs b/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs index 2fcdec3d453d..480596708e67 100644 --- a/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs +++ b/apps/explorer/test/explorer/smart_contract/solidity/publisher_test.exs @@ -1,23 +1,27 @@ -if Application.compile_env(:explorer, :chain_type) !== :zksync do - defmodule Explorer.SmartContract.Solidity.PublisherTest do - use ExUnit.Case, async: true +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.SmartContract.Solidity.PublisherTest do + use ExUnit.Case, async: true + use Explorer.DataCase - use Explorer.DataCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + if @chain_type == :default do doctest Explorer.SmartContract.Solidity.Publisher @moduletag timeout: :infinity - alias Explorer.Chain.{ContractMethod, SmartContract} + alias Explorer.Chain.{Data, ContractMethod, SmartContract} alias Explorer.{Factory, Repo} alias Explorer.SmartContract.Solidity.Publisher setup do configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) end @@ -107,7 +111,7 @@ if Application.compile_env(:explorer, :chain_type) !== :zksync do Enum.each(contract_code_info.abi, fn selector -> [parsed] = ABI.parse_specification([selector]) - assert Repo.get_by(ContractMethod, abi: selector, identifier: parsed.method_id) + assert Repo.get_by(ContractMethod, abi: selector, identifier: %Data{bytes: parsed.method_id}) end) end diff --git a/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs b/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs index 89dbd97ba48c..2b4f4248d1d2 100644 --- a/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs +++ b/apps/explorer/test/explorer/smart_contract/solidity/verifier_test.exs @@ -1,8 +1,11 @@ -if Application.compile_env(:explorer, :chain_type) !== :zksync do - defmodule Explorer.SmartContract.Solidity.VerifierTest do - use ExUnit.Case, async: true - use Explorer.DataCase +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.SmartContract.Solidity.VerifierTest do + use ExUnit.Case, async: true + use Explorer.DataCase + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + if @chain_type == :default do @moduletag timeout: :infinity doctest Explorer.SmartContract.Solidity.Verifier @@ -67,9 +70,11 @@ if Application.compile_env(:explorer, :chain_type) !== :zksync do setup do configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) end @@ -503,17 +508,17 @@ if Application.compile_env(:explorer, :chain_type) !== :zksync do |> insert() |> with_block(transaction_success_details) - :internal_transaction + :internal_transaction_create |> insert( - created_contract_address_hash: contract_address.hash, + created_contract_address: contract_address, init: init, type: "create", created_contract_code: bytecode, input: nil, transaction_hash: transaction.hash, + transaction_index: transaction.index, index: 0, - block_hash: transaction.block_hash, - block_index: 0 + block_number: transaction.block_number ) params = %{ @@ -561,30 +566,30 @@ if Application.compile_env(:explorer, :chain_type) !== :zksync do |> insert() |> with_block(transaction_failure_details) - :internal_transaction + :internal_transaction_create |> insert( - created_contract_address_hash: contract_address.hash, + created_contract_address: contract_address, init: init, type: "create", created_contract_code: bytecode, input: nil, transaction_hash: transaction_success.hash, + transaction_index: transaction_success.index, index: 0, - block_hash: transaction_success.block_hash, - block_index: 0 + block_number: transaction_success.block_number ) - :internal_transaction + :internal_transaction_create |> insert( - created_contract_address_hash: contract_address.hash, + created_contract_address: contract_address, init: init, type: "create", created_contract_code: bytecode, input: nil, transaction_hash: transaction_failure.hash, + transaction_index: transaction_failure.index, index: 0, - block_hash: transaction_failure.block_hash, - block_index: 0 + block_number: transaction_failure.block_number ) params = %{ diff --git a/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs b/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs index 7e31394cccaa..f1ef4d2ccf04 100644 --- a/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs +++ b/apps/explorer/test/explorer/smart_contract/vyper/publisher_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout if Application.compile_env(:explorer, :chain_type) !== :zksync do defmodule Explorer.SmartContract.Vyper.PublisherTest do use ExUnit.Case, async: true @@ -15,9 +16,11 @@ if Application.compile_env(:explorer, :chain_type) !== :zksync do setup do configuration = Application.get_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour) Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, enabled: false) + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) on_exit(fn -> Application.put_env(:explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, configuration) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) end) end diff --git a/apps/explorer/test/explorer/smart_contract/writer_test.exs b/apps/explorer/test/explorer/smart_contract/writer_test.exs index 5239df67ca54..ade1dafa7b85 100644 --- a/apps/explorer/test/explorer/smart_contract/writer_test.exs +++ b/apps/explorer/test/explorer/smart_contract/writer_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.SmartContract.WriterTest do use EthereumJSONRPC.Case use Explorer.DataCase diff --git a/apps/explorer/test/explorer/tags/address_to_tag_test.exs b/apps/explorer/test/explorer/tags/address_to_tag_test.exs new file mode 100644 index 000000000000..5c0b75eb8585 --- /dev/null +++ b/apps/explorer/test/explorer/tags/address_to_tag_test.exs @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Tags.AddressToTagTest do + use Explorer.DataCase + + alias Explorer.Repo + alias Explorer.Tags.AddressToTag + + describe "set_tag_to_addresses/2" do + test "does not remove existing address" do + address = insert(:address, hash: "0x3078000000000000000000000000000000000001") + address_hash = address.hash + tag = insert(:address_tag) + tag_id = tag.id + att = insert(:address_to_tag, tag_id: tag.id, tag: tag, address_hash: address.hash, address: address) + att_inserted_at = att.inserted_at + + :timer.sleep(100) + + AddressToTag.set_tag_to_addresses(tag.id, [to_string(address_hash)]) + + # timestamp should be the same, no need to delete and reinsert the same address + assert [%AddressToTag{tag_id: ^tag_id, address_hash: ^address_hash, inserted_at: ^att_inserted_at}] = + Repo.all(AddressToTag) + end + end +end diff --git a/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs b/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs index 75b468152940..93c4ccdf44a0 100644 --- a/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs +++ b/apps/explorer/test/explorer/third_party_integrations/universal_proxy_test.exs @@ -1,5 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do - use ExUnit.Case, async: true + use ExUnit.Case import Mox @@ -221,7 +222,47 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do }).url end - test "correctly parsing chain_id - dependent param in the path" do + test "correctly parsing chain_id - dependent param in the path when chain id is NOT present as param" do + Tesla.Test.expect_tesla_call( + times: 1, + returns: %Tesla.Env{ + status: 200, + body: + Jason.encode!(%{ + "platforms" => %{ + "test_platform" => %{ + "base_url" => "https://api.test.com", + "endpoints" => %{ + "base" => %{ + "path" => "/test/:endpoint_platform_id", + "method" => "get", + "params" => [ + %{ + "location" => "path", + "type" => "chain_id_dependent", + "name" => "endpoint_platform_id", + "mapping" => %{ + "1" => "first_endpoint_platform_id", + "100500" => "another_endpoint_platform_id" + } + } + ] + } + } + } + } + }) + } + ) + + assert "https://api.test.com/test/another_endpoint_platform_id" = + UniversalProxy.parse_proxy_params(%{ + "platform_id" => "test_platform", + "chain_id" => "100500" + }).url + end + + test "correctly parsing chain_id - dependent param in the path when chain id is present as param as well" do Tesla.Test.expect_tesla_call( times: 1, returns: %Tesla.Env{ @@ -533,4 +574,45 @@ defmodule Explorer.ThirdPartyIntegrations.UniversalProxyTest do } ) end + + describe "config from env (UNIVERSAL_PROXY_CONFIG)" do + test "parse_proxy_params uses inline env config without HTTP fetch" do + inline_config = %{ + "platforms" => %{ + "test_platform" => %{ + "base_url" => "https://api.test.com", + "endpoints" => %{ + "base" => %{ + "path" => "/test", + "method" => "get", + "params" => [] + } + }, + "api_key" => %{ + "location" => "header", + "param_name" => "Authorization", + "prefix" => "Bearer" + } + } + } + } + + Application.put_env( + :explorer, + Explorer.ThirdPartyIntegrations.UniversalProxy, + config_json: Jason.encode!(inline_config), + config_url: nil + ) + + on_exit(fn -> + Application.delete_env(:explorer, Explorer.ThirdPartyIntegrations.UniversalProxy) + end) + + proxy_params = UniversalProxy.parse_proxy_params(%{"platform_id" => "test_platform"}) + + assert proxy_params.url == "https://api.test.com/test" + assert proxy_params.method == :get + assert proxy_params.headers == [{"Authorization", "Bearer test_api_key"}] + end + end end diff --git a/apps/explorer/test/explorer/token/balance_reader_test.exs b/apps/explorer/test/explorer/token/balance_reader_test.exs index 126e903cde3c..c77edf352308 100644 --- a/apps/explorer/test/explorer/token/balance_reader_test.exs +++ b/apps/explorer/test/explorer/token/balance_reader_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Token.BalanceReaderTest do use EthereumJSONRPC.Case use Explorer.DataCase diff --git a/apps/explorer/test/explorer/token/metadata_retriever_test.exs b/apps/explorer/test/explorer/token/metadata_retriever_test.exs index 87bd8544cb7d..377debf0c1dc 100644 --- a/apps/explorer/test/explorer/token/metadata_retriever_test.exs +++ b/apps/explorer/test/explorer/token/metadata_retriever_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Token.MetadataRetrieverTest do use EthereumJSONRPC.Case use Explorer.DataCase @@ -534,59 +535,109 @@ defmodule Explorer.Token.MetadataRetrieverTest do on_exit(fn -> Application.put_env(:explorer, :token_functions_reader_max_retries, original) end) end - end - test "returns name and symbol when they are bytes32" do - token = insert(:token, contract_address: build(:contract_address)) + test "returns name and symbol when they are bytes32" do + token = insert(:token, contract_address: build(:contract_address)) - expect( - EthereumJSONRPC.Mox, - :json_rpc, - 1, - fn requests, _opts -> - {:ok, - Enum.map(requests, fn - %{id: id, method: "eth_call", params: [%{data: "0x313ce567", to: _}, "latest"]} -> - %{ - id: id, - result: "0x0000000000000000000000000000000000000000000000000000000000000012" - } + expect( + EthereumJSONRPC.Mox, + :json_rpc, + 1, + fn requests, _opts -> + {:ok, + Enum.map(requests, fn + %{id: id, method: "eth_call", params: [%{data: "0x313ce567", to: _}, "latest"]} -> + %{ + id: id, + result: "0x0000000000000000000000000000000000000000000000000000000000000012" + } - %{id: id, method: "eth_call", params: [%{data: "0x06fdde03", to: _}, "latest"]} -> - %{ - id: id, - result: "0x4d616b6572000000000000000000000000000000000000000000000000000000" - } + %{id: id, method: "eth_call", params: [%{data: "0x06fdde03", to: _}, "latest"]} -> + %{ + id: id, + result: "0x4d616b6572000000000000000000000000000000000000000000000000000000" + } - %{id: id, method: "eth_call", params: [%{data: "0x95d89b41", to: _}, "latest"]} -> - %{ - id: id, - result: "0x4d4b520000000000000000000000000000000000000000000000000000000000" - } + %{id: id, method: "eth_call", params: [%{data: "0x95d89b41", to: _}, "latest"]} -> + %{ + id: id, + result: "0x4d4b520000000000000000000000000000000000000000000000000000000000" + } - %{id: id, method: "eth_call", params: [%{data: "0x18160ddd", to: _}, "latest"]} -> - %{ - id: id, - result: "0x00000000000000000000000000000000000000000000d3c21bcecceda1000000" - } - end)} - end - ) - - expected = %{ - decimals: 18, - name: "Maker", - symbol: "MKR", - total_supply: 1_000_000_000_000_000_000_000_000 - } - - assert MetadataRetriever.get_functions_of(token) == expected + %{id: id, method: "eth_call", params: [%{data: "0x18160ddd", to: _}, "latest"]} -> + %{ + id: id, + result: "0x00000000000000000000000000000000000000000000d3c21bcecceda1000000" + } + end)} + end + ) + + expected = %{ + decimals: 18, + name: "Maker", + symbol: "MKR", + total_supply: 1_000_000_000_000_000_000_000_000 + } + + assert MetadataRetriever.get_functions_of(token) == expected + end + + test "sets skip_metadata if contract does not have metadata" do + token = insert(:token, contract_address: build(:contract_address)) + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + 3, + fn requests, _opts -> + {:ok, + Enum.map(requests, fn + %{id: id, method: "eth_call", params: [%{data: _, to: _}, "latest"]} -> + %{ + id: id, + error: %{code: -32015, data: "something", message: "execution reverted"}, + jsonrpc: "2.0" + } + end)} + end + ) + + assert MetadataRetriever.get_functions_of(token, set_skip_metadata: true) == %{skip_metadata: true} + end + + test "does not set skip_metadata if errors are not contract related" do + token = insert(:token, contract_address: build(:contract_address)) + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + 3, + fn requests, _opts -> + {:ok, + Enum.map(requests, fn + %{id: id, method: "eth_call", params: [%{data: _, to: _}, "latest"]} -> + %{ + id: id, + error: %{code: -32015, data: "something", message: "network error"}, + jsonrpc: "2.0" + } + end)} + end + ) + + assert MetadataRetriever.get_functions_of(token, set_skip_metadata: true) == %{skip_metadata: false} + end end describe "fetch_json/4" do setup do bypass = Bypass.open() + on_exit(fn -> + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) + {:ok, bypass: bypass} end @@ -673,14 +724,17 @@ defmodule Explorer.Token.MetadataRetrieverTest do "collectionId" => "1871_1665123820823" } - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP?x-apikey=mykey", - _headers, - _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(result)}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP?x-apikey=mykey"}, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(result) + }} + end + ) assert {:ok, %{ @@ -693,7 +747,6 @@ defmodule Explorer.Token.MetadataRetrieverTest do } }} == MetadataRetriever.fetch_json({:ok, [data]}) - Application.put_env(:explorer, :http_adapter, HTTPoison) Application.put_env(:indexer, :ipfs, configuration) end @@ -717,12 +770,16 @@ defmodule Explorer.Token.MetadataRetrieverTest do "collectionId" => "1871_1665123820823" } - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP", _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(result)}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(result) + }} + end + ) assert {:ok, %{ @@ -735,7 +792,6 @@ defmodule Explorer.Token.MetadataRetrieverTest do } }} == MetadataRetriever.fetch_json({:ok, [data]}) - Application.put_env(:explorer, :http_adapter, HTTPoison) Application.put_env(:indexer, :ipfs, configuration) end @@ -759,14 +815,20 @@ defmodule Explorer.Token.MetadataRetrieverTest do "collectionId" => "1871_1665123820823" } - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP", - [{"x-apikey", "mykey"}, {"User-Agent", _}], - _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(result)}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{ + url: "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP", + headers: [{"x-apikey", "mykey"}, {"User-Agent", _}] + }, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(result) + }} + end + ) assert {:ok, %{ @@ -779,7 +841,6 @@ defmodule Explorer.Token.MetadataRetrieverTest do } }} == MetadataRetriever.fetch_json({:ok, [data]}) - Application.put_env(:explorer, :http_adapter, HTTPoison) Application.put_env(:indexer, :ipfs, configuration) end @@ -792,6 +853,8 @@ defmodule Explorer.Token.MetadataRetrieverTest do } """ + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + Bypass.expect(bypass, "GET", path, fn conn -> Conn.resp(conn, 200, json) end) @@ -822,6 +885,8 @@ defmodule Explorer.Token.MetadataRetrieverTest do } """ + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + Bypass.expect(bypass, "GET", path, fn conn -> Conn.resp(conn, 200, json) end) @@ -898,14 +963,16 @@ defmodule Explorer.Token.MetadataRetrieverTest do } """ - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/bafybeig6nlmyzui7llhauc52j2xo5hoy4lzp6442lkve5wysdvjkizxonu", - _headers, - _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: json}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://ipfs.io/ipfs/bafybeig6nlmyzui7llhauc52j2xo5hoy4lzp6442lkve5wysdvjkizxonu"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: json + }} + end + ) data = {:ok, @@ -919,8 +986,6 @@ defmodule Explorer.Token.MetadataRetrieverTest do "image" => "https://ipfs.io/ipfs/bafybeig6nlmyzui7llhauc52j2xo5hoy4lzp6442lkve5wysdvjkizxonu" } }} == MetadataRetriever.fetch_json(data) - - Application.put_env(:explorer, :http_adapter, HTTPoison) end test "Fetches metadata from ipfs" do @@ -932,14 +997,17 @@ defmodule Explorer.Token.MetadataRetrieverTest do } """ - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/bafybeid4ed2ua7fwupv4nx2ziczr3edhygl7ws3yx6y2juon7xakgj6cfm/51.json", - _headers, - _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: json}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://ipfs.io/ipfs/bafybeid4ed2ua7fwupv4nx2ziczr3edhygl7ws3yx6y2juon7xakgj6cfm/51.json"}, + _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: json + }} + end + ) data = {:ok, @@ -953,7 +1021,6 @@ defmodule Explorer.Token.MetadataRetrieverTest do }} = MetadataRetriever.fetch_json(data) assert "ipfs://bafybeihxuj3gxk7x5p36amzootyukbugmx3pw7dyntsrohg3se64efkuga/51.png" == Map.get(metadata, "image") - Application.put_env(:explorer, :http_adapter, HTTPoison) end test "Fetches metadata from '${url}'", %{bypass: bypass} do @@ -980,6 +1047,8 @@ defmodule Explorer.Token.MetadataRetrieverTest do } """ + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + Bypass.expect(bypass, "GET", path, fn conn -> Conn.resp(conn, 200, json) end) @@ -1009,12 +1078,16 @@ defmodule Explorer.Token.MetadataRetrieverTest do "collectionId" => "1871_1665123820823" } - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP", _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(result)}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://ipfs.io/ipfs/QmT1Yz43R1PLn2RVovAnEM5dHQEvpTcnwgX8zftvY1FcjP"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(result) + }} + end + ) assert {:ok, %{ @@ -1026,8 +1099,6 @@ defmodule Explorer.Token.MetadataRetrieverTest do "salePrice" => 34 } }} == MetadataRetriever.fetch_json({:ok, [data]}) - - Application.put_env(:explorer, :http_adapter, HTTPoison) end test "Process URI directly from link", %{bypass: bypass} do @@ -1063,6 +1134,8 @@ defmodule Explorer.Token.MetadataRetrieverTest do } """ + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + Bypass.expect(bypass, "GET", path, fn conn -> Conn.resp(conn, 200, json) end) @@ -1072,8 +1145,7 @@ defmodule Explorer.Token.MetadataRetrieverTest do assert {:ok_store_uri, %{ metadata: Jason.decode!(json) - }, - url} == + }, url} == MetadataRetriever.fetch_json({:ok, [url]}) end end @@ -1171,4 +1243,37 @@ defmodule Explorer.Token.MetadataRetrieverTest do assert MetadataRetriever.arweave_link(data) == expected_link end end + + describe "IPFS link validation" do + test "valid_ipfs_path?/1 returns true for valid CIDv0 (Qm...)" do + valid_cid_v0 = "ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco" + assert MetadataRetriever.valid_ipfs_path?(valid_cid_v0) + end + + test "valid_ipfs_path?/1 returns true for valid CIDv1 (b...)" do + valid_cid_v1 = "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3hlgtif73fqpyae" + assert MetadataRetriever.valid_ipfs_path?(valid_cid_v1) + end + + test "valid_ipfs_path?/1 returns true for short valid CIDv1" do + short_cid_v1 = "ipfs://bafybeic" + assert MetadataRetriever.valid_ipfs_path?(short_cid_v1) + end + + test "valid_ipfs_path?/1 returns false for empty string" do + refute MetadataRetriever.valid_ipfs_path?("") + end + + test "valid_ipfs_path?/1 returns false for malformed path like ipfs://invalid" do + refute MetadataRetriever.valid_ipfs_path?("ipfs://invalid") + end + + test "HTTP request is NOT made when path is invalid" do + invalid_path = "ipfs://invalid" + + # We assert that it returns the error immediately without any HTTP mock being called. + # If it tried to make a request, it would fail because no expectation is set for this URL. + assert MetadataRetriever.fetch_json({:ok, [invalid_path]}) == {:error, "invalid ipfs path"} + end + end end diff --git a/apps/explorer/test/explorer/token_instance_owner_address_migration/helper_test.exs b/apps/explorer/test/explorer/token_instance_owner_address_migration/helper_test.exs index 355d161259bb..fe33dcabd1c5 100644 --- a/apps/explorer/test/explorer/token_instance_owner_address_migration/helper_test.exs +++ b/apps/explorer/test/explorer/token_instance_owner_address_migration/helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.TokenInstanceOwnerAddressMigration.HelperTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/utility/missing_block_range_test.exs b/apps/explorer/test/explorer/utility/missing_block_range_test.exs index bde341055c78..2b6ddd503cbb 100644 --- a/apps/explorer/test/explorer/utility/missing_block_range_test.exs +++ b/apps/explorer/test/explorer/utility/missing_block_range_test.exs @@ -1,4 +1,5 @@ -defmodule Explorer.Utility.TestMissingBlockRange do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Utility.MissingBlockRangeTest do use ExUnit.Case, async: true alias Explorer.Utility.MissingBlockRange @@ -82,14 +83,10 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) - assert length(ranges) == 5 - - assert Enum.any?(ranges, fn range -> - range.from_number == 15 and range.to_number == 12 and range.priority == nil - end) + assert length(ranges) == 4 assert Enum.any?(ranges, fn range -> - range.from_number == 11 and range.to_number == 11 and range.priority == nil + range.from_number == 15 and range.to_number == 11 and range.priority == nil end) assert Enum.any?(ranges, fn range -> @@ -118,22 +115,14 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) - assert length(ranges) == 4 + assert length(ranges) == 2 assert Enum.any?(ranges, fn range -> range.from_number == 15 and range.to_number == 14 and range.priority == nil end) assert Enum.any?(ranges, fn range -> - range.from_number == 13 and range.to_number == 12 and range.priority == 1 - end) - - assert Enum.any?(ranges, fn range -> - range.from_number == 11 and range.to_number == 7 and range.priority == 1 - end) - - assert Enum.any?(ranges, fn range -> - range.from_number == 6 and range.to_number == 3 and range.priority == 1 + range.from_number == 13 and range.to_number == 3 and range.priority == 1 end) end @@ -269,14 +258,10 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) - assert length(ranges) == 4 - - assert Enum.any?(ranges, fn range -> - range.from_number == 112 and range.to_number == 86 and range.priority == nil - end) + assert length(ranges) == 3 assert Enum.any?(ranges, fn range -> - range.from_number == 85 and range.to_number == 46 and range.priority == nil + range.from_number == 112 and range.to_number == 46 and range.priority == nil end) assert Enum.any?(ranges, fn range -> @@ -331,18 +316,14 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) - assert length(ranges) == 3 + assert length(ranges) == 2 assert Enum.any?(ranges, fn range -> range.from_number == 112 and range.to_number == 111 and range.priority == nil end) assert Enum.any?(ranges, fn range -> - range.from_number == 110 and range.to_number == 86 and range.priority == 1 - end) - - assert Enum.any?(ranges, fn range -> - range.from_number == 85 and range.to_number == 7 and range.priority == 1 + range.from_number == 110 and range.to_number == 7 and range.priority == 1 end) end @@ -358,14 +339,10 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) - assert length(ranges) == 2 - - assert Enum.any?(ranges, fn range -> - range.from_number == 112 and range.to_number == 86 and range.priority == 1 - end) + assert length(ranges) == 1 assert Enum.any?(ranges, fn range -> - range.from_number == 85 and range.to_number == 7 and range.priority == 1 + range.from_number == 112 and range.to_number == 7 and range.priority == 1 end) end @@ -435,18 +412,10 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) - assert length(ranges) == 4 - - assert Enum.any?(ranges, fn range -> - range.from_number == 130 and range.to_number == 46 and range.priority == 1 - end) - - assert Enum.any?(ranges, fn range -> - range.from_number == 45 and range.to_number == 30 and range.priority == 1 - end) + assert length(ranges) == 2 assert Enum.any?(ranges, fn range -> - range.from_number == 29 and range.to_number == 23 and range.priority == 1 + range.from_number == 130 and range.to_number == 23 and range.priority == 1 end) assert Enum.any?(ranges, fn range -> @@ -466,18 +435,50 @@ defmodule Explorer.Utility.TestMissingBlockRange do ranges = Repo.all(MissingBlockRange) + assert length(ranges) == 1 + + assert Enum.any?(ranges, fn range -> + range.from_number == 130 and range.to_number == 20 and range.priority == 1 + end) + end + end + + describe "clear_batch/1" do + setup do + # Ensure the database is clean before each test + Repo.delete_all(MissingBlockRange) + + on_exit(fn -> + # Clean up the database after each test + Repo.delete_all(MissingBlockRange) + end) + + :ok + end + + test "correctly clears the batch" do + Repo.insert!(%MissingBlockRange{from_number: 112, to_number: 86, priority: 1}) + Repo.insert!(%MissingBlockRange{from_number: 45, to_number: 30, priority: 1}) + Repo.insert!(%MissingBlockRange{from_number: 25, to_number: 20, priority: nil}) + + batch = [95..80//-1, 60..58//-1, 42..35//-1, 30..19//-1] + + MissingBlockRange.clear_batch(batch) + + ranges = Repo.all(MissingBlockRange) + assert length(ranges) == 3 assert Enum.any?(ranges, fn range -> - range.from_number == 130 and range.to_number == 46 and range.priority == 1 + range.from_number == 112 and range.to_number == 96 and range.priority == 1 end) assert Enum.any?(ranges, fn range -> - range.from_number == 45 and range.to_number == 30 and range.priority == 1 + range.from_number == 45 and range.to_number == 43 and range.priority == 1 end) assert Enum.any?(ranges, fn range -> - range.from_number == 29 and range.to_number == 20 and range.priority == 1 + range.from_number == 34 and range.to_number == 31 and range.priority == 1 end) end end diff --git a/apps/explorer/test/explorer/utility/rate_limiter_test.exs b/apps/explorer/test/explorer/utility/rate_limiter_test.exs index df5baba49116..b66a044fbd2b 100644 --- a/apps/explorer/test/explorer/utility/rate_limiter_test.exs +++ b/apps/explorer/test/explorer/utility/rate_limiter_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Utility.RateLimiterTest do use ExUnit.Case, async: true @@ -32,7 +33,8 @@ defmodule Explorer.Utility.RateLimiterTest do assert RateLimiter.check_rate("test", :on_demand) == :deny expected_ban_data = "#{now + 1}:1" - assert [{"test_on_demand", ^expected_ban_data}] = :ets.lookup(:rate_limiter, "test_on_demand") + key = add_chain_id_prefix("test_on_demand") + assert [{^key, ^expected_ban_data}] = :ets.lookup(:rate_limiter, key) Process.sleep(2000) @@ -42,6 +44,10 @@ defmodule Explorer.Utility.RateLimiterTest do assert RateLimiter.check_rate("test", :on_demand) == :deny expected_ban_data = "#{now + 2}:2" - assert [{"test_on_demand", ^expected_ban_data}] = :ets.lookup(:rate_limiter, "test_on_demand") + assert [{^key, ^expected_ban_data}] = :ets.lookup(:rate_limiter, key) + end + + defp add_chain_id_prefix(key) do + "#{Application.get_env(:block_scout_web, :chain_id)}_#{key}" end end diff --git a/apps/explorer/test/explorer/validator/metadata_importer_test.exs b/apps/explorer/test/explorer/validator/metadata_importer_test.exs index c30e719856c4..ccba88f7ad0b 100644 --- a/apps/explorer/test/explorer/validator/metadata_importer_test.exs +++ b/apps/explorer/test/explorer/validator/metadata_importer_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Validator.MetadataImporterTest do use Explorer.DataCase diff --git a/apps/explorer/test/explorer/validator/metadata_retriever_test.exs b/apps/explorer/test/explorer/validator/metadata_retriever_test.exs index 7bcc9040d462..ac82ee341ab5 100644 --- a/apps/explorer/test/explorer/validator/metadata_retriever_test.exs +++ b/apps/explorer/test/explorer/validator/metadata_retriever_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Validator.MetadataRetrieverTest do use EthereumJSONRPC.Case diff --git a/apps/explorer/test/string/chars/explorer/chain/address_test.exs b/apps/explorer/test/string/chars/explorer/chain/address_test.exs index 5d08c3e03c11..4eac2287bd69 100644 --- a/apps/explorer/test/string/chars/explorer/chain/address_test.exs +++ b/apps/explorer/test/string/chars/explorer/chain/address_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule String.Chars.Explorer.Chain.AddressTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/string/chars/explorer/chain/data_test.exs b/apps/explorer/test/string/chars/explorer/chain/data_test.exs index 01f830a83c8d..d29131ae7b83 100644 --- a/apps/explorer/test/string/chars/explorer/chain/data_test.exs +++ b/apps/explorer/test/string/chars/explorer/chain/data_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule String.Chars.Explorer.Chain.DataTest do use ExUnit.Case, async: true diff --git a/apps/explorer/test/support/benchmark_case.ex b/apps/explorer/test/support/benchmark_case.ex new file mode 100644 index 000000000000..25baee18aab1 --- /dev/null +++ b/apps/explorer/test/support/benchmark_case.ex @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.BenchmarkCase do + @moduledoc """ + This module defines the benchmark case to be used by benchmarks. + + This module provides common setup and utilities for benchmarking, + similar to DataCase but optimized for benchmarking needs, including: + + - Database sandbox management + - Factory imports + - Common helpers + + ## Example + + ```elixir + defmodule Explorer.MyBenchmark do + use Explorer.BenchmarkCase + + def run do + Benchee.run(...) + end + end + + # Run the benchmark + Explorer.MyBenchmark.run() + ``` + """ + + defmacro __using__(_opts) do + caller_file = __CALLER__.file + path = caller_file |> String.replace(Path.extname(caller_file), ".benchee") + + quote do + import Explorer.Factory + + @path unquote(path) + + @doc """ + Resets the database for consistent benchmarks + """ + def reset_db do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo, ownership_timeout: :infinity) + end + + @doc """ + Setup common benchmark environment + """ + def benchmark_setup do + {:ok, _} = Application.ensure_all_started(:explorer) + + :ok + end + end + end +end diff --git a/apps/explorer/test/support/chain/import/runner_case.ex b/apps/explorer/test/support/chain/import/runner_case.ex index a73e395e4ded..1640f9943e84 100644 --- a/apps/explorer/test/support/chain/import/runner_case.ex +++ b/apps/explorer/test/support/chain/import/runner_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Chain.Import.RunnerCase do import Explorer.Factory, only: [insert: 2] import Ecto.Query, only: [from: 2] diff --git a/apps/explorer/test/support/data_case.ex b/apps/explorer/test/support/data_case.ex index da18760983cc..2a67c2630c4a 100644 --- a/apps/explorer/test/support/data_case.ex +++ b/apps/explorer/test/support/data_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.DataCase do @moduledoc """ This module defines the setup for tests requiring @@ -35,10 +36,12 @@ defmodule Explorer.DataCase do :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo.Account) + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Explorer.Repo.EventNotifications) - unless tags[:async] do + if !tags[:async] do Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, {:shared, self()}) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Account, {:shared, self()}) + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.EventNotifications, {:shared, self()}) end Supervisor.terminate_child(Explorer.Supervisor, Explorer.Chain.Cache.BlockNumber.child_id()) @@ -54,11 +57,20 @@ defmodule Explorer.DataCase do end def wait_for_results(producer) do + wait_for_results(producer, 30) + end + + def wait_for_results(_producer, 0) do + raise "wait_for_results timed out after exhausting retries" + end + + def wait_for_results(producer, retries) when retries > 0 do + Process.sleep(100) producer.() rescue - [DBConnection.ConnectionError, Ecto.NoResultsError] -> - Process.sleep(100) - wait_for_results(producer) + _error in [DBConnection.ConnectionError, Ecto.NoResultsError] -> + Process.sleep(300) + wait_for_results(producer, retries - 1) end @doc """ diff --git a/apps/explorer/test/support/factory.ex b/apps/explorer/test/support/factory.ex index d8f52ebba90e..f88567275e7d 100644 --- a/apps/explorer/test/support/factory.ex +++ b/apps/explorer/test/support/factory.ex @@ -1,6 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Factory do use ExMachina.Ecto, repo: Explorer.Repo use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] require Ecto.Query @@ -22,10 +24,17 @@ defmodule Explorer.Factory do } alias Explorer.Admin.Administrator - alias Explorer.Chain.Beacon.{Blob, BlobTransaction} + alias Explorer.Chain.Beacon.{Blob, BlobTransaction, Deposit} alias Explorer.Chain.Block.{EmissionReward, Range, Reward} + alias Explorer.Chain.Arbitrum.BatchBlock, as: ArbitrumBatchBlock + alias Explorer.Chain.Arbitrum.BatchTransaction, as: ArbitrumBatchTransaction + alias Explorer.Chain.Arbitrum.L1Batch, as: ArbitrumL1Batch + alias Explorer.Chain.Arbitrum.LifecycleTransaction, as: ArbitrumLifecycleTransaction + alias Explorer.Chain.Arbitrum.Message, as: ArbitrumMessage + alias Explorer.Chain.Scroll.Batch, as: ScrollBatch + alias Explorer.Chain.Scroll.BatchBundle, as: ScrollBatchBundle + alias Explorer.Chain.Scroll.Bridge, as: ScrollBridge alias Explorer.Chain.Stability.Validator, as: ValidatorStability - alias Explorer.Chain.Celo.PendingEpochBlockOperation, as: CeloPendingEpochBlockOperation alias Explorer.Chain.{ Address, @@ -33,28 +42,41 @@ defmodule Explorer.Factory do Address.TokenBalance, Address.CoinBalance, Address.CoinBalanceDaily, + Address.ScamBadgeToAddress, Block, ContractMethod, Data, + FheOperation, Hash, InternalTransaction, + InternalTransaction.DeleteQueue, Log, + MultichainSearchDb, PendingBlockOperation, PendingTransactionOperation, + SignedAuthorization, SmartContract, SmartContractAdditionalSource, Token, TokenTransfer, Token.Instance, Transaction, + TransactionError, + Wei, Withdrawal } - alias Explorer.Chain.Optimism.OutputRoot + alias Explorer.Chain.Optimism.{InteropMessage, OutputRoot} alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Chain.Zilliqa.Hash.BLSPublicKey alias Explorer.Chain.Zilliqa.Staker, as: ZilliqaStaker + alias Explorer.Chain.Optimism.Deposit, as: OptimismDeposit + + alias Explorer.Chain.Celo.AggregatedElectionReward, as: CeloAggregatedElectionReward + alias Explorer.Chain.Celo.ElectionReward, as: CeloElectionReward + alias Explorer.Chain.Celo.Epoch, as: CeloEpoch + alias Explorer.Migrator.MigrationStatus alias Explorer.SmartContract.Helper @@ -62,7 +84,14 @@ defmodule Explorer.Factory do alias Explorer.Market.MarketHistory alias Explorer.Repo - alias Explorer.Utility.{MissingBalanceOfToken, MissingBlockRange} + alias Explorer.Utility.{ + AddressIdToAddressHash, + EventNotification, + InternalTransactionsAddressPlaceholder, + MassiveBlock, + MissingBalanceOfToken, + MissingBlockRange + } alias Ueberauth.Strategy.Auth0 alias Ueberauth.Auth.{Extra, Info} @@ -140,27 +169,36 @@ defmodule Explorer.Factory do end def watchlist_address_factory do + notification_settings = %{ + "native" => %{ + "incoming" => random_bool(), + "outcoming" => random_bool() + }, + "ERC-20" => %{ + "incoming" => random_bool(), + "outcoming" => random_bool() + }, + "ERC-721" => %{ + "incoming" => random_bool(), + "outcoming" => random_bool() + }, + "ERC-404" => %{ + "incoming" => random_bool(), + "outcoming" => random_bool() + } + } + + notification_settings_extended = + if chain_type() == :zilliqa do + Map.put(notification_settings, "ZRC-2", %{"incoming" => random_bool(), "outcoming" => random_bool()}) + else + notification_settings + end + %{ "address_hash" => to_string(build(:address).hash), "name" => sequence("test"), - "notification_settings" => %{ - "native" => %{ - "incoming" => random_bool(), - "outcoming" => random_bool() - }, - "ERC-20" => %{ - "incoming" => random_bool(), - "outcoming" => random_bool() - }, - "ERC-721" => %{ - "incoming" => random_bool(), - "outcoming" => random_bool() - }, - "ERC-404" => %{ - "incoming" => random_bool(), - "outcoming" => random_bool() - } - }, + "notification_settings" => notification_settings_extended, "notification_methods" => %{ "email" => random_bool() } @@ -170,7 +208,7 @@ defmodule Explorer.Factory do def watchlist_address_db_factory(%{wl_id: id}) do hash = insert(:address).hash - %WatchlistAddress{ + watchlist_address = %WatchlistAddress{ name: sequence("test"), watchlist_id: id, address_hash: hash, @@ -187,6 +225,17 @@ defmodule Explorer.Factory do watch_erc_404_output: random_bool(), notify_email: random_bool() } + + watchlist_address_extended = + if chain_type() == :zilliqa do + watchlist_address + |> Map.put(:watch_zrc_2_input, random_bool()) + |> Map.put(:watch_zrc_2_output, random_bool()) + else + watchlist_address + end + + watchlist_address_extended end def custom_abi_factory do @@ -195,19 +244,6 @@ defmodule Explorer.Factory do %{"contract_address_hash" => contract_address_hash, "name" => sequence("test"), "abi" => contract_code_info().abi} end - def public_tags_request_factory do - %{ - "full_name" => sequence("full name"), - "email" => sequence(:email, &"test_user-#{&1}@blockscout.com"), - "tags" => Enum.join(Enum.map(1..Enum.random(1..2), fn _ -> sequence("Tag") end), ";"), - "website" => sequence("website"), - "additional_comment" => sequence("additional_comment"), - "addresses" => Enum.map(1..Enum.random(1..10), fn _ -> to_string(build(:address).hash) end), - "company" => sequence("company"), - "is_owner" => random_bool() - } - end - def account_watchlist_factory do %Watchlist{ identity: build(:account_identity) @@ -266,6 +302,37 @@ defmodule Explorer.Factory do } end + def multichain_search_db_export_token_info_queue_factory do + [data_type] = Enum.take_random([:metadata, :total_supply, :counters, :market_data], 1) + + data = + case data_type do + :metadata -> + %{ + token_type: "ERC-20", + name: sequence("TokenName"), + symbol: sequence("TS"), + decimals: 18, + total_supply: "1000" + } + + :total_supply -> + %{total_supply: "1000"} + + :counters -> + %{transfers_count: "456", holders_count: "123"} + + :market_data -> + %{fiat_value: "123.456", circulating_market_cap: "1000.0001"} + end + + %MultichainSearchDb.TokenInfoExportQueue{ + address_hash: address_hash(), + data_type: data_type, + data: data + } + end + def address_factory do %Address{ hash: address_hash() @@ -273,6 +340,22 @@ defmodule Explorer.Factory do |> Map.merge(address_factory_chain_type_fields()) end + def address_id_to_address_hash_factory do + %AddressIdToAddressHash{ + address_id: sequence("address_id", & &1), + address: build(:address) + } + end + + def deleted_internal_transactions_address_placeholder_factory do + %InternalTransactionsAddressPlaceholder{ + address_id: sequence("address_id", & &1), + block_number: block_number(), + count_tos: 1, + count_froms: 1 + } + end + case @chain_type do :zksync -> defp address_factory_chain_type_fields() do @@ -705,18 +788,6 @@ defmodule Explorer.Factory do |> Repo.update!() end - def with_contract_creation(%InternalTransaction{} = internal_transaction, %Address{ - contract_code: contract_code, - hash: contract_address_hash - }) do - internal_transaction - |> InternalTransaction.changeset(%{ - contract_code: contract_code, - created_contract_address_hash: contract_address_hash - }) - |> Repo.update!() - end - def data(sequence_name) do unpadded = sequence_name @@ -744,62 +815,109 @@ defmodule Explorer.Factory do %PendingTransactionOperation{} end - def internal_transaction_factory() do + def multichain_search_db_main_export_queue_factory do + %MultichainSearchDb.MainExportQueue{ + hash: address_hash().bytes, + hash_type: :address + } + end + + def multichain_search_db_export_balances_queue_factory do + %MultichainSearchDb.BalancesExportQueue{ + address_hash: address_hash().bytes, + token_contract_address_hash_or_native: "native" + } + end + + def multichain_search_db_export_counters_queue_factory do + %MultichainSearchDb.CountersExportQueue{ + timestamp: DateTime.utc_now(), + counter_type: :global, + data: %{ + "daily_transactions_count" => 100, + "total_transactions_count" => 10000, + "total_addresses_count" => 5000 + } + } + end + + def internal_transaction_factory(attrs) do gas = Enum.random(21_000..100_000) gas_used = Enum.random(0..gas) + all_attrs = + attrs + |> adjust_internal_transaction_addresses_attrs([:from_address, :to_address]) + |> adjust_internal_transaction_error_attr() + %InternalTransaction{ - from_address: build(:address), - to_address: build(:address), call_type: :delegatecall, gas: gas, gas_used: gas_used, input: %Data{bytes: <<1>>}, output: %Data{bytes: <<2>>}, # caller MUST supply `index` - trace_address: [], + trace_address: nil, # caller MUST supply `transaction` because it can't be built lazily to allow overrides without creating an extra # transaction # caller MUST supply `block_hash` (usually the same as the transaction's) - # caller MUST supply `block_index` type: :call, value: sequence("internal_transaction_value", &Decimal.new(&1)) } + |> merge_attributes(all_attrs) + |> evaluate_lazy_attributes() end - def internal_transaction_create_factory() do + def internal_transaction_create_factory(attrs) do gas = Enum.random(21_000..100_000) gas_used = Enum.random(0..gas) contract_code = Map.fetch!(contract_code_info(), :bytecode) + all_attrs = + attrs + |> adjust_internal_transaction_addresses_attrs([:from_address, :created_contract_address], contract_code) + |> adjust_internal_transaction_error_attr() + %InternalTransaction{ created_contract_code: contract_code, - created_contract_address: build(:address, contract_code: contract_code), - from_address: build(:address), gas: gas, gas_used: gas_used, # caller MUST supply `index` init: data(:internal_transaction_init), - trace_address: [], + trace_address: nil, # caller MUST supply `transaction` because it can't be built lazily to allow overrides without creating an extra # transaction # caller MUST supply `block_hash` (usually the same as the transaction's) - # caller MUST supply `block_index` type: :create, value: sequence("internal_transaction_value", &Decimal.new(&1)) } + |> merge_attributes(all_attrs) + |> evaluate_lazy_attributes() end - def internal_transaction_selfdestruct_factory() do + def internal_transaction_selfdestruct_factory(attrs) do + all_attrs = + attrs + |> adjust_internal_transaction_addresses_attrs([:from_address, :to_address]) + |> adjust_internal_transaction_error_attr() + %InternalTransaction{ from_address: build(:address), - trace_address: [], + trace_address: nil, # caller MUST supply `transaction` because it can't be built lazily to allow overrides without creating an extra # transaction type: :selfdestruct, value: sequence("internal_transaction_value", &Decimal.new(&1)) } + |> merge_attributes(all_attrs) + |> evaluate_lazy_attributes() + end + + def transaction_error_factory do + %TransactionError{ + message: "error_#{sequence("transaction_error_message", & &1)}" + } end def log_factory do @@ -838,19 +956,39 @@ defmodule Explorer.Factory do Map.replace(token_factory(), :name, sequence("Infinite Token")) end + def massive_block_factory do + %MassiveBlock{ + number: block_number() + } + end + + def erc7984_token_factory do + %Token{ + name: "Confidential Token", + symbol: "CT", + total_supply: 1_000_000_000, + decimals: 18, + contract_address: build(:address), + type: "ERC-7984", + cataloged: true + } + end + def token_transfer_log_factory do - token_contract_address = build(:address) to_address = build(:address) from_address = build(:address) - transaction = build(:transaction, to_address: token_contract_address, from_address: from_address) + contract_code = Map.fetch!(contract_code_info(), :bytecode) + token_address = insert(:contract_address, contract_code: contract_code) + + transaction = build(:transaction, to_address: token_address, from_address: from_address) log_params = %{ first_topic: TokenTransfer.constant(), second_topic: zero_padded_address_hash_string(from_address.hash), third_topic: zero_padded_address_hash_string(to_address.hash), - address_hash: token_contract_address.hash, - address: nil, + address_hash: token_address.hash, + address: token_address, data: "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", transaction: transaction } @@ -899,6 +1037,81 @@ defmodule Explorer.Factory do } end + def token_transfer_with_predefined_params_factory(%{log: log, block: block}) do + to_address_hash = address_hash_from_zero_padded_hash_string(to_string(log.third_topic)) + from_address_hash = address_hash_from_zero_padded_hash_string(to_string(log.second_topic)) + + # `to_address` is the only thing that isn't created from the token_transfer_log_factory + to_address = build(:address, hash: to_address_hash) + from_address = build(:address, hash: from_address_hash) + + token = insert(:token, contract_address: log.address) + + %TokenTransfer{ + block: block, + amount: Decimal.new(1), + block_number: block.number, + from_address: from_address, + to_address: to_address, + token_contract_address: log.address, + token_type: token.type, + transaction: log.transaction, + log_index: log.index, + block_consensus: true + } + end + + def erc7984_token_transfer_log_factory do + from_address = build(:address) + to_address = build(:address) + token_address = insert(:contract_address) + transaction = build(:transaction) + + # ConfidentialTransfer(address indexed from, address indexed to, bytes32 indexed amount) + # Event signature: 0x67500e8d0ed826d2194f514dd0d8124f35648ab6e3fb5e6ed867134cffe661e9 + amount_pointer = + sequence("erc7984_amount_pointer", &("0x" <> String.pad_leading(Integer.to_string(&1, 16), 64, "0"))) + + log_params = %{ + first_topic: "0x67500e8d0ed826d2194f514dd0d8124f35648ab6e3fb5e6ed867134cffe661e9", + second_topic: zero_padded_address_hash_string(from_address.hash), + third_topic: zero_padded_address_hash_string(to_address.hash), + fourth_topic: amount_pointer, + address_hash: token_address.hash, + address: token_address, + data: "0x", + transaction: transaction + } + + build(:log, log_params) + end + + def erc7984_token_transfer_factory do + log = build(:erc7984_token_transfer_log) + to_address_hash = address_hash_from_zero_padded_hash_string(log.third_topic) + from_address_hash = address_hash_from_zero_padded_hash_string(log.second_topic) + + to_address = build(:address, hash: to_address_hash) + from_address = build(:address, hash: from_address_hash) + + _token = insert(:erc7984_token, contract_address: log.address) + block = build(:block) + + %TokenTransfer{ + block: block, + amount: nil, + block_number: block.number, + from_address: from_address, + to_address: to_address, + token_contract_address: log.address, + token_type: "ERC-7984", + transaction: log.transaction, + log_index: log.index, + token_ids: nil, + block_consensus: true + } + end + def market_history_factory do %MarketHistory{ closing_price: price(), @@ -940,6 +1153,7 @@ defmodule Explorer.Factory do def transaction_factory do %Transaction{ from_address: build(:address), + fhe_operations_count: 0, gas: Enum.random(21_000..100_000), gas_price: Enum.random(10..99) * 1_000_000_00, hash: transaction_hash(), @@ -1006,11 +1220,26 @@ defmodule Explorer.Factory do } end + def signed_authorization_factory do + %SignedAuthorization{ + transaction: build(:transaction), + index: 0, + chain_id: 0, + address: address_hash(), + nonce: 0, + r: 0, + s: 0, + v: 0, + authority: address_hash(), + status: nil + } + end + def smart_contract_factory do contract_code_info = contract_code_info() {:ok, data} = Explorer.Chain.Data.cast(contract_code_info.bytecode) - bytecode_md5 = Helper.contract_code_md5(data.bytes) + bytecode_md5 = Helper.md5(data.bytes) %SmartContract{ address_hash: insert(:address, contract_code: contract_code_info.bytecode, verified: true).hash, @@ -1162,6 +1391,59 @@ defmodule Explorer.Factory do } end + defp adjust_internal_transaction_addresses_attrs(attrs, addresses_fields, contract_code \\ nil) do + hash_fields = Enum.map(addresses_fields, &String.to_existing_atom("#{&1}_hash")) + {address_related_attrs, other_attrs} = Map.split(attrs, addresses_fields ++ hash_fields) + + addresses_fields + |> Enum.reduce(address_related_attrs, fn address_field, acc -> + hash_field = String.to_existing_atom("#{address_field}_hash") + + additional_fields = + case address_field do + :created_contract_address -> %{contract_code: contract_code} + _ -> %{} + end + + address = + case Map.has_key?(acc, address_field) && Map.get(acc, address_field) do + nil -> + nil + + false -> + address_params = + Map.merge(additional_fields, if(hash = Map.get(acc, hash_field), do: %{hash: hash}, else: %{})) + + case Map.get(address_params, :hash) && Repo.get_by(Address, hash: address_params.hash) do + nil -> + built_address = build(:address, address_params) + insert(built_address) + built_address + + _ -> + build(:address, address_params) + end + + address -> + address + end + + Map.merge(acc, %{ + address_field => address, + hash_field => address && address.hash, + :"#{address_field}_mapping" => address && AddressIdToAddressHash.find_or_create(address.hash) + }) + end) + |> Map.merge(other_attrs) + end + + defp adjust_internal_transaction_error_attr(attrs) do + case Map.get(attrs, :error) do + nil -> attrs + error -> Map.put(attrs, :error_id, Map.get(attrs, :error_id) || TransactionError.find_or_create(error)) + end + end + defp block_hash_to_next_transaction_index(block_hash) do import Kernel, except: [+: 2] @@ -1272,6 +1554,22 @@ defmodule Explorer.Factory do } end + def op_interop_message_factory do + %InteropMessage{ + sender_address_hash: insert(:address).hash, + target_address_hash: insert(:address).hash, + nonce: sequence("op_interop_message_nonce", & &1), + init_chain_id: 1, + init_transaction_hash: insert(:transaction).hash, + block_number: insert(:block).number, + timestamp: DateTime.utc_now(), + relay_chain_id: 2, + relay_transaction_hash: transaction_hash(), + payload: "0x123123", + failed: random_bool() + } + end + def op_output_root_factory do %OutputRoot{ l2_output_index: op_output_root_l2_output_index(), @@ -1283,6 +1581,31 @@ defmodule Explorer.Factory do } end + def op_deposit_factory do + block = insert(:block) + gas_used = Enum.random(21_000..100_000) + + l2_transaction = + insert( + :transaction, + block_number: block.number, + block_hash: block.hash, + cumulative_gas_used: gas_used, + gas_used: gas_used, + index: 0, + status: :ok + ) + + %OptimismDeposit{ + l1_block_number: block_number(), + l1_block_timestamp: DateTime.utc_now(), + l1_transaction_hash: transaction_hash(), + l1_transaction_origin: address_hash(), + l2_transaction_hash: l2_transaction.hash, + l2_transaction: l2_transaction + } + end + def db_migration_status_factory do %MigrationStatus{ migration_name: nil, @@ -1308,14 +1631,78 @@ defmodule Explorer.Factory do hash end + def scroll_bridge_factory do + %ScrollBridge{ + type: :deposit, + index: sequence("scroll_bridge_index", & &1), + l1_transaction_hash: transaction_hash(), + l2_transaction_hash: transaction_hash(), + amount: Enum.random(1..100_000), + block_number: block_number(), + block_timestamp: DateTime.utc_now(), + message_hash: transaction_hash() + } + end + + def scroll_batch_factory do + %ScrollBatch{ + number: sequence("scroll_batch_index", & &1), + commit_transaction_hash: transaction_hash(), + commit_block_number: block_number(), + commit_timestamp: DateTime.utc_now(), + bundle_id: 0, + container: :in_calldata + } + end + + def scroll_batch_bundle_factory do + %ScrollBatchBundle{ + final_batch_number: 50, + finalize_transaction_hash: transaction_hash(), + finalize_block_number: block_number(), + finalize_timestamp: DateTime.utc_now() + } + end + def random_bool, do: Enum.random([true, false]) + def celo_epoch_factory do + %CeloEpoch{ + number: sequence("celo_epoch_number", & &1), + fetched?: false, + start_block_number: nil, + end_block_number: nil, + start_processing_block_hash: nil, + end_processing_block_hash: nil + } + end + + def celo_aggregated_election_reward_factory do + %CeloAggregatedElectionReward{ + epoch_number: sequence("celo_aggregated_election_reward_epoch_number", & &1), + type: Enum.random(CeloElectionReward.types()), + sum: Enum.random(1..100_000), + count: Enum.random(0..100) + } + end + + def celo_election_reward_factory do + %CeloElectionReward{ + amount: Enum.random(1..100_000), + type: Enum.random(CeloElectionReward.types()), + epoch_number: sequence("celo_election_reward_epoch_number", & &1), + account_address_hash: insert(:address).hash, + associated_account_address_hash: insert(:address).hash + } + end + def validator_stability_factory do address = insert(:address) %ValidatorStability{ address_hash: address.hash, - state: Enum.random(0..2) + state: Enum.random(0..2), + blocks_validated: Enum.random(0..100) } end @@ -1347,10 +1734,6 @@ defmodule Explorer.Factory do to_string(bls_public_key) end - def celo_pending_epoch_block_operation_factory do - %CeloPendingEpochBlockOperation{} - end - def withdrawal_log_factory(params) do weth_log(TokenTransfer.weth_withdrawal_signature(), params) end @@ -1382,4 +1765,237 @@ defmodule Explorer.Factory do transaction: transaction } end + + def event_notification_factory do + %EventNotification{ + data: "test_data", + inserted_at: DateTime.utc_now() + } + end + + def scam_badge_to_address_factory do + %ScamBadgeToAddress{ + address_hash: insert(:address).hash + } + end + + def internal_transaction_delete_queue_factory do + %DeleteQueue{ + block_number: block_number() + } + end + + @beacon_deposit_abi ABI.parse_specification( + [ + %{ + "anonymous" => false, + "inputs" => [ + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "pubkey", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "withdrawal_credentials", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "amount", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "signature", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "index", + "type" => "bytes" + } + ], + "name" => "DepositEvent", + "type" => "event" + } + ], + include_events?: true + ) + |> List.first() + + def beacon_deposit_log_factory(attrs) do + pubkey_raw = Map.get(attrs, :deposit_pubkey, sequence("beacon_deposit_pubkey", &<<&1::384>>)) + + withdrawal_credentials_raw = + Map.get( + attrs, + :deposit_withdrawal_credentials, + sequence("beacon_deposit_withdrawal_credentials", &<<&1::256>>) + ) + + amount = Map.get(attrs, :deposit_amount, sequence("beacon_deposit_amount", & &1)) + signature_raw = Map.get(attrs, :deposit_signature, sequence("beacon_deposit_signature", &<<&1::768>>)) + index = Map.get_lazy(attrs, :deposit_index, fn -> sequence("beacon_deposit_index", & &1) end) + + <<_::bytes-4, data_raw::binary>> = + ABI.TypeEncoder.encode( + [ + pubkey_raw, + withdrawal_credentials_raw, + <>, + signature_raw, + <> + ], + @beacon_deposit_abi + ) + + build( + :log, + Map.merge( + %{ + first_topic: "0x649BBC62D0E31342AFEA4E5CD82D4049E7E1EE912FC0889AA790803BE39038C5", + second_topic: nil, + third_topic: nil, + fourth_topic: nil, + data: %Data{bytes: data_raw} + }, + attrs + |> Map.drop([ + :deposit_pubkey, + :deposit_withdrawal_credentials, + :deposit_amount, + :deposit_signature, + :deposit_index + ]) + ) + ) + end + + def beacon_deposit_factory do + pubkey_raw = sequence("beacon_deposit_pubkey", &<<&1::384>>) + withdrawal_credentials_raw = sequence("beacon_deposit_withdrawal_credentials", &<<&1::256>>) + amount = sequence("beacon_deposit_amount", & &1) + signature_raw = sequence("beacon_deposit_signature", &<<&1::768>>) + index = sequence("beacon_deposit_index", & &1) + + log = + insert(:beacon_deposit_log, + deposit_pubkey: pubkey_raw, + deposit_withdrawal_credentials: withdrawal_credentials_raw, + deposit_amount: amount, + deposit_signature: signature_raw, + deposit_index: index + ) + + block = insert(:block) + transaction = insert(:transaction) |> with_block(block) + + %Deposit{ + pubkey: %Data{bytes: pubkey_raw}, + withdrawal_credentials: %Data{bytes: withdrawal_credentials_raw}, + amount: amount |> Decimal.new() |> Wei.from(:wei), + signature: %Data{bytes: signature_raw}, + index: index, + block_number: transaction.block.number, + block_timestamp: transaction.block.timestamp, + log_index: log.index, + status: :pending, + from_address: insert(:address), + block: transaction.block, + transaction: transaction + } + end + + def fhe_operation_factory do + transaction = insert(:transaction) |> with_block() + block = transaction.block + caller = insert(:address) + + %FheOperation{ + transaction_hash: transaction.hash, + log_index: sequence("fhe_operation_log_index", & &1), + block_hash: block.hash, + block_number: block.number, + operation: sequence("fhe_operation", fn i -> "FheAdd#{i}" end), + operation_type: "arithmetic", + fhe_type: "Uint8", + is_scalar: false, + hcu_cost: sequence("fhe_operation_hcu_cost", fn i -> Kernel.+(100, i) end), + hcu_depth: sequence("fhe_operation_hcu_depth", fn i -> Kernel.+(1, rem(i, 5)) end), + caller: caller.hash, + result_handle: sequence("fhe_operation_result_handle", &<<&1::256>>), + input_handles: %{ + "lhs" => "0x" <> Base.encode16(<<1::256>>, case: :lower), + "rhs" => "0x" <> Base.encode16(<<2::256>>, case: :lower) + } + } + end + + def migration_status_factory do + %MigrationStatus{ + migration_name: sequence("migration_", &"migration_#{&1}"), + status: "started", + meta: nil + } + end + + def arbitrum_lifecycle_transaction_factory do + %ArbitrumLifecycleTransaction{ + id: sequence("arbitrum_lifecycle_tx_id", & &1, start_at: 1), + hash: transaction_hash(), + block_number: block_number(), + timestamp: DateTime.utc_now(), + status: :finalized + } + end + + def arbitrum_l1_batch_factory do + lifecycle_tx = insert(:arbitrum_lifecycle_transaction) + start = Enum.random(1..100_000) + + %ArbitrumL1Batch{ + number: sequence("arbitrum_l1_batch_number", & &1), + transactions_count: Enum.random(1..100), + start_block: start, + end_block: Kernel.+(start, 10), + before_acc: block_hash(), + after_acc: block_hash(), + commitment_id: lifecycle_tx.id + } + end + + def arbitrum_message_factory do + %ArbitrumMessage{ + direction: :to_l2, + message_id: sequence("arbitrum_message_id", & &1, start_at: 1), + originator_address: address_hash(), + originating_transaction_hash: transaction_hash(), + origination_timestamp: DateTime.utc_now(), + originating_transaction_block_number: block_number(), + completion_transaction_hash: transaction_hash(), + status: :relayed + } + end + + # Pure association factory — callers must supply `:batch_number` and `:block_number` + # as overrides referencing existing records. Both are required foreign keys on + # `Explorer.Chain.Arbitrum.BatchBlock`, so `insert(:arbitrum_batch_block)` without + # overrides will raise. + def arbitrum_batch_block_factory do + %ArbitrumBatchBlock{} + end + + # Pure association factory — callers must supply `:batch_number` and `:transaction_hash` + # as overrides referencing existing records. Both are required foreign keys on + # `Explorer.Chain.Arbitrum.BatchTransaction`, so `insert(:arbitrum_batch_transaction)` + # without overrides will raise. + def arbitrum_batch_transaction_factory do + %ArbitrumBatchTransaction{} + end end diff --git a/apps/explorer/test/support/fakes/one_coin_source.ex b/apps/explorer/test/support/fakes/one_coin_source.ex index 8152a12326fd..a53327b04c1d 100644 --- a/apps/explorer/test/support/fakes/one_coin_source.ex +++ b/apps/explorer/test/support/fakes/one_coin_source.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Explorer.Market.Source.OneCoinSource do @moduledoc false @@ -17,7 +18,8 @@ defmodule Explorer.Market.Source.OneCoinSource do symbol: Explorer.coin(), fiat_value: Decimal.new(1), volume_24h: Decimal.new(1), - image_url: nil + image_url: nil, + circulating_supply: Decimal.new(10_000_000) } {:ok, address_hash} = Hash.Address.cast("0x0000000000000000000000000000000000000001") diff --git a/apps/explorer/test/support/fixture/exchange_rates/coin_gecko.json b/apps/explorer/test/support/fixture/exchange_rates/coin_gecko.json deleted file mode 100644 index cc110eb402d9..000000000000 --- a/apps/explorer/test/support/fixture/exchange_rates/coin_gecko.json +++ /dev/null @@ -1,1806 +0,0 @@ -{ - "asset_platform_id": null, - "block_time_in_minutes": 0, - "categories": [], - "coingecko_rank": 376, - "coingecko_score": 33.7, - "community_data": { - "facebook_likes": 0, - "reddit_accounts_active_48h": 0, - "reddit_average_comments_48h": 0.0, - "reddit_average_posts_48h": 0.0, - "reddit_subscribers": 0, - "telegram_channel_user_count": 5125, - "twitter_followers": 17737 - }, - "community_score": 9.403, - "country_origin": "", - "description": { - "ar": "", - "de": "", - "en": "", - "es": "", - "fr": "", - "hu": "", - "id": "", - "it": "", - "ja": "", - "ko": "", - "nl": "", - "pl": "", - "pt": "", - "ro": "", - "ru": "", - "sv": "", - "th": "", - "tr": "", - "vi": "", - "zh": "", - "zh-tw": "" - }, - "developer_data": { - "closed_issues": 0, - "code_additions_deletions_4_weeks": { - "additions": 0, - "deletions": 0 - }, - "commit_count_4_weeks": 0, - "forks": 44, - "last_4_weeks_commit_activity_series": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "pull_request_contributors": 6, - "pull_requests_merged": 133, - "stars": 58, - "subscribers": 8, - "total_issues": 0 - }, - "developer_score": 52.776, - "genesis_date": null, - "id": "poa-network", - "image": { - "large": "https://assets.coingecko.com/coins/images/3157/large/poa-network.png?1548331565", - "small": "https://assets.coingecko.com/coins/images/3157/small/poa-network.png?1548331565", - "thumb": "https://assets.coingecko.com/coins/images/3157/thumb/poa-network.png?1548331565" - }, - "last_updated": "2019-08-21T08:36:49.371Z", - "links": { - "announcement_url": [ - "", - "" - ], - "bitcointalk_thread_identifier": null, - "blockchain_site": [ - "https://blockscout.com/poa/core/", - "", - "", - "", - "" - ], - "chat_url": [ - "", - "", - "" - ], - "facebook_username": "", - "homepage": [ - "https://poa.network/", - "", - "" - ], - "official_forum_url": [ - "", - "", - "" - ], - "repos_url": { - "bitbucket": [], - "github": [ - "https://github.com/poanetwork/poa-network-consensus-contracts" - ] - }, - "subreddit_url": null, - "telegram_channel_identifier": "oraclesnetwork", - "twitter_screen_name": "poanetwork" - }, - "liquidity_score": 19.334, - "localization": { - "ar": "POA Network", - "de": "POA Network", - "en": "POA Network", - "es": "POA Network", - "fr": "POA Network", - "hu": "POA Network", - "id": "POA Network", - "it": "POA Network", - "ja": "ポアネットワーク", - "ko": "POA 네트워크", - "nl": "POA Network", - "pl": "POA Network", - "pt": "POA Network", - "ro": "POA Network", - "ru": "POA Network", - "sv": "POA Network", - "th": "POA Network", - "tr": "POA Network", - "vi": "POA Network", - "zh": "POA Network", - "zh-tw": "POA Network" - }, - "market_cap_rank": 499, - "market_data": { - "ath": { - "aed": 3.06, - "ars": 18.93, - "aud": 1.25, - "bch": 0.00076772, - "bdt": 70.74, - "bhd": 0.314495, - "bmd": 0.833914, - "bnb": 0.01322268, - "brl": 3.36, - "btc": 0.00010137, - "cad": 1.2, - "chf": 0.938803, - "clp": 519.58, - "cny": 5.94, - "czk": 17.83, - "dkk": 5.87, - "eos": 0.02444974, - "eth": 0.0011725, - "eur": 0.787803, - "gbp": 0.689151, - "hkd": 7.35, - "huf": 219.96, - "idr": 13179.33, - "ils": 2.97, - "inr": 62.94, - "jpy": 102.6, - "krw": 1000.83, - "kwd": 0.251571, - "lkr": 131.79, - "ltc": 0.00537407, - "mmk": 1125.41, - "mxn": 18.15, - "myr": 3.76, - "nok": 6.67, - "nzd": 1.35, - "php": 48.59, - "pkr": 96.55, - "pln": 3.34, - "rub": 58.27, - "sar": 3.13, - "sek": 8.13, - "sgd": 1.25, - "thb": 26.65, - "try": 3.54, - "twd": 27.89, - "uah": 1.44, - "usd": 0.935923, - "vef": 58307, - "vnd": 1281.67, - "xag": 0.056398, - "xau": 0.00071129, - "xdr": 0.656022, - "xlm": 0.58965135, - "xrp": 0.2920468, - "zar": 11.63 - }, - "ath_change_percentage": { - "aed": -98.3819, - "ars": -96.09901, - "aud": -98.40941, - "bch": -94.11046, - "bdt": -98.39058, - "bhd": -98.38261, - "bmd": -98.38194, - "bnb": -96.24179, - "brl": -98.37442, - "btc": -98.68937, - "cad": -98.49893, - "chf": -98.59269, - "clp": -98.15517, - "cny": -98.39701, - "czk": -98.2406, - "dkk": -98.45473, - "eos": -84.56025, - "eth": -93.8673, - "eur": -98.45627, - "gbp": -98.38711, - "hkd": -98.55948, - "huf": -98.18974, - "idr": -98.54127, - "ils": -98.39903, - "inr": -98.46908, - "jpy": -98.59899, - "krw": -98.37868, - "kwd": -98.3684, - "lkr": -98.16735, - "ltc": -96.51103, - "mmk": -98.17585, - "mxn": -98.53298, - "myr": -98.49907, - "nok": -98.18519, - "nzd": -98.43938, - "php": -98.54716, - "pkr": -97.76349, - "pln": -98.41583, - "rub": -98.4658, - "sar": -98.3821, - "sek": -98.39542, - "sgd": -98.51152, - "thb": -98.44172, - "try": -97.81784, - "twd": -98.48322, - "uah": -76.52065, - "usd": -98.5583, - "vef": -94.2496, - "vnd": -75.59837, - "xag": -98.59644, - "xau": -98.73411, - "xdr": -98.49988, - "xlm": -66.24383, - "xrp": -82.56607, - "zar": -98.23009 - }, - "ath_date": { - "aed": "2018-05-10T00:00:00.000Z", - "ars": "2018-05-10T00:00:00.000Z", - "aud": "2018-05-10T09:45:31.809Z", - "bch": "2018-04-14T00:00:00.000Z", - "bdt": "2018-05-10T00:00:00.000Z", - "bhd": "2018-05-10T00:00:00.000Z", - "bmd": "2018-05-10T00:00:00.000Z", - "bnb": "2018-10-16T12:40:17.749Z", - "brl": "2018-05-10T09:45:31.809Z", - "btc": "2018-05-10T09:45:31.809Z", - "cad": "2018-05-10T09:45:31.809Z", - "chf": "2018-05-10T09:45:31.809Z", - "clp": "2018-05-10T00:00:00.000Z", - "cny": "2018-05-10T09:45:31.809Z", - "czk": "2018-05-10T00:00:00.000Z", - "dkk": "2018-05-10T09:45:31.809Z", - "eos": "2018-10-16T12:40:17.749Z", - "eth": "2018-04-09T00:00:00.000Z", - "eur": "2018-05-10T09:45:31.809Z", - "gbp": "2018-05-10T09:45:31.809Z", - "hkd": "2018-05-10T09:45:31.809Z", - "huf": "2018-05-10T00:00:00.000Z", - "idr": "2018-05-10T09:45:31.809Z", - "ils": "2018-05-10T00:00:00.000Z", - "inr": "2018-05-10T09:45:31.809Z", - "jpy": "2018-05-10T09:45:31.809Z", - "krw": "2018-05-10T09:45:31.809Z", - "kwd": "2018-05-10T00:00:00.000Z", - "lkr": "2018-05-10T00:00:00.000Z", - "ltc": "2018-05-10T00:00:00.000Z", - "mmk": "2018-05-10T00:00:00.000Z", - "mxn": "2018-05-10T09:45:31.809Z", - "myr": "2018-05-10T09:45:31.809Z", - "nok": "2018-05-10T00:00:00.000Z", - "nzd": "2018-05-10T09:45:31.809Z", - "php": "2018-05-10T09:45:31.809Z", - "pkr": "2018-05-10T00:00:00.000Z", - "pln": "2018-05-10T09:45:31.809Z", - "rub": "2018-05-10T09:45:31.809Z", - "sar": "2018-05-10T00:00:00.000Z", - "sek": "2018-05-10T09:45:31.809Z", - "sgd": "2018-05-10T09:45:31.809Z", - "thb": "2018-05-10T00:00:00.000Z", - "try": "2018-05-10T00:00:00.000Z", - "twd": "2018-05-10T09:45:31.809Z", - "uah": "2019-05-27T12:49:29.532Z", - "usd": "2018-05-10T09:45:31.809Z", - "vef": "2018-05-10T00:00:00.000Z", - "vnd": "2019-05-27T12:49:29.532Z", - "xag": "2018-05-10T09:45:31.809Z", - "xau": "2018-05-10T09:45:31.809Z", - "xdr": "2018-05-10T09:45:31.809Z", - "xlm": "2018-10-16T12:49:26.379Z", - "xrp": "2018-10-16T12:40:17.749Z", - "zar": "2018-05-10T09:45:31.809Z" - }, - "circulating_supply": 220167621.0, - "current_price": { - "aed": 0.04942712, - "ars": 0.73641, - "aud": 0.0198328, - "bch": 4.486e-05, - "bdt": 1.14, - "bhd": 0.00507448, - "bmd": 0.01345698, - "bnb": 0.00049505, - "brl": 0.054544, - "btc": 1.32e-06, - "cad": 0.0178991, - "chf": 0.01317061, - "clp": 9.55, - "cny": 0.095047, - "czk": 0.312629, - "dkk": 0.090366, - "eos": 0.0037559, - "eth": 7.147e-05, - "eur": 0.01211961, - "gbp": 0.01107566, - "hkd": 0.105541, - "huf": 3.97, - "idr": 191.75, - "ils": 0.04745883, - "inr": 0.960893, - "jpy": 1.43, - "krw": 16.19, - "kwd": 0.00409361, - "lkr": 2.4, - "ltc": 0.00018598, - "mmk": 20.47, - "mxn": 0.26536, - "myr": 0.056211, - "nok": 0.120606, - "nzd": 0.02100918, - "php": 0.704042, - "pkr": 2.15, - "pln": 0.052749, - "rub": 0.891153, - "sar": 0.050476, - "sek": 0.129909, - "sgd": 0.01861909, - "thb": 0.414071, - "try": 0.076819, - "twd": 0.422042, - "uah": 0.338308, - "usd": 0.01345698, - "vef": 3343.89, - "vnd": 311.66, - "xag": 0.00078867, - "xau": 8.98e-06, - "xdr": 0.00981471, - "xlm": 0.1975895, - "xrp": 0.05067322, - "zar": 0.204922 - }, - "high_24h": { - "aed": 0.053611, - "ars": 0.810805, - "aud": 0.02152674, - "bch": 4.628e-05, - "bdt": 1.23, - "bhd": 0.00550347, - "bmd": 0.0145949, - "bnb": 0.00052215, - "brl": 0.058875, - "btc": 1.37e-06, - "cad": 0.01943935, - "chf": 0.01428979, - "clp": 10.36, - "cny": 0.103044, - "czk": 0.339287, - "dkk": 0.098076, - "eos": 0.00402026, - "eth": 7.423e-05, - "eur": 0.01315448, - "gbp": 0.01201087, - "hkd": 0.114474, - "huf": 4.31, - "idr": 208.11, - "ils": 0.051491, - "inr": 1.04, - "jpy": 1.55, - "krw": 17.63, - "kwd": 0.00443977, - "lkr": 2.6, - "ltc": 0.00019631, - "mmk": 22.24, - "mxn": 0.288202, - "myr": 0.061014, - "nok": 0.131376, - "nzd": 0.02274396, - "php": 0.76332, - "pkr": 2.34, - "pln": 0.057291, - "rub": 0.972261, - "sar": 0.054726, - "sek": 0.14165, - "sgd": 0.02021141, - "thb": 0.449117, - "try": 0.083602, - "twd": 0.457778, - "uah": 0.367456, - "usd": 0.0145949, - "vef": 3626.65, - "vnd": 338.27, - "xag": 0.00085369, - "xau": 9.72e-06, - "xdr": 0.01064365, - "xlm": 0.20827298, - "xrp": 0.05318558, - "zar": 0.223696 - }, - "last_updated": "2019-08-21T08:36:49.371Z", - "low_24h": { - "aed": 0.0491677, - "ars": 0.732555, - "aud": 0.0197477, - "bch": 4.364e-05, - "bdt": 1.13, - "bhd": 0.00504693, - "bmd": 0.01338635, - "bnb": 0.00049273, - "brl": 0.054256, - "btc": 1.3e-06, - "cad": 0.01782004, - "chf": 0.01311131, - "clp": 9.51, - "cny": 0.094517, - "czk": 0.311235, - "dkk": 0.089982, - "eos": 0.00373147, - "eth": 7.098e-05, - "eur": 0.01206786, - "gbp": 0.01103078, - "hkd": 0.104993, - "huf": 3.95, - "idr": 190.79, - "ils": 0.04723279, - "inr": 0.958004, - "jpy": 1.43, - "krw": 16.1, - "kwd": 0.00406945, - "lkr": 2.39, - "ltc": 0.00018228, - "mmk": 20.37, - "mxn": 0.264134, - "myr": 0.055903, - "nok": 0.120189, - "nzd": 0.02091282, - "php": 0.700581, - "pkr": 2.14, - "pln": 0.052569, - "rub": 0.886879, - "sar": 0.050214, - "sek": 0.129578, - "sgd": 0.01853166, - "thb": 0.412199, - "try": 0.076627, - "twd": 0.4198, - "uah": 0.336532, - "usd": 0.01338635, - "vef": 3326.34, - "vnd": 310.32, - "xag": 0.00078625, - "xau": 8.93e-06, - "xdr": 0.0097632, - "xlm": 0.19635382, - "xrp": 0.05037993, - "zar": 0.204544 - }, - "market_cap": { - "aed": 10882251, - "ars": 162133701, - "aud": 4366541, - "bch": 9877, - "bdt": 249997342, - "bhd": 1117236, - "bmd": 2962791, - "bnb": 108994, - "brl": 12008719, - "btc": 290.732, - "cad": 3940802, - "chf": 2899743, - "clp": 2101619696, - "cny": 20926193, - "czk": 68830802, - "dkk": 19895675, - "eos": 826927, - "eth": 15736, - "eur": 2668346, - "gbp": 2438501, - "hkd": 23236814, - "huf": 873552757, - "idr": 42217105275, - "ils": 10448899, - "inr": 211557455, - "jpy": 315583737, - "krw": 3564593111, - "kwd": 901281, - "lkr": 529480380, - "ltc": 40948, - "mmk": 4507718929, - "mxn": 58423768, - "myr": 12375904, - "nok": 26553448, - "nzd": 4625542, - "php": 155007300, - "pkr": 474133000, - "pln": 11613646, - "rub": 196202962, - "sar": 11113192, - "sek": 28601664, - "sgd": 4099321, - "thb": 91165079, - "try": 16912998, - "twd": 92920077, - "uah": 74484427, - "usd": 2962791, - "vef": 736216950688, - "vnd": 68617204931, - "xag": 173639, - "xau": 1976.89, - "xdr": 2160882, - "xlm": 43502811, - "xrp": 11156602, - "zar": 45117091 - }, - "market_cap_change_24h": -160615.84039035, - "market_cap_change_24h_in_currency": { - "aed": -590899.65443375, - "ars": -9124373.5980551, - "aud": -239353.78003874, - "bch": 102.549, - "bdt": -13867874.4362154, - "bhd": -60251.00254582, - "bmd": -160615.84039035, - "bnb": -1507.543569741, - "brl": -718070.38245432, - "btc": 0.03769596, - "cad": -221121.61427417, - "chf": -161095.94704691, - "clp": -127118490.79824165, - "cny": -1134429.68067712, - "czk": -3925858.7665586, - "dkk": -1136450.3780936, - "eos": -24548.1071608166, - "eth": -56.533938946, - "eur": -152193.5816755, - "gbp": -144996.42715597, - "hkd": -1263688.72303977, - "huf": -48860628.81047357, - "idr": -2346101857.217293, - "ils": -576162.08791993, - "inr": -12451722.53525829, - "jpy": -16857632.06248754, - "krw": -209731718.0214291, - "kwd": -49171.67933105, - "lkr": -26048761.01962376, - "ltc": -309.0831858893, - "mmk": -251710124.48363495, - "mxn": -3503488.89323037, - "myr": -687829.52792143, - "nok": -1513569.75537506, - "nzd": -244980.03753899, - "php": -8388263.35503852, - "pkr": -26642819.2931419, - "pln": -696325.10549893, - "rub": -12221976.62125358, - "sar": -601457.67784725, - "sek": -1668070.90346983, - "sgd": -230174.18438848, - "thb": -5107689.97148955, - "try": -927927.24538566, - "twd": -5107834.82635887, - "uah": -4241989.31565817, - "usd": -160615.84039035, - "vef": -39911051485.156494, - "vnd": -4088394297.5983276, - "xag": -9606.58322042, - "xau": -102.32803274, - "xdr": -115659.93877989, - "xlm": -1276957.0373063833, - "xrp": -122973.5124849025, - "zar": -2969034.78716005 - }, - "market_cap_change_percentage_24h": -5.14233, - "market_cap_change_percentage_24h_in_currency": { - "aed": -5.15028, - "ars": -5.32785, - "aud": -5.19668, - "bch": 1.0491, - "bdt": -5.25567, - "bhd": -5.11691, - "bmd": -5.14233, - "bnb": -1.36428, - "brl": -5.6422, - "btc": 0.01297, - "cad": -5.31297, - "chf": -5.26313, - "clp": -5.70361, - "cny": -5.14233, - "czk": -5.39588, - "dkk": -5.4034, - "eos": -2.88301, - "eth": -0.35797, - "eur": -5.3959, - "gbp": -5.61241, - "hkd": -5.15781, - "huf": -5.29704, - "idr": -5.26466, - "ils": -5.22593, - "inr": -5.55858, - "jpy": -5.07086, - "krw": -5.5568, - "kwd": -5.1735, - "lkr": -4.689, - "ltc": -0.74917, - "mmk": -5.28866, - "mxn": -5.65743, - "myr": -5.26518, - "nok": -5.3927, - "nzd": -5.02985, - "php": -5.13372, - "pkr": -5.32031, - "pln": -5.65659, - "rub": -5.86397, - "sar": -5.13424, - "sek": -5.51069, - "sgd": -5.31642, - "thb": -5.30544, - "try": -5.20112, - "twd": -5.21059, - "uah": -5.38827, - "usd": -5.14233, - "vef": -5.14233, - "vnd": -5.62322, - "xag": -5.24248, - "xau": -4.92146, - "xdr": -5.08051, - "xlm": -2.85164, - "xrp": -1.09023, - "zar": -6.17441 - }, - "market_cap_rank": 499, - "price_change_24h": -0.00070501, - "price_change_24h_in_currency": { - "aed": -0.00259384, - "ars": -0.0401764, - "aud": -0.00106049, - "bch": 4.401e-07, - "bdt": -0.06091731, - "bhd": -0.00026442, - "bmd": -0.00070501, - "bnb": -7.9537e-06, - "brl": -0.00316161, - "btc": -7e-10, - "cad": -0.000975, - "chf": -0.00070391, - "clp": -0.55975334, - "cny": -0.00498938, - "czk": -0.01720638, - "dkk": -0.00497873, - "eos": -0.0001041039, - "eth": -1.756e-07, - "eur": -0.00066664, - "gbp": -0.00063744, - "hkd": -0.00554812, - "huf": -0.21652693, - "idr": -10.32117361, - "ils": -0.00254123, - "inr": -0.05433204, - "jpy": -0.07396812, - "krw": -0.92440016, - "kwd": -0.00021588, - "lkr": -0.11324611, - "ltc": -1.1432e-06, - "mmk": -1.10591975, - "mxn": -0.01564173, - "myr": -0.00303114, - "nok": -0.0066618, - "nzd": -0.00108422, - "php": -0.03666507, - "pkr": -0.11708205, - "pln": -0.0030605, - "rub": -0.05396307, - "sar": -0.00263569, - "sek": -0.00737328, - "sgd": -0.00101489, - "thb": -0.02240118, - "try": -0.00415931, - "twd": -0.02243055, - "uah": -0.01864934, - "usd": -0.00070501, - "vef": -175.18566197, - "vnd": -17.91454955, - "xag": -4.268e-05, - "xau": -4.5e-07, - "xdr": -0.00050746, - "xlm": -0.0043814329, - "xrp": -0.0005808077, - "zar": -0.01325238 - }, - "price_change_percentage_14d": -20.09212, - "price_change_percentage_14d_in_currency": { - "aed": -20.09664, - "ars": -3.40252, - "aud": -20.3743, - "bch": -10.35771, - "bdt": -20.21051, - "bhd": -20.07198, - "bmd": -20.09212, - "bnb": -18.90438, - "brl": -18.21564, - "btc": -10.20835, - "cad": -19.99931, - "chf": -19.86474, - "clp": -20.70274, - "cny": -19.67703, - "czk": -19.20224, - "dkk": -19.44946, - "eos": -6.27667, - "eth": -3.85819, - "eur": -19.3615, - "gbp": -19.99287, - "hkd": -20.04155, - "huf": -18.72941, - "idr": -19.92585, - "ils": -19.11955, - "inr": -19.53455, - "jpy": -19.93789, - "krw": -20.72061, - "kwd": -20.08213, - "lkr": -19.39749, - "ltc": 3.01755, - "mmk": -19.52559, - "mxn": -19.64657, - "myr": -20.39088, - "nok": -19.55283, - "nzd": -18.54, - "php": -19.79625, - "pkr": -19.69932, - "pln": -18.70635, - "rub": -18.9447, - "sar": -20.11299, - "sek": -19.43426, - "sgd": -19.93866, - "thb": -19.96922, - "try": -17.50796, - "twd": -20.26871, - "uah": -21.43567, - "usd": -20.09212, - "vef": -20.09212, - "vnd": -20.39781, - "xag": -22.82706, - "xau": -21.25807, - "xdr": -19.80447, - "xlm": -6.90503, - "xrp": -5.82792, - "zar": -18.53383 - }, - "price_change_percentage_1h_in_currency": { - "aed": -0.65082, - "ars": -0.65814, - "aud": -0.81241, - "bch": 1.34916, - "bdt": -0.64945, - "bhd": -0.62736, - "bmd": -0.65082, - "bnb": -0.66128, - "brl": -0.65109, - "btc": -0.64949, - "cad": -0.75327, - "chf": -0.6566, - "clp": -0.81121, - "cny": -0.61001, - "czk": -0.71198, - "dkk": -0.72865, - "eos": -0.27623, - "eth": -0.72711, - "eur": -0.73062, - "gbp": -0.79594, - "hkd": -0.65424, - "huf": -0.65304, - "idr": -0.69877, - "ils": -0.68895, - "inr": -0.85681, - "jpy": -0.70261, - "krw": -0.64504, - "kwd": -0.68347, - "lkr": -0.66193, - "ltc": 0.20669, - "mmk": -0.65082, - "mxn": -0.79588, - "myr": -0.58632, - "nok": -0.94166, - "nzd": -0.77361, - "php": -0.65651, - "pkr": -0.65082, - "pln": -0.82133, - "rub": -0.80523, - "sar": -0.62904, - "sek": -0.92466, - "sgd": -0.72845, - "thb": -0.71559, - "try": -0.93842, - "twd": -0.63818, - "uah": -0.65082, - "usd": -0.65082, - "vef": -0.65082, - "vnd": -0.72098, - "xag": -0.86625, - "xau": -0.66422, - "xdr": -0.65082, - "xlm": -0.6397, - "xrp": -0.05073, - "zar": -1.25864 - }, - "price_change_percentage_1y": -80.39683, - "price_change_percentage_1y_in_currency": { - "aed": -80.39794, - "ars": -61.05676, - "aud": -78.80429, - "bch": -66.67604, - "bdt": -80.26581, - "bhd": -80.39891, - "bmd": -80.39683, - "brl": -79.97401, - "btc": -87.93415, - "cad": -80.00734, - "chf": -80.63119, - "clp": -79.21186, - "cny": -79.80673, - "czk": -79.67769, - "dkk": -79.72199, - "eth": -71.77736, - "eur": -79.71573, - "gbp": -79.34655, - "hkd": -80.41399, - "huf": -79.49619, - "idr": -80.5156, - "ils": -81.08059, - "inr": -79.93604, - "jpy": -81.01454, - "krw": -78.90452, - "kwd": -80.34132, - "lkr": -78.15787, - "ltc": -85.4361, - "mmk": -80.27721, - "mxn": -79.65762, - "myr": -80.02222, - "nok": -79.18424, - "nzd": -79.67365, - "php": -80.76747, - "pkr": -74.51734, - "pln": -79.50758, - "rub": -80.62327, - "sar": -80.39647, - "sek": -79.31728, - "sgd": -80.18923, - "thb": -81.67711, - "try": -81.65737, - "twd": -79.98887, - "uah": -82.20648, - "usd": -80.39683, - "vef": -80.39558, - "vnd": -80.34916, - "xag": -83.03235, - "xau": -84.4215, - "xdr": -80.03907, - "zar": -79.43625 - }, - "price_change_percentage_200d": -50.1114, - "price_change_percentage_200d_in_currency": { - "aed": -50.11196, - "ars": -26.51722, - "aud": -46.67198, - "bch": -80.79243, - "bdt": -49.71149, - "bhd": -50.09578, - "bmd": -50.1114, - "bnb": -87.93302, - "brl": -44.75887, - "btc": -83.02991, - "cad": -49.34427, - "chf": -50.94689, - "clp": -45.88183, - "cny": -47.75857, - "czk": -48.37087, - "dkk": -48.59098, - "eos": -67.57399, - "eth": -71.6239, - "eur": -48.52075, - "gbp": -46.27358, - "hkd": -50.136, - "huf": -46.91923, - "idr": -49.00487, - "ils": -51.63145, - "inr": -50.15368, - "jpy": -51.46878, - "krw": -46.36111, - "kwd": -49.91845, - "lkr": -49.53248, - "ltc": -77.44522, - "mmk": -49.82943, - "mxn": -48.49618, - "myr": -49.12989, - "nok": -47.01887, - "nzd": -46.2816, - "php": -50.10377, - "pkr": -42.33563, - "pln": -47.7461, - "rub": -49.52654, - "sar": -50.10515, - "sek": -46.7992, - "sgd": -48.88484, - "thb": -50.95616, - "try": -45.32063, - "twd": -49.1634, - "uah": -54.76622, - "usd": -50.1114, - "vef": -50.1114, - "vnd": -50.2089, - "xag": -53.50147, - "xau": -56.13406, - "xdr": -49.07152, - "xlm": -39.53366, - "xrp": -42.37148, - "zar": -42.98063 - }, - "price_change_percentage_24h": -4.97817, - "price_change_percentage_24h_in_currency": { - "aed": -4.98614, - "ars": -5.17346, - "aud": -5.07575, - "bch": 0.99066, - "bdt": -5.0917, - "bhd": -4.95271, - "bmd": -4.97817, - "bnb": -1.58124, - "brl": -5.4789, - "btc": -0.05257, - "cad": -5.16583, - "chf": -5.07341, - "clp": -5.53921, - "cny": -4.98758, - "czk": -5.21666, - "dkk": -5.22182, - "eos": -2.69699, - "eth": -0.24506, - "eur": -5.21371, - "gbp": -5.44209, - "hkd": -4.99428, - "huf": -5.17487, - "idr": -5.1077, - "ils": -5.08246, - "inr": -5.35173, - "jpy": -4.90717, - "krw": -5.40119, - "kwd": -5.00939, - "lkr": -4.49721, - "ltc": -0.61091, - "mmk": -5.12476, - "mxn": -5.56641, - "myr": -5.1165, - "nok": -5.23449, - "nzd": -4.90745, - "php": -4.95001, - "pkr": -5.15646, - "pln": -5.48383, - "rub": -5.70968, - "sar": -4.96254, - "sek": -5.3709, - "sgd": -5.16905, - "thb": -5.13232, - "try": -5.13634, - "twd": -5.04655, - "uah": -5.22453, - "usd": -4.97817, - "vef": -4.97817, - "vnd": -5.43568, - "xag": -5.1341, - "xau": -4.8084, - "xdr": -4.91624, - "xlm": -2.16934, - "xrp": -1.13319, - "zar": -6.07423 - }, - "price_change_percentage_30d": -34.83569, - "price_change_percentage_30d_in_currency": { - "aed": -34.83544, - "ars": -15.98426, - "aud": -32.35353, - "bch": -30.11906, - "bdt": -34.93771, - "bhd": -34.7457, - "bmd": -34.83569, - "bnb": -27.08407, - "brl": -29.51868, - "btc": -32.08124, - "cad": -33.60701, - "chf": -35.09987, - "clp": -32.84418, - "cny": -33.12269, - "czk": -33.51308, - "dkk": -34.24389, - "eos": -21.08346, - "eth": -21.69788, - "eur": -34.16271, - "gbp": -32.89529, - "hkd": -34.53494, - "huf": -33.76133, - "idr": -33.48833, - "ils": -35.07099, - "inr": -32.4077, - "jpy": -35.60577, - "krw": -33.33925, - "kwd": -34.83162, - "lkr": -33.78522, - "ltc": -10.07379, - "mmk": -34.67478, - "mxn": -32.46726, - "myr": -33.79734, - "nok": -31.87864, - "nzd": -31.23716, - "php": -33.28065, - "pkr": -34.80085, - "pln": -32.59928, - "rub": -31.5309, - "sar": -34.83447, - "sek": -32.96828, - "sgd": -33.73135, - "thb": -35.04678, - "try": -34.23351, - "twd": -34.17354, - "uah": -36.37221, - "usd": -34.83569, - "vef": -34.83569, - "vnd": -35.19581, - "xag": -38.03606, - "xau": -38.0235, - "xdr": -34.25025, - "xlm": -12.74201, - "xrp": -18.8697, - "zar": -28.75132 - }, - "price_change_percentage_60d": -61.24989, - "price_change_percentage_60d_in_currency": { - "aed": -61.25202, - "ars": -50.32249, - "aud": -60.412, - "bch": -43.0945, - "bdt": -61.20391, - "bhd": -61.23344, - "bmd": -61.24989, - "bnb": -44.49169, - "brl": -58.90604, - "btc": -61.42639, - "cad": -61.04496, - "chf": -61.10703, - "clp": -59.79652, - "cny": -60.16104, - "czk": -60.03065, - "dkk": -60.38034, - "eos": -23.51938, - "eth": -39.23291, - "eur": -60.26215, - "gbp": -59.36192, - "hkd": -61.10542, - "huf": -59.91464, - "idr": -60.90777, - "ils": -62.26651, - "inr": -60.23354, - "jpy": -61.53855, - "krw": -59.78067, - "kwd": -61.16844, - "lkr": -60.71768, - "ltc": -25.65935, - "mmk": -61.20413, - "mxn": -60.04539, - "myr": -60.87356, - "nok": -59.13075, - "nzd": -60.12056, - "php": -60.49041, - "pkr": -60.53743, - "pln": -59.42569, - "rub": -59.28727, - "sar": -61.24451, - "sek": -59.99926, - "sgd": -60.43237, - "thb": -61.1805, - "try": -62.00162, - "twd": -60.74424, - "uah": -62.83383, - "usd": -61.24989, - "vef": -61.24989, - "vnd": -61.16958, - "xag": -65.19693, - "xau": -63.82007, - "xdr": -60.62729, - "xlm": -29.85582, - "xrp": -35.11899, - "zar": -58.83133 - }, - "price_change_percentage_7d": -12.91815, - "price_change_percentage_7d_in_currency": { - "aed": -12.92308, - "ars": -14.25115, - "aud": -12.76443, - "bch": 0.88592, - "bdt": -13.147, - "bhd": -12.88673, - "bmd": -12.91815, - "bnb": -5.78627, - "brl": -10.95714, - "btc": -6.92663, - "cad": -12.35787, - "chf": -12.68629, - "clp": -12.25865, - "cny": -12.67706, - "czk": -12.47009, - "dkk": -12.42719, - "eos": -0.91188, - "eth": -3.2526, - "eur": -12.3442, - "gbp": -13.56715, - "hkd": -12.94473, - "huf": -11.29007, - "idr": -13.63998, - "ils": -11.80257, - "inr": -12.53093, - "jpy": -12.98836, - "krw": -13.1693, - "kwd": -12.90068, - "lkr": -11.99684, - "ltc": 1.8824, - "mmk": -12.2934, - "mxn": -11.39186, - "myr": -13.13461, - "nok": -11.99025, - "nzd": -12.14546, - "php": -12.50757, - "pkr": -13.04362, - "pln": -11.96443, - "rub": -11.16958, - "sar": -12.91768, - "sek": -11.88369, - "sgd": -12.90808, - "thb": -12.98884, - "try": -10.77039, - "twd": -12.26523, - "uah": -12.97908, - "usd": -12.91815, - "vef": -12.91815, - "vnd": -13.40154, - "xag": -13.34686, - "xau": -12.77828, - "xdr": -12.6042, - "xlm": -3.71389, - "xrp": -2.40223, - "zar": -12.40714 - }, - "roi": null, - "total_supply": 252193195.0, - "total_volume": { - "aed": 440559, - "ars": 6563849, - "aud": 176776, - "bch": 399.879, - "bdt": 10120936, - "bhd": 45230, - "bmd": 119946, - "bnb": 4413, - "brl": 486163, - "btc": 11.770053, - "cad": 159540, - "chf": 117394, - "clp": 85082341, - "cny": 847180, - "czk": 2786558, - "dkk": 805460, - "eos": 33477, - "eth": 637.075, - "eur": 108026, - "gbp": 98721, - "hkd": 940723, - "huf": 35365063, - "idr": 1709124708, - "ils": 423015, - "inr": 8564729, - "jpy": 12776147, - "krw": 144309614, - "kwd": 36488, - "lkr": 21435577, - "ltc": 1658, - "mmk": 182491285, - "mxn": 2365238, - "myr": 501028, - "nok": 1074994, - "nzd": 187261, - "php": 6275343, - "pkr": 19194884, - "pln": 470169, - "rub": 7943115, - "sar": 449908, - "sek": 1157915, - "sgd": 165958, - "thb": 3690743, - "try": 684709, - "twd": 3761793, - "uah": 3015441, - "usd": 119946, - "vef": 29805136391, - "vnd": 2777910981, - "xag": 7029.61, - "xau": 80.03, - "xdr": 87482, - "xlm": 1761175, - "xrp": 451666, - "zar": 1826528 - } - }, - "name": "POA Network", - "public_interest_score": 27.778, - "public_interest_stats": { - "alexa_rank": 702581, - "bing_matches": 107000 - }, - "sentiment_votes_down_percentage": null, - "sentiment_votes_up_percentage": null, - "status_updates": [], - "symbol": "poa", - "tickers": [ - { - "base": "POA", - "bid_ask_spread_percentage": 1.503759, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.32e-06, - "eth": 7.139e-05, - "usd": 0.01344331 - }, - "converted_volume": { - "btc": 10.856044, - "eth": 587.121, - "usd": 110562 - }, - "is_anomaly": false, - "is_stale": false, - "last": 1.32e-06, - "last_fetch_at": "2019-08-21T08:34:21+00:00", - "last_traded_at": "2019-08-21T08:34:21+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "binance", - "name": "Binance" - }, - "target": "BTC", - "timestamp": "2019-08-21T08:34:21+00:00", - "trade_url": "https://www.binance.com/trade.html?symbol=POA_BTC", - "trust_score": "green", - "volume": 8224275.5227272725 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 1.729386, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.34e-06, - "eth": 7.226e-05, - "usd": 0.01360764 - }, - "converted_volume": { - "btc": 0.35219856, - "eth": 19.04774, - "usd": 3586.91 - }, - "is_anomaly": false, - "is_stale": false, - "last": 7.238e-05, - "last_fetch_at": "2019-08-21T08:34:26+00:00", - "last_traded_at": "2019-08-21T08:34:26+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "binance", - "name": "Binance" - }, - "target": "ETH", - "timestamp": "2019-08-21T08:34:26+00:00", - "trade_url": "https://www.binance.com/trade.html?symbol=POA_ETH", - "trust_score": "green", - "volume": 263595.07819839736 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 1.39165, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.34e-06, - "eth": 7.259e-05, - "usd": 0.01363846 - }, - "converted_volume": { - "btc": 0.24038472, - "eth": 13.008637, - "usd": 2444.17 - }, - "is_anomaly": false, - "is_stale": false, - "last": 0.000502, - "last_fetch_at": "2019-08-21T08:34:26+00:00", - "last_traded_at": "2019-08-21T08:13:08+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "binance", - "name": "Binance" - }, - "target": "BNB", - "timestamp": "2019-08-21T08:13:08+00:00", - "trade_url": "https://www.binance.com/trade.html?symbol=POA_BNB", - "trust_score": "green", - "volume": 179211.83864541832 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 2.985075, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.3e-06, - "eth": 7.036e-05, - "usd": 0.01324803 - }, - "converted_volume": { - "btc": 0.1595777, - "eth": 8.637423, - "usd": 1626.22 - }, - "is_anomaly": false, - "is_stale": false, - "last": 1.3e-06, - "last_fetch_at": "2019-08-21T08:36:07+00:00", - "last_traded_at": "2019-08-21T08:36:07+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "bibox", - "name": "Bibox" - }, - "target": "BTC", - "timestamp": "2019-08-21T08:36:07+00:00", - "trade_url": "https://www.bibox.com/exchange?coinPair=POA_BTC", - "trust_score": "yellow", - "volume": 122752.0737 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 3.247202, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.31e-06, - "eth": 7.077e-05, - "usd": 0.01332498 - }, - "converted_volume": { - "btc": 0.1127777, - "eth": 6.104291, - "usd": 1149.29 - }, - "is_anomaly": false, - "is_stale": false, - "last": 7.087e-05, - "last_fetch_at": "2019-08-21T08:35:50+00:00", - "last_traded_at": "2019-08-21T08:35:50+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "bibox", - "name": "Bibox" - }, - "target": "ETH", - "timestamp": "2019-08-21T08:35:50+00:00", - "trade_url": "https://www.bibox.com/exchange?coinPair=POA_ETH", - "trust_score": "yellow", - "volume": 86251.0552 - }, - { - "base": "POA20", - "bid_ask_spread_percentage": 13.032676, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.16e-06, - "eth": 6.257e-05, - "usd": 0.01177973 - }, - "converted_volume": { - "btc": 0.00087672, - "eth": 0.04748752, - "usd": 8.94 - }, - "is_anomaly": false, - "is_stale": false, - "last": 1.1551e-06, - "last_fetch_at": "2019-08-21T08:37:38+00:00", - "last_traded_at": "2019-08-21T07:08:25+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "BTC", - "timestamp": "2019-08-21T07:08:25+00:00", - "trade_url": "https://hitbtc.com/POA20-to-BTC", - "trust_score": "red", - "volume": 759.0 - }, - { - "base": "POA20", - "bid_ask_spread_percentage": 33.072445, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.57e-06, - "eth": 8.48e-05, - "usd": 0.01595278 - }, - "converted_volume": { - "btc": 0.00021915, - "eth": 0.0118714, - "usd": 2.23 - }, - "is_anomaly": false, - "is_stale": false, - "last": 8.4891e-05, - "last_fetch_at": "2019-08-21T08:37:21+00:00", - "last_traded_at": "2019-08-21T07:37:25+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "ETH", - "timestamp": "2019-08-21T07:37:25+00:00", - "trade_url": "https://hitbtc.com/POA20-to-ETH", - "trust_score": "red", - "volume": 140.0 - }, - { - "base": "POA20", - "bid_ask_spread_percentage": 53.1004, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.15e-06, - "eth": 6.232e-05, - "usd": 0.0117249 - }, - "converted_volume": { - "btc": 7.478e-05, - "eth": 0.00405098, - "usd": 0.762119 - }, - "is_anomaly": false, - "is_stale": false, - "last": 0.0117249, - "last_fetch_at": "2019-08-21T08:37:46+00:00", - "last_traded_at": "2019-08-21T07:37:33+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "USD", - "timestamp": "2019-08-21T07:37:33+00:00", - "trade_url": "https://hitbtc.com/POA20-to-USD", - "trust_score": null, - "volume": 65.0 - }, - { - "base": "POA20", - "bid_ask_spread_percentage": null, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.32e-06, - "eth": 7.134e-05, - "usd": 0.01339322 - }, - "converted_volume": { - "btc": 0.03831341, - "eth": 2.073192, - "usd": 389.22 - }, - "is_anomaly": false, - "is_stale": false, - "last": 0.034733328618915156, - "last_fetch_at": "2019-08-21T08:21:59+00:00", - "last_traded_at": "2019-08-21T08:21:59+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "bancor", - "name": "Bancor Network" - }, - "target": "BNT", - "timestamp": "2019-08-21T08:21:59+00:00", - "trade_url": null, - "trust_score": null, - "volume": 29061.03634563883 - }, - { - "base": "POA20", - "bid_ask_spread_percentage": null, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.31e-06, - "eth": 7.074e-05, - "usd": 0.0132809 - }, - "converted_volume": { - "btc": 0.009587, - "eth": 0.51876605, - "usd": 97.39 - }, - "is_anomaly": false, - "is_stale": false, - "last": 7.0704231362995e-05, - "last_fetch_at": "2019-08-21T08:26:12+00:00", - "last_traded_at": "2019-08-21T08:21:13+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "uniswap", - "name": "Uniswap" - }, - "target": "ETH", - "timestamp": "2019-08-21T08:21:13+00:00", - "trade_url": null, - "trust_score": null, - "volume": 7333.316809051611 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 40.083051, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.33e-06, - "eth": 7.005e-05, - "usd": 0.01377146 - }, - "converted_volume": { - "btc": 0.0022752, - "eth": 0.12002061, - "usd": 23.59 - }, - "is_anomaly": false, - "is_stale": true, - "last": 7.004e-05, - "last_fetch_at": "2019-08-21T08:32:14+00:00", - "last_traded_at": "2019-08-18T18:43:13+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "ethfinex", - "name": "Ethfinex" - }, - "target": "ETH", - "timestamp": "2019-08-18T18:43:13+00:00", - "trade_url": "https://ethfinex.com", - "trust_score": "red", - "volume": 1713.3067 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 44.440399, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.33e-06, - "eth": 7.311e-05, - "usd": 0.013499 - }, - "converted_volume": { - "btc": 0.00442255, - "eth": 0.24305992, - "usd": 44.88 - }, - "is_anomaly": false, - "is_stale": true, - "last": 0.013499, - "last_fetch_at": "2019-08-21T08:32:13+00:00", - "last_traded_at": "2019-08-14T17:19:27+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "ethfinex", - "name": "Ethfinex" - }, - "target": "USD", - "timestamp": "2019-08-14T17:19:27+00:00", - "trade_url": "https://ethfinex.com", - "trust_score": "red", - "volume": 3324.44883078 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 14.012015, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.36e-06, - "eth": 7.49e-05, - "usd": 0.01446844 - }, - "converted_volume": { - "btc": 5.45e-06, - "eth": 0.00029958, - "usd": 0.057874 - }, - "is_anomaly": false, - "is_stale": true, - "last": 7.4743e-05, - "last_fetch_at": "2019-08-21T08:37:09+00:00", - "last_traded_at": "2019-08-21T02:52:09+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "ETH", - "timestamp": "2019-08-21T02:52:09+00:00", - "trade_url": "https://hitbtc.com/POA-to-ETH", - "trust_score": null, - "volume": 4.0 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 64.645979, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.36e-06, - "eth": 7.473e-05, - "usd": 0.01443604 - }, - "converted_volume": { - "btc": 2.72e-06, - "eth": 0.00014946, - "usd": 0.02887207 - }, - "is_anomaly": false, - "is_stale": true, - "last": 0.0143016, - "last_fetch_at": "2019-08-21T08:37:44+00:00", - "last_traded_at": "2019-08-21T02:52:25+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "DAI", - "timestamp": "2019-08-21T02:52:25+00:00", - "trade_url": "https://hitbtc.com/POA-to-DAI", - "trust_score": null, - "volume": 2.0 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 64.353312, - "coin_id": "poa-network", - "converted_last": { - "btc": 2.23e-06, - "eth": 0.00012505, - "usd": 0.02310309 - }, - "converted_volume": { - "btc": 0.00275182, - "eth": 0.15430951, - "usd": 28.51 - }, - "is_anomaly": true, - "is_stale": true, - "last": 2.23e-06, - "last_fetch_at": "2019-08-21T08:32:12+00:00", - "last_traded_at": "2019-08-17T12:50:06+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "ethfinex", - "name": "Ethfinex" - }, - "target": "BTC", - "timestamp": "2019-08-17T12:50:06+00:00", - "trade_url": "https://ethfinex.com", - "trust_score": "red", - "volume": 1234.0 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 21.080977, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.23e-06, - "eth": 6.72e-05, - "usd": 0.01322824 - }, - "converted_volume": { - "btc": 0.0, - "eth": 0.0, - "usd": 0.0 - }, - "is_anomaly": true, - "is_stale": true, - "last": 1.2331e-06, - "last_fetch_at": "2019-08-21T08:37:17+00:00", - "last_traded_at": "2019-08-20T18:50:31+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "BTC", - "timestamp": "2019-08-20T18:50:31+00:00", - "trade_url": "https://hitbtc.com/POA-to-BTC", - "trust_score": null, - "volume": 0.0 - }, - { - "base": "POA", - "bid_ask_spread_percentage": 36.441715, - "coin_id": "poa-network", - "converted_last": { - "btc": 1.22e-06, - "eth": 6.66e-05, - "usd": 0.0131106 - }, - "converted_volume": { - "btc": 0.0, - "eth": 0.0, - "usd": 0.0 - }, - "is_anomaly": true, - "is_stale": true, - "last": 0.0131106, - "last_fetch_at": "2019-08-21T08:37:14+00:00", - "last_traded_at": "2019-08-20T18:50:15+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "USD", - "timestamp": "2019-08-20T18:50:15+00:00", - "trade_url": "https://hitbtc.com/POA-to-USD", - "trust_score": null, - "volume": 0.0 - }, - { - "base": "POA20", - "bid_ask_spread_percentage": 70.656026, - "coin_id": "poa-network", - "converted_last": { - "btc": 7.522e-07, - "eth": 4.006e-05, - "usd": 0.0080416 - }, - "converted_volume": { - "btc": 0.0, - "eth": 0.0, - "usd": 0.0 - }, - "is_anomaly": true, - "is_stale": true, - "last": 0.008, - "last_fetch_at": "2019-08-21T08:37:29+00:00", - "last_traded_at": "2019-08-19T09:09:06+00:00", - "market": { - "has_trading_incentive": false, - "identifier": "hitbtc", - "name": "HitBTC" - }, - "target": "DAI", - "timestamp": "2019-08-19T09:09:06+00:00", - "trade_url": "https://hitbtc.com/POA20-to-DAI", - "trust_score": null, - "volume": 0.0 - } - ] -} diff --git a/apps/explorer/test/support/fixture/market/dia/native_coin_history.json b/apps/explorer/test/support/fixture/market/dia/native_coin_history.json new file mode 100644 index 000000000000..1dba2391a797 --- /dev/null +++ b/apps/explorer/test/support/fixture/market/dia/native_coin_history.json @@ -0,0 +1,29490 @@ +{ + "DataPoints": [ + { + "statement_id": 0, + "Series": [ + { + "name": "filters", + "columns": [ + "time", + "address", + "blockchain", + "exchange", + "filter", + "symbol", + "value" + ], + "values": [ + [ + "2025-11-26T23:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.151520522141 + ], + [ + "2025-11-26T23:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.9952309956097 + ], + [ + "2025-11-26T23:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.7906787878583 + ], + [ + "2025-11-26T23:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.016492941718 + ], + [ + "2025-11-26T23:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.348125747006 + ], + [ + "2025-11-26T23:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.155385791533 + ], + [ + "2025-11-26T23:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.405882509573 + ], + [ + "2025-11-26T23:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.354572989277 + ], + [ + "2025-11-26T23:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.819066265902 + ], + [ + "2025-11-26T23:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.676800539638 + ], + [ + "2025-11-26T23:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.317946538746 + ], + [ + "2025-11-26T23:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.465210342624 + ], + [ + "2025-11-26T23:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.7064363256623 + ], + [ + "2025-11-26T23:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.4536572902407 + ], + [ + "2025-11-26T23:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.4573464586233 + ], + [ + "2025-11-26T23:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.7946899822246 + ], + [ + "2025-11-26T23:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.813687093641 + ], + [ + "2025-11-26T23:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.629349725893 + ], + [ + "2025-11-26T23:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.738119120008 + ], + [ + "2025-11-26T23:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.32716019548 + ], + [ + "2025-11-26T23:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.803311656306 + ], + [ + "2025-11-26T23:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.7996816298323 + ], + [ + "2025-11-26T23:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.2930118929366 + ], + [ + "2025-11-26T23:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.9135444207636 + ], + [ + "2025-11-26T23:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3029.9010124855263 + ], + [ + "2025-11-26T23:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3029.161406928666 + ], + [ + "2025-11-26T23:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.077602092493 + ], + [ + "2025-11-26T23:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.397443868711 + ], + [ + "2025-11-26T23:03:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.79667545943 + ], + [ + "2025-11-26T23:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.2401761320916 + ], + [ + "2025-11-26T22:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.0524342154113 + ], + [ + "2025-11-26T22:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.9170363250464 + ], + [ + "2025-11-26T22:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.5615556587113 + ], + [ + "2025-11-26T22:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.287008682643 + ], + [ + "2025-11-26T22:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.8148994578582 + ], + [ + "2025-11-26T22:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.260801742472 + ], + [ + "2025-11-26T22:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.2842749893493 + ], + [ + "2025-11-26T22:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.684110569924 + ], + [ + "2025-11-26T22:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.6690954254645 + ], + [ + "2025-11-26T22:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.1150399483636 + ], + [ + "2025-11-26T22:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.0667491255904 + ], + [ + "2025-11-26T22:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.012855349582 + ], + [ + "2025-11-26T22:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.210697286231 + ], + [ + "2025-11-26T22:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.128943239289 + ], + [ + "2025-11-26T22:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.7342678537702 + ], + [ + "2025-11-26T22:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.601243443932 + ], + [ + "2025-11-26T22:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.052469621102 + ], + [ + "2025-11-26T22:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.45200910658 + ], + [ + "2025-11-26T22:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.7005241522597 + ], + [ + "2025-11-26T22:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.7265772544047 + ], + [ + "2025-11-26T22:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.392016430371 + ], + [ + "2025-11-26T22:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.1805430193212 + ], + [ + "2025-11-26T22:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.3997598695014 + ], + [ + "2025-11-26T22:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.9226602725307 + ], + [ + "2025-11-26T22:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.264822958744 + ], + [ + "2025-11-26T22:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.7746139284013 + ], + [ + "2025-11-26T22:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.894148642655 + ], + [ + "2025-11-26T22:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.576694655565 + ], + [ + "2025-11-26T22:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.3015064767524 + ], + [ + "2025-11-26T22:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.770671881976 + ], + [ + "2025-11-26T21:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.8434786213347 + ], + [ + "2025-11-26T21:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.1922815859207 + ], + [ + "2025-11-26T21:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.654629118373 + ], + [ + "2025-11-26T21:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.0755305745884 + ], + [ + "2025-11-26T21:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.3879050909513 + ], + [ + "2025-11-26T21:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.9124781486066 + ], + [ + "2025-11-26T21:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.1643263488086 + ], + [ + "2025-11-26T21:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.185672247033 + ], + [ + "2025-11-26T21:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.255434855479 + ], + [ + "2025-11-26T21:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.5275185481796 + ], + [ + "2025-11-26T21:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.0723822072646 + ], + [ + "2025-11-26T21:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.415828591947 + ], + [ + "2025-11-26T21:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3029.400605228132 + ], + [ + "2025-11-26T21:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3032.247633930668 + ], + [ + "2025-11-26T21:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.339188175033 + ], + [ + "2025-11-26T21:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.1376741926374 + ], + [ + "2025-11-26T21:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.9126560295676 + ], + [ + "2025-11-26T21:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.033134827457 + ], + [ + "2025-11-26T21:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.559101779141 + ], + [ + "2025-11-26T21:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3017.3953971036576 + ], + [ + "2025-11-26T21:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.7745116883684 + ], + [ + "2025-11-26T21:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.1705015189277 + ], + [ + "2025-11-26T21:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.118301963756 + ], + [ + "2025-11-26T21:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.7732787399927 + ], + [ + "2025-11-26T21:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.287382141945 + ], + [ + "2025-11-26T21:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.671110711504 + ], + [ + "2025-11-26T21:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.9497378818064 + ], + [ + "2025-11-26T21:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.0134677292485 + ], + [ + "2025-11-26T21:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.2256571852154 + ], + [ + "2025-11-26T21:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.4116632955706 + ], + [ + "2025-11-26T20:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.5223550569067 + ], + [ + "2025-11-26T20:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.3761884761716 + ], + [ + "2025-11-26T20:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.034673073346 + ], + [ + "2025-11-26T20:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.537003079573 + ], + [ + "2025-11-26T20:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3016.896092503228 + ], + [ + "2025-11-26T20:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3011.068848239424 + ], + [ + "2025-11-26T20:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3012.096681943245 + ], + [ + "2025-11-26T20:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3011.167286510448 + ], + [ + "2025-11-26T20:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.021829899216 + ], + [ + "2025-11-26T20:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3017.871870622501 + ], + [ + "2025-11-26T20:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.133754430514 + ], + [ + "2025-11-26T20:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.6283892970437 + ], + [ + "2025-11-26T20:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3017.13797554675 + ], + [ + "2025-11-26T20:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.3641586468293 + ], + [ + "2025-11-26T20:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.890682654623 + ], + [ + "2025-11-26T20:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.775070918795 + ], + [ + "2025-11-26T20:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.070512465065 + ], + [ + "2025-11-26T20:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.5116064856097 + ], + [ + "2025-11-26T20:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.0205580764505 + ], + [ + "2025-11-26T20:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3016.532189306943 + ], + [ + "2025-11-26T20:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.7433828326098 + ], + [ + "2025-11-26T20:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.0717579321126 + ], + [ + "2025-11-26T20:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.4067342147405 + ], + [ + "2025-11-26T20:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3022.9121810736424 + ], + [ + "2025-11-26T20:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.388270553996 + ], + [ + "2025-11-26T20:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3013.1177959667116 + ], + [ + "2025-11-26T20:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3013.4346831895605 + ], + [ + "2025-11-26T20:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.8658203730974 + ], + [ + "2025-11-26T20:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.475509064918 + ], + [ + "2025-11-26T20:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.300445979269 + ], + [ + "2025-11-26T19:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3035.2078397385976 + ], + [ + "2025-11-26T19:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.8589888899864 + ], + [ + "2025-11-26T19:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.319939956977 + ], + [ + "2025-11-26T19:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3035.27045458342 + ], + [ + "2025-11-26T19:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3035.297152806807 + ], + [ + "2025-11-26T19:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.466162766857 + ], + [ + "2025-11-26T19:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3033.9634036132547 + ], + [ + "2025-11-26T19:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3032.586488841507 + ], + [ + "2025-11-26T19:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3033.771793256169 + ], + [ + "2025-11-26T19:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.2746915843404 + ], + [ + "2025-11-26T19:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.176474279526 + ], + [ + "2025-11-26T19:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.8179613752545 + ], + [ + "2025-11-26T19:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.7458395268773 + ], + [ + "2025-11-26T19:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.7624773888506 + ], + [ + "2025-11-26T19:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3032.156844702454 + ], + [ + "2025-11-26T19:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.99129346763 + ], + [ + "2025-11-26T19:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.432744061586 + ], + [ + "2025-11-26T19:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.618729767293 + ], + [ + "2025-11-26T19:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.308867781954 + ], + [ + "2025-11-26T19:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.89430595998 + ], + [ + "2025-11-26T19:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.0947723010295 + ], + [ + "2025-11-26T19:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.7149833267435 + ], + [ + "2025-11-26T19:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.9729681606345 + ], + [ + "2025-11-26T19:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3019.8541867855147 + ], + [ + "2025-11-26T19:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.1975551672153 + ], + [ + "2025-11-26T19:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.1813037360967 + ], + [ + "2025-11-26T19:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.113166620033 + ], + [ + "2025-11-26T19:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.469033539616 + ], + [ + "2025-11-26T19:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.7787558256537 + ], + [ + "2025-11-26T19:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.130234929596 + ], + [ + "2025-11-26T18:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3032.8897067242237 + ], + [ + "2025-11-26T18:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.548632506069 + ], + [ + "2025-11-26T18:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.7953021683716 + ], + [ + "2025-11-26T18:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3026.6357149286873 + ], + [ + "2025-11-26T18:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3027.8934212174263 + ], + [ + "2025-11-26T18:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.5476659673045 + ], + [ + "2025-11-26T18:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3020.97834202484 + ], + [ + "2025-11-26T18:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3017.148257754839 + ], + [ + "2025-11-26T18:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.3758637454152 + ], + [ + "2025-11-26T18:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.5340710499595 + ], + [ + "2025-11-26T18:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3021.416968113133 + ], + [ + "2025-11-26T18:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3029.9369183863214 + ], + [ + "2025-11-26T18:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3029.915298796048 + ], + [ + "2025-11-26T18:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.4463738093873 + ], + [ + "2025-11-26T18:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3037.056458887182 + ], + [ + "2025-11-26T18:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3036.7047543300077 + ], + [ + "2025-11-26T18:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3038.9542468319737 + ], + [ + "2025-11-26T18:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.601555504045 + ], + [ + "2025-11-26T18:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3036.2512604162016 + ], + [ + "2025-11-26T18:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.575831783197 + ], + [ + "2025-11-26T18:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3038.858851452559 + ], + [ + "2025-11-26T18:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3042.8549111004386 + ], + [ + "2025-11-26T18:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3040.1981534911088 + ], + [ + "2025-11-26T18:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.778022634944 + ], + [ + "2025-11-26T18:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.9615724234436 + ], + [ + "2025-11-26T18:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3034.9219468612077 + ], + [ + "2025-11-26T18:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3033.2923933201073 + ], + [ + "2025-11-26T18:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3033.098636209787 + ], + [ + "2025-11-26T18:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3031.7210079629162 + ], + [ + "2025-11-26T18:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3030.436417973437 + ], + [ + "2025-11-26T17:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3029.583713897495 + ], + [ + "2025-11-26T17:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3025.081792785059 + ], + [ + "2025-11-26T17:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3018.604415897914 + ], + [ + "2025-11-26T17:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3015.4195432331385 + ], + [ + "2025-11-26T17:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3023.680525946276 + ], + [ + "2025-11-26T17:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3028.695471338884 + ], + [ + "2025-11-26T17:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3024.9210623992867 + ], + [ + "2025-11-26T17:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3017.0070804179295 + ], + [ + "2025-11-26T17:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3011.2665244947057 + ], + [ + "2025-11-26T17:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3006.416644568037 + ], + [ + "2025-11-26T17:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3003.8410317174767 + ], + [ + "2025-11-26T17:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3004.968057980944 + ], + [ + "2025-11-26T17:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 3001.3745097741526 + ], + [ + "2025-11-26T17:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2984.28629607061 + ], + [ + "2025-11-26T17:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.849823604123 + ], + [ + "2025-11-26T17:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.532012946143 + ], + [ + "2025-11-26T17:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.4515140834587 + ], + [ + "2025-11-26T17:26:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.682091995241 + ], + [ + "2025-11-26T17:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.904302541376 + ], + [ + "2025-11-26T17:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.6694179811057 + ], + [ + "2025-11-26T17:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.26623617124 + ], + [ + "2025-11-26T17:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.4925434129564 + ], + [ + "2025-11-26T17:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.681391520425 + ], + [ + "2025-11-26T17:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.1873979115157 + ], + [ + "2025-11-26T17:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.8125357751082 + ], + [ + "2025-11-26T17:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.4131543138524 + ], + [ + "2025-11-26T17:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.6159697915386 + ], + [ + "2025-11-26T17:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.1492563809475 + ], + [ + "2025-11-26T17:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.085897777473 + ], + [ + "2025-11-26T17:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.754822642638 + ], + [ + "2025-11-26T16:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.496468200753 + ], + [ + "2025-11-26T16:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.2258330271575 + ], + [ + "2025-11-26T16:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.364311875727 + ], + [ + "2025-11-26T16:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.3947300353807 + ], + [ + "2025-11-26T16:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.1459397403164 + ], + [ + "2025-11-26T16:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.331213221082 + ], + [ + "2025-11-26T16:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.962740252946 + ], + [ + "2025-11-26T16:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.258636449231 + ], + [ + "2025-11-26T16:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.327651506909 + ], + [ + "2025-11-26T16:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.787646220086 + ], + [ + "2025-11-26T16:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.8428510752797 + ], + [ + "2025-11-26T16:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.3963885392086 + ], + [ + "2025-11-26T16:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.3636279937064 + ], + [ + "2025-11-26T16:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.364268747501 + ], + [ + "2025-11-26T16:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.7023778783755 + ], + [ + "2025-11-26T16:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.7986459292288 + ], + [ + "2025-11-26T16:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.5601893309704 + ], + [ + "2025-11-26T16:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.3534702153224 + ], + [ + "2025-11-26T16:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.8060888554273 + ], + [ + "2025-11-26T16:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.4600149270177 + ], + [ + "2025-11-26T16:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.214435687855 + ], + [ + "2025-11-26T16:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.055519145734 + ], + [ + "2025-11-26T16:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.607541613766 + ], + [ + "2025-11-26T16:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.698941381484 + ], + [ + "2025-11-26T16:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.681358479241 + ], + [ + "2025-11-26T16:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.054499753902 + ], + [ + "2025-11-26T16:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.5629246358453 + ], + [ + "2025-11-26T16:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.8500270187155 + ], + [ + "2025-11-26T16:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.4468661959922 + ], + [ + "2025-11-26T16:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.8386823427986 + ], + [ + "2025-11-26T15:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.8708064065904 + ], + [ + "2025-11-26T15:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.92752455132 + ], + [ + "2025-11-26T15:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.19176345128 + ], + [ + "2025-11-26T15:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.838447799192 + ], + [ + "2025-11-26T15:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.5625706893256 + ], + [ + "2025-11-26T15:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.217045655755 + ], + [ + "2025-11-26T15:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.5322973266134 + ], + [ + "2025-11-26T15:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.661452139863 + ], + [ + "2025-11-26T15:44:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.2169799839644 + ], + [ + "2025-11-26T15:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.1195376194714 + ], + [ + "2025-11-26T15:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.118071120733 + ], + [ + "2025-11-26T15:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.462881602796 + ], + [ + "2025-11-26T15:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.5074227088667 + ], + [ + "2025-11-26T15:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.422399524901 + ], + [ + "2025-11-26T15:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.435922340358 + ], + [ + "2025-11-26T15:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.8889300276 + ], + [ + "2025-11-26T15:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.80216875357 + ], + [ + "2025-11-26T15:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.675669262228 + ], + [ + "2025-11-26T15:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.539016046155 + ], + [ + "2025-11-26T15:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.8779482456384 + ], + [ + "2025-11-26T15:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.2202188412944 + ], + [ + "2025-11-26T15:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.055282453235 + ], + [ + "2025-11-26T15:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.594559550768 + ], + [ + "2025-11-26T15:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.9824546670857 + ], + [ + "2025-11-26T15:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.2116134440116 + ], + [ + "2025-11-26T15:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.5014824179802 + ], + [ + "2025-11-26T15:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.203013089112 + ], + [ + "2025-11-26T15:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.5988355384693 + ], + [ + "2025-11-26T15:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.1574554646345 + ], + [ + "2025-11-26T15:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.6308512124283 + ], + [ + "2025-11-26T14:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.2646687793635 + ], + [ + "2025-11-26T14:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.9646652157967 + ], + [ + "2025-11-26T14:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.9882934138336 + ], + [ + "2025-11-26T14:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.8050018709578 + ], + [ + "2025-11-26T14:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.6764723291462 + ], + [ + "2025-11-26T14:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.6026259898686 + ], + [ + "2025-11-26T14:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.6434559633394 + ], + [ + "2025-11-26T14:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.679927571382 + ], + [ + "2025-11-26T14:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.017549622513 + ], + [ + "2025-11-26T14:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.68950659883 + ], + [ + "2025-11-26T14:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.5506567681873 + ], + [ + "2025-11-26T14:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.0327696119166 + ], + [ + "2025-11-26T14:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.463684523494 + ], + [ + "2025-11-26T14:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.1720085277816 + ], + [ + "2025-11-26T14:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.0705358995 + ], + [ + "2025-11-26T14:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.582781360747 + ], + [ + "2025-11-26T14:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.365662808429 + ], + [ + "2025-11-26T14:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.6616009889367 + ], + [ + "2025-11-26T14:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.504298030269 + ], + [ + "2025-11-26T14:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.2747626745495 + ], + [ + "2025-11-26T14:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.2008497693146 + ], + [ + "2025-11-26T14:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.189386208329 + ], + [ + "2025-11-26T14:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.3667295627984 + ], + [ + "2025-11-26T14:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.6532911067807 + ], + [ + "2025-11-26T14:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.560023244092 + ], + [ + "2025-11-26T14:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.6697045554965 + ], + [ + "2025-11-26T14:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.1962678075383 + ], + [ + "2025-11-26T14:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.4110204066947 + ], + [ + "2025-11-26T14:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.660784351018 + ], + [ + "2025-11-26T14:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.025372019662 + ], + [ + "2025-11-26T13:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.749130043076 + ], + [ + "2025-11-26T13:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.53139005059 + ], + [ + "2025-11-26T13:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.321398116442 + ], + [ + "2025-11-26T13:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.010708745985 + ], + [ + "2025-11-26T13:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.631234852231 + ], + [ + "2025-11-26T13:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.5396178164438 + ], + [ + "2025-11-26T13:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.908322841552 + ], + [ + "2025-11-26T13:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.6499531237096 + ], + [ + "2025-11-26T13:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.3089405657056 + ], + [ + "2025-11-26T13:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.0615722956504 + ], + [ + "2025-11-26T13:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.391691250555 + ], + [ + "2025-11-26T13:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.180713876524 + ], + [ + "2025-11-26T13:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.183150440062 + ], + [ + "2025-11-26T13:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.593049557681 + ], + [ + "2025-11-26T13:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.718521398907 + ], + [ + "2025-11-26T13:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.199497364437 + ], + [ + "2025-11-26T13:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.4319867440477 + ], + [ + "2025-11-26T13:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.522525245532 + ], + [ + "2025-11-26T13:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.230677281002 + ], + [ + "2025-11-26T13:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.6806335274227 + ], + [ + "2025-11-26T13:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.250730262095 + ], + [ + "2025-11-26T13:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.6026577593884 + ], + [ + "2025-11-26T13:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.942136425657 + ], + [ + "2025-11-26T13:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.8690005278468 + ], + [ + "2025-11-26T13:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.112735005857 + ], + [ + "2025-11-26T13:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.273137705173 + ], + [ + "2025-11-26T13:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.691743062504 + ], + [ + "2025-11-26T13:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.8582820274787 + ], + [ + "2025-11-26T13:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.221277493662 + ], + [ + "2025-11-26T13:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.009137313316 + ], + [ + "2025-11-26T12:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.734506484024 + ], + [ + "2025-11-26T12:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.8403702262367 + ], + [ + "2025-11-26T12:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.3142516928415 + ], + [ + "2025-11-26T12:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.502419841903 + ], + [ + "2025-11-26T12:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.8024884973665 + ], + [ + "2025-11-26T12:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.019194327666 + ], + [ + "2025-11-26T12:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.4868039422795 + ], + [ + "2025-11-26T12:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.7416929607393 + ], + [ + "2025-11-26T12:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.1861709850614 + ], + [ + "2025-11-26T12:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.7834531439935 + ], + [ + "2025-11-26T12:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.960836166363 + ], + [ + "2025-11-26T12:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.0491424595903 + ], + [ + "2025-11-26T12:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.5386959316543 + ], + [ + "2025-11-26T12:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.0958487004355 + ], + [ + "2025-11-26T12:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.9690510031583 + ], + [ + "2025-11-26T12:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.892072113158 + ], + [ + "2025-11-26T12:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.140615152582 + ], + [ + "2025-11-26T12:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.611253563305 + ], + [ + "2025-11-26T12:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.313358432984 + ], + [ + "2025-11-26T12:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.802403424873 + ], + [ + "2025-11-26T12:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.203371793062 + ], + [ + "2025-11-26T12:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.8053506291967 + ], + [ + "2025-11-26T12:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.7123421470155 + ], + [ + "2025-11-26T12:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.300095784219 + ], + [ + "2025-11-26T12:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.241342167253 + ], + [ + "2025-11-26T12:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.9789080996925 + ], + [ + "2025-11-26T12:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.965864274086 + ], + [ + "2025-11-26T12:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.695742700139 + ], + [ + "2025-11-26T12:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.9405103143886 + ], + [ + "2025-11-26T12:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.4300098636895 + ], + [ + "2025-11-26T11:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.320481214955 + ], + [ + "2025-11-26T11:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.513562304243 + ], + [ + "2025-11-26T11:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.7880772690073 + ], + [ + "2025-11-26T11:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.332580964658 + ], + [ + "2025-11-26T11:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.181194951116 + ], + [ + "2025-11-26T11:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.007248180217 + ], + [ + "2025-11-26T11:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.8515218467837 + ], + [ + "2025-11-26T11:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.6673300156394 + ], + [ + "2025-11-26T11:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.4055542946803 + ], + [ + "2025-11-26T11:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.990645158666 + ], + [ + "2025-11-26T11:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.0890679003655 + ], + [ + "2025-11-26T11:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.126312424463 + ], + [ + "2025-11-26T11:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.3931226520526 + ], + [ + "2025-11-26T11:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.743360862661 + ], + [ + "2025-11-26T11:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.5332342293477 + ], + [ + "2025-11-26T11:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.110691022866 + ], + [ + "2025-11-26T11:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.4502848274096 + ], + [ + "2025-11-26T11:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.7580797133555 + ], + [ + "2025-11-26T11:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.511925936891 + ], + [ + "2025-11-26T11:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.68638353527 + ], + [ + "2025-11-26T11:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.625490461781 + ], + [ + "2025-11-26T11:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.7959081068134 + ], + [ + "2025-11-26T11:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.480229958646 + ], + [ + "2025-11-26T11:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.809563915068 + ], + [ + "2025-11-26T11:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.3586366230884 + ], + [ + "2025-11-26T11:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.5959338135212 + ], + [ + "2025-11-26T11:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.242779831083 + ], + [ + "2025-11-26T11:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.9564501588534 + ], + [ + "2025-11-26T11:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.841527851626 + ], + [ + "2025-11-26T11:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.338224870277 + ], + [ + "2025-11-26T10:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.2722258353797 + ], + [ + "2025-11-26T10:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.050146157738 + ], + [ + "2025-11-26T10:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.6773747595594 + ], + [ + "2025-11-26T10:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.381052257795 + ], + [ + "2025-11-26T10:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.9749510126644 + ], + [ + "2025-11-26T10:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.106916851508 + ], + [ + "2025-11-26T10:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.453100479823 + ], + [ + "2025-11-26T10:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.791449241497 + ], + [ + "2025-11-26T10:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.9429565184896 + ], + [ + "2025-11-26T10:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.2084965354147 + ], + [ + "2025-11-26T10:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.9460048110077 + ], + [ + "2025-11-26T10:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.5761596488996 + ], + [ + "2025-11-26T10:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.5310783516943 + ], + [ + "2025-11-26T10:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.241471867122 + ], + [ + "2025-11-26T10:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.386021036281 + ], + [ + "2025-11-26T10:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.002297905133 + ], + [ + "2025-11-26T10:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.5768837074834 + ], + [ + "2025-11-26T10:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.8403671868377 + ], + [ + "2025-11-26T10:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.7705005402413 + ], + [ + "2025-11-26T10:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.3393125336597 + ], + [ + "2025-11-26T10:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.158111800588 + ], + [ + "2025-11-26T10:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.1234810700053 + ], + [ + "2025-11-26T10:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.203177905144 + ], + [ + "2025-11-26T10:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.5524213156646 + ], + [ + "2025-11-26T10:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.045470371478 + ], + [ + "2025-11-26T10:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.8639918494705 + ], + [ + "2025-11-26T10:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.0172036085664 + ], + [ + "2025-11-26T10:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.7686584335397 + ], + [ + "2025-11-26T10:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.525331620353 + ], + [ + "2025-11-26T10:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.153560006629 + ], + [ + "2025-11-26T09:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.3161955384962 + ], + [ + "2025-11-26T09:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.714736538994 + ], + [ + "2025-11-26T09:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.0313534933334 + ], + [ + "2025-11-26T09:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.5962242510745 + ], + [ + "2025-11-26T09:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.740653261345 + ], + [ + "2025-11-26T09:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.516693509086 + ], + [ + "2025-11-26T09:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.221822063625 + ], + [ + "2025-11-26T09:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.291769152591 + ], + [ + "2025-11-26T09:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.966592496411 + ], + [ + "2025-11-26T09:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.5433145654574 + ], + [ + "2025-11-26T09:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.423518739349 + ], + [ + "2025-11-26T09:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.847304186992 + ], + [ + "2025-11-26T09:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.3752368736978 + ], + [ + "2025-11-26T09:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.912679997563 + ], + [ + "2025-11-26T09:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.6163500396324 + ], + [ + "2025-11-26T09:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.23123587222 + ], + [ + "2025-11-26T09:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.6137075017245 + ], + [ + "2025-11-26T09:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.7525810211546 + ], + [ + "2025-11-26T09:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.541366622421 + ], + [ + "2025-11-26T09:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.72777218742 + ], + [ + "2025-11-26T09:19:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.9968584264057 + ], + [ + "2025-11-26T09:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.8032364374326 + ], + [ + "2025-11-26T09:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.7959746293673 + ], + [ + "2025-11-26T09:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.8678268041285 + ], + [ + "2025-11-26T09:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.3512333440685 + ], + [ + "2025-11-26T09:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.844531886493 + ], + [ + "2025-11-26T09:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.5045745677926 + ], + [ + "2025-11-26T09:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.4626857679914 + ], + [ + "2025-11-26T09:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.27586243742 + ], + [ + "2025-11-26T09:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.679607506038 + ], + [ + "2025-11-26T08:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.476029357599 + ], + [ + "2025-11-26T08:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.089300557565 + ], + [ + "2025-11-26T08:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.9847423611077 + ], + [ + "2025-11-26T08:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.307881260014 + ], + [ + "2025-11-26T08:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.5804000105654 + ], + [ + "2025-11-26T08:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.8805718402655 + ], + [ + "2025-11-26T08:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.966971877987 + ], + [ + "2025-11-26T08:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.806785115112 + ], + [ + "2025-11-26T08:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.337538714483 + ], + [ + "2025-11-26T08:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.4026843070274 + ], + [ + "2025-11-26T08:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.046910561961 + ], + [ + "2025-11-26T08:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.0239733745684 + ], + [ + "2025-11-26T08:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.8574719304993 + ], + [ + "2025-11-26T08:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.4094433541313 + ], + [ + "2025-11-26T08:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.778861015195 + ], + [ + "2025-11-26T08:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.496550856435 + ], + [ + "2025-11-26T08:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.6243691196632 + ], + [ + "2025-11-26T08:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.9108837723848 + ], + [ + "2025-11-26T08:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.214127607114 + ], + [ + "2025-11-26T08:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.2055620883293 + ], + [ + "2025-11-26T08:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.8827996423015 + ], + [ + "2025-11-26T08:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.8829935402273 + ], + [ + "2025-11-26T08:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.3606837533634 + ], + [ + "2025-11-26T08:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.366115506712 + ], + [ + "2025-11-26T08:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.2055075987464 + ], + [ + "2025-11-26T08:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.2004802640054 + ], + [ + "2025-11-26T08:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.334679821757 + ], + [ + "2025-11-26T08:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.320269158006 + ], + [ + "2025-11-26T08:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.6108919909366 + ], + [ + "2025-11-26T08:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.828884927482 + ], + [ + "2025-11-26T07:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.2196921096747 + ], + [ + "2025-11-26T07:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.5771338329705 + ], + [ + "2025-11-26T07:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.859303874028 + ], + [ + "2025-11-26T07:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.836003853413 + ], + [ + "2025-11-26T07:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.631859532357 + ], + [ + "2025-11-26T07:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.048648618009 + ], + [ + "2025-11-26T07:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.131471925049 + ], + [ + "2025-11-26T07:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.197054104234 + ], + [ + "2025-11-26T07:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.080272178511 + ], + [ + "2025-11-26T07:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.471399907659 + ], + [ + "2025-11-26T07:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.491460895034 + ], + [ + "2025-11-26T07:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.2647985935755 + ], + [ + "2025-11-26T07:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.274943740413 + ], + [ + "2025-11-26T07:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.052695225883 + ], + [ + "2025-11-26T07:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.2574124840753 + ], + [ + "2025-11-26T07:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.523902249845 + ], + [ + "2025-11-26T07:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.1840236976896 + ], + [ + "2025-11-26T07:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.2988880045546 + ], + [ + "2025-11-26T07:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.6337595486107 + ], + [ + "2025-11-26T07:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.616942605512 + ], + [ + "2025-11-26T07:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.1445229020605 + ], + [ + "2025-11-26T07:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.9102116574836 + ], + [ + "2025-11-26T07:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.294099403695 + ], + [ + "2025-11-26T07:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.0908957357765 + ], + [ + "2025-11-26T07:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.51711633162 + ], + [ + "2025-11-26T07:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.6780375890476 + ], + [ + "2025-11-26T07:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.48713198736 + ], + [ + "2025-11-26T07:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.9810255336884 + ], + [ + "2025-11-26T07:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.379177993403 + ], + [ + "2025-11-26T07:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.252889863863 + ], + [ + "2025-11-26T06:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.0350325295954 + ], + [ + "2025-11-26T06:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.9352408461277 + ], + [ + "2025-11-26T06:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.494841736899 + ], + [ + "2025-11-26T06:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.7001173742156 + ], + [ + "2025-11-26T06:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.194693707555 + ], + [ + "2025-11-26T06:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.3848517240453 + ], + [ + "2025-11-26T06:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.1751894529166 + ], + [ + "2025-11-26T06:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.3129524478836 + ], + [ + "2025-11-26T06:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.6970935897566 + ], + [ + "2025-11-26T06:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.227040742291 + ], + [ + "2025-11-26T06:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.873495053849 + ], + [ + "2025-11-26T06:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.0169954150224 + ], + [ + "2025-11-26T06:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.35671039409 + ], + [ + "2025-11-26T06:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.2848004627635 + ], + [ + "2025-11-26T06:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.666665724114 + ], + [ + "2025-11-26T06:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.7426486252166 + ], + [ + "2025-11-26T06:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.8797014800653 + ], + [ + "2025-11-26T06:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.169300004837 + ], + [ + "2025-11-26T06:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.944203063805 + ], + [ + "2025-11-26T06:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.4525555048463 + ], + [ + "2025-11-26T06:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.2770306403945 + ], + [ + "2025-11-26T06:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.066284263337 + ], + [ + "2025-11-26T06:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.370020497128 + ], + [ + "2025-11-26T06:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.3712315753833 + ], + [ + "2025-11-26T06:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.127779679332 + ], + [ + "2025-11-26T06:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.6390229490867 + ], + [ + "2025-11-26T06:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.867646978111 + ], + [ + "2025-11-26T06:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.317176805528 + ], + [ + "2025-11-26T06:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.5646782951217 + ], + [ + "2025-11-26T06:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.171622988449 + ], + [ + "2025-11-26T05:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.305557493166 + ], + [ + "2025-11-26T05:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.9684176315536 + ], + [ + "2025-11-26T05:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.9079897108686 + ], + [ + "2025-11-26T05:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.017199652863 + ], + [ + "2025-11-26T05:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.9731932581417 + ], + [ + "2025-11-26T05:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.5580145192052 + ], + [ + "2025-11-26T05:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.2813987214936 + ], + [ + "2025-11-26T05:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.7764051534537 + ], + [ + "2025-11-26T05:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.7334514861955 + ], + [ + "2025-11-26T05:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.471216558321 + ], + [ + "2025-11-26T05:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.2113414324863 + ], + [ + "2025-11-26T05:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.3258983260503 + ], + [ + "2025-11-26T05:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.4310854093824 + ], + [ + "2025-11-26T05:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.410890510638 + ], + [ + "2025-11-26T05:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.459014505879 + ], + [ + "2025-11-26T05:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.12289046199 + ], + [ + "2025-11-26T05:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.9498236322106 + ], + [ + "2025-11-26T05:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.7356321702773 + ], + [ + "2025-11-26T05:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.5665720799925 + ], + [ + "2025-11-26T05:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.3689600886873 + ], + [ + "2025-11-26T05:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.4818324647026 + ], + [ + "2025-11-26T05:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.521587931057 + ], + [ + "2025-11-26T05:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.1820284208557 + ], + [ + "2025-11-26T05:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.214566808 + ], + [ + "2025-11-26T05:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.965680234375 + ], + [ + "2025-11-26T05:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.6403867882564 + ], + [ + "2025-11-26T05:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.6567848622203 + ], + [ + "2025-11-26T05:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.9533260756693 + ], + [ + "2025-11-26T05:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.5558934828064 + ], + [ + "2025-11-26T05:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.2950584147025 + ], + [ + "2025-11-26T04:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.463356016824 + ], + [ + "2025-11-26T04:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.8640933350844 + ], + [ + "2025-11-26T04:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.028100251433 + ], + [ + "2025-11-26T04:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.201632795194 + ], + [ + "2025-11-26T04:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.792217400524 + ], + [ + "2025-11-26T04:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.132314193 + ], + [ + "2025-11-26T04:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.6385737320998 + ], + [ + "2025-11-26T04:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.369752641011 + ], + [ + "2025-11-26T04:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.6704926593043 + ], + [ + "2025-11-26T04:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.731180149152 + ], + [ + "2025-11-26T04:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.605574475818 + ], + [ + "2025-11-26T04:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.536696794494 + ], + [ + "2025-11-26T04:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.3556729233655 + ], + [ + "2025-11-26T04:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.9966539095353 + ], + [ + "2025-11-26T04:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.530986995635 + ], + [ + "2025-11-26T04:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.0266856994367 + ], + [ + "2025-11-26T04:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.8908919146857 + ], + [ + "2025-11-26T04:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.5414376545377 + ], + [ + "2025-11-26T04:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.773803759667 + ], + [ + "2025-11-26T04:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.5434633626933 + ], + [ + "2025-11-26T04:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.9832362711663 + ], + [ + "2025-11-26T04:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.960830912848 + ], + [ + "2025-11-26T04:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.109681292959 + ], + [ + "2025-11-26T04:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.423317468068 + ], + [ + "2025-11-26T04:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.081706938979 + ], + [ + "2025-11-26T04:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.793345810545 + ], + [ + "2025-11-26T04:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.0052286052787 + ], + [ + "2025-11-26T04:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.38157601157 + ], + [ + "2025-11-26T04:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.6530569203783 + ], + [ + "2025-11-26T04:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.782660364043 + ], + [ + "2025-11-26T03:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.4468286218353 + ], + [ + "2025-11-26T03:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.700376908767 + ], + [ + "2025-11-26T03:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.0068563608547 + ], + [ + "2025-11-26T03:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.555692150941 + ], + [ + "2025-11-26T03:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.2813259684544 + ], + [ + "2025-11-26T03:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.7516675553115 + ], + [ + "2025-11-26T03:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.991922390069 + ], + [ + "2025-11-26T03:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.7233924867055 + ], + [ + "2025-11-26T03:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.146255642844 + ], + [ + "2025-11-26T03:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.8856562772007 + ], + [ + "2025-11-26T03:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.637196675603 + ], + [ + "2025-11-26T03:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.3242319976803 + ], + [ + "2025-11-26T03:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.0903962974953 + ], + [ + "2025-11-26T03:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.5738932748627 + ], + [ + "2025-11-26T03:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.502608878407 + ], + [ + "2025-11-26T03:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.562789302658 + ], + [ + "2025-11-26T03:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.7518230831356 + ], + [ + "2025-11-26T03:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.2493195878246 + ], + [ + "2025-11-26T03:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.6100185120263 + ], + [ + "2025-11-26T03:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.4403613767054 + ], + [ + "2025-11-26T03:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.330427984946 + ], + [ + "2025-11-26T03:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.5354769981104 + ], + [ + "2025-11-26T03:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.0823980959635 + ], + [ + "2025-11-26T03:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.0584139147963 + ], + [ + "2025-11-26T03:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.9353500988705 + ], + [ + "2025-11-26T03:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.798252811467 + ], + [ + "2025-11-26T03:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.924130290219 + ], + [ + "2025-11-26T03:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.0890730159754 + ], + [ + "2025-11-26T03:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.353260007459 + ], + [ + "2025-11-26T03:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.728413930324 + ], + [ + "2025-11-26T02:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.3955235848985 + ], + [ + "2025-11-26T02:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.2190183839175 + ], + [ + "2025-11-26T02:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.6040177364875 + ], + [ + "2025-11-26T02:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.8235078564758 + ], + [ + "2025-11-26T02:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.6034502980224 + ], + [ + "2025-11-26T02:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.4147793707793 + ], + [ + "2025-11-26T02:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.0660996743172 + ], + [ + "2025-11-26T02:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.798731178741 + ], + [ + "2025-11-26T02:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.546927768173 + ], + [ + "2025-11-26T02:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.0265132405393 + ], + [ + "2025-11-26T02:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.6446128748876 + ], + [ + "2025-11-26T02:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.18416130437 + ], + [ + "2025-11-26T02:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.3307124431785 + ], + [ + "2025-11-26T02:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.402380257789 + ], + [ + "2025-11-26T02:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.591672822258 + ], + [ + "2025-11-26T02:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.547915868642 + ], + [ + "2025-11-26T02:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.468455137117 + ], + [ + "2025-11-26T02:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.6029553873877 + ], + [ + "2025-11-26T02:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.8357731550022 + ], + [ + "2025-11-26T02:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.0255726065784 + ], + [ + "2025-11-26T02:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.1333480333187 + ], + [ + "2025-11-26T02:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2972.8270118191335 + ], + [ + "2025-11-26T02:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.8535864958153 + ], + [ + "2025-11-26T02:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.169304748153 + ], + [ + "2025-11-26T02:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.0966844356635 + ], + [ + "2025-11-26T02:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.4046988519804 + ], + [ + "2025-11-26T02:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.9791592824026 + ], + [ + "2025-11-26T02:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.513116160631 + ], + [ + "2025-11-26T02:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.575840929596 + ], + [ + "2025-11-26T02:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.490347907269 + ], + [ + "2025-11-26T01:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.609496104451 + ], + [ + "2025-11-26T01:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.8845236571665 + ], + [ + "2025-11-26T01:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.2093380586834 + ], + [ + "2025-11-26T01:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.273586343689 + ], + [ + "2025-11-26T01:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.4094003620953 + ], + [ + "2025-11-26T01:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.2224259072213 + ], + [ + "2025-11-26T01:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.8820743359383 + ], + [ + "2025-11-26T01:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2976.75811246079 + ], + [ + "2025-11-26T01:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2979.019752917284 + ], + [ + "2025-11-26T01:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2972.925507941516 + ], + [ + "2025-11-26T01:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.0725256976793 + ], + [ + "2025-11-26T01:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.3239500578948 + ], + [ + "2025-11-26T01:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.230009743699 + ], + [ + "2025-11-26T01:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.4728133567287 + ], + [ + "2025-11-26T01:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.9377011346883 + ], + [ + "2025-11-26T01:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.1265649390575 + ], + [ + "2025-11-26T01:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.5241255340356 + ], + [ + "2025-11-26T01:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.7857368677464 + ], + [ + "2025-11-26T01:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.6821783688074 + ], + [ + "2025-11-26T01:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.9757829252303 + ], + [ + "2025-11-26T01:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.202548460901 + ], + [ + "2025-11-26T01:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.5681182898625 + ], + [ + "2025-11-26T01:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.738872460329 + ], + [ + "2025-11-26T01:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.834530467997 + ], + [ + "2025-11-26T01:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.640114195599 + ], + [ + "2025-11-26T01:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.7748966201284 + ], + [ + "2025-11-26T01:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.0356793474543 + ], + [ + "2025-11-26T01:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.009776368899 + ], + [ + "2025-11-26T01:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.600615332331 + ], + [ + "2025-11-26T01:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.643954295729 + ], + [ + "2025-11-26T00:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.731168679213 + ], + [ + "2025-11-26T00:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.02037581628 + ], + [ + "2025-11-26T00:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.2323723003624 + ], + [ + "2025-11-26T00:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.280870766218 + ], + [ + "2025-11-26T00:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.8484969623482 + ], + [ + "2025-11-26T00:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.8627513812453 + ], + [ + "2025-11-26T00:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.048143020825 + ], + [ + "2025-11-26T00:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.054074346517 + ], + [ + "2025-11-26T00:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.6141463081094 + ], + [ + "2025-11-26T00:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.7114480252703 + ], + [ + "2025-11-26T00:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.908324248734 + ], + [ + "2025-11-26T00:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.7311896378787 + ], + [ + "2025-11-26T00:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.986193649611 + ], + [ + "2025-11-26T00:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.8380858479077 + ], + [ + "2025-11-26T00:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.4574326086217 + ], + [ + "2025-11-26T00:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.9109029558617 + ], + [ + "2025-11-26T00:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.7682384473583 + ], + [ + "2025-11-26T00:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.427100525848 + ], + [ + "2025-11-26T00:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.864174466647 + ], + [ + "2025-11-26T00:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.7849027764 + ], + [ + "2025-11-26T00:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.4466330528476 + ], + [ + "2025-11-26T00:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.51407711134 + ], + [ + "2025-11-26T00:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.992141794122 + ], + [ + "2025-11-26T00:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.2289274540467 + ], + [ + "2025-11-26T00:11:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.7848911714045 + ], + [ + "2025-11-26T00:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.5322939669554 + ], + [ + "2025-11-26T00:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.601378522957 + ], + [ + "2025-11-26T00:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.4488461963847 + ], + [ + "2025-11-26T00:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.862482459695 + ], + [ + "2025-11-26T00:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.062301250391 + ], + [ + "2025-11-25T23:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.1210339664754 + ], + [ + "2025-11-25T23:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.383302838337 + ], + [ + "2025-11-25T23:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.4493739636077 + ], + [ + "2025-11-25T23:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.666750758642 + ], + [ + "2025-11-25T23:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.476894774917 + ], + [ + "2025-11-25T23:50:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.272576400263 + ], + [ + "2025-11-25T23:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.7245930196073 + ], + [ + "2025-11-25T23:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.63094606737 + ], + [ + "2025-11-25T23:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.565339851819 + ], + [ + "2025-11-25T23:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.286296885321 + ], + [ + "2025-11-25T23:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.333860355092 + ], + [ + "2025-11-25T23:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.126753318705 + ], + [ + "2025-11-25T23:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.191251949839 + ], + [ + "2025-11-25T23:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.62433335115 + ], + [ + "2025-11-25T23:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.129322028203 + ], + [ + "2025-11-25T23:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.6875426595093 + ], + [ + "2025-11-25T23:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.314356250502 + ], + [ + "2025-11-25T23:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.1469064747625 + ], + [ + "2025-11-25T23:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.117377930124 + ], + [ + "2025-11-25T23:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.0702194719634 + ], + [ + "2025-11-25T23:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.7439889452385 + ], + [ + "2025-11-25T23:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2972.456722505905 + ], + [ + "2025-11-25T23:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.8765975936913 + ], + [ + "2025-11-25T23:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.9037482689373 + ], + [ + "2025-11-25T23:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.249429393934 + ], + [ + "2025-11-25T23:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.2484963572842 + ], + [ + "2025-11-25T23:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.6959546059925 + ], + [ + "2025-11-25T23:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.1364873019 + ], + [ + "2025-11-25T23:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.8898613115 + ], + [ + "2025-11-25T23:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.3725996519915 + ], + [ + "2025-11-25T22:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2972.290560055061 + ], + [ + "2025-11-25T22:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.2104027532837 + ], + [ + "2025-11-25T22:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.9398012681277 + ], + [ + "2025-11-25T22:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.997112938222 + ], + [ + "2025-11-25T22:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.7867481604685 + ], + [ + "2025-11-25T22:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.6183974109417 + ], + [ + "2025-11-25T22:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.630923480878 + ], + [ + "2025-11-25T22:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.6026535940705 + ], + [ + "2025-11-25T22:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.610471417995 + ], + [ + "2025-11-25T22:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.4927976074578 + ], + [ + "2025-11-25T22:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.241342249739 + ], + [ + "2025-11-25T22:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.5217864312 + ], + [ + "2025-11-25T22:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.5709423929848 + ], + [ + "2025-11-25T22:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.677206040501 + ], + [ + "2025-11-25T22:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.5492744925514 + ], + [ + "2025-11-25T22:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.748162645214 + ], + [ + "2025-11-25T22:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.6585973507645 + ], + [ + "2025-11-25T22:26:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.4880679841326 + ], + [ + "2025-11-25T22:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.598921012492 + ], + [ + "2025-11-25T22:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.8027877085415 + ], + [ + "2025-11-25T22:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.0058534179793 + ], + [ + "2025-11-25T22:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.6706148674725 + ], + [ + "2025-11-25T22:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.8610599706176 + ], + [ + "2025-11-25T22:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.8240629759753 + ], + [ + "2025-11-25T22:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.8588558739702 + ], + [ + "2025-11-25T22:10:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.8809353944257 + ], + [ + "2025-11-25T22:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.214317855613 + ], + [ + "2025-11-25T22:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.043630825805 + ], + [ + "2025-11-25T22:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.2065733601767 + ], + [ + "2025-11-25T22:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.1740690963834 + ], + [ + "2025-11-25T21:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.685438310566 + ], + [ + "2025-11-25T21:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.395383946968 + ], + [ + "2025-11-25T21:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.2785053629264 + ], + [ + "2025-11-25T21:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.1871335199976 + ], + [ + "2025-11-25T21:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.585379337488 + ], + [ + "2025-11-25T21:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.542785143297 + ], + [ + "2025-11-25T21:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.5852635053025 + ], + [ + "2025-11-25T21:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.5754927849675 + ], + [ + "2025-11-25T21:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.0869374494578 + ], + [ + "2025-11-25T21:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.0835393593684 + ], + [ + "2025-11-25T21:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.8685256709205 + ], + [ + "2025-11-25T21:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.3472987139467 + ], + [ + "2025-11-25T21:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.2563397478043 + ], + [ + "2025-11-25T21:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.511874694518 + ], + [ + "2025-11-25T21:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.431709847041 + ], + [ + "2025-11-25T21:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.6095920414764 + ], + [ + "2025-11-25T21:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.773997684772 + ], + [ + "2025-11-25T21:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.384097870118 + ], + [ + "2025-11-25T21:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.065571861881 + ], + [ + "2025-11-25T21:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.3118072591124 + ], + [ + "2025-11-25T21:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.5431639829176 + ], + [ + "2025-11-25T21:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.703378423347 + ], + [ + "2025-11-25T21:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.6672868324654 + ], + [ + "2025-11-25T21:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.0995434378506 + ], + [ + "2025-11-25T21:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.1408397567234 + ], + [ + "2025-11-25T21:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.367520785747 + ], + [ + "2025-11-25T21:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.200380755664 + ], + [ + "2025-11-25T21:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.3513944267725 + ], + [ + "2025-11-25T21:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.845770544833 + ], + [ + "2025-11-25T21:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.8661415072115 + ], + [ + "2025-11-25T21:00:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.239830663175 + ], + [ + "2025-11-25T20:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.6882822777493 + ], + [ + "2025-11-25T20:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.8636805785754 + ], + [ + "2025-11-25T20:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.3897922045107 + ], + [ + "2025-11-25T20:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.609155167689 + ], + [ + "2025-11-25T20:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.9232105950277 + ], + [ + "2025-11-25T20:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.0084800880472 + ], + [ + "2025-11-25T20:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.7251896462794 + ], + [ + "2025-11-25T20:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.047762256397 + ], + [ + "2025-11-25T20:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.207421494343 + ], + [ + "2025-11-25T20:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.3909585818433 + ], + [ + "2025-11-25T20:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.554415835041 + ], + [ + "2025-11-25T20:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.1633751179234 + ], + [ + "2025-11-25T20:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.9326250930594 + ], + [ + "2025-11-25T20:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.400038560357 + ], + [ + "2025-11-25T20:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.2460972804847 + ], + [ + "2025-11-25T20:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.7808467879718 + ], + [ + "2025-11-25T20:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.0277561729954 + ], + [ + "2025-11-25T20:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.9554560593588 + ], + [ + "2025-11-25T20:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.645931648351 + ], + [ + "2025-11-25T20:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.457054356633 + ], + [ + "2025-11-25T20:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.359800812322 + ], + [ + "2025-11-25T20:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.3981072560173 + ], + [ + "2025-11-25T20:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.143210596286 + ], + [ + "2025-11-25T20:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.5626257230724 + ], + [ + "2025-11-25T20:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.1013404831065 + ], + [ + "2025-11-25T20:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.2622258471533 + ], + [ + "2025-11-25T20:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.013668278518 + ], + [ + "2025-11-25T20:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.3670226307845 + ], + [ + "2025-11-25T20:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.0031600778034 + ], + [ + "2025-11-25T19:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.7057397548283 + ], + [ + "2025-11-25T19:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.9861735010927 + ], + [ + "2025-11-25T19:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.065605819314 + ], + [ + "2025-11-25T19:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.0949294062702 + ], + [ + "2025-11-25T19:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.2575814223646 + ], + [ + "2025-11-25T19:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.7952778679046 + ], + [ + "2025-11-25T19:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.778599448861 + ], + [ + "2025-11-25T19:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.740317102181 + ], + [ + "2025-11-25T19:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.3487893856172 + ], + [ + "2025-11-25T19:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.789093747701 + ], + [ + "2025-11-25T19:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.08305553579 + ], + [ + "2025-11-25T19:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2887.984879466269 + ], + [ + "2025-11-25T19:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2890.3064036326105 + ], + [ + "2025-11-25T19:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2885.180185304743 + ], + [ + "2025-11-25T19:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2883.8329971766007 + ], + [ + "2025-11-25T19:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.9802547364134 + ], + [ + "2025-11-25T19:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2876.41921745579 + ], + [ + "2025-11-25T19:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.6361388123546 + ], + [ + "2025-11-25T19:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2875.755557407133 + ], + [ + "2025-11-25T19:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.1951587067597 + ], + [ + "2025-11-25T19:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2886.692617197032 + ], + [ + "2025-11-25T19:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.1637341857254 + ], + [ + "2025-11-25T19:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.1803513650075 + ], + [ + "2025-11-25T19:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.4957890054 + ], + [ + "2025-11-25T19:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.8384623884735 + ], + [ + "2025-11-25T19:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.913908239898 + ], + [ + "2025-11-25T19:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.3435129404197 + ], + [ + "2025-11-25T19:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.480206555332 + ], + [ + "2025-11-25T19:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.0121705985075 + ], + [ + "2025-11-25T19:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.106750804255 + ], + [ + "2025-11-25T18:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.4072466452258 + ], + [ + "2025-11-25T18:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.4157449606355 + ], + [ + "2025-11-25T18:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.4169623636726 + ], + [ + "2025-11-25T18:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.6215674394684 + ], + [ + "2025-11-25T18:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.8529172106796 + ], + [ + "2025-11-25T18:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.2747482029686 + ], + [ + "2025-11-25T18:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.739153890164 + ], + [ + "2025-11-25T18:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.326210872826 + ], + [ + "2025-11-25T18:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.527045431205 + ], + [ + "2025-11-25T18:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.26371543641 + ], + [ + "2025-11-25T18:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.3581014487636 + ], + [ + "2025-11-25T18:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.5865870630923 + ], + [ + "2025-11-25T18:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.3897341892134 + ], + [ + "2025-11-25T18:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.347558571257 + ], + [ + "2025-11-25T18:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2951.004855718467 + ], + [ + "2025-11-25T18:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.991764136277 + ], + [ + "2025-11-25T18:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.4420398308953 + ], + [ + "2025-11-25T18:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.3525618045687 + ], + [ + "2025-11-25T18:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.059957772071 + ], + [ + "2025-11-25T18:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.6420841235104 + ], + [ + "2025-11-25T18:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.4946044351104 + ], + [ + "2025-11-25T18:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.770441200522 + ], + [ + "2025-11-25T18:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.054684378161 + ], + [ + "2025-11-25T18:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.186624282435 + ], + [ + "2025-11-25T18:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.888230421978 + ], + [ + "2025-11-25T18:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.9312905494735 + ], + [ + "2025-11-25T18:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.913564322787 + ], + [ + "2025-11-25T18:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.9666525400594 + ], + [ + "2025-11-25T18:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.0052522356827 + ], + [ + "2025-11-25T18:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.2083160567136 + ], + [ + "2025-11-25T17:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.3054197350198 + ], + [ + "2025-11-25T17:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.3273353856243 + ], + [ + "2025-11-25T17:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.5072268951467 + ], + [ + "2025-11-25T17:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.744016936501 + ], + [ + "2025-11-25T17:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.536137409632 + ], + [ + "2025-11-25T17:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.4966937805148 + ], + [ + "2025-11-25T17:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.038874539562 + ], + [ + "2025-11-25T17:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.1679567237943 + ], + [ + "2025-11-25T17:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.6555381872904 + ], + [ + "2025-11-25T17:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.1636737245067 + ], + [ + "2025-11-25T17:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.0073877353952 + ], + [ + "2025-11-25T17:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.4326580698425 + ], + [ + "2025-11-25T17:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.4687339718466 + ], + [ + "2025-11-25T17:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.705922679686 + ], + [ + "2025-11-25T17:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.267240655678 + ], + [ + "2025-11-25T17:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.5814526948316 + ], + [ + "2025-11-25T17:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.0853099598826 + ], + [ + "2025-11-25T17:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.7715421658436 + ], + [ + "2025-11-25T17:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.4480990034 + ], + [ + "2025-11-25T17:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.439489072851 + ], + [ + "2025-11-25T17:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.8484873716848 + ], + [ + "2025-11-25T17:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.0509042107196 + ], + [ + "2025-11-25T17:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.5246645322936 + ], + [ + "2025-11-25T17:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.2482429526617 + ], + [ + "2025-11-25T17:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.0987540725914 + ], + [ + "2025-11-25T17:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.7764836742554 + ], + [ + "2025-11-25T17:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.0630056631185 + ], + [ + "2025-11-25T17:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.457660639885 + ], + [ + "2025-11-25T17:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.407531204903 + ], + [ + "2025-11-25T17:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.4050043699553 + ], + [ + "2025-11-25T16:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.769914242029 + ], + [ + "2025-11-25T16:57:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.2794534942523 + ], + [ + "2025-11-25T16:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.8227619344884 + ], + [ + "2025-11-25T16:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.6382605769672 + ], + [ + "2025-11-25T16:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.8933095809807 + ], + [ + "2025-11-25T16:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.5282225301976 + ], + [ + "2025-11-25T16:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.7143934925043 + ], + [ + "2025-11-25T16:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.586738126733 + ], + [ + "2025-11-25T16:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.5051581028956 + ], + [ + "2025-11-25T16:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.5266414285315 + ], + [ + "2025-11-25T16:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.2176970401215 + ], + [ + "2025-11-25T16:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.0664956622213 + ], + [ + "2025-11-25T16:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.1062374573153 + ], + [ + "2025-11-25T16:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.304441828586 + ], + [ + "2025-11-25T16:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.3734521591614 + ], + [ + "2025-11-25T16:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.244323798677 + ], + [ + "2025-11-25T16:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.420171386891 + ], + [ + "2025-11-25T16:26:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.9551942499547 + ], + [ + "2025-11-25T16:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.7577201866566 + ], + [ + "2025-11-25T16:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.754004369561 + ], + [ + "2025-11-25T16:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.151625539209 + ], + [ + "2025-11-25T16:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.288619925643 + ], + [ + "2025-11-25T16:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.369443011761 + ], + [ + "2025-11-25T16:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.4767745925215 + ], + [ + "2025-11-25T16:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.2501142368037 + ], + [ + "2025-11-25T16:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.9701208100473 + ], + [ + "2025-11-25T16:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.771418134724 + ], + [ + "2025-11-25T16:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.326862449308 + ], + [ + "2025-11-25T16:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.430313039108 + ], + [ + "2025-11-25T16:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.19431913164 + ], + [ + "2025-11-25T15:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.1527894816873 + ], + [ + "2025-11-25T15:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.322466256628 + ], + [ + "2025-11-25T15:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.5824512848094 + ], + [ + "2025-11-25T15:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.0033205710906 + ], + [ + "2025-11-25T15:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.9800317886725 + ], + [ + "2025-11-25T15:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.85250754541 + ], + [ + "2025-11-25T15:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.394316666003 + ], + [ + "2025-11-25T15:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.1386826494295 + ], + [ + "2025-11-25T15:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.9346440596987 + ], + [ + "2025-11-25T15:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.3987439058487 + ], + [ + "2025-11-25T15:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.340842178321 + ], + [ + "2025-11-25T15:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.5492739698957 + ], + [ + "2025-11-25T15:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.0833149815785 + ], + [ + "2025-11-25T15:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.1570891344327 + ], + [ + "2025-11-25T15:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.8070165669433 + ], + [ + "2025-11-25T15:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.7368532902633 + ], + [ + "2025-11-25T15:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2875.872759804861 + ], + [ + "2025-11-25T15:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2872.3772413527176 + ], + [ + "2025-11-25T15:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.3151736727873 + ], + [ + "2025-11-25T15:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2869.757949233907 + ], + [ + "2025-11-25T15:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.9771051296693 + ], + [ + "2025-11-25T15:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.1472418457474 + ], + [ + "2025-11-25T15:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2874.459196619543 + ], + [ + "2025-11-25T15:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2879.979753727136 + ], + [ + "2025-11-25T15:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2888.014292432281 + ], + [ + "2025-11-25T15:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2872.822539554824 + ], + [ + "2025-11-25T15:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2867.1666019366544 + ], + [ + "2025-11-25T15:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2860.573287300417 + ], + [ + "2025-11-25T15:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.9915308966374 + ], + [ + "2025-11-25T15:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2873.533372900368 + ], + [ + "2025-11-25T14:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2878.74422945269 + ], + [ + "2025-11-25T14:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2874.2948127228306 + ], + [ + "2025-11-25T14:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2871.185001764947 + ], + [ + "2025-11-25T14:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2871.4248775813926 + ], + [ + "2025-11-25T14:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2862.3187252755415 + ], + [ + "2025-11-25T14:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2873.9636426501434 + ], + [ + "2025-11-25T14:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.595837210763 + ], + [ + "2025-11-25T14:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.1125850670546 + ], + [ + "2025-11-25T14:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.242572952728 + ], + [ + "2025-11-25T14:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.358570938569 + ], + [ + "2025-11-25T14:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.2555505180526 + ], + [ + "2025-11-25T14:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.5975283004086 + ], + [ + "2025-11-25T14:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.0734357244655 + ], + [ + "2025-11-25T14:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.1415197650613 + ], + [ + "2025-11-25T14:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.8026543890937 + ], + [ + "2025-11-25T14:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.1919437002093 + ], + [ + "2025-11-25T14:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.035631410525 + ], + [ + "2025-11-25T14:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.0320984539803 + ], + [ + "2025-11-25T14:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.9555031375303 + ], + [ + "2025-11-25T14:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.1746956890556 + ], + [ + "2025-11-25T14:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.780703797096 + ], + [ + "2025-11-25T14:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.4699589902552 + ], + [ + "2025-11-25T14:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.3128955610177 + ], + [ + "2025-11-25T14:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.978022442177 + ], + [ + "2025-11-25T14:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.949615614862 + ], + [ + "2025-11-25T14:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.3696546300257 + ], + [ + "2025-11-25T14:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.8238664918495 + ], + [ + "2025-11-25T14:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.834392735277 + ], + [ + "2025-11-25T14:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.791005914536 + ], + [ + "2025-11-25T13:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.725349626592 + ], + [ + "2025-11-25T13:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.0778560413346 + ], + [ + "2025-11-25T13:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.845936324498 + ], + [ + "2025-11-25T13:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.094288293443 + ], + [ + "2025-11-25T13:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.1706813171754 + ], + [ + "2025-11-25T13:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.408625780537 + ], + [ + "2025-11-25T13:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.5431196462723 + ], + [ + "2025-11-25T13:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.226720114856 + ], + [ + "2025-11-25T13:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.6783576621224 + ], + [ + "2025-11-25T13:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.3843275616177 + ], + [ + "2025-11-25T13:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.3539003331553 + ], + [ + "2025-11-25T13:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.10752715448 + ], + [ + "2025-11-25T13:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.7827710604147 + ], + [ + "2025-11-25T13:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.8569052691682 + ], + [ + "2025-11-25T13:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.489471686846 + ], + [ + "2025-11-25T13:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.083698830142 + ], + [ + "2025-11-25T13:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.2712731387055 + ], + [ + "2025-11-25T13:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.1975680305386 + ], + [ + "2025-11-25T13:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.370610932095 + ], + [ + "2025-11-25T13:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.4028503224454 + ], + [ + "2025-11-25T13:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.75114754462 + ], + [ + "2025-11-25T13:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.5690365708983 + ], + [ + "2025-11-25T13:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.9858944469515 + ], + [ + "2025-11-25T13:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.0249416040438 + ], + [ + "2025-11-25T13:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.9646914003424 + ], + [ + "2025-11-25T13:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.791580312053 + ], + [ + "2025-11-25T13:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.9257994427508 + ], + [ + "2025-11-25T13:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.2116701495784 + ], + [ + "2025-11-25T13:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.6521766947835 + ], + [ + "2025-11-25T13:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.2317309669543 + ], + [ + "2025-11-25T12:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.1950571353063 + ], + [ + "2025-11-25T12:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.3281659885097 + ], + [ + "2025-11-25T12:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.147106852427 + ], + [ + "2025-11-25T12:53:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.261615028771 + ], + [ + "2025-11-25T12:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.7039156755145 + ], + [ + "2025-11-25T12:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.6561089470483 + ], + [ + "2025-11-25T12:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.921147541774 + ], + [ + "2025-11-25T12:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.067849689041 + ], + [ + "2025-11-25T12:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.3202566273303 + ], + [ + "2025-11-25T12:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.8067430944716 + ], + [ + "2025-11-25T12:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.1694812738915 + ], + [ + "2025-11-25T12:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.9623277889 + ], + [ + "2025-11-25T12:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.8390447282386 + ], + [ + "2025-11-25T12:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.9414155384625 + ], + [ + "2025-11-25T12:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.3538105174066 + ], + [ + "2025-11-25T12:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.10406524373 + ], + [ + "2025-11-25T12:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.087883471956 + ], + [ + "2025-11-25T12:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.218225004926 + ], + [ + "2025-11-25T12:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.364861003702 + ], + [ + "2025-11-25T12:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.5971017638585 + ], + [ + "2025-11-25T12:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.364986719194 + ], + [ + "2025-11-25T12:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.5916319359703 + ], + [ + "2025-11-25T12:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.3128970196385 + ], + [ + "2025-11-25T12:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.242940018195 + ], + [ + "2025-11-25T12:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.996733145544 + ], + [ + "2025-11-25T12:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.569044948713 + ], + [ + "2025-11-25T12:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.4887799293165 + ], + [ + "2025-11-25T12:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.5071010870975 + ], + [ + "2025-11-25T12:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.793677175374 + ], + [ + "2025-11-25T12:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2890.56816399269 + ], + [ + "2025-11-25T11:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.3232405660733 + ], + [ + "2025-11-25T11:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2890.9662243887205 + ], + [ + "2025-11-25T11:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.7246353312353 + ], + [ + "2025-11-25T11:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.4583311882034 + ], + [ + "2025-11-25T11:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.9742820230354 + ], + [ + "2025-11-25T11:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.5231769620364 + ], + [ + "2025-11-25T11:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.9007921250222 + ], + [ + "2025-11-25T11:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.124102731036 + ], + [ + "2025-11-25T11:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.3556501402577 + ], + [ + "2025-11-25T11:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.326084619482 + ], + [ + "2025-11-25T11:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.055527276837 + ], + [ + "2025-11-25T11:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.259596966005 + ], + [ + "2025-11-25T11:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.1995035735654 + ], + [ + "2025-11-25T11:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.533626650877 + ], + [ + "2025-11-25T11:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.4002765573528 + ], + [ + "2025-11-25T11:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.3220893069897 + ], + [ + "2025-11-25T11:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.1122345221274 + ], + [ + "2025-11-25T11:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.6752580576135 + ], + [ + "2025-11-25T11:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2890.4416966498366 + ], + [ + "2025-11-25T11:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.562460948457 + ], + [ + "2025-11-25T11:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.896819186055 + ], + [ + "2025-11-25T11:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.2869692681184 + ], + [ + "2025-11-25T11:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.144286365895 + ], + [ + "2025-11-25T11:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.1991756287034 + ], + [ + "2025-11-25T11:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.736645041163 + ], + [ + "2025-11-25T11:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.2720650285505 + ], + [ + "2025-11-25T11:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.5010633608617 + ], + [ + "2025-11-25T11:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.0591428860553 + ], + [ + "2025-11-25T11:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.973300744731 + ], + [ + "2025-11-25T11:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.8846311641682 + ], + [ + "2025-11-25T10:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.7223173788207 + ], + [ + "2025-11-25T10:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.1985546754936 + ], + [ + "2025-11-25T10:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2896.106696526224 + ], + [ + "2025-11-25T10:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.929789625524 + ], + [ + "2025-11-25T10:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.2074870817783 + ], + [ + "2025-11-25T10:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.88845202099 + ], + [ + "2025-11-25T10:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.4659283469155 + ], + [ + "2025-11-25T10:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.5366836213716 + ], + [ + "2025-11-25T10:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2894.8987459937657 + ], + [ + "2025-11-25T10:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2893.8759937888653 + ], + [ + "2025-11-25T10:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.6200194970506 + ], + [ + "2025-11-25T10:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2895.560776187952 + ], + [ + "2025-11-25T10:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.070273226301 + ], + [ + "2025-11-25T10:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.1497575495487 + ], + [ + "2025-11-25T10:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.7727188450312 + ], + [ + "2025-11-25T10:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2890.7841779475925 + ], + [ + "2025-11-25T10:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.9685666058626 + ], + [ + "2025-11-25T10:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2890.3593647496295 + ], + [ + "2025-11-25T10:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2885.7049973805138 + ], + [ + "2025-11-25T10:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.9798878530323 + ], + [ + "2025-11-25T10:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2881.0272901173944 + ], + [ + "2025-11-25T10:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2884.7361314207337 + ], + [ + "2025-11-25T10:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2886.529860251159 + ], + [ + "2025-11-25T10:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2887.843843044949 + ], + [ + "2025-11-25T10:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.0299461507984 + ], + [ + "2025-11-25T10:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2886.4956317202577 + ], + [ + "2025-11-25T10:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2886.1662278250897 + ], + [ + "2025-11-25T10:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2886.8678059997223 + ], + [ + "2025-11-25T10:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2885.7525943140668 + ], + [ + "2025-11-25T10:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2888.7064069092635 + ], + [ + "2025-11-25T09:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2891.6109583009243 + ], + [ + "2025-11-25T09:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.672879189027 + ], + [ + "2025-11-25T09:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2886.0834315094235 + ], + [ + "2025-11-25T09:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2888.048808474307 + ], + [ + "2025-11-25T09:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2888.711862570423 + ], + [ + "2025-11-25T09:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.6781922494156 + ], + [ + "2025-11-25T09:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2885.3111658949642 + ], + [ + "2025-11-25T09:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2888.118128295657 + ], + [ + "2025-11-25T09:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2882.661443716595 + ], + [ + "2025-11-25T09:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.0691724910416 + ], + [ + "2025-11-25T09:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.7754480119065 + ], + [ + "2025-11-25T09:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2878.2163673234795 + ], + [ + "2025-11-25T09:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2875.643296981712 + ], + [ + "2025-11-25T09:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2874.622712199072 + ], + [ + "2025-11-25T09:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2873.9487008282667 + ], + [ + "2025-11-25T09:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2871.0349445070196 + ], + [ + "2025-11-25T09:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2869.645641805467 + ], + [ + "2025-11-25T09:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2869.5363593027846 + ], + [ + "2025-11-25T09:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2869.424136999083 + ], + [ + "2025-11-25T09:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2870.238368707053 + ], + [ + "2025-11-25T09:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2870.0769189555836 + ], + [ + "2025-11-25T09:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.0118625020573 + ], + [ + "2025-11-25T09:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2870.730301603727 + ], + [ + "2025-11-25T09:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2869.062398231564 + ], + [ + "2025-11-25T09:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2870.275248330321 + ], + [ + "2025-11-25T09:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2874.138138088865 + ], + [ + "2025-11-25T09:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2875.3258871628714 + ], + [ + "2025-11-25T09:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2874.218785358106 + ], + [ + "2025-11-25T09:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2873.85217734841 + ], + [ + "2025-11-25T09:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2871.645594686188 + ], + [ + "2025-11-25T08:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2871.4023773264125 + ], + [ + "2025-11-25T08:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2874.4685490463735 + ], + [ + "2025-11-25T08:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.7846747491208 + ], + [ + "2025-11-25T08:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2887.174077808871 + ], + [ + "2025-11-25T08:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2889.969793762413 + ], + [ + "2025-11-25T08:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.1042230112007 + ], + [ + "2025-11-25T08:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.4083103015128 + ], + [ + "2025-11-25T08:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.5794293898102 + ], + [ + "2025-11-25T08:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.2439910496732 + ], + [ + "2025-11-25T08:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.1435571085417 + ], + [ + "2025-11-25T08:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.1643696627243 + ], + [ + "2025-11-25T08:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2899.019556273523 + ], + [ + "2025-11-25T08:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.3168806124913 + ], + [ + "2025-11-25T08:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2897.4368786439936 + ], + [ + "2025-11-25T08:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.918905146053 + ], + [ + "2025-11-25T08:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.4325236681975 + ], + [ + "2025-11-25T08:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.4628902557693 + ], + [ + "2025-11-25T08:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.6580082644327 + ], + [ + "2025-11-25T08:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.1066583017914 + ], + [ + "2025-11-25T08:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.3710368787742 + ], + [ + "2025-11-25T08:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.017868952166 + ], + [ + "2025-11-25T08:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.0896357448546 + ], + [ + "2025-11-25T08:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2898.755176224638 + ], + [ + "2025-11-25T08:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.3704100836626 + ], + [ + "2025-11-25T08:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2902.366015033 + ], + [ + "2025-11-25T08:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.3909311181133 + ], + [ + "2025-11-25T08:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.3316999245176 + ], + [ + "2025-11-25T08:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2901.0455293233363 + ], + [ + "2025-11-25T08:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2900.5322918211414 + ], + [ + "2025-11-25T08:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2904.542942737753 + ], + [ + "2025-11-25T07:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2903.008865083118 + ], + [ + "2025-11-25T07:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.249629780636 + ], + [ + "2025-11-25T07:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2905.071332276281 + ], + [ + "2025-11-25T07:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.8281790707056 + ], + [ + "2025-11-25T07:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2906.176106483525 + ], + [ + "2025-11-25T07:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.540572576359 + ], + [ + "2025-11-25T07:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.5642618559227 + ], + [ + "2025-11-25T07:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.7774280554836 + ], + [ + "2025-11-25T07:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.6087673912984 + ], + [ + "2025-11-25T07:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.012845127453 + ], + [ + "2025-11-25T07:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.626995806325 + ], + [ + "2025-11-25T07:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.129275543179 + ], + [ + "2025-11-25T07:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.288958932832 + ], + [ + "2025-11-25T07:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.1071443736605 + ], + [ + "2025-11-25T07:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.6994049637588 + ], + [ + "2025-11-25T07:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.730712545259 + ], + [ + "2025-11-25T07:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.3720261412723 + ], + [ + "2025-11-25T07:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.563683898954 + ], + [ + "2025-11-25T07:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.500755179599 + ], + [ + "2025-11-25T07:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.3068942210602 + ], + [ + "2025-11-25T07:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.018277498745 + ], + [ + "2025-11-25T07:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.0042205929067 + ], + [ + "2025-11-25T07:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.552525178048 + ], + [ + "2025-11-25T07:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.5571275038374 + ], + [ + "2025-11-25T07:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.7188735331342 + ], + [ + "2025-11-25T07:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.6154760979857 + ], + [ + "2025-11-25T07:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.245693436618 + ], + [ + "2025-11-25T07:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.175852957793 + ], + [ + "2025-11-25T07:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.988671714983 + ], + [ + "2025-11-25T07:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.430143111362 + ], + [ + "2025-11-25T06:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.8845210657287 + ], + [ + "2025-11-25T06:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.5003602026422 + ], + [ + "2025-11-25T06:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.8568965661816 + ], + [ + "2025-11-25T06:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.45723657674 + ], + [ + "2025-11-25T06:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.817699052494 + ], + [ + "2025-11-25T06:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.161725381848 + ], + [ + "2025-11-25T06:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.4791751252305 + ], + [ + "2025-11-25T06:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.6967422217863 + ], + [ + "2025-11-25T06:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.8042149939206 + ], + [ + "2025-11-25T06:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.183515673781 + ], + [ + "2025-11-25T06:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.0989412308263 + ], + [ + "2025-11-25T06:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.0492968397375 + ], + [ + "2025-11-25T06:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.077535828968 + ], + [ + "2025-11-25T06:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.082464299336 + ], + [ + "2025-11-25T06:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.764744310789 + ], + [ + "2025-11-25T06:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.7616578984034 + ], + [ + "2025-11-25T06:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.361445685142 + ], + [ + "2025-11-25T06:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.9718267930225 + ], + [ + "2025-11-25T06:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.895483970998 + ], + [ + "2025-11-25T06:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.1706475778783 + ], + [ + "2025-11-25T06:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.797008222425 + ], + [ + "2025-11-25T06:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.0946017610295 + ], + [ + "2025-11-25T06:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.9089092293066 + ], + [ + "2025-11-25T06:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.742811373671 + ], + [ + "2025-11-25T06:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.3614946258526 + ], + [ + "2025-11-25T06:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.4963203551165 + ], + [ + "2025-11-25T06:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.63578565365 + ], + [ + "2025-11-25T06:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.163383526646 + ], + [ + "2025-11-25T06:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.906052679942 + ], + [ + "2025-11-25T06:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.8885811716323 + ], + [ + "2025-11-25T05:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.150099001158 + ], + [ + "2025-11-25T05:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.2843735621523 + ], + [ + "2025-11-25T05:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.755910079109 + ], + [ + "2025-11-25T05:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.053725259364 + ], + [ + "2025-11-25T05:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.6531051397374 + ], + [ + "2025-11-25T05:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.1911402830237 + ], + [ + "2025-11-25T05:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.702093188023 + ], + [ + "2025-11-25T05:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.6278340556364 + ], + [ + "2025-11-25T05:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.3192169923886 + ], + [ + "2025-11-25T05:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.912019686736 + ], + [ + "2025-11-25T05:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.6966390138155 + ], + [ + "2025-11-25T05:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.278422045186 + ], + [ + "2025-11-25T05:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.926237474774 + ], + [ + "2025-11-25T05:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.058449991874 + ], + [ + "2025-11-25T05:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.4838870608783 + ], + [ + "2025-11-25T05:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.106851551591 + ], + [ + "2025-11-25T05:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.0861850091123 + ], + [ + "2025-11-25T05:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.6284013253926 + ], + [ + "2025-11-25T05:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.6674149074483 + ], + [ + "2025-11-25T05:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.608388357344 + ], + [ + "2025-11-25T05:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.7147975102052 + ], + [ + "2025-11-25T05:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.682149899748 + ], + [ + "2025-11-25T05:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.350514628574 + ], + [ + "2025-11-25T05:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.240842474512 + ], + [ + "2025-11-25T05:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.197015827705 + ], + [ + "2025-11-25T05:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.192299630862 + ], + [ + "2025-11-25T05:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.59216507638 + ], + [ + "2025-11-25T05:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.2892434206333 + ], + [ + "2025-11-25T05:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.1346932449246 + ], + [ + "2025-11-25T05:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.7483433081934 + ], + [ + "2025-11-25T04:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.0235608779085 + ], + [ + "2025-11-25T04:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.9652973983975 + ], + [ + "2025-11-25T04:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.1915186658553 + ], + [ + "2025-11-25T04:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.770562039147 + ], + [ + "2025-11-25T04:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.931852082166 + ], + [ + "2025-11-25T04:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.594184355404 + ], + [ + "2025-11-25T04:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.154495666739 + ], + [ + "2025-11-25T04:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.262196222043 + ], + [ + "2025-11-25T04:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.201651724356 + ], + [ + "2025-11-25T04:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2933.0877276006636 + ], + [ + "2025-11-25T04:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.8890489798746 + ], + [ + "2025-11-25T04:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.76894325132 + ], + [ + "2025-11-25T04:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.7043971219005 + ], + [ + "2025-11-25T04:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.882686405999 + ], + [ + "2025-11-25T04:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.233745599834 + ], + [ + "2025-11-25T04:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.196311111129 + ], + [ + "2025-11-25T04:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.8273974311664 + ], + [ + "2025-11-25T04:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.973485684578 + ], + [ + "2025-11-25T04:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.6716143010403 + ], + [ + "2025-11-25T04:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.9990892867736 + ], + [ + "2025-11-25T04:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.8881967030165 + ], + [ + "2025-11-25T04:17:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.482724603553 + ], + [ + "2025-11-25T04:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.414599521039 + ], + [ + "2025-11-25T04:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.005910304715 + ], + [ + "2025-11-25T04:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.1946251699833 + ], + [ + "2025-11-25T04:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.130583980309 + ], + [ + "2025-11-25T04:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.4640599042373 + ], + [ + "2025-11-25T04:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.3386159674797 + ], + [ + "2025-11-25T04:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.041867383155 + ], + [ + "2025-11-25T04:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.1214433601167 + ], + [ + "2025-11-25T03:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.948457924176 + ], + [ + "2025-11-25T03:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.185287809479 + ], + [ + "2025-11-25T03:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.6169602834702 + ], + [ + "2025-11-25T03:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.637093048854 + ], + [ + "2025-11-25T03:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2907.916673648405 + ], + [ + "2025-11-25T03:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.323394556368 + ], + [ + "2025-11-25T03:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2910.999198765501 + ], + [ + "2025-11-25T03:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.282517699142 + ], + [ + "2025-11-25T03:44:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.294131629538 + ], + [ + "2025-11-25T03:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2908.60388618842 + ], + [ + "2025-11-25T03:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.633987141561 + ], + [ + "2025-11-25T03:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.7674875867447 + ], + [ + "2025-11-25T03:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.291644863047 + ], + [ + "2025-11-25T03:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.936022793239 + ], + [ + "2025-11-25T03:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.136174463249 + ], + [ + "2025-11-25T03:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.030211267642 + ], + [ + "2025-11-25T03:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.274712635732 + ], + [ + "2025-11-25T03:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.4949392929507 + ], + [ + "2025-11-25T03:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.580327978865 + ], + [ + "2025-11-25T03:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.578584772894 + ], + [ + "2025-11-25T03:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.6082798644024 + ], + [ + "2025-11-25T03:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.1421587059444 + ], + [ + "2025-11-25T03:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.973417803585 + ], + [ + "2025-11-25T03:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.5734983259244 + ], + [ + "2025-11-25T03:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.4770215706894 + ], + [ + "2025-11-25T03:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.9561646857 + ], + [ + "2025-11-25T03:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.7650579835636 + ], + [ + "2025-11-25T03:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.0583799232186 + ], + [ + "2025-11-25T03:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.0525621605407 + ], + [ + "2025-11-25T03:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.8974087268443 + ], + [ + "2025-11-25T02:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2929.586954374568 + ], + [ + "2025-11-25T02:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.9886156565663 + ], + [ + "2025-11-25T02:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.4514959011954 + ], + [ + "2025-11-25T02:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2928.3458578840555 + ], + [ + "2025-11-25T02:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.3152619968287 + ], + [ + "2025-11-25T02:50:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.9017195989036 + ], + [ + "2025-11-25T02:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.320075888693 + ], + [ + "2025-11-25T02:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.5263534569085 + ], + [ + "2025-11-25T02:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.4344436643955 + ], + [ + "2025-11-25T02:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.646686773465 + ], + [ + "2025-11-25T02:39:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.9978841131465 + ], + [ + "2025-11-25T02:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.962061546253 + ], + [ + "2025-11-25T02:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2912.8679147439966 + ], + [ + "2025-11-25T02:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.3335557165055 + ], + [ + "2025-11-25T02:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.0972908887366 + ], + [ + "2025-11-25T02:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2915.4597988252603 + ], + [ + "2025-11-25T02:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.997870961074 + ], + [ + "2025-11-25T02:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.0188814459984 + ], + [ + "2025-11-25T02:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.128779762502 + ], + [ + "2025-11-25T02:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.2609261764296 + ], + [ + "2025-11-25T02:19:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.719554278818 + ], + [ + "2025-11-25T02:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.274971629893 + ], + [ + "2025-11-25T02:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.042542404901 + ], + [ + "2025-11-25T02:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.975712693814 + ], + [ + "2025-11-25T02:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2919.7697496080264 + ], + [ + "2025-11-25T02:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.5300840900177 + ], + [ + "2025-11-25T02:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.986819953469 + ], + [ + "2025-11-25T02:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.3277706354847 + ], + [ + "2025-11-25T02:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.785030366394 + ], + [ + "2025-11-25T02:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2916.7766526656724 + ], + [ + "2025-11-25T01:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2920.2021619610887 + ], + [ + "2025-11-25T01:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2924.035950736119 + ], + [ + "2025-11-25T01:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.9800713075206 + ], + [ + "2025-11-25T01:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2926.242505019737 + ], + [ + "2025-11-25T01:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.3635905082706 + ], + [ + "2025-11-25T01:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.5877345440113 + ], + [ + "2025-11-25T01:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.751265845768 + ], + [ + "2025-11-25T01:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.8790009839113 + ], + [ + "2025-11-25T01:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.7097491054637 + ], + [ + "2025-11-25T01:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.9228760930264 + ], + [ + "2025-11-25T01:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.7421362226746 + ], + [ + "2025-11-25T01:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.0327523383335 + ], + [ + "2025-11-25T01:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.087678153927 + ], + [ + "2025-11-25T01:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.5160564090183 + ], + [ + "2025-11-25T01:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.883273792466 + ], + [ + "2025-11-25T01:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.024770335191 + ], + [ + "2025-11-25T01:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.4711933723265 + ], + [ + "2025-11-25T01:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.123707424354 + ], + [ + "2025-11-25T01:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.005583431902 + ], + [ + "2025-11-25T01:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.956289204093 + ], + [ + "2025-11-25T01:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.6350905958257 + ], + [ + "2025-11-25T01:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.332834615023 + ], + [ + "2025-11-25T01:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.4642229045467 + ], + [ + "2025-11-25T01:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.708948664206 + ], + [ + "2025-11-25T01:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.514784928628 + ], + [ + "2025-11-25T01:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.5598298708746 + ], + [ + "2025-11-25T01:07:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.6894702425325 + ], + [ + "2025-11-25T01:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.0586625159904 + ], + [ + "2025-11-25T01:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.509299283835 + ], + [ + "2025-11-25T01:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.1709491678266 + ], + [ + "2025-11-25T00:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.1289466681405 + ], + [ + "2025-11-25T00:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.40843488616 + ], + [ + "2025-11-25T00:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.085452543199 + ], + [ + "2025-11-25T00:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.0076787456323 + ], + [ + "2025-11-25T00:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.669727012378 + ], + [ + "2025-11-25T00:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.747613290039 + ], + [ + "2025-11-25T00:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.3796311879896 + ], + [ + "2025-11-25T00:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.9413546968285 + ], + [ + "2025-11-25T00:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.7412428886823 + ], + [ + "2025-11-25T00:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.0445627463514 + ], + [ + "2025-11-25T00:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.6189654396326 + ], + [ + "2025-11-25T00:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.9623061782795 + ], + [ + "2025-11-25T00:35:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.3128125524054 + ], + [ + "2025-11-25T00:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.0655103971217 + ], + [ + "2025-11-25T00:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.0718647005247 + ], + [ + "2025-11-25T00:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.722753149805 + ], + [ + "2025-11-25T00:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.657178723783 + ], + [ + "2025-11-25T00:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2934.7275241005477 + ], + [ + "2025-11-25T00:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2935.153570301041 + ], + [ + "2025-11-25T00:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.6156476168767 + ], + [ + "2025-11-25T00:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2937.20921442894 + ], + [ + "2025-11-25T00:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2936.795532831221 + ], + [ + "2025-11-25T00:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.0044512604986 + ], + [ + "2025-11-25T00:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.550678681602 + ], + [ + "2025-11-25T00:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.23910254053 + ], + [ + "2025-11-25T00:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.0986412112866 + ], + [ + "2025-11-25T00:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.625413773054 + ], + [ + "2025-11-25T00:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.987131680593 + ], + [ + "2025-11-25T00:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2942.9594773691715 + ], + [ + "2025-11-25T00:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2949.9206856724004 + ], + [ + "2025-11-24T23:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.88025966878 + ], + [ + "2025-11-24T23:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.6880661095797 + ], + [ + "2025-11-24T23:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2952.4699992133155 + ], + [ + "2025-11-24T23:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.8576134609543 + ], + [ + "2025-11-24T23:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.8925844816845 + ], + [ + "2025-11-24T23:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.203167159954 + ], + [ + "2025-11-24T23:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.3151028377965 + ], + [ + "2025-11-24T23:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.839888683509 + ], + [ + "2025-11-24T23:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.171736888373 + ], + [ + "2025-11-24T23:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.66680670781 + ], + [ + "2025-11-24T23:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.510042252904 + ], + [ + "2025-11-24T23:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.047333276213 + ], + [ + "2025-11-24T23:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.9948753763106 + ], + [ + "2025-11-24T23:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.544616529462 + ], + [ + "2025-11-24T23:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.2535038585315 + ], + [ + "2025-11-24T23:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.3330293854183 + ], + [ + "2025-11-24T23:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.267192044647 + ], + [ + "2025-11-24T23:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.931856268202 + ], + [ + "2025-11-24T23:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.028585272856 + ], + [ + "2025-11-24T23:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.7340961975656 + ], + [ + "2025-11-24T23:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.652429505102 + ], + [ + "2025-11-24T23:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.7407146172336 + ], + [ + "2025-11-24T23:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.862661988686 + ], + [ + "2025-11-24T23:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.504360766954 + ], + [ + "2025-11-24T23:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.4039808257985 + ], + [ + "2025-11-24T23:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.2436673451357 + ], + [ + "2025-11-24T23:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.2288236920717 + ], + [ + "2025-11-24T23:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.805146664146 + ], + [ + "2025-11-24T23:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.0303653972305 + ], + [ + "2025-11-24T23:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.651996223779 + ], + [ + "2025-11-24T22:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.8423383175027 + ], + [ + "2025-11-24T22:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.291296069925 + ], + [ + "2025-11-24T22:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.1240585645432 + ], + [ + "2025-11-24T22:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.912708123889 + ], + [ + "2025-11-24T22:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.5573791108886 + ], + [ + "2025-11-24T22:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.5600440956814 + ], + [ + "2025-11-24T22:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.6126549753485 + ], + [ + "2025-11-24T22:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.8998685903016 + ], + [ + "2025-11-24T22:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.2846756929644 + ], + [ + "2025-11-24T22:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.732290988764 + ], + [ + "2025-11-24T22:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.596262323827 + ], + [ + "2025-11-24T22:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.803413120235 + ], + [ + "2025-11-24T22:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.390347952095 + ], + [ + "2025-11-24T22:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.756322074248 + ], + [ + "2025-11-24T22:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.168193229311 + ], + [ + "2025-11-24T22:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.3156723154307 + ], + [ + "2025-11-24T22:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.4406485180943 + ], + [ + "2025-11-24T22:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.254967941473 + ], + [ + "2025-11-24T22:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.755051854393 + ], + [ + "2025-11-24T22:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.7776323743906 + ], + [ + "2025-11-24T22:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.8041189661617 + ], + [ + "2025-11-24T22:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.4093629540557 + ], + [ + "2025-11-24T22:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.4069757397815 + ], + [ + "2025-11-24T22:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.231097457165 + ], + [ + "2025-11-24T22:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.3514234549602 + ], + [ + "2025-11-24T22:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.034632327852 + ], + [ + "2025-11-24T22:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.9020739261664 + ], + [ + "2025-11-24T22:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.1622810391627 + ], + [ + "2025-11-24T22:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.931754961189 + ], + [ + "2025-11-24T22:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.7575472806407 + ], + [ + "2025-11-24T21:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.8920865259497 + ], + [ + "2025-11-24T21:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.7534922796917 + ], + [ + "2025-11-24T21:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.2155241460673 + ], + [ + "2025-11-24T21:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.9759038849966 + ], + [ + "2025-11-24T21:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.893510109461 + ], + [ + "2025-11-24T21:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.7776964143286 + ], + [ + "2025-11-24T21:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2950.7109810987768 + ], + [ + "2025-11-24T21:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.2366115030068 + ], + [ + "2025-11-24T21:44:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.793214161909 + ], + [ + "2025-11-24T21:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.5005169590686 + ], + [ + "2025-11-24T21:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.4639971976176 + ], + [ + "2025-11-24T21:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.09068735751 + ], + [ + "2025-11-24T21:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.5634737986234 + ], + [ + "2025-11-24T21:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.195189584833 + ], + [ + "2025-11-24T21:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.9743188241578 + ], + [ + "2025-11-24T21:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.95884286779 + ], + [ + "2025-11-24T21:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.406704879239 + ], + [ + "2025-11-24T21:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2965.988056765487 + ], + [ + "2025-11-24T21:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.5530031334683 + ], + [ + "2025-11-24T21:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.6905242235543 + ], + [ + "2025-11-24T21:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2963.832359483736 + ], + [ + "2025-11-24T21:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.1039206793935 + ], + [ + "2025-11-24T21:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.1468484813245 + ], + [ + "2025-11-24T21:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.2467558825365 + ], + [ + "2025-11-24T21:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2964.919485868995 + ], + [ + "2025-11-24T21:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.769763987707 + ], + [ + "2025-11-24T21:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.5078947307447 + ], + [ + "2025-11-24T21:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.938856269956 + ], + [ + "2025-11-24T21:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.3997992904688 + ], + [ + "2025-11-24T21:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.4739056252897 + ], + [ + "2025-11-24T20:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2972.219403320872 + ], + [ + "2025-11-24T20:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.2229283072143 + ], + [ + "2025-11-24T20:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2980.0089116287127 + ], + [ + "2025-11-24T20:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.4348423241463 + ], + [ + "2025-11-24T20:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2976.6603113471565 + ], + [ + "2025-11-24T20:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2980.058683053835 + ], + [ + "2025-11-24T20:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2981.375961192756 + ], + [ + "2025-11-24T20:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2978.0135524551697 + ], + [ + "2025-11-24T20:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2977.391994078317 + ], + [ + "2025-11-24T20:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2979.578964781072 + ], + [ + "2025-11-24T20:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2976.6500973886027 + ], + [ + "2025-11-24T20:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.8212039093196 + ], + [ + "2025-11-24T20:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.1852240878375 + ], + [ + "2025-11-24T20:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.9218613653384 + ], + [ + "2025-11-24T20:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2976.386568347793 + ], + [ + "2025-11-24T20:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2976.876799295731 + ], + [ + "2025-11-24T20:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.7500600129715 + ], + [ + "2025-11-24T20:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.6668180789256 + ], + [ + "2025-11-24T20:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2978.4568024393393 + ], + [ + "2025-11-24T20:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2976.8896574339365 + ], + [ + "2025-11-24T20:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2979.978167594805 + ], + [ + "2025-11-24T20:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.804682731531 + ], + [ + "2025-11-24T20:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.6469826264047 + ], + [ + "2025-11-24T20:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2975.0585110576994 + ], + [ + "2025-11-24T20:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.5109261794223 + ], + [ + "2025-11-24T20:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.743063392159 + ], + [ + "2025-11-24T20:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.627251358698 + ], + [ + "2025-11-24T20:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.192799515609 + ], + [ + "2025-11-24T20:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.5418365418072 + ], + [ + "2025-11-24T20:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2962.4026690615337 + ], + [ + "2025-11-24T19:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.6111606119935 + ], + [ + "2025-11-24T19:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.1959749292578 + ], + [ + "2025-11-24T19:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.6912613496675 + ], + [ + "2025-11-24T19:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.9548642217887 + ], + [ + "2025-11-24T19:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2961.8078905788207 + ], + [ + "2025-11-24T19:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2957.033406360468 + ], + [ + "2025-11-24T19:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.987756561174 + ], + [ + "2025-11-24T19:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2958.3365538583807 + ], + [ + "2025-11-24T19:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2959.1483566967627 + ], + [ + "2025-11-24T19:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.563041578135 + ], + [ + "2025-11-24T19:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.527290609369 + ], + [ + "2025-11-24T19:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.6422517442643 + ], + [ + "2025-11-24T19:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.886365206947 + ], + [ + "2025-11-24T19:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.3494422766585 + ], + [ + "2025-11-24T19:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2973.5044654158746 + ], + [ + "2025-11-24T19:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2968.2748815092073 + ], + [ + "2025-11-24T19:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2969.1729832918004 + ], + [ + "2025-11-24T19:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.682016240104 + ], + [ + "2025-11-24T19:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.9066805715906 + ], + [ + "2025-11-24T19:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2971.037823294929 + ], + [ + "2025-11-24T19:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.709351908446 + ], + [ + "2025-11-24T19:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2972.6264497942207 + ], + [ + "2025-11-24T19:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2970.675134717522 + ], + [ + "2025-11-24T19:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2966.8471223087167 + ], + [ + "2025-11-24T19:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.7558774291924 + ], + [ + "2025-11-24T19:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.5898818493097 + ], + [ + "2025-11-24T19:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2974.7082206693076 + ], + [ + "2025-11-24T19:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2967.7496048389826 + ], + [ + "2025-11-24T19:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2960.704076356668 + ], + [ + "2025-11-24T19:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2955.1977251886733 + ], + [ + "2025-11-24T18:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2956.321181417345 + ], + [ + "2025-11-24T18:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.0599894525985 + ], + [ + "2025-11-24T18:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.2793995535553 + ], + [ + "2025-11-24T18:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.579505900239 + ], + [ + "2025-11-24T18:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2954.971019900047 + ], + [ + "2025-11-24T18:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2953.249713393271 + ], + [ + "2025-11-24T18:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.7101018535136 + ], + [ + "2025-11-24T18:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.62622984051 + ], + [ + "2025-11-24T18:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.6508987522957 + ], + [ + "2025-11-24T18:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.2207901365337 + ], + [ + "2025-11-24T18:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.5463125883466 + ], + [ + "2025-11-24T18:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.519757651191 + ], + [ + "2025-11-24T18:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.79456029257 + ], + [ + "2025-11-24T18:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.348777300339 + ], + [ + "2025-11-24T18:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2932.7593685570055 + ], + [ + "2025-11-24T18:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.848851859948 + ], + [ + "2025-11-24T18:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2938.8313314595835 + ], + [ + "2025-11-24T18:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.1247825318883 + ], + [ + "2025-11-24T18:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2940.9036839422747 + ], + [ + "2025-11-24T18:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.302883848422 + ], + [ + "2025-11-24T18:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.3110447768604 + ], + [ + "2025-11-24T18:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2945.635960483933 + ], + [ + "2025-11-24T18:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2947.8568535406066 + ], + [ + "2025-11-24T18:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.794492222735 + ], + [ + "2025-11-24T18:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.160126912523 + ], + [ + "2025-11-24T18:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2948.7559532160876 + ], + [ + "2025-11-24T18:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2944.752282263503 + ], + [ + "2025-11-24T18:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2941.6386475500826 + ], + [ + "2025-11-24T18:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2946.9410975904834 + ], + [ + "2025-11-24T18:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2943.235182433932 + ], + [ + "2025-11-24T17:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2939.8615474651074 + ], + [ + "2025-11-24T17:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2931.956341795544 + ], + [ + "2025-11-24T17:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2930.6336525199 + ], + [ + "2025-11-24T17:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.9661798717875 + ], + [ + "2025-11-24T17:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2925.446549950532 + ], + [ + "2025-11-24T17:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.718149630943 + ], + [ + "2025-11-24T17:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.9661304775295 + ], + [ + "2025-11-24T17:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2922.0613255496814 + ], + [ + "2025-11-24T17:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2921.9551106366703 + ], + [ + "2025-11-24T17:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2927.4056999351233 + ], + [ + "2025-11-24T17:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2923.526751193933 + ], + [ + "2025-11-24T17:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2917.8035502979187 + ], + [ + "2025-11-24T17:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2918.3494306831667 + ], + [ + "2025-11-24T17:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2914.723645129074 + ], + [ + "2025-11-24T17:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2911.758689606823 + ], + [ + "2025-11-24T17:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2913.2928360137876 + ], + [ + "2025-11-24T17:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2909.324700469768 + ], + [ + "2025-11-24T17:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2892.2453244228614 + ], + [ + "2025-11-24T17:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.0362904013955 + ], + [ + "2025-11-24T17:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2873.8940743903736 + ], + [ + "2025-11-24T17:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2866.261121749773 + ], + [ + "2025-11-24T17:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2864.3040125771763 + ], + [ + "2025-11-24T17:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.201344630871 + ], + [ + "2025-11-24T17:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.785537615933 + ], + [ + "2025-11-24T17:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.422749880912 + ], + [ + "2025-11-24T17:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.4908758736524 + ], + [ + "2025-11-24T17:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.599578858636 + ], + [ + "2025-11-24T17:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2863.081069198393 + ], + [ + "2025-11-24T17:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2862.595012318289 + ], + [ + "2025-11-24T17:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2863.7446569424505 + ], + [ + "2025-11-24T16:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.7972570023776 + ], + [ + "2025-11-24T16:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2859.2469685086853 + ], + [ + "2025-11-24T16:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2859.822421727083 + ], + [ + "2025-11-24T16:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2860.3328936724447 + ], + [ + "2025-11-24T16:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2865.340772047467 + ], + [ + "2025-11-24T16:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.210082073674 + ], + [ + "2025-11-24T16:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2866.7478074020223 + ], + [ + "2025-11-24T16:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2863.183612248234 + ], + [ + "2025-11-24T16:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.4296347051427 + ], + [ + "2025-11-24T16:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.769277795648 + ], + [ + "2025-11-24T16:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2851.261157579745 + ], + [ + "2025-11-24T16:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.265351802046 + ], + [ + "2025-11-24T16:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.0470145357376 + ], + [ + "2025-11-24T16:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2847.720088672618 + ], + [ + "2025-11-24T16:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2850.88350213025 + ], + [ + "2025-11-24T16:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2849.874931776676 + ], + [ + "2025-11-24T16:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.450181826349 + ], + [ + "2025-11-24T16:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2849.1060461191937 + ], + [ + "2025-11-24T16:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2848.9062974399353 + ], + [ + "2025-11-24T16:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2849.6932877046215 + ], + [ + "2025-11-24T16:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2847.8421599964254 + ], + [ + "2025-11-24T16:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2843.9994254816024 + ], + [ + "2025-11-24T16:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.3246534123837 + ], + [ + "2025-11-24T16:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.738777034296 + ], + [ + "2025-11-24T16:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.9158817391176 + ], + [ + "2025-11-24T16:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2847.774315133134 + ], + [ + "2025-11-24T16:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2849.694982343296 + ], + [ + "2025-11-24T16:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.143223586264 + ], + [ + "2025-11-24T16:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.707799439013 + ], + [ + "2025-11-24T16:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.724449269404 + ], + [ + "2025-11-24T15:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.109595450076 + ], + [ + "2025-11-24T15:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.5971026283514 + ], + [ + "2025-11-24T15:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.677795173792 + ], + [ + "2025-11-24T15:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.0356132894603 + ], + [ + "2025-11-24T15:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2850.3940067497415 + ], + [ + "2025-11-24T15:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.697563708565 + ], + [ + "2025-11-24T15:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2850.4442082881433 + ], + [ + "2025-11-24T15:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2851.910773614806 + ], + [ + "2025-11-24T15:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.922968356743 + ], + [ + "2025-11-24T15:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2843.2126828566065 + ], + [ + "2025-11-24T15:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.000629224491 + ], + [ + "2025-11-24T15:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.5860555632603 + ], + [ + "2025-11-24T15:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.2490061756826 + ], + [ + "2025-11-24T15:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.1884448581095 + ], + [ + "2025-11-24T15:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.4455541719403 + ], + [ + "2025-11-24T15:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.0883120759804 + ], + [ + "2025-11-24T15:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.7342201359 + ], + [ + "2025-11-24T15:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.7215301526066 + ], + [ + "2025-11-24T15:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.8751968757306 + ], + [ + "2025-11-24T15:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.9349012045914 + ], + [ + "2025-11-24T15:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.062909418763 + ], + [ + "2025-11-24T15:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.7186861832492 + ], + [ + "2025-11-24T15:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.0777805488706 + ], + [ + "2025-11-24T15:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.348578215394 + ], + [ + "2025-11-24T15:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.9244668947586 + ], + [ + "2025-11-24T15:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.685748557931 + ], + [ + "2025-11-24T15:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.9875097227764 + ], + [ + "2025-11-24T15:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.774821701471 + ], + [ + "2025-11-24T15:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.2619101187433 + ], + [ + "2025-11-24T15:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.7030548872985 + ], + [ + "2025-11-24T14:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.0244834076225 + ], + [ + "2025-11-24T14:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.4464648044836 + ], + [ + "2025-11-24T14:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.6484478759944 + ], + [ + "2025-11-24T14:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.941748267748 + ], + [ + "2025-11-24T14:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.3396464397183 + ], + [ + "2025-11-24T14:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.9817176873767 + ], + [ + "2025-11-24T14:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.7796983292774 + ], + [ + "2025-11-24T14:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.1504712274113 + ], + [ + "2025-11-24T14:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.2520092764116 + ], + [ + "2025-11-24T14:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.352167870544 + ], + [ + "2025-11-24T14:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.726726145581 + ], + [ + "2025-11-24T14:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.2718494323476 + ], + [ + "2025-11-24T14:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.481152219592 + ], + [ + "2025-11-24T14:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.7201075475773 + ], + [ + "2025-11-24T14:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.977512314869 + ], + [ + "2025-11-24T14:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.2435886416324 + ], + [ + "2025-11-24T14:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.7944269939135 + ], + [ + "2025-11-24T14:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.92160566448 + ], + [ + "2025-11-24T14:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.5436288476653 + ], + [ + "2025-11-24T14:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.692272996374 + ], + [ + "2025-11-24T14:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.611608679754 + ], + [ + "2025-11-24T14:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.276974236058 + ], + [ + "2025-11-24T14:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.9883508691687 + ], + [ + "2025-11-24T14:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.6531849652283 + ], + [ + "2025-11-24T14:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.1961250366285 + ], + [ + "2025-11-24T14:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.1951476467602 + ], + [ + "2025-11-24T14:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.5120184621965 + ], + [ + "2025-11-24T14:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.8353565038 + ], + [ + "2025-11-24T14:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.413247564516 + ], + [ + "2025-11-24T13:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.3965608288404 + ], + [ + "2025-11-24T13:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.678399834287 + ], + [ + "2025-11-24T13:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.9238273513956 + ], + [ + "2025-11-24T13:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.4898959737134 + ], + [ + "2025-11-24T13:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.0201568032503 + ], + [ + "2025-11-24T13:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.4359797305174 + ], + [ + "2025-11-24T13:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.889635356269 + ], + [ + "2025-11-24T13:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.9424184919176 + ], + [ + "2025-11-24T13:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.6348046773232 + ], + [ + "2025-11-24T13:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.882041067516 + ], + [ + "2025-11-24T13:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.1290856648397 + ], + [ + "2025-11-24T13:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.5771116112182 + ], + [ + "2025-11-24T13:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.949416943276 + ], + [ + "2025-11-24T13:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.1460057435656 + ], + [ + "2025-11-24T13:32:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.083559052442 + ], + [ + "2025-11-24T13:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.2827625980044 + ], + [ + "2025-11-24T13:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.484247446253 + ], + [ + "2025-11-24T13:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.9423683071595 + ], + [ + "2025-11-24T13:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.2668455005855 + ], + [ + "2025-11-24T13:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.835913202656 + ], + [ + "2025-11-24T13:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.1180244395573 + ], + [ + "2025-11-24T13:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.3005169684557 + ], + [ + "2025-11-24T13:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.3016730884788 + ], + [ + "2025-11-24T13:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.587727454975 + ], + [ + "2025-11-24T13:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.4011717345643 + ], + [ + "2025-11-24T13:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.4369464544175 + ], + [ + "2025-11-24T13:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.16064994642 + ], + [ + "2025-11-24T13:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.179628718178 + ], + [ + "2025-11-24T13:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.065329212668 + ], + [ + "2025-11-24T13:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.2634676568573 + ], + [ + "2025-11-24T12:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.1495801224773 + ], + [ + "2025-11-24T12:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.9489489282496 + ], + [ + "2025-11-24T12:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.9171094338394 + ], + [ + "2025-11-24T12:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.329531956624 + ], + [ + "2025-11-24T12:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.378938503706 + ], + [ + "2025-11-24T12:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.951952719188 + ], + [ + "2025-11-24T12:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.4237387771796 + ], + [ + "2025-11-24T12:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.0161501729 + ], + [ + "2025-11-24T12:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.5484269538574 + ], + [ + "2025-11-24T12:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.0180423707675 + ], + [ + "2025-11-24T12:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.178546855289 + ], + [ + "2025-11-24T12:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.7217325624356 + ], + [ + "2025-11-24T12:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.152026425622 + ], + [ + "2025-11-24T12:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.7857156700975 + ], + [ + "2025-11-24T12:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.6480084033083 + ], + [ + "2025-11-24T12:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.0453579752007 + ], + [ + "2025-11-24T12:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.724732960827 + ], + [ + "2025-11-24T12:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.759723847959 + ], + [ + "2025-11-24T12:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.4315393115508 + ], + [ + "2025-11-24T12:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.73902638824 + ], + [ + "2025-11-24T12:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.0148342823136 + ], + [ + "2025-11-24T12:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.5348112779348 + ], + [ + "2025-11-24T12:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.2164739477616 + ], + [ + "2025-11-24T12:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.202552673174 + ], + [ + "2025-11-24T12:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.603710946968 + ], + [ + "2025-11-24T12:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.6506617703003 + ], + [ + "2025-11-24T12:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.433294987948 + ], + [ + "2025-11-24T12:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.0672431642442 + ], + [ + "2025-11-24T12:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.1777607646773 + ], + [ + "2025-11-24T12:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.7528986466496 + ], + [ + "2025-11-24T11:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.992908198734 + ], + [ + "2025-11-24T11:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.576423820639 + ], + [ + "2025-11-24T11:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.160333955923 + ], + [ + "2025-11-24T11:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.910175230218 + ], + [ + "2025-11-24T11:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.013912642675 + ], + [ + "2025-11-24T11:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.141161348198 + ], + [ + "2025-11-24T11:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.32172532634 + ], + [ + "2025-11-24T11:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.3169683164942 + ], + [ + "2025-11-24T11:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.127535869936 + ], + [ + "2025-11-24T11:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.3761792992664 + ], + [ + "2025-11-24T11:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.371061654692 + ], + [ + "2025-11-24T11:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.191121256055 + ], + [ + "2025-11-24T11:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.5183803038153 + ], + [ + "2025-11-24T11:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.2286028491926 + ], + [ + "2025-11-24T11:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.112421945256 + ], + [ + "2025-11-24T11:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.827125337616 + ], + [ + "2025-11-24T11:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.140487292319 + ], + [ + "2025-11-24T11:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.4079723668256 + ], + [ + "2025-11-24T11:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.553327905876 + ], + [ + "2025-11-24T11:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.5427250677026 + ], + [ + "2025-11-24T11:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.0149304071283 + ], + [ + "2025-11-24T11:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.2498482877254 + ], + [ + "2025-11-24T11:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.1515012770615 + ], + [ + "2025-11-24T11:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.6025559326004 + ], + [ + "2025-11-24T11:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.794869109491 + ], + [ + "2025-11-24T11:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.9412709886105 + ], + [ + "2025-11-24T11:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.761734203763 + ], + [ + "2025-11-24T11:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.4689136879942 + ], + [ + "2025-11-24T11:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.725871218954 + ], + [ + "2025-11-24T11:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.4593599494538 + ], + [ + "2025-11-24T10:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.504842360324 + ], + [ + "2025-11-24T10:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.99439479524 + ], + [ + "2025-11-24T10:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.55767265255 + ], + [ + "2025-11-24T10:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.5764680973707 + ], + [ + "2025-11-24T10:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.504020522216 + ], + [ + "2025-11-24T10:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.41409391772 + ], + [ + "2025-11-24T10:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.3071398959205 + ], + [ + "2025-11-24T10:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.482886086873 + ], + [ + "2025-11-24T10:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.7623456718243 + ], + [ + "2025-11-24T10:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.7451578159585 + ], + [ + "2025-11-24T10:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.3830775472256 + ], + [ + "2025-11-24T10:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.225760042655 + ], + [ + "2025-11-24T10:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.097139510031 + ], + [ + "2025-11-24T10:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.0828538417645 + ], + [ + "2025-11-24T10:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.012793086537 + ], + [ + "2025-11-24T10:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.1149237832974 + ], + [ + "2025-11-24T10:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.412276753662 + ], + [ + "2025-11-24T10:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.1174025806654 + ], + [ + "2025-11-24T10:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.1275891092905 + ], + [ + "2025-11-24T10:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.248325517741 + ], + [ + "2025-11-24T10:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.5963958602156 + ], + [ + "2025-11-24T10:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.5812041437366 + ], + [ + "2025-11-24T10:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.8564661422406 + ], + [ + "2025-11-24T10:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.0120324786453 + ], + [ + "2025-11-24T10:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.633247275313 + ], + [ + "2025-11-24T10:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.7635556804917 + ], + [ + "2025-11-24T10:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.8260323258473 + ], + [ + "2025-11-24T10:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.810219827445 + ], + [ + "2025-11-24T10:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.808383420192 + ], + [ + "2025-11-24T10:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.169339196292 + ], + [ + "2025-11-24T09:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.1858187525377 + ], + [ + "2025-11-24T09:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.7206975470126 + ], + [ + "2025-11-24T09:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.979166089721 + ], + [ + "2025-11-24T09:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.306765195641 + ], + [ + "2025-11-24T09:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.77853865985 + ], + [ + "2025-11-24T09:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.261005041267 + ], + [ + "2025-11-24T09:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.0752889128617 + ], + [ + "2025-11-24T09:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.2913073134628 + ], + [ + "2025-11-24T09:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.205222130654 + ], + [ + "2025-11-24T09:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.6887872901907 + ], + [ + "2025-11-24T09:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.3435393902837 + ], + [ + "2025-11-24T09:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.9887778634366 + ], + [ + "2025-11-24T09:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.919134928984 + ], + [ + "2025-11-24T09:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.013986437111 + ], + [ + "2025-11-24T09:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.27255779142 + ], + [ + "2025-11-24T09:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.880714093113 + ], + [ + "2025-11-24T09:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.8830873138854 + ], + [ + "2025-11-24T09:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.2747099760095 + ], + [ + "2025-11-24T09:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.525126609474 + ], + [ + "2025-11-24T09:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.169338801228 + ], + [ + "2025-11-24T09:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.715854158826 + ], + [ + "2025-11-24T09:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.1039028540513 + ], + [ + "2025-11-24T09:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.665765891389 + ], + [ + "2025-11-24T09:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.75886027284 + ], + [ + "2025-11-24T09:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.3170262723866 + ], + [ + "2025-11-24T09:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.1049660367426 + ], + [ + "2025-11-24T09:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.8927314237762 + ], + [ + "2025-11-24T09:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.4170331680793 + ], + [ + "2025-11-24T09:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.632799004028 + ], + [ + "2025-11-24T09:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.6079916422805 + ], + [ + "2025-11-24T08:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.6289809698724 + ], + [ + "2025-11-24T08:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.9846084045225 + ], + [ + "2025-11-24T08:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.9444042535806 + ], + [ + "2025-11-24T08:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.979455440244 + ], + [ + "2025-11-24T08:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.6472312840965 + ], + [ + "2025-11-24T08:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.157663992501 + ], + [ + "2025-11-24T08:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.275583202913 + ], + [ + "2025-11-24T08:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.2932099859026 + ], + [ + "2025-11-24T08:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.273299226159 + ], + [ + "2025-11-24T08:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.5523399525187 + ], + [ + "2025-11-24T08:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.0078294348646 + ], + [ + "2025-11-24T08:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.9332792133537 + ], + [ + "2025-11-24T08:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.7119015008025 + ], + [ + "2025-11-24T08:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.1761116039997 + ], + [ + "2025-11-24T08:32:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.811969867441 + ], + [ + "2025-11-24T08:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.876338827851 + ], + [ + "2025-11-24T08:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.1384785806363 + ], + [ + "2025-11-24T08:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.485562010504 + ], + [ + "2025-11-24T08:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.763041504133 + ], + [ + "2025-11-24T08:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.839252519545 + ], + [ + "2025-11-24T08:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.14531817655 + ], + [ + "2025-11-24T08:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.902172084506 + ], + [ + "2025-11-24T08:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.001364583275 + ], + [ + "2025-11-24T08:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.6903445872526 + ], + [ + "2025-11-24T08:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.0917406116428 + ], + [ + "2025-11-24T08:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.709714602877 + ], + [ + "2025-11-24T08:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.110838570415 + ], + [ + "2025-11-24T08:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.6046940021547 + ], + [ + "2025-11-24T08:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.286520471068 + ], + [ + "2025-11-24T08:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.486784884906 + ], + [ + "2025-11-24T07:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.0860246512225 + ], + [ + "2025-11-24T07:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.9949747223386 + ], + [ + "2025-11-24T07:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.1720527779075 + ], + [ + "2025-11-24T07:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.001493019861 + ], + [ + "2025-11-24T07:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.45980079259 + ], + [ + "2025-11-24T07:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.586381324959 + ], + [ + "2025-11-24T07:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.075452354635 + ], + [ + "2025-11-24T07:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2843.3741446454696 + ], + [ + "2025-11-24T07:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.410106275801 + ], + [ + "2025-11-24T07:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.5212648770125 + ], + [ + "2025-11-24T07:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.21190057056 + ], + [ + "2025-11-24T07:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.4968113273517 + ], + [ + "2025-11-24T07:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.3392540296886 + ], + [ + "2025-11-24T07:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.438547616935 + ], + [ + "2025-11-24T07:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.339549812489 + ], + [ + "2025-11-24T07:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.021620409026 + ], + [ + "2025-11-24T07:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.3559365979972 + ], + [ + "2025-11-24T07:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.6139895295823 + ], + [ + "2025-11-24T07:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.387297411296 + ], + [ + "2025-11-24T07:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.987003092427 + ], + [ + "2025-11-24T07:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.971457162536 + ], + [ + "2025-11-24T07:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.019708921531 + ], + [ + "2025-11-24T07:15:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.8772163905587 + ], + [ + "2025-11-24T07:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.243234013873 + ], + [ + "2025-11-24T07:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.7280963441667 + ], + [ + "2025-11-24T07:10:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.986122623993 + ], + [ + "2025-11-24T07:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.286023765252 + ], + [ + "2025-11-24T07:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.9499021980614 + ], + [ + "2025-11-24T07:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.161523514845 + ], + [ + "2025-11-24T07:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.4164452711584 + ], + [ + "2025-11-24T06:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.265876524305 + ], + [ + "2025-11-24T06:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.1961061885186 + ], + [ + "2025-11-24T06:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.676202291254 + ], + [ + "2025-11-24T06:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.3516317232884 + ], + [ + "2025-11-24T06:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.6733950386483 + ], + [ + "2025-11-24T06:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.0785024625948 + ], + [ + "2025-11-24T06:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.0609575513513 + ], + [ + "2025-11-24T06:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.095734291567 + ], + [ + "2025-11-24T06:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.8210853184 + ], + [ + "2025-11-24T06:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.345118880038 + ], + [ + "2025-11-24T06:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.245183312753 + ], + [ + "2025-11-24T06:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.0443839065697 + ], + [ + "2025-11-24T06:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.4100276409413 + ], + [ + "2025-11-24T06:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.075343785424 + ], + [ + "2025-11-24T06:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.669921472923 + ], + [ + "2025-11-24T06:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.7193182057154 + ], + [ + "2025-11-24T06:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.015869759275 + ], + [ + "2025-11-24T06:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.0408729484902 + ], + [ + "2025-11-24T06:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.6106536647885 + ], + [ + "2025-11-24T06:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.025680939299 + ], + [ + "2025-11-24T06:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2847.1655930480856 + ], + [ + "2025-11-24T06:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.433619373823 + ], + [ + "2025-11-24T06:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.172262029406 + ], + [ + "2025-11-24T06:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.022509199449 + ], + [ + "2025-11-24T06:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.364435619939 + ], + [ + "2025-11-24T06:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.0078165342875 + ], + [ + "2025-11-24T06:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.7309472686065 + ], + [ + "2025-11-24T06:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.2372943839337 + ], + [ + "2025-11-24T06:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2862.9788342317206 + ], + [ + "2025-11-24T06:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2864.9482809890123 + ], + [ + "2025-11-24T05:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2866.001162358664 + ], + [ + "2025-11-24T05:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2865.915757903727 + ], + [ + "2025-11-24T05:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2864.9927329765956 + ], + [ + "2025-11-24T05:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2866.436531717957 + ], + [ + "2025-11-24T05:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2863.4608655200987 + ], + [ + "2025-11-24T05:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2864.566579501286 + ], + [ + "2025-11-24T05:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2867.082006566318 + ], + [ + "2025-11-24T05:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.9070249940046 + ], + [ + "2025-11-24T05:44:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.8541091323136 + ], + [ + "2025-11-24T05:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2873.822901595713 + ], + [ + "2025-11-24T05:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2869.9535476653173 + ], + [ + "2025-11-24T05:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2867.3805339406904 + ], + [ + "2025-11-24T05:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2864.3554044136727 + ], + [ + "2025-11-24T05:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.6242286886904 + ], + [ + "2025-11-24T05:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2870.93154656184 + ], + [ + "2025-11-24T05:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2878.7798663547724 + ], + [ + "2025-11-24T05:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2882.1960708765387 + ], + [ + "2025-11-24T05:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.083064719835 + ], + [ + "2025-11-24T05:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.5620492312255 + ], + [ + "2025-11-24T05:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2882.0521735382804 + ], + [ + "2025-11-24T05:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2877.393243885741 + ], + [ + "2025-11-24T05:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2878.9335515793828 + ], + [ + "2025-11-24T05:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2880.708094891304 + ], + [ + "2025-11-24T05:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2881.1342939375972 + ], + [ + "2025-11-24T05:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2875.787178971778 + ], + [ + "2025-11-24T05:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2863.919779299185 + ], + [ + "2025-11-24T05:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.6407887475825 + ], + [ + "2025-11-24T05:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.6951493356837 + ], + [ + "2025-11-24T05:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.8978519912407 + ], + [ + "2025-11-24T05:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.3213786123383 + ], + [ + "2025-11-24T04:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.1731057947045 + ], + [ + "2025-11-24T04:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.3259710339044 + ], + [ + "2025-11-24T04:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.9930095290647 + ], + [ + "2025-11-24T04:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.8837550208145 + ], + [ + "2025-11-24T04:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.8500853204464 + ], + [ + "2025-11-24T04:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.682659340777 + ], + [ + "2025-11-24T04:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.402599464074 + ], + [ + "2025-11-24T04:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.9109333570595 + ], + [ + "2025-11-24T04:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.382162572806 + ], + [ + "2025-11-24T04:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.044029707909 + ], + [ + "2025-11-24T04:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.789341658479 + ], + [ + "2025-11-24T04:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.6403683190238 + ], + [ + "2025-11-24T04:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.606258352088 + ], + [ + "2025-11-24T04:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.6553522878917 + ], + [ + "2025-11-24T04:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.7097692431553 + ], + [ + "2025-11-24T04:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.4961153734903 + ], + [ + "2025-11-24T04:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.1477698089457 + ], + [ + "2025-11-24T04:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.523431615333 + ], + [ + "2025-11-24T04:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.6888678774035 + ], + [ + "2025-11-24T04:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.870501379858 + ], + [ + "2025-11-24T04:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.3883356614706 + ], + [ + "2025-11-24T04:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.6748682410494 + ], + [ + "2025-11-24T04:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.258752714035 + ], + [ + "2025-11-24T04:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2868.1071811262887 + ], + [ + "2025-11-24T04:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2863.7954599664827 + ], + [ + "2025-11-24T04:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2860.017670645689 + ], + [ + "2025-11-24T04:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.519687720347 + ], + [ + "2025-11-24T04:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.60844670727 + ], + [ + "2025-11-24T04:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2854.998507619734 + ], + [ + "2025-11-24T04:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.6203480259915 + ], + [ + "2025-11-24T03:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.7085628006553 + ], + [ + "2025-11-24T03:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.4617463003256 + ], + [ + "2025-11-24T03:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.72965034127 + ], + [ + "2025-11-24T03:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.397361887044 + ], + [ + "2025-11-24T03:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.5218388606027 + ], + [ + "2025-11-24T03:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.3393995704164 + ], + [ + "2025-11-24T03:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.7478424037827 + ], + [ + "2025-11-24T03:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.2423028642606 + ], + [ + "2025-11-24T03:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.3441876583825 + ], + [ + "2025-11-24T03:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.2646574248533 + ], + [ + "2025-11-24T03:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2854.7261683165657 + ], + [ + "2025-11-24T03:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.1824760625395 + ], + [ + "2025-11-24T03:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2862.567428670513 + ], + [ + "2025-11-24T03:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2862.586791090011 + ], + [ + "2025-11-24T03:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.8889190963637 + ], + [ + "2025-11-24T03:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2857.536433755234 + ], + [ + "2025-11-24T03:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.9108101057327 + ], + [ + "2025-11-24T03:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.7109670066698 + ], + [ + "2025-11-24T03:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2860.1109484547087 + ], + [ + "2025-11-24T03:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2861.030075128435 + ], + [ + "2025-11-24T03:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.684787142152 + ], + [ + "2025-11-24T03:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2843.375647766022 + ], + [ + "2025-11-24T03:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.6050353807354 + ], + [ + "2025-11-24T03:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.132781253603 + ], + [ + "2025-11-24T03:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.97761140344 + ], + [ + "2025-11-24T03:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.6937910048714 + ], + [ + "2025-11-24T03:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.232233655028 + ], + [ + "2025-11-24T03:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.498555219249 + ], + [ + "2025-11-24T03:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.043073101256 + ], + [ + "2025-11-24T03:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.950427430574 + ], + [ + "2025-11-24T02:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.5812128646257 + ], + [ + "2025-11-24T02:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.6581796302166 + ], + [ + "2025-11-24T02:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.859465598572 + ], + [ + "2025-11-24T02:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.2524471072124 + ], + [ + "2025-11-24T02:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.404473639135 + ], + [ + "2025-11-24T02:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.5016823752726 + ], + [ + "2025-11-24T02:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.5472847236765 + ], + [ + "2025-11-24T02:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.6062841159187 + ], + [ + "2025-11-24T02:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.4006458735716 + ], + [ + "2025-11-24T02:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.578617701187 + ], + [ + "2025-11-24T02:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.758861449977 + ], + [ + "2025-11-24T02:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.2999462166367 + ], + [ + "2025-11-24T02:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.6704272329575 + ], + [ + "2025-11-24T02:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2852.793682294398 + ], + [ + "2025-11-24T02:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.1037484830795 + ], + [ + "2025-11-24T02:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.609572521522 + ], + [ + "2025-11-24T02:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2856.7663088062804 + ], + [ + "2025-11-24T02:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2861.687832148089 + ], + [ + "2025-11-24T02:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2859.008074961798 + ], + [ + "2025-11-24T02:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2861.4616237360688 + ], + [ + "2025-11-24T02:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2861.761994833643 + ], + [ + "2025-11-24T02:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2860.2267543221083 + ], + [ + "2025-11-24T02:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2858.4714958893496 + ], + [ + "2025-11-24T02:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2853.750441670302 + ], + [ + "2025-11-24T02:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2851.890252124752 + ], + [ + "2025-11-24T02:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2846.558694013556 + ], + [ + "2025-11-24T02:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.2693906385184 + ], + [ + "2025-11-24T02:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.2898841113984 + ], + [ + "2025-11-24T02:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.599567347711 + ], + [ + "2025-11-24T02:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.255041924752 + ], + [ + "2025-11-24T01:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.583032065395 + ], + [ + "2025-11-24T01:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.198216410052 + ], + [ + "2025-11-24T01:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.768245521999 + ], + [ + "2025-11-24T01:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.7865905503872 + ], + [ + "2025-11-24T01:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.581448846857 + ], + [ + "2025-11-24T01:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.645655862826 + ], + [ + "2025-11-24T01:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.0942622167395 + ], + [ + "2025-11-24T01:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.04903442261 + ], + [ + "2025-11-24T01:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.0537007441562 + ], + [ + "2025-11-24T01:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2786.5349090167106 + ], + [ + "2025-11-24T01:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.4415191125013 + ], + [ + "2025-11-24T01:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.9883161051184 + ], + [ + "2025-11-24T01:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.5638380871114 + ], + [ + "2025-11-24T01:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.0791271335133 + ], + [ + "2025-11-24T01:32:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2788.332656523004 + ], + [ + "2025-11-24T01:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.7989337461154 + ], + [ + "2025-11-24T01:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.391490987516 + ], + [ + "2025-11-24T01:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2786.560735666073 + ], + [ + "2025-11-24T01:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.641401217804 + ], + [ + "2025-11-24T01:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.1305753396873 + ], + [ + "2025-11-24T01:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.4516090123584 + ], + [ + "2025-11-24T01:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.7853990208096 + ], + [ + "2025-11-24T01:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.288292661701 + ], + [ + "2025-11-24T01:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.87612783259 + ], + [ + "2025-11-24T01:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.244657981129 + ], + [ + "2025-11-24T01:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.7140116343076 + ], + [ + "2025-11-24T01:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.50074728733 + ], + [ + "2025-11-24T01:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.4435149819965 + ], + [ + "2025-11-24T01:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.5351912919455 + ], + [ + "2025-11-24T01:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.6697964227574 + ], + [ + "2025-11-24T00:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.3991276925162 + ], + [ + "2025-11-24T00:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.184496039506 + ], + [ + "2025-11-24T00:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.1255520542004 + ], + [ + "2025-11-24T00:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.656943228843 + ], + [ + "2025-11-24T00:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.109320837387 + ], + [ + "2025-11-24T00:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.105795295864 + ], + [ + "2025-11-24T00:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2786.3394745271107 + ], + [ + "2025-11-24T00:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.653202107151 + ], + [ + "2025-11-24T00:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.9939697769605 + ], + [ + "2025-11-24T00:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2792.3220120392234 + ], + [ + "2025-11-24T00:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.0449314674156 + ], + [ + "2025-11-24T00:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2788.6508336624966 + ], + [ + "2025-11-24T00:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.6768893357976 + ], + [ + "2025-11-24T00:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2779.7606050502436 + ], + [ + "2025-11-24T00:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2779.9002438316606 + ], + [ + "2025-11-24T00:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.390639028626 + ], + [ + "2025-11-24T00:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.1182397694033 + ], + [ + "2025-11-24T00:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2777.0014866003817 + ], + [ + "2025-11-24T00:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2771.5094628475435 + ], + [ + "2025-11-24T00:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2766.35104893 + ], + [ + "2025-11-24T00:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.3834264574925 + ], + [ + "2025-11-24T00:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2777.9588496300235 + ], + [ + "2025-11-24T00:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2778.2054337263053 + ], + [ + "2025-11-24T00:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.4650408575276 + ], + [ + "2025-11-24T00:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.32362783889 + ], + [ + "2025-11-24T00:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.617159360575 + ], + [ + "2025-11-24T00:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.3047119430557 + ], + [ + "2025-11-24T00:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2788.5298729855426 + ], + [ + "2025-11-24T00:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.70280741913 + ], + [ + "2025-11-24T00:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.783002464827 + ], + [ + "2025-11-23T23:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.5141364778983 + ], + [ + "2025-11-23T23:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.540900696033 + ], + [ + "2025-11-23T23:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.5182139054973 + ], + [ + "2025-11-23T23:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.2282713147597 + ], + [ + "2025-11-23T23:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.0256191855947 + ], + [ + "2025-11-23T23:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.2225551553643 + ], + [ + "2025-11-23T23:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.007065875865 + ], + [ + "2025-11-23T23:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.6670532441185 + ], + [ + "2025-11-23T23:44:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.0810007042774 + ], + [ + "2025-11-23T23:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.5541592247573 + ], + [ + "2025-11-23T23:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.303596971007 + ], + [ + "2025-11-23T23:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.4040123875966 + ], + [ + "2025-11-23T23:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.5715466526135 + ], + [ + "2025-11-23T23:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.6246779045773 + ], + [ + "2025-11-23T23:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.295891578169 + ], + [ + "2025-11-23T23:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.1388028481065 + ], + [ + "2025-11-23T23:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.931104971849 + ], + [ + "2025-11-23T23:26:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.4279378931897 + ], + [ + "2025-11-23T23:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.028950859129 + ], + [ + "2025-11-23T23:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.383355153614 + ], + [ + "2025-11-23T23:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.3675810319164 + ], + [ + "2025-11-23T23:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.163355572044 + ], + [ + "2025-11-23T23:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.500215699899 + ], + [ + "2025-11-23T23:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.682885934961 + ], + [ + "2025-11-23T23:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.8279066509695 + ], + [ + "2025-11-23T23:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.534448004924 + ], + [ + "2025-11-23T23:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.1009015680465 + ], + [ + "2025-11-23T23:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.300526508474 + ], + [ + "2025-11-23T23:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.2555731561642 + ], + [ + "2025-11-23T23:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.4114635740584 + ], + [ + "2025-11-23T22:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.4865873399153 + ], + [ + "2025-11-23T22:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.6213913406805 + ], + [ + "2025-11-23T22:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.582778428403 + ], + [ + "2025-11-23T22:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.1071686942064 + ], + [ + "2025-11-23T22:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.585142349418 + ], + [ + "2025-11-23T22:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.686571082753 + ], + [ + "2025-11-23T22:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.885853411046 + ], + [ + "2025-11-23T22:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.85939900461 + ], + [ + "2025-11-23T22:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.595055884048 + ], + [ + "2025-11-23T22:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.167927392373 + ], + [ + "2025-11-23T22:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.8633831197985 + ], + [ + "2025-11-23T22:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.756552092606 + ], + [ + "2025-11-23T22:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.9382850664724 + ], + [ + "2025-11-23T22:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.711892357617 + ], + [ + "2025-11-23T22:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.998307215859 + ], + [ + "2025-11-23T22:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.683912066417 + ], + [ + "2025-11-23T22:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.646504672991 + ], + [ + "2025-11-23T22:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.7886024523423 + ], + [ + "2025-11-23T22:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.6674454253066 + ], + [ + "2025-11-23T22:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.3490618040296 + ], + [ + "2025-11-23T22:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.8454817430325 + ], + [ + "2025-11-23T22:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.9393269559678 + ], + [ + "2025-11-23T22:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.995078963878 + ], + [ + "2025-11-23T22:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.2968230471256 + ], + [ + "2025-11-23T22:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.1562045075075 + ], + [ + "2025-11-23T22:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.740670098427 + ], + [ + "2025-11-23T22:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.1885941982982 + ], + [ + "2025-11-23T22:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.1240565846915 + ], + [ + "2025-11-23T22:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.584264007418 + ], + [ + "2025-11-23T22:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.4054507520787 + ], + [ + "2025-11-23T21:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.2959906946635 + ], + [ + "2025-11-23T21:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2843.752077356975 + ], + [ + "2025-11-23T21:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.7874141928805 + ], + [ + "2025-11-23T21:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.3911698043953 + ], + [ + "2025-11-23T21:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.9765650391732 + ], + [ + "2025-11-23T21:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.7223408430805 + ], + [ + "2025-11-23T21:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.840538577572 + ], + [ + "2025-11-23T21:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.2742559840462 + ], + [ + "2025-11-23T21:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.899543701218 + ], + [ + "2025-11-23T21:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.1009521948436 + ], + [ + "2025-11-23T21:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.49539575158 + ], + [ + "2025-11-23T21:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.735658737843 + ], + [ + "2025-11-23T21:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.578780861324 + ], + [ + "2025-11-23T21:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.737662191786 + ], + [ + "2025-11-23T21:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.817237595275 + ], + [ + "2025-11-23T21:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.3515976488857 + ], + [ + "2025-11-23T21:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.2621349374604 + ], + [ + "2025-11-23T21:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.4523628159936 + ], + [ + "2025-11-23T21:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.055780299179 + ], + [ + "2025-11-23T21:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.347602015809 + ], + [ + "2025-11-23T21:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.991157794401 + ], + [ + "2025-11-23T21:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.39038546379 + ], + [ + "2025-11-23T21:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.3835062990406 + ], + [ + "2025-11-23T21:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.4863304654305 + ], + [ + "2025-11-23T21:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.448909588657 + ], + [ + "2025-11-23T21:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.3920322575746 + ], + [ + "2025-11-23T21:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.9892890009883 + ], + [ + "2025-11-23T21:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.1620455097463 + ], + [ + "2025-11-23T21:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.334889698061 + ], + [ + "2025-11-23T21:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.564780811827 + ], + [ + "2025-11-23T20:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.4688061503207 + ], + [ + "2025-11-23T20:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.0203736906205 + ], + [ + "2025-11-23T20:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.9485462282246 + ], + [ + "2025-11-23T20:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.9083771028254 + ], + [ + "2025-11-23T20:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.5962216183725 + ], + [ + "2025-11-23T20:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.840334458068 + ], + [ + "2025-11-23T20:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.711742027871 + ], + [ + "2025-11-23T20:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.794346035183 + ], + [ + "2025-11-23T20:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.0026204305386 + ], + [ + "2025-11-23T20:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.235498036752 + ], + [ + "2025-11-23T20:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.395493800654 + ], + [ + "2025-11-23T20:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.361746821338 + ], + [ + "2025-11-23T20:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.1301169671906 + ], + [ + "2025-11-23T20:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.7120824250787 + ], + [ + "2025-11-23T20:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.076137603509 + ], + [ + "2025-11-23T20:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.6109171905246 + ], + [ + "2025-11-23T20:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.325504609311 + ], + [ + "2025-11-23T20:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.361988799767 + ], + [ + "2025-11-23T20:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.1435703932 + ], + [ + "2025-11-23T20:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.2168629884313 + ], + [ + "2025-11-23T20:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.6871180853027 + ], + [ + "2025-11-23T20:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.1232121486214 + ], + [ + "2025-11-23T20:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.9752617707527 + ], + [ + "2025-11-23T20:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.2565403278118 + ], + [ + "2025-11-23T20:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.8773306685157 + ], + [ + "2025-11-23T20:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.7880852708554 + ], + [ + "2025-11-23T20:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.060612922841 + ], + [ + "2025-11-23T20:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.201149669178 + ], + [ + "2025-11-23T20:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.4170771699187 + ], + [ + "2025-11-23T20:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.8594554149404 + ], + [ + "2025-11-23T19:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.561777103808 + ], + [ + "2025-11-23T19:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.369161842805 + ], + [ + "2025-11-23T19:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.4480908005585 + ], + [ + "2025-11-23T19:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.9004287496523 + ], + [ + "2025-11-23T19:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.433612105265 + ], + [ + "2025-11-23T19:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.1420977656794 + ], + [ + "2025-11-23T19:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.4563979621225 + ], + [ + "2025-11-23T19:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.3333139565466 + ], + [ + "2025-11-23T19:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.864652398292 + ], + [ + "2025-11-23T19:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.7219080490127 + ], + [ + "2025-11-23T19:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.0513085364337 + ], + [ + "2025-11-23T19:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.0127932992527 + ], + [ + "2025-11-23T19:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.865442976775 + ], + [ + "2025-11-23T19:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.909546202483 + ], + [ + "2025-11-23T19:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.1838958937387 + ], + [ + "2025-11-23T19:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.59333743371 + ], + [ + "2025-11-23T19:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.921239561801 + ], + [ + "2025-11-23T19:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.4785272430786 + ], + [ + "2025-11-23T19:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.1123583756844 + ], + [ + "2025-11-23T19:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.677858168582 + ], + [ + "2025-11-23T19:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.173742752725 + ], + [ + "2025-11-23T19:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.973559595566 + ], + [ + "2025-11-23T19:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.2801608433824 + ], + [ + "2025-11-23T19:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.4889074854304 + ], + [ + "2025-11-23T19:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.647788654286 + ], + [ + "2025-11-23T19:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.6902360224694 + ], + [ + "2025-11-23T19:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.641665059359 + ], + [ + "2025-11-23T19:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.9005562733923 + ], + [ + "2025-11-23T19:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.7153114269668 + ], + [ + "2025-11-23T19:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.1245754056627 + ], + [ + "2025-11-23T18:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.7230159104142 + ], + [ + "2025-11-23T18:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.0793882782905 + ], + [ + "2025-11-23T18:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.9954249852362 + ], + [ + "2025-11-23T18:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.138441825618 + ], + [ + "2025-11-23T18:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.7982912334687 + ], + [ + "2025-11-23T18:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.028546655885 + ], + [ + "2025-11-23T18:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.4555552534925 + ], + [ + "2025-11-23T18:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.342558151854 + ], + [ + "2025-11-23T18:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.6267327182973 + ], + [ + "2025-11-23T18:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.13926921044 + ], + [ + "2025-11-23T18:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.1559906501857 + ], + [ + "2025-11-23T18:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.093494953076 + ], + [ + "2025-11-23T18:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.1890017261 + ], + [ + "2025-11-23T18:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.4869312423707 + ], + [ + "2025-11-23T18:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.1444978384757 + ], + [ + "2025-11-23T18:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.229779021817 + ], + [ + "2025-11-23T18:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.8470167088776 + ], + [ + "2025-11-23T18:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.9837937221746 + ], + [ + "2025-11-23T18:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.8977695216513 + ], + [ + "2025-11-23T18:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.5500061097314 + ], + [ + "2025-11-23T18:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.9607305798213 + ], + [ + "2025-11-23T18:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.225631954117 + ], + [ + "2025-11-23T18:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.4687192075125 + ], + [ + "2025-11-23T18:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.3206896312777 + ], + [ + "2025-11-23T18:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.882528766238 + ], + [ + "2025-11-23T18:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.5143056543798 + ], + [ + "2025-11-23T18:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.4689979516475 + ], + [ + "2025-11-23T18:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.6550600454116 + ], + [ + "2025-11-23T18:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.323526435477 + ], + [ + "2025-11-23T18:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.2822267502356 + ], + [ + "2025-11-23T18:00:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2792.477706778234 + ], + [ + "2025-11-23T17:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.3197585209145 + ], + [ + "2025-11-23T17:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.7248900756476 + ], + [ + "2025-11-23T17:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.037627357415 + ], + [ + "2025-11-23T17:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.440078728751 + ], + [ + "2025-11-23T17:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.0667328009517 + ], + [ + "2025-11-23T17:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.197212070392 + ], + [ + "2025-11-23T17:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.6418955866347 + ], + [ + "2025-11-23T17:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.8432618620823 + ], + [ + "2025-11-23T17:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.4447909581227 + ], + [ + "2025-11-23T17:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.2738433105606 + ], + [ + "2025-11-23T17:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.9079680050154 + ], + [ + "2025-11-23T17:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.9883131365586 + ], + [ + "2025-11-23T17:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.4867504494814 + ], + [ + "2025-11-23T17:31:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.169523085152 + ], + [ + "2025-11-23T17:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.777701552578 + ], + [ + "2025-11-23T17:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2792.950034688787 + ], + [ + "2025-11-23T17:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.4831777751633 + ], + [ + "2025-11-23T17:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.9985570775984 + ], + [ + "2025-11-23T17:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2792.6585565167293 + ], + [ + "2025-11-23T17:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2788.597746808651 + ], + [ + "2025-11-23T17:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.417297625978 + ], + [ + "2025-11-23T17:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.0006789443223 + ], + [ + "2025-11-23T17:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2798.9793993220837 + ], + [ + "2025-11-23T17:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.297474918653 + ], + [ + "2025-11-23T17:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.2251839634896 + ], + [ + "2025-11-23T17:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.960356847677 + ], + [ + "2025-11-23T17:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.097415258769 + ], + [ + "2025-11-23T17:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.646123305424 + ], + [ + "2025-11-23T17:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.9819408804838 + ], + [ + "2025-11-23T16:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.876400948122 + ], + [ + "2025-11-23T16:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.7183727031706 + ], + [ + "2025-11-23T16:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.652309320529 + ], + [ + "2025-11-23T16:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.0810619620347 + ], + [ + "2025-11-23T16:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.231444873005 + ], + [ + "2025-11-23T16:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.5626658444353 + ], + [ + "2025-11-23T16:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.7742743880754 + ], + [ + "2025-11-23T16:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.9493915505454 + ], + [ + "2025-11-23T16:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.86715929865 + ], + [ + "2025-11-23T16:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.402968452854 + ], + [ + "2025-11-23T16:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.5343062893126 + ], + [ + "2025-11-23T16:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.6377170903024 + ], + [ + "2025-11-23T16:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.396351152994 + ], + [ + "2025-11-23T16:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.1038374034074 + ], + [ + "2025-11-23T16:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.9219256628276 + ], + [ + "2025-11-23T16:30:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.954104894974 + ], + [ + "2025-11-23T16:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.9273360680004 + ], + [ + "2025-11-23T16:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.0922340724987 + ], + [ + "2025-11-23T16:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.1040685649837 + ], + [ + "2025-11-23T16:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.938193750037 + ], + [ + "2025-11-23T16:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.8009003149355 + ], + [ + "2025-11-23T16:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.0441442733377 + ], + [ + "2025-11-23T16:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.440074154102 + ], + [ + "2025-11-23T16:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.927677277802 + ], + [ + "2025-11-23T16:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.3508406829387 + ], + [ + "2025-11-23T16:10:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2843.336346252057 + ], + [ + "2025-11-23T16:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.5333576744433 + ], + [ + "2025-11-23T16:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.6829081280257 + ], + [ + "2025-11-23T16:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.9250145582128 + ], + [ + "2025-11-23T16:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.9154918936842 + ], + [ + "2025-11-23T15:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.9777153140076 + ], + [ + "2025-11-23T15:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.685281609964 + ], + [ + "2025-11-23T15:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.868402166831 + ], + [ + "2025-11-23T15:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.9053769536354 + ], + [ + "2025-11-23T15:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.2052675556492 + ], + [ + "2025-11-23T15:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.2504958480995 + ], + [ + "2025-11-23T15:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.8844943947115 + ], + [ + "2025-11-23T15:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.0615707390275 + ], + [ + "2025-11-23T15:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.3723929168013 + ], + [ + "2025-11-23T15:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.351325697657 + ], + [ + "2025-11-23T15:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.4155484147605 + ], + [ + "2025-11-23T15:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.227616926826 + ], + [ + "2025-11-23T15:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.48842667025 + ], + [ + "2025-11-23T15:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.3582548066056 + ], + [ + "2025-11-23T15:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.5411714778143 + ], + [ + "2025-11-23T15:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.3768169316095 + ], + [ + "2025-11-23T15:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.6462919377514 + ], + [ + "2025-11-23T15:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.4333723299696 + ], + [ + "2025-11-23T15:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.078052765253 + ], + [ + "2025-11-23T15:22:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.9175527753055 + ], + [ + "2025-11-23T15:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.6490267519944 + ], + [ + "2025-11-23T15:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.557175298362 + ], + [ + "2025-11-23T15:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.478365215955 + ], + [ + "2025-11-23T15:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.546459780404 + ], + [ + "2025-11-23T15:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.396540989756 + ], + [ + "2025-11-23T15:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.786533520108 + ], + [ + "2025-11-23T15:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.2579199602324 + ], + [ + "2025-11-23T15:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.4974958982807 + ], + [ + "2025-11-23T15:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.6261521385145 + ], + [ + "2025-11-23T15:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.146388337869 + ], + [ + "2025-11-23T14:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.3438514010036 + ], + [ + "2025-11-23T14:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.8643690493523 + ], + [ + "2025-11-23T14:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.2970613270663 + ], + [ + "2025-11-23T14:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.208875188246 + ], + [ + "2025-11-23T14:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.685926092215 + ], + [ + "2025-11-23T14:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.51313382392 + ], + [ + "2025-11-23T14:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.731841641821 + ], + [ + "2025-11-23T14:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.7475478755487 + ], + [ + "2025-11-23T14:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.356532939525 + ], + [ + "2025-11-23T14:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.0110767755514 + ], + [ + "2025-11-23T14:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.989297563397 + ], + [ + "2025-11-23T14:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.839384212069 + ], + [ + "2025-11-23T14:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.320907317497 + ], + [ + "2025-11-23T14:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.995229793308 + ], + [ + "2025-11-23T14:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.355878245435 + ], + [ + "2025-11-23T14:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.5654895880166 + ], + [ + "2025-11-23T14:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.011447380909 + ], + [ + "2025-11-23T14:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.9974647814233 + ], + [ + "2025-11-23T14:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.6208960510166 + ], + [ + "2025-11-23T14:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.409594770946 + ], + [ + "2025-11-23T14:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.3139894501946 + ], + [ + "2025-11-23T14:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.9815473633817 + ], + [ + "2025-11-23T14:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.8407946354587 + ], + [ + "2025-11-23T14:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.430284568809 + ], + [ + "2025-11-23T14:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.1062121717973 + ], + [ + "2025-11-23T14:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.5676011823425 + ], + [ + "2025-11-23T14:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.5231889695865 + ], + [ + "2025-11-23T14:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2855.5404183314863 + ], + [ + "2025-11-23T14:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2850.660479857965 + ], + [ + "2025-11-23T14:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2846.7865362514563 + ], + [ + "2025-11-23T13:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.1843140735427 + ], + [ + "2025-11-23T13:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.7132228090845 + ], + [ + "2025-11-23T13:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.9289121158986 + ], + [ + "2025-11-23T13:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.014138923983 + ], + [ + "2025-11-23T13:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.8334136930325 + ], + [ + "2025-11-23T13:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.371302549211 + ], + [ + "2025-11-23T13:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.1448281723347 + ], + [ + "2025-11-23T13:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2842.4011659151283 + ], + [ + "2025-11-23T13:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2840.0263600888843 + ], + [ + "2025-11-23T13:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.9843815543504 + ], + [ + "2025-11-23T13:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.2169958236223 + ], + [ + "2025-11-23T13:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.161174558012 + ], + [ + "2025-11-23T13:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.6169672473297 + ], + [ + "2025-11-23T13:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.347381010256 + ], + [ + "2025-11-23T13:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.5170199249615 + ], + [ + "2025-11-23T13:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.691894259068 + ], + [ + "2025-11-23T13:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.2094198130026 + ], + [ + "2025-11-23T13:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.53091969049 + ], + [ + "2025-11-23T13:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.1937063886016 + ], + [ + "2025-11-23T13:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.2446570073057 + ], + [ + "2025-11-23T13:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.148366424448 + ], + [ + "2025-11-23T13:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.060540936147 + ], + [ + "2025-11-23T13:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.496981894655 + ], + [ + "2025-11-23T13:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.3106074434486 + ], + [ + "2025-11-23T13:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.918424482467 + ], + [ + "2025-11-23T13:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.312067487946 + ], + [ + "2025-11-23T13:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.078682443924 + ], + [ + "2025-11-23T13:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.713036046841 + ], + [ + "2025-11-23T13:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.8882104465056 + ], + [ + "2025-11-23T13:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.5640204642564 + ], + [ + "2025-11-23T12:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.18399615629 + ], + [ + "2025-11-23T12:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.457137222904 + ], + [ + "2025-11-23T12:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.3756375745666 + ], + [ + "2025-11-23T12:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.904614102238 + ], + [ + "2025-11-23T12:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.635615639586 + ], + [ + "2025-11-23T12:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.6594289370687 + ], + [ + "2025-11-23T12:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.774953680891 + ], + [ + "2025-11-23T12:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.202061672447 + ], + [ + "2025-11-23T12:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.0998070096425 + ], + [ + "2025-11-23T12:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.1363060457406 + ], + [ + "2025-11-23T12:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.48139953286 + ], + [ + "2025-11-23T12:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2837.4409262830527 + ], + [ + "2025-11-23T12:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.9261586724415 + ], + [ + "2025-11-23T12:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.6994866951836 + ], + [ + "2025-11-23T12:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.3519121150293 + ], + [ + "2025-11-23T12:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.075830021268 + ], + [ + "2025-11-23T12:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2833.894165982704 + ], + [ + "2025-11-23T12:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.796850406598 + ], + [ + "2025-11-23T12:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2836.5783667621145 + ], + [ + "2025-11-23T12:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2841.2426198500334 + ], + [ + "2025-11-23T12:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2839.2639846433126 + ], + [ + "2025-11-23T12:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.4704115981003 + ], + [ + "2025-11-23T12:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.483690441902 + ], + [ + "2025-11-23T12:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.38028107478 + ], + [ + "2025-11-23T12:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.033337415251 + ], + [ + "2025-11-23T12:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.8633818334556 + ], + [ + "2025-11-23T12:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.5606419966516 + ], + [ + "2025-11-23T12:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.4208458496364 + ], + [ + "2025-11-23T12:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.6081346220267 + ], + [ + "2025-11-23T12:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.347754488546 + ], + [ + "2025-11-23T11:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.94209484836 + ], + [ + "2025-11-23T11:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.9381301071735 + ], + [ + "2025-11-23T11:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.7602872478715 + ], + [ + "2025-11-23T11:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.868420072211 + ], + [ + "2025-11-23T11:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.534577679244 + ], + [ + "2025-11-23T11:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.876211420119 + ], + [ + "2025-11-23T11:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.6280866818793 + ], + [ + "2025-11-23T11:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.2977046202855 + ], + [ + "2025-11-23T11:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.7105425447694 + ], + [ + "2025-11-23T11:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.5075740774823 + ], + [ + "2025-11-23T11:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.1769990957005 + ], + [ + "2025-11-23T11:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.316026208821 + ], + [ + "2025-11-23T11:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.2285194537317 + ], + [ + "2025-11-23T11:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.376262204351 + ], + [ + "2025-11-23T11:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.429624253037 + ], + [ + "2025-11-23T11:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.947529573773 + ], + [ + "2025-11-23T11:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.973872762775 + ], + [ + "2025-11-23T11:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.8184382391505 + ], + [ + "2025-11-23T11:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.8602410568683 + ], + [ + "2025-11-23T11:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.784577489343 + ], + [ + "2025-11-23T11:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.138574206889 + ], + [ + "2025-11-23T11:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.1658144794683 + ], + [ + "2025-11-23T11:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.134945751235 + ], + [ + "2025-11-23T11:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.8969595773005 + ], + [ + "2025-11-23T11:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.262207881878 + ], + [ + "2025-11-23T11:10:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.765473852405 + ], + [ + "2025-11-23T11:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.7506592796713 + ], + [ + "2025-11-23T11:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.3201602627228 + ], + [ + "2025-11-23T11:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.2665740997304 + ], + [ + "2025-11-23T11:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.388853456922 + ], + [ + "2025-11-23T10:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.360244025447 + ], + [ + "2025-11-23T10:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.253815935061 + ], + [ + "2025-11-23T10:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.7400668981636 + ], + [ + "2025-11-23T10:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.5345334248564 + ], + [ + "2025-11-23T10:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.5798713886425 + ], + [ + "2025-11-23T10:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.1800667147386 + ], + [ + "2025-11-23T10:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.344597420347 + ], + [ + "2025-11-23T10:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.994862704672 + ], + [ + "2025-11-23T10:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.1259277027198 + ], + [ + "2025-11-23T10:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.1860065228802 + ], + [ + "2025-11-23T10:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.0996141938954 + ], + [ + "2025-11-23T10:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.9899846735416 + ], + [ + "2025-11-23T10:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.5707024655358 + ], + [ + "2025-11-23T10:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.0016772161393 + ], + [ + "2025-11-23T10:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.344033836901 + ], + [ + "2025-11-23T10:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.135816440902 + ], + [ + "2025-11-23T10:28:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.347604948192 + ], + [ + "2025-11-23T10:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.5936022266646 + ], + [ + "2025-11-23T10:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.8723262094773 + ], + [ + "2025-11-23T10:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.4549395481486 + ], + [ + "2025-11-23T10:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.4119147983015 + ], + [ + "2025-11-23T10:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.7968875235474 + ], + [ + "2025-11-23T10:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.9647582890684 + ], + [ + "2025-11-23T10:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.248993359288 + ], + [ + "2025-11-23T10:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.3474378048786 + ], + [ + "2025-11-23T10:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.304661459685 + ], + [ + "2025-11-23T10:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.8884430186718 + ], + [ + "2025-11-23T10:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.9366950333474 + ], + [ + "2025-11-23T10:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.1229148243456 + ], + [ + "2025-11-23T10:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.9062692310335 + ], + [ + "2025-11-23T09:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.666762238106 + ], + [ + "2025-11-23T09:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.193142501168 + ], + [ + "2025-11-23T09:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.1998820461745 + ], + [ + "2025-11-23T09:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.5073109777836 + ], + [ + "2025-11-23T09:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.0083096560243 + ], + [ + "2025-11-23T09:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.108001621663 + ], + [ + "2025-11-23T09:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.292902113228 + ], + [ + "2025-11-23T09:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.48488860357 + ], + [ + "2025-11-23T09:44:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.1082184419647 + ], + [ + "2025-11-23T09:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.1828251604425 + ], + [ + "2025-11-23T09:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.620907035229 + ], + [ + "2025-11-23T09:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.392533472191 + ], + [ + "2025-11-23T09:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.2926026528826 + ], + [ + "2025-11-23T09:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.2087403682335 + ], + [ + "2025-11-23T09:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.4013505370253 + ], + [ + "2025-11-23T09:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.543539031165 + ], + [ + "2025-11-23T09:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.9496026960114 + ], + [ + "2025-11-23T09:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.7727710942095 + ], + [ + "2025-11-23T09:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.190831765157 + ], + [ + "2025-11-23T09:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.0465432423057 + ], + [ + "2025-11-23T09:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.348333712838 + ], + [ + "2025-11-23T09:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.207581717759 + ], + [ + "2025-11-23T09:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.695931761751 + ], + [ + "2025-11-23T09:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.119424589934 + ], + [ + "2025-11-23T09:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.6327390536503 + ], + [ + "2025-11-23T09:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.218484186016 + ], + [ + "2025-11-23T09:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.2619777937043 + ], + [ + "2025-11-23T09:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.784309651276 + ], + [ + "2025-11-23T09:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.6106825764164 + ], + [ + "2025-11-23T09:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.24019516809 + ], + [ + "2025-11-23T08:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.9637197387733 + ], + [ + "2025-11-23T08:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.088886602591 + ], + [ + "2025-11-23T08:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.454688114963 + ], + [ + "2025-11-23T08:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.717028801804 + ], + [ + "2025-11-23T08:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.173732896085 + ], + [ + "2025-11-23T08:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.7178009512004 + ], + [ + "2025-11-23T08:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.078576862168 + ], + [ + "2025-11-23T08:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.3133804219096 + ], + [ + "2025-11-23T08:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.8790261200456 + ], + [ + "2025-11-23T08:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.7828358654574 + ], + [ + "2025-11-23T08:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.1264803975914 + ], + [ + "2025-11-23T08:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.6117677639036 + ], + [ + "2025-11-23T08:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.503590838672 + ], + [ + "2025-11-23T08:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.968249757023 + ], + [ + "2025-11-23T08:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.4894372804947 + ], + [ + "2025-11-23T08:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.7582554660003 + ], + [ + "2025-11-23T08:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.6811227019875 + ], + [ + "2025-11-23T08:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.8922329532834 + ], + [ + "2025-11-23T08:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.339053522605 + ], + [ + "2025-11-23T08:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.2095454724804 + ], + [ + "2025-11-23T08:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.8739775145664 + ], + [ + "2025-11-23T08:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.6680744948694 + ], + [ + "2025-11-23T08:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.74685797194 + ], + [ + "2025-11-23T08:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.026368383354 + ], + [ + "2025-11-23T08:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.3632841226063 + ], + [ + "2025-11-23T08:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.2967220978053 + ], + [ + "2025-11-23T08:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.1260488150883 + ], + [ + "2025-11-23T08:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.626667249108 + ], + [ + "2025-11-23T08:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.661244566746 + ], + [ + "2025-11-23T08:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2794.4147193166123 + ], + [ + "2025-11-23T07:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2792.9138766502774 + ], + [ + "2025-11-23T07:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.4340928546917 + ], + [ + "2025-11-23T07:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.5744153203796 + ], + [ + "2025-11-23T07:54:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2792.599451716229 + ], + [ + "2025-11-23T07:52:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.0967757020167 + ], + [ + "2025-11-23T07:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.7506785908176 + ], + [ + "2025-11-23T07:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.254187313426 + ], + [ + "2025-11-23T07:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.2075888072936 + ], + [ + "2025-11-23T07:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.5285588510487 + ], + [ + "2025-11-23T07:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.066142839337 + ], + [ + "2025-11-23T07:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2786.1263356835425 + ], + [ + "2025-11-23T07:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2786.146340041448 + ], + [ + "2025-11-23T07:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.6570234387264 + ], + [ + "2025-11-23T07:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2782.7536499139915 + ], + [ + "2025-11-23T07:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.270740899186 + ], + [ + "2025-11-23T07:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.665994995939 + ], + [ + "2025-11-23T07:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.3224256647914 + ], + [ + "2025-11-23T07:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.056266376221 + ], + [ + "2025-11-23T07:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2791.4478431438524 + ], + [ + "2025-11-23T07:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.0822029213227 + ], + [ + "2025-11-23T07:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2788.0957318567134 + ], + [ + "2025-11-23T07:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2795.983901353263 + ], + [ + "2025-11-23T07:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.902062486985 + ], + [ + "2025-11-23T07:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.7823785152195 + ], + [ + "2025-11-23T07:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.1362397295534 + ], + [ + "2025-11-23T07:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.0830147773854 + ], + [ + "2025-11-23T07:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2796.41100262963 + ], + [ + "2025-11-23T07:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2797.839191078092 + ], + [ + "2025-11-23T07:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2803.231662508694 + ], + [ + "2025-11-23T07:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.3219925944154 + ], + [ + "2025-11-23T06:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.1880379991308 + ], + [ + "2025-11-23T06:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.8970382819366 + ], + [ + "2025-11-23T06:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.4444761427067 + ], + [ + "2025-11-23T06:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.934704172379 + ], + [ + "2025-11-23T06:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.5523210551883 + ], + [ + "2025-11-23T06:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.7367205135192 + ], + [ + "2025-11-23T06:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.1706935078937 + ], + [ + "2025-11-23T06:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.1557649234564 + ], + [ + "2025-11-23T06:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.521045410974 + ], + [ + "2025-11-23T06:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.7693687999526 + ], + [ + "2025-11-23T06:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.59368135648 + ], + [ + "2025-11-23T06:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.8075005799496 + ], + [ + "2025-11-23T06:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.7774708985153 + ], + [ + "2025-11-23T06:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.769610251876 + ], + [ + "2025-11-23T06:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.7093414404776 + ], + [ + "2025-11-23T06:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.804154348502 + ], + [ + "2025-11-23T06:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.134424019504 + ], + [ + "2025-11-23T06:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.3184429359767 + ], + [ + "2025-11-23T06:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.02670594915 + ], + [ + "2025-11-23T06:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.730638837939 + ], + [ + "2025-11-23T06:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.6387735678863 + ], + [ + "2025-11-23T06:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.6191615182197 + ], + [ + "2025-11-23T06:16:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.1805307879454 + ], + [ + "2025-11-23T06:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.183244910847 + ], + [ + "2025-11-23T06:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.667308493941 + ], + [ + "2025-11-23T06:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.274179470899 + ], + [ + "2025-11-23T06:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.483130086973 + ], + [ + "2025-11-23T06:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.1085033464005 + ], + [ + "2025-11-23T06:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.9829938618623 + ], + [ + "2025-11-23T06:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.5461236394867 + ], + [ + "2025-11-23T06:00:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.2605352751493 + ], + [ + "2025-11-23T05:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.13509876763 + ], + [ + "2025-11-23T05:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.6411813885156 + ], + [ + "2025-11-23T05:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.894058137425 + ], + [ + "2025-11-23T05:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.594106780679 + ], + [ + "2025-11-23T05:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.9578998731836 + ], + [ + "2025-11-23T05:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.5509408085177 + ], + [ + "2025-11-23T05:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.4429473018513 + ], + [ + "2025-11-23T05:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.4536005346213 + ], + [ + "2025-11-23T05:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.3976428376673 + ], + [ + "2025-11-23T05:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2815.39717980399 + ], + [ + "2025-11-23T05:38:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.4575284133866 + ], + [ + "2025-11-23T05:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.9712213837565 + ], + [ + "2025-11-23T05:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.486780282538 + ], + [ + "2025-11-23T05:32:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.150627351334 + ], + [ + "2025-11-23T05:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.096368465042 + ], + [ + "2025-11-23T05:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.021925281687 + ], + [ + "2025-11-23T05:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.5191815885187 + ], + [ + "2025-11-23T05:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.2749132120134 + ], + [ + "2025-11-23T05:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.0358433325086 + ], + [ + "2025-11-23T05:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.5198824762465 + ], + [ + "2025-11-23T05:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.2458007381715 + ], + [ + "2025-11-23T05:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.5255034695224 + ], + [ + "2025-11-23T05:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.4236374269763 + ], + [ + "2025-11-23T05:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.259041632418 + ], + [ + "2025-11-23T05:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.8845393184565 + ], + [ + "2025-11-23T05:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.705906387421 + ], + [ + "2025-11-23T05:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.399520518252 + ], + [ + "2025-11-23T05:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.0552477110896 + ], + [ + "2025-11-23T05:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.773222984343 + ], + [ + "2025-11-23T04:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.2315618533967 + ], + [ + "2025-11-23T04:58:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.8670967554726 + ], + [ + "2025-11-23T04:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.9754743162857 + ], + [ + "2025-11-23T04:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.918343151204 + ], + [ + "2025-11-23T04:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.6288570310553 + ], + [ + "2025-11-23T04:50:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.685465896728 + ], + [ + "2025-11-23T04:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.8004040049514 + ], + [ + "2025-11-23T04:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.1292126004146 + ], + [ + "2025-11-23T04:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.6448077846244 + ], + [ + "2025-11-23T04:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.7741825173507 + ], + [ + "2025-11-23T04:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.7627209309094 + ], + [ + "2025-11-23T04:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.5260695528873 + ], + [ + "2025-11-23T04:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.377443269895 + ], + [ + "2025-11-23T04:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.908428564858 + ], + [ + "2025-11-23T04:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.035469396137 + ], + [ + "2025-11-23T04:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2835.104699289568 + ], + [ + "2025-11-23T04:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.857722938137 + ], + [ + "2025-11-23T04:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.144837644351 + ], + [ + "2025-11-23T04:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2844.5643870191393 + ], + [ + "2025-11-23T04:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2845.9421785657455 + ], + [ + "2025-11-23T04:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2838.2808155145294 + ], + [ + "2025-11-23T04:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2834.553523930966 + ], + [ + "2025-11-23T04:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2831.3671422734246 + ], + [ + "2025-11-23T04:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2832.601397397395 + ], + [ + "2025-11-23T04:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2829.38561327716 + ], + [ + "2025-11-23T04:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.021381256922 + ], + [ + "2025-11-23T04:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.411814942492 + ], + [ + "2025-11-23T04:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.2031979109097 + ], + [ + "2025-11-23T04:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2823.489195334712 + ], + [ + "2025-11-23T04:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.639268927638 + ], + [ + "2025-11-23T03:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.4197294282867 + ], + [ + "2025-11-23T03:57:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.901718651421 + ], + [ + "2025-11-23T03:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.1838705071405 + ], + [ + "2025-11-23T03:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.7919820299635 + ], + [ + "2025-11-23T03:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.4337159122015 + ], + [ + "2025-11-23T03:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.578689109877 + ], + [ + "2025-11-23T03:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.810424683847 + ], + [ + "2025-11-23T03:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.84897021905 + ], + [ + "2025-11-23T03:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.7676992407405 + ], + [ + "2025-11-23T03:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2817.376979970314 + ], + [ + "2025-11-23T03:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.0721545798306 + ], + [ + "2025-11-23T03:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.7281086786693 + ], + [ + "2025-11-23T03:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2818.472352122482 + ], + [ + "2025-11-23T03:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.9515981620566 + ], + [ + "2025-11-23T03:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.694339003867 + ], + [ + "2025-11-23T03:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.1264007651735 + ], + [ + "2025-11-23T03:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.73903344585 + ], + [ + "2025-11-23T03:26:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.386400039563 + ], + [ + "2025-11-23T03:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.926757638837 + ], + [ + "2025-11-23T03:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.6303199043355 + ], + [ + "2025-11-23T03:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2812.662138641915 + ], + [ + "2025-11-23T03:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.7858587273167 + ], + [ + "2025-11-23T02:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.7293678144306 + ], + [ + "2025-11-23T02:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.9605559318793 + ], + [ + "2025-11-23T02:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2822.4962067597526 + ], + [ + "2025-11-23T02:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2825.567836454693 + ], + [ + "2025-11-23T02:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.0171984725835 + ], + [ + "2025-11-23T02:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2830.252231863054 + ], + [ + "2025-11-23T02:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2826.1242149814166 + ], + [ + "2025-11-23T02:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2828.164251901681 + ], + [ + "2025-11-23T02:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.5450793499576 + ], + [ + "2025-11-23T02:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2827.4790721131067 + ], + [ + "2025-11-23T02:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2824.9970072407145 + ], + [ + "2025-11-23T02:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.3210116902237 + ], + [ + "2025-11-23T02:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.3838413524913 + ], + [ + "2025-11-23T02:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2821.8998492136975 + ], + [ + "2025-11-23T02:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.650630341539 + ], + [ + "2025-11-23T02:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2819.7301035906703 + ], + [ + "2025-11-23T02:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2820.4514744610856 + ], + [ + "2025-11-23T02:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.2084487899806 + ], + [ + "2025-11-23T02:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.5732502417172 + ], + [ + "2025-11-23T02:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.75396210773 + ], + [ + "2025-11-23T02:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2811.556677946743 + ], + [ + "2025-11-23T02:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2810.7682001556436 + ], + [ + "2025-11-23T02:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.9276253093412 + ], + [ + "2025-11-23T02:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.978665739997 + ], + [ + "2025-11-23T02:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2806.478625839174 + ], + [ + "2025-11-23T02:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2807.443245055614 + ], + [ + "2025-11-23T02:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2816.5594987326967 + ], + [ + "2025-11-23T02:06:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2814.934597289183 + ], + [ + "2025-11-23T02:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2813.4659868359095 + ], + [ + "2025-11-23T02:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.506044260758 + ], + [ + "2025-11-23T01:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2804.702642206047 + ], + [ + "2025-11-23T01:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2809.330988595139 + ], + [ + "2025-11-23T01:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2808.0548935859742 + ], + [ + "2025-11-23T01:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2805.9618291725874 + ], + [ + "2025-11-23T01:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2801.0974272865437 + ], + [ + "2025-11-23T01:50:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.6824614492775 + ], + [ + "2025-11-23T01:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2800.4724406721516 + ], + [ + "2025-11-23T01:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.0445204624107 + ], + [ + "2025-11-23T01:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2802.931696898953 + ], + [ + "2025-11-23T01:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.9049050292588 + ], + [ + "2025-11-23T01:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2799.5574206826645 + ], + [ + "2025-11-23T01:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.848668965036 + ], + [ + "2025-11-23T01:36:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2788.422897356836 + ], + [ + "2025-11-23T01:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2789.456204875813 + ], + [ + "2025-11-23T01:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.2450647434566 + ], + [ + "2025-11-23T01:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.702316362292 + ], + [ + "2025-11-23T01:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.151711742746 + ], + [ + "2025-11-23T01:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2783.4636353995556 + ], + [ + "2025-11-23T01:24:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.8457986890458 + ], + [ + "2025-11-23T01:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.6819967847855 + ], + [ + "2025-11-23T01:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.936569422953 + ], + [ + "2025-11-23T01:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2784.4932022697935 + ], + [ + "2025-11-23T01:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.797983220695 + ], + [ + "2025-11-23T01:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.6124273660184 + ], + [ + "2025-11-23T01:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.445187320547 + ], + [ + "2025-11-23T01:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2779.6399241946056 + ], + [ + "2025-11-23T01:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2778.648319807401 + ], + [ + "2025-11-23T01:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2778.069901650798 + ], + [ + "2025-11-23T01:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2774.771154715162 + ], + [ + "2025-11-23T01:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2772.640283545438 + ], + [ + "2025-11-23T00:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2771.407896555969 + ], + [ + "2025-11-23T00:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.4573096158138 + ], + [ + "2025-11-23T00:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2771.003671075701 + ], + [ + "2025-11-23T00:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2773.9796070801162 + ], + [ + "2025-11-23T00:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2775.803412693695 + ], + [ + "2025-11-23T00:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2774.7415996384852 + ], + [ + "2025-11-23T00:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2779.228354871945 + ], + [ + "2025-11-23T00:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2782.335998133393 + ], + [ + "2025-11-23T00:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.1984089727594 + ], + [ + "2025-11-23T00:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.1767169180325 + ], + [ + "2025-11-23T00:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2785.931987529415 + ], + [ + "2025-11-23T00:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2779.604352987399 + ], + [ + "2025-11-23T00:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2782.70839296709 + ], + [ + "2025-11-23T00:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.294584399474 + ], + [ + "2025-11-23T00:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2778.331180401475 + ], + [ + "2025-11-23T00:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2776.3558294880104 + ], + [ + "2025-11-23T00:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.7608264468467 + ], + [ + "2025-11-23T00:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2774.697424697642 + ], + [ + "2025-11-23T00:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2775.131769956238 + ], + [ + "2025-11-23T00:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2772.0635410969417 + ], + [ + "2025-11-23T00:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2775.4925082062564 + ], + [ + "2025-11-23T00:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2768.2062977060264 + ], + [ + "2025-11-22T23:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.8064113406717 + ], + [ + "2025-11-22T23:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.542770556066 + ], + [ + "2025-11-22T23:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.2770735314693 + ], + [ + "2025-11-22T23:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2770.755565396828 + ], + [ + "2025-11-22T23:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2770.9042689556363 + ], + [ + "2025-11-22T23:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2772.7124161059896 + ], + [ + "2025-11-22T23:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.3192408139485 + ], + [ + "2025-11-22T23:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2772.154556498309 + ], + [ + "2025-11-22T23:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2771.5168598283344 + ], + [ + "2025-11-22T23:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2777.779739647873 + ], + [ + "2025-11-22T23:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2778.5971950827943 + ], + [ + "2025-11-22T23:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2776.311078709733 + ], + [ + "2025-11-22T23:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.1307972489403 + ], + [ + "2025-11-22T23:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2774.6771677485526 + ], + [ + "2025-11-22T23:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2777.30968302654 + ], + [ + "2025-11-22T23:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2776.2828774743216 + ], + [ + "2025-11-22T23:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2775.94560522718 + ], + [ + "2025-11-22T23:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2774.4111072487703 + ], + [ + "2025-11-22T23:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2772.3410453345673 + ], + [ + "2025-11-22T23:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2769.3337126427073 + ], + [ + "2025-11-22T23:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2771.178617024482 + ], + [ + "2025-11-22T23:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2773.53399689043 + ], + [ + "2025-11-22T23:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2780.9238606957197 + ], + [ + "2025-11-22T23:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2779.350972715226 + ], + [ + "2025-11-22T23:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2777.8848079267937 + ], + [ + "2025-11-22T23:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2781.253298561646 + ], + [ + "2025-11-22T23:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2782.3221848966773 + ], + [ + "2025-11-22T23:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.782106537095 + ], + [ + "2025-11-22T23:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2786.5087756973066 + ], + [ + "2025-11-22T23:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2787.003671286566 + ], + [ + "2025-11-22T22:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2790.862634112999 + ], + [ + "2025-11-22T22:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.691223436594 + ], + [ + "2025-11-22T22:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2793.714019018721 + ], + [ + "2025-11-22T22:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2776.345245144196 + ], + [ + "2025-11-22T22:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2765.476800666934 + ], + [ + "2025-11-22T22:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2766.7124448654654 + ], + [ + "2025-11-22T22:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2765.4514085460046 + ], + [ + "2025-11-22T22:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2766.8896937489903 + ], + [ + "2025-11-22T22:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2765.958732578387 + ], + [ + "2025-11-22T22:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2764.5420589554938 + ], + [ + "2025-11-22T22:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2765.3936684826967 + ], + [ + "2025-11-22T22:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2762.8699084886753 + ], + [ + "2025-11-22T22:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2760.45149075781 + ], + [ + "2025-11-22T22:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2760.475706680004 + ], + [ + "2025-11-22T22:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2759.422883243859 + ], + [ + "2025-11-22T22:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2758.030829556638 + ], + [ + "2025-11-22T22:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2759.5015928709668 + ], + [ + "2025-11-22T22:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2763.720032508668 + ], + [ + "2025-11-22T22:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2764.9636205994507 + ], + [ + "2025-11-22T22:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2761.524970747996 + ], + [ + "2025-11-22T22:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2762.2106019425564 + ], + [ + "2025-11-22T22:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2762.591323823876 + ], + [ + "2025-11-22T22:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2755.3789199536154 + ], + [ + "2025-11-22T22:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.1760964246037 + ], + [ + "2025-11-22T22:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.920226448035 + ], + [ + "2025-11-22T22:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.983937026727 + ], + [ + "2025-11-22T22:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2740.918739039599 + ], + [ + "2025-11-22T22:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.64199806091 + ], + [ + "2025-11-22T22:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.1227963948254 + ], + [ + "2025-11-22T22:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.142832194521 + ], + [ + "2025-11-22T21:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.2656205332437 + ], + [ + "2025-11-22T21:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.992314425164 + ], + [ + "2025-11-22T21:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.6594764253036 + ], + [ + "2025-11-22T21:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.8695307575767 + ], + [ + "2025-11-22T21:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.2290529878555 + ], + [ + "2025-11-22T21:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.979595938965 + ], + [ + "2025-11-22T21:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.481628361015 + ], + [ + "2025-11-22T21:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.8537275590807 + ], + [ + "2025-11-22T21:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.2036856576383 + ], + [ + "2025-11-22T21:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.5787109324615 + ], + [ + "2025-11-22T21:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.0710725356 + ], + [ + "2025-11-22T21:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.3438860201213 + ], + [ + "2025-11-22T21:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.905877842125 + ], + [ + "2025-11-22T21:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.5454332135814 + ], + [ + "2025-11-22T21:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.832946234099 + ], + [ + "2025-11-22T21:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.6178502701323 + ], + [ + "2025-11-22T21:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.5785083906776 + ], + [ + "2025-11-22T21:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.794903313834 + ], + [ + "2025-11-22T21:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.816145575658 + ], + [ + "2025-11-22T21:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.959907861941 + ], + [ + "2025-11-22T21:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.8693119552604 + ], + [ + "2025-11-22T21:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2740.88171788127 + ], + [ + "2025-11-22T21:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.585655140549 + ], + [ + "2025-11-22T21:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2737.025670244498 + ], + [ + "2025-11-22T21:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.138060516647 + ], + [ + "2025-11-22T21:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.4196200651913 + ], + [ + "2025-11-22T21:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.403065526106 + ], + [ + "2025-11-22T21:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2741.3325150578353 + ], + [ + "2025-11-22T21:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.607790686036 + ], + [ + "2025-11-22T21:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.9463307066108 + ], + [ + "2025-11-22T20:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.8757053014465 + ], + [ + "2025-11-22T20:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2735.1847011000523 + ], + [ + "2025-11-22T20:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2733.681391295416 + ], + [ + "2025-11-22T20:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2737.023487269208 + ], + [ + "2025-11-22T20:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2737.0775988184555 + ], + [ + "2025-11-22T20:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2740.662866036831 + ], + [ + "2025-11-22T20:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2741.918074774279 + ], + [ + "2025-11-22T20:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.7999896336655 + ], + [ + "2025-11-22T20:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.0144280007453 + ], + [ + "2025-11-22T20:42:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.19990995441 + ], + [ + "2025-11-22T20:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.884611085993 + ], + [ + "2025-11-22T20:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.6404202144636 + ], + [ + "2025-11-22T20:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.1577969252526 + ], + [ + "2025-11-22T20:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.723271028897 + ], + [ + "2025-11-22T20:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.9907277625866 + ], + [ + "2025-11-22T20:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.4672483398435 + ], + [ + "2025-11-22T20:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.0456596675012 + ], + [ + "2025-11-22T20:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.1098033444423 + ], + [ + "2025-11-22T20:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.028716242399 + ], + [ + "2025-11-22T20:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.5758952322994 + ], + [ + "2025-11-22T20:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.7093039965757 + ], + [ + "2025-11-22T20:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.72617057098 + ], + [ + "2025-11-22T20:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.480486119231 + ], + [ + "2025-11-22T20:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.3030220964715 + ], + [ + "2025-11-22T20:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.4581370734354 + ], + [ + "2025-11-22T20:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.4313618166143 + ], + [ + "2025-11-22T20:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.1977018188095 + ], + [ + "2025-11-22T20:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.9946981394364 + ], + [ + "2025-11-22T20:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.4693110316666 + ], + [ + "2025-11-22T20:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.332399677763 + ], + [ + "2025-11-22T19:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.7177893271255 + ], + [ + "2025-11-22T19:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.4906106270932 + ], + [ + "2025-11-22T19:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.707369815802 + ], + [ + "2025-11-22T19:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.089042291305 + ], + [ + "2025-11-22T19:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.4713176817413 + ], + [ + "2025-11-22T19:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.034271667036 + ], + [ + "2025-11-22T19:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.465364128157 + ], + [ + "2025-11-22T19:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.884712120497 + ], + [ + "2025-11-22T19:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.7122893974165 + ], + [ + "2025-11-22T19:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.7135774819276 + ], + [ + "2025-11-22T19:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.761534555166 + ], + [ + "2025-11-22T19:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.2944793507754 + ], + [ + "2025-11-22T19:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.004769599667 + ], + [ + "2025-11-22T19:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.032871540823 + ], + [ + "2025-11-22T19:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.2146162454305 + ], + [ + "2025-11-22T19:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.288258003633 + ], + [ + "2025-11-22T19:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.5476619440533 + ], + [ + "2025-11-22T19:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.7929916919898 + ], + [ + "2025-11-22T19:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.509543921128 + ], + [ + "2025-11-22T19:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.424818152948 + ], + [ + "2025-11-22T19:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.714768366837 + ], + [ + "2025-11-22T19:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.0479407143953 + ], + [ + "2025-11-22T19:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.1658675496637 + ], + [ + "2025-11-22T19:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.9225199792763 + ], + [ + "2025-11-22T19:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.4380082704706 + ], + [ + "2025-11-22T19:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.45263636897 + ], + [ + "2025-11-22T19:08:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.7029036105414 + ], + [ + "2025-11-22T19:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.540579691065 + ], + [ + "2025-11-22T18:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.093399867236 + ], + [ + "2025-11-22T18:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.245937904153 + ], + [ + "2025-11-22T18:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.6495664293243 + ], + [ + "2025-11-22T18:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.759337011483 + ], + [ + "2025-11-22T18:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.1022831390137 + ], + [ + "2025-11-22T18:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.3445539400354 + ], + [ + "2025-11-22T18:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.162123165552 + ], + [ + "2025-11-22T18:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.4150145701533 + ], + [ + "2025-11-22T18:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.2267450464183 + ], + [ + "2025-11-22T18:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.0343111993498 + ], + [ + "2025-11-22T18:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.7356666445526 + ], + [ + "2025-11-22T18:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.9492855361964 + ], + [ + "2025-11-22T18:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.315067271702 + ], + [ + "2025-11-22T18:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.2181580643896 + ], + [ + "2025-11-22T18:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.836255809655 + ], + [ + "2025-11-22T18:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.3210839877615 + ], + [ + "2025-11-22T18:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.5771551528346 + ], + [ + "2025-11-22T18:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.1627188357106 + ], + [ + "2025-11-22T18:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.434325922027 + ], + [ + "2025-11-22T18:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.6797169964575 + ], + [ + "2025-11-22T18:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.825874779915 + ], + [ + "2025-11-22T18:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.362044713597 + ], + [ + "2025-11-22T17:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.086742730443 + ], + [ + "2025-11-22T17:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.18898972511 + ], + [ + "2025-11-22T17:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.8051512632223 + ], + [ + "2025-11-22T17:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.3523413741564 + ], + [ + "2025-11-22T17:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.1096137634754 + ], + [ + "2025-11-22T17:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.2774962323956 + ], + [ + "2025-11-22T17:48:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.7838899408584 + ], + [ + "2025-11-22T17:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.4619707517923 + ], + [ + "2025-11-22T17:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.7092978427386 + ], + [ + "2025-11-22T17:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.1869481206218 + ], + [ + "2025-11-22T17:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.827942987769 + ], + [ + "2025-11-22T17:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.803097094085 + ], + [ + "2025-11-22T17:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.486730330575 + ], + [ + "2025-11-22T17:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.88597046639 + ], + [ + "2025-11-22T17:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.314967639841 + ], + [ + "2025-11-22T17:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.7146762729462 + ], + [ + "2025-11-22T17:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2755.0204307053064 + ], + [ + "2025-11-22T17:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.6357834334517 + ], + [ + "2025-11-22T17:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.7619474642893 + ], + [ + "2025-11-22T17:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.9679804702178 + ], + [ + "2025-11-22T17:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.3166890452203 + ], + [ + "2025-11-22T17:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.8934774911704 + ], + [ + "2025-11-22T17:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.356662557206 + ], + [ + "2025-11-22T17:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.249986806769 + ], + [ + "2025-11-22T17:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.5384130187267 + ], + [ + "2025-11-22T17:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.746428897278 + ], + [ + "2025-11-22T17:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.0528739130195 + ], + [ + "2025-11-22T17:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.797163429539 + ], + [ + "2025-11-22T17:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.257909012841 + ], + [ + "2025-11-22T17:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.4416345124887 + ], + [ + "2025-11-22T17:00:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.812601266146 + ], + [ + "2025-11-22T16:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2755.571679714315 + ], + [ + "2025-11-22T16:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.726548206302 + ], + [ + "2025-11-22T16:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2751.649081312899 + ], + [ + "2025-11-22T16:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2756.506651641448 + ], + [ + "2025-11-22T16:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2755.797180252845 + ], + [ + "2025-11-22T16:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2753.0015599508597 + ], + [ + "2025-11-22T16:46:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2754.137919095537 + ], + [ + "2025-11-22T16:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.5115183674047 + ], + [ + "2025-11-22T16:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.3463893603844 + ], + [ + "2025-11-22T16:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.655361677206 + ], + [ + "2025-11-22T16:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.4773412353993 + ], + [ + "2025-11-22T16:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.974043569746 + ], + [ + "2025-11-22T16:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.4974784582973 + ], + [ + "2025-11-22T16:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.9989925439463 + ], + [ + "2025-11-22T16:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.8274574813045 + ], + [ + "2025-11-22T16:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.6357209203916 + ], + [ + "2025-11-22T16:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2749.6687687465906 + ], + [ + "2025-11-22T16:23:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.110160994188 + ], + [ + "2025-11-22T16:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.424455919776 + ], + [ + "2025-11-22T16:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.5184484933607 + ], + [ + "2025-11-22T16:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.6088183626 + ], + [ + "2025-11-22T16:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.9938857673756 + ], + [ + "2025-11-22T16:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.580979669457 + ], + [ + "2025-11-22T16:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.3667067344822 + ], + [ + "2025-11-22T16:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.83548441073 + ], + [ + "2025-11-22T16:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.0140084814725 + ], + [ + "2025-11-22T16:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2735.2171284319193 + ], + [ + "2025-11-22T16:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2735.964717171755 + ], + [ + "2025-11-22T16:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2737.873322210815 + ], + [ + "2025-11-22T15:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2736.210834785115 + ], + [ + "2025-11-22T15:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2729.90424071205 + ], + [ + "2025-11-22T15:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.0844753070296 + ], + [ + "2025-11-22T15:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.515860133666 + ], + [ + "2025-11-22T15:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.9683056708595 + ], + [ + "2025-11-22T15:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2730.0793449692624 + ], + [ + "2025-11-22T15:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2729.7954833758595 + ], + [ + "2025-11-22T15:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2728.8750354396925 + ], + [ + "2025-11-22T15:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2728.6704550145564 + ], + [ + "2025-11-22T15:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.018721731993 + ], + [ + "2025-11-22T15:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.41763739807 + ], + [ + "2025-11-22T15:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.4183207290607 + ], + [ + "2025-11-22T15:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.4650324511535 + ], + [ + "2025-11-22T15:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.638537750042 + ], + [ + "2025-11-22T15:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.2346609450074 + ], + [ + "2025-11-22T15:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.9745589028475 + ], + [ + "2025-11-22T15:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.1548537055924 + ], + [ + "2025-11-22T15:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.0828520715354 + ], + [ + "2025-11-22T15:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.3630593003336 + ], + [ + "2025-11-22T15:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2741.517838203738 + ], + [ + "2025-11-22T15:20:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.154339293096 + ], + [ + "2025-11-22T15:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2741.24260742532 + ], + [ + "2025-11-22T15:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.4154367210567 + ], + [ + "2025-11-22T15:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.756966889055 + ], + [ + "2025-11-22T15:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.9471948011324 + ], + [ + "2025-11-22T15:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.810694979252 + ], + [ + "2025-11-22T15:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2740.474904540455 + ], + [ + "2025-11-22T15:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.31680284459 + ], + [ + "2025-11-22T15:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.614215951918 + ], + [ + "2025-11-22T15:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.379948251116 + ], + [ + "2025-11-22T14:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.270367565472 + ], + [ + "2025-11-22T14:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.5944060727575 + ], + [ + "2025-11-22T14:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.8545148269955 + ], + [ + "2025-11-22T14:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.1990434657087 + ], + [ + "2025-11-22T14:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.561122661312 + ], + [ + "2025-11-22T14:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.747390504847 + ], + [ + "2025-11-22T14:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.055185277894 + ], + [ + "2025-11-22T14:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2740.3949067828735 + ], + [ + "2025-11-22T14:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.777671333745 + ], + [ + "2025-11-22T14:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.01462219749 + ], + [ + "2025-11-22T14:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2748.4916243627877 + ], + [ + "2025-11-22T14:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.814139790271 + ], + [ + "2025-11-22T14:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.235277767103 + ], + [ + "2025-11-22T14:33:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.8549519079074 + ], + [ + "2025-11-22T14:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2742.512944928356 + ], + [ + "2025-11-22T14:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2745.00698051284 + ], + [ + "2025-11-22T14:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2747.379429815662 + ], + [ + "2025-11-22T14:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2743.987991964305 + ], + [ + "2025-11-22T14:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2744.45120135803 + ], + [ + "2025-11-22T14:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.937818469865 + ], + [ + "2025-11-22T14:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2740.4282936283453 + ], + [ + "2025-11-22T14:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2746.2962929564346 + ], + [ + "2025-11-22T14:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.179913380196 + ], + [ + "2025-11-22T14:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.8634566820688 + ], + [ + "2025-11-22T14:11:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2730.1137043542467 + ], + [ + "2025-11-22T14:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2735.869508585558 + ], + [ + "2025-11-22T14:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2732.7608584760546 + ], + [ + "2025-11-22T14:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2733.1593122628624 + ], + [ + "2025-11-22T14:04:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2729.224260691309 + ], + [ + "2025-11-22T14:02:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2724.495095846988 + ], + [ + "2025-11-22T13:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.444014678376 + ], + [ + "2025-11-22T13:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.8161187976903 + ], + [ + "2025-11-22T13:56:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.0670217753295 + ], + [ + "2025-11-22T13:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2718.0311205818443 + ], + [ + "2025-11-22T13:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.602123611244 + ], + [ + "2025-11-22T13:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2722.747155791708 + ], + [ + "2025-11-22T13:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2721.6490955559007 + ], + [ + "2025-11-22T13:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.620361984509 + ], + [ + "2025-11-22T13:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.734873026668 + ], + [ + "2025-11-22T13:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2714.15591574362 + ], + [ + "2025-11-22T13:39:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2712.8933299954715 + ], + [ + "2025-11-22T13:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.2987537865924 + ], + [ + "2025-11-22T13:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.8232587536645 + ], + [ + "2025-11-22T13:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.9765409507067 + ], + [ + "2025-11-22T13:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.881037461352 + ], + [ + "2025-11-22T13:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.521752424903 + ], + [ + "2025-11-22T13:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.0735436312584 + ], + [ + "2025-11-22T13:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.0840268020233 + ], + [ + "2025-11-22T13:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2722.3297055726903 + ], + [ + "2025-11-22T13:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2723.885584446532 + ], + [ + "2025-11-22T13:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2724.865581203137 + ], + [ + "2025-11-22T13:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2726.4692940195023 + ], + [ + "2025-11-22T13:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2727.9299240020537 + ], + [ + "2025-11-22T13:13:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.4350308925605 + ], + [ + "2025-11-22T13:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2730.628794898496 + ], + [ + "2025-11-22T13:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2722.1666576572134 + ], + [ + "2025-11-22T13:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.3757118541707 + ], + [ + "2025-11-22T13:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.5427839025365 + ], + [ + "2025-11-22T13:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.830496431821 + ], + [ + "2025-11-22T13:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.9977210569773 + ], + [ + "2025-11-22T12:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.41887203012 + ], + [ + "2025-11-22T12:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2718.823140045308 + ], + [ + "2025-11-22T12:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.554816813255 + ], + [ + "2025-11-22T12:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.263577645946 + ], + [ + "2025-11-22T12:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2714.737684677073 + ], + [ + "2025-11-22T12:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.984203361669 + ], + [ + "2025-11-22T12:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.7867948902876 + ], + [ + "2025-11-22T12:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.6304265600065 + ], + [ + "2025-11-22T12:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.9269068796407 + ], + [ + "2025-11-22T12:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.1524184643263 + ], + [ + "2025-11-22T12:40:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.7662426351644 + ], + [ + "2025-11-22T12:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.2678964814027 + ], + [ + "2025-11-22T12:35:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.551815512656 + ], + [ + "2025-11-22T12:34:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.997846240723 + ], + [ + "2025-11-22T12:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2718.325267005115 + ], + [ + "2025-11-22T12:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.8665325840966 + ], + [ + "2025-11-22T12:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.9074230462365 + ], + [ + "2025-11-22T12:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.3558027003564 + ], + [ + "2025-11-22T12:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.4981950074325 + ], + [ + "2025-11-22T12:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2718.244274763687 + ], + [ + "2025-11-22T12:19:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2719.4629127517064 + ], + [ + "2025-11-22T12:18:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2717.80272966981 + ], + [ + "2025-11-22T12:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.4193663046603 + ], + [ + "2025-11-22T12:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2714.7766833200003 + ], + [ + "2025-11-22T12:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2716.0802082362643 + ], + [ + "2025-11-22T12:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2720.7682682196623 + ], + [ + "2025-11-22T12:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2726.4806271125653 + ], + [ + "2025-11-22T12:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2729.3664982482524 + ], + [ + "2025-11-22T12:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2732.576057314775 + ], + [ + "2025-11-22T12:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2733.517743757391 + ], + [ + "2025-11-22T11:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.6258418840284 + ], + [ + "2025-11-22T11:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2736.0650572655813 + ], + [ + "2025-11-22T11:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2736.197468416382 + ], + [ + "2025-11-22T11:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.186984814236 + ], + [ + "2025-11-22T11:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.9499732799086 + ], + [ + "2025-11-22T11:50:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.2775766998266 + ], + [ + "2025-11-22T11:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.7867651442502 + ], + [ + "2025-11-22T11:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.360156358462 + ], + [ + "2025-11-22T11:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.8742919350866 + ], + [ + "2025-11-22T11:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2736.8023928197917 + ], + [ + "2025-11-22T11:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.1846114492573 + ], + [ + "2025-11-22T11:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.0969790274944 + ], + [ + "2025-11-22T11:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2736.2620615385276 + ], + [ + "2025-11-22T11:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.762875907144 + ], + [ + "2025-11-22T11:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.2314085903254 + ], + [ + "2025-11-22T11:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2736.147697045631 + ], + [ + "2025-11-22T11:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.0050110734724 + ], + [ + "2025-11-22T11:26:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2723.2029346671684 + ], + [ + "2025-11-22T11:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2723.0464860460306 + ], + [ + "2025-11-22T11:21:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2723.7983606024327 + ], + [ + "2025-11-22T11:19:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2723.5760088911884 + ], + [ + "2025-11-22T11:17:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2725.597516760434 + ], + [ + "2025-11-22T11:15:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2728.0151333250606 + ], + [ + "2025-11-22T11:14:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2724.3004810241578 + ], + [ + "2025-11-22T11:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2723.3863463127627 + ], + [ + "2025-11-22T11:09:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2718.862365611161 + ], + [ + "2025-11-22T11:07:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.1674066327337 + ], + [ + "2025-11-22T11:05:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2709.065712084767 + ], + [ + "2025-11-22T11:03:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2711.7988212166574 + ], + [ + "2025-11-22T11:01:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2711.882952580508 + ], + [ + "2025-11-22T10:59:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2711.6460553135594 + ], + [ + "2025-11-22T10:57:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2709.36691957821 + ], + [ + "2025-11-22T10:55:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2706.8909349934484 + ], + [ + "2025-11-22T10:53:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2706.815509377634 + ], + [ + "2025-11-22T10:51:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2707.002269745001 + ], + [ + "2025-11-22T10:49:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.374014890025 + ], + [ + "2025-11-22T10:47:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.0279615739732 + ], + [ + "2025-11-22T10:45:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2714.340179519083 + ], + [ + "2025-11-22T10:43:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2709.2287304995016 + ], + [ + "2025-11-22T10:41:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.5460536091045 + ], + [ + "2025-11-22T10:39:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.6532514133423 + ], + [ + "2025-11-22T10:37:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2714.651916270245 + ], + [ + "2025-11-22T10:35:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2713.9786972580437 + ], + [ + "2025-11-22T10:33:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2715.076604007814 + ], + [ + "2025-11-22T10:31:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2711.793671330055 + ], + [ + "2025-11-22T10:29:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2712.2133860579584 + ], + [ + "2025-11-22T10:27:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2710.7438879160836 + ], + [ + "2025-11-22T10:25:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2714.292835786476 + ], + [ + "2025-11-22T10:23:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2724.569063999994 + ], + [ + "2025-11-22T08:29:49Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2729.1099999999924 + ], + [ + "2025-11-22T07:41:12Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2756.435219691037 + ], + [ + "2025-11-22T06:51:29Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2757.714923200011 + ], + [ + "2025-11-22T06:11:27Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.5527718000076 + ], + [ + "2025-11-22T05:30:28Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2734.253002800003 + ], + [ + "2025-11-22T04:47:29Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2731.964766100014 + ], + [ + "2025-11-22T04:12:00Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2739.5491411396697 + ], + [ + "2025-11-22T04:11:59Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2738.7151974100802 + ], + [ + "2025-11-22T03:21:25Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2750.57049019772 + ], + [ + "2025-11-22T02:32:21Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2752.169283735319 + ], + [ + "2025-11-22T01:39:58Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2775.0927024000052 + ], + [ + "2025-11-22T00:58:16Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2763.5804032999968 + ], + [ + "2025-11-22T00:19:05Z", + "0x0000000000000000000000000000000000000000", + "Ethereum", + null, + "MAIR120", + "ETH", + 2765.03927910001 + ] + ] + } + ], + "Messages": null + } + ] +} diff --git a/apps/explorer/test/support/fixture/market/dia/secondary_coin_history.json b/apps/explorer/test/support/fixture/market/dia/secondary_coin_history.json new file mode 100644 index 000000000000..47967c177fd2 --- /dev/null +++ b/apps/explorer/test/support/fixture/market/dia/secondary_coin_history.json @@ -0,0 +1,29490 @@ +{ + "DataPoints": [ + { + "statement_id": 0, + "Series": [ + { + "name": "filters", + "columns": [ + "time", + "address", + "blockchain", + "exchange", + "filter", + "symbol", + "value" + ], + "values": [ + [ + "2025-11-26T23:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997050546597661 + ], + [ + "2025-11-26T23:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997795461438774 + ], + [ + "2025-11-26T23:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997622624626337 + ], + [ + "2025-11-26T23:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997684844415092 + ], + [ + "2025-11-26T23:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997886378014167 + ], + [ + "2025-11-26T23:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997478447346754 + ], + [ + "2025-11-26T23:47:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999051970136559 + ], + [ + "2025-11-26T23:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998091933564365 + ], + [ + "2025-11-26T23:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999245107659688 + ], + [ + "2025-11-26T23:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998473334666932 + ], + [ + "2025-11-26T23:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999388072813172 + ], + [ + "2025-11-26T23:38:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999079003604031 + ], + [ + "2025-11-26T23:35:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999231276345876 + ], + [ + "2025-11-26T23:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999524333137157 + ], + [ + "2025-11-26T23:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998024996089331 + ], + [ + "2025-11-26T23:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999829616399664 + ], + [ + "2025-11-26T23:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998979810466406 + ], + [ + "2025-11-26T23:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999951038305009 + ], + [ + "2025-11-26T23:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998375793017942 + ], + [ + "2025-11-26T23:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999631309082428 + ], + [ + "2025-11-26T23:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999822591515583 + ], + [ + "2025-11-26T23:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999820967926312 + ], + [ + "2025-11-26T23:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997553972136282 + ], + [ + "2025-11-26T23:13:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997789319763989 + ], + [ + "2025-11-26T23:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999546534853955 + ], + [ + "2025-11-26T23:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999807222615671 + ], + [ + "2025-11-26T23:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997972719090844 + ], + [ + "2025-11-26T23:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000246048143222 + ], + [ + "2025-11-26T23:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000028329232342 + ], + [ + "2025-11-26T23:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999088747695573 + ], + [ + "2025-11-26T22:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998731025725487 + ], + [ + "2025-11-26T22:58:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000523971165438 + ], + [ + "2025-11-26T22:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999802270011808 + ], + [ + "2025-11-26T22:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000013842038813 + ], + [ + "2025-11-26T22:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999873653992938 + ], + [ + "2025-11-26T22:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000140504008697 + ], + [ + "2025-11-26T22:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997196218140781 + ], + [ + "2025-11-26T22:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000506190404534 + ], + [ + "2025-11-26T22:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999676594459156 + ], + [ + "2025-11-26T22:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998497675673141 + ], + [ + "2025-11-26T22:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997904114198473 + ], + [ + "2025-11-26T22:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999900635659701 + ], + [ + "2025-11-26T22:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999138852816493 + ], + [ + "2025-11-26T22:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999811263167344 + ], + [ + "2025-11-26T22:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998109831208168 + ], + [ + "2025-11-26T22:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998374686654539 + ], + [ + "2025-11-26T22:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999069491678029 + ], + [ + "2025-11-26T22:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998327235202427 + ], + [ + "2025-11-26T22:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999132137224496 + ], + [ + "2025-11-26T22:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999808250205675 + ], + [ + "2025-11-26T22:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997652729585584 + ], + [ + "2025-11-26T22:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998497046886966 + ], + [ + "2025-11-26T22:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997155872129425 + ], + [ + "2025-11-26T22:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998561997814405 + ], + [ + "2025-11-26T22:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999847620923936 + ], + [ + "2025-11-26T22:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998049516622451 + ], + [ + "2025-11-26T22:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997242120657608 + ], + [ + "2025-11-26T22:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997621660567204 + ], + [ + "2025-11-26T22:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999787874632257 + ], + [ + "2025-11-26T22:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997438232168326 + ], + [ + "2025-11-26T21:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998249859396864 + ], + [ + "2025-11-26T21:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997590827976958 + ], + [ + "2025-11-26T21:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998399469285009 + ], + [ + "2025-11-26T21:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997844338607101 + ], + [ + "2025-11-26T21:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999139483507542 + ], + [ + "2025-11-26T21:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997802537313051 + ], + [ + "2025-11-26T21:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999101709452008 + ], + [ + "2025-11-26T21:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999718103474216 + ], + [ + "2025-11-26T21:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998089246689195 + ], + [ + "2025-11-26T21:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999169994767035 + ], + [ + "2025-11-26T21:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999013584775756 + ], + [ + "2025-11-26T21:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998206992728874 + ], + [ + "2025-11-26T21:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998947983544066 + ], + [ + "2025-11-26T21:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999873658193712 + ], + [ + "2025-11-26T21:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99985412066119 + ], + [ + "2025-11-26T21:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998535326861892 + ], + [ + "2025-11-26T21:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998538108236031 + ], + [ + "2025-11-26T21:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996620029845299 + ], + [ + "2025-11-26T21:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998059294230369 + ], + [ + "2025-11-26T21:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999739292369874 + ], + [ + "2025-11-26T21:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999790085893341 + ], + [ + "2025-11-26T21:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999472366271309 + ], + [ + "2025-11-26T21:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999292898205866 + ], + [ + "2025-11-26T21:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998396426463748 + ], + [ + "2025-11-26T21:12:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997833807068368 + ], + [ + "2025-11-26T21:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001706223090219 + ], + [ + "2025-11-26T21:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001334461731919 + ], + [ + "2025-11-26T21:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999824169135552 + ], + [ + "2025-11-26T21:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998736556174154 + ], + [ + "2025-11-26T21:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997689316814466 + ], + [ + "2025-11-26T20:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997022653675538 + ], + [ + "2025-11-26T20:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997205670471739 + ], + [ + "2025-11-26T20:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998086459722371 + ], + [ + "2025-11-26T20:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997107720899308 + ], + [ + "2025-11-26T20:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996601518238335 + ], + [ + "2025-11-26T20:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996617890419368 + ], + [ + "2025-11-26T20:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997326419738951 + ], + [ + "2025-11-26T20:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996753937479669 + ], + [ + "2025-11-26T20:44:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996969265861991 + ], + [ + "2025-11-26T20:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999119450899464 + ], + [ + "2025-11-26T20:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999181295384537 + ], + [ + "2025-11-26T20:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999741493645329 + ], + [ + "2025-11-26T20:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996664585699486 + ], + [ + "2025-11-26T20:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999876462003662 + ], + [ + "2025-11-26T20:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999400706363507 + ], + [ + "2025-11-26T20:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998708300750315 + ], + [ + "2025-11-26T20:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998631462532491 + ], + [ + "2025-11-26T20:26:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998508607699774 + ], + [ + "2025-11-26T20:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000306001882797 + ], + [ + "2025-11-26T20:21:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999943718789138 + ], + [ + "2025-11-26T20:20:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997407612481735 + ], + [ + "2025-11-26T20:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999637354265408 + ], + [ + "2025-11-26T20:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002712078281848 + ], + [ + "2025-11-26T20:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001153250464254 + ], + [ + "2025-11-26T20:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000670567174224 + ], + [ + "2025-11-26T20:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999565297959054 + ], + [ + "2025-11-26T20:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003531215893302 + ], + [ + "2025-11-26T20:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998452411737069 + ], + [ + "2025-11-26T20:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997747230228671 + ], + [ + "2025-11-26T20:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998216798456928 + ], + [ + "2025-11-26T19:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998634296204367 + ], + [ + "2025-11-26T19:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997814692498667 + ], + [ + "2025-11-26T19:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999764066976513 + ], + [ + "2025-11-26T19:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998917370400144 + ], + [ + "2025-11-26T19:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998867240032958 + ], + [ + "2025-11-26T19:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998336874873804 + ], + [ + "2025-11-26T19:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998386312352499 + ], + [ + "2025-11-26T19:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998848608588113 + ], + [ + "2025-11-26T19:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999490331954825 + ], + [ + "2025-11-26T19:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998205899050473 + ], + [ + "2025-11-26T19:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997561218206235 + ], + [ + "2025-11-26T19:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997097420549027 + ], + [ + "2025-11-26T19:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997947277264851 + ], + [ + "2025-11-26T19:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998888653590094 + ], + [ + "2025-11-26T19:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997662729808814 + ], + [ + "2025-11-26T19:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997717309935543 + ], + [ + "2025-11-26T19:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998656429914854 + ], + [ + "2025-11-26T19:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999828113690766 + ], + [ + "2025-11-26T19:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997327599156698 + ], + [ + "2025-11-26T19:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997257994451819 + ], + [ + "2025-11-26T19:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996755389781946 + ], + [ + "2025-11-26T19:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996019750313625 + ], + [ + "2025-11-26T19:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997397839531686 + ], + [ + "2025-11-26T19:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995240273250311 + ], + [ + "2025-11-26T19:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999735806692482 + ], + [ + "2025-11-26T19:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999889303903008 + ], + [ + "2025-11-26T19:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997741682358227 + ], + [ + "2025-11-26T19:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996685634246064 + ], + [ + "2025-11-26T19:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998443787107734 + ], + [ + "2025-11-26T19:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000641301583235 + ], + [ + "2025-11-26T18:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996294607433589 + ], + [ + "2025-11-26T18:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998870809226978 + ], + [ + "2025-11-26T18:56:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001394109255597 + ], + [ + "2025-11-26T18:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997566571312322 + ], + [ + "2025-11-26T18:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997243167134897 + ], + [ + "2025-11-26T18:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997015569751374 + ], + [ + "2025-11-26T18:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998105989011111 + ], + [ + "2025-11-26T18:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998631443414778 + ], + [ + "2025-11-26T18:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998851921986562 + ], + [ + "2025-11-26T18:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997500326981517 + ], + [ + "2025-11-26T18:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997888849621616 + ], + [ + "2025-11-26T18:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000101942419972 + ], + [ + "2025-11-26T18:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999328031331489 + ], + [ + "2025-11-26T18:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997683415583205 + ], + [ + "2025-11-26T18:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998636003714575 + ], + [ + "2025-11-26T18:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999846865636755 + ], + [ + "2025-11-26T18:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997240588555163 + ], + [ + "2025-11-26T18:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997617284005578 + ], + [ + "2025-11-26T18:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996934370343453 + ], + [ + "2025-11-26T18:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998520973387829 + ], + [ + "2025-11-26T18:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997356009987507 + ], + [ + "2025-11-26T18:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997726159655821 + ], + [ + "2025-11-26T18:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997932927604325 + ], + [ + "2025-11-26T18:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997793007812747 + ], + [ + "2025-11-26T18:12:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999210634299216 + ], + [ + "2025-11-26T18:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998133859152907 + ], + [ + "2025-11-26T18:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998297223113496 + ], + [ + "2025-11-26T18:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998062902722398 + ], + [ + "2025-11-26T18:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999902539792241 + ], + [ + "2025-11-26T18:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998265950399238 + ], + [ + "2025-11-26T17:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998487654462093 + ], + [ + "2025-11-26T17:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996601869840217 + ], + [ + "2025-11-26T17:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997332931403637 + ], + [ + "2025-11-26T17:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997629107491472 + ], + [ + "2025-11-26T17:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998151904947202 + ], + [ + "2025-11-26T17:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998645306635691 + ], + [ + "2025-11-26T17:48:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999779786072583 + ], + [ + "2025-11-26T17:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999136008533324 + ], + [ + "2025-11-26T17:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997545695772351 + ], + [ + "2025-11-26T17:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999781316498219 + ], + [ + "2025-11-26T17:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999392336502844 + ], + [ + "2025-11-26T17:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998225119145083 + ], + [ + "2025-11-26T17:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999482459534397 + ], + [ + "2025-11-26T17:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999933555979289 + ], + [ + "2025-11-26T17:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999751405514377 + ], + [ + "2025-11-26T17:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000926723128327 + ], + [ + "2025-11-26T17:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999540623880309 + ], + [ + "2025-11-26T17:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000245762924402 + ], + [ + "2025-11-26T17:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000041414056701 + ], + [ + "2025-11-26T17:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998635971704719 + ], + [ + "2025-11-26T17:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999504758431653 + ], + [ + "2025-11-26T17:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999889382521444 + ], + [ + "2025-11-26T17:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999734396515993 + ], + [ + "2025-11-26T17:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998874247972065 + ], + [ + "2025-11-26T17:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997746791815078 + ], + [ + "2025-11-26T17:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997293543333358 + ], + [ + "2025-11-26T17:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997364091144402 + ], + [ + "2025-11-26T17:05:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999710120021226 + ], + [ + "2025-11-26T17:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997460493607886 + ], + [ + "2025-11-26T17:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997682661693696 + ], + [ + "2025-11-26T16:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002610765313573 + ], + [ + "2025-11-26T16:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000164279746285 + ], + [ + "2025-11-26T16:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997236340340758 + ], + [ + "2025-11-26T16:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996816050039016 + ], + [ + "2025-11-26T16:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997574122391331 + ], + [ + "2025-11-26T16:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997377811756192 + ], + [ + "2025-11-26T16:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997099633485602 + ], + [ + "2025-11-26T16:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997978261427499 + ], + [ + "2025-11-26T16:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000270096359578 + ], + [ + "2025-11-26T16:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000139001518643 + ], + [ + "2025-11-26T16:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999680006053887 + ], + [ + "2025-11-26T16:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999799231849043 + ], + [ + "2025-11-26T16:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999505580550245 + ], + [ + "2025-11-26T16:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998043986669755 + ], + [ + "2025-11-26T16:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999137567602109 + ], + [ + "2025-11-26T16:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999117011086135 + ], + [ + "2025-11-26T16:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997145896768646 + ], + [ + "2025-11-26T16:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000228418616584 + ], + [ + "2025-11-26T16:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999996521625361 + ], + [ + "2025-11-26T16:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998221497783564 + ], + [ + "2025-11-26T16:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997808350543667 + ], + [ + "2025-11-26T16:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999946719725185 + ], + [ + "2025-11-26T16:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999933841666305 + ], + [ + "2025-11-26T16:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996539965629246 + ], + [ + "2025-11-26T16:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999388373678275 + ], + [ + "2025-11-26T16:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999717993513633 + ], + [ + "2025-11-26T16:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999554457649874 + ], + [ + "2025-11-26T16:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996212078512571 + ], + [ + "2025-11-26T16:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998481709088812 + ], + [ + "2025-11-26T16:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996617178448969 + ], + [ + "2025-11-26T15:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998599977256668 + ], + [ + "2025-11-26T15:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998247468998889 + ], + [ + "2025-11-26T15:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997838349271123 + ], + [ + "2025-11-26T15:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996680762499556 + ], + [ + "2025-11-26T15:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001301501484987 + ], + [ + "2025-11-26T15:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003321719377836 + ], + [ + "2025-11-26T15:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997969681891393 + ], + [ + "2025-11-26T15:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997361269601972 + ], + [ + "2025-11-26T15:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012116787950047 + ], + [ + "2025-11-26T15:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006448272050328 + ], + [ + "2025-11-26T15:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997625736835359 + ], + [ + "2025-11-26T15:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996885337862177 + ], + [ + "2025-11-26T15:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996667464360156 + ], + [ + "2025-11-26T15:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996969821075177 + ], + [ + "2025-11-26T15:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996875972955518 + ], + [ + "2025-11-26T15:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997854517412105 + ], + [ + "2025-11-26T15:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997741051894488 + ], + [ + "2025-11-26T15:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996277161515187 + ], + [ + "2025-11-26T15:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998303683517451 + ], + [ + "2025-11-26T15:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999128592572264 + ], + [ + "2025-11-26T15:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998067808967316 + ], + [ + "2025-11-26T15:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999576045612363 + ], + [ + "2025-11-26T15:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999172882784734 + ], + [ + "2025-11-26T15:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999786926195497 + ], + [ + "2025-11-26T15:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998910526059578 + ], + [ + "2025-11-26T15:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998593847911524 + ], + [ + "2025-11-26T15:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997463985845988 + ], + [ + "2025-11-26T15:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997335831322945 + ], + [ + "2025-11-26T15:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997350005966174 + ], + [ + "2025-11-26T15:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996499573975608 + ], + [ + "2025-11-26T14:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997880951669488 + ], + [ + "2025-11-26T14:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998467100585365 + ], + [ + "2025-11-26T14:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997665955126598 + ], + [ + "2025-11-26T14:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9993733051790405 + ], + [ + "2025-11-26T14:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997675656915228 + ], + [ + "2025-11-26T14:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000355593908898 + ], + [ + "2025-11-26T14:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99957366410737 + ], + [ + "2025-11-26T14:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997092695040444 + ], + [ + "2025-11-26T14:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997041261350035 + ], + [ + "2025-11-26T14:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007325951959465 + ], + [ + "2025-11-26T14:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997683969300291 + ], + [ + "2025-11-26T14:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997994203398345 + ], + [ + "2025-11-26T14:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996455888482407 + ], + [ + "2025-11-26T14:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998111365413809 + ], + [ + "2025-11-26T14:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999186513417809 + ], + [ + "2025-11-26T14:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998349306120303 + ], + [ + "2025-11-26T14:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997753356165611 + ], + [ + "2025-11-26T14:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998454004896326 + ], + [ + "2025-11-26T14:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005717227414035 + ], + [ + "2025-11-26T14:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999192590693992 + ], + [ + "2025-11-26T14:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996550928412652 + ], + [ + "2025-11-26T14:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997929781589904 + ], + [ + "2025-11-26T14:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000069839705712 + ], + [ + "2025-11-26T14:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999722953832713 + ], + [ + "2025-11-26T14:12:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997055871445467 + ], + [ + "2025-11-26T14:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997762319082045 + ], + [ + "2025-11-26T14:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997230988084514 + ], + [ + "2025-11-26T14:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996814367429007 + ], + [ + "2025-11-26T14:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997094910107032 + ], + [ + "2025-11-26T14:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999735892229098 + ], + [ + "2025-11-26T13:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997171438701996 + ], + [ + "2025-11-26T13:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997452683765626 + ], + [ + "2025-11-26T13:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995011042892579 + ], + [ + "2025-11-26T13:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999819911218695 + ], + [ + "2025-11-26T13:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997386750138164 + ], + [ + "2025-11-26T13:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998690098092164 + ], + [ + "2025-11-26T13:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998813017377433 + ], + [ + "2025-11-26T13:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998262656491248 + ], + [ + "2025-11-26T13:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998985253855557 + ], + [ + "2025-11-26T13:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000290982977089 + ], + [ + "2025-11-26T13:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999968310708264 + ], + [ + "2025-11-26T13:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998644933340559 + ], + [ + "2025-11-26T13:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999326608674087 + ], + [ + "2025-11-26T13:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999454151382755 + ], + [ + "2025-11-26T13:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999480228507963 + ], + [ + "2025-11-26T13:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998839113887685 + ], + [ + "2025-11-26T13:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000751101203245 + ], + [ + "2025-11-26T13:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999749708832555 + ], + [ + "2025-11-26T13:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998677795768426 + ], + [ + "2025-11-26T13:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001797407224928 + ], + [ + "2025-11-26T13:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998584028755215 + ], + [ + "2025-11-26T13:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998364122456607 + ], + [ + "2025-11-26T13:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002083304641731 + ], + [ + "2025-11-26T13:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000567247691872 + ], + [ + "2025-11-26T13:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998135901409203 + ], + [ + "2025-11-26T13:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998688041802336 + ], + [ + "2025-11-26T13:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999700068569337 + ], + [ + "2025-11-26T13:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001911307874038 + ], + [ + "2025-11-26T13:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999065173620074 + ], + [ + "2025-11-26T13:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001358104425864 + ], + [ + "2025-11-26T12:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010881401184455 + ], + [ + "2025-11-26T12:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000522407727283 + ], + [ + "2025-11-26T12:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999456363383102 + ], + [ + "2025-11-26T12:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999941990534663 + ], + [ + "2025-11-26T12:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011616211067749 + ], + [ + "2025-11-26T12:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005001628383334 + ], + [ + "2025-11-26T12:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998870593605257 + ], + [ + "2025-11-26T12:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998063989016451 + ], + [ + "2025-11-26T12:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014870231504558 + ], + [ + "2025-11-26T12:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006566921115039 + ], + [ + "2025-11-26T12:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999378449434309 + ], + [ + "2025-11-26T12:38:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001523177051863 + ], + [ + "2025-11-26T12:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999075651351566 + ], + [ + "2025-11-26T12:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998879711465879 + ], + [ + "2025-11-26T12:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003299331802684 + ], + [ + "2025-11-26T12:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001973495290437 + ], + [ + "2025-11-26T12:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999777396932872 + ], + [ + "2025-11-26T12:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998806951891298 + ], + [ + "2025-11-26T12:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004617949295418 + ], + [ + "2025-11-26T12:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000779023336917 + ], + [ + "2025-11-26T12:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998366723309333 + ], + [ + "2025-11-26T12:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000826172317978 + ], + [ + "2025-11-26T12:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000409555625729 + ], + [ + "2025-11-26T12:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000709600051556 + ], + [ + "2025-11-26T12:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999592995851283 + ], + [ + "2025-11-26T12:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003221852584652 + ], + [ + "2025-11-26T12:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998136112017 + ], + [ + "2025-11-26T12:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000769920728871 + ], + [ + "2025-11-26T12:03:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000777288491647 + ], + [ + "2025-11-26T12:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998804967484063 + ], + [ + "2025-11-26T11:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999849576061272 + ], + [ + "2025-11-26T11:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00047612316213 + ], + [ + "2025-11-26T11:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999070806462985 + ], + [ + "2025-11-26T11:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999060264257905 + ], + [ + "2025-11-26T11:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000457220665788 + ], + [ + "2025-11-26T11:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000774720228562 + ], + [ + "2025-11-26T11:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998850743933388 + ], + [ + "2025-11-26T11:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999899643502157 + ], + [ + "2025-11-26T11:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006108042101518 + ], + [ + "2025-11-26T11:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009611932740086 + ], + [ + "2025-11-26T11:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998618121432056 + ], + [ + "2025-11-26T11:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999637870640251 + ], + [ + "2025-11-26T11:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998603810315907 + ], + [ + "2025-11-26T11:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998103162543649 + ], + [ + "2025-11-26T11:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998666014345974 + ], + [ + "2025-11-26T11:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999324578988507 + ], + [ + "2025-11-26T11:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999486328514133 + ], + [ + "2025-11-26T11:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998124748056777 + ], + [ + "2025-11-26T11:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998343725273814 + ], + [ + "2025-11-26T11:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999882749409426 + ], + [ + "2025-11-26T11:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998007626200522 + ], + [ + "2025-11-26T11:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002662016151371 + ], + [ + "2025-11-26T11:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999822439134867 + ], + [ + "2025-11-26T11:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003794133258153 + ], + [ + "2025-11-26T11:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998650666248726 + ], + [ + "2025-11-26T11:10:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999888665298494 + ], + [ + "2025-11-26T11:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998870049163512 + ], + [ + "2025-11-26T11:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001369748142113 + ], + [ + "2025-11-26T11:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002682417177429 + ], + [ + "2025-11-26T11:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000187198666304 + ], + [ + "2025-11-26T10:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999826512375682 + ], + [ + "2025-11-26T10:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000650525148273 + ], + [ + "2025-11-26T10:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998374769196834 + ], + [ + "2025-11-26T10:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008390148244821 + ], + [ + "2025-11-26T10:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997357187101388 + ], + [ + "2025-11-26T10:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000234381324837 + ], + [ + "2025-11-26T10:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999010756714347 + ], + [ + "2025-11-26T10:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998153798235446 + ], + [ + "2025-11-26T10:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002144414542458 + ], + [ + "2025-11-26T10:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001679236650538 + ], + [ + "2025-11-26T10:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005077780595162 + ], + [ + "2025-11-26T10:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000852265580893 + ], + [ + "2025-11-26T10:36:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010309277031708 + ], + [ + "2025-11-26T10:34:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00030168562641 + ], + [ + "2025-11-26T10:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999939333014742 + ], + [ + "2025-11-26T10:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000792564779008 + ], + [ + "2025-11-26T10:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009571367808856 + ], + [ + "2025-11-26T10:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012907726384097 + ], + [ + "2025-11-26T10:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003364545278 + ], + [ + "2025-11-26T10:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008890386864417 + ], + [ + "2025-11-26T10:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002238754353294 + ], + [ + "2025-11-26T10:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000717954129659 + ], + [ + "2025-11-26T10:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004293467654466 + ], + [ + "2025-11-26T10:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998719793195504 + ], + [ + "2025-11-26T10:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00028812911569 + ], + [ + "2025-11-26T10:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000323620754221 + ], + [ + "2025-11-26T10:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000040850699472 + ], + [ + "2025-11-26T10:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999884657911759 + ], + [ + "2025-11-26T10:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003971781453662 + ], + [ + "2025-11-26T10:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012451983212836 + ], + [ + "2025-11-26T09:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010612267585455 + ], + [ + "2025-11-26T09:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999295317321326 + ], + [ + "2025-11-26T09:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000298832818842 + ], + [ + "2025-11-26T09:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997796145204159 + ], + [ + "2025-11-26T09:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997545053376904 + ], + [ + "2025-11-26T09:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000123210305551 + ], + [ + "2025-11-26T09:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999973677148806 + ], + [ + "2025-11-26T09:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998203026163514 + ], + [ + "2025-11-26T09:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998188338487439 + ], + [ + "2025-11-26T09:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009521702798987 + ], + [ + "2025-11-26T09:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008210725784565 + ], + [ + "2025-11-26T09:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999211660053963 + ], + [ + "2025-11-26T09:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000012854704733 + ], + [ + "2025-11-26T09:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00110247472653 + ], + [ + "2025-11-26T09:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011074597669078 + ], + [ + "2025-11-26T09:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004391236972314 + ], + [ + "2025-11-26T09:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997422703739989 + ], + [ + "2025-11-26T09:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004679784524486 + ], + [ + "2025-11-26T09:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996855257125505 + ], + [ + "2025-11-26T09:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000103969947395 + ], + [ + "2025-11-26T09:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997636192888967 + ], + [ + "2025-11-26T09:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998191296791699 + ], + [ + "2025-11-26T09:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997028510023256 + ], + [ + "2025-11-26T09:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997526949664814 + ], + [ + "2025-11-26T09:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998469361391479 + ], + [ + "2025-11-26T09:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007032489597059 + ], + [ + "2025-11-26T09:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997380804805045 + ], + [ + "2025-11-26T09:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997309056122958 + ], + [ + "2025-11-26T09:03:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999713522071605 + ], + [ + "2025-11-26T09:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008052526591957 + ], + [ + "2025-11-26T08:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997341300107307 + ], + [ + "2025-11-26T08:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997839939344135 + ], + [ + "2025-11-26T08:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997615793512757 + ], + [ + "2025-11-26T08:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996508228770206 + ], + [ + "2025-11-26T08:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999752994385011 + ], + [ + "2025-11-26T08:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997522994016826 + ], + [ + "2025-11-26T08:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999761671350318 + ], + [ + "2025-11-26T08:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997408184859947 + ], + [ + "2025-11-26T08:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998140014891417 + ], + [ + "2025-11-26T08:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000348032240225 + ], + [ + "2025-11-26T08:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998634238020868 + ], + [ + "2025-11-26T08:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998413319211761 + ], + [ + "2025-11-26T08:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003165011247794 + ], + [ + "2025-11-26T08:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997800952051825 + ], + [ + "2025-11-26T08:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000760336666836 + ], + [ + "2025-11-26T08:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004220773050547 + ], + [ + "2025-11-26T08:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999744534052076 + ], + [ + "2025-11-26T08:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001474309680427 + ], + [ + "2025-11-26T08:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997674198165288 + ], + [ + "2025-11-26T08:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997494343177404 + ], + [ + "2025-11-26T08:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997620752378631 + ], + [ + "2025-11-26T08:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997883895378386 + ], + [ + "2025-11-26T08:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996893928388778 + ], + [ + "2025-11-26T08:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997849279830453 + ], + [ + "2025-11-26T08:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997267436071954 + ], + [ + "2025-11-26T08:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996435941080886 + ], + [ + "2025-11-26T08:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997554061819988 + ], + [ + "2025-11-26T08:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996804146662798 + ], + [ + "2025-11-26T08:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997335521003662 + ], + [ + "2025-11-26T08:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996924974697736 + ], + [ + "2025-11-26T07:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997046563251035 + ], + [ + "2025-11-26T07:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997200750108948 + ], + [ + "2025-11-26T07:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998234488819675 + ], + [ + "2025-11-26T07:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997630090030514 + ], + [ + "2025-11-26T07:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997880704132861 + ], + [ + "2025-11-26T07:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999786773984595 + ], + [ + "2025-11-26T07:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997552713163189 + ], + [ + "2025-11-26T07:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996816435801261 + ], + [ + "2025-11-26T07:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005236651722178 + ], + [ + "2025-11-26T07:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997013495670791 + ], + [ + "2025-11-26T07:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998390966242283 + ], + [ + "2025-11-26T07:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997090642020382 + ], + [ + "2025-11-26T07:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005119501868964 + ], + [ + "2025-11-26T07:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997662933727344 + ], + [ + "2025-11-26T07:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998006826401035 + ], + [ + "2025-11-26T07:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997439469216635 + ], + [ + "2025-11-26T07:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004962152925474 + ], + [ + "2025-11-26T07:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997856667541938 + ], + [ + "2025-11-26T07:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999793292610901 + ], + [ + "2025-11-26T07:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997210143328584 + ], + [ + "2025-11-26T07:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997538161459449 + ], + [ + "2025-11-26T07:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997664394930211 + ], + [ + "2025-11-26T07:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000495652776042 + ], + [ + "2025-11-26T07:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997846441698366 + ], + [ + "2025-11-26T07:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997315111726648 + ], + [ + "2025-11-26T07:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999749251742152 + ], + [ + "2025-11-26T07:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002500519376358 + ], + [ + "2025-11-26T07:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996375828930766 + ], + [ + "2025-11-26T07:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002011123253491 + ], + [ + "2025-11-26T07:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997597509798141 + ], + [ + "2025-11-26T06:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997643777598351 + ], + [ + "2025-11-26T06:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996929960820701 + ], + [ + "2025-11-26T06:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999799288684716 + ], + [ + "2025-11-26T06:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997246609331445 + ], + [ + "2025-11-26T06:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997228939002616 + ], + [ + "2025-11-26T06:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997754687624205 + ], + [ + "2025-11-26T06:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996964889086409 + ], + [ + "2025-11-26T06:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997619945917894 + ], + [ + "2025-11-26T06:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997858051111927 + ], + [ + "2025-11-26T06:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999784842085863 + ], + [ + "2025-11-26T06:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997860165414512 + ], + [ + "2025-11-26T06:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998029675607183 + ], + [ + "2025-11-26T06:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002245236116953 + ], + [ + "2025-11-26T06:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998243952674934 + ], + [ + "2025-11-26T06:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997917099608962 + ], + [ + "2025-11-26T06:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999771143980994 + ], + [ + "2025-11-26T06:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002720297736865 + ], + [ + "2025-11-26T06:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999586391694893 + ], + [ + "2025-11-26T06:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996119698624301 + ], + [ + "2025-11-26T06:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000239895251632 + ], + [ + "2025-11-26T06:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000503472465654 + ], + [ + "2025-11-26T06:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004204535472563 + ], + [ + "2025-11-26T06:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999778125915925 + ], + [ + "2025-11-26T06:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003852751482694 + ], + [ + "2025-11-26T06:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008513301257147 + ], + [ + "2025-11-26T06:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003795196546732 + ], + [ + "2025-11-26T06:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999867516333719 + ], + [ + "2025-11-26T06:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008064400353387 + ], + [ + "2025-11-26T06:03:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000938938386978 + ], + [ + "2025-11-26T06:01:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999006027384443 + ], + [ + "2025-11-26T05:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999517846362147 + ], + [ + "2025-11-26T05:58:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996815344605331 + ], + [ + "2025-11-26T05:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002643608501083 + ], + [ + "2025-11-26T05:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997879603960761 + ], + [ + "2025-11-26T05:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002061778950033 + ], + [ + "2025-11-26T05:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996836066332527 + ], + [ + "2025-11-26T05:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000426056014424 + ], + [ + "2025-11-26T05:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001754661455324 + ], + [ + "2025-11-26T05:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997879054879624 + ], + [ + "2025-11-26T05:42:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997733109967785 + ], + [ + "2025-11-26T05:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997501592176846 + ], + [ + "2025-11-26T05:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998437274398727 + ], + [ + "2025-11-26T05:35:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997796935919436 + ], + [ + "2025-11-26T05:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998811081730907 + ], + [ + "2025-11-26T05:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997561480942823 + ], + [ + "2025-11-26T05:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996812277256962 + ], + [ + "2025-11-26T05:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997550807460167 + ], + [ + "2025-11-26T05:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997245701241113 + ], + [ + "2025-11-26T05:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997921044455745 + ], + [ + "2025-11-26T05:21:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997485353475897 + ], + [ + "2025-11-26T05:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997823429747327 + ], + [ + "2025-11-26T05:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998825607962248 + ], + [ + "2025-11-26T05:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997792236819835 + ], + [ + "2025-11-26T05:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997780731353004 + ], + [ + "2025-11-26T05:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004284499186262 + ], + [ + "2025-11-26T05:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000065164903996 + ], + [ + "2025-11-26T05:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999726908003204 + ], + [ + "2025-11-26T05:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998037040762707 + ], + [ + "2025-11-26T05:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997757977013993 + ], + [ + "2025-11-26T05:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998408468579235 + ], + [ + "2025-11-26T04:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997376781387377 + ], + [ + "2025-11-26T04:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998576541599482 + ], + [ + "2025-11-26T04:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997875713967023 + ], + [ + "2025-11-26T04:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997876131312446 + ], + [ + "2025-11-26T04:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997830503040498 + ], + [ + "2025-11-26T04:50:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997412799290307 + ], + [ + "2025-11-26T04:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997671709658129 + ], + [ + "2025-11-26T04:46:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997982989149199 + ], + [ + "2025-11-26T04:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999764694388367 + ], + [ + "2025-11-26T04:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998156728319927 + ], + [ + "2025-11-26T04:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996824539818425 + ], + [ + "2025-11-26T04:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998107629805179 + ], + [ + "2025-11-26T04:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999787966232131 + ], + [ + "2025-11-26T04:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999802760125009 + ], + [ + "2025-11-26T04:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997842165726245 + ], + [ + "2025-11-26T04:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999784986429668 + ], + [ + "2025-11-26T04:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997492970626436 + ], + [ + "2025-11-26T04:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998425759912646 + ], + [ + "2025-11-26T04:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998067329423997 + ], + [ + "2025-11-26T04:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997841150861893 + ], + [ + "2025-11-26T04:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997286498556923 + ], + [ + "2025-11-26T04:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997695795551069 + ], + [ + "2025-11-26T04:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998438780687271 + ], + [ + "2025-11-26T04:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998012408398593 + ], + [ + "2025-11-26T04:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996622306076249 + ], + [ + "2025-11-26T04:10:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999601621306343 + ], + [ + "2025-11-26T04:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998144138206004 + ], + [ + "2025-11-26T04:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996865252326239 + ], + [ + "2025-11-26T04:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997587034396404 + ], + [ + "2025-11-26T04:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997967457153738 + ], + [ + "2025-11-26T03:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997516193922715 + ], + [ + "2025-11-26T03:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997618897440241 + ], + [ + "2025-11-26T03:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999745185698625 + ], + [ + "2025-11-26T03:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997380105275097 + ], + [ + "2025-11-26T03:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996930733922931 + ], + [ + "2025-11-26T03:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998449690673736 + ], + [ + "2025-11-26T03:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998134073098326 + ], + [ + "2025-11-26T03:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997677255998942 + ], + [ + "2025-11-26T03:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996570138108481 + ], + [ + "2025-11-26T03:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005456345553203 + ], + [ + "2025-11-26T03:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997663125124817 + ], + [ + "2025-11-26T03:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998008890591024 + ], + [ + "2025-11-26T03:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997616661663569 + ], + [ + "2025-11-26T03:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997812309355795 + ], + [ + "2025-11-26T03:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998683832126245 + ], + [ + "2025-11-26T03:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998504058441008 + ], + [ + "2025-11-26T03:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997428148446021 + ], + [ + "2025-11-26T03:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996978879869041 + ], + [ + "2025-11-26T03:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997406836175855 + ], + [ + "2025-11-26T03:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998450057599841 + ], + [ + "2025-11-26T03:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998612448117129 + ], + [ + "2025-11-26T03:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997344190850849 + ], + [ + "2025-11-26T03:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997269691896263 + ], + [ + "2025-11-26T03:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997026247658888 + ], + [ + "2025-11-26T03:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996956891903067 + ], + [ + "2025-11-26T03:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999688708648247 + ], + [ + "2025-11-26T03:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997965661264764 + ], + [ + "2025-11-26T03:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995709601122795 + ], + [ + "2025-11-26T03:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998251601191542 + ], + [ + "2025-11-26T03:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997760955634131 + ], + [ + "2025-11-26T02:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999849138920695 + ], + [ + "2025-11-26T02:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996820665133551 + ], + [ + "2025-11-26T02:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000765533712503 + ], + [ + "2025-11-26T02:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998295000081536 + ], + [ + "2025-11-26T02:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997837488726656 + ], + [ + "2025-11-26T02:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999780786113811 + ], + [ + "2025-11-26T02:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001922881191077 + ], + [ + "2025-11-26T02:46:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999149912448297 + ], + [ + "2025-11-26T02:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997736263674968 + ], + [ + "2025-11-26T02:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997395598595967 + ], + [ + "2025-11-26T02:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000661368695343 + ], + [ + "2025-11-26T02:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996479610545492 + ], + [ + "2025-11-26T02:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999604177009162 + ], + [ + "2025-11-26T02:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998676709826383 + ], + [ + "2025-11-26T02:32:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999796293757662 + ], + [ + "2025-11-26T02:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997477627469733 + ], + [ + "2025-11-26T02:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996593795156006 + ], + [ + "2025-11-26T02:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001836890667466 + ], + [ + "2025-11-26T02:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004377025366296 + ], + [ + "2025-11-26T02:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004248081968192 + ], + [ + "2025-11-26T02:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997849994654961 + ], + [ + "2025-11-26T02:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997437271199381 + ], + [ + "2025-11-26T02:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998306217872966 + ], + [ + "2025-11-26T02:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997842805000094 + ], + [ + "2025-11-26T02:11:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997480042720017 + ], + [ + "2025-11-26T02:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998113267930587 + ], + [ + "2025-11-26T02:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997550396553911 + ], + [ + "2025-11-26T02:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999666794880583 + ], + [ + "2025-11-26T02:03:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998622806096527 + ], + [ + "2025-11-26T02:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998469589691392 + ], + [ + "2025-11-26T02:00:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998733126913399 + ], + [ + "2025-11-26T01:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996911099605534 + ], + [ + "2025-11-26T01:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997847662073179 + ], + [ + "2025-11-26T01:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998912838606265 + ], + [ + "2025-11-26T01:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996760508379052 + ], + [ + "2025-11-26T01:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995727170090175 + ], + [ + "2025-11-26T01:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998828168504814 + ], + [ + "2025-11-26T01:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003752748819692 + ], + [ + "2025-11-26T01:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997790475952594 + ], + [ + "2025-11-26T01:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996867429113795 + ], + [ + "2025-11-26T01:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999437648685986 + ], + [ + "2025-11-26T01:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996462884838182 + ], + [ + "2025-11-26T01:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996734854954478 + ], + [ + "2025-11-26T01:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996210704185837 + ], + [ + "2025-11-26T01:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997780451708415 + ], + [ + "2025-11-26T01:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996605042579109 + ], + [ + "2025-11-26T01:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997060624648059 + ], + [ + "2025-11-26T01:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996391781807744 + ], + [ + "2025-11-26T01:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998085666912127 + ], + [ + "2025-11-26T01:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996712421660956 + ], + [ + "2025-11-26T01:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997367768842403 + ], + [ + "2025-11-26T01:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997459011588228 + ], + [ + "2025-11-26T01:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997204284617924 + ], + [ + "2025-11-26T01:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997122531838406 + ], + [ + "2025-11-26T01:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99979291493484 + ], + [ + "2025-11-26T01:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996890475625196 + ], + [ + "2025-11-26T01:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996479731966019 + ], + [ + "2025-11-26T01:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997320300101347 + ], + [ + "2025-11-26T01:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997786959801203 + ], + [ + "2025-11-26T01:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997450308788195 + ], + [ + "2025-11-26T00:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996339770544184 + ], + [ + "2025-11-26T00:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996896803981311 + ], + [ + "2025-11-26T00:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996998458339358 + ], + [ + "2025-11-26T00:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997370906591995 + ], + [ + "2025-11-26T00:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997200660116158 + ], + [ + "2025-11-26T00:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997382397680332 + ], + [ + "2025-11-26T00:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997046513341755 + ], + [ + "2025-11-26T00:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997860921698508 + ], + [ + "2025-11-26T00:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999817571443867 + ], + [ + "2025-11-26T00:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998076942552411 + ], + [ + "2025-11-26T00:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997520194461893 + ], + [ + "2025-11-26T00:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997091624293425 + ], + [ + "2025-11-26T00:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998195058251192 + ], + [ + "2025-11-26T00:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995077364844934 + ], + [ + "2025-11-26T00:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997305951133287 + ], + [ + "2025-11-26T00:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999791873143188 + ], + [ + "2025-11-26T00:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999837874353635 + ], + [ + "2025-11-26T00:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995781356317536 + ], + [ + "2025-11-26T00:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998128299699816 + ], + [ + "2025-11-26T00:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997397895555438 + ], + [ + "2025-11-26T00:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997787321702079 + ], + [ + "2025-11-26T00:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998103627772589 + ], + [ + "2025-11-26T00:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997876061978161 + ], + [ + "2025-11-26T00:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997863821999154 + ], + [ + "2025-11-26T00:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997517671165176 + ], + [ + "2025-11-26T00:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997299149662209 + ], + [ + "2025-11-26T00:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997949185361393 + ], + [ + "2025-11-26T00:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997904453270198 + ], + [ + "2025-11-26T00:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997449561708841 + ], + [ + "2025-11-26T00:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998251538809131 + ], + [ + "2025-11-25T23:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997550066438543 + ], + [ + "2025-11-25T23:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996791734897135 + ], + [ + "2025-11-25T23:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997937585492749 + ], + [ + "2025-11-25T23:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001966764615313 + ], + [ + "2025-11-25T23:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997532092177781 + ], + [ + "2025-11-25T23:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997238646733568 + ], + [ + "2025-11-25T23:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997347691066918 + ], + [ + "2025-11-25T23:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002072274632847 + ], + [ + "2025-11-25T23:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999263933478111 + ], + [ + "2025-11-25T23:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997451077478194 + ], + [ + "2025-11-25T23:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998089585340177 + ], + [ + "2025-11-25T23:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997426867687471 + ], + [ + "2025-11-25T23:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997287965006512 + ], + [ + "2025-11-25T23:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997444850357957 + ], + [ + "2025-11-25T23:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997875650818193 + ], + [ + "2025-11-25T23:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997150568533771 + ], + [ + "2025-11-25T23:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998514691613115 + ], + [ + "2025-11-25T23:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002947026835998 + ], + [ + "2025-11-25T23:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999819107013759 + ], + [ + "2025-11-25T23:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998309367261093 + ], + [ + "2025-11-25T23:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999860345731084 + ], + [ + "2025-11-25T23:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007404180841295 + ], + [ + "2025-11-25T23:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997682776732236 + ], + [ + "2025-11-25T23:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998656537543463 + ], + [ + "2025-11-25T23:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998508094603751 + ], + [ + "2025-11-25T23:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996101097858489 + ], + [ + "2025-11-25T23:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996110880627449 + ], + [ + "2025-11-25T23:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997551442023004 + ], + [ + "2025-11-25T23:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998265613692391 + ], + [ + "2025-11-25T23:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995859018707154 + ], + [ + "2025-11-25T22:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997889877016393 + ], + [ + "2025-11-25T22:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998452760325784 + ], + [ + "2025-11-25T22:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997676072182236 + ], + [ + "2025-11-25T22:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997806561556104 + ], + [ + "2025-11-25T22:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997647288021507 + ], + [ + "2025-11-25T22:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997971646650989 + ], + [ + "2025-11-25T22:47:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996430816871948 + ], + [ + "2025-11-25T22:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999786470296556 + ], + [ + "2025-11-25T22:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995386693446084 + ], + [ + "2025-11-25T22:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998910128306773 + ], + [ + "2025-11-25T22:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998326850445499 + ], + [ + "2025-11-25T22:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998263798121627 + ], + [ + "2025-11-25T22:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997003246485014 + ], + [ + "2025-11-25T22:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999458150130983 + ], + [ + "2025-11-25T22:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994663178488665 + ], + [ + "2025-11-25T22:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998051398884983 + ], + [ + "2025-11-25T22:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997778211086326 + ], + [ + "2025-11-25T22:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000767206533203 + ], + [ + "2025-11-25T22:24:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997547965277152 + ], + [ + "2025-11-25T22:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009313260018577 + ], + [ + "2025-11-25T22:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000333146863776 + ], + [ + "2025-11-25T22:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003143054987496 + ], + [ + "2025-11-25T22:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997340735236713 + ], + [ + "2025-11-25T22:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996938319674609 + ], + [ + "2025-11-25T22:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999762797152505 + ], + [ + "2025-11-25T22:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004394140777948 + ], + [ + "2025-11-25T22:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998108580786356 + ], + [ + "2025-11-25T22:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997803178602522 + ], + [ + "2025-11-25T22:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997685031951493 + ], + [ + "2025-11-25T22:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010328400495172 + ], + [ + "2025-11-25T21:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997445112656844 + ], + [ + "2025-11-25T21:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996154611039434 + ], + [ + "2025-11-25T21:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999756822806708 + ], + [ + "2025-11-25T21:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997208994934466 + ], + [ + "2025-11-25T21:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997730617313436 + ], + [ + "2025-11-25T21:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996958467528241 + ], + [ + "2025-11-25T21:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001190041894494 + ], + [ + "2025-11-25T21:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997779363403356 + ], + [ + "2025-11-25T21:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001440554505152 + ], + [ + "2025-11-25T21:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996738484835662 + ], + [ + "2025-11-25T21:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012330341934137 + ], + [ + "2025-11-25T21:38:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004380289405574 + ], + [ + "2025-11-25T21:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007298474394166 + ], + [ + "2025-11-25T21:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997717792954275 + ], + [ + "2025-11-25T21:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997070881635174 + ], + [ + "2025-11-25T21:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998039172141308 + ], + [ + "2025-11-25T21:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998599181111485 + ], + [ + "2025-11-25T21:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999340698911114 + ], + [ + "2025-11-25T21:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996446585629865 + ], + [ + "2025-11-25T21:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999769246852974 + ], + [ + "2025-11-25T21:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997367788576416 + ], + [ + "2025-11-25T21:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999675578844796 + ], + [ + "2025-11-25T21:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995937667604671 + ], + [ + "2025-11-25T21:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999842755636638 + ], + [ + "2025-11-25T21:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997155261211521 + ], + [ + "2025-11-25T21:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996906808197678 + ], + [ + "2025-11-25T21:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998691740141862 + ], + [ + "2025-11-25T21:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997213259017618 + ], + [ + "2025-11-25T21:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997876192478263 + ], + [ + "2025-11-25T21:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996759273129757 + ], + [ + "2025-11-25T20:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997098324056207 + ], + [ + "2025-11-25T20:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996594360542362 + ], + [ + "2025-11-25T20:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997210813567421 + ], + [ + "2025-11-25T20:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997251563031688 + ], + [ + "2025-11-25T20:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996640911382552 + ], + [ + "2025-11-25T20:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997791462165742 + ], + [ + "2025-11-25T20:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995382497315682 + ], + [ + "2025-11-25T20:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997503396574499 + ], + [ + "2025-11-25T20:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9992772652993548 + ], + [ + "2025-11-25T20:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997264593952254 + ], + [ + "2025-11-25T20:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994893284432598 + ], + [ + "2025-11-25T20:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997234652035419 + ], + [ + "2025-11-25T20:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999375005604837 + ], + [ + "2025-11-25T20:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998759300718616 + ], + [ + "2025-11-25T20:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995836715808167 + ], + [ + "2025-11-25T20:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999611419502628 + ], + [ + "2025-11-25T20:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99947689662861 + ], + [ + "2025-11-25T20:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997261367524656 + ], + [ + "2025-11-25T20:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996546098739593 + ], + [ + "2025-11-25T20:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995970732920295 + ], + [ + "2025-11-25T20:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995094163923016 + ], + [ + "2025-11-25T20:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996698465961774 + ], + [ + "2025-11-25T20:15:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996623172149655 + ], + [ + "2025-11-25T20:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996071307775082 + ], + [ + "2025-11-25T20:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997129048493086 + ], + [ + "2025-11-25T20:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999378690978973 + ], + [ + "2025-11-25T20:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997148098891812 + ], + [ + "2025-11-25T20:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996025503325859 + ], + [ + "2025-11-25T20:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996326031589171 + ], + [ + "2025-11-25T20:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994145925401084 + ], + [ + "2025-11-25T19:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997112945982749 + ], + [ + "2025-11-25T19:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995671277329661 + ], + [ + "2025-11-25T19:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995431598211489 + ], + [ + "2025-11-25T19:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994267600454952 + ], + [ + "2025-11-25T19:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995271046302686 + ], + [ + "2025-11-25T19:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996099704482266 + ], + [ + "2025-11-25T19:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996245285342245 + ], + [ + "2025-11-25T19:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995171087317197 + ], + [ + "2025-11-25T19:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995979036988054 + ], + [ + "2025-11-25T19:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996039795112999 + ], + [ + "2025-11-25T19:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996326284847838 + ], + [ + "2025-11-25T19:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999590416122589 + ], + [ + "2025-11-25T19:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996398246767312 + ], + [ + "2025-11-25T19:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997451773209135 + ], + [ + "2025-11-25T19:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996581873908864 + ], + [ + "2025-11-25T19:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996755655690054 + ], + [ + "2025-11-25T19:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996751177621485 + ], + [ + "2025-11-25T19:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997386661552634 + ], + [ + "2025-11-25T19:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997291255599017 + ], + [ + "2025-11-25T19:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996734098288382 + ], + [ + "2025-11-25T19:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996244604992763 + ], + [ + "2025-11-25T19:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998877834357514 + ], + [ + "2025-11-25T19:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996605476985366 + ], + [ + "2025-11-25T19:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996361924479605 + ], + [ + "2025-11-25T19:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996718130128084 + ], + [ + "2025-11-25T19:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997746279917613 + ], + [ + "2025-11-25T19:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997418856833808 + ], + [ + "2025-11-25T19:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998323923369045 + ], + [ + "2025-11-25T19:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997633017357 + ], + [ + "2025-11-25T19:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997268071597651 + ], + [ + "2025-11-25T18:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999698940504223 + ], + [ + "2025-11-25T18:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998791557563108 + ], + [ + "2025-11-25T18:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99978937520059 + ], + [ + "2025-11-25T18:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998542293399321 + ], + [ + "2025-11-25T18:52:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998115626234292 + ], + [ + "2025-11-25T18:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997842353874543 + ], + [ + "2025-11-25T18:48:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996884562386789 + ], + [ + "2025-11-25T18:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999721803745494 + ], + [ + "2025-11-25T18:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997628978580415 + ], + [ + "2025-11-25T18:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999020355928134 + ], + [ + "2025-11-25T18:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997158206306186 + ], + [ + "2025-11-25T18:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998690276378012 + ], + [ + "2025-11-25T18:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999154168844038 + ], + [ + "2025-11-25T18:33:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999087187515915 + ], + [ + "2025-11-25T18:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998825785977489 + ], + [ + "2025-11-25T18:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997723846345422 + ], + [ + "2025-11-25T18:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996824587099645 + ], + [ + "2025-11-25T18:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997515437388605 + ], + [ + "2025-11-25T18:24:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997850655908643 + ], + [ + "2025-11-25T18:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998457575256567 + ], + [ + "2025-11-25T18:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998317716789504 + ], + [ + "2025-11-25T18:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999757736021985 + ], + [ + "2025-11-25T18:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998514980655275 + ], + [ + "2025-11-25T18:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997733983174766 + ], + [ + "2025-11-25T18:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999875206384566 + ], + [ + "2025-11-25T18:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999084825399163 + ], + [ + "2025-11-25T18:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000343187358849 + ], + [ + "2025-11-25T18:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998185914905924 + ], + [ + "2025-11-25T18:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998690576953362 + ], + [ + "2025-11-25T18:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998532220126016 + ], + [ + "2025-11-25T17:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998452802582276 + ], + [ + "2025-11-25T17:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999855048776546 + ], + [ + "2025-11-25T17:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999816592840171 + ], + [ + "2025-11-25T17:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999632071891708 + ], + [ + "2025-11-25T17:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998381540102848 + ], + [ + "2025-11-25T17:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001777034116457 + ], + [ + "2025-11-25T17:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998056014537163 + ], + [ + "2025-11-25T17:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000407114074757 + ], + [ + "2025-11-25T17:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998814728200986 + ], + [ + "2025-11-25T17:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004531060907809 + ], + [ + "2025-11-25T17:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997632315990614 + ], + [ + "2025-11-25T17:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997035365412266 + ], + [ + "2025-11-25T17:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000468606930122 + ], + [ + "2025-11-25T17:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997616430551373 + ], + [ + "2025-11-25T17:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997374908612776 + ], + [ + "2025-11-25T17:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998208724347499 + ], + [ + "2025-11-25T17:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999826276188958 + ], + [ + "2025-11-25T17:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998149055905272 + ], + [ + "2025-11-25T17:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997668639922435 + ], + [ + "2025-11-25T17:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997751596024553 + ], + [ + "2025-11-25T17:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998001059043033 + ], + [ + "2025-11-25T17:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996239021990432 + ], + [ + "2025-11-25T17:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997990572351707 + ], + [ + "2025-11-25T17:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998086463333634 + ], + [ + "2025-11-25T17:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002010245731838 + ], + [ + "2025-11-25T17:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996577728455719 + ], + [ + "2025-11-25T17:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999904811605048 + ], + [ + "2025-11-25T17:06:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999734389475902 + ], + [ + "2025-11-25T17:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003273488925366 + ], + [ + "2025-11-25T17:02:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999563741692226 + ], + [ + "2025-11-25T16:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001778793737695 + ], + [ + "2025-11-25T16:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000088288699756 + ], + [ + "2025-11-25T16:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002001450195555 + ], + [ + "2025-11-25T16:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998504908057506 + ], + [ + "2025-11-25T16:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000119485242306 + ], + [ + "2025-11-25T16:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999912930885142 + ], + [ + "2025-11-25T16:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001367661921547 + ], + [ + "2025-11-25T16:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999777433228827 + ], + [ + "2025-11-25T16:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998546143672851 + ], + [ + "2025-11-25T16:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999499553241141 + ], + [ + "2025-11-25T16:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001468868956294 + ], + [ + "2025-11-25T16:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998842840380875 + ], + [ + "2025-11-25T16:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005386288930866 + ], + [ + "2025-11-25T16:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999105008872737 + ], + [ + "2025-11-25T16:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006516306055664 + ], + [ + "2025-11-25T16:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998595679647033 + ], + [ + "2025-11-25T16:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006918127050148 + ], + [ + "2025-11-25T16:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998439650961811 + ], + [ + "2025-11-25T16:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999969993123113 + ], + [ + "2025-11-25T16:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000628310226858 + ], + [ + "2025-11-25T16:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009151602111976 + ], + [ + "2025-11-25T16:18:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000587361502656 + ], + [ + "2025-11-25T16:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003979267222074 + ], + [ + "2025-11-25T16:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002664057239838 + ], + [ + "2025-11-25T16:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008432227595732 + ], + [ + "2025-11-25T16:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000933217201766 + ], + [ + "2025-11-25T16:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000939047436292 + ], + [ + "2025-11-25T16:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999105148652725 + ], + [ + "2025-11-25T16:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000462607401248 + ], + [ + "2025-11-25T16:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996887539785648 + ], + [ + "2025-11-25T15:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999900261814223 + ], + [ + "2025-11-25T15:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001674215675675 + ], + [ + "2025-11-25T15:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000118866356986 + ], + [ + "2025-11-25T15:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996331185729971 + ], + [ + "2025-11-25T15:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002367315832157 + ], + [ + "2025-11-25T15:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000754305936713 + ], + [ + "2025-11-25T15:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000005383672552 + ], + [ + "2025-11-25T15:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997498446716232 + ], + [ + "2025-11-25T15:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007232320811985 + ], + [ + "2025-11-25T15:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008559798871661 + ], + [ + "2025-11-25T15:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999213642263018 + ], + [ + "2025-11-25T15:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998559216417409 + ], + [ + "2025-11-25T15:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999158135891382 + ], + [ + "2025-11-25T15:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997905779003547 + ], + [ + "2025-11-25T15:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998156941732693 + ], + [ + "2025-11-25T15:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998104132028932 + ], + [ + "2025-11-25T15:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998619579369918 + ], + [ + "2025-11-25T15:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997523100894427 + ], + [ + "2025-11-25T15:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999031888105901 + ], + [ + "2025-11-25T15:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997027028771012 + ], + [ + "2025-11-25T15:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996761902066672 + ], + [ + "2025-11-25T15:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996463468222035 + ], + [ + "2025-11-25T15:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997192795545162 + ], + [ + "2025-11-25T15:13:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999764531287436 + ], + [ + "2025-11-25T15:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996974083297895 + ], + [ + "2025-11-25T15:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997705523852748 + ], + [ + "2025-11-25T15:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999237069431882 + ], + [ + "2025-11-25T15:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997280318944726 + ], + [ + "2025-11-25T15:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999721279774264 + ], + [ + "2025-11-25T15:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999136543997681 + ], + [ + "2025-11-25T14:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998756245880513 + ], + [ + "2025-11-25T14:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997833011928285 + ], + [ + "2025-11-25T14:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997405804228449 + ], + [ + "2025-11-25T14:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998137648927774 + ], + [ + "2025-11-25T14:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998628824799666 + ], + [ + "2025-11-25T14:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997345779958798 + ], + [ + "2025-11-25T14:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996964637764033 + ], + [ + "2025-11-25T14:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998360035197452 + ], + [ + "2025-11-25T14:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003289276407392 + ], + [ + "2025-11-25T14:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996524084006575 + ], + [ + "2025-11-25T14:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997026533992894 + ], + [ + "2025-11-25T14:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997514459224848 + ], + [ + "2025-11-25T14:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999225552166675 + ], + [ + "2025-11-25T14:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998421638516737 + ], + [ + "2025-11-25T14:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998363482262804 + ], + [ + "2025-11-25T14:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999165972995457 + ], + [ + "2025-11-25T14:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997530847169749 + ], + [ + "2025-11-25T14:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997681982094769 + ], + [ + "2025-11-25T14:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999476434982355 + ], + [ + "2025-11-25T14:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999070679516029 + ], + [ + "2025-11-25T14:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996476688321364 + ], + [ + "2025-11-25T14:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997898300372541 + ], + [ + "2025-11-25T14:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998357774064759 + ], + [ + "2025-11-25T14:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998374587854704 + ], + [ + "2025-11-25T14:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997412424770756 + ], + [ + "2025-11-25T14:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000082338981152 + ], + [ + "2025-11-25T14:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999791673392784 + ], + [ + "2025-11-25T14:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000404613483833 + ], + [ + "2025-11-25T14:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998695792097113 + ], + [ + "2025-11-25T13:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000029563122041 + ], + [ + "2025-11-25T13:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999850710131545 + ], + [ + "2025-11-25T13:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000922031346005 + ], + [ + "2025-11-25T13:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000015861735768 + ], + [ + "2025-11-25T13:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999771673380934 + ], + [ + "2025-11-25T13:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999437939219907 + ], + [ + "2025-11-25T13:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000071746009097 + ], + [ + "2025-11-25T13:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001181658903733 + ], + [ + "2025-11-25T13:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997814666114943 + ], + [ + "2025-11-25T13:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999902639444433 + ], + [ + "2025-11-25T13:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000481116922981 + ], + [ + "2025-11-25T13:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000589927226473 + ], + [ + "2025-11-25T13:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999719299351997 + ], + [ + "2025-11-25T13:34:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000356254987417 + ], + [ + "2025-11-25T13:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001371770685765 + ], + [ + "2025-11-25T13:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000100756993861 + ], + [ + "2025-11-25T13:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998243343852736 + ], + [ + "2025-11-25T13:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002178581446541 + ], + [ + "2025-11-25T13:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998986363171932 + ], + [ + "2025-11-25T13:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998806677445958 + ], + [ + "2025-11-25T13:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999018063948866 + ], + [ + "2025-11-25T13:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000243078206277 + ], + [ + "2025-11-25T13:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997975640000795 + ], + [ + "2025-11-25T13:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000317021937135 + ], + [ + "2025-11-25T13:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998945716784543 + ], + [ + "2025-11-25T13:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997070898544925 + ], + [ + "2025-11-25T13:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002223723742723 + ], + [ + "2025-11-25T13:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001169261948526 + ], + [ + "2025-11-25T13:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002756682483953 + ], + [ + "2025-11-25T13:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998354492894196 + ], + [ + "2025-11-25T12:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000128090654129 + ], + [ + "2025-11-25T12:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000501397845305 + ], + [ + "2025-11-25T12:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005762995184113 + ], + [ + "2025-11-25T12:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000549461626569 + ], + [ + "2025-11-25T12:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001324557638125 + ], + [ + "2025-11-25T12:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000070415609301 + ], + [ + "2025-11-25T12:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007346944674727 + ], + [ + "2025-11-25T12:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000051032375082 + ], + [ + "2025-11-25T12:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001250782394948 + ], + [ + "2025-11-25T12:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002604007672304 + ], + [ + "2025-11-25T12:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012915551319994 + ], + [ + "2025-11-25T12:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001642237549038 + ], + [ + "2025-11-25T12:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996448604796635 + ], + [ + "2025-11-25T12:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997638161595134 + ], + [ + "2025-11-25T12:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003361268536082 + ], + [ + "2025-11-25T12:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99969508910734 + ], + [ + "2025-11-25T12:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996965485260113 + ], + [ + "2025-11-25T12:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998209091624646 + ], + [ + "2025-11-25T12:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997339798585799 + ], + [ + "2025-11-25T12:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997203335335771 + ], + [ + "2025-11-25T12:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996759164192436 + ], + [ + "2025-11-25T12:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997805406419735 + ], + [ + "2025-11-25T12:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998724054047563 + ], + [ + "2025-11-25T12:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998297857188243 + ], + [ + "2025-11-25T12:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997647827723251 + ], + [ + "2025-11-25T12:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999402387100382 + ], + [ + "2025-11-25T12:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998596504453765 + ], + [ + "2025-11-25T12:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998534661300733 + ], + [ + "2025-11-25T12:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998758344181069 + ], + [ + "2025-11-25T12:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997012505856075 + ], + [ + "2025-11-25T11:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001220963059365 + ], + [ + "2025-11-25T11:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999623954407934 + ], + [ + "2025-11-25T11:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999495391797149 + ], + [ + "2025-11-25T11:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998424663709391 + ], + [ + "2025-11-25T11:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006706993680625 + ], + [ + "2025-11-25T11:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997820150401096 + ], + [ + "2025-11-25T11:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997159203429552 + ], + [ + "2025-11-25T11:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998252549045875 + ], + [ + "2025-11-25T11:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997026272903416 + ], + [ + "2025-11-25T11:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996999182913423 + ], + [ + "2025-11-25T11:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997503010708712 + ], + [ + "2025-11-25T11:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997190064556432 + ], + [ + "2025-11-25T11:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997692380818617 + ], + [ + "2025-11-25T11:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998159011568069 + ], + [ + "2025-11-25T11:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997426838329855 + ], + [ + "2025-11-25T11:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997531737167039 + ], + [ + "2025-11-25T11:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999216544245004 + ], + [ + "2025-11-25T11:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998193633101737 + ], + [ + "2025-11-25T11:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996941997979918 + ], + [ + "2025-11-25T11:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997547782443266 + ], + [ + "2025-11-25T11:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999855162864122 + ], + [ + "2025-11-25T11:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998114957418397 + ], + [ + "2025-11-25T11:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999786659362046 + ], + [ + "2025-11-25T11:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999144475301809 + ], + [ + "2025-11-25T11:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998366296508255 + ], + [ + "2025-11-25T11:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998938889688843 + ], + [ + "2025-11-25T11:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998255718808234 + ], + [ + "2025-11-25T11:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999964621944718 + ], + [ + "2025-11-25T11:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000148001614968 + ], + [ + "2025-11-25T11:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998997113293315 + ], + [ + "2025-11-25T10:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998423067858055 + ], + [ + "2025-11-25T10:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999617883966497 + ], + [ + "2025-11-25T10:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999889034183407 + ], + [ + "2025-11-25T10:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999117787309573 + ], + [ + "2025-11-25T10:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999956504756852 + ], + [ + "2025-11-25T10:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998898766247147 + ], + [ + "2025-11-25T10:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999256342516373 + ], + [ + "2025-11-25T10:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998460826804448 + ], + [ + "2025-11-25T10:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000734865358891 + ], + [ + "2025-11-25T10:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999269853428571 + ], + [ + "2025-11-25T10:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998979859329341 + ], + [ + "2025-11-25T10:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006870434785156 + ], + [ + "2025-11-25T10:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005265034501283 + ], + [ + "2025-11-25T10:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999473789909145 + ], + [ + "2025-11-25T10:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007032221487615 + ], + [ + "2025-11-25T10:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000639594346967 + ], + [ + "2025-11-25T10:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999836038941605 + ], + [ + "2025-11-25T10:25:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997957332315006 + ], + [ + "2025-11-25T10:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99973676654172 + ], + [ + "2025-11-25T10:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002164123024937 + ], + [ + "2025-11-25T10:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999598806978303 + ], + [ + "2025-11-25T10:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996962267792666 + ], + [ + "2025-11-25T10:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997618411712353 + ], + [ + "2025-11-25T10:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999024746504538 + ], + [ + "2025-11-25T10:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998157246588525 + ], + [ + "2025-11-25T10:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997400143324677 + ], + [ + "2025-11-25T10:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998735183655526 + ], + [ + "2025-11-25T10:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00010005900279 + ], + [ + "2025-11-25T10:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998652625797496 + ], + [ + "2025-11-25T10:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998103288936468 + ], + [ + "2025-11-25T09:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997835084666115 + ], + [ + "2025-11-25T09:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001165170965325 + ], + [ + "2025-11-25T09:56:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999212241299856 + ], + [ + "2025-11-25T09:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999425810592942 + ], + [ + "2025-11-25T09:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998623252272273 + ], + [ + "2025-11-25T09:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001601354922653 + ], + [ + "2025-11-25T09:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999993099225433 + ], + [ + "2025-11-25T09:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999978085779935 + ], + [ + "2025-11-25T09:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003260426801108 + ], + [ + "2025-11-25T09:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002029879307746 + ], + [ + "2025-11-25T09:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999848263268778 + ], + [ + "2025-11-25T09:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999895919592659 + ], + [ + "2025-11-25T09:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999280998814742 + ], + [ + "2025-11-25T09:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997918348590663 + ], + [ + "2025-11-25T09:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005907510402758 + ], + [ + "2025-11-25T09:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000214874369013 + ], + [ + "2025-11-25T09:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996964126468548 + ], + [ + "2025-11-25T09:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001126394190794 + ], + [ + "2025-11-25T09:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998825871164587 + ], + [ + "2025-11-25T09:22:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999882624075251 + ], + [ + "2025-11-25T09:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998033571815589 + ], + [ + "2025-11-25T09:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000532363565868 + ], + [ + "2025-11-25T09:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999009366057116 + ], + [ + "2025-11-25T09:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999786703271549 + ], + [ + "2025-11-25T09:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002879463462344 + ], + [ + "2025-11-25T09:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999717197224279 + ], + [ + "2025-11-25T09:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999825956569259 + ], + [ + "2025-11-25T09:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999923528811809 + ], + [ + "2025-11-25T09:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000368087181388 + ], + [ + "2025-11-25T09:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997227716919461 + ], + [ + "2025-11-25T08:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001409651558815 + ], + [ + "2025-11-25T08:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996399445677887 + ], + [ + "2025-11-25T08:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998888368308698 + ], + [ + "2025-11-25T08:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998252315868031 + ], + [ + "2025-11-25T08:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999023363701179 + ], + [ + "2025-11-25T08:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996436481643088 + ], + [ + "2025-11-25T08:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996818476055696 + ], + [ + "2025-11-25T08:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003553317323977 + ], + [ + "2025-11-25T08:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000169419810528 + ], + [ + "2025-11-25T08:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999896044381827 + ], + [ + "2025-11-25T08:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998730827125494 + ], + [ + "2025-11-25T08:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000097953547702 + ], + [ + "2025-11-25T08:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005400376698805 + ], + [ + "2025-11-25T08:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999825933341235 + ], + [ + "2025-11-25T08:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999834624942703 + ], + [ + "2025-11-25T08:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998962600509664 + ], + [ + "2025-11-25T08:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999207846140583 + ], + [ + "2025-11-25T08:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999925588359293 + ], + [ + "2025-11-25T08:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999138918447997 + ], + [ + "2025-11-25T08:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998628021252892 + ], + [ + "2025-11-25T08:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996857703615186 + ], + [ + "2025-11-25T08:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999258666904651 + ], + [ + "2025-11-25T08:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999340872844722 + ], + [ + "2025-11-25T08:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999172108744665 + ], + [ + "2025-11-25T08:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999369900636309 + ], + [ + "2025-11-25T08:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999756608850557 + ], + [ + "2025-11-25T08:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996596155222288 + ], + [ + "2025-11-25T08:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004121053012862 + ], + [ + "2025-11-25T08:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998024182481343 + ], + [ + "2025-11-25T08:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998749569749845 + ], + [ + "2025-11-25T07:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997937326055791 + ], + [ + "2025-11-25T07:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000488833429475 + ], + [ + "2025-11-25T07:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995713963995898 + ], + [ + "2025-11-25T07:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00019171878003 + ], + [ + "2025-11-25T07:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000652422904621 + ], + [ + "2025-11-25T07:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008462015840947 + ], + [ + "2025-11-25T07:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999900873804339 + ], + [ + "2025-11-25T07:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006528995489352 + ], + [ + "2025-11-25T07:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001640625711092 + ], + [ + "2025-11-25T07:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998408872296669 + ], + [ + "2025-11-25T07:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999932084585219 + ], + [ + "2025-11-25T07:38:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998975464017207 + ], + [ + "2025-11-25T07:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999089356287039 + ], + [ + "2025-11-25T07:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995108883096794 + ], + [ + "2025-11-25T07:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999764997136218 + ], + [ + "2025-11-25T07:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997788445498289 + ], + [ + "2025-11-25T07:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997516089573643 + ], + [ + "2025-11-25T07:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996826774671643 + ], + [ + "2025-11-25T07:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997823267174938 + ], + [ + "2025-11-25T07:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997930883894427 + ], + [ + "2025-11-25T07:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999973605878155 + ], + [ + "2025-11-25T07:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997790068299932 + ], + [ + "2025-11-25T07:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999617662264547 + ], + [ + "2025-11-25T07:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000450175175306 + ], + [ + "2025-11-25T07:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000188290966956 + ], + [ + "2025-11-25T07:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998305862690327 + ], + [ + "2025-11-25T07:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999223827726709 + ], + [ + "2025-11-25T07:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003996200025849 + ], + [ + "2025-11-25T07:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000877845704337 + ], + [ + "2025-11-25T07:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998055421821485 + ], + [ + "2025-11-25T06:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999072953421505 + ], + [ + "2025-11-25T06:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005101612231757 + ], + [ + "2025-11-25T06:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999452175608993 + ], + [ + "2025-11-25T06:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997147562801103 + ], + [ + "2025-11-25T06:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999411843184446 + ], + [ + "2025-11-25T06:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999586086745672 + ], + [ + "2025-11-25T06:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000230808054032 + ], + [ + "2025-11-25T06:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008299949023753 + ], + [ + "2025-11-25T06:44:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997453860794695 + ], + [ + "2025-11-25T06:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997582070237642 + ], + [ + "2025-11-25T06:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999586903639561 + ], + [ + "2025-11-25T06:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000058299064502 + ], + [ + "2025-11-25T06:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999094222000259 + ], + [ + "2025-11-25T06:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999467147929023 + ], + [ + "2025-11-25T06:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000158455376735 + ], + [ + "2025-11-25T06:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999995431504848 + ], + [ + "2025-11-25T06:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997792462208347 + ], + [ + "2025-11-25T06:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007545094503758 + ], + [ + "2025-11-25T06:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999722819740314 + ], + [ + "2025-11-25T06:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008244449231625 + ], + [ + "2025-11-25T06:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998131481139538 + ], + [ + "2025-11-25T06:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999731946515104 + ], + [ + "2025-11-25T06:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998784824274244 + ], + [ + "2025-11-25T06:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998215899656077 + ], + [ + "2025-11-25T06:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997720477830874 + ], + [ + "2025-11-25T06:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996674284105077 + ], + [ + "2025-11-25T06:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999933286431849 + ], + [ + "2025-11-25T06:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997767318835222 + ], + [ + "2025-11-25T06:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999941267510296 + ], + [ + "2025-11-25T06:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999127491584417 + ], + [ + "2025-11-25T05:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999843753197558 + ], + [ + "2025-11-25T05:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995785170096922 + ], + [ + "2025-11-25T05:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001675676508681 + ], + [ + "2025-11-25T05:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998668162088488 + ], + [ + "2025-11-25T05:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999350834923392 + ], + [ + "2025-11-25T05:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999803239517531 + ], + [ + "2025-11-25T05:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999622530736283 + ], + [ + "2025-11-25T05:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999445196558229 + ], + [ + "2025-11-25T05:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000720273856378 + ], + [ + "2025-11-25T05:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999769650989305 + ], + [ + "2025-11-25T05:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997714469977556 + ], + [ + "2025-11-25T05:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998901022125671 + ], + [ + "2025-11-25T05:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999949961205405 + ], + [ + "2025-11-25T05:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999711450817733 + ], + [ + "2025-11-25T05:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996929221130985 + ], + [ + "2025-11-25T05:30:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997865579425824 + ], + [ + "2025-11-25T05:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996673830801456 + ], + [ + "2025-11-25T05:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000198645300535 + ], + [ + "2025-11-25T05:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997879587385796 + ], + [ + "2025-11-25T05:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997589147478794 + ], + [ + "2025-11-25T05:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001865759026913 + ], + [ + "2025-11-25T05:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013723466102702 + ], + [ + "2025-11-25T05:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997280027614847 + ], + [ + "2025-11-25T05:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000156824820454 + ], + [ + "2025-11-25T05:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000024156117815 + ], + [ + "2025-11-25T05:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010038234601901 + ], + [ + "2025-11-25T05:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000030760548093 + ], + [ + "2025-11-25T05:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006725088610744 + ], + [ + "2025-11-25T05:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000584066165876 + ], + [ + "2025-11-25T05:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000078368035353 + ], + [ + "2025-11-25T04:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999626593452313 + ], + [ + "2025-11-25T04:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999928357657034 + ], + [ + "2025-11-25T04:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000047236487585 + ], + [ + "2025-11-25T04:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005468114753666 + ], + [ + "2025-11-25T04:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998455949507384 + ], + [ + "2025-11-25T04:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001201793769852 + ], + [ + "2025-11-25T04:47:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001089298744392 + ], + [ + "2025-11-25T04:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002482394604142 + ], + [ + "2025-11-25T04:43:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998896669074044 + ], + [ + "2025-11-25T04:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000114195644247 + ], + [ + "2025-11-25T04:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013872844679046 + ], + [ + "2025-11-25T04:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003082324521237 + ], + [ + "2025-11-25T04:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999137500099519 + ], + [ + "2025-11-25T04:33:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002482771222883 + ], + [ + "2025-11-25T04:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003939651787501 + ], + [ + "2025-11-25T04:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999902069572972 + ], + [ + "2025-11-25T04:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997583435192137 + ], + [ + "2025-11-25T04:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002232476338637 + ], + [ + "2025-11-25T04:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00017453354854 + ], + [ + "2025-11-25T04:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999916708964566 + ], + [ + "2025-11-25T04:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999190176490911 + ], + [ + "2025-11-25T04:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006081879364495 + ], + [ + "2025-11-25T04:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001656822121394 + ], + [ + "2025-11-25T04:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999247289462997 + ], + [ + "2025-11-25T04:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999395058218663 + ], + [ + "2025-11-25T04:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997916611422637 + ], + [ + "2025-11-25T04:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002929785775936 + ], + [ + "2025-11-25T04:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998858595449218 + ], + [ + "2025-11-25T04:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998269543957289 + ], + [ + "2025-11-25T04:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007139806932108 + ], + [ + "2025-11-25T03:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003979257530422 + ], + [ + "2025-11-25T03:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998779734135187 + ], + [ + "2025-11-25T03:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998651112924306 + ], + [ + "2025-11-25T03:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005634029766703 + ], + [ + "2025-11-25T03:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000009594041898 + ], + [ + "2025-11-25T03:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999990467764656 + ], + [ + "2025-11-25T03:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999839155950075 + ], + [ + "2025-11-25T03:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008714780273078 + ], + [ + "2025-11-25T03:44:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998451695165224 + ], + [ + "2025-11-25T03:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998779396734724 + ], + [ + "2025-11-25T03:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998849747668849 + ], + [ + "2025-11-25T03:37:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996585977454119 + ], + [ + "2025-11-25T03:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999915089435465 + ], + [ + "2025-11-25T03:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000542790119158 + ], + [ + "2025-11-25T03:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999903218108491 + ], + [ + "2025-11-25T03:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998484324430432 + ], + [ + "2025-11-25T03:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997443525867452 + ], + [ + "2025-11-25T03:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002204775025947 + ], + [ + "2025-11-25T03:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999091775949076 + ], + [ + "2025-11-25T03:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999078642266491 + ], + [ + "2025-11-25T03:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997462051751898 + ], + [ + "2025-11-25T03:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005396172472987 + ], + [ + "2025-11-25T03:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999730940969751 + ], + [ + "2025-11-25T03:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997316476315483 + ], + [ + "2025-11-25T03:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000765005875025 + ], + [ + "2025-11-25T03:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006571202498313 + ], + [ + "2025-11-25T03:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997524114315863 + ], + [ + "2025-11-25T03:05:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997865068589137 + ], + [ + "2025-11-25T03:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002197474505707 + ], + [ + "2025-11-25T03:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001159533364347 + ], + [ + "2025-11-25T02:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998958172084063 + ], + [ + "2025-11-25T02:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998504140937391 + ], + [ + "2025-11-25T02:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999702690931724 + ], + [ + "2025-11-25T02:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997828003440016 + ], + [ + "2025-11-25T02:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998123745304572 + ], + [ + "2025-11-25T02:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998288214912373 + ], + [ + "2025-11-25T02:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999805549577991 + ], + [ + "2025-11-25T02:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998292756905325 + ], + [ + "2025-11-25T02:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999777407600883 + ], + [ + "2025-11-25T02:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998202844563009 + ], + [ + "2025-11-25T02:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997889555517199 + ], + [ + "2025-11-25T02:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998288238576005 + ], + [ + "2025-11-25T02:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998124217914061 + ], + [ + "2025-11-25T02:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998315808974336 + ], + [ + "2025-11-25T02:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998420614022057 + ], + [ + "2025-11-25T02:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997569517745589 + ], + [ + "2025-11-25T02:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012127422344212 + ], + [ + "2025-11-25T02:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997507223357013 + ], + [ + "2025-11-25T02:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997672015600358 + ], + [ + "2025-11-25T02:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99981664060273 + ], + [ + "2025-11-25T02:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012585727592953 + ], + [ + "2025-11-25T02:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996938445316056 + ], + [ + "2025-11-25T02:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999860423149419 + ], + [ + "2025-11-25T02:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997733014869201 + ], + [ + "2025-11-25T02:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004074195710604 + ], + [ + "2025-11-25T02:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997902096166185 + ], + [ + "2025-11-25T02:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004999021522683 + ], + [ + "2025-11-25T02:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996956664656128 + ], + [ + "2025-11-25T02:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000870803108405 + ], + [ + "2025-11-25T02:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998879235370624 + ], + [ + "2025-11-25T01:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997907833508526 + ], + [ + "2025-11-25T01:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997757113453217 + ], + [ + "2025-11-25T01:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996892125946018 + ], + [ + "2025-11-25T01:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000297842177249 + ], + [ + "2025-11-25T01:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999383595203599 + ], + [ + "2025-11-25T01:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998620099260775 + ], + [ + "2025-11-25T01:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997668475228488 + ], + [ + "2025-11-25T01:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996826631664992 + ], + [ + "2025-11-25T01:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006295055685026 + ], + [ + "2025-11-25T01:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002242335515095 + ], + [ + "2025-11-25T01:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998303994012582 + ], + [ + "2025-11-25T01:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997741454770352 + ], + [ + "2025-11-25T01:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996975901804193 + ], + [ + "2025-11-25T01:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997485792992096 + ], + [ + "2025-11-25T01:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997731300849305 + ], + [ + "2025-11-25T01:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997316345078016 + ], + [ + "2025-11-25T01:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996575300175901 + ], + [ + "2025-11-25T01:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999800512602948 + ], + [ + "2025-11-25T01:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998231343572225 + ], + [ + "2025-11-25T01:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997669152496887 + ], + [ + "2025-11-25T01:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002258936446544 + ], + [ + "2025-11-25T01:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998653313596826 + ], + [ + "2025-11-25T01:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998481625556015 + ], + [ + "2025-11-25T01:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998472241512868 + ], + [ + "2025-11-25T01:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998248948176998 + ], + [ + "2025-11-25T01:10:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997331094460805 + ], + [ + "2025-11-25T01:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99981178393896 + ], + [ + "2025-11-25T01:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997929881776643 + ], + [ + "2025-11-25T01:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998193231007878 + ], + [ + "2025-11-25T01:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997758944208843 + ], + [ + "2025-11-25T00:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998529089850573 + ], + [ + "2025-11-25T00:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998766533099487 + ], + [ + "2025-11-25T00:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998604053939159 + ], + [ + "2025-11-25T00:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998225629810131 + ], + [ + "2025-11-25T00:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998844845273691 + ], + [ + "2025-11-25T00:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997844173191583 + ], + [ + "2025-11-25T00:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998431096144001 + ], + [ + "2025-11-25T00:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998126567851991 + ], + [ + "2025-11-25T00:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998520542970188 + ], + [ + "2025-11-25T00:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999468945557937 + ], + [ + "2025-11-25T00:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998164878316389 + ], + [ + "2025-11-25T00:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997609021292004 + ], + [ + "2025-11-25T00:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99994637370298 + ], + [ + "2025-11-25T00:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000545156375585 + ], + [ + "2025-11-25T00:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998492523542697 + ], + [ + "2025-11-25T00:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997548896701074 + ], + [ + "2025-11-25T00:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003527983715597 + ], + [ + "2025-11-25T00:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008036833940979 + ], + [ + "2025-11-25T00:24:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000020223032355 + ], + [ + "2025-11-25T00:22:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996993691810718 + ], + [ + "2025-11-25T00:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997597025244418 + ], + [ + "2025-11-25T00:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99985846292348 + ], + [ + "2025-11-25T00:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997478159073802 + ], + [ + "2025-11-25T00:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997632903502918 + ], + [ + "2025-11-25T00:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997880895994082 + ], + [ + "2025-11-25T00:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002194229001127 + ], + [ + "2025-11-25T00:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998123510927671 + ], + [ + "2025-11-25T00:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997280031042762 + ], + [ + "2025-11-25T00:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000035501698424 + ], + [ + "2025-11-25T00:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006631013107907 + ], + [ + "2025-11-24T23:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999003120252966 + ], + [ + "2025-11-24T23:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997718259817189 + ], + [ + "2025-11-24T23:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996763358856926 + ], + [ + "2025-11-24T23:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997064928323484 + ], + [ + "2025-11-24T23:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996907608544406 + ], + [ + "2025-11-24T23:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997083031766208 + ], + [ + "2025-11-24T23:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997189241749952 + ], + [ + "2025-11-24T23:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996878474928492 + ], + [ + "2025-11-24T23:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997629625322771 + ], + [ + "2025-11-24T23:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997009901171348 + ], + [ + "2025-11-24T23:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997615945874126 + ], + [ + "2025-11-24T23:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997627682133654 + ], + [ + "2025-11-24T23:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997171110068892 + ], + [ + "2025-11-24T23:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999759162848746 + ], + [ + "2025-11-24T23:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996375452866083 + ], + [ + "2025-11-24T23:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997059325187019 + ], + [ + "2025-11-24T23:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999711788902636 + ], + [ + "2025-11-24T23:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997230988242067 + ], + [ + "2025-11-24T23:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99969350043931 + ], + [ + "2025-11-24T23:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996365197219423 + ], + [ + "2025-11-24T23:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997752382798157 + ], + [ + "2025-11-24T23:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996160379138328 + ], + [ + "2025-11-24T23:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996662133128308 + ], + [ + "2025-11-24T23:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996630985067517 + ], + [ + "2025-11-24T23:11:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996435999461822 + ], + [ + "2025-11-24T23:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996692966793406 + ], + [ + "2025-11-24T23:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996609333633009 + ], + [ + "2025-11-24T23:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996757349144674 + ], + [ + "2025-11-24T23:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996688264580759 + ], + [ + "2025-11-24T23:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996497485729577 + ], + [ + "2025-11-24T22:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997208489026581 + ], + [ + "2025-11-24T22:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997568928367778 + ], + [ + "2025-11-24T22:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997146447294347 + ], + [ + "2025-11-24T22:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996962132757453 + ], + [ + "2025-11-24T22:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997440731983092 + ], + [ + "2025-11-24T22:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997491318903812 + ], + [ + "2025-11-24T22:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996975425856318 + ], + [ + "2025-11-24T22:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997775587901938 + ], + [ + "2025-11-24T22:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999790095060674 + ], + [ + "2025-11-24T22:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999759444839689 + ], + [ + "2025-11-24T22:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996712869628156 + ], + [ + "2025-11-24T22:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999652151782347 + ], + [ + "2025-11-24T22:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997854767594816 + ], + [ + "2025-11-24T22:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999738230970859 + ], + [ + "2025-11-24T22:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999709341465187 + ], + [ + "2025-11-24T22:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997289571639857 + ], + [ + "2025-11-24T22:28:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998563513982714 + ], + [ + "2025-11-24T22:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999836041756057 + ], + [ + "2025-11-24T22:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999700865949215 + ], + [ + "2025-11-24T22:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997899117738775 + ], + [ + "2025-11-24T22:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998709429786827 + ], + [ + "2025-11-24T22:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999776798251224 + ], + [ + "2025-11-24T22:15:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999699994766623 + ], + [ + "2025-11-24T22:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996153489345726 + ], + [ + "2025-11-24T22:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998821334956745 + ], + [ + "2025-11-24T22:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999667956202331 + ], + [ + "2025-11-24T22:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996827815229955 + ], + [ + "2025-11-24T22:05:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996979947311494 + ], + [ + "2025-11-24T22:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997124274593818 + ], + [ + "2025-11-24T22:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997889487418589 + ], + [ + "2025-11-24T21:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996307867362114 + ], + [ + "2025-11-24T21:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997073003189867 + ], + [ + "2025-11-24T21:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997706981984201 + ], + [ + "2025-11-24T21:54:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997694883883237 + ], + [ + "2025-11-24T21:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997163044003868 + ], + [ + "2025-11-24T21:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998752173093101 + ], + [ + "2025-11-24T21:48:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996730882013569 + ], + [ + "2025-11-24T21:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997866727033785 + ], + [ + "2025-11-24T21:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999803493813276 + ], + [ + "2025-11-24T21:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997275238415734 + ], + [ + "2025-11-24T21:40:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997976315197833 + ], + [ + "2025-11-24T21:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997472639775618 + ], + [ + "2025-11-24T21:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997378219617727 + ], + [ + "2025-11-24T21:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997051548725566 + ], + [ + "2025-11-24T21:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996680886261721 + ], + [ + "2025-11-24T21:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996144115780253 + ], + [ + "2025-11-24T21:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996027740639821 + ], + [ + "2025-11-24T21:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996739230765634 + ], + [ + "2025-11-24T21:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996929291586201 + ], + [ + "2025-11-24T21:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999601393919439 + ], + [ + "2025-11-24T21:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997453094365828 + ], + [ + "2025-11-24T21:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996290731376426 + ], + [ + "2025-11-24T21:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995136835542417 + ], + [ + "2025-11-24T21:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997028804725188 + ], + [ + "2025-11-24T21:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995864669278725 + ], + [ + "2025-11-24T21:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996899131959786 + ], + [ + "2025-11-24T21:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995505598852755 + ], + [ + "2025-11-24T21:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996874022501808 + ], + [ + "2025-11-24T21:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996954862685996 + ], + [ + "2025-11-24T21:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996654249887755 + ], + [ + "2025-11-24T20:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996354254673392 + ], + [ + "2025-11-24T20:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996375761322271 + ], + [ + "2025-11-24T20:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997406386954036 + ], + [ + "2025-11-24T20:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997408562674245 + ], + [ + "2025-11-24T20:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997504419099711 + ], + [ + "2025-11-24T20:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996414920548692 + ], + [ + "2025-11-24T20:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999681802800454 + ], + [ + "2025-11-24T20:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000023139425052 + ], + [ + "2025-11-24T20:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004808457465293 + ], + [ + "2025-11-24T20:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996434138994191 + ], + [ + "2025-11-24T20:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996709356351485 + ], + [ + "2025-11-24T20:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996775240689153 + ], + [ + "2025-11-24T20:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996900283135495 + ], + [ + "2025-11-24T20:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996635910684937 + ], + [ + "2025-11-24T20:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996911975988715 + ], + [ + "2025-11-24T20:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996121699753155 + ], + [ + "2025-11-24T20:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997284250910853 + ], + [ + "2025-11-24T20:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996614056170724 + ], + [ + "2025-11-24T20:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996453254803394 + ], + [ + "2025-11-24T20:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996646029716071 + ], + [ + "2025-11-24T20:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000758461743795 + ], + [ + "2025-11-24T20:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996889376043622 + ], + [ + "2025-11-24T20:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997282852199255 + ], + [ + "2025-11-24T20:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999742343652301 + ], + [ + "2025-11-24T20:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996887305322179 + ], + [ + "2025-11-24T20:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997599052561437 + ], + [ + "2025-11-24T20:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997127609759738 + ], + [ + "2025-11-24T20:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997454815747859 + ], + [ + "2025-11-24T20:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997155988783019 + ], + [ + "2025-11-24T20:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999756482601901 + ], + [ + "2025-11-24T19:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996693751844015 + ], + [ + "2025-11-24T19:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997160549987253 + ], + [ + "2025-11-24T19:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997598335767502 + ], + [ + "2025-11-24T19:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996606541355852 + ], + [ + "2025-11-24T19:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999721265707463 + ], + [ + "2025-11-24T19:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997511681108506 + ], + [ + "2025-11-24T19:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997675103315893 + ], + [ + "2025-11-24T19:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997191720897411 + ], + [ + "2025-11-24T19:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004138596668553 + ], + [ + "2025-11-24T19:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003100053703147 + ], + [ + "2025-11-24T19:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005383130809646 + ], + [ + "2025-11-24T19:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997114121509725 + ], + [ + "2025-11-24T19:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997498862606122 + ], + [ + "2025-11-24T19:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997352902192235 + ], + [ + "2025-11-24T19:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997143870015479 + ], + [ + "2025-11-24T19:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997394802083769 + ], + [ + "2025-11-24T19:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998090864438641 + ], + [ + "2025-11-24T19:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997203525626169 + ], + [ + "2025-11-24T19:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997115928031791 + ], + [ + "2025-11-24T19:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996236502135689 + ], + [ + "2025-11-24T19:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996048004169806 + ], + [ + "2025-11-24T19:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994838086500506 + ], + [ + "2025-11-24T19:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996039306547345 + ], + [ + "2025-11-24T19:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996319233195673 + ], + [ + "2025-11-24T19:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996243240201027 + ], + [ + "2025-11-24T19:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996176043112949 + ], + [ + "2025-11-24T19:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996245866775735 + ], + [ + "2025-11-24T19:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997089862494368 + ], + [ + "2025-11-24T19:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995001143953619 + ], + [ + "2025-11-24T19:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995366883890868 + ], + [ + "2025-11-24T18:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999542956648914 + ], + [ + "2025-11-24T18:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996194123367643 + ], + [ + "2025-11-24T18:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996124853954896 + ], + [ + "2025-11-24T18:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996350712943358 + ], + [ + "2025-11-24T18:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995587721455054 + ], + [ + "2025-11-24T18:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997260496548246 + ], + [ + "2025-11-24T18:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997168692455376 + ], + [ + "2025-11-24T18:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995697316617368 + ], + [ + "2025-11-24T18:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999731890535294 + ], + [ + "2025-11-24T18:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003741581550578 + ], + [ + "2025-11-24T18:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997134712223374 + ], + [ + "2025-11-24T18:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996982805180572 + ], + [ + "2025-11-24T18:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999711719282266 + ], + [ + "2025-11-24T18:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997410796480392 + ], + [ + "2025-11-24T18:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997139665349352 + ], + [ + "2025-11-24T18:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997113010511306 + ], + [ + "2025-11-24T18:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997115399582119 + ], + [ + "2025-11-24T18:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999577427943348 + ], + [ + "2025-11-24T18:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996819081989934 + ], + [ + "2025-11-24T18:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996841558561386 + ], + [ + "2025-11-24T18:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996129956959405 + ], + [ + "2025-11-24T18:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996244864003844 + ], + [ + "2025-11-24T18:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996638473631282 + ], + [ + "2025-11-24T18:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996723145718018 + ], + [ + "2025-11-24T18:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996559156883007 + ], + [ + "2025-11-24T18:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999664734761893 + ], + [ + "2025-11-24T18:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996847141098407 + ], + [ + "2025-11-24T18:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999677160942809 + ], + [ + "2025-11-24T18:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996587116194665 + ], + [ + "2025-11-24T18:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996011237620125 + ], + [ + "2025-11-24T17:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998565281966344 + ], + [ + "2025-11-24T17:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997246338679006 + ], + [ + "2025-11-24T17:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997636564739518 + ], + [ + "2025-11-24T17:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997123013665195 + ], + [ + "2025-11-24T17:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997203413230513 + ], + [ + "2025-11-24T17:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997836336316911 + ], + [ + "2025-11-24T17:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998644357371907 + ], + [ + "2025-11-24T17:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997256952742963 + ], + [ + "2025-11-24T17:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996956954948668 + ], + [ + "2025-11-24T17:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999746608126818 + ], + [ + "2025-11-24T17:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998202309461306 + ], + [ + "2025-11-24T17:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999664739046024 + ], + [ + "2025-11-24T17:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997777194079608 + ], + [ + "2025-11-24T17:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997498798969076 + ], + [ + "2025-11-24T17:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997591079976786 + ], + [ + "2025-11-24T17:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997309725012486 + ], + [ + "2025-11-24T17:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996941263152105 + ], + [ + "2025-11-24T17:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997638670818152 + ], + [ + "2025-11-24T17:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997513072535044 + ], + [ + "2025-11-24T17:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999759021705809 + ], + [ + "2025-11-24T17:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996743397199644 + ], + [ + "2025-11-24T17:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997079195930939 + ], + [ + "2025-11-24T17:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999763478326249 + ], + [ + "2025-11-24T17:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999743773347173 + ], + [ + "2025-11-24T17:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998099952916257 + ], + [ + "2025-11-24T17:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998034240615771 + ], + [ + "2025-11-24T17:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998203312652586 + ], + [ + "2025-11-24T17:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00004214163827 + ], + [ + "2025-11-24T17:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999677488129148 + ], + [ + "2025-11-24T17:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999414701146441 + ], + [ + "2025-11-24T16:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998502805937298 + ], + [ + "2025-11-24T16:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997541300711973 + ], + [ + "2025-11-24T16:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999304324884942 + ], + [ + "2025-11-24T16:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998765998455328 + ], + [ + "2025-11-24T16:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998208435314343 + ], + [ + "2025-11-24T16:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997336866662815 + ], + [ + "2025-11-24T16:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999472510593522 + ], + [ + "2025-11-24T16:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997830907276037 + ], + [ + "2025-11-24T16:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998515967701769 + ], + [ + "2025-11-24T16:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998017678248361 + ], + [ + "2025-11-24T16:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003016543914593 + ], + [ + "2025-11-24T16:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012466076149844 + ], + [ + "2025-11-24T16:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997514546895899 + ], + [ + "2025-11-24T16:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999243217575611 + ], + [ + "2025-11-24T16:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998252683226134 + ], + [ + "2025-11-24T16:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998588346898859 + ], + [ + "2025-11-24T16:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997177701887897 + ], + [ + "2025-11-24T16:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998205779976485 + ], + [ + "2025-11-24T16:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998646828425839 + ], + [ + "2025-11-24T16:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998041594326349 + ], + [ + "2025-11-24T16:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996108962562695 + ], + [ + "2025-11-24T16:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997989886802167 + ], + [ + "2025-11-24T16:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000555220132274 + ], + [ + "2025-11-24T16:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998513947348632 + ], + [ + "2025-11-24T16:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997557504178415 + ], + [ + "2025-11-24T16:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999327993514006 + ], + [ + "2025-11-24T16:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002848067592602 + ], + [ + "2025-11-24T16:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999428617384495 + ], + [ + "2025-11-24T16:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997573627936304 + ], + [ + "2025-11-24T16:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996692683964423 + ], + [ + "2025-11-24T15:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998263917001072 + ], + [ + "2025-11-24T15:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998179313410307 + ], + [ + "2025-11-24T15:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996762810197987 + ], + [ + "2025-11-24T15:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997295855179302 + ], + [ + "2025-11-24T15:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998202947003588 + ], + [ + "2025-11-24T15:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998841210029129 + ], + [ + "2025-11-24T15:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997235809835262 + ], + [ + "2025-11-24T15:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999720405741602 + ], + [ + "2025-11-24T15:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999001070904213 + ], + [ + "2025-11-24T15:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000143526723885 + ], + [ + "2025-11-24T15:40:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999612977936392 + ], + [ + "2025-11-24T15:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997668507315136 + ], + [ + "2025-11-24T15:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999774765100382 + ], + [ + "2025-11-24T15:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002870809487012 + ], + [ + "2025-11-24T15:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003182793764847 + ], + [ + "2025-11-24T15:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999800577360581 + ], + [ + "2025-11-24T15:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003648161386165 + ], + [ + "2025-11-24T15:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007018125279712 + ], + [ + "2025-11-24T15:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0019162519242768 + ], + [ + "2025-11-24T15:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000821416152654 + ], + [ + "2025-11-24T15:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000260822583793 + ], + [ + "2025-11-24T15:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000275137219603 + ], + [ + "2025-11-24T15:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0017142861104429 + ], + [ + "2025-11-24T15:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002150364936768 + ], + [ + "2025-11-24T15:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000255141382387 + ], + [ + "2025-11-24T15:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997101194140923 + ], + [ + "2025-11-24T15:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002593298159483 + ], + [ + "2025-11-24T15:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005535573960533 + ], + [ + "2025-11-24T15:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000440782688402 + ], + [ + "2025-11-24T15:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999033907320946 + ], + [ + "2025-11-24T14:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007450996005731 + ], + [ + "2025-11-24T14:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003062767588253 + ], + [ + "2025-11-24T14:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010920455218073 + ], + [ + "2025-11-24T14:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000468504154887 + ], + [ + "2025-11-24T14:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009699860949952 + ], + [ + "2025-11-24T14:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997912480834152 + ], + [ + "2025-11-24T14:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009062513510778 + ], + [ + "2025-11-24T14:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006659908726112 + ], + [ + "2025-11-24T14:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997966983034261 + ], + [ + "2025-11-24T14:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997992106187238 + ], + [ + "2025-11-24T14:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998459517751694 + ], + [ + "2025-11-24T14:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998200832184333 + ], + [ + "2025-11-24T14:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999777653100801 + ], + [ + "2025-11-24T14:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998744561304728 + ], + [ + "2025-11-24T14:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998196158522012 + ], + [ + "2025-11-24T14:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997914841656454 + ], + [ + "2025-11-24T14:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998339243991885 + ], + [ + "2025-11-24T14:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999813278821053 + ], + [ + "2025-11-24T14:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997976558536721 + ], + [ + "2025-11-24T14:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999835043188132 + ], + [ + "2025-11-24T14:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998785206946139 + ], + [ + "2025-11-24T14:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998124686886642 + ], + [ + "2025-11-24T14:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997868871375716 + ], + [ + "2025-11-24T14:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997569261521295 + ], + [ + "2025-11-24T14:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997361656306807 + ], + [ + "2025-11-24T14:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997616548663679 + ], + [ + "2025-11-24T14:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998362985899616 + ], + [ + "2025-11-24T14:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997738212626782 + ], + [ + "2025-11-24T14:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997199151243231 + ], + [ + "2025-11-24T13:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998149209772741 + ], + [ + "2025-11-24T13:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997699418374086 + ], + [ + "2025-11-24T13:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998118392469992 + ], + [ + "2025-11-24T13:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997682863076421 + ], + [ + "2025-11-24T13:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998103355120513 + ], + [ + "2025-11-24T13:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997559640707672 + ], + [ + "2025-11-24T13:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998277938853812 + ], + [ + "2025-11-24T13:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998319440364529 + ], + [ + "2025-11-24T13:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998468406942423 + ], + [ + "2025-11-24T13:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996437334453651 + ], + [ + "2025-11-24T13:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997993551359147 + ], + [ + "2025-11-24T13:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000033785009818 + ], + [ + "2025-11-24T13:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998285094873851 + ], + [ + "2025-11-24T13:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999770309591307 + ], + [ + "2025-11-24T13:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998475673173296 + ], + [ + "2025-11-24T13:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003608570148812 + ], + [ + "2025-11-24T13:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997073077359301 + ], + [ + "2025-11-24T13:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016391075556652 + ], + [ + "2025-11-24T13:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997053364353508 + ], + [ + "2025-11-24T13:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997951276938967 + ], + [ + "2025-11-24T13:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997329679526685 + ], + [ + "2025-11-24T13:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996704434544519 + ], + [ + "2025-11-24T13:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997689068600146 + ], + [ + "2025-11-24T13:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999682719156051 + ], + [ + "2025-11-24T13:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997260300101589 + ], + [ + "2025-11-24T13:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997182315731291 + ], + [ + "2025-11-24T13:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998767902359199 + ], + [ + "2025-11-24T13:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997731208219157 + ], + [ + "2025-11-24T13:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996511230141126 + ], + [ + "2025-11-24T13:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998766194954318 + ], + [ + "2025-11-24T12:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999921992880544 + ], + [ + "2025-11-24T12:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999441145350053 + ], + [ + "2025-11-24T12:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998653382203865 + ], + [ + "2025-11-24T12:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999873413202622 + ], + [ + "2025-11-24T12:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999772285674037 + ], + [ + "2025-11-24T12:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000437071762708 + ], + [ + "2025-11-24T12:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998472571386473 + ], + [ + "2025-11-24T12:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998552935839665 + ], + [ + "2025-11-24T12:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007994409611645 + ], + [ + "2025-11-24T12:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998437263450438 + ], + [ + "2025-11-24T12:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998610057413493 + ], + [ + "2025-11-24T12:38:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000131724806037 + ], + [ + "2025-11-24T12:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014370659459255 + ], + [ + "2025-11-24T12:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001423233759936 + ], + [ + "2025-11-24T12:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999035584619088 + ], + [ + "2025-11-24T12:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997230078639379 + ], + [ + "2025-11-24T12:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997939671767618 + ], + [ + "2025-11-24T12:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997466597152662 + ], + [ + "2025-11-24T12:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999957308335926 + ], + [ + "2025-11-24T12:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997567163700225 + ], + [ + "2025-11-24T12:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996693817069018 + ], + [ + "2025-11-24T12:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996742290015774 + ], + [ + "2025-11-24T12:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997071360341325 + ], + [ + "2025-11-24T12:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998611225566775 + ], + [ + "2025-11-24T12:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997125474233701 + ], + [ + "2025-11-24T12:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996758478976334 + ], + [ + "2025-11-24T12:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998039883115585 + ], + [ + "2025-11-24T12:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998596300773791 + ], + [ + "2025-11-24T12:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998327541241901 + ], + [ + "2025-11-24T12:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996833164257473 + ], + [ + "2025-11-24T11:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998086747531374 + ], + [ + "2025-11-24T11:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997512061670629 + ], + [ + "2025-11-24T11:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998234759206678 + ], + [ + "2025-11-24T11:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997118456416884 + ], + [ + "2025-11-24T11:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997732165407873 + ], + [ + "2025-11-24T11:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996334235223182 + ], + [ + "2025-11-24T11:48:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998747350084359 + ], + [ + "2025-11-24T11:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999637024499314 + ], + [ + "2025-11-24T11:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998408877060435 + ], + [ + "2025-11-24T11:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997202292784702 + ], + [ + "2025-11-24T11:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999563252813499 + ], + [ + "2025-11-24T11:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000379822064505 + ], + [ + "2025-11-24T11:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000898817701587 + ], + [ + "2025-11-24T11:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003274294106645 + ], + [ + "2025-11-24T11:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000030863088991 + ], + [ + "2025-11-24T11:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002604724536583 + ], + [ + "2025-11-24T11:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002690686349094 + ], + [ + "2025-11-24T11:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003563461163991 + ], + [ + "2025-11-24T11:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000314213854098 + ], + [ + "2025-11-24T11:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997695596721125 + ], + [ + "2025-11-24T11:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000509154052664 + ], + [ + "2025-11-24T11:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996782647439321 + ], + [ + "2025-11-24T11:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997247649667707 + ], + [ + "2025-11-24T11:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001530432864143 + ], + [ + "2025-11-24T11:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000590785384899 + ], + [ + "2025-11-24T11:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996767680832397 + ], + [ + "2025-11-24T11:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997405341673337 + ], + [ + "2025-11-24T11:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002565309563713 + ], + [ + "2025-11-24T11:04:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006500756130035 + ], + [ + "2025-11-24T11:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997096081961514 + ], + [ + "2025-11-24T10:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999965883210342 + ], + [ + "2025-11-24T10:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998012636218696 + ], + [ + "2025-11-24T10:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999908065593665 + ], + [ + "2025-11-24T10:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997422881068195 + ], + [ + "2025-11-24T10:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001001830420693 + ], + [ + "2025-11-24T10:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998725326610728 + ], + [ + "2025-11-24T10:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000203579674639 + ], + [ + "2025-11-24T10:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002492229360254 + ], + [ + "2025-11-24T10:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005565695726817 + ], + [ + "2025-11-24T10:42:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999623218172451 + ], + [ + "2025-11-24T10:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999236771280983 + ], + [ + "2025-11-24T10:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007167287196135 + ], + [ + "2025-11-24T10:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005843583929483 + ], + [ + "2025-11-24T10:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002023282906538 + ], + [ + "2025-11-24T10:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012711523200153 + ], + [ + "2025-11-24T10:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005233545776946 + ], + [ + "2025-11-24T10:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004838840135843 + ], + [ + "2025-11-24T10:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996761902130452 + ], + [ + "2025-11-24T10:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998061553228427 + ], + [ + "2025-11-24T10:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000459353056612 + ], + [ + "2025-11-24T10:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997906767149377 + ], + [ + "2025-11-24T10:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997425321294384 + ], + [ + "2025-11-24T10:15:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998070592160061 + ], + [ + "2025-11-24T10:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000705382513952 + ], + [ + "2025-11-24T10:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997916424651295 + ], + [ + "2025-11-24T10:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997124543394119 + ], + [ + "2025-11-24T10:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997873283782264 + ], + [ + "2025-11-24T10:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996932589860567 + ], + [ + "2025-11-24T10:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998238242486667 + ], + [ + "2025-11-24T10:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998421207696112 + ], + [ + "2025-11-24T09:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997427173946616 + ], + [ + "2025-11-24T09:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997807791782133 + ], + [ + "2025-11-24T09:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997585800907947 + ], + [ + "2025-11-24T09:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000078834965478 + ], + [ + "2025-11-24T09:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998307940278205 + ], + [ + "2025-11-24T09:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997529498613199 + ], + [ + "2025-11-24T09:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997174435331134 + ], + [ + "2025-11-24T09:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005519345379104 + ], + [ + "2025-11-24T09:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997932960040292 + ], + [ + "2025-11-24T09:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998482327726823 + ], + [ + "2025-11-24T09:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997865884271424 + ], + [ + "2025-11-24T09:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999916925131713 + ], + [ + "2025-11-24T09:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998141493596303 + ], + [ + "2025-11-24T09:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001789134647272 + ], + [ + "2025-11-24T09:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000210598972028 + ], + [ + "2025-11-24T09:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999046457228977 + ], + [ + "2025-11-24T09:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999641033158682 + ], + [ + "2025-11-24T09:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016609336867806 + ], + [ + "2025-11-24T09:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010527364112267 + ], + [ + "2025-11-24T09:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999458568132683 + ], + [ + "2025-11-24T09:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001770097462648 + ], + [ + "2025-11-24T09:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016107049721248 + ], + [ + "2025-11-24T09:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998527641951177 + ], + [ + "2025-11-24T09:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000108998957266 + ], + [ + "2025-11-24T09:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002532921882121 + ], + [ + "2025-11-24T09:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014743669705672 + ], + [ + "2025-11-24T09:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000223581401466 + ], + [ + "2025-11-24T09:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005750890805862 + ], + [ + "2025-11-24T09:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004213736105152 + ], + [ + "2025-11-24T09:01:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013214435579 + ], + [ + "2025-11-24T08:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003124945492845 + ], + [ + "2025-11-24T08:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014609413885214 + ], + [ + "2025-11-24T08:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010038542220485 + ], + [ + "2025-11-24T08:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007146003483505 + ], + [ + "2025-11-24T08:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005573521525852 + ], + [ + "2025-11-24T08:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00064090624419 + ], + [ + "2025-11-24T08:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009610488350822 + ], + [ + "2025-11-24T08:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998894282060007 + ], + [ + "2025-11-24T08:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998883762651213 + ], + [ + "2025-11-24T08:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004716861640743 + ], + [ + "2025-11-24T08:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998744026773533 + ], + [ + "2025-11-24T08:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000010992790209 + ], + [ + "2025-11-24T08:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999210700597044 + ], + [ + "2025-11-24T08:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004493698348065 + ], + [ + "2025-11-24T08:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999110261840115 + ], + [ + "2025-11-24T08:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999175859966675 + ], + [ + "2025-11-24T08:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003223883569246 + ], + [ + "2025-11-24T08:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999605961758107 + ], + [ + "2025-11-24T08:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997615755102264 + ], + [ + "2025-11-24T08:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002149017899689 + ], + [ + "2025-11-24T08:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994806633667196 + ], + [ + "2025-11-24T08:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999932912770382 + ], + [ + "2025-11-24T08:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998297834270072 + ], + [ + "2025-11-24T08:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003479646424378 + ], + [ + "2025-11-24T08:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996155872216596 + ], + [ + "2025-11-24T08:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996859349916503 + ], + [ + "2025-11-24T08:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999795290258233 + ], + [ + "2025-11-24T08:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998815075798528 + ], + [ + "2025-11-24T08:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997239111279621 + ], + [ + "2025-11-24T08:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997824313149445 + ], + [ + "2025-11-24T07:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000154235390722 + ], + [ + "2025-11-24T07:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999564236385371 + ], + [ + "2025-11-24T07:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998296235459336 + ], + [ + "2025-11-24T07:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001661791624117 + ], + [ + "2025-11-24T07:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002965702592752 + ], + [ + "2025-11-24T07:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000095041863335 + ], + [ + "2025-11-24T07:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998821295453815 + ], + [ + "2025-11-24T07:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004311221436515 + ], + [ + "2025-11-24T07:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004683314302627 + ], + [ + "2025-11-24T07:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007615806621237 + ], + [ + "2025-11-24T07:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005423551325212 + ], + [ + "2025-11-24T07:37:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996849934330788 + ], + [ + "2025-11-24T07:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996496711233455 + ], + [ + "2025-11-24T07:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008611621043848 + ], + [ + "2025-11-24T07:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000229077501617 + ], + [ + "2025-11-24T07:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000242768490008 + ], + [ + "2025-11-24T07:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997065170955198 + ], + [ + "2025-11-24T07:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000085087015393 + ], + [ + "2025-11-24T07:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998490770954896 + ], + [ + "2025-11-24T07:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000367070887052 + ], + [ + "2025-11-24T07:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995875290932719 + ], + [ + "2025-11-24T07:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00041505852004 + ], + [ + "2025-11-24T07:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997926425844472 + ], + [ + "2025-11-24T07:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004812687609737 + ], + [ + "2025-11-24T07:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996181249708667 + ], + [ + "2025-11-24T07:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005756900161227 + ], + [ + "2025-11-24T07:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996967714352555 + ], + [ + "2025-11-24T07:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006461755681038 + ], + [ + "2025-11-24T07:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995503765854061 + ], + [ + "2025-11-24T07:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002160002318716 + ], + [ + "2025-11-24T06:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996768956520341 + ], + [ + "2025-11-24T06:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006171675884823 + ], + [ + "2025-11-24T06:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996267938662325 + ], + [ + "2025-11-24T06:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999687938347641 + ], + [ + "2025-11-24T06:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996103894543086 + ], + [ + "2025-11-24T06:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002578206714534 + ], + [ + "2025-11-24T06:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997367889861748 + ], + [ + "2025-11-24T06:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000427649482388 + ], + [ + "2025-11-24T06:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996804188271048 + ], + [ + "2025-11-24T06:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998252478906474 + ], + [ + "2025-11-24T06:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997315983542113 + ], + [ + "2025-11-24T06:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999646168261319 + ], + [ + "2025-11-24T06:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998257200135644 + ], + [ + "2025-11-24T06:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998366683258992 + ], + [ + "2025-11-24T06:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997983403262679 + ], + [ + "2025-11-24T06:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002253758173139 + ], + [ + "2025-11-24T06:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999759805310997 + ], + [ + "2025-11-24T06:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997702844531365 + ], + [ + "2025-11-24T06:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999811663523878 + ], + [ + "2025-11-24T06:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000515946462641 + ], + [ + "2025-11-24T06:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998767983275306 + ], + [ + "2025-11-24T06:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997290750848619 + ], + [ + "2025-11-24T06:16:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997597311806167 + ], + [ + "2025-11-24T06:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008543762109892 + ], + [ + "2025-11-24T06:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999050372475629 + ], + [ + "2025-11-24T06:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997506089361843 + ], + [ + "2025-11-24T06:07:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997167260886372 + ], + [ + "2025-11-24T06:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997016322379113 + ], + [ + "2025-11-24T06:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998887041836393 + ], + [ + "2025-11-24T06:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998663824967984 + ], + [ + "2025-11-24T05:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999817841493457 + ], + [ + "2025-11-24T05:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000524668667088 + ], + [ + "2025-11-24T05:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999390115502285 + ], + [ + "2025-11-24T05:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998431446628764 + ], + [ + "2025-11-24T05:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998962193867801 + ], + [ + "2025-11-24T05:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000564064950964 + ], + [ + "2025-11-24T05:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000002146900297 + ], + [ + "2025-11-24T05:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000074125459664 + ], + [ + "2025-11-24T05:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999768704389942 + ], + [ + "2025-11-24T05:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000665494028465 + ], + [ + "2025-11-24T05:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999715979340449 + ], + [ + "2025-11-24T05:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000239941105853 + ], + [ + "2025-11-24T05:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998174685148493 + ], + [ + "2025-11-24T05:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006717707997934 + ], + [ + "2025-11-24T05:31:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001911472033458 + ], + [ + "2025-11-24T05:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998511120889658 + ], + [ + "2025-11-24T05:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998470520334114 + ], + [ + "2025-11-24T05:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999536104210003 + ], + [ + "2025-11-24T05:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000150575669919 + ], + [ + "2025-11-24T05:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998790132925374 + ], + [ + "2025-11-24T05:19:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006002888478969 + ], + [ + "2025-11-24T05:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000635758193848 + ], + [ + "2025-11-24T05:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999806325701417 + ], + [ + "2025-11-24T05:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001226258264189 + ], + [ + "2025-11-24T05:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004453248252563 + ], + [ + "2025-11-24T05:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001597642590332 + ], + [ + "2025-11-24T05:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000852108652931 + ], + [ + "2025-11-24T05:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000894404851857 + ], + [ + "2025-11-24T05:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005010449324907 + ], + [ + "2025-11-24T05:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006442922495042 + ], + [ + "2025-11-24T04:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001007968216478 + ], + [ + "2025-11-24T04:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000323431168532 + ], + [ + "2025-11-24T04:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001229421396887 + ], + [ + "2025-11-24T04:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998687874154143 + ], + [ + "2025-11-24T04:51:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001097277611266 + ], + [ + "2025-11-24T04:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000745477563804 + ], + [ + "2025-11-24T04:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003093681489212 + ], + [ + "2025-11-24T04:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996614283060631 + ], + [ + "2025-11-24T04:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001897320596698 + ], + [ + "2025-11-24T04:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001756488737805 + ], + [ + "2025-11-24T04:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006071443919171 + ], + [ + "2025-11-24T04:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997066972494215 + ], + [ + "2025-11-24T04:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003884586134786 + ], + [ + "2025-11-24T04:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001208545461004 + ], + [ + "2025-11-24T04:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998116174883409 + ], + [ + "2025-11-24T04:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998313610579858 + ], + [ + "2025-11-24T04:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999302613701702 + ], + [ + "2025-11-24T04:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001550288953203 + ], + [ + "2025-11-24T04:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997990095673897 + ], + [ + "2025-11-24T04:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998506987978061 + ], + [ + "2025-11-24T04:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998909318493714 + ], + [ + "2025-11-24T04:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009765334559737 + ], + [ + "2025-11-24T04:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002212403154338 + ], + [ + "2025-11-24T04:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998350788418706 + ], + [ + "2025-11-24T04:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000487080143317 + ], + [ + "2025-11-24T04:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007972984579803 + ], + [ + "2025-11-24T04:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003046205802273 + ], + [ + "2025-11-24T04:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008170604107214 + ], + [ + "2025-11-24T04:03:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001058112364192 + ], + [ + "2025-11-24T04:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006065055893174 + ], + [ + "2025-11-24T03:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002550936602512 + ], + [ + "2025-11-24T03:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002881884729684 + ], + [ + "2025-11-24T03:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003688598035436 + ], + [ + "2025-11-24T03:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008085322922498 + ], + [ + "2025-11-24T03:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005971444726447 + ], + [ + "2025-11-24T03:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002108502440052 + ], + [ + "2025-11-24T03:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001984635042842 + ], + [ + "2025-11-24T03:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999922912011306 + ], + [ + "2025-11-24T03:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010574736389344 + ], + [ + "2025-11-24T03:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008769227462764 + ], + [ + "2025-11-24T03:40:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001096328229413 + ], + [ + "2025-11-24T03:37:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000524948817142 + ], + [ + "2025-11-24T03:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004138020215425 + ], + [ + "2025-11-24T03:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001344402135423 + ], + [ + "2025-11-24T03:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004546285515887 + ], + [ + "2025-11-24T03:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006038874158336 + ], + [ + "2025-11-24T03:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014090127009778 + ], + [ + "2025-11-24T03:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012446515980509 + ], + [ + "2025-11-24T03:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0015584311394066 + ], + [ + "2025-11-24T03:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008355775734539 + ], + [ + "2025-11-24T03:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001307892605568 + ], + [ + "2025-11-24T03:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014459043988524 + ], + [ + "2025-11-24T03:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001873527344747 + ], + [ + "2025-11-24T03:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007846865811088 + ], + [ + "2025-11-24T03:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014462205601091 + ], + [ + "2025-11-24T03:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013031643752017 + ], + [ + "2025-11-24T03:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001965635132834 + ], + [ + "2025-11-24T03:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000830209659346 + ], + [ + "2025-11-24T03:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011785519685412 + ], + [ + "2025-11-24T03:01:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009319990428047 + ], + [ + "2025-11-24T02:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00127353155282 + ], + [ + "2025-11-24T02:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000803094883938 + ], + [ + "2025-11-24T02:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006360948414892 + ], + [ + "2025-11-24T02:53:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009925568735958 + ], + [ + "2025-11-24T02:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000835200744271 + ], + [ + "2025-11-24T02:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007035381113116 + ], + [ + "2025-11-24T02:47:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008950744503884 + ], + [ + "2025-11-24T02:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006578691680243 + ], + [ + "2025-11-24T02:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000127065858309 + ], + [ + "2025-11-24T02:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005421101192675 + ], + [ + "2025-11-24T02:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000762656909643 + ], + [ + "2025-11-24T02:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000622162516017 + ], + [ + "2025-11-24T02:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000370467769615 + ], + [ + "2025-11-24T02:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000262804863866 + ], + [ + "2025-11-24T02:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000089735613967 + ], + [ + "2025-11-24T02:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001312514965427 + ], + [ + "2025-11-24T02:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000519525474891 + ], + [ + "2025-11-24T02:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999623674765705 + ], + [ + "2025-11-24T02:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003526124735806 + ], + [ + "2025-11-24T02:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012652864084965 + ], + [ + "2025-11-24T02:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001531667173482 + ], + [ + "2025-11-24T02:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002132796834546 + ], + [ + "2025-11-24T02:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000126949017742 + ], + [ + "2025-11-24T02:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000600715041692 + ], + [ + "2025-11-24T02:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000695233692059 + ], + [ + "2025-11-24T02:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004590242833158 + ], + [ + "2025-11-24T02:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000306330005038 + ], + [ + "2025-11-24T02:05:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999260043919298 + ], + [ + "2025-11-24T02:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008168224564349 + ], + [ + "2025-11-24T02:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000794479976147 + ], + [ + "2025-11-24T01:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000598199368016 + ], + [ + "2025-11-24T01:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000985989152298 + ], + [ + "2025-11-24T01:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999988338781677 + ], + [ + "2025-11-24T01:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001015585578577 + ], + [ + "2025-11-24T01:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001564843753352 + ], + [ + "2025-11-24T01:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003328406293732 + ], + [ + "2025-11-24T01:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000320499103543 + ], + [ + "2025-11-24T01:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999979322472045 + ], + [ + "2025-11-24T01:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999955434288761 + ], + [ + "2025-11-24T01:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007001594991682 + ], + [ + "2025-11-24T01:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001008639142754 + ], + [ + "2025-11-24T01:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000730698695959 + ], + [ + "2025-11-24T01:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999675433810737 + ], + [ + "2025-11-24T01:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000144392200511 + ], + [ + "2025-11-24T01:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999764219161318 + ], + [ + "2025-11-24T01:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002247759073286 + ], + [ + "2025-11-24T01:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000067759330315 + ], + [ + "2025-11-24T01:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010542842673642 + ], + [ + "2025-11-24T01:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999408641377397 + ], + [ + "2025-11-24T01:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999977824645465 + ], + [ + "2025-11-24T01:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000091828773132 + ], + [ + "2025-11-24T01:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000197544645206 + ], + [ + "2025-11-24T01:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999666646034451 + ], + [ + "2025-11-24T01:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999259995616676 + ], + [ + "2025-11-24T01:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000106054627018 + ], + [ + "2025-11-24T01:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000085722035982 + ], + [ + "2025-11-24T01:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999589422283706 + ], + [ + "2025-11-24T01:05:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999930278171483 + ], + [ + "2025-11-24T01:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000063493869059 + ], + [ + "2025-11-24T01:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002249649793244 + ], + [ + "2025-11-24T00:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999040342485569 + ], + [ + "2025-11-24T00:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999593648042039 + ], + [ + "2025-11-24T00:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000224861094213 + ], + [ + "2025-11-24T00:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011322639739026 + ], + [ + "2025-11-24T00:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004759195265915 + ], + [ + "2025-11-24T00:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999326756447848 + ], + [ + "2025-11-24T00:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000357324747173 + ], + [ + "2025-11-24T00:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001537070062088 + ], + [ + "2025-11-24T00:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007096811057243 + ], + [ + "2025-11-24T00:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997853758755323 + ], + [ + "2025-11-24T00:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003158472106 + ], + [ + "2025-11-24T00:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000002561658621 + ], + [ + "2025-11-24T00:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003370201864101 + ], + [ + "2025-11-24T00:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999761410142493 + ], + [ + "2025-11-24T00:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999195169004261 + ], + [ + "2025-11-24T00:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999679012689344 + ], + [ + "2025-11-24T00:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999288478835064 + ], + [ + "2025-11-24T00:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998042058258654 + ], + [ + "2025-11-24T00:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999224208441774 + ], + [ + "2025-11-24T00:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000047313833893 + ], + [ + "2025-11-24T00:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999749085975451 + ], + [ + "2025-11-24T00:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999124072476229 + ], + [ + "2025-11-24T00:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999993266757512 + ], + [ + "2025-11-24T00:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999962968329202 + ], + [ + "2025-11-24T00:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998968966367187 + ], + [ + "2025-11-24T00:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998370213708953 + ], + [ + "2025-11-24T00:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999996994907187 + ], + [ + "2025-11-24T00:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999003321814833 + ], + [ + "2025-11-24T00:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999320770761366 + ], + [ + "2025-11-24T00:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000636244141254 + ], + [ + "2025-11-23T23:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003179715114685 + ], + [ + "2025-11-23T23:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999994978662487 + ], + [ + "2025-11-23T23:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999990415631702 + ], + [ + "2025-11-23T23:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000536893388812 + ], + [ + "2025-11-23T23:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004702534764314 + ], + [ + "2025-11-23T23:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001001796702194 + ], + [ + "2025-11-23T23:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00011783580791 + ], + [ + "2025-11-23T23:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000935400078563 + ], + [ + "2025-11-23T23:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001143695335442 + ], + [ + "2025-11-23T23:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001108266026093 + ], + [ + "2025-11-23T23:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001094476140877 + ], + [ + "2025-11-23T23:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000124686906834 + ], + [ + "2025-11-23T23:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001642707215783 + ], + [ + "2025-11-23T23:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000659725815848 + ], + [ + "2025-11-23T23:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000766018780853 + ], + [ + "2025-11-23T23:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001244530694535 + ], + [ + "2025-11-23T23:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000115703082685 + ], + [ + "2025-11-23T23:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999902862477823 + ], + [ + "2025-11-23T23:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000128798339651 + ], + [ + "2025-11-23T23:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00015004397212 + ], + [ + "2025-11-23T23:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001011488733667 + ], + [ + "2025-11-23T23:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001065847035606 + ], + [ + "2025-11-23T23:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000754593355345 + ], + [ + "2025-11-23T23:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001520646001147 + ], + [ + "2025-11-23T23:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001371474571938 + ], + [ + "2025-11-23T23:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002650552691887 + ], + [ + "2025-11-23T23:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999733418046615 + ], + [ + "2025-11-23T23:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000689623090646 + ], + [ + "2025-11-23T23:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000086601097355 + ], + [ + "2025-11-23T23:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998363052147878 + ], + [ + "2025-11-23T22:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998825215710082 + ], + [ + "2025-11-23T22:58:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999697114844143 + ], + [ + "2025-11-23T22:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997172599987606 + ], + [ + "2025-11-23T22:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997351361237667 + ], + [ + "2025-11-23T22:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999761637519287 + ], + [ + "2025-11-23T22:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998624761796863 + ], + [ + "2025-11-23T22:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998039879029658 + ], + [ + "2025-11-23T22:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008378705346903 + ], + [ + "2025-11-23T22:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999302318095296 + ], + [ + "2025-11-23T22:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998422822608783 + ], + [ + "2025-11-23T22:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998612916759436 + ], + [ + "2025-11-23T22:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000031727691436 + ], + [ + "2025-11-23T22:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000142824155853 + ], + [ + "2025-11-23T22:33:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999110337587555 + ], + [ + "2025-11-23T22:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998498324990083 + ], + [ + "2025-11-23T22:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000370024548724 + ], + [ + "2025-11-23T22:28:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000008611869858 + ], + [ + "2025-11-23T22:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999423070463883 + ], + [ + "2025-11-23T22:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999949894531672 + ], + [ + "2025-11-23T22:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003026462742115 + ], + [ + "2025-11-23T22:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001158801425252 + ], + [ + "2025-11-23T22:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000137109352016 + ], + [ + "2025-11-23T22:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000206245318455 + ], + [ + "2025-11-23T22:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004812190619774 + ], + [ + "2025-11-23T22:11:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012189010289885 + ], + [ + "2025-11-23T22:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997680499864224 + ], + [ + "2025-11-23T22:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000043670572117 + ], + [ + "2025-11-23T22:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004417312508673 + ], + [ + "2025-11-23T22:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011764227512774 + ], + [ + "2025-11-23T22:01:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998950685072655 + ], + [ + "2025-11-23T21:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998987347418805 + ], + [ + "2025-11-23T21:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014435754594608 + ], + [ + "2025-11-23T21:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999988759035085 + ], + [ + "2025-11-23T21:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000414389394605 + ], + [ + "2025-11-23T21:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001340282146158 + ], + [ + "2025-11-23T21:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005812993970613 + ], + [ + "2025-11-23T21:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999613442479893 + ], + [ + "2025-11-23T21:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000567197015502 + ], + [ + "2025-11-23T21:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000303359337598 + ], + [ + "2025-11-23T21:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0015341027370308 + ], + [ + "2025-11-23T21:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999559631211803 + ], + [ + "2025-11-23T21:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000398678365339 + ], + [ + "2025-11-23T21:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009788073458106 + ], + [ + "2025-11-23T21:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999820860258559 + ], + [ + "2025-11-23T21:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999942793434696 + ], + [ + "2025-11-23T21:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999699210592216 + ], + [ + "2025-11-23T21:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009760072037193 + ], + [ + "2025-11-23T21:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001728380466226 + ], + [ + "2025-11-23T21:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999393748823054 + ], + [ + "2025-11-23T21:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000146809606658 + ], + [ + "2025-11-23T21:20:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012698254956447 + ], + [ + "2025-11-23T21:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002790462697857 + ], + [ + "2025-11-23T21:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999285400478738 + ], + [ + "2025-11-23T21:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999741687342683 + ], + [ + "2025-11-23T21:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013065589019963 + ], + [ + "2025-11-23T21:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001232469731813 + ], + [ + "2025-11-23T21:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999871791243046 + ], + [ + "2025-11-23T21:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999837089284426 + ], + [ + "2025-11-23T21:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996235805319383 + ], + [ + "2025-11-23T21:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000248791778654 + ], + [ + "2025-11-23T20:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999356676424369 + ], + [ + "2025-11-23T20:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000014070536667 + ], + [ + "2025-11-23T20:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000497286291983 + ], + [ + "2025-11-23T20:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000027021541703 + ], + [ + "2025-11-23T20:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000181912097663 + ], + [ + "2025-11-23T20:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000134461563166 + ], + [ + "2025-11-23T20:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999850838547026 + ], + [ + "2025-11-23T20:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001438586926965 + ], + [ + "2025-11-23T20:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000318677468514 + ], + [ + "2025-11-23T20:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000158210410233 + ], + [ + "2025-11-23T20:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999939076959711 + ], + [ + "2025-11-23T20:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002376861254014 + ], + [ + "2025-11-23T20:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000039568115887 + ], + [ + "2025-11-23T20:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999373932565038 + ], + [ + "2025-11-23T20:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000543976831828 + ], + [ + "2025-11-23T20:30:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000208056513692 + ], + [ + "2025-11-23T20:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999444505043094 + ], + [ + "2025-11-23T20:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000104261427323 + ], + [ + "2025-11-23T20:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000680079377624 + ], + [ + "2025-11-23T20:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004643052248772 + ], + [ + "2025-11-23T20:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999102081115662 + ], + [ + "2025-11-23T20:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999464319115885 + ], + [ + "2025-11-23T20:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000131587649748 + ], + [ + "2025-11-23T20:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998921115483306 + ], + [ + "2025-11-23T20:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999155799227538 + ], + [ + "2025-11-23T20:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999995689886185 + ], + [ + "2025-11-23T20:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000095346418636 + ], + [ + "2025-11-23T20:05:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999211919835205 + ], + [ + "2025-11-23T20:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999922057695766 + ], + [ + "2025-11-23T20:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000207880797014 + ], + [ + "2025-11-23T19:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000686713474165 + ], + [ + "2025-11-23T19:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000090312544565 + ], + [ + "2025-11-23T19:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000309824229372 + ], + [ + "2025-11-23T19:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000468004010716 + ], + [ + "2025-11-23T19:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000930252810891 + ], + [ + "2025-11-23T19:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001348761547617 + ], + [ + "2025-11-23T19:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000358242615037 + ], + [ + "2025-11-23T19:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000436610628036 + ], + [ + "2025-11-23T19:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007135306123467 + ], + [ + "2025-11-23T19:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007646907996153 + ], + [ + "2025-11-23T19:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999957428288921 + ], + [ + "2025-11-23T19:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000127281228235 + ], + [ + "2025-11-23T19:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000010598462772 + ], + [ + "2025-11-23T19:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007378955213995 + ], + [ + "2025-11-23T19:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999785425430333 + ], + [ + "2025-11-23T19:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000492241128582 + ], + [ + "2025-11-23T19:28:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999199151994497 + ], + [ + "2025-11-23T19:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006117859096222 + ], + [ + "2025-11-23T19:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000075087366933 + ], + [ + "2025-11-23T19:21:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000570441962011 + ], + [ + "2025-11-23T19:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997943010087635 + ], + [ + "2025-11-23T19:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010338826808245 + ], + [ + "2025-11-23T19:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000380566272769 + ], + [ + "2025-11-23T19:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000157885871637 + ], + [ + "2025-11-23T19:11:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997780457881256 + ], + [ + "2025-11-23T19:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000442920636725 + ], + [ + "2025-11-23T19:07:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999842927511368 + ], + [ + "2025-11-23T19:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999168621965916 + ], + [ + "2025-11-23T19:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000073493021761 + ], + [ + "2025-11-23T19:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002045783566729 + ], + [ + "2025-11-23T18:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000329461852233 + ], + [ + "2025-11-23T18:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005357626572575 + ], + [ + "2025-11-23T18:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003718651358775 + ], + [ + "2025-11-23T18:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003152068779935 + ], + [ + "2025-11-23T18:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000226828266786 + ], + [ + "2025-11-23T18:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008384655598621 + ], + [ + "2025-11-23T18:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004088119144685 + ], + [ + "2025-11-23T18:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012730746762113 + ], + [ + "2025-11-23T18:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000290985877754 + ], + [ + "2025-11-23T18:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014715654391029 + ], + [ + "2025-11-23T18:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004198282939734 + ], + [ + "2025-11-23T18:37:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000263110071002 + ], + [ + "2025-11-23T18:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000361153602981 + ], + [ + "2025-11-23T18:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000375213014525 + ], + [ + "2025-11-23T18:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997112280065664 + ], + [ + "2025-11-23T18:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999969577380603 + ], + [ + "2025-11-23T18:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999821121254866 + ], + [ + "2025-11-23T18:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998844778251585 + ], + [ + "2025-11-23T18:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998614331035466 + ], + [ + "2025-11-23T18:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999716285108861 + ], + [ + "2025-11-23T18:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000193899411635 + ], + [ + "2025-11-23T18:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999541610546588 + ], + [ + "2025-11-23T18:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999948805995159 + ], + [ + "2025-11-23T18:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997807291923428 + ], + [ + "2025-11-23T18:12:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002274597106224 + ], + [ + "2025-11-23T18:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999103443728107 + ], + [ + "2025-11-23T18:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000050785864343 + ], + [ + "2025-11-23T18:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006842757502699 + ], + [ + "2025-11-23T18:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002768239468764 + ], + [ + "2025-11-23T18:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999950131952732 + ], + [ + "2025-11-23T17:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001408239692786 + ], + [ + "2025-11-23T17:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016113700371754 + ], + [ + "2025-11-23T17:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000814018957256 + ], + [ + "2025-11-23T17:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999426115337119 + ], + [ + "2025-11-23T17:52:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000169816086348 + ], + [ + "2025-11-23T17:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012799097291134 + ], + [ + "2025-11-23T17:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000078405461984 + ], + [ + "2025-11-23T17:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000858503886807 + ], + [ + "2025-11-23T17:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001103023773246 + ], + [ + "2025-11-23T17:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001658808826142 + ], + [ + "2025-11-23T17:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000057509054943 + ], + [ + "2025-11-23T17:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000864205089417 + ], + [ + "2025-11-23T17:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999553493291757 + ], + [ + "2025-11-23T17:33:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000179154453643 + ], + [ + "2025-11-23T17:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999566637709889 + ], + [ + "2025-11-23T17:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008855930815876 + ], + [ + "2025-11-23T17:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999787453730925 + ], + [ + "2025-11-23T17:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000381154885511 + ], + [ + "2025-11-23T17:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999926563373277 + ], + [ + "2025-11-23T17:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000757692314326 + ], + [ + "2025-11-23T17:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998151649285413 + ], + [ + "2025-11-23T17:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999779427670231 + ], + [ + "2025-11-23T17:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999841329549597 + ], + [ + "2025-11-23T17:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998707566597919 + ], + [ + "2025-11-23T17:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998096865921892 + ], + [ + "2025-11-23T17:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998065856241363 + ], + [ + "2025-11-23T17:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999549992773579 + ], + [ + "2025-11-23T17:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999904938148471 + ], + [ + "2025-11-23T17:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998624559163604 + ], + [ + "2025-11-23T17:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000011604277093 + ], + [ + "2025-11-23T16:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999910484812414 + ], + [ + "2025-11-23T16:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999416756727036 + ], + [ + "2025-11-23T16:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999979565896028 + ], + [ + "2025-11-23T16:54:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005831135928487 + ], + [ + "2025-11-23T16:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998574072589659 + ], + [ + "2025-11-23T16:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999397742539192 + ], + [ + "2025-11-23T16:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999058598440959 + ], + [ + "2025-11-23T16:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998496817494276 + ], + [ + "2025-11-23T16:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012968836437022 + ], + [ + "2025-11-23T16:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000916817085234 + ], + [ + "2025-11-23T16:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009832482960979 + ], + [ + "2025-11-23T16:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999730100151388 + ], + [ + "2025-11-23T16:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996974595185565 + ], + [ + "2025-11-23T16:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999753425541715 + ], + [ + "2025-11-23T16:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001962079560784 + ], + [ + "2025-11-23T16:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005170097157259 + ], + [ + "2025-11-23T16:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002843401957637 + ], + [ + "2025-11-23T16:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996492163355961 + ], + [ + "2025-11-23T16:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999391285589623 + ], + [ + "2025-11-23T16:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999914082315287 + ], + [ + "2025-11-23T16:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999295795766455 + ], + [ + "2025-11-23T16:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007177625820798 + ], + [ + "2025-11-23T16:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999606019431467 + ], + [ + "2025-11-23T16:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999185375676553 + ], + [ + "2025-11-23T16:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000080457314313 + ], + [ + "2025-11-23T16:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000745730692787 + ], + [ + "2025-11-23T16:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000302976575163 + ], + [ + "2025-11-23T16:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999636905590821 + ], + [ + "2025-11-23T16:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003601240798172 + ], + [ + "2025-11-23T16:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000789193693085 + ], + [ + "2025-11-23T15:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008427163488505 + ], + [ + "2025-11-23T15:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000027549606316 + ], + [ + "2025-11-23T15:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006873837510784 + ], + [ + "2025-11-23T15:54:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011508313447115 + ], + [ + "2025-11-23T15:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011569552983404 + ], + [ + "2025-11-23T15:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000726467506091 + ], + [ + "2025-11-23T15:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996880852545884 + ], + [ + "2025-11-23T15:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999466518121135 + ], + [ + "2025-11-23T15:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998036015531451 + ], + [ + "2025-11-23T15:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001238303658332 + ], + [ + "2025-11-23T15:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998929634824182 + ], + [ + "2025-11-23T15:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998836894575477 + ], + [ + "2025-11-23T15:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999281879299845 + ], + [ + "2025-11-23T15:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005678752015563 + ], + [ + "2025-11-23T15:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002652254444706 + ], + [ + "2025-11-23T15:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009068997119923 + ], + [ + "2025-11-23T15:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999914582117816 + ], + [ + "2025-11-23T15:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002775436292481 + ], + [ + "2025-11-23T15:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999850015340132 + ], + [ + "2025-11-23T15:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999743088297524 + ], + [ + "2025-11-23T15:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999111265632041 + ], + [ + "2025-11-23T15:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016394107059554 + ], + [ + "2025-11-23T15:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998573149570058 + ], + [ + "2025-11-23T15:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998942452043573 + ], + [ + "2025-11-23T15:11:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999444397168659 + ], + [ + "2025-11-23T15:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999983579673685 + ], + [ + "2025-11-23T15:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999488140614845 + ], + [ + "2025-11-23T15:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998279758199028 + ], + [ + "2025-11-23T15:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999172197650309 + ], + [ + "2025-11-23T15:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998777249920072 + ], + [ + "2025-11-23T14:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999806674602075 + ], + [ + "2025-11-23T14:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997379794389344 + ], + [ + "2025-11-23T14:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999147167014474 + ], + [ + "2025-11-23T14:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997706370574165 + ], + [ + "2025-11-23T14:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998225702498669 + ], + [ + "2025-11-23T14:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998857465951428 + ], + [ + "2025-11-23T14:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995919547170091 + ], + [ + "2025-11-23T14:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997574060615751 + ], + [ + "2025-11-23T14:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998992802746948 + ], + [ + "2025-11-23T14:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999533288480669 + ], + [ + "2025-11-23T14:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997274496808798 + ], + [ + "2025-11-23T14:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996578459452757 + ], + [ + "2025-11-23T14:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998948477855025 + ], + [ + "2025-11-23T14:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000173537858954 + ], + [ + "2025-11-23T14:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998683490022336 + ], + [ + "2025-11-23T14:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998078714392162 + ], + [ + "2025-11-23T14:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999405926227406 + ], + [ + "2025-11-23T14:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001149115396573 + ], + [ + "2025-11-23T14:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998658567368127 + ], + [ + "2025-11-23T14:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999146193217034 + ], + [ + "2025-11-23T14:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999538369306932 + ], + [ + "2025-11-23T14:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000667418231605 + ], + [ + "2025-11-23T14:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998450908639874 + ], + [ + "2025-11-23T14:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000188951254339 + ], + [ + "2025-11-23T14:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999527271760793 + ], + [ + "2025-11-23T14:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000958710090506 + ], + [ + "2025-11-23T14:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998432009212176 + ], + [ + "2025-11-23T14:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000709431944674 + ], + [ + "2025-11-23T14:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999320744942702 + ], + [ + "2025-11-23T14:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007186863959276 + ], + [ + "2025-11-23T13:59:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997155007136421 + ], + [ + "2025-11-23T13:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008583090367709 + ], + [ + "2025-11-23T13:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998414708029962 + ], + [ + "2025-11-23T13:53:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016351121891622 + ], + [ + "2025-11-23T13:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997610863556663 + ], + [ + "2025-11-23T13:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010271284826553 + ], + [ + "2025-11-23T13:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001204310187688 + ], + [ + "2025-11-23T13:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012536780449777 + ], + [ + "2025-11-23T13:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013909081732126 + ], + [ + "2025-11-23T13:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012409789711367 + ], + [ + "2025-11-23T13:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996749893682855 + ], + [ + "2025-11-23T13:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011372308493336 + ], + [ + "2025-11-23T13:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998599607926998 + ], + [ + "2025-11-23T13:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000349182453964 + ], + [ + "2025-11-23T13:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998766077374768 + ], + [ + "2025-11-23T13:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001743977186925 + ], + [ + "2025-11-23T13:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998341182727902 + ], + [ + "2025-11-23T13:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999859602478612 + ], + [ + "2025-11-23T13:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999190336100224 + ], + [ + "2025-11-23T13:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998253583232906 + ], + [ + "2025-11-23T13:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997692798366168 + ], + [ + "2025-11-23T13:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998022904410508 + ], + [ + "2025-11-23T13:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999194862655144 + ], + [ + "2025-11-23T13:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999888083457334 + ], + [ + "2025-11-23T13:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997769502634115 + ], + [ + "2025-11-23T13:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997620714963356 + ], + [ + "2025-11-23T13:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998591782645023 + ], + [ + "2025-11-23T13:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999163633296358 + ], + [ + "2025-11-23T13:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999184454571592 + ], + [ + "2025-11-23T13:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998341973551921 + ], + [ + "2025-11-23T12:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998485431492058 + ], + [ + "2025-11-23T12:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999216671006619 + ], + [ + "2025-11-23T12:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999882012585479 + ], + [ + "2025-11-23T12:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998771592162348 + ], + [ + "2025-11-23T12:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999846781522589 + ], + [ + "2025-11-23T12:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999273661405996 + ], + [ + "2025-11-23T12:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998483673953484 + ], + [ + "2025-11-23T12:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999470414800404 + ], + [ + "2025-11-23T12:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998532467372595 + ], + [ + "2025-11-23T12:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999375064888113 + ], + [ + "2025-11-23T12:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000018815833873 + ], + [ + "2025-11-23T12:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000058511890202 + ], + [ + "2025-11-23T12:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998074197463679 + ], + [ + "2025-11-23T12:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999644190310353 + ], + [ + "2025-11-23T12:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000398007603153 + ], + [ + "2025-11-23T12:30:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000326080295359 + ], + [ + "2025-11-23T12:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000561196576694 + ], + [ + "2025-11-23T12:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000032272938137 + ], + [ + "2025-11-23T12:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000033386666664 + ], + [ + "2025-11-23T12:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000048777566135 + ], + [ + "2025-11-23T12:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000046603151724 + ], + [ + "2025-11-23T12:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000066131171094 + ], + [ + "2025-11-23T12:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000443157407203 + ], + [ + "2025-11-23T12:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000041103475028 + ], + [ + "2025-11-23T12:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999938982370405 + ], + [ + "2025-11-23T12:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002632547121402 + ], + [ + "2025-11-23T12:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011541741615082 + ], + [ + "2025-11-23T12:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000211044939273 + ], + [ + "2025-11-23T12:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999777773160307 + ], + [ + "2025-11-23T12:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001414463732796 + ], + [ + "2025-11-23T11:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008465610129627 + ], + [ + "2025-11-23T11:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999956850864157 + ], + [ + "2025-11-23T11:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999231085009396 + ], + [ + "2025-11-23T11:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001438620420071 + ], + [ + "2025-11-23T11:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003668552321192 + ], + [ + "2025-11-23T11:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998667176024453 + ], + [ + "2025-11-23T11:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000698105596516 + ], + [ + "2025-11-23T11:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001370168924344 + ], + [ + "2025-11-23T11:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000887563184122 + ], + [ + "2025-11-23T11:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997455481629673 + ], + [ + "2025-11-23T11:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0019306961271464 + ], + [ + "2025-11-23T11:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014073042403122 + ], + [ + "2025-11-23T11:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000023280993168 + ], + [ + "2025-11-23T11:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006961600675979 + ], + [ + "2025-11-23T11:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001281889460483 + ], + [ + "2025-11-23T11:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012409543752683 + ], + [ + "2025-11-23T11:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997732753446098 + ], + [ + "2025-11-23T11:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008023238137433 + ], + [ + "2025-11-23T11:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002935526968095 + ], + [ + "2025-11-23T11:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011439186808078 + ], + [ + "2025-11-23T11:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997109047985789 + ], + [ + "2025-11-23T11:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999161863802677 + ], + [ + "2025-11-23T11:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008167283589007 + ], + [ + "2025-11-23T11:13:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997249938091918 + ], + [ + "2025-11-23T11:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000027735336414 + ], + [ + "2025-11-23T11:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000051192934667 + ], + [ + "2025-11-23T11:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000055086984454 + ], + [ + "2025-11-23T11:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996546048299565 + ], + [ + "2025-11-23T11:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997541166521631 + ], + [ + "2025-11-23T11:01:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997564424161112 + ], + [ + "2025-11-23T10:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000555336630814 + ], + [ + "2025-11-23T10:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998997897503276 + ], + [ + "2025-11-23T10:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016298942453823 + ], + [ + "2025-11-23T10:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998471314051548 + ], + [ + "2025-11-23T10:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005078496270525 + ], + [ + "2025-11-23T10:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999008983089455 + ], + [ + "2025-11-23T10:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999859190711745 + ], + [ + "2025-11-23T10:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001766121577669 + ], + [ + "2025-11-23T10:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009832666284777 + ], + [ + "2025-11-23T10:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009317770451338 + ], + [ + "2025-11-23T10:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000097180888285 + ], + [ + "2025-11-23T10:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001449228980252 + ], + [ + "2025-11-23T10:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999614621960101 + ], + [ + "2025-11-23T10:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000980883718971 + ], + [ + "2025-11-23T10:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999682414789813 + ], + [ + "2025-11-23T10:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999040319428735 + ], + [ + "2025-11-23T10:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999831217211972 + ], + [ + "2025-11-23T10:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00090872661843 + ], + [ + "2025-11-23T10:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00014051389251 + ], + [ + "2025-11-23T10:21:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999491396142565 + ], + [ + "2025-11-23T10:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000014072420105 + ], + [ + "2025-11-23T10:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009811969350157 + ], + [ + "2025-11-23T10:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004910640735054 + ], + [ + "2025-11-23T10:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999826049881645 + ], + [ + "2025-11-23T10:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997944516707762 + ], + [ + "2025-11-23T10:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001000684343484 + ], + [ + "2025-11-23T10:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002192706514235 + ], + [ + "2025-11-23T10:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999220898375581 + ], + [ + "2025-11-23T10:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000512095719833 + ], + [ + "2025-11-23T10:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007933756459997 + ], + [ + "2025-11-23T09:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011146627175673 + ], + [ + "2025-11-23T09:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998626489726059 + ], + [ + "2025-11-23T09:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008301659539285 + ], + [ + "2025-11-23T09:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998533734253771 + ], + [ + "2025-11-23T09:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999180314577211 + ], + [ + "2025-11-23T09:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997288789133275 + ], + [ + "2025-11-23T09:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999400427757583 + ], + [ + "2025-11-23T09:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999414882499279 + ], + [ + "2025-11-23T09:44:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998487752369637 + ], + [ + "2025-11-23T09:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997952841276312 + ], + [ + "2025-11-23T09:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000392086370444 + ], + [ + "2025-11-23T09:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999822528996908 + ], + [ + "2025-11-23T09:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999462476121825 + ], + [ + "2025-11-23T09:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998013628871153 + ], + [ + "2025-11-23T09:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000136310577434 + ], + [ + "2025-11-23T09:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999213554789993 + ], + [ + "2025-11-23T09:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000031631466137 + ], + [ + "2025-11-23T09:25:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998964399050653 + ], + [ + "2025-11-23T09:24:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998378944737755 + ], + [ + "2025-11-23T09:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000133233574648 + ], + [ + "2025-11-23T09:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998834406936353 + ], + [ + "2025-11-23T09:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000173043219354 + ], + [ + "2025-11-23T09:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000002771180788 + ], + [ + "2025-11-23T09:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001225820641253 + ], + [ + "2025-11-23T09:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999302489334699 + ], + [ + "2025-11-23T09:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000011483368705 + ], + [ + "2025-11-23T09:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003454038465724 + ], + [ + "2025-11-23T09:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000132950819629 + ], + [ + "2025-11-23T09:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999934395167027 + ], + [ + "2025-11-23T09:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001174045689674 + ], + [ + "2025-11-23T08:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005264354575896 + ], + [ + "2025-11-23T08:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001409078751966 + ], + [ + "2025-11-23T08:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000014214040916 + ], + [ + "2025-11-23T08:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001004609627704 + ], + [ + "2025-11-23T08:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010977448713156 + ], + [ + "2025-11-23T08:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002239728321056 + ], + [ + "2025-11-23T08:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001892213794883 + ], + [ + "2025-11-23T08:46:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000111779562381 + ], + [ + "2025-11-23T08:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011226727001805 + ], + [ + "2025-11-23T08:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003431818946034 + ], + [ + "2025-11-23T08:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001388106733358 + ], + [ + "2025-11-23T08:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000167730257612 + ], + [ + "2025-11-23T08:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999586740821108 + ], + [ + "2025-11-23T08:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000172840071646 + ], + [ + "2025-11-23T08:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000108076226805 + ], + [ + "2025-11-23T08:29:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007145047995005 + ], + [ + "2025-11-23T08:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999746253303998 + ], + [ + "2025-11-23T08:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999785065811181 + ], + [ + "2025-11-23T08:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000059686950966 + ], + [ + "2025-11-23T08:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010877295798484 + ], + [ + "2025-11-23T08:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00005978122636 + ], + [ + "2025-11-23T08:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999211896531186 + ], + [ + "2025-11-23T08:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009074619764962 + ], + [ + "2025-11-23T08:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0012786510364136 + ], + [ + "2025-11-23T08:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006060965296868 + ], + [ + "2025-11-23T08:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999156031426162 + ], + [ + "2025-11-23T08:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000289419669651 + ], + [ + "2025-11-23T08:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009477675924632 + ], + [ + "2025-11-23T08:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000969870186866 + ], + [ + "2025-11-23T08:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999359375986615 + ], + [ + "2025-11-23T07:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000418365586287 + ], + [ + "2025-11-23T07:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003815735300845 + ], + [ + "2025-11-23T07:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996522323756282 + ], + [ + "2025-11-23T07:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999979266056732 + ], + [ + "2025-11-23T07:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999846896893624 + ], + [ + "2025-11-23T07:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999697573700329 + ], + [ + "2025-11-23T07:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999773487311745 + ], + [ + "2025-11-23T07:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999445465217123 + ], + [ + "2025-11-23T07:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000877482857329 + ], + [ + "2025-11-23T07:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003864544882304 + ], + [ + "2025-11-23T07:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000277263252564 + ], + [ + "2025-11-23T07:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999958757525745 + ], + [ + "2025-11-23T07:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000103556989078 + ], + [ + "2025-11-23T07:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998938754924569 + ], + [ + "2025-11-23T07:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999725381701475 + ], + [ + "2025-11-23T07:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999438955589155 + ], + [ + "2025-11-23T07:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005449924879501 + ], + [ + "2025-11-23T07:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998703949273053 + ], + [ + "2025-11-23T07:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999698968986209 + ], + [ + "2025-11-23T07:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000303572579188 + ], + [ + "2025-11-23T07:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008149462213967 + ], + [ + "2025-11-23T07:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999868350092175 + ], + [ + "2025-11-23T07:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999162092356646 + ], + [ + "2025-11-23T07:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000007727537064 + ], + [ + "2025-11-23T07:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008882312078686 + ], + [ + "2025-11-23T07:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998742991860814 + ], + [ + "2025-11-23T07:07:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999551150828562 + ], + [ + "2025-11-23T07:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000362817216575 + ], + [ + "2025-11-23T07:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000025744021293 + ], + [ + "2025-11-23T07:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998231680800486 + ], + [ + "2025-11-23T06:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996564580718162 + ], + [ + "2025-11-23T06:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999077791763095 + ], + [ + "2025-11-23T06:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996871632109637 + ], + [ + "2025-11-23T06:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998494358190875 + ], + [ + "2025-11-23T06:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997277421502894 + ], + [ + "2025-11-23T06:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999775416782943 + ], + [ + "2025-11-23T06:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998456488125088 + ], + [ + "2025-11-23T06:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999411713095439 + ], + [ + "2025-11-23T06:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002670402996106 + ], + [ + "2025-11-23T06:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998429130545754 + ], + [ + "2025-11-23T06:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998274938885047 + ], + [ + "2025-11-23T06:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999679454137637 + ], + [ + "2025-11-23T06:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005129393302243 + ], + [ + "2025-11-23T06:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997447144390482 + ], + [ + "2025-11-23T06:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999981386819835 + ], + [ + "2025-11-23T06:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999988405763836 + ], + [ + "2025-11-23T06:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011590674870154 + ], + [ + "2025-11-23T06:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999469869449256 + ], + [ + "2025-11-23T06:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008685270107465 + ], + [ + "2025-11-23T06:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998452616235481 + ], + [ + "2025-11-23T06:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999905470006938 + ], + [ + "2025-11-23T06:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000770636111393 + ], + [ + "2025-11-23T06:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003958516462803 + ], + [ + "2025-11-23T06:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9995757808249941 + ], + [ + "2025-11-23T06:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001499072964255 + ], + [ + "2025-11-23T06:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000446398245726 + ], + [ + "2025-11-23T06:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997354605828528 + ], + [ + "2025-11-23T06:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996106466097886 + ], + [ + "2025-11-23T06:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011809599490311 + ], + [ + "2025-11-23T06:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996869897900753 + ], + [ + "2025-11-23T05:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998734503876516 + ], + [ + "2025-11-23T05:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998134235556375 + ], + [ + "2025-11-23T05:56:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007223017064297 + ], + [ + "2025-11-23T05:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.99968604492015 + ], + [ + "2025-11-23T05:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997787400423884 + ], + [ + "2025-11-23T05:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997401415926112 + ], + [ + "2025-11-23T05:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997805569993706 + ], + [ + "2025-11-23T05:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997129006386914 + ], + [ + "2025-11-23T05:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996565982583193 + ], + [ + "2025-11-23T05:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997837905329808 + ], + [ + "2025-11-23T05:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999484365993977 + ], + [ + "2025-11-23T05:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997938985115454 + ], + [ + "2025-11-23T05:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999762094135496 + ], + [ + "2025-11-23T05:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998991188355535 + ], + [ + "2025-11-23T05:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000726300235352 + ], + [ + "2025-11-23T05:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998387577327984 + ], + [ + "2025-11-23T05:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998131547079075 + ], + [ + "2025-11-23T05:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998806665848764 + ], + [ + "2025-11-23T05:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007540603227134 + ], + [ + "2025-11-23T05:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997848508192207 + ], + [ + "2025-11-23T05:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000061417967544 + ], + [ + "2025-11-23T05:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997537899797905 + ], + [ + "2025-11-23T05:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009097969720364 + ], + [ + "2025-11-23T05:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998225590758292 + ], + [ + "2025-11-23T05:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997597010076391 + ], + [ + "2025-11-23T05:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999096955671488 + ], + [ + "2025-11-23T05:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999077799116544 + ], + [ + "2025-11-23T05:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997330777514959 + ], + [ + "2025-11-23T05:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998534303406507 + ], + [ + "2025-11-23T05:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999926920263359 + ], + [ + "2025-11-23T04:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999653177245998 + ], + [ + "2025-11-23T04:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998084379236747 + ], + [ + "2025-11-23T04:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996494182710589 + ], + [ + "2025-11-23T04:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000989036276864 + ], + [ + "2025-11-23T04:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999566813041261 + ], + [ + "2025-11-23T04:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998487506731639 + ], + [ + "2025-11-23T04:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999648867485715 + ], + [ + "2025-11-23T04:46:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005881741424907 + ], + [ + "2025-11-23T04:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998097163807872 + ], + [ + "2025-11-23T04:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996541190387794 + ], + [ + "2025-11-23T04:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997035241661173 + ], + [ + "2025-11-23T04:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999778152065504 + ], + [ + "2025-11-23T04:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999988687711491 + ], + [ + "2025-11-23T04:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997416770544721 + ], + [ + "2025-11-23T04:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998287516937456 + ], + [ + "2025-11-23T04:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000055118042381 + ], + [ + "2025-11-23T04:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999264588474728 + ], + [ + "2025-11-23T04:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998427853365287 + ], + [ + "2025-11-23T04:24:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998160947030272 + ], + [ + "2025-11-23T04:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999312396019966 + ], + [ + "2025-11-23T04:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999713595482976 + ], + [ + "2025-11-23T04:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000239700190032 + ], + [ + "2025-11-23T04:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000064525924417 + ], + [ + "2025-11-23T04:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000017284004109 + ], + [ + "2025-11-23T04:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999896913027666 + ], + [ + "2025-11-23T04:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001627294218518 + ], + [ + "2025-11-23T04:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000045354279788 + ], + [ + "2025-11-23T04:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999771643746023 + ], + [ + "2025-11-23T04:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998932400181185 + ], + [ + "2025-11-23T04:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003105209965981 + ], + [ + "2025-11-23T03:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999197399709512 + ], + [ + "2025-11-23T03:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997163129064334 + ], + [ + "2025-11-23T03:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997158550446545 + ], + [ + "2025-11-23T03:53:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004738751667628 + ], + [ + "2025-11-23T03:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999165030341517 + ], + [ + "2025-11-23T03:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997510187932329 + ], + [ + "2025-11-23T03:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997186777677298 + ], + [ + "2025-11-23T03:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013543188259049 + ], + [ + "2025-11-23T03:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999280229619392 + ], + [ + "2025-11-23T03:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999985329173636 + ], + [ + "2025-11-23T03:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998083015085288 + ], + [ + "2025-11-23T03:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005481381672034 + ], + [ + "2025-11-23T03:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000026364802178 + ], + [ + "2025-11-23T03:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999240754933982 + ], + [ + "2025-11-23T03:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999843956815076 + ], + [ + "2025-11-23T03:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998909336776789 + ], + [ + "2025-11-23T03:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001137422708186 + ], + [ + "2025-11-23T03:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999524324812231 + ], + [ + "2025-11-23T03:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999626145888063 + ], + [ + "2025-11-23T03:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999868661138728 + ], + [ + "2025-11-23T03:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002135174915778 + ], + [ + "2025-11-23T03:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000184822079757 + ], + [ + "2025-11-23T02:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999367507727775 + ], + [ + "2025-11-23T02:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999626677963632 + ], + [ + "2025-11-23T02:55:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00096218333622 + ], + [ + "2025-11-23T02:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999176414706007 + ], + [ + "2025-11-23T02:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001032540161825 + ], + [ + "2025-11-23T02:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000281775073612 + ], + [ + "2025-11-23T02:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000002970171575 + ], + [ + "2025-11-23T02:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999698709410065 + ], + [ + "2025-11-23T02:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999902911371601 + ], + [ + "2025-11-23T02:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004462518176334 + ], + [ + "2025-11-23T02:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004086709104183 + ], + [ + "2025-11-23T02:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998977482582865 + ], + [ + "2025-11-23T02:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997046687444472 + ], + [ + "2025-11-23T02:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010619506200122 + ], + [ + "2025-11-23T02:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007172982817854 + ], + [ + "2025-11-23T02:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998371982975716 + ], + [ + "2025-11-23T02:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997802971444764 + ], + [ + "2025-11-23T02:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007725633708136 + ], + [ + "2025-11-23T02:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999252396871241 + ], + [ + "2025-11-23T02:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999669945767725 + ], + [ + "2025-11-23T02:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999016369774415 + ], + [ + "2025-11-23T02:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999956189137873 + ], + [ + "2025-11-23T02:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999839781353164 + ], + [ + "2025-11-23T02:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000157888433257 + ], + [ + "2025-11-23T02:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999237586222735 + ], + [ + "2025-11-23T02:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998233015245879 + ], + [ + "2025-11-23T02:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011754106776973 + ], + [ + "2025-11-23T02:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999973432655327 + ], + [ + "2025-11-23T02:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999546137728583 + ], + [ + "2025-11-23T02:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999912141082423 + ], + [ + "2025-11-23T01:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999057354951937 + ], + [ + "2025-11-23T01:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999364644976326 + ], + [ + "2025-11-23T01:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999095418720347 + ], + [ + "2025-11-23T01:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998968791448 + ], + [ + "2025-11-23T01:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999828985696834 + ], + [ + "2025-11-23T01:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999484002464682 + ], + [ + "2025-11-23T01:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999792030255412 + ], + [ + "2025-11-23T01:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000188927913378 + ], + [ + "2025-11-23T01:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006277407406872 + ], + [ + "2025-11-23T01:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999992379517336 + ], + [ + "2025-11-23T01:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000111939322415 + ], + [ + "2025-11-23T01:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004808081986318 + ], + [ + "2025-11-23T01:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999884646152823 + ], + [ + "2025-11-23T01:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001167504889932 + ], + [ + "2025-11-23T01:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007618306048467 + ], + [ + "2025-11-23T01:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009030994945904 + ], + [ + "2025-11-23T01:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999608234998215 + ], + [ + "2025-11-23T01:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000480129499604 + ], + [ + "2025-11-23T01:24:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000229642912151 + ], + [ + "2025-11-23T01:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001654624154959 + ], + [ + "2025-11-23T01:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999910590551507 + ], + [ + "2025-11-23T01:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0011001359882121 + ], + [ + "2025-11-23T01:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000855367882274 + ], + [ + "2025-11-23T01:14:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008913514903501 + ], + [ + "2025-11-23T01:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999576279140083 + ], + [ + "2025-11-23T01:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999798321657294 + ], + [ + "2025-11-23T01:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999787078392784 + ], + [ + "2025-11-23T01:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999665044347229 + ], + [ + "2025-11-23T01:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999940589485234 + ], + [ + "2025-11-23T01:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000027923136527 + ], + [ + "2025-11-23T00:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999782232320642 + ], + [ + "2025-11-23T00:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999878707310052 + ], + [ + "2025-11-23T00:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003999797427956 + ], + [ + "2025-11-23T00:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004323166826932 + ], + [ + "2025-11-23T00:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998747958778946 + ], + [ + "2025-11-23T00:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000018200261527 + ], + [ + "2025-11-23T00:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009148226632294 + ], + [ + "2025-11-23T00:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997772220476396 + ], + [ + "2025-11-23T00:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999118342225124 + ], + [ + "2025-11-23T00:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000442691269291 + ], + [ + "2025-11-23T00:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014961232248276 + ], + [ + "2025-11-23T00:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998083057437482 + ], + [ + "2025-11-23T00:19:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000060997255951 + ], + [ + "2025-11-23T00:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005302265739253 + ], + [ + "2025-11-23T00:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001627776250153 + ], + [ + "2025-11-23T00:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996572264690686 + ], + [ + "2025-11-23T00:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0015345593875344 + ], + [ + "2025-11-23T00:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997341397685136 + ], + [ + "2025-11-23T00:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006234895136297 + ], + [ + "2025-11-23T00:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998433540149779 + ], + [ + "2025-11-23T00:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006203428222162 + ], + [ + "2025-11-23T00:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998108086782626 + ], + [ + "2025-11-22T23:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0014961501515858 + ], + [ + "2025-11-22T23:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997794567131123 + ], + [ + "2025-11-22T23:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999914953015882 + ], + [ + "2025-11-22T23:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0009069731990043 + ], + [ + "2025-11-22T23:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000819386031687 + ], + [ + "2025-11-22T23:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998963099196656 + ], + [ + "2025-11-22T23:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000039067301281 + ], + [ + "2025-11-22T23:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0013074795887722 + ], + [ + "2025-11-22T23:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999291982670581 + ], + [ + "2025-11-22T23:41:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998251599289828 + ], + [ + "2025-11-22T23:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000185428576453 + ], + [ + "2025-11-22T23:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997318831799958 + ], + [ + "2025-11-22T23:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999223405185248 + ], + [ + "2025-11-22T23:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999205702198701 + ], + [ + "2025-11-22T23:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010506522298162 + ], + [ + "2025-11-22T23:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997879124910942 + ], + [ + "2025-11-22T23:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999867569314566 + ], + [ + "2025-11-22T23:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002396046335547 + ], + [ + "2025-11-22T23:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997983696025294 + ], + [ + "2025-11-22T23:21:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998506415744008 + ], + [ + "2025-11-22T23:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998901797927424 + ], + [ + "2025-11-22T23:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004285698975577 + ], + [ + "2025-11-22T23:16:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997438022108484 + ], + [ + "2025-11-22T23:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999568897629705 + ], + [ + "2025-11-22T23:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998347518362584 + ], + [ + "2025-11-22T23:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016575970944275 + ], + [ + "2025-11-22T23:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999719961690946 + ], + [ + "2025-11-22T23:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000468292877125 + ], + [ + "2025-11-22T23:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999484146959995 + ], + [ + "2025-11-22T23:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997249792981966 + ], + [ + "2025-11-22T22:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001442921725443 + ], + [ + "2025-11-22T22:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010694522273291 + ], + [ + "2025-11-22T22:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998024804566819 + ], + [ + "2025-11-22T22:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007478646884695 + ], + [ + "2025-11-22T22:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001701822616287 + ], + [ + "2025-11-22T22:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999222603988757 + ], + [ + "2025-11-22T22:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998024333731773 + ], + [ + "2025-11-22T22:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997973487131924 + ], + [ + "2025-11-22T22:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999434341919391 + ], + [ + "2025-11-22T22:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998514184629899 + ], + [ + "2025-11-22T22:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998092265352428 + ], + [ + "2025-11-22T22:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999084005253489 + ], + [ + "2025-11-22T22:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999798412460225 + ], + [ + "2025-11-22T22:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999935461060746 + ], + [ + "2025-11-22T22:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000173646308971 + ], + [ + "2025-11-22T22:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999961255866873 + ], + [ + "2025-11-22T22:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002031165336511 + ], + [ + "2025-11-22T22:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001476650805576 + ], + [ + "2025-11-22T22:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000046402456227 + ], + [ + "2025-11-22T22:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000750170001125 + ], + [ + "2025-11-22T22:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008850428314229 + ], + [ + "2025-11-22T22:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000476632814621 + ], + [ + "2025-11-22T22:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998013606688646 + ], + [ + "2025-11-22T22:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003725880069139 + ], + [ + "2025-11-22T22:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999480408801065 + ], + [ + "2025-11-22T22:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001060365780834 + ], + [ + "2025-11-22T22:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999081432459223 + ], + [ + "2025-11-22T22:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005180393118083 + ], + [ + "2025-11-22T22:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000226822031495 + ], + [ + "2025-11-22T22:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999364451662001 + ], + [ + "2025-11-22T21:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000152517452234 + ], + [ + "2025-11-22T21:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004039964983782 + ], + [ + "2025-11-22T21:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004294764443746 + ], + [ + "2025-11-22T21:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000032634841138 + ], + [ + "2025-11-22T21:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001754083564847 + ], + [ + "2025-11-22T21:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005103774455288 + ], + [ + "2025-11-22T21:47:57Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008166594133754 + ], + [ + "2025-11-22T21:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999597202974415 + ], + [ + "2025-11-22T21:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004185985418441 + ], + [ + "2025-11-22T21:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007582248984446 + ], + [ + "2025-11-22T21:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999011138435968 + ], + [ + "2025-11-22T21:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999527387891453 + ], + [ + "2025-11-22T21:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998969537391185 + ], + [ + "2025-11-22T21:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999828160820985 + ], + [ + "2025-11-22T21:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999573464564904 + ], + [ + "2025-11-22T21:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999889294645241 + ], + [ + "2025-11-22T21:27:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000007520187591 + ], + [ + "2025-11-22T21:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999778360422654 + ], + [ + "2025-11-22T21:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999893527308201 + ], + [ + "2025-11-22T21:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999869936769308 + ], + [ + "2025-11-22T21:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000236505583402 + ], + [ + "2025-11-22T21:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999868173762444 + ], + [ + "2025-11-22T21:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999430065564889 + ], + [ + "2025-11-22T21:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000523570822029 + ], + [ + "2025-11-22T21:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999023695255731 + ], + [ + "2025-11-22T21:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997518475853279 + ], + [ + "2025-11-22T21:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998191117322668 + ], + [ + "2025-11-22T21:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999980510702733 + ], + [ + "2025-11-22T21:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998393851651205 + ], + [ + "2025-11-22T21:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999660344839861 + ], + [ + "2025-11-22T20:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998892071703509 + ], + [ + "2025-11-22T20:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999610948403977 + ], + [ + "2025-11-22T20:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998724929334873 + ], + [ + "2025-11-22T20:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998692802912333 + ], + [ + "2025-11-22T20:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998829282732209 + ], + [ + "2025-11-22T20:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999262420605878 + ], + [ + "2025-11-22T20:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998657588646309 + ], + [ + "2025-11-22T20:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997986800518365 + ], + [ + "2025-11-22T20:44:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999871490044171 + ], + [ + "2025-11-22T20:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998876447856797 + ], + [ + "2025-11-22T20:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999612417749124 + ], + [ + "2025-11-22T20:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999849701002633 + ], + [ + "2025-11-22T20:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999197921929102 + ], + [ + "2025-11-22T20:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999449722687413 + ], + [ + "2025-11-22T20:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999841368472566 + ], + [ + "2025-11-22T20:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999817809620092 + ], + [ + "2025-11-22T20:28:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999943037851245 + ], + [ + "2025-11-22T20:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999555957634871 + ], + [ + "2025-11-22T20:23:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999981860745828 + ], + [ + "2025-11-22T20:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999339521950409 + ], + [ + "2025-11-22T20:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999800791116259 + ], + [ + "2025-11-22T20:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000025409906206 + ], + [ + "2025-11-22T20:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996886379397556 + ], + [ + "2025-11-22T20:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999935942523682 + ], + [ + "2025-11-22T20:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000160900555306 + ], + [ + "2025-11-22T20:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000072520795213 + ], + [ + "2025-11-22T20:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998493465396533 + ], + [ + "2025-11-22T20:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999958581217895 + ], + [ + "2025-11-22T20:04:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000301358837058 + ], + [ + "2025-11-22T20:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000352467871818 + ], + [ + "2025-11-22T19:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998560035686724 + ], + [ + "2025-11-22T19:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999921988205134 + ], + [ + "2025-11-22T19:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999753442741282 + ], + [ + "2025-11-22T19:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000784127947462 + ], + [ + "2025-11-22T19:51:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999921887712328 + ], + [ + "2025-11-22T19:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999178487632909 + ], + [ + "2025-11-22T19:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999963591243852 + ], + [ + "2025-11-22T19:45:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000272524886253 + ], + [ + "2025-11-22T19:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999186209175097 + ], + [ + "2025-11-22T19:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999774927906356 + ], + [ + "2025-11-22T19:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998744189947711 + ], + [ + "2025-11-22T19:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000093705288842 + ], + [ + "2025-11-22T19:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998543682995312 + ], + [ + "2025-11-22T19:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999590706934978 + ], + [ + "2025-11-22T19:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997628095997602 + ], + [ + "2025-11-22T19:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999734621115853 + ], + [ + "2025-11-22T19:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997812072153569 + ], + [ + "2025-11-22T19:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999415857328791 + ], + [ + "2025-11-22T19:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998156856659642 + ], + [ + "2025-11-22T19:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998310906039689 + ], + [ + "2025-11-22T19:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998560478742291 + ], + [ + "2025-11-22T19:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998020670404352 + ], + [ + "2025-11-22T19:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999872479034228 + ], + [ + "2025-11-22T19:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998478092699414 + ], + [ + "2025-11-22T19:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999261088206056 + ], + [ + "2025-11-22T19:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998026522256354 + ], + [ + "2025-11-22T19:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998359173025547 + ], + [ + "2025-11-22T19:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999865453552148 + ], + [ + "2025-11-22T18:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997921410182907 + ], + [ + "2025-11-22T18:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000055481909014 + ], + [ + "2025-11-22T18:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997676813489489 + ], + [ + "2025-11-22T18:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998752778790869 + ], + [ + "2025-11-22T18:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998687763414177 + ], + [ + "2025-11-22T18:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998673864799709 + ], + [ + "2025-11-22T18:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997752241936135 + ], + [ + "2025-11-22T18:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998650809692273 + ], + [ + "2025-11-22T18:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998385159608129 + ], + [ + "2025-11-22T18:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998462644922212 + ], + [ + "2025-11-22T18:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998438332852453 + ], + [ + "2025-11-22T18:22:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998915346006709 + ], + [ + "2025-11-22T18:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998068502274334 + ], + [ + "2025-11-22T18:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997937137085301 + ], + [ + "2025-11-22T18:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997949093209048 + ], + [ + "2025-11-22T18:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998880425556416 + ], + [ + "2025-11-22T18:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998599697520503 + ], + [ + "2025-11-22T18:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998331194539659 + ], + [ + "2025-11-22T18:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999895589562365 + ], + [ + "2025-11-22T18:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998367649245621 + ], + [ + "2025-11-22T18:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997983671202305 + ], + [ + "2025-11-22T18:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998525026541402 + ], + [ + "2025-11-22T17:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998792918411684 + ], + [ + "2025-11-22T17:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998219925502707 + ], + [ + "2025-11-22T17:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998352135665691 + ], + [ + "2025-11-22T17:53:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997273245429493 + ], + [ + "2025-11-22T17:52:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997265325204258 + ], + [ + "2025-11-22T17:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998253835703039 + ], + [ + "2025-11-22T17:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998462930670555 + ], + [ + "2025-11-22T17:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999768391107075 + ], + [ + "2025-11-22T17:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999753542908528 + ], + [ + "2025-11-22T17:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998853831209651 + ], + [ + "2025-11-22T17:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997003976729588 + ], + [ + "2025-11-22T17:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998380836797203 + ], + [ + "2025-11-22T17:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998611587399369 + ], + [ + "2025-11-22T17:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0005694997271868 + ], + [ + "2025-11-22T17:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997489853320657 + ], + [ + "2025-11-22T17:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997915659213827 + ], + [ + "2025-11-22T17:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998556036164602 + ], + [ + "2025-11-22T17:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998276874124074 + ], + [ + "2025-11-22T17:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998317077420759 + ], + [ + "2025-11-22T17:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998042652717524 + ], + [ + "2025-11-22T17:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998642765769561 + ], + [ + "2025-11-22T17:17:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998247179648099 + ], + [ + "2025-11-22T17:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997993543791612 + ], + [ + "2025-11-22T17:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997976651931934 + ], + [ + "2025-11-22T17:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998188220109894 + ], + [ + "2025-11-22T17:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999811900838663 + ], + [ + "2025-11-22T17:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997463461700192 + ], + [ + "2025-11-22T17:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997863922127369 + ], + [ + "2025-11-22T17:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997038274007445 + ], + [ + "2025-11-22T17:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998116345127379 + ], + [ + "2025-11-22T16:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997872303698464 + ], + [ + "2025-11-22T16:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997045408783566 + ], + [ + "2025-11-22T16:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997791627993982 + ], + [ + "2025-11-22T16:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998106267951052 + ], + [ + "2025-11-22T16:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997287197511137 + ], + [ + "2025-11-22T16:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997658342793176 + ], + [ + "2025-11-22T16:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997261780030828 + ], + [ + "2025-11-22T16:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998359317449639 + ], + [ + "2025-11-22T16:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998015526642442 + ], + [ + "2025-11-22T16:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998113766927985 + ], + [ + "2025-11-22T16:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998335089150179 + ], + [ + "2025-11-22T16:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998074959717367 + ], + [ + "2025-11-22T16:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997503188513677 + ], + [ + "2025-11-22T16:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999825816001285 + ], + [ + "2025-11-22T16:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997643202405186 + ], + [ + "2025-11-22T16:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998152442454907 + ], + [ + "2025-11-22T16:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998059616742495 + ], + [ + "2025-11-22T16:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998337516455089 + ], + [ + "2025-11-22T16:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998021698618412 + ], + [ + "2025-11-22T16:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000032451994811 + ], + [ + "2025-11-22T16:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997561526985248 + ], + [ + "2025-11-22T16:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999038296803183 + ], + [ + "2025-11-22T16:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999896879214113 + ], + [ + "2025-11-22T16:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0008271022815756 + ], + [ + "2025-11-22T16:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998334047300338 + ], + [ + "2025-11-22T16:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998309295055889 + ], + [ + "2025-11-22T16:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999905141044421 + ], + [ + "2025-11-22T16:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997899260478582 + ], + [ + "2025-11-22T16:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998504691866007 + ], + [ + "2025-11-22T16:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999769342550034 + ], + [ + "2025-11-22T15:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998402559102472 + ], + [ + "2025-11-22T15:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001832853600243 + ], + [ + "2025-11-22T15:56:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997795583821556 + ], + [ + "2025-11-22T15:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998126531467371 + ], + [ + "2025-11-22T15:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998330533181574 + ], + [ + "2025-11-22T15:49:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004602805067104 + ], + [ + "2025-11-22T15:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996875904972785 + ], + [ + "2025-11-22T15:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997262014954812 + ], + [ + "2025-11-22T15:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000550659484973 + ], + [ + "2025-11-22T15:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006697934060633 + ], + [ + "2025-11-22T15:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997835902221862 + ], + [ + "2025-11-22T15:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998081537996035 + ], + [ + "2025-11-22T15:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997494259423166 + ], + [ + "2025-11-22T15:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998039022053683 + ], + [ + "2025-11-22T15:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999756495288041 + ], + [ + "2025-11-22T15:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997292121473187 + ], + [ + "2025-11-22T15:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997361573253576 + ], + [ + "2025-11-22T15:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998082418617803 + ], + [ + "2025-11-22T15:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998095354902072 + ], + [ + "2025-11-22T15:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996994023197516 + ], + [ + "2025-11-22T15:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997314612119778 + ], + [ + "2025-11-22T15:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997495923225436 + ], + [ + "2025-11-22T15:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997548621990258 + ], + [ + "2025-11-22T15:13:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997548459707932 + ], + [ + "2025-11-22T15:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996403379324642 + ], + [ + "2025-11-22T15:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997520980278868 + ], + [ + "2025-11-22T15:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997710313681428 + ], + [ + "2025-11-22T15:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998330525045814 + ], + [ + "2025-11-22T15:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997243428939567 + ], + [ + "2025-11-22T15:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998634212035847 + ], + [ + "2025-11-22T14:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998394480123242 + ], + [ + "2025-11-22T14:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998568164903222 + ], + [ + "2025-11-22T14:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998464795763409 + ], + [ + "2025-11-22T14:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000537436477481 + ], + [ + "2025-11-22T14:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999931767937235 + ], + [ + "2025-11-22T14:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998706158679601 + ], + [ + "2025-11-22T14:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999768162286512 + ], + [ + "2025-11-22T14:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0003191014446335 + ], + [ + "2025-11-22T14:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999422370811337 + ], + [ + "2025-11-22T14:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999535439692198 + ], + [ + "2025-11-22T14:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999725454754167 + ], + [ + "2025-11-22T14:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0010332157084607 + ], + [ + "2025-11-22T14:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999350184996965 + ], + [ + "2025-11-22T14:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999642653200431 + ], + [ + "2025-11-22T14:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999292295440066 + ], + [ + "2025-11-22T14:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000302114037407 + ], + [ + "2025-11-22T14:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999919705605782 + ], + [ + "2025-11-22T14:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000004658922618 + ], + [ + "2025-11-22T14:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999360448528654 + ], + [ + "2025-11-22T14:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001042355523428 + ], + [ + "2025-11-22T14:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999780659340938 + ], + [ + "2025-11-22T14:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000278321130023 + ], + [ + "2025-11-22T14:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999032424941461 + ], + [ + "2025-11-22T14:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001978191453802 + ], + [ + "2025-11-22T14:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999802000611882 + ], + [ + "2025-11-22T14:10:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999808116185785 + ], + [ + "2025-11-22T14:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999690529740067 + ], + [ + "2025-11-22T14:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00105664910904 + ], + [ + "2025-11-22T14:04:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999418544073392 + ], + [ + "2025-11-22T14:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000319425282973 + ], + [ + "2025-11-22T13:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999566038909473 + ], + [ + "2025-11-22T13:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999633058173961 + ], + [ + "2025-11-22T13:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999396498094111 + ], + [ + "2025-11-22T13:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000078066079405 + ], + [ + "2025-11-22T13:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000212642448756 + ], + [ + "2025-11-22T13:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999868824092855 + ], + [ + "2025-11-22T13:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000005279476809 + ], + [ + "2025-11-22T13:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999953898462951 + ], + [ + "2025-11-22T13:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000024269915141 + ], + [ + "2025-11-22T13:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999459486426179 + ], + [ + "2025-11-22T13:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999380649797757 + ], + [ + "2025-11-22T13:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999817143477493 + ], + [ + "2025-11-22T13:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000017530469004 + ], + [ + "2025-11-22T13:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999785820481083 + ], + [ + "2025-11-22T13:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000145452032048 + ], + [ + "2025-11-22T13:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000255515692054 + ], + [ + "2025-11-22T13:28:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999926813282737 + ], + [ + "2025-11-22T13:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999978047096216 + ], + [ + "2025-11-22T13:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999571993032558 + ], + [ + "2025-11-22T13:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002918270491097 + ], + [ + "2025-11-22T13:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999933963450515 + ], + [ + "2025-11-22T13:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999281550152779 + ], + [ + "2025-11-22T13:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999632149428941 + ], + [ + "2025-11-22T13:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016113817371186 + ], + [ + "2025-11-22T13:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000679330649767 + ], + [ + "2025-11-22T13:09:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999050537875201 + ], + [ + "2025-11-22T13:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997527105258794 + ], + [ + "2025-11-22T13:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996949928314859 + ], + [ + "2025-11-22T13:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998422061412298 + ], + [ + "2025-11-22T13:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997180773712704 + ], + [ + "2025-11-22T12:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997929450650964 + ], + [ + "2025-11-22T12:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998643513083755 + ], + [ + "2025-11-22T12:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997266047280615 + ], + [ + "2025-11-22T12:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000005700486116 + ], + [ + "2025-11-22T12:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999924371247013 + ], + [ + "2025-11-22T12:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998210842343856 + ], + [ + "2025-11-22T12:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0007583902976782 + ], + [ + "2025-11-22T12:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999650240551233 + ], + [ + "2025-11-22T12:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999589389744945 + ], + [ + "2025-11-22T12:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000721842037343 + ], + [ + "2025-11-22T12:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998116199942813 + ], + [ + "2025-11-22T12:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998066461442215 + ], + [ + "2025-11-22T12:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998441351761722 + ], + [ + "2025-11-22T12:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996313342375991 + ], + [ + "2025-11-22T12:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998968861101516 + ], + [ + "2025-11-22T12:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999809818743353 + ], + [ + "2025-11-22T12:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998043504313151 + ], + [ + "2025-11-22T12:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996234405924262 + ], + [ + "2025-11-22T12:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998650572164521 + ], + [ + "2025-11-22T12:21:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998619125734516 + ], + [ + "2025-11-22T12:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998375770904521 + ], + [ + "2025-11-22T12:18:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996834583459275 + ], + [ + "2025-11-22T12:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998827852272673 + ], + [ + "2025-11-22T12:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999956665266741 + ], + [ + "2025-11-22T12:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000064312115147 + ], + [ + "2025-11-22T12:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998384494862979 + ], + [ + "2025-11-22T12:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998707096026573 + ], + [ + "2025-11-22T12:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999941227455828 + ], + [ + "2025-11-22T12:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000181279287834 + ], + [ + "2025-11-22T12:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998501394778735 + ], + [ + "2025-11-22T11:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999305850977248 + ], + [ + "2025-11-22T11:57:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999828067086285 + ], + [ + "2025-11-22T11:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002134750517002 + ], + [ + "2025-11-22T11:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999184202050897 + ], + [ + "2025-11-22T11:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999602053116604 + ], + [ + "2025-11-22T11:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999402178655429 + ], + [ + "2025-11-22T11:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002711983575892 + ], + [ + "2025-11-22T11:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999539324136274 + ], + [ + "2025-11-22T11:43:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999387123270301 + ], + [ + "2025-11-22T11:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998042551411237 + ], + [ + "2025-11-22T11:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006742049564978 + ], + [ + "2025-11-22T11:37:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999465492808643 + ], + [ + "2025-11-22T11:35:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999786573743963 + ], + [ + "2025-11-22T11:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997529478979107 + ], + [ + "2025-11-22T11:31:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996688442649257 + ], + [ + "2025-11-22T11:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999579793500869 + ], + [ + "2025-11-22T11:28:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000010982077464 + ], + [ + "2025-11-22T11:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997808434641452 + ], + [ + "2025-11-22T11:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997052314828981 + ], + [ + "2025-11-22T11:22:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999394423983278 + ], + [ + "2025-11-22T11:19:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000023207192186 + ], + [ + "2025-11-22T11:17:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998433247423024 + ], + [ + "2025-11-22T11:15:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996774779636146 + ], + [ + "2025-11-22T11:13:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000103258341069 + ], + [ + "2025-11-22T11:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001862856747967 + ], + [ + "2025-11-22T11:09:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999515494941421 + ], + [ + "2025-11-22T11:07:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996948036005658 + ], + [ + "2025-11-22T11:05:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.00007615463092 + ], + [ + "2025-11-22T11:03:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0002059528351688 + ], + [ + "2025-11-22T11:01:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999848255152454 + ], + [ + "2025-11-22T10:59:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997241756526897 + ], + [ + "2025-11-22T10:57:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000299287302967 + ], + [ + "2025-11-22T10:55:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0004619165704989 + ], + [ + "2025-11-22T10:53:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.999932115795213 + ], + [ + "2025-11-22T10:51:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998054804560648 + ], + [ + "2025-11-22T10:49:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0016882171776922 + ], + [ + "2025-11-22T10:47:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998897733863816 + ], + [ + "2025-11-22T10:45:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.000021243381761 + ], + [ + "2025-11-22T10:43:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998937011711151 + ], + [ + "2025-11-22T10:41:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001752242365653 + ], + [ + "2025-11-22T10:39:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999946270550053 + ], + [ + "2025-11-22T10:37:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0001095125172499 + ], + [ + "2025-11-22T10:35:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998459865513988 + ], + [ + "2025-11-22T10:33:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0018388555252709 + ], + [ + "2025-11-22T10:31:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0006475666765764 + ], + [ + "2025-11-22T10:29:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999585743240587 + ], + [ + "2025-11-22T10:27:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997972412319274 + ], + [ + "2025-11-22T10:25:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.001863731771352 + ], + [ + "2025-11-22T10:23:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0019007000000013 + ], + [ + "2025-11-22T08:29:48Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998397255000017 + ], + [ + "2025-11-22T07:41:12Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9994295961425436 + ], + [ + "2025-11-22T06:51:29Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997296150000017 + ], + [ + "2025-11-22T06:11:26Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997296150000042 + ], + [ + "2025-11-22T05:30:28Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997296150000021 + ], + [ + "2025-11-22T04:47:29Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997296150000013 + ], + [ + "2025-11-22T04:12:00Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998745972259941 + ], + [ + "2025-11-22T04:11:59Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9996890029367008 + ], + [ + "2025-11-22T03:21:26Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9999897449999995 + ], + [ + "2025-11-22T02:32:21Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 1.0000292639485056 + ], + [ + "2025-11-22T01:39:58Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9997696350000039 + ], + [ + "2025-11-22T00:58:16Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998046720670152 + ], + [ + "2025-11-22T00:19:05Z", + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "Ethereum", + null, + "MAIR120", + "USDC", + 0.9998295379999973 + ] + ] + } + ], + "Messages": null + } + ] +} diff --git a/apps/explorer/test/test_helper.exs b/apps/explorer/test/test_helper.exs index 53de43f744bb..50bfec9620bd 100644 --- a/apps/explorer/test/test_helper.exs +++ b/apps/explorer/test/test_helper.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # https://github.com/CircleCI-Public/circleci-demo-elixir-phoenix/blob/a89de33a01df67b6773ac90adc74c34367a4a2d6/test/test_helper.exs#L1-L3 junit_folder = Mix.Project.build_path() <> "/junit/#{Mix.Project.config()[:app]}" File.mkdir_p!(junit_folder) @@ -11,10 +12,11 @@ ExUnit.start() {:ok, _} = Application.ensure_all_started(:ex_machina) +Explorer.TestHelper.run_necessary_background_migrations() + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Account, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.PolygonEdge, :auto) -Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.PolygonZkevm, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.RSK, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Shibarium, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Suave, :auto) @@ -24,11 +26,11 @@ Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Filecoin, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Stability, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.Mud, :auto) Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.ShrunkInternalTransactions, :auto) +Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.EventNotifications, :auto) Mox.defmock(Explorer.Market.Source.TestSource, for: Explorer.Market.Source) Mox.defmock(Explorer.History.TestHistorian, for: Explorer.History.Historian) Mox.defmock(EthereumJSONRPC.Mox, for: EthereumJSONRPC.Transport) -Mox.defmock(Explorer.Mox.HTTPoison, for: HTTPoison.Base) Mox.defmock(Explorer.Mock.TeslaAdapter, for: Tesla.Adapter) diff --git a/apps/indexer/README.md b/apps/indexer/README.md index 262019b28d22..8842996a0ee8 100644 --- a/apps/indexer/README.md +++ b/apps/indexer/README.md @@ -21,7 +21,6 @@ Some data has to be extracted from already fetched data, and there're several tr - `address_coin_balances`: detects coin balance-changing entities (transactions, minted blocks, etc) to create coin balance entities for further fetching - `token_transfers`: parses logs to extract token transfers - `mint_transfers`: parses logs to extract token mint transfers -- `transaction_actions`: parses logs to extract transaction actions - `address_token_balances`: creates token balance entities for further fetching, based on detected token transfers - `blocks`: extracts block signer hash from additional data for Clique chains - `optimism_withdrawals`: parses logs to extract L2 withdrawal messages @@ -45,7 +44,6 @@ Both block fetchers retrieve/extract the blocks themselves and the following add - `transactions` - `logs` - `token_transfers` -- `transaction_actions` - `addresses` - `withdrawals` diff --git a/apps/indexer/config/config.exs b/apps/indexer/config/config.exs index dc97d7881075..8ce64418d856 100644 --- a/apps/indexer/config/config.exs +++ b/apps/indexer/config/config.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file is responsible for configuring your application # and its dependencies with the aid of the Config module. import Config @@ -15,11 +16,7 @@ config :indexer, Indexer.Block.Catchup.MissingRangesCollector, future_check_inte config :indexer, Indexer.Migrator.RecoveryWETHTokenTransfers, enabled: true config :logger, :indexer, - # keep synced with `config/config.exs` - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, + metadata: ConfigHelper.logger_metadata(), metadata_filter: [application: :indexer] config :os_mon, diff --git a/apps/indexer/config/dev.exs b/apps/indexer/config/dev.exs index 28f928baa1ae..e7b2bf272181 100644 --- a/apps/indexer/config/dev.exs +++ b/apps/indexer/config/dev.exs @@ -1,42 +1,34 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, Indexer.Tracer, env: "dev", disabled?: true -config :logger, :indexer, - level: :debug, - path: Path.absname("logs/dev/indexer.log") +config :logger, :indexer, path: Path.absname("logs/dev/indexer.log") config :logger, :indexer_token_balances, - level: :debug, path: Path.absname("logs/dev/indexer/token_balances/error.log"), metadata_filter: [fetcher: :token_balances] config :logger, :failed_contract_creations, - level: :debug, path: Path.absname("logs/dev/indexer/failed_contract_creations.log"), metadata_filter: [fetcher: :failed_created_addresses] config :logger, :addresses_without_code, - level: :debug, path: Path.absname("logs/dev/indexer/addresses_without_code.log"), metadata_filter: [fetcher: :addresses_without_code] config :logger, :pending_transactions_to_refetch, - level: :debug, path: Path.absname("logs/dev/indexer/pending_transactions_to_refetch.log"), metadata_filter: [fetcher: :pending_transactions_to_refetch] config :logger, :empty_blocks_to_refetch, - level: :debug, path: Path.absname("logs/dev/indexer/empty_blocks_to_refetch.log"), metadata_filter: [fetcher: :empty_blocks_to_refetch] config :logger, :block_import_timings, - level: :debug, path: Path.absname("logs/dev/indexer/block_import_timings.log"), metadata_filter: [fetcher: :block_import_timings] config :logger, :withdrawal, - level: :debug, path: Path.absname("logs/dev/indexer/withdrawal.log"), metadata_filter: [fetcher: :withdrawal] diff --git a/apps/indexer/config/dev/anvil.exs b/apps/indexer/config/dev/anvil.exs index 28e8a0d5f9d0..84108c0aa73f 100644 --- a/apps/indexer/config/dev/anvil.exs +++ b/apps/indexer/config/dev/anvil.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), fallback_urls: ConfigHelper.parse_urls_list(:fallback_http), @@ -24,17 +22,17 @@ config :indexer, method_to_url: [ eth_call: :eth_call ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Anvil ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/dev/besu.exs b/apps/indexer/config/dev/besu.exs index d85be5890ae1..0bc3bbd80e25 100644 --- a/apps/indexer/config/dev/besu.exs +++ b/apps/indexer/config/dev/besu.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -17,7 +15,7 @@ config :indexer, ), else: EthereumJSONRPC.IPC, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -31,17 +29,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Besu ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/dev/erigon.exs b/apps/indexer/config/dev/erigon.exs index ae702ba3497b..3c2f8ca49d5d 100644 --- a/apps/indexer/config/dev/erigon.exs +++ b/apps/indexer/config/dev/erigon.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -17,7 +15,7 @@ config :indexer, ), else: EthereumJSONRPC.IPC, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -31,17 +29,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Erigon ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/dev/filecoin.exs b/apps/indexer/config/dev/filecoin.exs index 4b95daaadb2e..a3cbdd7f2ed7 100644 --- a/apps/indexer/config/dev/filecoin.exs +++ b/apps/indexer/config/dev/filecoin.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http, "http://localhost:1234/rpc/v1"), trace_urls: ConfigHelper.parse_urls_list(:trace, "http://localhost:1234/rpc/v1"), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call, "http://localhost:1234/rpc/v1"), @@ -27,17 +25,17 @@ config :indexer, eth_call: :eth_call, trace_block: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Filecoin ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/dev/geth.exs b/apps/indexer/config/dev/geth.exs index fdeb51f4cb15..b465878a4af7 100644 --- a/apps/indexer/config/dev/geth.exs +++ b/apps/indexer/config/dev/geth.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -28,17 +26,17 @@ config :indexer, debug_traceTransaction: :trace, debug_traceBlockByNumber: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Geth ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/dev/nethermind.exs b/apps/indexer/config/dev/nethermind.exs index abdf7fff639e..d64560d7279f 100644 --- a/apps/indexer/config/dev/nethermind.exs +++ b/apps/indexer/config/dev/nethermind.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -17,7 +15,7 @@ config :indexer, ), else: EthereumJSONRPC.IPC, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -31,17 +29,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Nethermind ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/dev/rsk.exs b/apps/indexer/config/dev/rsk.exs index f0c75824564d..1d30f393d1c4 100644 --- a/apps/indexer/config/dev/rsk.exs +++ b/apps/indexer/config/dev/rsk.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), blocks_concurrency: 1, @@ -18,7 +16,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -32,17 +30,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.RSK ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod.exs b/apps/indexer/config/prod.exs index 8e81a078f4a5..a4a766bb00df 100644 --- a/apps/indexer/config/prod.exs +++ b/apps/indexer/config/prod.exs @@ -1,50 +1,43 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, Indexer.Tracer, env: "production", disabled?: true config :logger, :indexer, - level: :info, path: Path.absname("logs/prod/indexer.log"), rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :indexer_token_balances, - level: :debug, path: Path.absname("logs/prod/indexer/token_balances/error.log"), metadata_filter: [fetcher: :token_balances], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :failed_contract_creations, - level: :debug, path: Path.absname("logs/prod/indexer/failed_contract_creations.log"), metadata_filter: [fetcher: :failed_created_addresses], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :addresses_without_code, - level: :debug, path: Path.absname("logs/prod/indexer/addresses_without_code.log"), metadata_filter: [fetcher: :addresses_without_code], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :pending_transactions_to_refetch, - level: :debug, path: Path.absname("logs/prod/indexer/pending_transactions_to_refetch.log"), metadata_filter: [fetcher: :pending_transactions_to_refetch], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :empty_blocks_to_refetch, - level: :info, path: Path.absname("logs/prod/indexer/empty_blocks_to_refetch.log"), metadata_filter: [fetcher: :empty_blocks_to_refetch], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :block_import_timings, - level: :debug, path: Path.absname("logs/prod/indexer/block_import_timings.log"), metadata_filter: [fetcher: :block_import_timings], rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :withdrawal, - level: :info, path: Path.absname("logs/prod/indexer/withdrawal.log"), metadata_filter: [fetcher: :withdrawal], rotate: %{max_bytes: 52_428_800, keep: 19} diff --git a/apps/indexer/config/prod/anvil.exs b/apps/indexer/config/prod/anvil.exs index 28e8a0d5f9d0..84108c0aa73f 100644 --- a/apps/indexer/config/prod/anvil.exs +++ b/apps/indexer/config/prod/anvil.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(1) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), fallback_urls: ConfigHelper.parse_urls_list(:fallback_http), @@ -24,17 +22,17 @@ config :indexer, method_to_url: [ eth_call: :eth_call ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(1) ], variant: EthereumJSONRPC.Anvil ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod/besu.exs b/apps/indexer/config/prod/besu.exs index 60341ae55d1d..906816f6fe84 100644 --- a/apps/indexer/config/prod/besu.exs +++ b/apps/indexer/config/prod/besu.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -30,17 +28,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Besu ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod/erigon.exs b/apps/indexer/config/prod/erigon.exs index f49677fc218e..d3da29b20c80 100644 --- a/apps/indexer/config/prod/erigon.exs +++ b/apps/indexer/config/prod/erigon.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -30,17 +28,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Erigon ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod/filecoin.exs b/apps/indexer/config/prod/filecoin.exs index 8bbf0307d930..de1fdcc317a5 100644 --- a/apps/indexer/config/prod/filecoin.exs +++ b/apps/indexer/config/prod/filecoin.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -27,17 +25,17 @@ config :indexer, eth_call: :eth_call, trace_block: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Filecoin ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod/geth.exs b/apps/indexer/config/prod/geth.exs index 7a213fbbfffe..93e9f94e5929 100644 --- a/apps/indexer/config/prod/geth.exs +++ b/apps/indexer/config/prod/geth.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -28,17 +26,17 @@ config :indexer, debug_traceTransaction: :trace, debug_traceBlockByNumber: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.Geth ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod/nethermind.exs b/apps/indexer/config/prod/nethermind.exs index 1f42d5ee155d..0285a055df60 100644 --- a/apps/indexer/config/prod/nethermind.exs +++ b/apps/indexer/config/prod/nethermind.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout() - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), json_rpc_named_arguments: [ @@ -16,7 +14,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -30,17 +28,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options() ], variant: EthereumJSONRPC.Nethermind ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/prod/rsk.exs b/apps/indexer/config/prod/rsk.exs index f0c75824564d..1d30f393d1c4 100644 --- a/apps/indexer/config/prod/rsk.exs +++ b/apps/indexer/config/prod/rsk.exs @@ -1,12 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config ~w(config config_helper.exs) |> Path.join() |> Code.eval_file() -hackney_opts = ConfigHelper.hackney_options() -timeout = ConfigHelper.timeout(10) - config :indexer, block_interval: ConfigHelper.parse_time_env_var("INDEXER_CATCHUP_BLOCK_INTERVAL", "0s"), blocks_concurrency: 1, @@ -18,7 +16,7 @@ config :indexer, else: EthereumJSONRPC.IPC ), transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: ConfigHelper.parse_urls_list(:http), trace_urls: ConfigHelper.parse_urls_list(:trace), eth_call_urls: ConfigHelper.parse_urls_list(:eth_call), @@ -32,17 +30,17 @@ config :indexer, trace_replayBlockTransactions: :trace, trace_replayTransaction: :trace ], - http_options: [recv_timeout: timeout, timeout: timeout, hackney: hackney_opts] + http_options: ConfigHelper.http_options(10) ], variant: EthereumJSONRPC.RSK ], subscribe_named_arguments: [ transport: - System.get_env("ETHEREUM_JSONRPC_WS_URL") && System.get_env("ETHEREUM_JSONRPC_WS_URL") !== "" && + ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL") && EthereumJSONRPC.WebSocket, transport_options: [ web_socket: EthereumJSONRPC.WebSocket.WebSocketClient, - url: System.get_env("ETHEREUM_JSONRPC_WS_URL"), - fallback_url: System.get_env("ETHEREUM_JSONRPC_FALLBACK_WS_URL") + url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_WS_URL"), + fallback_url: ConfigHelper.parse_url_env_var("ETHEREUM_JSONRPC_FALLBACK_WS_URL") ] ] diff --git a/apps/indexer/config/runtime/test.exs b/apps/indexer/config/runtime/test.exs index 0eb6e7a598d1..fdb1d1c59535 100644 --- a/apps/indexer/config/runtime/test.exs +++ b/apps/indexer/config/runtime/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant diff --git a/apps/indexer/config/test.exs b/apps/indexer/config/test.exs index c3f053a1365e..21b84f01b0b2 100644 --- a/apps/indexer/config/test.exs +++ b/apps/indexer/config/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, Indexer.Tracer, disabled?: false @@ -6,21 +7,16 @@ config :indexer, Indexer.Block.Catchup.MissingRangesCollector, future_check_inte config :indexer, Indexer.Migrator.RecoveryWETHTokenTransfers, enabled: false -config :logger, :indexer, - level: :warn, - path: Path.absname("logs/test/indexer.log") +config :logger, :indexer, path: Path.absname("logs/test/indexer.log") config :logger, :indexer_token_balances, - level: :debug, path: Path.absname("logs/test/indexer/token_balances/error.log"), metadata_filter: [fetcher: :token_balances] config :logger, :failed_contract_creations, - level: :debug, path: Path.absname("logs/test/indexer/failed_contract_creations.log"), metadata_filter: [fetcher: :failed_created_addresses] config :logger, :addresses_without_code, - level: :debug, path: Path.absname("logs/test/indexer/addresses_without_code.log"), metadata_filter: [fetcher: :addresses_without_code] diff --git a/apps/indexer/config/test/anvil.exs b/apps/indexer/config/test/anvil.exs index d5870672f919..00ef8931765a 100644 --- a/apps/indexer/config/test/anvil.exs +++ b/apps/indexer/config/test/anvil.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/config/test/besu.exs b/apps/indexer/config/test/besu.exs index 2d388f17026e..871a005dcd8d 100644 --- a/apps/indexer/config/test/besu.exs +++ b/apps/indexer/config/test/besu.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/config/test/erigon.exs b/apps/indexer/config/test/erigon.exs index 5f15cc7a8531..ad9215510a7f 100644 --- a/apps/indexer/config/test/erigon.exs +++ b/apps/indexer/config/test/erigon.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/config/test/filecoin.exs b/apps/indexer/config/test/filecoin.exs index a7509d5e827e..c83199412680 100644 --- a/apps/indexer/config/test/filecoin.exs +++ b/apps/indexer/config/test/filecoin.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/config/test/geth.exs b/apps/indexer/config/test/geth.exs index c70674d063c2..847740a778ca 100644 --- a/apps/indexer/config/test/geth.exs +++ b/apps/indexer/config/test/geth.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/config/test/nethermind.exs b/apps/indexer/config/test/nethermind.exs index 7ef74da67bc9..134fb8ca2217 100644 --- a/apps/indexer/config/test/nethermind.exs +++ b/apps/indexer/config/test/nethermind.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/config/test/rsk.exs b/apps/indexer/config/test/rsk.exs index 998275b2dfe4..de7f3501f7d9 100644 --- a/apps/indexer/config/test/rsk.exs +++ b/apps/indexer/config/test/rsk.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config config :indexer, diff --git a/apps/indexer/lib/indexer.ex b/apps/indexer/lib/indexer.ex index 34335d12aac0..f53484c25475 100644 --- a/apps/indexer/lib/indexer.ex +++ b/apps/indexer/lib/indexer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer do @moduledoc """ Indexes an Ethereum-based chain using JSONRPC. diff --git a/apps/indexer/lib/indexer/application.ex b/apps/indexer/lib/indexer/application.ex index 06ef190f0261..86f593b22e6f 100644 --- a/apps/indexer/lib/indexer/application.ex +++ b/apps/indexer/lib/indexer/application.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Application do @moduledoc """ This is the `Application` module for `Indexer`. @@ -16,9 +17,14 @@ defmodule Indexer.Application do alias Indexer.Fetcher.TokenInstance.Refetch, as: TokenInstanceRefetch alias Indexer.Memory + alias Indexer.Prometheus.Instrumenter @impl Application def start(_type, _args) do + if Explorer.mode() in [:indexer, :all] do + Instrumenter.setup() + end + memory_monitor_name = Memory.Monitor json_rpc_named_arguments = Application.fetch_env!(:indexer, :json_rpc_named_arguments) diff --git a/apps/indexer/lib/indexer/block/catchup/bound_interval_supervisor.ex b/apps/indexer/lib/indexer/block/catchup/bound_interval_supervisor.ex index 45a5b7e81b59..d5b83c0e0f74 100644 --- a/apps/indexer/lib/indexer/block/catchup/bound_interval_supervisor.ex +++ b/apps/indexer/lib/indexer/block/catchup/bound_interval_supervisor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.BoundIntervalSupervisor do @moduledoc """ Supervises the `Indexer.BlockerFetcher.Catchup` with exponential backoff for restarts. @@ -12,7 +13,7 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisor do alias Indexer.Block.Catchup @type named_arguments :: %{ - required(:block_fetcher) => Block.Fetcher.t(), + required(:block_fetcher) => Block.Fetcher.t(Catchup.Fetcher), optional(:block_interval) => pos_integer, optional(:memory_monitor) => GenServer.server() } @@ -63,7 +64,7 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisor do {:ok, state} end - defp new(%{block_fetcher: common_block_fetcher} = named_arguments) do + defp new(%{block_fetcher: %Block.Fetcher{} = common_block_fetcher} = named_arguments) do block_fetcher = %Block.Fetcher{common_block_fetcher | broadcast: :catchup, callback_module: Catchup.Fetcher} block_interval = Map.get(named_arguments, :block_interval, @block_interval) diff --git a/apps/indexer/lib/indexer/block/catchup/fetcher.ex b/apps/indexer/lib/indexer/block/catchup/fetcher.ex index 3edc286ad9f8..59b33c9c19d0 100644 --- a/apps/indexer/lib/indexer/block/catchup/fetcher.ex +++ b/apps/indexer/lib/indexer/block/catchup/fetcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.Fetcher do @moduledoc """ Fetches and indexes block ranges from the block before the latest block to genesis (0) that are missing. @@ -12,12 +13,15 @@ defmodule Indexer.Block.Catchup.Fetcher do async_import_blobs: 2, async_import_block_rewards: 2, async_import_celo_epoch_block_operations: 2, - async_import_coin_balances: 2, + async_import_celo_accounts: 2, + async_import_coin_balances: 1, async_import_created_contract_codes: 2, async_import_filecoin_addresses_info: 2, async_import_internal_transactions: 2, async_import_replaced_transactions: 2, + async_import_signed_authorizations_statuses: 2, async_import_token_balances: 2, + async_import_current_token_balances: 2, async_import_token_instances: 1, async_import_tokens: 2, async_import_uncles: 2, @@ -28,7 +32,7 @@ defmodule Indexer.Block.Catchup.Fetcher do alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.Chain alias Explorer.Chain.NullRoundHeight - alias Explorer.Utility.{MassiveBlock, MissingRangesManipulator} + alias Explorer.Utility.{MassiveBlock, MissingBlockRange} alias Indexer.{Block, Tracer} alias Indexer.Block.Catchup.TaskSupervisor alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand @@ -49,7 +53,7 @@ defmodule Indexer.Block.Catchup.Fetcher do Logger.metadata(fetcher: :block_catchup) Process.flag(:trap_exit, true) - case MissingRangesManipulator.get_latest_batch(blocks_batch_size() * blocks_concurrency()) do + case MissingBlockRange.get_latest_batch(blocks_batch_size() * blocks_concurrency()) do [] -> %{ first_block_number: nil, @@ -130,22 +134,25 @@ defmodule Indexer.Block.Catchup.Fetcher do defp async_import_remaining_block_data( imported, - %{block_rewards: %{errors: block_reward_errors}} = options + %{block_rewards: %{errors: block_reward_errors}} ) do realtime? = false async_import_block_rewards(block_reward_errors, realtime?) - async_import_coin_balances(imported, options) + async_import_coin_balances(imported) async_import_created_contract_codes(imported, realtime?) async_import_internal_transactions(imported, realtime?) async_import_tokens(imported, realtime?) async_import_token_balances(imported, realtime?) + async_import_current_token_balances(imported, realtime?) async_import_uncles(imported, realtime?) async_import_replaced_transactions(imported, realtime?) async_import_token_instances(imported) async_import_blobs(imported, realtime?) async_import_celo_epoch_block_operations(imported, realtime?) + async_import_celo_accounts(imported, realtime?) async_import_filecoin_addresses_info(imported, realtime?) + async_import_signed_authorizations_statuses(imported, realtime?) end defp stream_fetch_and_import(state, ranges) do @@ -157,7 +164,7 @@ defmodule Indexer.Block.Catchup.Fetcher do timeout: :infinity, shutdown: Application.get_env(:indexer, :graceful_shutdown_period) ) - |> Stream.run() + |> handle_fetch_and_import_results() end # Run at state.blocks_concurrency max_concurrency when called by `stream_import/1` @@ -175,23 +182,23 @@ defmodule Indexer.Block.Catchup.Fetcher do {fetch_duration, result} = :timer.tc(fn -> fetch_and_import_range(block_fetcher, range) end) - Prometheus.Instrumenter.block_full_process(fetch_duration, __MODULE__) + Prometheus.Instrumenter.set_block_full_process(fetch_duration, __MODULE__) case result do - {:ok, %{inserted: inserted, errors: errors}} -> + {:ok, %{errors: errors}} -> valid_errors = handle_null_rounds(errors) - clear_missing_ranges(range, valid_errors) + log_errors(valid_errors, range) - {:ok, inserted: inserted} + {:ok, %{range: range, errors: valid_errors}} {:error, {:import = step, [%Changeset{} | _] = changesets}} = error -> - Prometheus.Instrumenter.import_errors() + Prometheus.Instrumenter.set_import_errors_count() Logger.error(fn -> ["failed to validate: ", inspect(changesets), ". Retrying."] end, step: step) error {:error, {:import = step, reason}} = error -> - Prometheus.Instrumenter.import_errors() + Prometheus.Instrumenter.set_import_errors_count() Logger.error(fn -> [inspect(reason), ". Retrying."] end, step: step) if reason == :timeout, do: add_range_to_massive_blocks(range) @@ -224,6 +231,20 @@ defmodule Indexer.Block.Catchup.Fetcher do {:error, exception} end + defp handle_fetch_and_import_results(results) do + results + |> Enum.reduce([], fn + {:ok, {:ok, %{range: range, errors: errors}}}, acc -> + success_numbers = Enum.to_list(range) -- Enum.map(errors, &block_error_to_number/1) + success_numbers ++ acc + + _result, acc -> + acc + end) + |> numbers_to_ranges() + |> MissingBlockRange.clear_batch() + end + defp handle_null_rounds(errors) do {null_rounds, other_errors} = Enum.split_with(errors, fn @@ -238,13 +259,32 @@ defmodule Indexer.Block.Catchup.Fetcher do other_errors end + defp log_errors([], _range), do: :ok + + defp log_errors(errors, range), + do: Logger.error(fn -> "Failed to fetch block range #{inspect(range)}: #{inspect(errors)}" end) + defp timeout_exception?(%{message: message}) when is_binary(message) do - String.match?(message, ~r/due to a timeout/) + match_timeout_exception?(message) + end + + defp timeout_exception?(%{postgres: %{message: message}}) when is_binary(message) do + match_timeout_exception?(message) end defp timeout_exception?(_exception), do: false - defp add_range_to_massive_blocks(range) do + defp match_timeout_exception?(error_message) do + String.match?(error_message, ~r/due to a timeout/) or String.match?(error_message, ~r/due to user request/) + end + + @doc """ + Adds block numbers or block numbers range into `massive_blocks` and clears them from `missing_block_ranges` + """ + @spec add_range_to_massive_blocks(Range.t() | [non_neg_integer()]) :: any() + def add_range_to_massive_blocks([]), do: :ok + + def add_range_to_massive_blocks(range) do clear_missing_ranges(range) range @@ -257,7 +297,7 @@ defmodule Indexer.Block.Catchup.Fetcher do success_numbers |> numbers_to_ranges() - |> MissingRangesManipulator.clear_batch() + |> MissingBlockRange.clear_batch() end defp block_error_to_number(%{data: %{number: number}}) when is_integer(number), do: number diff --git a/apps/indexer/lib/indexer/block/catchup/massive_blocks_fetcher.ex b/apps/indexer/lib/indexer/block/catchup/massive_blocks_fetcher.ex index ea3de2e8d538..fa7308ac0c8d 100644 --- a/apps/indexer/lib/indexer/block/catchup/massive_blocks_fetcher.ex +++ b/apps/indexer/lib/indexer/block/catchup/massive_blocks_fetcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.MassiveBlocksFetcher do @moduledoc """ Fetches and indexes blocks by numbers from massive_blocks table. @@ -8,20 +9,42 @@ defmodule Indexer.Block.Catchup.MassiveBlocksFetcher do require Logger alias Explorer.Utility.MassiveBlock + alias Indexer.Block.Catchup.Fetcher, as: CatchupFetcher alias Indexer.Block.Fetcher + @behaviour Fetcher + @increased_interval 10000 - @spec start_link(term()) :: GenServer.on_start() - def start_link(_) do - GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + @type named_arguments :: %{required(:task_supervisor) => module()} + + @spec child_spec([named_arguments | GenServer.options(), ...]) :: Supervisor.child_spec() + def child_spec([named_arguments]) when is_map(named_arguments), do: child_spec([named_arguments, []]) + + def child_spec([named_arguments, gen_server_options] = start_link_arguments) + when is_map(named_arguments) and is_list(gen_server_options) do + Supervisor.child_spec( + %{id: __MODULE__, start: {__MODULE__, :start_link, start_link_arguments}, type: :worker}, + [] + ) + end + + @spec start_link(named_arguments :: map()) :: GenServer.on_start() + @spec start_link(named_arguments :: %{}, GenServer.options()) :: GenServer.on_start() + def start_link(named_arguments, gen_server_options \\ []) + when is_map(named_arguments) and is_list(gen_server_options) do + GenServer.start_link(__MODULE__, named_arguments, gen_server_options) end @impl true - def init(_) do + def init(opts) do + if !opts[:task_supervisor] do + raise ArgumentError, ":task_supervisor must be provided to #{__MODULE__}.start_link/1" + end + send_new_task() - {:ok, %{block_fetcher: generate_block_fetcher(), low_priority_blocks: []}} + {:ok, %{block_fetcher: generate_block_fetcher(opts), low_priority_blocks: []}} end @impl true @@ -55,6 +78,11 @@ defmodule Indexer.Block.Catchup.MassiveBlocksFetcher do {:noreply, state} end + @impl Fetcher + def import(block_fetcher, options) do + CatchupFetcher.import(block_fetcher, options) + end + defp process_block(block_fetcher, number) do case Fetcher.fetch_and_import_range(block_fetcher, number..number, %{timeout: :infinity}) do {:ok, _result} -> @@ -72,17 +100,18 @@ defmodule Indexer.Block.Catchup.MassiveBlocksFetcher do [number] end - defp generate_block_fetcher do + defp generate_block_fetcher(opts) do receipts_batch_size = Application.get_env(:indexer, :receipts_batch_size) receipts_concurrency = Application.get_env(:indexer, :receipts_concurrency) json_rpc_named_arguments = Application.get_env(:indexer, :json_rpc_named_arguments) %Fetcher{ broadcast: :catchup, - callback_module: Indexer.Block.Catchup.Fetcher, + callback_module: __MODULE__, json_rpc_named_arguments: json_rpc_named_arguments, receipts_batch_size: receipts_batch_size, - receipts_concurrency: receipts_concurrency + receipts_concurrency: receipts_concurrency, + task_supervisor: opts[:task_supervisor] } end diff --git a/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex b/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex index 26f526855e53..15dc99c4f2e5 100644 --- a/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex +++ b/apps/indexer/lib/indexer/block/catchup/missing_ranges_collector.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.MissingRangesCollector do @moduledoc """ Collects and manages missing block ranges in the blockchain. @@ -62,10 +63,9 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do use Utils.CompileTimeEnvHelper, future_check_interval: [:indexer, [__MODULE__, :future_check_interval]] alias EthereumJSONRPC.Utility.RangesHelper - alias Explorer.{Chain, Helper, Repo} - alias Explorer.Chain.Cache.BlockNumber + alias Explorer.{Chain, Repo} alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Explorer.Utility.{MissingBlockRange, MissingRangesManipulator} + alias Explorer.Utility.MissingBlockRange @default_missing_ranges_batch_size 100_000 @past_check_interval 10 @@ -188,7 +188,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do ranges |> Enum.reverse() |> Enum.flat_map(fn f..l//_ -> Chain.missing_block_number_ranges(l..f) end) - |> MissingRangesManipulator.save_batch() + |> MissingBlockRange.save_batch() if not is_nil(max_fetched_block_number) do schedule_future_check() @@ -276,7 +276,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do %{min: nil, max: nil} -> max_number = last_block() {min_number, first_batch} = fetch_missing_ranges_batch(max_number, false) - MissingRangesManipulator.save_batch(first_batch) + MissingBlockRange.save_batch(first_batch) {min_number, max_number} %{min: min, max: max} -> @@ -298,7 +298,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do def handle_info(:update_future, %{max_fetched_block_number: max_number} = state) do if continue_future_updating?(max_number) do {new_max_number, batch} = fetch_missing_ranges_batch(max_number, true) - MissingRangesManipulator.save_batch(batch) + MissingBlockRange.save_batch(batch) schedule_future_check() {:noreply, %{state | max_fetched_block_number: new_max_number}} else @@ -322,7 +322,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do def handle_info(:update_past, %{min_fetched_block_number: min_number} = state) do if min_number > first_block() do {new_min_number, batch} = fetch_missing_ranges_batch(min_number, false) - MissingRangesManipulator.save_batch(batch) + MissingBlockRange.save_batch(batch) schedule_past_check(state.first_check_completed?) {:noreply, %{state | min_fetched_block_number: new_min_number}} else @@ -393,23 +393,17 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do @spec last_block() :: non_neg_integer() defp last_block do last_block = Application.get_env(:indexer, :last_block) - if last_block, do: last_block + 1, else: fetch_max_block_number() + if last_block, do: last_block + 1, else: fetch_max_block_number_from_node() end - # Retrieves the highest block number from cache or blockchain. - @spec fetch_max_block_number() :: non_neg_integer() - defp fetch_max_block_number do - case BlockNumber.get_max() do - 0 -> - json_rpc_named_arguments = Application.get_env(:indexer, :json_rpc_named_arguments) + # Retrieves the highest block number from blockchain. + @spec fetch_max_block_number_from_node() :: non_neg_integer() + defp fetch_max_block_number_from_node do + json_rpc_named_arguments = Application.get_env(:indexer, :json_rpc_named_arguments) - case EthereumJSONRPC.fetch_block_number_by_tag("latest", json_rpc_named_arguments) do - {:ok, number} -> number - _ -> 0 - end - - number -> - number + case EthereumJSONRPC.fetch_block_number_by_tag("latest", json_rpc_named_arguments) do + {:ok, number} -> number + _ -> 0 end end @@ -487,22 +481,7 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do @spec parse_block_ranges(binary()) :: {:finite_ranges, [Range.t()]} | {:infinite_ranges, [Range.t()], non_neg_integer()} | :no_ranges def parse_block_ranges(block_ranges_string) do - ranges = - block_ranges_string - |> String.split(",") - |> Enum.map(fn string_range -> - case String.split(string_range, "..") do - [from_string, "latest"] -> - Helper.parse_integer(from_string) - - [from_string, to_string] -> - get_from_to(from_string, to_string) - - _ -> - nil - end - end) - |> RangesHelper.sanitize_ranges() + ranges = RangesHelper.parse_block_ranges(block_ranges_string) case List.last(ranges) do _from.._to//_ -> @@ -515,15 +494,4 @@ defmodule Indexer.Block.Catchup.MissingRangesCollector do {:infinite_ranges, List.delete_at(ranges, -1), num - 1} end end - - # Creates a range from string boundaries, returning nil if parsing fails or if from > to - @spec get_from_to(binary(), binary()) :: Range.t() | nil - defp get_from_to(from_string, to_string) do - with {from, ""} <- Integer.parse(from_string), - {to, ""} <- Integer.parse(to_string) do - if from <= to, do: from..to, else: nil - else - _ -> nil - end - end end diff --git a/apps/indexer/lib/indexer/block/catchup/supervisor.ex b/apps/indexer/lib/indexer/block/catchup/supervisor.ex index 024c93c91a91..26164d5e4092 100644 --- a/apps/indexer/lib/indexer/block/catchup/supervisor.ex +++ b/apps/indexer/lib/indexer/block/catchup/supervisor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.Supervisor do @moduledoc """ Supervises `Indexer.Block.Catchup.TaskSupervisor` and `Indexer.Block.Catchup.BoundIntervalSupervisor` @@ -31,8 +32,15 @@ defmodule Indexer.Block.Catchup.Supervisor do [ {MissingRangesCollector, []}, {Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}, - {MassiveBlocksFetcher, []}, - {BoundIntervalSupervisor, [bound_interval_supervisor_arguments, [name: BoundIntervalSupervisor]]} + {MassiveBlocksFetcher, + [%{task_supervisor: Indexer.Block.Catchup.TaskSupervisor}, [name: MassiveBlocksFetcher]]}, + {BoundIntervalSupervisor, + [ + update_in(bound_interval_supervisor_arguments, [:block_fetcher], fn fetcher -> + %{fetcher | task_supervisor: Indexer.Block.Catchup.TaskSupervisor} + end), + [name: BoundIntervalSupervisor] + ]} ], strategy: :one_for_one ) diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index b90f5120e743..783002787a2d 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -1,8 +1,13 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Fetcher do @moduledoc """ Fetches and indexes block ranges. """ + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + chain_identity: [:explorer, :chain_identity] + use Spandex.Decorators require Logger @@ -10,33 +15,42 @@ defmodule Indexer.Block.Fetcher do import EthereumJSONRPC, only: [quantity_to_integer: 1] alias EthereumJSONRPC.{Blocks, FetchedBeneficiaries} - alias Explorer.Chain + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Block, Hash, Import, Transaction, Wei, Withdrawal} alias Explorer.Chain.Block.Reward - alias Explorer.Chain.Cache.Blocks, as: BlocksCache alias Explorer.Chain.Cache.{Accounts, BlockNumber, Transactions, Uncles} + alias Explorer.Chain.Cache.Blocks, as: BlocksCache + alias Explorer.Chain.Celo.Legacy.Accounts, as: CeloAccountsTransform alias Explorer.Chain.Filecoin.PendingAddressOperation, as: FilecoinPendingAddressOperation - alias Explorer.Chain.{Address, Block, Hash, Import, Transaction, Wei} - alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Indexer.Block.Catchup.Fetcher, as: CatchupFetcher + alias Indexer.Block.Catchup.MassiveBlocksFetcher alias Indexer.Block.Fetcher.Receipts alias Indexer.Fetcher.Arbitrum.MessagesToL2Matcher, as: ArbitrumMessagesToL2Matcher alias Indexer.Fetcher.Celo.EpochBlockOperations, as: CeloEpochBlockOperations alias Indexer.Fetcher.Celo.EpochLogs, as: CeloEpochLogs + alias Indexer.Fetcher.Celo.Legacy.Account, as: CeloAccount alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup alias Indexer.Fetcher.CoinBalance.Realtime, as: CoinBalanceRealtime alias Indexer.Fetcher.Filecoin.AddressInfo, as: FilecoinAddressInfo - alias Indexer.Fetcher.PolygonZkevm.BridgeL1Tokens, as: PolygonZkevmBridgeL1Tokens + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical alias Indexer.Fetcher.TokenInstance.Realtime, as: TokenInstanceRealtime + alias Indexer.Fetcher.Zilliqa.Zrc2Tokens alias Indexer.{Prometheus, TokenBalances, Tracer} alias Indexer.Fetcher.{ + AddressImporter, + AddressNonceUpdater, Beacon.Blob, BlockReward, ContractCode, + CurrentTokenBalanceImporter, InternalTransaction, ReplacedTransaction, + SignedAuthorizationStatus, Token, - TokenBalance, + TokenInstanceImporter, UncleBlock } @@ -44,16 +58,16 @@ defmodule Indexer.Block.Fetcher do AddressCoinBalances, Addresses, AddressTokenBalances, + FheOperations, MintTransfers, SignedAuthorizations, TokenInstances, - TokenTransfers, - TransactionActions + TokenTransfers } - alias Indexer.Transform.Optimism.Withdrawals, as: OptimismWithdrawals + alias Indexer.Transform.Stability.Validators, as: StabilityValidators - alias Indexer.Transform.PolygonEdge.{DepositExecutes, Withdrawals} + alias Indexer.Transform.Optimism.Withdrawals, as: OptimismWithdrawals alias Indexer.Transform.Scroll.L1FeeParams, as: ScrollL1FeeParams @@ -61,23 +75,31 @@ defmodule Indexer.Block.Fetcher do alias Indexer.Transform.Shibarium.Bridge, as: ShibariumBridge alias Indexer.Transform.Blocks, as: TransformBlocks - alias Indexer.Transform.PolygonZkevm.Bridge, as: PolygonZkevmBridge + alias Indexer.Transform.Celo.L1Epochs, as: CeloL1Epochs + alias Indexer.Transform.Celo.L2Epochs, as: CeloL2Epochs alias Indexer.Transform.Celo.TransactionGasTokens, as: CeloTransactionGasTokens alias Indexer.Transform.Celo.TransactionTokenTransfers, as: CeloTransactionTokenTransfers @type address_hash_to_fetched_balance_block_number :: %{String.t() => Block.block_number()} - @type t :: %__MODULE__{} + @type t(callback_module) :: %__MODULE__{ + broadcast: term(), + task_supervisor: module(), + callback_module: callback_module, + json_rpc_named_arguments: EthereumJSONRPC.json_rpc_named_arguments(), + receipts_batch_size: pos_integer(), + receipts_concurrency: pos_integer() + } @doc """ Calculates the balances and internal transactions and imports those with the given data. """ @callback import( - t, + t(module()), %{ - address_hash_to_fetched_balance_block_number: address_hash_to_fetched_balance_block_number, - addresses: Import.Runner.options(), + optional(:address_hash_to_fetched_balance_block_number) => address_hash_to_fetched_balance_block_number, + optional(:addresses) => Import.Runner.options(), address_coin_balances: Import.Runner.options(), address_coin_balances_daily: Import.Runner.options(), address_token_balances: Import.Runner.options(), @@ -104,9 +126,10 @@ defmodule Indexer.Block.Fetcher do @doc false def default_receipts_concurrency, do: @receipts_concurrency - @enforce_keys ~w(json_rpc_named_arguments)a + @enforce_keys ~w(json_rpc_named_arguments task_supervisor)a defstruct broadcast: nil, callback_module: nil, + task_supervisor: nil, json_rpc_named_arguments: nil, receipts_batch_size: @receipts_batch_size, receipts_concurrency: @receipts_concurrency @@ -131,7 +154,7 @@ defmodule Indexer.Block.Fetcher do end @decorate span(tracer: Tracer) - @spec fetch_and_import_range(t, Range.t(), map) :: + @spec fetch_and_import_range(t(module()), Range.t(), map) :: {:ok, %{inserted: %{}, errors: [EthereumJSONRPC.Transport.error()]}} | {:error, {step :: atom(), reason :: [Ecto.Changeset.t()] | term()} @@ -140,7 +163,8 @@ defmodule Indexer.Block.Fetcher do %__MODULE__{ broadcast: _broadcast, callback_module: callback_module, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: task_supervisor } = state, _.._//_ = range, additional_options \\ %{} @@ -149,15 +173,14 @@ defmodule Indexer.Block.Fetcher do {fetch_time, fetch_result} = :timer.tc(fn -> EthereumJSONRPC.fetch_blocks_by_range(range, json_rpc_named_arguments) end) - with {:blocks, - {:ok, - %Blocks{ - blocks_params: blocks_params, - transactions_params: transactions_params_without_receipts, - withdrawals_params: withdrawals_params, - block_second_degree_relations_params: block_second_degree_relations_params, - errors: blocks_errors - } = fetched_blocks}} <- {:blocks, fetch_result}, + with {:blocks, {:ok, fetched_blocks}} <- {:blocks, fetch_result}, + %Blocks{ + blocks_params: blocks_params, + transactions_params: transactions_params_without_receipts, + withdrawals_params: withdrawals_params, + block_second_degree_relations_params: block_second_degree_relations_params, + errors: blocks_errors + } = filtered_fetched_blocks = process_massive_blocks(fetched_blocks, callback_module), blocks = TransformBlocks.transform_blocks(blocks_params), {:receipts, {:ok, receipt_params}} <- {:receipts, Receipts.fetch(state, transactions_params_without_receipts)}, %{logs: receipt_logs, receipts: receipts} = receipt_params, @@ -169,18 +192,14 @@ defmodule Indexer.Block.Fetcher do CeloTransactionTokenTransfers.parse_transactions(transactions_with_receipts), celo_gas_tokens = CeloTransactionGasTokens.parse(transactions_with_receipts), token_transfers = token_transfers ++ celo_native_token_transfers, + celo_l1_epochs = CeloL1Epochs.parse(blocks), + celo_l2_epochs = CeloL2Epochs.parse(logs), + celo_pending_account_operations = parse_celo_pending_account_operations(logs), tokens = Enum.uniq(tokens ++ celo_tokens), - %{transaction_actions: transaction_actions} = TransactionActions.parse(logs), + %{fhe_operations: fhe_operations} = FheOperations.parse(logs), %{mint_transfers: mint_transfers} = MintTransfers.parse(logs), optimism_withdrawals = if(callback_module == Indexer.Block.Realtime.Fetcher, do: OptimismWithdrawals.parse(logs), else: []), - polygon_edge_withdrawals = - if(callback_module == Indexer.Block.Realtime.Fetcher, do: Withdrawals.parse(logs), else: []), - polygon_edge_deposit_executes = - if(callback_module == Indexer.Block.Realtime.Fetcher, - do: DepositExecutes.parse(logs), - else: [] - ), scroll_l1_fee_params = if(callback_module == Indexer.Block.Realtime.Fetcher, do: ScrollL1FeeParams.parse(logs), @@ -191,11 +210,6 @@ defmodule Indexer.Block.Fetcher do do: ShibariumBridge.parse(blocks, transactions_with_receipts, logs), else: [] ), - polygon_zkevm_bridge_operations = - if(callback_module == Indexer.Block.Realtime.Fetcher, - do: PolygonZkevmBridge.parse(blocks, logs), - else: [] - ), {arbitrum_xlevel_messages, arbitrum_transactions_for_further_handling} = ArbitrumMessaging.parse(transactions_with_receipts, logs), %FetchedBeneficiaries{params_set: beneficiary_params_set, errors: beneficiaries_errors} = @@ -209,9 +223,8 @@ defmodule Indexer.Block.Fetcher do shibarium_bridge_operations: shibarium_bridge_operations, token_transfers: token_transfers, transactions: transactions_with_receipts, - transaction_actions: transaction_actions, withdrawals: withdrawals_params, - polygon_zkevm_bridge_operations: polygon_zkevm_bridge_operations + celo_pending_account_operations: celo_pending_account_operations }), coin_balances_params_set = %{ @@ -227,68 +240,60 @@ defmodule Indexer.Block.Fetcher do token_transfers_with_token = token_transfers_merge_token(token_transfers, tokens), address_token_balances = AddressTokenBalances.params_set(%{token_transfers_params: token_transfers_with_token}), - transaction_actions = - Enum.map(transaction_actions, fn action -> Map.put(action, :data, Map.delete(action.data, :block_number)) end), - token_instances = TokenInstances.params_set(%{token_transfers_params: token_transfers}), + stability_validators = StabilityValidators.parse(blocks), basic_import_options = %{ addresses: %{params: addresses}, address_coin_balances: %{params: coin_balances_params_set}, address_token_balances: %{params: address_token_balances}, - address_current_token_balances: %{ - params: address_token_balances |> MapSet.to_list() |> TokenBalances.to_address_current_token_balances() - }, blocks: %{params: blocks}, block_second_degree_relations: %{params: block_second_degree_relations_params}, block_rewards: %{errors: beneficiaries_errors, params: beneficiaries_with_gas_payment}, logs: %{params: logs}, token_transfers: %{params: token_transfers}, - tokens: %{params: tokens}, transactions: %{params: transactions_with_receipts}, withdrawals: %{params: withdrawals_params}, - token_instances: %{params: token_instances}, - signed_authorizations: %{params: SignedAuthorizations.parse(transactions_with_receipts)} + signed_authorizations: %{params: SignedAuthorizations.parse(transactions_with_receipts)}, + fhe_operations: %{params: fhe_operations} }, chain_type_import_options = %{ transactions_with_receipts: transactions_with_receipts, optimism_withdrawals: optimism_withdrawals, - polygon_edge_withdrawals: polygon_edge_withdrawals, - polygon_edge_deposit_executes: polygon_edge_deposit_executes, - polygon_zkevm_bridge_operations: polygon_zkevm_bridge_operations, scroll_l1_fee_params: scroll_l1_fee_params, shibarium_bridge_operations: shibarium_bridge_operations, celo_gas_tokens: celo_gas_tokens, - arbitrum_messages: arbitrum_xlevel_messages + celo_epochs: celo_l1_epochs ++ celo_l2_epochs, + celo_pending_account_operations: celo_pending_account_operations, + arbitrum_messages: arbitrum_xlevel_messages, + stability_validators: stability_validators } - |> extend_with_zilliqa_import_options(fetched_blocks), + |> extend_with_zilliqa_import_options(filtered_fetched_blocks), {:ok, inserted} <- __MODULE__.import( state, - basic_import_options |> Map.merge(additional_options) |> import_options(chain_type_import_options) - ), - {:transaction_actions, {:ok, inserted_transaction_actions}} <- - {:transaction_actions, - Chain.import(%{ - transaction_actions: %{params: transaction_actions}, - timeout: :infinity - })} do - inserted = Map.merge(inserted, inserted_transaction_actions) - Prometheus.Instrumenter.block_batch_fetch(fetch_time, callback_module) + merge_options(basic_import_options, additional_options) + |> import_options(chain_type_import_options) + |> extend_with_asyncable_import_options(tokens, token_transfers, address_token_balances, callback_module) + ) do + Prometheus.Instrumenter.set_block_batch_fetch(fetch_time, callback_module) result = {:ok, %{inserted: inserted, errors: blocks_errors}} - update_block_cache(inserted[:blocks]) - update_transactions_cache(inserted[:transactions]) - update_addresses_cache(inserted[:addresses]) - update_uncles_cache(inserted[:block_second_degree_relations]) - update_withdrawals_cache(inserted[:withdrawals]) - - update_multichain_search_db(%{ - addresses: inserted[:addresses], - blocks: inserted[:blocks], - transactions: inserted[:transactions] - }) + + Task.Supervisor.start_child(task_supervisor, fn -> + update_block_cache(inserted[:blocks], inserted) + update_transactions_cache(inserted[:transactions], inserted) + update_addresses_cache(inserted[:addresses]) + update_uncles_cache(inserted[:block_second_degree_relations]) + update_withdrawals_cache(inserted[:withdrawals]) + end) async_match_arbitrum_messages_to_l2(arbitrum_transactions_for_further_handling) + if chain_type() == :zilliqa do + inserted_logs = Map.get(inserted, :logs, []) + inserted_transactions = Map.get(inserted, :transactions, []) + Zrc2Tokens.fetch_zrc2_token_transfers_and_adapters(inserted_logs, inserted_transactions, range, callback_module) + end + result else {step, {:error, reason}} -> {:error, {step, reason}} @@ -296,37 +301,63 @@ defmodule Indexer.Block.Fetcher do end end + defp process_massive_blocks(fetched_blocks, MassiveBlocksFetcher), do: fetched_blocks + + defp process_massive_blocks(fetched_blocks, _callback_module) do + massive_block_threshold = Application.get_env(:indexer, :massive_block_threshold, 1000) + + massive_block_numbers = + fetched_blocks + |> Map.get(:transactions_params, []) + |> Enum.reduce(%{}, fn %{block_number: number}, acc -> + Map.update(acc, number, 1, &(&1 + 1)) + end) + |> Enum.reduce([], fn {number, transactions_count}, acc -> + if transactions_count > massive_block_threshold do + Logger.warning("Marking block #{number} as massive by transactions count: #{transactions_count}") + [number | acc] + else + acc + end + end) + + # realtime fetcher intentionally filters and defers massive blocks to the catchup pipeline + CatchupFetcher.add_range_to_massive_blocks(massive_block_numbers) + + Blocks.reject_data_by_block_numbers(fetched_blocks, massive_block_numbers) + end + defp import_options(basic_import_options, chain_specific_import_options) do - chain_type = Application.get_env(:explorer, :chain_type) - do_import_options(chain_type, basic_import_options, chain_specific_import_options) + do_import_options( + chain_type(), + basic_import_options, + chain_specific_import_options + ) end - defp do_import_options(:ethereum, basic_import_options, %{transactions_with_receipts: transactions_with_receipts}) do + defp do_import_options(:ethereum, basic_import_options, %{ + transactions_with_receipts: transactions_with_receipts + }) do basic_import_options |> Map.put_new(:beacon_blob_transactions, %{ params: transactions_with_receipts |> Enum.filter(&Map.has_key?(&1, :max_fee_per_blob_gas)) }) end - defp do_import_options(:optimism, basic_import_options, %{optimism_withdrawals: optimism_withdrawals}) do - basic_import_options - |> Map.put_new(:optimism_withdrawals, %{params: optimism_withdrawals}) - end - - defp do_import_options(:polygon_edge, basic_import_options, %{ - polygon_edge_withdrawals: polygon_edge_withdrawals, - polygon_edge_deposit_executes: polygon_edge_deposit_executes - }) do - basic_import_options - |> Map.put_new(:polygon_edge_withdrawals, %{params: polygon_edge_withdrawals}) - |> Map.put_new(:polygon_edge_deposit_executes, %{params: polygon_edge_deposit_executes}) - end + defp do_import_options( + :optimism, + basic_import_options, + %{optimism_withdrawals: optimism_withdrawals} = chain_specific_import_options + ) do + import_options = + basic_import_options + |> Map.put_new(:optimism_withdrawals, %{params: optimism_withdrawals}) - defp do_import_options(:polygon_zkevm, basic_import_options, %{ - polygon_zkevm_bridge_operations: polygon_zkevm_bridge_operations - }) do - basic_import_options - |> Map.put_new(:polygon_zkevm_bridge_operations, %{params: polygon_zkevm_bridge_operations}) + do_chain_identity_import_options( + chain_identity(), + import_options, + chain_specific_import_options + ) end defp do_import_options(:scroll, basic_import_options, %{scroll_l1_fee_params: scroll_l1_fee_params}) do @@ -334,24 +365,13 @@ defmodule Indexer.Block.Fetcher do |> Map.put_new(:scroll_l1_fee_params, %{params: scroll_l1_fee_params}) end - defp do_import_options(:shibarium, basic_import_options, %{shibarium_bridge_operations: shibarium_bridge_operations}) do + defp do_import_options(:shibarium, basic_import_options, %{ + shibarium_bridge_operations: shibarium_bridge_operations + }) do basic_import_options |> Map.put_new(:shibarium_bridge_operations, %{params: shibarium_bridge_operations}) end - defp do_import_options(:celo, basic_import_options, %{celo_gas_tokens: celo_gas_tokens}) do - tokens = - basic_import_options - |> Map.get(:tokens, %{}) - |> Map.get(:params, []) - - basic_import_options - |> Map.put( - :tokens, - %{params: (tokens ++ celo_gas_tokens) |> Enum.uniq()} - ) - end - defp do_import_options(:arbitrum, basic_import_options, %{arbitrum_messages: arbitrum_xlevel_messages}) do basic_import_options |> Map.put_new(:arbitrum_messages, %{params: arbitrum_xlevel_messages}) @@ -368,7 +388,50 @@ defmodule Indexer.Block.Fetcher do |> Map.put_new(:zilliqa_nested_quorum_certificates, %{params: zilliqa_nested_quorum_certificates}) end - defp do_import_options(_chain_type, basic_import_options, _chain_specific_import_options) do + defp do_import_options(:stability, basic_import_options, %{stability_validators: stability_validators}) do + basic_import_options + |> Map.put_new(:stability_validators, %{params: stability_validators}) + end + + defp do_import_options(_chain_identity, basic_import_options, _chain_specific_import_options) do + basic_import_options + end + + defp do_chain_identity_import_options( + {:optimism, :celo}, + basic_import_options, + %{ + celo_gas_tokens: celo_gas_tokens, + celo_epochs: celo_epochs, + celo_pending_account_operations: celo_pending_account_operations + } = chain_specific_import_options + ) do + tokens = + basic_import_options + |> Map.get(:tokens, %{}) + |> Map.get(:params, []) + + import_options = + do_import_options( + {:optimism, nil}, + basic_import_options, + chain_specific_import_options + ) + + import_options + |> Map.put_new(:celo_pending_account_operations, %{params: celo_pending_account_operations}) + |> Map.put_new(:celo_epochs, %{params: celo_epochs}) + |> then(fn options -> + if enable_partial_async_import?() do + TokenInstanceImporter.add(celo_gas_tokens, []) + options + else + Map.put(options, :tokens, %{params: Enum.uniq(tokens ++ celo_gas_tokens)}) + end + end) + end + + defp do_chain_identity_import_options(_, basic_import_options, _chain_specific_import_options) do basic_import_options end @@ -381,22 +444,96 @@ defmodule Indexer.Block.Fetcher do }) end - defp update_block_cache([]), do: :ok + defp merge_options(left, right) when is_map(left) and is_map(right) do + Map.merge(left, right, fn _key, value1, value2 -> merge_option_values(value1, value2) end) + end + + defp merge_option_values(%{params: params1} = map1, %{params: params2} = map2) do + merged_map = Map.merge(map1, map2) + merged_params = Enum.uniq(List.wrap(params1) ++ List.wrap(params2)) + Map.put(merged_map, :params, merged_params) + end + + defp merge_option_values(list1, list2) when is_list(list1) and is_list(list2) do + Enum.uniq(list1 ++ list2) + end + + defp merge_option_values(_value1, value2), do: value2 + + defp extend_with_asyncable_import_options(import_options, tokens, token_transfers, token_balances, callback_module) do + current_token_balances_params = + token_balances + |> MapSet.to_list() + |> TokenBalances.to_address_current_token_balances() + + if enable_partial_async_import?() do + TokenInstanceImporter.add(tokens, token_transfers) + CurrentTokenBalanceImporter.add(current_token_balances_params, callback_module == Indexer.Block.Realtime.Fetcher) + import_options + else + token_instances = TokenInstances.params_set(%{token_transfers_params: token_transfers}) + addresses_without_nonce = process_addresses_nonce(import_options[:addresses][:params]) + + merge_options(import_options, %{ + addresses: %{params: addresses_without_nonce}, + address_current_token_balances: %{params: current_token_balances_params}, + tokens: %{params: tokens}, + token_instances: %{params: token_instances} + }) + end + end + + defp enable_partial_async_import?, do: Application.get_env(:indexer, :enable_partial_async_import?) + + defp update_block_cache([], _), do: :ok - defp update_block_cache(blocks) when is_list(blocks) do + defp update_block_cache(blocks, inserted) when is_list(blocks) do {min_block, max_block} = Enum.min_max_by(blocks, & &1.number) BlockNumber.update_all(max_block.number) BlockNumber.update_all(min_block.number) - BlocksCache.update(blocks) + + transactions_by_block = Enum.group_by(Map.get(inserted, :transactions, []), & &1.block_hash) + rewards_by_block = Enum.group_by(Map.get(inserted, :block_rewards, []), & &1.block_hash) + + blocks + |> Repo.preload( + transactions: fn block_hashes -> + Enum.flat_map(block_hashes, &Map.get(transactions_by_block, &1, [])) + end, + rewards: fn block_hashes -> + Enum.flat_map(block_hashes, &Map.get(rewards_by_block, &1, [])) + end + ) + |> BlocksCache.update() end - defp update_block_cache(_), do: :ok + defp update_block_cache(_, _), do: :ok + + defp update_transactions_cache([], _), do: :ok + + defp update_transactions_cache(transactions, inserted) when is_list(transactions) do + blocks_map = Map.new(Map.get(inserted, :blocks, []), fn block -> {block.hash, [block]} end) + token_transfers_transaction_hashes_set = MapSet.new(Map.get(inserted, :token_transfers, []), & &1.transaction_hash) - defp update_transactions_cache(transactions) do - Transactions.update(transactions) + transactions + |> Repo.preload( + block: fn transaction_block_hashes -> + Enum.flat_map(transaction_block_hashes, &Map.get(blocks_map, &1, [])) + end + ) + |> Enum.map(fn transaction -> + Map.put( + transaction, + :has_token_transfers, + MapSet.member?(token_transfers_transaction_hashes_set, transaction.hash) + ) + end) + |> Transactions.update() end + defp update_transactions_cache(_, _), do: :ok + defp update_addresses_cache(addresses), do: Accounts.drop(addresses) defp update_uncles_cache(updated_relations) do @@ -405,21 +542,13 @@ defmodule Indexer.Block.Fetcher do defp update_withdrawals_cache([_ | _] = withdrawals) do %{index: index} = List.last(withdrawals) - Chain.upsert_count_withdrawals(index) + Withdrawal.upsert_count_withdrawals(index) end defp update_withdrawals_cache(_) do :ok end - defp update_multichain_search_db(%{addresses: addresses, blocks: blocks, transactions: transactions}) do - MultichainSearch.batch_import(%{ - addresses: addresses || [], - blocks: blocks || [], - transactions: transactions || [] - }) - end - def import( %__MODULE__{broadcast: broadcast, callback_module: callback_module} = state, options @@ -429,27 +558,35 @@ defmodule Indexer.Block.Fetcher do pop_address_hash_to_fetched_balance_block_number(options) options_with_broadcast = - Map.merge( - import_options, - %{ - address_hash_to_fetched_balance_block_number: address_hash_to_fetched_balance_block_number, - broadcast: broadcast - } - ) + if enable_partial_async_import?() do + AddressImporter.add(import_options[:addresses][:params]) + + import_options + |> Map.merge(%{broadcast: broadcast}) + |> Map.delete(:addresses) + else + Map.merge( + import_options, + %{ + address_hash_to_fetched_balance_block_number: address_hash_to_fetched_balance_block_number, + broadcast: broadcast + } + ) + end {import_time, result} = :timer.tc(fn -> callback_module.import(state, options_with_broadcast) end) no_blocks_to_import = length(options_with_broadcast.blocks.params) if no_blocks_to_import != 0 do - Prometheus.Instrumenter.block_import(import_time / no_blocks_to_import, callback_module) + Prometheus.Instrumenter.set_block_import(import_time / no_blocks_to_import, callback_module) end result end - def async_import_token_instances(%{token_transfers: token_transfers}) do - TokenInstanceRealtime.async_fetch(token_transfers) + def async_import_token_instances(%{token_instances: token_instances}) do + TokenInstanceRealtime.async_fetch(token_instances) end def async_import_token_instances(_), do: :ok @@ -475,18 +612,11 @@ defmodule Indexer.Block.Fetcher do |> BlockReward.async_fetch(realtime?) end - def async_import_coin_balances(%{addresses: addresses}, %{ - address_hash_to_fetched_balance_block_number: address_hash_to_block_number - }) do - addresses - |> Enum.map(fn %Address{hash: address_hash} -> - block_number = Map.fetch!(address_hash_to_block_number, to_string(address_hash)) - %{address_hash: address_hash, block_number: block_number} - end) - |> CoinBalanceCatchup.async_fetch_balances() + def async_import_coin_balances(%{address_coin_balances: balances}) do + CoinBalanceCatchup.async_fetch_balances(balances) end - def async_import_coin_balances(_, _), do: :ok + def async_import_coin_balances(_), do: :ok def async_import_realtime_coin_balances(%{address_coin_balances: balances}) do CoinBalanceRealtime.async_fetch_balances(balances) @@ -500,14 +630,30 @@ defmodule Indexer.Block.Fetcher do def async_import_created_contract_codes(_, _), do: :ok - def async_import_internal_transactions(%{blocks: blocks, transactions: transactions}, realtime?) do + def async_import_internal_transactions(_imported, _realtime?) + + def async_import_internal_transactions(%{blocks: blocks} = imported, realtime?) do blocks |> Enum.map(fn %Block{number: block_number} -> block_number end) - |> InternalTransaction.async_fetch(transactions, realtime?, 10_000) + |> InternalTransaction.async_fetch(Map.get(imported, :transactions, []), realtime?, 10_000) end def async_import_internal_transactions(_, _), do: :ok + @doc """ + Triggers async import of tokens just inserted into the database by the realtime or catchup indexer. + + ## Parameters + - `%{tokens: tokens}`: A map returned by the `Chain.import` function containing the list of inserted tokens. + - `realtime?`: A boolean flag indicating whether to insert the tokens to the beginning (true) + or to the end (false) of the import queue. + + ## Returns + - :ok + """ + @spec async_import_tokens(%{:tokens => list(), optional(any()) => any()}, boolean()) :: :ok + def async_import_tokens(%{tokens: []}, _realtime?), do: :ok + def async_import_tokens(%{tokens: tokens}, realtime?) do tokens |> Enum.map(& &1.contract_address_hash) @@ -516,12 +662,52 @@ defmodule Indexer.Block.Fetcher do def async_import_tokens(_, _), do: :ok + @doc """ + Triggers async import of historical token balances just inserted into the database by the realtime or catchup indexer + or internal transactions fetcher. + + ## Parameters + - `%{address_token_balances: token_balances}`: A map returned by the `Chain.import` function containing + the list of inserted token balances. + - `realtime?`: A boolean flag indicating whether to insert the items to the beginning (true) + or to the end (false) of the import queue. + + ## Returns + - :ok + """ + @spec async_import_token_balances(%{:address_token_balances => list(), optional(any()) => any()}, boolean()) :: :ok + def async_import_token_balances(%{address_token_balances: []}, _realtime?), do: :ok + def async_import_token_balances(%{address_token_balances: token_balances}, realtime?) do - TokenBalance.async_fetch(token_balances, realtime?) + TokenBalanceHistorical.async_fetch(token_balances, realtime?) end def async_import_token_balances(_, _), do: :ok + @doc """ + Triggers async import of current token balances just inserted into the database by the realtime or catchup indexer. + + ## Parameters + - `%{address_current_token_balances: current_token_balances}`: A map returned by the `Chain.import` function containing + the list of inserted current token balances. + - `realtime?`: A boolean flag indicating whether to insert the items to the beginning (true) + or to the end (false) of the import queue. + + ## Returns + - :ok + """ + @spec async_import_current_token_balances( + %{:address_current_token_balances => list(), optional(any()) => any()}, + boolean() + ) :: :ok + def async_import_current_token_balances(%{address_current_token_balances: []}, _realtime?), do: :ok + + def async_import_current_token_balances(%{address_current_token_balances: current_token_balances}, realtime?) do + TokenBalanceCurrent.async_fetch(current_token_balances, realtime?) + end + + def async_import_current_token_balances(_, _), do: :ok + def async_import_uncles(%{block_second_degree_relations: block_second_degree_relations}, realtime?) do UncleBlock.async_fetch_blocks(block_second_degree_relations, realtime?) end @@ -542,25 +728,18 @@ defmodule Indexer.Block.Fetcher do def async_import_replaced_transactions(_, _), do: :ok - @doc """ - Fills a buffer of L1 token addresses to handle it asynchronously in - the Indexer.Fetcher.PolygonZkevm.BridgeL1Tokens module. The addresses are - taken from the `operations` list. - """ - @spec async_import_polygon_zkevm_bridge_l1_tokens(map()) :: :ok - def async_import_polygon_zkevm_bridge_l1_tokens(%{polygon_zkevm_bridge_operations: operations}) do - PolygonZkevmBridgeL1Tokens.async_fetch(operations) + def async_import_celo_epoch_block_operations(%{celo_epochs: epochs}, realtime?) do + CeloEpochBlockOperations.async_fetch(epochs, realtime?) end - def async_import_polygon_zkevm_bridge_l1_tokens(_), do: :ok + def async_import_celo_epoch_block_operations(_, _), do: :ok - def async_import_celo_epoch_block_operations(%{blocks: operations}, realtime?) do - operations - |> Enum.map(&%{block_number: &1.number, block_hash: &1.hash}) - |> CeloEpochBlockOperations.async_fetch(realtime?) + @spec async_import_celo_accounts(map(), boolean()) :: :ok + def async_import_celo_accounts(%{celo_pending_account_operations: celo_pending_account_operations}, realtime?) do + CeloAccount.async_fetch(celo_pending_account_operations, realtime?) end - def async_import_celo_epoch_block_operations(_, _), do: :ok + def async_import_celo_accounts(_, _), do: :ok def async_import_filecoin_addresses_info(%{addresses: addresses}, realtime?) do addresses @@ -570,6 +749,15 @@ defmodule Indexer.Block.Fetcher do def async_import_filecoin_addresses_info(_, _), do: :ok + def async_import_signed_authorizations_statuses( + %{transactions: transactions, signed_authorizations: signed_authorizations}, + realtime? + ) do + SignedAuthorizationStatus.async_fetch(transactions, signed_authorizations, realtime?) + end + + def async_import_signed_authorizations_statuses(_, _), do: :ok + defp block_reward_errors_to_block_numbers(block_reward_errors) when is_list(block_reward_errors) do Enum.map(block_reward_errors, &block_reward_error_to_block_number/1) end @@ -594,7 +782,7 @@ defmodule Indexer.Block.Fetcher do blocks |> Enum.map(fn block -> fetch_beneficiaries_manual(block, block_transactions_map[block.number] || []) end) - |> Enum.reduce(%FetchedBeneficiaries{}, fn params_set, %{params_set: acc_params_set} = acc -> + |> Enum.reduce(%FetchedBeneficiaries{}, fn params_set, %FetchedBeneficiaries{params_set: acc_params_set} = acc -> %FetchedBeneficiaries{acc | params_set: MapSet.union(acc_params_set, params_set)} end) end @@ -789,6 +977,25 @@ defmodule Indexer.Block.Fetcher do end) end + defp process_addresses_nonce(addresses) do + {addresses_excluding_nonce_update, addresses_nonce_update_params} = + Enum.reduce(addresses, {[], []}, fn address, + {addresses_excluding_nonce_update_acc, addresses_nonce_update_params_acc} -> + case Map.get(address, :nonce) do + nil -> + {[address | addresses_excluding_nonce_update_acc], addresses_nonce_update_params_acc} + + nonce -> + {[Map.delete(address, :nonce) | addresses_excluding_nonce_update_acc], + [%{hash: address.hash, nonce: nonce} | addresses_nonce_update_params_acc]} + end + end) + + AddressNonceUpdater.add(addresses_nonce_update_params) + + Enum.reverse(addresses_excluding_nonce_update) + end + # Asynchronously schedules matching of Arbitrum L1-to-L2 messages where the message ID is hashed. @spec async_match_arbitrum_messages_to_l2([map()]) :: :ok defp async_match_arbitrum_messages_to_l2([]), do: :ok @@ -814,4 +1021,17 @@ defmodule Indexer.Block.Fetcher do end) |> List.flatten() end + + defp parse_celo_pending_account_operations(logs) do + if chain_identity() == {:optimism, :celo} do + logs + |> CeloAccountsTransform.parse() + |> Map.take([:accounts, :attestations_fulfilled, :attestations_requested]) + |> Map.values() + |> Enum.concat() + |> Enum.uniq_by(& &1.address_hash) + else + [] + end + end end diff --git a/apps/indexer/lib/indexer/block/fetcher/receipts.ex b/apps/indexer/lib/indexer/block/fetcher/receipts.ex index fd1bef51bace..274944ca7d23 100644 --- a/apps/indexer/lib/indexer/block/fetcher/receipts.ex +++ b/apps/indexer/lib/indexer/block/fetcher/receipts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Fetcher.Receipts do @moduledoc """ Fetches and processes transaction receipts and logs for block indexing. @@ -9,6 +10,7 @@ defmodule Indexer.Block.Fetcher.Receipts do require Logger + alias EthereumJSONRPC.Receipts alias Indexer.Block @doc """ @@ -27,7 +29,7 @@ defmodule Indexer.Block.Fetcher.Receipts do and logs with block numbers added where missing - `{:error, reason}` - Error occurred during fetch or processing """ - @spec fetch(Block.Fetcher.t(), [map()]) :: {:ok, %{logs: [map()], receipts: [map()]}} | {:error, term()} + @spec fetch(Block.Fetcher.t(module()), [map()]) :: {:ok, %{logs: [map()], receipts: [map()]}} | {:error, term()} def fetch(state, transaction_params) def fetch(%Block.Fetcher{} = _state, []), do: {:ok, %{logs: [], receipts: []}} @@ -39,9 +41,26 @@ defmodule Indexer.Block.Fetcher.Receipts do Logger.debug("fetching transaction receipts", count: Enum.count(transaction_params)) stream_opts = [max_concurrency: state.receipts_concurrency, timeout: :infinity] - transaction_params + {block_numbers, filtered_transaction_params} = + if Application.get_env(:ethereum_jsonrpc, :receipts_by_block?) do + split_transaction_params(transaction_params) + else + {[], transaction_params} + end + + filtered_transaction_params |> Enum.chunk_every(state.receipts_batch_size) - |> Task.async_stream(&EthereumJSONRPC.fetch_transaction_receipts(&1, json_rpc_named_arguments), stream_opts) + |> Enum.concat(block_numbers) + |> Task.async_stream( + fn + block_number when is_integer(block_number) -> + Receipts.fetch_by_block_numbers([block_number], json_rpc_named_arguments) + + transactions when is_list(transactions) -> + EthereumJSONRPC.fetch_transaction_receipts(transactions, json_rpc_named_arguments) + end, + stream_opts + ) |> Enum.reduce_while({:ok, %{logs: [], receipts: []}}, fn {:ok, {:ok, %{logs: logs, receipts: receipts}}}, {:ok, %{logs: acc_logs, receipts: acc_receipts}} -> {:cont, {:ok, %{logs: acc_logs ++ logs, receipts: acc_receipts ++ receipts}}} @@ -136,4 +155,18 @@ defmodule Indexer.Block.Fetcher.Receipts do transaction[:hash] == transaction_hash end) end + + defp split_transaction_params(transaction_params) do + max_receipts_by_block = Application.get_env(:ethereum_jsonrpc, :max_receipts_by_block) + + transaction_params + |> Enum.group_by(& &1.block_number) + |> Enum.reduce({[], []}, fn {block_number, transaction_params}, {blocks_acc, transactions_acc} -> + if Enum.count(transaction_params) > max_receipts_by_block do + {blocks_acc, transaction_params ++ transactions_acc} + else + {[block_number | blocks_acc], transactions_acc} + end + end) + end end diff --git a/apps/indexer/lib/indexer/block/realtime/fetcher.ex b/apps/indexer/lib/indexer/block/realtime/fetcher.ex index 496f7cec1489..7b415a255c98 100644 --- a/apps/indexer/lib/indexer/block/realtime/fetcher.ex +++ b/apps/indexer/lib/indexer/block/realtime/fetcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Realtime.Fetcher do @moduledoc """ Fetches and indexes block ranges from latest block forward using a WebSocket. @@ -16,13 +17,15 @@ defmodule Indexer.Block.Realtime.Fetcher do async_import_blobs: 2, async_import_block_rewards: 2, async_import_celo_epoch_block_operations: 2, + async_import_celo_accounts: 2, async_import_created_contract_codes: 2, async_import_filecoin_addresses_info: 2, async_import_internal_transactions: 2, - async_import_polygon_zkevm_bridge_l1_tokens: 1, async_import_realtime_coin_balances: 1, async_import_replaced_transactions: 2, + async_import_signed_authorizations_statuses: 2, async_import_token_balances: 2, + async_import_current_token_balances: 2, async_import_token_instances: 1, async_import_tokens: 2, async_import_uncles: 2, @@ -34,7 +37,7 @@ defmodule Indexer.Block.Realtime.Fetcher do alias Explorer.Chain alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Chain.Events.Publisher - alias Explorer.Utility.MissingRangesManipulator + alias Explorer.Utility.MissingBlockRange alias Indexer.{Block, Tracer} alias Indexer.Block.Realtime.TaskSupervisor alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand @@ -59,13 +62,7 @@ defmodule Indexer.Block.Realtime.Fetcher do last_realtime_blocks: %{} @type t :: %__MODULE__{ - block_fetcher: %Block.Fetcher{ - broadcast: term(), - callback_module: __MODULE__, - json_rpc_named_arguments: EthereumJSONRPC.json_rpc_named_arguments(), - receipts_batch_size: pos_integer(), - receipts_concurrency: pos_integer() - }, + block_fetcher: Block.Fetcher.t(__MODULE__), subscription: Subscription.t(), previous_number: pos_integer() | nil, timer: reference(), @@ -397,21 +394,6 @@ defmodule Indexer.Block.Realtime.Fetcher do Indexer.Fetcher.Optimism.Withdrawal.remove(reorg_block_number) end - # Removes all rows from `polygon_edge_withdrawals` and `polygon_edge_deposit_executes` tables - # previously written starting from the reorg block number - defp do_remove_assets_by_number(:polygon_edge, reorg_block) do - # credo:disable-for-lines:2 Credo.Check.Design.AliasUsage - Indexer.Fetcher.PolygonEdge.Withdrawal.remove(reorg_block) - Indexer.Fetcher.PolygonEdge.DepositExecute.remove(reorg_block) - end - - # Removes all rows from `polygon_zkevm_bridge` table - # previously written starting from the reorg block number - defp do_remove_assets_by_number(:polygon_zkevm, reorg_block) do - # credo:disable-for-next-line Credo.Check.Design.AliasUsage - Indexer.Fetcher.PolygonZkevm.BridgeL2.reorg_handle(reorg_block) - end - # Removes all rows from `shibarium_bridge` table # previously written starting from the reorg block number defp do_remove_assets_by_number(:shibarium, reorg_block) do @@ -436,12 +418,12 @@ defmodule Indexer.Block.Realtime.Fetcher do {fetch_duration, result} = :timer.tc(fn -> fetch_and_import_range(block_fetcher, block_number_to_fetch..block_number_to_fetch) end) - Prometheus.Instrumenter.block_full_process(fetch_duration, __MODULE__) + Prometheus.Instrumenter.set_block_full_process(fetch_duration, __MODULE__) case result do {:ok, %{inserted: inserted, errors: []}} -> log_import_timings(inserted, fetch_duration, time_before) - MissingRangesManipulator.clear_batch([block_number_to_fetch..block_number_to_fetch]) + MissingBlockRange.clear_batch([block_number_to_fetch..block_number_to_fetch]) Logger.debug("Fetched and imported.") {:ok, %{inserted: _, errors: [_ | _] = errors}} -> @@ -454,7 +436,7 @@ defmodule Indexer.Block.Realtime.Fetcher do end) {:error, {:import = step, [%Changeset{} | _] = changesets}} -> - Prometheus.Instrumenter.import_errors() + Prometheus.Instrumenter.set_import_errors_count() params = %{ changesets: changesets, @@ -479,7 +461,7 @@ defmodule Indexer.Block.Realtime.Fetcher do end {:error, {:import = step, reason}} -> - Prometheus.Instrumenter.import_errors() + Prometheus.Instrumenter.set_import_errors_count() Logger.error(fn -> inspect(reason) end, step: step) {:error, {step, reason}} -> @@ -510,7 +492,7 @@ defmodule Indexer.Block.Realtime.Fetcher do defp log_import_timings(%{blocks: [%{number: number, timestamp: timestamp}]}, fetch_duration, time_before) do node_delay = Timex.diff(time_before, timestamp, :seconds) - Prometheus.Instrumenter.node_delay(node_delay) + Prometheus.Instrumenter.set_json_rpc_node_delay(node_delay) Logger.debug("Block #{number} fetching duration: #{fetch_duration / 1_000_000}s. Node delay: #{node_delay}s.", fetcher: :block_import_timings @@ -552,12 +534,14 @@ defmodule Indexer.Block.Realtime.Fetcher do async_import_internal_transactions(imported, realtime?) async_import_tokens(imported, realtime?) async_import_token_balances(imported, realtime?) + async_import_current_token_balances(imported, realtime?) async_import_token_instances(imported) async_import_uncles(imported, realtime?) async_import_replaced_transactions(imported, realtime?) async_import_blobs(imported, realtime?) - async_import_polygon_zkevm_bridge_l1_tokens(imported) async_import_celo_epoch_block_operations(imported, realtime?) + async_import_celo_accounts(imported, realtime?) async_import_filecoin_addresses_info(imported, realtime?) + async_import_signed_authorizations_statuses(imported, realtime?) end end diff --git a/apps/indexer/lib/indexer/block/realtime/supervisor.ex b/apps/indexer/lib/indexer/block/realtime/supervisor.ex index 9a3d3b184a56..8795b4a8665b 100644 --- a/apps/indexer/lib/indexer/block/realtime/supervisor.ex +++ b/apps/indexer/lib/indexer/block/realtime/supervisor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Realtime.Supervisor do @moduledoc """ Supervises realtime block fetcher. @@ -5,16 +6,22 @@ defmodule Indexer.Block.Realtime.Supervisor do use Supervisor + alias Indexer.Block.Fetcher + def start_link([arguments, gen_server_options]) do Supervisor.start_link(__MODULE__, arguments, gen_server_options) end @impl Supervisor - def init(%{block_fetcher: block_fetcher, subscribe_named_arguments: subscribe_named_arguments}) do + def init(%{block_fetcher: %Fetcher{} = block_fetcher, subscribe_named_arguments: subscribe_named_arguments}) do + block_fetcher_with_task_supervisor = + %Fetcher{block_fetcher | task_supervisor: Indexer.Block.Realtime.TaskSupervisor} + children = case Keyword.fetch!(subscribe_named_arguments, :transport) do EthereumJSONRPC.WebSocket -> - transport_options = + %EthereumJSONRPC.WebSocket{} = + transport_options = struct!(EthereumJSONRPC.WebSocket, Keyword.fetch!(subscribe_named_arguments, :transport_options)) web_socket = Indexer.Block.Realtime.WebSocket @@ -29,7 +36,10 @@ defmodule Indexer.Block.Realtime.Supervisor do {EthereumJSONRPC.WebSocket.Supervisor, transport_options}, {Indexer.Block.Realtime.Fetcher, [ - %{block_fetcher: block_fetcher, subscribe_named_arguments: block_fetcher_subscribe_named_arguments}, + %{ + block_fetcher: block_fetcher_with_task_supervisor, + subscribe_named_arguments: block_fetcher_subscribe_named_arguments + }, [name: Indexer.Block.Realtime.Fetcher] ]} ] @@ -39,7 +49,7 @@ defmodule Indexer.Block.Realtime.Supervisor do {Task.Supervisor, name: Indexer.Block.Realtime.TaskSupervisor}, {Indexer.Block.Realtime.Fetcher, [ - %{block_fetcher: block_fetcher, subscribe_named_arguments: nil}, + %{block_fetcher: block_fetcher_with_task_supervisor, subscribe_named_arguments: nil}, [name: Indexer.Block.Realtime.Fetcher] ]} ] diff --git a/apps/indexer/lib/indexer/bound_interval.ex b/apps/indexer/lib/indexer/bound_interval.ex index 3ccfdfd2969c..79dd3847f881 100644 --- a/apps/indexer/lib/indexer/bound_interval.ex +++ b/apps/indexer/lib/indexer/bound_interval.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.BoundInterval do @moduledoc """ An interval for `Process.send_after` that is restricted to being between a `minimum` and `maximum` value diff --git a/apps/indexer/lib/indexer/bridged_tokens/calc_lp_tokens_total_liquidity.ex b/apps/indexer/lib/indexer/bridged_tokens/calc_lp_tokens_total_liquidity.ex index fabf22e75dcf..5c4842f057e5 100644 --- a/apps/indexer/lib/indexer/bridged_tokens/calc_lp_tokens_total_liquidity.ex +++ b/apps/indexer/lib/indexer/bridged_tokens/calc_lp_tokens_total_liquidity.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.BridgedTokens.CalcLpTokensTotalLiquidity do @moduledoc """ Periodically updates LP tokens total liquidity diff --git a/apps/indexer/lib/indexer/bridged_tokens/set_amb_bridged_metadata_for_tokens.ex b/apps/indexer/lib/indexer/bridged_tokens/set_amb_bridged_metadata_for_tokens.ex index 02fcaa970502..86bd88e6a7a5 100644 --- a/apps/indexer/lib/indexer/bridged_tokens/set_amb_bridged_metadata_for_tokens.ex +++ b/apps/indexer/lib/indexer/bridged_tokens/set_amb_bridged_metadata_for_tokens.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.BridgedTokens.SetAmbBridgedMetadataForTokens do @moduledoc """ Sets token metadata for bridged tokens from AMB extensions. diff --git a/apps/indexer/lib/indexer/bridged_tokens/set_omni_bridged_metadata_for_tokens.ex b/apps/indexer/lib/indexer/bridged_tokens/set_omni_bridged_metadata_for_tokens.ex index e6ba7afc3cb2..b55a97f47053 100644 --- a/apps/indexer/lib/indexer/bridged_tokens/set_omni_bridged_metadata_for_tokens.ex +++ b/apps/indexer/lib/indexer/bridged_tokens/set_omni_bridged_metadata_for_tokens.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.BridgedTokens.SetOmniBridgedMetadataForTokens do @moduledoc """ Periodically checks unprocessed tokens and sets bridged status. diff --git a/apps/indexer/lib/indexer/buffered_task.ex b/apps/indexer/lib/indexer/buffered_task.ex index a8d055de0265..9e82aa307e01 100644 --- a/apps/indexer/lib/indexer/buffered_task.ex +++ b/apps/indexer/lib/indexer/buffered_task.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.BufferedTask do @moduledoc """ Provides a behaviour for batched task running with retries and memory-aware buffering. @@ -315,6 +316,7 @@ defmodule Indexer.BufferedTask do :max_batch_size ] defstruct init_task: nil, + init_task_delay: 0, flush_timer: nil, callback_module: nil, callback_module_state: nil, @@ -332,8 +334,10 @@ defmodule Indexer.BufferedTask do @typedoc """ BufferedTask struct: * `init_task` - reference to the initial streaming task. This field holds the - `Task.t()` for the initial data population process. It's used to track the + `reference()` for the initial data population process. It's used to track the completion of the initial data streaming. + * `init_task_delay` - delay between running init tasks. + It increases if the last init task added nothing to queue and resets to 0 otherwise. * `flush_timer` - reference to the timer for periodic buffer flushing. This field stores the timer reference returned by `Process.send_after/3`, which is scheduled using the `flush_interval`. When the timer triggers, it sends @@ -385,7 +389,8 @@ defmodule Indexer.BufferedTask do results and retries. """ @type t :: %__MODULE__{ - init_task: Task.t() | :complete | nil, + init_task: reference() | :complete | :delay | nil, + init_task_delay: non_neg_integer(), flush_timer: reference() | nil, callback_module: module(), callback_module_state: term(), @@ -557,13 +562,16 @@ defmodule Indexer.BufferedTask do ## Parameters - `server`: The name or PID of the BufferedTask process. + - `current_front_buffer?`: If `true`, includes entries in the front buffer + in the total count; if `false`, only includes entries in the regular buffer + and the processing queue. ## Returns A map with keys `:buffer` (total entries count) and `:tasks` (active tasks count). """ - @spec debug_count(GenServer.name()) :: %{buffer: non_neg_integer(), tasks: non_neg_integer()} - def debug_count(server) do - GenServer.call(server, :debug_count) + @spec debug_count(GenServer.name(), boolean()) :: %{buffer: non_neg_integer(), tasks: non_neg_integer()} + def debug_count(server, current_front_buffer? \\ true) do + GenServer.call(server, {:debug_count, current_front_buffer?}) end @doc """ @@ -693,6 +701,13 @@ defmodule Indexer.BufferedTask do {:noreply, flush(state)} end + # Handles graceful shutdown. A fetcher implementing BufferedTask behaviour + # can invoke `Process.send(__MODULE__, :shutdown, [])` to shutdown itself. + # Its `restart` configuration must be set to `:transient`. + def handle_info(:shutdown, state) do + {:stop, :shutdown, state} + end + # Handles the successful completion of the initial streaming task. def handle_info({ref, :ok}, %__MODULE__{init_task: ref} = state) do {:noreply, state} @@ -708,7 +723,7 @@ defmodule Indexer.BufferedTask do # Handles the successful completion of a task processing queue data, updated the # callback module state, removes the reference to the task, and triggers processing # of the next batch if queue contains data. - def handle_info({ref, {:ok, new_callback_module_state}}, state) do + def handle_info({ref, {:ok, new_callback_module_state}}, %__MODULE__{} = state) do {:noreply, drop_task(%__MODULE__{state | callback_module_state: new_callback_module_state}, ref)} end @@ -716,6 +731,7 @@ defmodule Indexer.BufferedTask do # is added back to the queue and processing of the next batch is triggered. # Useful when all data from the batch needs to be reprocessed. def handle_info({ref, :retry}, state) do + Logger.debug("Retrying batch with ref #{inspect(ref)}") {:noreply, drop_task_and_retry(state, ref)} end @@ -724,6 +740,7 @@ defmodule Indexer.BufferedTask do # the next batch is triggered. Useful when only part of the original batch # needs to be reprocessed. def handle_info({ref, {:retry, retryable_entries}}, state) do + Logger.debug("Retrying batch with ref #{inspect(ref)} and specific entries #{inspect(retryable_entries)}") {:noreply, drop_task_and_retry(state, ref, retryable_entries)} end @@ -733,14 +750,25 @@ defmodule Indexer.BufferedTask do # the next batch is triggered. # If all entries are needed to be retried, the `retryable_entries` should # be `nil`. - def handle_info({ref, {:retry, retryable_entries, new_callback_module_state}}, state) do + def handle_info({ref, {:retry, retryable_entries, new_callback_module_state}}, %__MODULE__{} = state) do + Logger.debug("Retrying batch with ref #{inspect(ref)} and specific entries #{inspect(retryable_entries)}") + {:noreply, drop_task_and_retry(%__MODULE__{state | callback_module_state: new_callback_module_state}, ref, retryable_entries)} end # Handles the normal termination of the initial streaming task, marking it as complete. - def handle_info({:DOWN, ref, :process, _pid, :normal}, %__MODULE__{init_task: ref} = state) do - {:noreply, %__MODULE__{state | init_task: :complete}} + def handle_info( + {:DOWN, ref, :process, _pid, :normal}, + %__MODULE__{init_task: ref, bound_queue: %{size: size}} = state + ) do + init_task_delay = + case size do + 0 -> increased_delay() + _ -> 0 + end + + {:noreply, %__MODULE__{state | init_task: :complete, init_task_delay: init_task_delay}} end # Handles the normal termination of a non-initial task, no action needed. @@ -751,6 +779,7 @@ defmodule Indexer.BufferedTask do # Handles abnormal termination of a task processing queue data. The task's batch # is re-added to the queue and processing of the next batch is triggered. def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do + Logger.debug("Task crashed, retrying batch with ref #{inspect(ref)}") {:noreply, drop_task_and_retry(state, ref)} end @@ -772,7 +801,7 @@ defmodule Indexer.BufferedTask do # Returns a count of entries in buffers and queue, and the number of active tasks. # This is useful for monitoring and debugging the BufferedTask's internal state. def handle_call( - :debug_count, + {:debug_count, current_front_buffer?}, _from, %__MODULE__{ current_buffer: current_buffer, @@ -782,7 +811,14 @@ defmodule Indexer.BufferedTask do task_ref_to_batch: task_ref_to_batch } = state ) do - count = length(current_buffer) + length(current_front_buffer) + Enum.count(bound_queue) * max_batch_size + current_front_buffer_count = + if current_front_buffer? do + length(current_front_buffer) + else + 0 + end + + count = length(current_buffer) + current_front_buffer_count + Enum.count(bound_queue) * max_batch_size {:reply, %{buffer: count, tasks: Enum.count(task_ref_to_batch)}, state} end @@ -863,7 +899,7 @@ defmodule Indexer.BufferedTask do # Updated state after removing the task and potentially spawning a new data # portion for processing. @spec drop_task(t(), reference()) :: t() - defp drop_task(state, ref) do + defp drop_task(%__MODULE__{} = state, ref) do spawn_next_batch(%__MODULE__{state | task_ref_to_batch: Map.delete(state.task_ref_to_batch, ref)}) end @@ -920,12 +956,12 @@ defmodule Indexer.BufferedTask do defp buffer_entries(state, [], _front?), do: state @spec buffer_entries(t(), nonempty_list(), true) :: t() - defp buffer_entries(state, entries, true) do + defp buffer_entries(%__MODULE__{} = state, entries, true) do %__MODULE__{state | current_front_buffer: [entries | state.current_front_buffer]} end @spec buffer_entries(t(), nonempty_list(), false) :: t() - defp buffer_entries(state, entries, false) do + defp buffer_entries(%__MODULE__{} = state, entries, false) do %__MODULE__{state | current_buffer: [entries | state.current_buffer]} end @@ -951,7 +987,7 @@ defmodule Indexer.BufferedTask do # ## Returns # - Updated `state` with the new `init_task` reference and scheduled buffer flush. @spec do_initial_stream(%__MODULE__{ - init_task: Task.t() | :complete | nil, + init_task: reference() | :complete | :delay | nil, callback_module: module(), max_batch_size: pos_integer(), task_supervisor: GenServer.name(), @@ -1217,9 +1253,13 @@ defmodule Indexer.BufferedTask do bound_queue: BoundQueue.t(term()), task_ref_to_batch: %{reference() => [term(), ...]} }) :: t() - defp schedule_next(%__MODULE__{poll: true, bound_queue: %BoundQueue{size: 0}, task_ref_to_batch: tasks} = state) - when tasks == %{} do - do_initial_stream(state) + defp schedule_next( + %__MODULE__{poll: true, init_task: init_task, bound_queue: %BoundQueue{size: 0}, task_ref_to_batch: tasks} = + state + ) + when tasks == %{} and init_task in [:complete, nil] do + Process.send_after(self(), :initial_stream, state.init_task_delay) + schedule_next_buffer_flush(%{state | init_task: :delay}) end defp schedule_next(%__MODULE__{} = state) do @@ -1239,7 +1279,7 @@ defmodule Indexer.BufferedTask do # - The updated state with the new flush_timer if a flush was scheduled, # or the unchanged state if flush_interval is :infinity. @spec schedule_next_buffer_flush(%__MODULE__{flush_interval: timeout() | :infinity}) :: t() - defp schedule_next_buffer_flush(state) do + defp schedule_next_buffer_flush(%__MODULE__{} = state) do if state.flush_interval == :infinity do state else @@ -1389,4 +1429,6 @@ defmodule Indexer.BufferedTask do |> push_front(front_entries) |> flush() end + + defp increased_delay, do: Application.get_env(:indexer, :fetcher_init_delay) end diff --git a/apps/indexer/lib/indexer/fetcher.ex b/apps/indexer/lib/indexer/fetcher.ex index fc9851da862e..dc7e6c70af35 100644 --- a/apps/indexer/lib/indexer/fetcher.ex +++ b/apps/indexer/lib/indexer/fetcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher do @moduledoc """ General fetcher infrastructure. diff --git a/apps/indexer/lib/indexer/fetcher/address_importer.ex b/apps/indexer/lib/indexer/fetcher/address_importer.ex new file mode 100644 index 000000000000..353f126e2e4e --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/address_importer.ex @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.AddressImporter do + @moduledoc """ + Periodically updates addresses accumulated from block fetcher + """ + + use GenServer + + require Logger + + alias Explorer.Chain + alias Explorer.Chain.Cache.Accounts + alias Indexer.Block.Fetcher + + @default_update_interval :timer.minutes(1) + + def child_spec(_) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, []}, + type: :worker, + restart: :permanent, + shutdown: Application.get_env(:indexer, :graceful_shutdown_period) + } + end + + def start_link do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + Process.flag(:trap_exit, true) + schedule_next_update() + + {:ok, %{}} + end + + def add(addresses_params) do + GenServer.cast(__MODULE__, {:add, addresses_params}) + end + + def handle_cast({:add, addresses_params}, state) do + result_state = + Enum.reduce(addresses_params, state, fn new_address, acc -> + Map.update(acc, new_address.hash, new_address, fn old_address -> + old_address + |> process_contract_code(new_address) + |> process_nonce(new_address) + end) + end) + + {:noreply, result_state} + end + + def handle_info(:update, addresses_map) do + Logger.info("AddressImporter importing #{Enum.count(addresses_map)} addresses") + result_state = do_update(addresses_map) + schedule_next_update() + {:noreply, result_state} + rescue + exception -> + error = Exception.format(:error, exception, __STACKTRACE__) + log_error(error) + schedule_next_update() + + {:noreply, addresses_map} + end + + def handle_info({_ref, _result}, state) do + {:noreply, state} + end + + def handle_info({:EXIT, _pid, :normal}, state) do + {:noreply, state} + end + + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + do_update(state) + end + + defp do_update(addresses_map) when addresses_map == %{}, do: addresses_map + + defp do_update(addresses_map) do + addresses_params = Map.values(addresses_map) + + case Chain.import(%{addresses: %{params: addresses_params}, timeout: :infinity}) do + {:ok, imported} -> + Accounts.drop(imported[:addresses]) + Fetcher.async_import_filecoin_addresses_info(imported, false) + Logger.info("AddressImporter imported #{Enum.count(addresses_map)} addresses") + + %{} + + error -> + log_error(inspect(error)) + addresses_map + end + end + + defp process_contract_code(old_address, %{contract_code: contract_code}) when not is_nil(contract_code) do + Map.put(old_address, :contract_code, contract_code) + end + + defp process_contract_code(old_address, _new_address), do: old_address + + defp process_nonce(old_address, new_address) do + old_nonce = old_address[:nonce] + new_nonce = new_address[:nonce] + + if not is_nil(new_nonce) and (is_nil(old_nonce) or new_nonce > old_nonce) do + Map.put(old_address, :nonce, new_nonce) + else + old_address + end + end + + defp schedule_next_update do + Process.send_after(self(), :update, @default_update_interval) + end + + defp log_error(error) do + Logger.error("Failed to update addresses: #{error}, retrying") + end +end diff --git a/apps/indexer/lib/indexer/fetcher/address_nonce_updater.ex b/apps/indexer/lib/indexer/fetcher/address_nonce_updater.ex new file mode 100644 index 000000000000..a03d56be7eb5 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/address_nonce_updater.ex @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.AddressNonceUpdater do + @moduledoc """ + Periodically updates addresses nonce + """ + + use GenServer + + require Logger + + alias Explorer.Chain + + @default_update_interval :timer.seconds(10) + + def child_spec(_) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, []}, + type: :worker, + restart: :permanent, + shutdown: Application.get_env(:indexer, :graceful_shutdown_period) + } + end + + def start_link do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + schedule_next_update() + + {:ok, %{}} + end + + def add(addresses_params) do + GenServer.cast(__MODULE__, {:add, addresses_params}) + end + + def handle_cast({:add, addresses_params}, state) do + params_map = Map.new(addresses_params, fn address -> {address.hash, address} end) + + result_state = + Map.merge(state, params_map, fn _hash, old_address, new_address -> + if new_address.nonce > old_address.nonce, do: new_address, else: old_address + end) + + {:noreply, result_state} + end + + def handle_info(:update, addresses_map) do + addresses_params = Map.values(addresses_map) + + result_state = + case Chain.import(%{addresses: %{params: addresses_params}, timeout: :infinity}) do + {:ok, _} -> + %{} + + error -> + log_error(inspect(error)) + addresses_map + end + + schedule_next_update() + + {:noreply, result_state} + rescue + exception -> + error = Exception.format(:error, exception, __STACKTRACE__) + log_error(error) + schedule_next_update() + + {:noreply, addresses_map} + end + + def handle_info({_ref, _result}, state) do + {:noreply, state} + end + + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do + {:noreply, state} + end + + defp schedule_next_update do + Process.send_after(self(), :update, @default_update_interval) + end + + defp log_error(error) do + Logger.error("Failed to update addresses nonce: #{error}, retrying") + end +end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/da/README.md b/apps/indexer/lib/indexer/fetcher/arbitrum/da/README.md new file mode 100644 index 000000000000..8b97b96cae4f --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/da/README.md @@ -0,0 +1,539 @@ +# Data Availability Solution Extension Guide for Arbitrum/Orbit Rollups + +This guide provides a comprehensive list of all files that need to be touched when extending the Blockscout indexer for Arbitrum-based (Orbit) rollups with support for new Data Availability (DA) solutions or updating existing DA solutions to newer versions. + +## Overview + +This document outlines the systematic approach required to add new DA solutions or update existing ones for Arbitrum-based rollups. The implementation typically follows a multi-phase approach to ensure stability and proper functionality. + +## Prerequisites for New DA Solution Implementation + +Before implementing support for a new DA solution, developers must research and understand the following requirements: + +### 1. Contract Method ABI +**Requirement**: Complete ABI definition of the method used to commit Orbit batches to the base (L1) layer. + +**Examples**: +- `addSequencerL2BatchFromEigenDA(uint256 sequenceNumber, EigenDACert calldata cert, ...)` +- `addSequencerL2BatchFromOrigin(uint256 sequenceNumber, bytes calldata data, ...)` + +**Investigation needed**: Contract source code, deployment documentation, or ABI extraction from verified contracts. + +### 2. DA Data Encoding Logic +**Requirement**: Understanding how DA-related data is encoded within the contract method parameters. + +**Two patterns**: +- **Independent encoding**: DA data is part of generic `data` parameter (like in `addSequencerL2BatchFromOrigin`) +- **Explicit encoding**: DA data has dedicated parameter with specific structure (like `cert` in `addSequencerL2BatchFromEigenDA`) + +**Investigation needed**: Contract implementation details, DA solution documentation, example transactions. + +### 3. Data Storage Requirements +**Requirement**: Determine which DA information must be decoded/accessible vs. stored as binary blobs. + +**Decoded information** (for API display): +- **Celestia**: Block height, transaction commitment +- **AnyTrust**: Data hash, timeout, signers mask, keyset information +- **EigenDA**: Blob metadata, certificate details + +**Binary blob storage** (for completeness): +- **Celestia**: `raw` field containing complete blob descriptor +- **EigenDA**: Blob header and verification proof as encoded bytes + +**Investigation needed**: What information do rollup operators and users need to see in the explorer UI? + +### 4. Supplementary Data Collection +**Requirement**: Identify any additional information that must be collected separately from the batch committing transaction. + +**Example**: AnyTrust keyset data +- **Primary DA record**: Certificate with keyset hash reference +- **Supplementary record**: Complete keyset details (threshold, committee member public keys) +- **Collection method**: Separate L1 event logs (`SetValidKeyset` events) + +**Investigation needed**: Dependencies, cross-references, external data sources required for complete DA information. + +### 5. Data Key Strategy +**Requirement**: Define how to uniquely identify DA records and link them with external explorers. + +**Strategies**: +- **Celestia**: Hash of (block height + transaction commitment) - enables `/da/celestia/:height/:commitment` endpoints +- **AnyTrust**: Direct data hash usage - enables `/da/anytrust/:data_hash` endpoints +- **EigenDA**: Hash of blob header - enables `/da/eigenda/:data_hash` endpoints + +**Investigation needed**: What identifiers do external DA explorers use? How should backlinks be constructed? + +### 6. Conflict Resolution Strategy +**Requirement**: Define how to handle duplicate DA records with the same identifier. + +**Strategies**: +- **Simple deduplication**: Database record wins (Celestia/EigenDA pattern) +- **Value-based resolution**: Compare fields and keep better record (AnyTrust timeout comparison) +- **Custom logic**: DA-specific rules based on solution characteristics + +**Investigation needed**: Are DA records immutable? Do they have quality metrics or validity periods? + +## Preparation Checklist + +Before starting implementation: +- [ ] Contract ABI obtained and validated +- [ ] DA encoding/decoding logic understood +- [ ] Data storage requirements defined +- [ ] Supplementary data sources identified +- [ ] External explorer integration requirements clarified +- [ ] Conflict resolution strategy determined +- [ ] Test data and scenarios prepared + +## Background: Understanding the Data Flow + +To understand why specific files need modification, it's essential to understand how batch data flows through the Blockscout indexer and how Data Availability (DA) information is processed, stored, and served. + +### Batch Discovery and Processing Flow + +The core batch indexing process follows this flow: + +```plaintext +Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery.perform + └─ Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery.handle_batches_from_logs + └─ Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery.execute_transaction_requests_parse_transactions_calldata + ├─ Indexer.Fetcher.Arbitrum.Utils.Rpc.parse_calldata_of_add_sequencer_l2_batch + └─ Indexer.Fetcher.Arbitrum.DA.Common.examine_batch_accompanying_data + └─ Indexer.Fetcher.Arbitrum.DA.Common.parse_data_availability_info + └─ Indexer.Fetcher.Arbitrum.DA.{Celestia,Anytrust,Eigenda}.parse_batch_accompanying_data +``` + +**Why this matters**: Each new DA solution requires updates at multiple points in this flow: + +1. **RPC Layer**: `parse_calldata_of_add_sequencer_l2_batch` must recognize new method selectors and decode new transaction structures +2. **DA Common Layer**: `examine_batch_accompanying_data` must handle new DA type identifiers and route to appropriate parsers +3. **DA Specific Layer**: New DA modules must implement parsing logic for their specific data structures + +### Data Import and Storage Flow + +After parsing, DA information follows this import flow: + +```plaintext +Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery.execute_transaction_requests_parse_transactions_calldata + └─ Indexer.Fetcher.Arbitrum.DA.Common.required_import? + └─ Indexer.Fetcher.Arbitrum.DA.Common.prepare_for_import + └─ Indexer.Fetcher.Arbitrum.DA.{Celestia,Anytrust,Eigenda}.prepare_for_import + ├─ [AnyTrust] check_if_new_keyset → get_keyset_info_from_l1 (RPC calls) + └─ [Others] Direct data preparation + └─ Indexer.Fetcher.Arbitrum.DA.Common.eliminate_conflicts + └─ Indexer.Fetcher.Arbitrum.DA.Common.process_records + └─ Indexer.Fetcher.Arbitrum.DA.{Celestia,Anytrust,Eigenda}.resolve_conflict +``` + +**Why this matters**: Each DA solution has different parsing and storage requirements: + +1. **Parsing Approaches**: + - **Celestia/AnyTrust**: Extract DA info during input parameter parsing + - **EigenDA**: Full ABI decoding of complex nested structures + +2. **Storage Requirements**: Not all DA information requires database storage + - **Celestia/EigenDA**: Basic display information only + - **AnyTrust**: Comprehensive data including committee member details and keysets + +3. **Supplementary Data Collection**: Some DA solutions require additional data gathering during `prepare_for_import` + - **AnyTrust**: Fetch keyset details via separate L1 RPC calls to retrieve `SetValidKeyset` events + - **Complex operations**: May involve multiple RPC requests, event log parsing, or third-party data provider communication + - **Performance impact**: Can significantly increase import processing time + +4. **Data Import Logic**: Each DA solution creates specific database records that must be prepared and conflict-resolved + +### API Rendering and Serving Flow + +When serving batch information via API, the flow varies by DA solution complexity: + +```plaintext +BlockScoutWeb.API.V2.ArbitrumController.batch + └─ Explorer.Chain.Arbitrum.Reader.API.Settlement.batch + └─ BlockScoutWeb.API.V2.ArbitrumView.render("arbitrum_batch.json", %{batch: batch}) + └─ BlockScoutWeb.API.V2.ArbitrumView.add_da_info + ├─ generate_celestia_da_info (basic blob info) + ├─ generate_anytrust_certificate (certificate + keyset lookup) + │ └─ Explorer.Chain.Arbitrum.Reader.API.Settlement.get_da_info_by_batch_number + │ └─ Fetch keyset details for committee information + └─ generate_eigen_da_info (basic certificate info) +``` + +**Why this matters**: DA solutions have different rendering complexity: + +1. **Basic Rendering** (Celestia/EigenDA): Simple metadata display + - Height, commitment, blob headers + - Direct mapping from database records + +2. **Complex Rendering** (AnyTrust): Multi-record aggregation + - Certificate data (data_type = 0) + - Keyset information (data_type = 1) for committee details + - Cross-reference between records using keyset hash + +3. **Schema Layer**: API response schemas must include new DA container types + +### Reverse Lookup Flow (Data Hash to Batch) + +DA solutions support different reverse lookup patterns based on their identifier schemes. These endpoints enable **backlink integration** with DA solution explorers (e.g., Celenium for Celestia, EigenDA Blob Explorer) to provide direct links to corresponding batch pages in Blockscout instances. + +**Single-Parameter Lookups** (AnyTrust, EigenDA): +```plaintext +/api/v2/arbitrum/batches/da/{anytrust|eigenda}/:data_hash + └─ BlockScoutWeb.API.V2.ArbitrumController.batch_by_data_availability_info + └─ BlockScoutWeb.API.V2.ArbitrumController.one_batch_by_data_availability_info + └─ Explorer.Chain.Arbitrum.Reader.API.Settlement.get_da_record_by_data_key + └─ BlockScoutWeb.API.V2.ArbitrumController.batch +``` + +**Multi-Parameter Lookups** (Celestia): +```plaintext +/api/v2/arbitrum/batches/da/celestia/:height/:transaction_commitment + └─ calculate_celestia_data_key(height, transaction_commitment) + └─ [same lookup flow as above] +``` + +**Why this matters**: DA solutions have different identifier requirements and integration needs: + +1. **Endpoint Design**: + - **AnyTrust/EigenDA**: Single hash parameter endpoints (`/batches/da/{solution}/:data_hash`) + - **Celestia**: Multi-parameter endpoints (`/batches/da/celestia/:height/:transaction_commitment`) + +2. **External Integration**: Enable DA solution explorers to provide backlinks + - **Celenium (Celestia Explorer)**: Links from blob data to corresponding Arbitrum batch + - **EigenDA Blob Explorer**: Links from blob certificates to rollup batch information + - **AnyTrust explorers**: Links from data hash to batch details + +3. **Data Key Calculation**: Helper functions must compute correct database keys + - **AnyTrust**: Direct data hash usage + - **Celestia**: Hash of (height + transaction_commitment) + - **EigenDA**: Hash of blob header + +4. **Database Indexing**: Efficient reverse lookups from computed keys to batch numbers + +### Contract Integration Flow + +The system must decode new contract method calls: + +```plaintext +Smart Contract Method Call (addSequencerL2BatchFrom{DASolution}) + └─ EthereumJSONRPC.Arbitrum.Constants.Contracts.add_sequencer_l2_batch_from_{da_solution}_selector_with_abi + └─ Indexer.Fetcher.Arbitrum.Utils.Rpc.parse_calldata_of_add_sequencer_l2_batch + └─ ABI.TypeDecoder.decode(calldata, abi) +``` + +**Why this matters**: Each new DA solution typically introduces: + +1. **New Contract Methods**: Different function signatures for batch submission +2. **Data Processing Variations**: Some DA solutions return decoded data as-is, others require preprocessing (e.g., EigenDA prepends a header byte flag) +3. **ABI Complexity**: Varies from simple parameters to complex nested structures requiring comprehensive ABI definitions + +### Database Entity Relationships + +DA information uses a flexible multi-purpose storage design: + +```plaintext +arbitrum_l1_batches (batch_container field - indicates DA type) + ├─ arbitrum_da_multi_purpose (main DA storage with type-based records) + │ ├─ DA Records (data_type = 0): certificates, proofs, blob descriptors + │ └─ Supplementary Records (data_type ≠ 0): keysets, additional metadata + └─ arbitrum_batches_to_da_blobs (many-to-one batch-DA relationships) + └─ Multiple batches can reference the same DA record when data is identical +``` + +**Why this matters**: The flexible design supports varying DA solution requirements: + +1. **Multi-Purpose Storage**: `arbitrum_da_multi_purpose` stores JSON data with different `data_type` values + - **Type 0**: Primary DA records (certificates, blob descriptors) + - **Type 1+**: Supplementary records (e.g., AnyTrust keysets) + +2. **Data Key Strategies**: Each DA solution uses different identifier schemes + - **AnyTrust**: Data hash of the certificate + - **Celestia**: Hash of (block height + transaction commitment) + - **EigenDA**: Hash of blob header + +3. **Storage Granularity**: Varies by DA solution based on display requirements + - **Celestia/EigenDA**: Basic information (height, commitment, blob metadata) + - **AnyTrust**: Comprehensive data (certificate + individual committee member contributions) + +4. **Batch Relationships**: `arbitrum_batches_to_da_blobs` handles multiple batches referencing the same DA record (many-to-one relationship when batches contain identical data) + +This interconnected architecture explains why implementing a new DA solution requires touching multiple layers of the system - from low-level contract parsing to high-level API responses. Each component serves a specific role in the overall data flow, and all must be updated cohesively to maintain system integrity. + +## Complete File List + +### 1. Core DA Module Files + +**Location**: `apps/indexer/lib/indexer/fetcher/arbitrum/da/` + +- `common.ex` - Core DA functionality that handles all DA types +- `{new_da_solution}.ex` - New DA-specific module (e.g., `eigenda.ex`, `celestia.ex`, `anytrust.ex`) + +**Purpose**: +- `common.ex` contains shared functionality for all DA solutions +- Individual DA modules handle parsing, preparation, and conflict resolution for specific DA types + +### 2. Database Schema and Migrations + +**Migration Files**: `apps/explorer/priv/arbitrum/migrations/` + +- `{timestamp}_add_{da_solution}_batches.exs` - Adds new DA container type to enum +- (Reference: `20250731001757_add_eigenda_batches.exs` for EigenDA) +- (Reference: `20240527212653_add_da_info.exs` for original DA types) + +**Schema Files**: `apps/explorer/lib/explorer/chain/arbitrum/` + +- `l1_batch.ex` - Updates batch container enum and type definitions +- `da_multi_purpose_record.ex` - May need helper functions for data key calculation + +**Purpose**: Define new DA container types and update database schema to support them. + +### 3. RPC and Contract Interaction + +**Files**: +- `apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/arbitrum/constants/contracts.ex` +- `apps/indexer/lib/indexer/fetcher/arbitrum/utils/rpc.ex` + +**Purpose**: +- Add new method selectors and ABI definitions +- Update calldata parsing functions to handle new DA methods +- Define contract interaction patterns for new DA solutions + +### 4. Batch Discovery and Processing + +**Files**: +- `apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery.ex` +- `apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/tasks.ex` +- `apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/README.md` (documentation) + +**Purpose**: Handle batch discovery workflow and update documentation for new DA types. + +### 5. API Layer + +#### Controllers +**File**: `apps/block_scout_web/lib/block_scout_web/controllers/api/v2/arbitrum_controller.ex` + +**Purpose**: Handle API endpoints for DA-specific batch lookups (e.g., `/batches/da/{da_solution}/:data_hash`) to enable integration with external DA explorers + +#### Views +**File**: `apps/block_scout_web/lib/block_scout_web/views/api/v2/arbitrum_view.ex` + +**Purpose**: +- Add new DA info generation functions (e.g., `generate_{da_solution}_da_info`) +- Update `add_da_info` function to handle new DA container types +- Implement rendering logic for DA-specific information + +#### Routing +**File**: `apps/block_scout_web/lib/block_scout_web/routers/api_router.ex` + +**Purpose**: Add new API endpoints for DA-specific batch lookups, enabling backlink integration with DA solution explorers (Celenium, EigenDA Blob Explorer, etc.) + +#### API Schemas +**File**: `apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block.ex` + +**Purpose**: Update API response schemas to include new DA container types in enums + +### 6. Configuration and Documentation + +**Files**: +- `cspell.json` - Add new DA solution terms to spell check dictionary +- `apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/README.md` - Update documentation + +## Implementation Phases + +### Phase 1: Basic Support (No Exception) +- Add method selector support in `contracts.ex` +- Update `parse_calldata_of_add_sequencer_l2_batch` in `rpc.ex` +- Return placeholder data to prevent indexing failures + +### Phase 2: Database Schema Extension +- Create migration to add new DA container type to enum +- Update `l1_batch.ex` schema definitions: + - Add new enum value to `batch_container` field definition + - Update type specifications to include new DA container type +- Update type specifications in `common.ex`: + - Add new DA type to `@spec` definitions that enumerate DA container types + - Update documentation comments that list supported DA types + +### Phase 3: DA Certificate/Data Parsing +- Implement appropriate parsing approach for the DA solution: + - **Simple parsing**: Extract basic parameters during input processing + - **Complex parsing**: Full ABI decoding of nested structures +- Create new DA module with parsing logic +- Update `parse_data_availability_info` in `common.ex`: + - Add new header flag case to route to the new DA module + - Ensure the function returns the correct DA type atom + +### Phase 4: Data Preparation and Import +- Implement `prepare_for_import` functionality based on storage requirements: + - **Basic storage**: Simple metadata for display + - **Comprehensive storage**: Multiple record types with supplementary data + - **Supplementary data collection**: Implement additional data gathering if required + - **Example**: AnyTrust keyset fetching via `check_if_new_keyset` → `get_keyset_info_from_l1` + - **RPC operations**: Block number retrieval, event log fetching, data decoding + - **Third-party integration**: May require external data provider APIs + - **Caching strategy**: Implement caching to avoid redundant external calls +- Update `common.ex` import logic: + - Add new DA type to `required_import?` function (if storage is needed) + - Add pattern matching for new DA type in `prepare_for_import` + - Include new DA type in `eliminate_conflicts` function +- Implement conflict resolution for both intra-batch and inter-batch scenarios: + - **Intra-batch deduplication**: Handle multiple batches in same processing chunk referencing same DA blob + - **Inter-batch resolution**: Implement `resolve_conflict/2` function to handle conflicts with existing database records + - **Simple deduplication** (Celestia/EigenDA pattern): Exclude candidates when data_key already exists in database + - **Value-based resolution** (AnyTrust pattern): Compare specific fields (e.g., timeout values) and keep the better record + - **Custom logic**: Define DA-specific rules for handling duplicate data scenarios + +### Phase 5: API Integration +- Add DA-specific rendering functions based on complexity: + - **Simple rendering**: Direct mapping from single database record + - **Complex rendering**: Multi-record aggregation with cross-references +- Update API response generation +- Implement DA-specific data retrieval + +### Phase 6: Endpoint Extension +- Add new API endpoints for DA-specific lookups to enable backlink integration: + - **Single-parameter**: `/batches/da/{solution}/:identifier` (AnyTrust, EigenDA) + - **Multi-parameter**: `/batches/da/{solution}/:param1/:param2` (Celestia) + - **Purpose**: Allow DA solution explorers to link directly to corresponding batch pages +- Update routing configuration + +### Phase 7: Schema Updates +- Update API response schemas +- Add new DA container types to API documentation + +## Key Patterns to Follow + +### 1. DA Module Structure +Each DA solution module should implement: +- `@enforce_keys` and `defstruct` for DA info structure +- `@type t` type definition +- `parse_batch_accompanying_data/2` function +- `prepare_for_import/2` function +- `resolve_conflict/2` function + +### 2. Common DA Integration Points +**Phase 2 Updates (Type Specifications):** +- Update `@spec` definitions in `common.ex` to include new DA container type +- Update documentation comments listing supported DA types + +**Phase 3 Updates (Parsing Logic):** +- Add new header flag case in `parse_data_availability_info` to route to new DA module +- Ensure correct DA type atom is returned + +**Phase 4 Updates (Import Logic):** +- Add new DA type to `required_import?` function based on storage needs +- Include new DA type in `prepare_for_import` pattern matching +- Add conflict resolution support in `eliminate_conflicts` + +### 3. RPC Parsing Patterns +Two main approaches for handling DA data in `parse_calldata_of_add_sequencer_l2_batch`: +```elixir +# Simple approach: return data as-is +{sequence_number, prev_message_count, new_message_count, data} + +# Complex approach: prepend header flag for DA routing +{sequence_number, prev_message_count, new_message_count, <> <> processed_data} +``` + +### 4. Database Migration Pattern +```elixir +def change do + execute("ALTER TYPE arbitrum_da_containers_types ADD VALUE 'in_{da_solution}'") +end +``` + +### 5. API View Pattern +```elixir +case batch.batch_container do + :in_{da_solution} -> generate_{da_solution}_da_info(batch.number) + # ... other cases +end +``` + +### 6. Data Key Calculation +Each DA solution needs a helper function in `da_multi_purpose_record.ex` to calculate the data key used for database storage and lookups. The calculation varies by DA solution: +- **Single-parameter**: Hash of primary identifier +- **Multi-parameter**: Hash of combined parameters + +### 7. Conflict Resolution Patterns +The `resolve_conflict/2` function handles duplicate data keys between database and candidate records. + +**What are these conflicts?** +There are **two types of conflicts** that must be handled: + +**1. Intra-Batch Conflicts** (within current import set): +- **Problem**: Multiple batches in the same processing chunk referencing the same DA blob +- **Issue**: Database uniqueness constraints fail when trying to insert duplicate `data_key` values +- **Solution**: Deduplicate within the current batch before database import +- **Example**: Batches B1 and B2 both reference the same AnyTrust data blob D1 + +**2. Inter-Batch Conflicts** (current vs existing database): +- **Problem**: Current batch references a DA blob that already exists in database +- **Issue**: Without proper handling, new batch associations can overwrite existing ones +- **Solution**: Check existing database records and resolve conflicts appropriately +- **Example**: Previously imported batch B1 with blob D1, now importing batch B2 with same blob D1 + +**Common scenarios**: +- Reprocessing the same batch data during indexer restarts +- Multiple batches referencing identical DA blobs (empty batches scenario) +- Chain reorganizations causing re-import of previously processed DA records + +**Resolution strategies:** + +**Simple Deduplication Pattern** (Celestia/EigenDA): +- Database record always wins - if `data_key` exists in database, exclude the candidate +- Used when DA records are immutable and duplicates are identical + +**Value-Based Resolution Pattern** (AnyTrust): +- Compare specific field values (e.g., timeout) between database and candidate records +- Keep the record with better characteristics (higher timeout = longer data availability) +- Used when DA records can have different quality metrics + +## Testing Considerations + +When implementing a new DA solution, ensure testing covers: +- Batch discovery and parsing +- **Supplementary data collection** (if applicable): + - RPC call handling and error scenarios + - Event log parsing and decoding + - Caching mechanism functionality + - Third-party data provider integration + - Performance impact of additional network calls +- Database import and conflict resolution scenarios: + - **Intra-batch conflicts**: Multiple batches in same processing chunk with same DA blob + - **Inter-batch conflicts**: Current batch vs existing database records + - Value-based conflict resolution (if applicable) + - Multiple batches referencing the same DA record + - Chain reorganization scenarios +- API endpoint functionality +- Schema validation +- Error handling for malformed data + +## Backward Compatibility + +Always ensure that: +- Existing DA solutions continue to work +- Database migrations are non-destructive +- API responses maintain backward compatibility +- New fields are properly nullable where appropriate + +## Dependencies + +New DA solutions may require: +- Additional Elixir dependencies for cryptographic functions +- ABI encoder/decoder updates +- External service integrations for DA layer communication +- **Supplementary data sources**: + - Enhanced RPC node access for event log retrieval + - Third-party data provider APIs for off-chain information + - Caching infrastructure for performance optimization of external calls + +## Implementation Example + +The EigenDA implementation ([PR #12915](https://github.com/blockscout/blockscout/pull/12915)) serves as a concrete example of following this systematic approach. The implementation demonstrates: + +- **Multi-phase rollout**: From basic method support to full API integration +- **Complex ABI handling**: Nested structures with BlobVerificationProof and BlobHeader +- **Header flag usage**: Custom flag (237) for DA type routing +- **Supplementary data**: While EigenDA doesn't require external data collection like AnyTrust keysets, it showcases complex certificate parsing +- **Database design**: Integration with the `arbitrum_batches_to_da_blobs` table structure +- **API endpoints**: Implementation of `/batches/da/eigenda/:data_hash` reverse lookup + +This example illustrates how the abstract patterns described in this guide translate into working code across all the identified file layers. \ No newline at end of file diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/da/anytrust.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/da/anytrust.ex index f42de7202719..1f1c0b3a17aa 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/da/anytrust.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/da/anytrust.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.DA.Anytrust do @moduledoc """ Provides functionality for handling AnyTrust data availability information @@ -362,13 +363,13 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Anytrust do json_rpc_named_arguments ) - if length(logs) > 0 do + if Enum.empty?(logs) do + log_error("No SetValidKeyset logs found in the block #{block_number}") + nil + else log_info("Found #{length(logs)} SetValidKeyset logs") set_valid_keyset_event_parse(List.first(logs)) - else - log_error("No SetValidKeyset logs found in the block #{block_number}") - nil end end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/da/celestia.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/da/celestia.ex index 6f20ded63a59..0cdfd3bc55be 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/da/celestia.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/da/celestia.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.DA.Celestia do @moduledoc """ Provides functionality for parsing and preparing Celestia data availability diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/da/common.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/da/common.ex index 7a334225ecea..a373c572b0b7 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/da/common.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/da/common.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.DA.Common do @moduledoc """ This module provides common functionalities for handling data availability (DA) @@ -6,7 +7,7 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_error: 1] - alias Indexer.Fetcher.Arbitrum.DA.{Anytrust, Celestia} + alias Indexer.Fetcher.Arbitrum.DA.{Anytrust, Celestia, Eigenda} alias Indexer.Fetcher.Arbitrum.Utils.Db.Settlement, as: Db alias Explorer.Chain.Arbitrum @@ -23,15 +24,16 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do ## Returns - `{status, da_type, da_info}` where `da_type` is one of `:in_blob4844`, - `:in_calldata`, `:in_celestia`, `:in_anytrust`, or `nil` if the accompanying - data cannot be parsed or is of an unsupported type. `da_info` contains the DA - info descriptor for Celestia or Anytrust. + `:in_calldata`, `:in_celestia`, `:in_anytrust`, `:in_eigenda`, or `nil` if + the accompanying data cannot be parsed or is of an unsupported type. + `da_info` contains the DA info descriptor for Celestia, Anytrust, or EigenDA. """ @spec examine_batch_accompanying_data(non_neg_integer(), binary()) :: {:ok, :in_blob4844, nil} | {:ok, :in_calldata, nil} | {:ok, :in_celestia, Celestia.t()} | {:ok, :in_anytrust, Anytrust.t()} + | {:ok, :in_eigenda, Eigenda.t()} | {:error, nil, nil} def examine_batch_accompanying_data(batch_number, batch_accompanying_data) do case batch_accompanying_data do @@ -43,9 +45,9 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do @doc """ Prepares data availability (DA) information for import. - This function processes a list of DA information, either from Celestia or Anytrust, - preparing it for database import. It handles deduplication of records within the same - processing chunk and against existing database records. + This function processes a list of DA information, either from Celestia, Anytrust, or + EigenDA, preparing it for database import. It handles deduplication of records + within the same processing chunk and against existing database records. ## Parameters - `da_info`: A list of DA information structs. @@ -58,7 +60,7 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do the current batch and against existing database records. - A list of batch-to-blob associations (`BatchToDaBlob`) ready for import. """ - @spec prepare_for_import([Celestia.t() | Anytrust.t() | map()], %{ + @spec prepare_for_import([Celestia.t() | Anytrust.t() | Eigenda.t() | map()], %{ :sequencer_inbox_address => String.t(), :json_rpc_named_arguments => EthereumJSONRPC.json_rpc_named_arguments() }) :: {[Arbitrum.DaMultiPurposeRecord.to_import()], [Arbitrum.BatchToDaBlob.to_import()]} @@ -69,7 +71,8 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do initial_acc = { %{ celestia: %{}, - anytrust: %{} + anytrust: %{}, + eigenda: %{} }, [], MapSet.new() @@ -105,6 +108,16 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do updated_cache } + %Eigenda{} -> + {updated_records, updated_batches} = + Eigenda.prepare_for_import({da_records_by_type.eigenda, batch_to_blob_acc}, info) + + { + %{da_records_by_type | eigenda: updated_records}, + updated_batches, + cache + } + _ -> {da_records_by_type, batch_to_blob_acc, cache} end @@ -123,19 +136,21 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do # # ## Parameters # - `da_records_by_type`: A map containing candidate records organized by DA type - # (`:celestia` and `:anytrust`), where each type has a map of records keyed by `data_key`. + # (`:celestia`, `:anytrust`, and `:eigenda`), where each type has a map of records keyed by `data_key`. # # ## Returns # - A list of `DaMultiPurposeRecord` records after conflict resolution, ready for import. @spec eliminate_conflicts(%{ celestia: %{binary() => Arbitrum.DaMultiPurposeRecord.to_import()}, - anytrust: %{binary() => Arbitrum.DaMultiPurposeRecord.to_import()} + anytrust: %{binary() => Arbitrum.DaMultiPurposeRecord.to_import()}, + eigenda: %{binary() => Arbitrum.DaMultiPurposeRecord.to_import()} }) :: [Arbitrum.DaMultiPurposeRecord.to_import()] defp eliminate_conflicts(da_records_by_type) do # Define the types and their corresponding resolution modules type_configs = [ {:celestia, da_records_by_type.celestia, &Celestia.resolve_conflict/2}, - {:anytrust, da_records_by_type.anytrust, &Anytrust.resolve_conflict/2} + {:anytrust, da_records_by_type.anytrust, &Anytrust.resolve_conflict/2}, + {:eigenda, da_records_by_type.eigenda, &Eigenda.resolve_conflict/2} ] # Process each type using the same pattern @@ -148,7 +163,7 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do # Processes candidate DA records using a type-specific resolution function. # # This function takes a map of candidate DA records and a resolution function specific - # to the DA type (Celestia or AnyTrust). It fetches any existing records from the + # to the DA type (Celestia, AnyTrust, or EigenDA). It fetches any existing records from the # database with matching data keys and uses the resolution function to determine # which records should be imported. # @@ -188,16 +203,16 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do ## Parameters - `da_type`: The type of data availability, which can be `:in_blob4844`, `:in_calldata`, - `:in_celestia`, `:in_anytrust`, or `nil`. + `:in_celestia`, `:in_anytrust`, `:in_eigenda`, or `nil`. ## Returns - - `true` if the DA type is `:in_celestia` or `:in_anytrust`, indicating that the data - requires import. + - `true` if the DA type is `:in_celestia`, `:in_anytrust`, or `:in_eigenda`, indicating + that the data requires import. - `false` for all other DA types, indicating that the data does not require import. """ - @spec required_import?(:in_blob4844 | :in_calldata | :in_celestia | :in_anytrust | nil) :: boolean() + @spec required_import?(:in_blob4844 | :in_calldata | :in_celestia | :in_anytrust | :in_eigenda | nil) :: boolean() def required_import?(da_type) do - da_type in [:in_celestia, :in_anytrust] + da_type in [:in_celestia, :in_anytrust, :in_eigenda] end # Parses data availability information based on the header flag. @@ -205,6 +220,7 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do {:ok, :in_calldata, nil} | {:ok, :in_celestia, Celestia.t()} | {:ok, :in_anytrust, Anytrust.t()} + | {:ok, :in_eigenda, Eigenda.t()} | {:error, nil, nil} defp parse_data_availability_info(batch_number, << header_flag::size(8), @@ -216,18 +232,26 @@ defmodule Indexer.Fetcher.Arbitrum.DA.Common do {:ok, :in_calldata, nil} 32 -> + # 0x20 log_error("ZERO HEAVY messages are not supported.") {:error, nil, nil} 99 -> + # 0x63 Celestia.parse_batch_accompanying_data(batch_number, rest) 128 -> + # 0x80 Anytrust.parse_batch_accompanying_data(batch_number, rest) 136 -> + # 0x88 Anytrust.parse_batch_accompanying_data(batch_number, rest) + 237 -> + # 0xed + Eigenda.parse_batch_accompanying_data(batch_number, rest) + _ -> log_error("Unknown header flag found during an attempt to parse DA data: #{header_flag}") {:error, nil, nil} diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/da/eigenda.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/da/eigenda.ex new file mode 100644 index 000000000000..8ed92a3c2f0c --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/da/eigenda.ex @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Arbitrum.DA.Eigenda do + @moduledoc """ + Provides functionality for parsing EigenDA data availability information + associated with Arbitrum rollup batches. + """ + + import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_error: 1, log_info: 1] + import Explorer.Chain.Arbitrum.DaMultiPurposeRecord.Helper, only: [calculate_eigenda_data_key: 1] + + alias ABI.{TypeDecoder, TypeEncoder} + alias EthereumJSONRPC.Arbitrum.Constants.Contracts, as: ArbitrumContracts + alias Indexer.Fetcher.Arbitrum.Utils.Helper, as: ArbitrumHelper + + alias Explorer.Chain.Arbitrum + + @enforce_keys [:batch_number, :blob_verification_proof, :blob_header] + defstruct @enforce_keys + + @typedoc """ + EigenDA certificate struct: + * `batch_number` - The batch number in the Arbitrum rollup associated with the + EigenDA certificate. + * `blob_verification_proof` - The encoded binary data of the blob verification proof + containing batchId, blobIndex, BatchMetadata, inclusionProof, and quorumIndices. + * `blob_header` - The encoded binary data of the blob header containing commitment (BN254.G1Point), + dataLength, and quorumBlobParams array. + """ + @type t :: %__MODULE__{ + batch_number: non_neg_integer(), + blob_verification_proof: binary(), + blob_header: binary() + } + + @doc """ + Parses the batch accompanying data for EigenDA. + + This function extracts EigenDA certificate information from the binary input + associated with a given batch number by decoding the full EigenDACert structure, + then encoding the BlobVerificationProof and BlobHeader components back to binary + format for storage. The complex nested structures (BatchMetadata, BN254.G1Point, + QuorumBlobParams, etc.) are preserved in their encoded binary form. + + ## Parameters + - `batch_number`: The batch number in the Arbitrum rollup associated with the EigenDA data. + - `binary_data`: The binary data containing the encoded EigenDA certificate. + + ## Returns + - `{:ok, :in_eigenda, da_info}` if the data is successfully parsed. + - `{:error, nil, nil}` if the data cannot be parsed. + """ + @spec parse_batch_accompanying_data(non_neg_integer(), binary()) :: + {:ok, :in_eigenda, __MODULE__.t()} | {:error, nil, nil} + def parse_batch_accompanying_data(batch_number, binary_data) do + # This function implements a decode->encode pattern that may seem redundant but is + # architecturally necessary for the following reasons: + # + # 1. Data Validation: Decoding first ensures the EigenDA certificate is well-formed + # and contains valid data before storing it in the database. + # 2. Interface Compatibility: The DA pipeline requires individual BlobVerificationProof + # and BlobHeader components as separate binary fields, not as a single combined structure. + # 3. Library Reliability: Using TypeDecoder/TypeEncoder leverages battle-tested ABI + # handling instead of manual binary parsing, reducing the risk of encoding bugs. + # + # Alternative approaches (direct binary extraction) would require reimplementing + # complex ABI parsing logic and handling dynamic arrays/nested structures manually, + # introducing significant complexity and maintenance burden for minimal performance gain. + + # Decode the complex EigenDACert structure to get the tuple components + [{blob_verification_proof_tuple, blob_header_tuple}] = + TypeDecoder.decode( + binary_data, + ArbitrumContracts.eigen_da_cert_abi() + ) + + # Encode each component back to bytes for storage + blob_verification_proof_bytes = + TypeEncoder.encode([blob_verification_proof_tuple], ArbitrumContracts.eigen_da_blob_verification_proof_abi()) + + blob_header_bytes = + TypeEncoder.encode([blob_header_tuple], ArbitrumContracts.eigen_da_blob_header_abi()) + + {:ok, :in_eigenda, + %__MODULE__{ + batch_number: batch_number, + blob_verification_proof: blob_verification_proof_bytes, + blob_header: blob_header_bytes + }} + rescue + exception -> + log_error("Can not parse EigenDA certificate: #{inspect(exception)}") + {:error, nil, nil} + end + + @doc """ + Prepares EigenDA certificate data for import. + + ## Parameters + - A tuple containing: + - A map of already prepared DA records + - A list of already prepared batch-to-blob associations + - `da_info`: The EigenDA certificate struct containing blob header and verification proof. + + ## Returns + - A tuple containing: + - An updated map of `DaMultiPurposeRecord` structures ready for import in the DB, + where `data_key` maps to the record + - An updated list of `BatchToDaBlob` structures ready for import in the DB. + """ + @spec prepare_for_import( + {%{binary() => Arbitrum.DaMultiPurposeRecord.to_import()}, [Arbitrum.BatchToDaBlob.to_import()]}, + __MODULE__.t() + ) :: + {%{binary() => Arbitrum.DaMultiPurposeRecord.to_import()}, [Arbitrum.BatchToDaBlob.to_import()]} + def prepare_for_import({da_records_acc, batch_to_blob_acc}, %__MODULE__{} = da_info) do + blob_header_hex = ArbitrumHelper.bytes_to_hex_str(da_info.blob_header) + blob_verification_proof_hex = ArbitrumHelper.bytes_to_hex_str(da_info.blob_verification_proof) + + data = %{ + blob_header: blob_header_hex, + blob_verification_proof: blob_verification_proof_hex + } + + data_key = calculate_eigenda_data_key(da_info.blob_header) + + # Create record for arbitrum_da_multi_purpose table with batch_number set to nil + da_record = %{ + data_type: 0, + data_key: data_key, + data: data, + # TODO: This field must be removed as soon as migration to a separate table for Batch-to-DA-record associations is completed. + batch_number: nil + } + + # Create record for arbitrum_batches_to_da_blobs table + batch_to_blob_record = %{ + batch_number: da_info.batch_number, + data_blob_id: data_key + } + + # Only add the DA record if it doesn't already exist in the map + updated_da_records = + if Map.has_key?(da_records_acc, data_key) do + log_info("Found duplicate DA record #{ArbitrumHelper.bytes_to_hex_str(data_key)}") + # Record with this data_key already exists, keep existing record + da_records_acc + else + # No duplicate, add the new record + Map.put(da_records_acc, data_key, da_record) + end + + {updated_da_records, [batch_to_blob_record | batch_to_blob_acc]} + end + + @doc """ + Resolves conflicts between existing database records and candidate DA records. + + This function handles deduplication by comparing EigenDA data keys between database + records and candidate records. For EigenDA records, if a record with a matching data_key + already exists in the database, the candidate record is excluded from import. + + ## Parameters + - `db_records`: A list of `Arbitrum.DaMultiPurposeRecord` retrieved from the database + - `candidate_records`: A map where `data_key` maps to `Arbitrum.DaMultiPurposeRecord.to_import()` + + ## Returns + - A list of `Arbitrum.DaMultiPurposeRecord.to_import()` after resolving conflicts + """ + @spec resolve_conflict( + [Arbitrum.DaMultiPurposeRecord.t()], + %{binary() => Arbitrum.DaMultiPurposeRecord.to_import()} + ) :: [Arbitrum.DaMultiPurposeRecord.to_import()] + # When no database records to check against, simply return all candidate records + def resolve_conflict([], candidate_records) do + Map.values(candidate_records) + end + + def resolve_conflict(db_records, candidate_records) do + # Create a set of keys to exclude (those already present in DB) + keys_to_exclude = + Enum.reduce(db_records, MapSet.new(), fn db_record, acc -> + # Any key present in both DB and candidates should be excluded + if Map.has_key?(candidate_records, db_record.data_key) do + log_info("DA record #{ArbitrumHelper.bytes_to_hex_str(db_record.data_key)} already exists in DB") + + MapSet.put(acc, db_record.data_key) + else + acc + end + end) + + # Return only candidate records not in the exclude set + candidate_records + |> Enum.reject(fn {data_key, _record} -> MapSet.member?(keys_to_exclude, data_key) end) + |> Enum.map(fn {_data_key, record} -> record end) + end +end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/data_backfill.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/data_backfill.ex index 86ef3308d2b5..b1c9b66a1540 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/data_backfill.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/data_backfill.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.DataBackfill do @moduledoc """ Manages the backfilling process for Arbitrum-specific block data in a controlled manner @@ -58,7 +59,7 @@ defmodule Indexer.Fetcher.Arbitrum.DataBackfill do def child_spec([init_options, gen_server_options]) do {json_rpc_named_arguments, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless json_rpc_named_arguments do + if !json_rpc_named_arguments do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec` " <> "to allow for json_rpc calls when running." diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/messages_to_l2_matcher.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/messages_to_l2_matcher.ex index 49cfa23c9ee4..afb9f36211e2 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/messages_to_l2_matcher.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/messages_to_l2_matcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.MessagesToL2Matcher do @moduledoc """ Matches and processes L1-to-L2 messages in the Arbitrum protocol. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/messaging.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/messaging.ex index 662a35784cce..286e58313406 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/messaging.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/messaging.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Messaging do @moduledoc """ Provides functionality for filtering and handling messaging between Layer 1 (L1) and Layer 2 (L2) in the Arbitrum protocol. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/rollup_messages_catchup.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/rollup_messages_catchup.ex index e228068814e9..b2349ccaa11c 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/rollup_messages_catchup.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/rollup_messages_catchup.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.RollupMessagesCatchup do @moduledoc """ Manages the catch-up process for historical rollup messages between Layer 1 (L1) diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_batches_statuses.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_batches_statuses.ex index a8818761d166..25d7033e053d 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_batches_statuses.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_batches_statuses.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.TrackingBatchesStatuses do @moduledoc """ Manages the tracking and updating of the statuses of rollup batches, diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_messages_on_l1.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_messages_on_l1.ex index a0bd16bc36b9..725eece520a4 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_messages_on_l1.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/tracking_messages_on_l1.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1 do @moduledoc """ Manages the tracking and processing of new and historical cross-chain messages initiated on L1 for an Arbitrum rollup. @@ -5,63 +6,102 @@ defmodule Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1 do This module is responsible for continuously monitoring and importing new messages initiated from Layer 1 (L1) to Arbitrum's Layer 2 (L2), as well as discovering and processing historical messages that were sent previously but have not yet - been processed. - - The fetcher's operation is divided into 3 phases, each initiated by sending - specific messages: - - `:init_worker`: Initializes the worker with the required configuration for message - tracking. - - `:check_new_msgs_to_rollup`: Processes new L1-to-L2 messages appearing on L1 as - the blockchain progresses. - - `:check_historical_msgs_to_rollup`: Retrieves historical L1-to-L2 messages that - were missed if the message synchronization process did not start from the - Arbitrum rollup's inception. - - While the `:init_worker` message is sent only once during the fetcher startup, - the subsequent sending of `:check_new_msgs_to_rollup` and - `:check_historical_msgs_to_rollup` forms the operation cycle of the fetcher. - - Discovery of L1-to-L2 messages is executed by requesting logs on L1 that correspond - to the `MessageDelivered` event emitted by the Arbitrum bridge contract. - Cross-chain messages are composed of information from the logs' data as well as from - the corresponding transaction details. To get the transaction details, RPC calls - `eth_getTransactionByHash` are made in chunks. + been imported into the database. + + The fetcher uses a BufferedTask-based approach for task scheduling, where each + type of task is scheduled independently with appropriate intervals. This provides + better error isolation and task management. + + Task types include: + - `:check_new`: Discovers new L1-to-L2 messages appearing on L1 as the blockchain + progresses. This task runs continuously with a configurable recheck interval. + - `:check_historical`: Retrieves historical L1-to-L2 messages that were missed + if the message synchronization process did not start from the Arbitrum rollup's + inception. This task walks backward from the current block until it reaches the + rollup initialization block, at which point it stops scheduling itself. + - `:check_missing_origination`: Discovers L1-to-L2 messages that have completion + information on L2 but are missing origination transaction details from L1. This + task works backward from the most recent fully indexed message in configurable + ranges, filling gaps where L2 indexing ran ahead of L1 event discovery. + + Task scheduling behavior: + The `:check_new` task runs continuously with a standard recheck interval, while + the `:check_historical` and `:check_missing_origination` tasks use a shorter + catchup interval (2 seconds) to expedite backfilling. Once a catchup task + completes (historical reaches the rollup init block, missing origination reaches + the earliest discovered message), it is no longer scheduled. + + Tasks that fail abnormally within a configurable threshold period will enter a + cooldown state for 10 minutes to prevent resource exhaustion. + + Initialization architecture: + The module uses a two-phase initialization process: + 1. Static configuration in `child_spec`: + - L1 RPC parameters and rollup addresses + - Recheck intervals for live and catchup work + - Failure threshold for cooldown triggering + 2. Dynamic initialization in `init/3`: + - Queries the L1 bridge address from the RPC + - Determines initial cursors from the database and L1 network state + - Prepares the task_data map with both `:check_new` and `:check_historical` + cursors + + Discovery of L1-to-L2 messages is executed by requesting logs on L1 that + correspond to the `MessageDelivered` event emitted by the Arbitrum bridge + contract. Cross-chain messages are composed of information from the logs' data + as well as from the corresponding transaction details. To get the transaction + details, RPC calls `eth_getTransactionByHash` are made in chunks. """ - use GenServer - use Indexer.Fetcher + use Indexer.Fetcher, restart: :permanent - import Indexer.Fetcher.Arbitrum.Utils.Helper, only: [increase_duration: 2] + import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1, log_warning: 1] alias EthereumJSONRPC.Arbitrum, as: ArbitrumRpc - - alias Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 - + alias Indexer.BufferedTask alias Indexer.Fetcher.Arbitrum.Utils.Db.Messages, as: DbMessages alias Indexer.Fetcher.Arbitrum.Utils.Rpc + alias Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 alias Indexer.Helper, as: IndexerHelper - require Logger - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(args) do - Logger.metadata(fetcher: :arbitrum_bridge_l1) - + @behaviour BufferedTask + + # 250ms interval between processing buffered entries. + # Note: This interval has no effect on retry behavior - when a task fails or + # is explicitly retried via :retry return value, it is re-queued and the next + # available task is picked up immediately without this delay. + @idle_interval 250 + # Only one task at a time + @max_concurrency 1 + # Process one task per batch + @max_batch_size 1 + + # 10 minutes cooldown interval for failed tasks + @cooldown_interval :timer.minutes(10) + + # Catchup task (historical messages discovery) needs to run as quickly as possible + # since it is only needed when indexing a rollup chain that already has many blocks. + # This interval is hardcoded since these tasks should complete rapidly and don't need + # to be configurable. + @catchup_recheck_interval :timer.seconds(2) + + @stoppable_tasks [:check_historical, :check_missing_origination] + + @typep fetcher_task :: :check_new | :check_historical | :check_missing_origination + @typep rescheduled_tasks :: :check_historical | :check_missing_origination + @typep queued_task :: :init_worker | {non_neg_integer(), fetcher_task()} + @typep completion_status :: %{rescheduled_tasks() => boolean()} + @typep fetcher_tasks_data :: %{fetcher_task() => map()} + + # Creates a child specification for the BufferedTask supervisor. Extracts and merges + # configuration from application environment, sets up task intervals, initializes + # RPC configurations for parent and rollup chains, and creates the initial state + # with task scheduling parameters. Returns a transient supervisor child spec with + # the configured BufferedTask. + def child_spec([init_options, gen_server_options]) do + {json_rpc_named_arguments, init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) + + # Extract configuration from application environment config_common = Application.get_all_env(:indexer)[Indexer.Fetcher.Arbitrum] l1_rpc = config_common[:l1_rpc] l1_rpc_block_range = config_common[:l1_rpc_block_range] @@ -72,159 +112,313 @@ defmodule Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1 do config_tracker = Application.get_all_env(:indexer)[__MODULE__] recheck_interval = config_tracker[:recheck_interval] + missed_message_ids_range = config_tracker[:missed_message_ids_range] + + failure_interval_threshold = + config_tracker[:failure_interval_threshold] || min(20 * recheck_interval, :timer.minutes(10)) + + # Configure intervals for each task type + intervals = %{ + check_new: recheck_interval, + check_historical: @catchup_recheck_interval, + check_missing_origination: @catchup_recheck_interval + } + + # Set up initial configuration structure + initial_config = %{ + json_l1_rpc_named_arguments: IndexerHelper.json_rpc_named_arguments(l1_rpc), + json_l2_rpc_named_arguments: json_rpc_named_arguments, + l1_rpc_block_range: l1_rpc_block_range, + l1_rpc_chunk_size: l1_rpc_chunk_size, + l1_rollup_address: l1_rollup_address, + l1_start_block: l1_start_block, + l1_rollup_init_block: l1_rollup_init_block, + recheck_interval: recheck_interval, + failure_interval_threshold: failure_interval_threshold, + catchup_recheck_interval: @catchup_recheck_interval, + missed_message_ids_range: missed_message_ids_range + } - Process.send(self(), :init_worker, []) - - {:ok, - %{ - config: %{ - json_l2_rpc_named_arguments: args[:json_rpc_named_arguments], - json_l1_rpc_named_arguments: IndexerHelper.json_rpc_named_arguments(l1_rpc), - recheck_interval: recheck_interval, - l1_rpc_chunk_size: l1_rpc_chunk_size, - l1_rpc_block_range: l1_rpc_block_range, - l1_rollup_address: l1_rollup_address, - l1_start_block: l1_start_block, - l1_rollup_init_block: l1_rollup_init_block - }, - data: %{} - }} + # Initial state structure + initial_state = %{ + config: initial_config, + intervals: intervals, + task_data: %{}, + completed_tasks: %{} + } + + buffered_task_init_options = + defaults() + |> Keyword.merge(init_options) + |> Keyword.put(:state, initial_state) + + Supervisor.child_spec( + {BufferedTask, [{__MODULE__, buffered_task_init_options}, gen_server_options]}, + id: __MODULE__, + restart: :transient + ) end - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} + defp defaults do + [ + flush_interval: @idle_interval, + max_concurrency: @max_concurrency, + max_batch_size: @max_batch_size, + poll: false, + task_supervisor: __MODULE__.TaskSupervisor, + metadata: [fetcher: :arbitrum_l1_messages_tracker] + ] end - # Initializes the worker for discovering new and historical L1-to-L2 messages. + @impl BufferedTask + def init(initial, reducer, _state) do + reducer.(:init_worker, initial) + end + + @impl BufferedTask + @spec run([queued_task()], map()) :: {:ok, map()} | {:retry, [queued_task()], map()} | :retry + def run(tasks, state) + + # Initializes the worker state and schedules all tasks for execution. Configures the + # initial state with RPC/DB values, sets up tasks in order (new batches/confirmations/ + # executions followed by historical ones), defines their completion states, and + # conditionally disables missing batches discovery and settlement transactions + # finalization based on configuration. + def run([:init_worker], state) do + # Complete configuration with RPC/DB dependent values + configured_state = initialize_workers(state) + + # Get current timestamp for initial task scheduling + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + # Define all possible tasks with their initial timestamps + default_tasks_to_run = [ + {now, :check_new}, + {now, :check_historical}, + {now, :check_missing_origination} + ] + + # Define default completion state for stoppable tasks + default_completion_state = %{ + check_historical: false, + check_missing_origination: false + } + + # Apply conditional disabling logic + {tasks_to_run, completion_state} = + {default_tasks_to_run, default_completion_state} + |> maybe_disable_check_missing_origination(configured_state) + + BufferedTask.buffer(__MODULE__, tasks_to_run, false) + + updated_state = Map.put(configured_state, :completed_tasks, completion_state) + {:ok, updated_state} + end + + # Executes or returns a task back to the queue based on its timeout and failure threshold. # - # This function prepares the initial parameters for the message discovery process. - # It fetches the Arbitrum bridge address and determines the starting block for - # new message discovery. If the starting block is not configured (set to a default - # value), the latest block number from L1 is used as the start. It also calculates - # the end block for historical message discovery. + # The function evaluates three conditions in sequence: + # 1. Whether the task's timeout has elapsed (current time >= timeout) + # 2. Whether the task hasn't exceeded the failure threshold or is an initial task (timeout == 0) # - # After setting these parameters, it immediately transitions to discovering new - # messages by sending the `:check_new_msgs_to_rollup` message. + # If all conditions are met, executes the appropriate task runner. Otherwise: + # - If timeout hasn't elapsed: Returns the task to the queue with the same timeout + # - If failure threshold exceeded: Applies a 10-minute cooldown and reschedules # # ## Parameters - # - `:init_worker`: The message triggering the initialization. - # - `state`: The current state of the process, containing configuration for data - # initialization and further message discovery. + # - `timeout`: Unix timestamp in milliseconds when the task should execute + # - `task_tag`: Atom identifying the type of task to run + # - `state`: Current state containing configuration and intervals # # ## Returns - # - `{:noreply, new_state}` where `new_state` is updated with the bridge address, - # determined start block for new messages, and calculated end block for - # historical messages. - @impl GenServer - def handle_info( - :init_worker, - %{config: %{l1_rollup_address: _, json_l1_rpc_named_arguments: _, l1_start_block: _}, data: _} = state - ) do + # - `{:ok, state}` on successful execution + # - `{:retry, [{timeout, task_tag}], state}` when task needs to be rescheduled + def run([{timeout, task_tag}], state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + with {:timeout_elapsed, true} <- {:timeout_elapsed, timeout <= now}, + {:threshold_ok, true} <- {:threshold_ok, now - timeout <= state.config.failure_interval_threshold}, + {:runner_defined, runner} when not is_nil(runner) <- {:runner_defined, Map.get(task_runners(), task_tag)} do + runner.(state) + else + {:timeout_elapsed, false} -> + {:retry, [{timeout, task_tag}], state} + + {:threshold_ok, false} -> + new_timeout = now + @cooldown_interval + + log_warning( + "Task #{task_tag} has been failing abnormally, applying cooldown for #{div(@cooldown_interval, 1000)} seconds" + ) + + {:retry, [{new_timeout, task_tag}], state} + + {:runner_defined, nil} -> + log_warning("Unknown task type: #{inspect(task_tag)}") + {:ok, state} + end + end + + defp task_runners do + %{ + check_new: &handle_check_new/1, + check_historical: &handle_check_historical/1, + check_missing_origination: &handle_check_missing_origination/1 + } + end + + # Initializes the worker state with contract addresses and block information + @spec initialize_workers(%{ + :config => map(), + optional(any()) => any() + }) :: %{:config => map(), :task_data => fetcher_tasks_data(), optional(any()) => any()} + defp initialize_workers(state) do + json_l1_rpc_named_arguments = state.config.json_l1_rpc_named_arguments + l1_rollup_address = state.config.l1_rollup_address + %{bridge: bridge_address} = ArbitrumRpc.get_contracts_for_rollup( - state.config.l1_rollup_address, + l1_rollup_address, :bridge, - state.config.json_l1_rpc_named_arguments + json_l1_rpc_named_arguments ) - l1_start_block = Rpc.get_l1_start_block(state.config.l1_start_block, state.config.json_l1_rpc_named_arguments) + l1_start_block = Rpc.get_l1_start_block(state.config.l1_start_block, json_l1_rpc_named_arguments) + + {safe_l1_block, _latest_l1_block} = + Rpc.get_safe_and_latest_l1_blocks(json_l1_rpc_named_arguments, state.config.l1_rpc_block_range) + new_msg_to_l2_start_block = DbMessages.l1_block_to_discover_latest_message_to_l2(l1_start_block) - historical_msg_to_l2_end_block = DbMessages.l1_block_to_discover_earliest_message_to_l2(l1_start_block - 1) - - Process.send(self(), :check_new_msgs_to_rollup, []) - - new_state = - state - |> Map.put( - :config, - Map.merge(state.config, %{ - l1_start_block: l1_start_block, - l1_bridge_address: bridge_address - }) - ) - |> Map.put( - :data, - Map.merge(state.data, %{ - new_msg_to_l2_start_block: new_msg_to_l2_start_block, - historical_msg_to_l2_end_block: historical_msg_to_l2_end_block - }) - ) - {:noreply, new_state} - end + %{ + l1_block_to_discover_earlier_messages: historical_msg_to_l2_end_block, + already_discovered_message_id: earliest_discovered_msg_id + } = DbMessages.inspect_earliest_discovered_message_to_l2(l1_start_block - 1) - # Initiates the process to discover and handle new L1-to-L2 messages initiated from L1. - # - # This function discovers new messages from L1 to L2 by retrieving logs for the - # calculated L1 block range. Discovered events are used to compose messages, which - # are then stored in the database. - # - # After processing, the function immediately transitions to discovering historical - # messages by sending the `:check_historical_msgs_to_rollup` message. - # - # ## Parameters - # - `:check_new_msgs_to_rollup`: The message that triggers the handler. - # - `state`: The current state of the fetcher, containing configuration and data - # needed for message discovery. - # - # ## Returns - # - `{:noreply, new_state}` where the starting block for the next new L1-to-L2 - # message discovery iteration is updated based on the results of the current - # iteration. - @impl GenServer - def handle_info(:check_new_msgs_to_rollup, %{data: _} = state) do - {handle_duration, {:ok, end_block}} = - :timer.tc(&NewMessagesToL2.discover_new_messages_to_l2/1, [ - state - ]) - - Process.send(self(), :check_historical_msgs_to_rollup, []) - - new_data = - Map.merge(state.data, %{ - duration: increase_duration(state.data, handle_duration), - new_msg_to_l2_start_block: end_block + 1 + highest_safe_fully_indexed_msg_id = + DbMessages.highest_safe_fully_indexed_message_to_l2(safe_l1_block, nil) + + updated_config = + Map.merge(state.config, %{ + l1_start_block: l1_start_block, + l1_bridge_address: bridge_address }) - {:noreply, %{state | data: new_data}} + task_data = %{ + check_new: %{ + start_block: new_msg_to_l2_start_block + }, + check_historical: %{ + end_block: historical_msg_to_l2_end_block + }, + check_missing_origination: %{ + end_message_id: + if(is_nil(highest_safe_fully_indexed_msg_id), do: nil, else: highest_safe_fully_indexed_msg_id - 1), + earliest_discovered_message_id: earliest_discovered_msg_id || 0, + safe_l1_block: safe_l1_block + } + } + + %{state | config: updated_config, task_data: task_data} + end + + # Conditionally disables missing origination discovery based on whether there + # are any fully indexed messages to work backward from. If `end_message_id` in + # the task data is nil, it means no fully indexed messages exist yet, so there's + # nothing to backfill and the task is disabled. + @spec maybe_disable_check_missing_origination({[queued_task()], completion_status()}, %{ + :task_data => %{ + :check_missing_origination => %{ + :end_message_id => non_neg_integer() | nil, + optional(any()) => any() + }, + optional(any()) => any() + }, + optional(any()) => any() + }) :: {[queued_task()], completion_status()} + defp maybe_disable_check_missing_origination({tasks, completion}, state) do + if is_nil(state.task_data.check_missing_origination.end_message_id) do + log_info("Missing origination discovery is disabled (no fully indexed messages found)") + {delete_task_by_tag(tasks, :check_missing_origination), %{completion | check_missing_origination: true}} + else + {tasks, completion} + end end - # Initiates the process to discover and handle historical L1-to-L2 messages initiated from L1. + # Helper to remove a task from the tasks list by its tag + @spec delete_task_by_tag([queued_task()], fetcher_task()) :: [queued_task()] + defp delete_task_by_tag(tasks, tag) do + Enum.reject(tasks, fn + {_timeout, task_tag} -> task_tag == tag + _other -> false + end) + end + + # Handles the discovery of new L1-to-L2 messages + defp handle_check_new(state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + {:ok, updated_state} = NewMessagesToL2.check_new(state) + + next_run_time = now + updated_state.intervals[:check_new] + BufferedTask.buffer(__MODULE__, [{next_run_time, :check_new}], false) + + {:ok, updated_state} + end + + # Handles the discovery of historical L1-to-L2 messages + defp handle_check_historical(state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + {:ok, updated_state} = NewMessagesToL2.check_historical(state) + + if rescheduled?(:check_historical, updated_state) do + next_run_time = now + updated_state.intervals[:check_historical] + BufferedTask.buffer(__MODULE__, [{next_run_time, :check_historical}], false) + end + + {:ok, updated_state} + end + + # Handles the discovery of L1-to-L2 messages that have completion information + # but are missing origination transaction details. # - # This function discovers historical messages by retrieving logs for a calculated L1 block range. - # The discovered events are then used to compose messages to be stored in the database. + # This function works backward from the most recent fully indexed message, + # checking ranges of message IDs for those that have been relayed on L2 but + # lack L1 origination data. It stops when reaching the earliest discovered + # message ID that was captured during initialization. # - # After processing, as it is the final handler in the loop, it schedules the - # `:check_new_msgs_to_rollup` message to initiate the next iteration. The scheduling of this - # message is delayed, taking into account the time spent on the previous handler's execution. + # The function retrieves the current safe L1 block, then calls the worker + # module to perform the actual discovery and import. Based on the worker's + # response, it either schedules the next iteration or marks the task as + # complete. # # ## Parameters - # - `:check_historical_msgs_to_rollup`: The message that triggers the handler. - # - `state`: The current state of the fetcher, containing configuration and data needed for - # message discovery. + # - `state`: Current fetcher state containing configuration, task data, and + # completion tracking. # # ## Returns - # - `{:noreply, new_state}` where the end block for the next L1-to-L2 message discovery - # iteration is updated based on the results of the current iteration. - @impl GenServer - def handle_info(:check_historical_msgs_to_rollup, %{config: %{recheck_interval: _}, data: _} = state) do - {handle_duration, {:ok, start_block}} = - :timer.tc(&NewMessagesToL2.discover_historical_messages_to_l2/1, [ - state - ]) - - next_timeout = max(state.config.recheck_interval - div(increase_duration(state.data, handle_duration), 1000), 0) - - Process.send_after(self(), :check_new_msgs_to_rollup, next_timeout) - - new_data = - Map.merge(state.data, %{ - duration: 0, - historical_msg_to_l2_end_block: start_block - 1 - }) + # - `{:ok, updated_state}` where the state includes: + # - Updated `task_data.check_missing_origination.end_message_id` cursor + # - Updated `completed_tasks.check_missing_origination` flag + defp handle_check_missing_origination(state) do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + + {:ok, updated_state} = NewMessagesToL2.check_missing_origination(state) - {:noreply, %{state | data: new_data}} + if rescheduled?(:check_missing_origination, updated_state) do + next_run_time = now + updated_state.intervals[:check_missing_origination] + BufferedTask.buffer(__MODULE__, [{next_run_time, :check_missing_origination}], false) + end + + {:ok, updated_state} + end + + # Returns true if the task should be rescheduled (not marked as completed) + @spec rescheduled?(atom(), %{:completed_tasks => completion_status(), optional(any()) => any()}) :: boolean() + defp rescheduled?(task_tag, state) when task_tag in @stoppable_tasks do + not Map.get(state.completed_tasks, task_tag) end + + defp rescheduled?(_task_tag, _state), do: true end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/common.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/common.ex index ef6f42e0a99e..a72f710e1cac 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/common.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/common.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Common do @moduledoc """ Provides chain-agnostic database utility functions for block-related operations. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/messages.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/messages.ex index e52d2738c473..bac56a06c01c 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/messages.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/messages.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Messages do @moduledoc """ Provides utility functions for querying Arbitrum cross-chain message data. @@ -52,26 +53,65 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Messages do end @doc """ - Calculates the next L1 block number to start the search for messages sent to L2 - that precede the earliest message already discovered. + Retrieves the highest message ID for an L1-to-L2 message that has been fully indexed within or before a specified safe L1 block, meaning both the originating transaction on L1 and the completion transaction on L2 have been discovered. + + This function is useful for determining the progress of message indexing + and identifying the starting point for discovering messages that are + missing originating transaction information, while ensuring only finalized + L1 data is considered. ## Parameters - - `value_if_nil`: The default value to return if no L1-to-L2 messages have been discovered. + - `safe_block`: The L1 block number threshold for filtering messages. + - `value_if_nil`: The default value to return if no fully indexed L1-to-L2 + messages exist within the safe block range. ## Returns - - The L1 block number immediately preceding the earliest discovered message to L2, - or `value_if_nil` if no messages to L2 have been found. + - The highest message ID for a fully indexed and relayed L1-to-L2 message + within the safe block range, or `value_if_nil` if no such messages are + found. """ - @spec l1_block_to_discover_earliest_message_to_l2(nil | FullBlock.block_number()) :: nil | FullBlock.block_number() - def l1_block_to_discover_earliest_message_to_l2(value_if_nil) - when (is_integer(value_if_nil) and value_if_nil >= 0) or is_nil(value_if_nil) do - case Reader.l1_block_of_earliest_discovered_message_to_l2() do + @spec highest_safe_fully_indexed_message_to_l2(FullBlock.block_number(), nil | non_neg_integer()) :: + nil | non_neg_integer() + def highest_safe_fully_indexed_message_to_l2(safe_block, value_if_nil) + when is_integer(safe_block) and safe_block >= 0 and + ((is_integer(value_if_nil) and value_if_nil >= 0) or is_nil(value_if_nil)) do + case Reader.highest_safe_fully_indexed_message_to_l2(safe_block) do nil -> - log_warning(@no_messages_warning) + log_warning("No fully indexed messages to L2 found in DB within safe block #{safe_block}") value_if_nil value -> - value - 1 + value + end + end + + @doc """ + Inspects the earliest discovered L1-to-L2 message and returns its message ID + along with the L1 block number to start the search for messages that precede it. + + ## Parameters + - `value_if_nil`: The default L1 block number to use if no L1-to-L2 messages have been discovered. + + ## Returns + - A map containing: + - `already_discovered_message_id`: The message ID of the earliest discovered message, + or `nil` if no messages have been found. + - `l1_block_to_discover_earlier_messages`: The L1 block number immediately preceding + the earliest discovered message, or `value_if_nil` if no messages have been found. + """ + @spec inspect_earliest_discovered_message_to_l2(nil | FullBlock.block_number()) :: %{ + already_discovered_message_id: non_neg_integer() | nil, + l1_block_to_discover_earlier_messages: FullBlock.block_number() | nil + } + def inspect_earliest_discovered_message_to_l2(value_if_nil) + when (is_integer(value_if_nil) and value_if_nil >= 0) or is_nil(value_if_nil) do + case Reader.earliest_discovered_message_to_l2() do + nil -> + log_warning(@no_messages_warning) + %{already_discovered_message_id: nil, l1_block_to_discover_earlier_messages: value_if_nil} + + {message_id, block_number} -> + %{already_discovered_message_id: message_id, l1_block_to_discover_earlier_messages: block_number - 1} end end @@ -279,6 +319,81 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Messages do Reader.get_uncompleted_l1_to_l2_messages_ids() end + @doc """ + Retrieves the message IDs of L1-to-L2 messages that have completion transactions + but are missing originating transaction information within a specified message ID range. + + This function identifies messages where the completion transaction has been discovered + on L2, but the corresponding originating transaction on L1 has not yet been indexed. + + ## Parameters + - `start_message_id`: The starting message ID of the range (inclusive). + - `end_message_id`: The ending message ID of the range (inclusive). + + ## Returns + - A list of message IDs for L1-to-L2 messages that have completion transactions + but lack originating transaction information, ordered by message ID in descending + order (highest to lowest). + """ + @spec messages_to_l2_completed_but_originating_info_missed(non_neg_integer(), non_neg_integer()) :: [ + non_neg_integer() + ] + def messages_to_l2_completed_but_originating_info_missed(start_message_id, end_message_id) + when is_integer(start_message_id) and start_message_id >= 0 and + is_integer(end_message_id) and end_message_id >= 0 and + start_message_id <= end_message_id do + Reader.messages_to_l2_completed_but_originating_info_missed(start_message_id, end_message_id) + end + + @doc """ + Determines the L1 block range to search for the originating transaction of an L1-to-L2 + message with the given message ID. + + The function finds the closest preceding and following messages that have originating + transaction information and returns their message IDs and L1 block numbers. If either + bound cannot be determined from existing messages, the provided fallback block values + are used and the message_id is set to nil. + + ## Parameters + - `message_id`: The message ID to find the L1 block range for. + - `fallback_min_block`: The fallback L1 block number to use as the lower bound if no + preceding message with originating information is found (typically the L1 block where + the rollup was initialized). + - `fallback_max_block`: The fallback L1 block number to use as the upper bound if no + following message with originating information is found (typically the current L1 chain tip). + + ## Returns + - A map with `:lower` and `:higher` keys, each containing a map with: + - `:message_id` - The message ID of the bound (or `nil` if fallback was used) + - `:block_number` - The L1 block number of the bound + """ + @spec l1_block_range_for_message_to_l2( + non_neg_integer(), + FullBlock.block_number(), + FullBlock.block_number() + ) :: %{ + lower: %{message_id: non_neg_integer() | nil, block_number: FullBlock.block_number()}, + higher: %{message_id: non_neg_integer() | nil, block_number: FullBlock.block_number()} + } + def l1_block_range_for_message_to_l2(message_id, fallback_min_block, fallback_max_block) + when is_integer(message_id) and message_id >= 0 and + is_integer(fallback_min_block) and is_integer(fallback_max_block) and + fallback_min_block <= fallback_max_block do + lower = + case Reader.l1_block_of_closest_preceding_message_to_l2(message_id) do + nil -> %{message_id: nil, block_number: fallback_min_block} + {msg_id, block_number} -> %{message_id: msg_id, block_number: block_number} + end + + higher = + case Reader.l1_block_of_closest_following_message_to_l2(message_id) do + nil -> %{message_id: nil, block_number: fallback_max_block} + {msg_id, block_number} -> %{message_id: msg_id, block_number: block_number} + end + + %{lower: lower, higher: higher} + end + @spec message_to_map(Message.t()) :: Message.to_import() defp message_to_map(message) do [ diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/parent_chain_transactions.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/parent_chain_transactions.ex index 744b8dfcf902..1d58c638cd9a 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/parent_chain_transactions.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/parent_chain_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Db.ParentChainTransactions do @moduledoc """ Manages database operations for Arbitrum L1 (parent chain) lifecycle transactions. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/settlement.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/settlement.ex index 076008a7cc5f..8fb49904257a 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/settlement.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/settlement.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Settlement do @moduledoc """ Provides utility functions for querying Arbitrum rollup settlement data. @@ -210,11 +211,14 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Settlement do end @doc """ - Retrieves rollup blocks within a specified block range that have not yet been confirmed. + Retrieves rollup blocks within the specified range from `first_block` to `last_block`, inclusive, + that either: + - Have not been confirmed yet, or + - May need re-confirmation due to potentially incorrect confirmation assignments ## Parameters - - `first_block`: The starting block number of the range to search for unconfirmed rollup blocks. - - `last_block`: The ending block number of the range. + - `first_block`: The rollup block number starting the lookup range. + - `last_block`: The rollup block number ending the lookup range. ## Returns - A list of maps, each representing an unconfirmed rollup block within the specified range, @@ -227,8 +231,32 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Settlement do def unconfirmed_rollup_blocks(first_block, last_block) when is_integer(first_block) and first_block >= 0 and is_integer(last_block) and first_block <= last_block do - # credo:disable-for-lines:2 Credo.Check.Refactor.PipeChainStart - Reader.unconfirmed_rollup_blocks(first_block, last_block) + # Get truly unconfirmed blocks first + unconfirmed_blocks = Reader.unconfirmed_rollup_blocks(first_block, last_block) + + # If there are unconfirmed blocks, check if we need to add more blocks for re-confirmation + blocks_to_transform = + case unconfirmed_blocks do + [] -> + [] + + blocks -> + # Since blocks are in descending order, the first one is the highest unconfirmed + highest_unconfirmed = hd(blocks) + + # If the highest unconfirmed block is not the last_block, it means last_block is already confirmed + # but potentially with wrong transaction. Get all blocks from highest_unconfirmed + 1 to last_block + if highest_unconfirmed.block_number < last_block do + # Get blocks eligible for re-confirmation and combine with truly unconfirmed blocks + reconfirmation_blocks = Reader.rollup_blocks_by_range(highest_unconfirmed.block_number + 1, last_block) + reconfirmation_blocks ++ blocks + else + blocks + end + end + + # Transform blocks to the expected format and maintain ascending order + blocks_to_transform |> Enum.reverse() |> Enum.map(&rollup_block_to_map/1) end @@ -322,7 +350,7 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Settlement do {{:error, _}, _} -> # Error case: DB is inconsistent: although there should not be any unconfirmed blocks - # above the highest unconfirmed block, we cannot find the the confirmations transaction + # above the highest unconfirmed block, we cannot find the confirmations transaction # for one of the block higher than the highest unconfirmed block. raise "DB is inconsistent: could not get the L1 block of the closest confirmed block above the highest unconfirmed block" end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/tools.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/tools.ex index e3eb7cf4c587..0e3c01041d99 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/tools.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/db/tools.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Db.Tools do @moduledoc """ Internal database utility functions for Arbitrum-related data processing. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/helper.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/helper.ex index fc0f9e33cae5..ee20b9caa220 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Helper do alias Explorer.Chain.Arbitrum.LifecycleTransaction alias Explorer.Chain.Cache.BackgroundMigrations diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/logging.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/logging.ex index 1dd8da71d9cf..7f21e60ac068 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/logging.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/logging.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Logging do @moduledoc """ Common logging functions for Indexer.Fetcher.Arbitrum fetchers diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/rpc.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/rpc.ex index b1e6a3858363..2c1b54d6746f 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/utils/rpc.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/utils/rpc.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Utils.Rpc do @moduledoc """ Common functions to simplify RPC routines for Indexer.Fetcher.Arbitrum fetchers @@ -5,7 +6,7 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Rpc do # TODO: Move the module under EthereumJSONRPC.Arbitrum. - alias ABI.TypeDecoder + alias ABI.{TypeDecoder, TypeEncoder} import EthereumJSONRPC, only: [json_rpc: 2, quantity_to_integer: 1, timestamp_to_datetime: 1] @@ -324,6 +325,57 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Rpc do {safe_block, latest_chain_block} end + @doc """ + Computes the safe start and end L1 blocks for discovery, respecting overlap safeguards. + + The returned `safe_start_block` is constrained to avoid revisiting blocks already + covered by historical entities discovery (`historical_entities_end_block`) while + still overlapping the safe block window derived from the current chain state. + The `end_block` is bounded by both the configured block range and the latest block + number. + + ## Parameters + - `new_entities_start_block`: Proposed start block for new-entity discovery. + - `historical_entities_end_block`: Last block already covered by historical discovery. + - `json_rpc_named_arguments`: RPC arguments used to query safe/latest blocks. + - `rpc_logs_block_range`: Max range to cover in a single discovery iteration. + + ## Returns + - `{safe_start_block, end_block}` tuple delimiting the adjusted discovery range. + """ + @spec safe_start_and_end_blocks( + non_neg_integer(), + non_neg_integer(), + EthereumJSONRPC.json_rpc_named_arguments(), + non_neg_integer() + ) :: {non_neg_integer(), non_neg_integer()} + def safe_start_and_end_blocks( + new_entities_start_block, + historical_entities_end_block, + json_rpc_named_arguments, + rpc_logs_block_range + ) + when is_integer(new_entities_start_block) and new_entities_start_block >= 0 and + is_integer(historical_entities_end_block) and + historical_entities_end_block >= 0 and is_integer(rpc_logs_block_range) and rpc_logs_block_range > 0 do + # It is necessary to revisit some of the previous blocks to ensure that + # no information is missed due to reorgs or RPC node inconsistency behind + # a load balancer. The number of blocks to revisit depends on the current safe + # block or the block which is considered as safest in case of L3 (where the + # safe block could be too far behind the latest block) or if RPC does not + # support "safe" block. + {safe_block, latest_block} = get_safe_and_latest_l1_blocks(json_rpc_named_arguments, rpc_logs_block_range) + + # At the same time it does not make sense to revisit blocks that will be + # revisited by the historical entities discovery process. + # If the new entities discovery process does not reach the chain head + # previously, there is no need to revisit the blocks. + safe_start_block = max(min(new_entities_start_block, safe_block), historical_entities_end_block + 1) + end_block = min(new_entities_start_block + rpc_logs_block_range - 1, latest_block) + + {safe_start_block, end_block} + end + @doc """ Identifies the block range for a batch by using the block number located on one end of the range. @@ -647,6 +699,15 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Rpc do - addSequencerL2BatchFromBlobsDelayProof - addSequencerL2BatchFromOriginDelayProof - addSequencerL2BatchDelayProof + - addSequencerL2BatchFromEigenDA + + Note: Although it is technically possible to implement a basic fallback to + avoid raising exceptions for unknown function selectors, this is + intentionally not implemented. Such a fallback would obscure issues where + batch data is not properly parsed and stored in the database. If this + occurred and was discovered later, a backfiller would need to be written to + re-process all discovered batches to assign them proper container types and + extract DA information correctly. ## Parameters - `calldata`: The raw calldata from the transaction as a binary string starting with "0x" @@ -764,6 +825,43 @@ defmodule Indexer.Fetcher.Arbitrum.Utils.Rpc do ) {sequence_number, prev_message_count, new_message_count, data} + + "0x283d8225" <> encoded_params -> + # addSequencerL2BatchFromEigenDA(uint256 sequenceNumber, EigenDACert calldata cert, IGasRefunder gasRefunder, uint256 afterDelayedMessagesRead, uint256 prevMessageCount, uint256 newMessageCount) + # https://github.com/Layr-Labs/nitro-contracts/blob/278fdbc39089fa86330f0c23f0a05aee61972c84/src/bridge/SequencerInbox.sol#L505-L512 + [ + sequence_number, + cert, + _gas_refunder, + _after_delayed_messages_read, + prev_message_count, + new_message_count + ] = + TypeDecoder.decode( + Base.decode16!(encoded_params, case: :lower), + ArbitrumContracts.add_sequencer_l2_batch_from_eigen_da_selector_with_abi() + ) + + # Encode the complex EigenDACert structure to bytes for interface compatibility. + # + # Why this encoding step is necessary: + # 1. TypeDecoder.decode() automatically unpacks the complex EigenDACert tuple structure + # into Elixir tuples when using the detailed ABI definition above. + # 2. However, the data availability parsing pipeline expects binary data that can be + # prefixed with a header flag (237 for EigenDA) and passed through the common interface. + # 3. Alternative approaches don't work: + # - Using :bytes in the function ABI gives ABI-encoded data with offset pointers, + # not the clean tuple encoding needed for later decoding + # - Changing the common interface to handle tuples would break compatibility with + # other DA types (Celestia, AnyTrust) that expect binary data + # 4. This encode step converts the decoded Elixir tuples back into clean ABI-encoded + # bytes that can be consistently decoded later in parse_batch_accompanying_data. + # + # This is not a performance issue as the encoding overhead is negligible compared to + # blockchain I/O operations, and it maintains clean separation of concerns. + cert_encoded = TypeEncoder.encode([cert], ArbitrumContracts.eigen_da_cert_abi()) + + {sequence_number, prev_message_count, new_message_count, <<237>> <> cert_encoded} end end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/backfill.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/backfill.ex index 7978619d3241..27fd4be601f1 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/backfill.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/backfill.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Backfill do @moduledoc """ Handles backfilling of missing Arbitrum-specific data for indexed blocks and their @@ -288,14 +289,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Backfill do hash: Hash.to_string(tx.hash) } end) do - transaction_params - |> Enum.chunk_every(chunk_size) - |> Enum.reduce_while({:ok, []}, fn chunk, {:ok, acc} -> - case Receipts.fetch(chunk, json_rpc_named_arguments) do - {:ok, %{receipts: receipts}} -> {:cont, {:ok, acc ++ receipts}} - {:error, reason} -> {:halt, {:error, reason}} - end - end) + fetch_transaction_receipts_batch(transaction_params, chunk_size, json_rpc_named_arguments) else # It is assumed that this branch is unreachable, as there is a check for # `indexed_blocks?` above in the stack @@ -303,6 +297,17 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Backfill do end end + defp fetch_transaction_receipts_batch(transaction_params, chunk_size, json_rpc_named_arguments) do + transaction_params + |> Enum.chunk_every(chunk_size) + |> Enum.reduce_while({:ok, []}, fn chunk, {:ok, acc} -> + case Receipts.fetch(chunk, json_rpc_named_arguments) do + {:ok, %{receipts: receipts}} -> {:cont, {:ok, acc ++ receipts}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + # Updates `Explorer.Chain.Block` and `Explorer.Chain.Transaction` records in the # database with Arbitrum-specific data. # diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/README.md b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/README.md index 34bfce7ca1fd..60c231ad5f8f 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/README.md +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/README.md @@ -17,6 +17,7 @@ The indexer supports multiple data storage mechanisms for batch data: - Data Availability (DA) blobs (EIP-4844) - AnyTrust solution - Celestia DA layer +- Eigen DA layer For each batch, the indexer: 1. Processes the `SequencerBatchDelivered` event diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery.ex index 2f744d9414ec..901a1ef8e74e 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery do @moduledoc """ Implements core batch discovery functionality for the Arbitrum rollup indexer. @@ -5,7 +6,8 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery do The module's primary responsibilities include: * Processing `SequencerBatchDelivered` event logs to extract batch information * Building comprehensive data structures for batches and associated entities - * Handling Data Availability information for AnyTrust and Celestia solutions + * Handling Data Availability information for AnyTrust, Celestia, and EigenDA + solutions * Managing L2-to-L1 message status updates for committed messages * Importing discovered data into the database * Broadcasting new batch notifications for websocket clients @@ -21,8 +23,8 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery do alias Explorer.Chain.Arbitrum alias Explorer.Chain.Events.Publisher + alias Indexer.Fetcher.Arbitrum.DA.{Anytrust, Celestia, Eigenda} alias Indexer.Fetcher.Arbitrum.DA.Common, as: DataAvailabilityInfo - alias Indexer.Fetcher.Arbitrum.DA.{Anytrust, Celestia} alias Indexer.Fetcher.Arbitrum.Utils.Db.Messages, as: DbMessages alias Indexer.Fetcher.Arbitrum.Utils.Db.ParentChainTransactions, as: DbParentChainTransactions alias Indexer.Fetcher.Arbitrum.Utils.Db.Settlement, as: DbSettlement @@ -466,8 +468,8 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery do # and decodes the calldata from the transactions to obtain batch details. It updates # the provided batch map with block ranges for new batches and constructs a map of # lifecycle transactions with their timestamps and finalization status. Additionally, - # it examines the data availability (DA) information for Anytrust or Celestia and - # constructs a list of DA info structs. + # it examines the data availability (DA) information for Anytrust, Celestia, or EigenDA + # and constructs a list of DA info structs. # # ## Parameters # - `transactions_requests`: The list of RPC requests to fetch transaction data. @@ -487,7 +489,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery do # database import and require further processing. # - An updated map of batch descriptions with block ranges and data availability # information. - # - A list of data availability information structs for Anytrust or Celestia. + # - A list of data availability information structs for Anytrust, Celestia, or EigenDA. @spec execute_transaction_requests_parse_transactions_calldata( [EthereumJSONRPC.Transport.request()], non_neg_integer(), @@ -520,7 +522,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Discovery do :data_available => atom() | nil, optional(any()) => any() } - }, [Anytrust.t() | Celestia.t()]} + }, [Anytrust.t() | Celestia.t() | Eigenda.t()]} defp execute_transaction_requests_parse_transactions_calldata( transactions_requests, msg_to_block_shift, diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery_utils.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery_utils.ex index 001dadaa8a92..4cff31bbb041 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery_utils.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/discovery_utils.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.DiscoveryUtils do @moduledoc """ Provides utility functions for batch discovery in Arbitrum rollups. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/events.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/events.ex index ecd62667fe55..857c1c4460ea 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/events.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/events.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Events do @moduledoc """ Provides functionality for retrieving Arbitrum `SequencerBatchDelivered` event logs. @@ -38,7 +39,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Events do json_rpc_named_arguments ) - if length(logs) > 0 do + unless Enum.empty?(logs) do log_debug("Found #{length(logs)} SequencerBatchDelivered logs") end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/rollup_entities.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/rollup_entities.ex index a64358af1c4d..39a708118c79 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/rollup_entities.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/rollup_entities.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.RollupEntities do @moduledoc """ The module associates rollup blocks and transactions with their corresponding batches in the Arbitrum blockchain. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/tasks.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/tasks.ex index c8fc4824cb55..233506db3ab8 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/tasks.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/batches/tasks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Tasks do @moduledoc """ Manages the discovery and importation of new and historical batches of transactions for an Arbitrum rollup. @@ -10,10 +11,10 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Tasks do The module processes logs from the `SequencerBatchDelivered` events emitted by the Arbitrum `SequencerInbox` contract to extract batch details. It maintains linkages between batches and their corresponding rollup blocks and transactions. - For batches stored in Data Availability solutions like AnyTrust or Celestia, - it retrieves DA information to locate the batch data. The module also tracks - cross-chain messages initiated in rollup blocks associated with new batches, - updating their status to committed (`:sent`). + For batches stored in Data Availability solutions like AnyTrust, Celestia, or + EigenDA, it retrieves DA information to locate the batch data. The module also + tracks cross-chain messages initiated in rollup blocks associated with new + batches, updating their status to committed (`:sent`). For any blocks or transactions missing in the database, data is requested in chunks from the rollup RPC endpoint by `eth_getBlockByNumber`. Additionally, @@ -141,21 +142,16 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Tasks do # Requesting the "latest" block instead of "safe" allows to catch new batches # without latency. - # It is necessary to re-visit some amount of the previous blocks to ensure that - # no batches are missed due to reorgs. The amount of blocks to re-visit depends - # on the current safe block or the block which is considered as safest in case - # of L3 (where the safe block could be too far behind the latest block) or if - # RPC does not support "safe" block. - {safe_block, latest_block} = - Rpc.get_safe_and_latest_l1_blocks(l1_rpc_config.json_rpc_named_arguments, l1_rpc_config.logs_block_range) - - # At the same time it does not make sense to re-visit blocks that will be - # re-visited by the historical batches discovery process. - # If the new batches discovery process does not reach the chain head previously - # no need to re-visit the blocks. - safe_start_block = max(min(start_block, safe_block), historical_batches_end_block + 1) - - end_block = min(start_block + l1_rpc_config.logs_block_range - 1, latest_block) + # It is necessary to revisit some of the previous blocks to ensure that + # no information is missed due to reorgs or RPC node inconsistency behind + # a load balancer. + {safe_start_block, end_block} = + Rpc.safe_start_and_end_blocks( + start_block, + historical_batches_end_block, + l1_rpc_config.json_rpc_named_arguments, + l1_rpc_config.logs_block_range + ) if safe_start_block <= end_block do log_info("Block range for new batches discovery: #{safe_start_block}..#{end_block}") @@ -348,7 +344,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Batches.Tasks do l1_block_ranges_for_missing_batches = DbSettlement.get_l1_block_ranges_for_missing_batches(start_batch, end_batch, lowest_l1_block - 1) - unless l1_block_ranges_for_missing_batches == [] do + if l1_block_ranges_for_missing_batches != [] do discover_missing( sequencer_inbox_address, l1_block_ranges_for_missing_batches, diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/discovery.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/discovery.ex index b5f710fc04d7..59f3850bbd7c 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/discovery.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/discovery.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Discovery do @moduledoc """ Handles the discovery and processing of rollup block confirmations in Arbitrum. diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/events.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/events.ex index 3ea3764db147..8b72daeb0a48 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/events.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/events.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Events do @moduledoc """ Provides functionality for fetching and parsing Arbitrum's SendRootUpdated events. @@ -11,7 +12,9 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Events do alias EthereumJSONRPC.Arbitrum.Constants.Events, as: ArbitrumEvents alias Indexer.Helper, as: IndexerHelper - import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_debug: 1] + alias Indexer.Fetcher.Arbitrum.Utils.Db.Settlement, as: DbSettlement + + import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_debug: 1, log_warning: 1] require Logger @@ -68,7 +71,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Events do {cached_logs, cache} end - if length(logs) > 0 do + unless Enum.empty?(logs) do log_debug("Found #{length(logs)} SendRootUpdated logs") end @@ -91,4 +94,79 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Events do l2_block_hash end + + @doc """ + Fetches and sorts rollup block numbers from `SendRootUpdated` events in the specified L1 block range. + + Retrieves logs from the Outbox contract and extracts the top confirmed rollup block numbers. + The block numbers are sorted in descending order to ensure proper handling of overlapping + confirmations by finding the highest already-confirmed block below the current confirmation. + Uses caching to minimize RPC calls. + + ## Parameters + - `log_start`: Starting L1 block number for log retrieval + - `log_end`: Ending L1 block number for log retrieval + - `l1_outbox_config`: Configuration for the Arbitrum outbox contract + - `cache`: Cache for logs to minimize RPC calls + + ## Returns + A tuple containing: + - `{:ok, sorted_block_numbers, new_cache, logs_length}` where: + * `sorted_block_numbers` is a list of rollup block numbers in descending order + * `new_cache` is the updated logs cache + * `logs_length` is the number of logs processed + - `{:error, nil, new_cache, logs_length}` if any block hash cannot be resolved + """ + @spec fetch_and_sort_confirmations_logs( + non_neg_integer(), + non_neg_integer(), + %{ + :outbox_address => binary(), + :json_rpc_named_arguments => EthereumJSONRPC.json_rpc_named_arguments(), + optional(any()) => any() + }, + cached_logs() + ) :: + {:ok, [non_neg_integer()], cached_logs(), non_neg_integer()} | {:error, nil, cached_logs(), non_neg_integer()} + def fetch_and_sort_confirmations_logs(log_start, log_end, l1_outbox_config, cache) do + {logs, new_cache} = + get_logs_for_confirmations( + log_start, + log_end, + l1_outbox_config.outbox_address, + l1_outbox_config.json_rpc_named_arguments, + cache + ) + + logs_length = length(logs) + + # Process each log to extract block numbers + blocks = + Enum.reduce_while(logs, {:ok, []}, fn log, {:ok, acc} -> + log_debug("Examining the transaction #{log["transactionHash"]}") + + rollup_block_hash = send_root_updated_event_parse(log) + rollup_block_num = DbSettlement.rollup_block_hash_to_num(rollup_block_hash) + + case rollup_block_num do + nil -> + log_warning("The rollup block ##{rollup_block_hash} not found") + {:halt, :error} + + value -> + log_debug("Found rollup block ##{rollup_block_num}") + {:cont, {:ok, [value | acc]}} + end + end) + + case blocks do + {:ok, list} -> + # Sort block numbers in descending order to find highest confirmed block first + sorted = Enum.sort(list, :desc) + {:ok, sorted, new_cache, logs_length} + + :error -> + {:error, nil, new_cache, logs_length} + end + end end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/rollup_blocks.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/rollup_blocks.ex index 4b8f5360115e..f7a6a2e1a313 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/rollup_blocks.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/rollup_blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.RollupBlocks do @moduledoc """ Handles the discovery and marking of confirmed rollup blocks in Arbitrum. @@ -100,14 +101,14 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.RollupBlocks do ) # credo:disable-for-next-line Credo.Check.Refactor.Nesting - if length(confirmed_blocks) > 0 do + if Enum.empty?(confirmed_blocks) do + log_info("Either no unconfirmed blocks found or DB inconsistency error discovered") + [] + else log_info("Found #{length(confirmed_blocks)} confirmed blocks") add_confirmation_transaction(confirmed_blocks, block_to_l1_transactions[block_number].l1_transaction_hash) ++ updated_rollup_blocks - else - log_info("Either no unconfirmed blocks found or DB inconsistency error discovered") - [] end end) end @@ -220,22 +221,15 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.RollupBlocks do not genesis_reached?(first_unconfirmed_block, rollup_first_block) do log_info("End of the batch #{batch.number} discovered, moving to the previous batch") - {status, updated_rollup_blocks} = - discover_rollup_blocks_belonging_to_one_confirmation( - first_unconfirmed_block - 1, - confirmation_desc, - outbox_config, - rollup_first_block, - new_cache - ) - - case status do - :error -> {:error, []} - # updated_rollup_blocks will contain either [] if the previous batch - # already confirmed or list of unconfirmed blocks of all previous - # unconfirmed batches - :ok -> {:ok, unconfirmed_rollup_blocks ++ updated_rollup_blocks} - end + discover_previous_batch_blocks( + first_unconfirmed_block, + confirmation_desc, + outbox_config, + rollup_first_block, + new_cache, + unconfirmed_rollup_blocks, + raw_unconfirmed_rollup_blocks + ) else # During the process of new confirmations discovery it will show "N of N", # for the process of historical confirmations discovery it will show "N of M". @@ -248,6 +242,33 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.RollupBlocks do end end + defp discover_previous_batch_blocks( + first_unconfirmed_block, + confirmation_desc, + outbox_config, + rollup_first_block, + new_cache, + unconfirmed_rollup_blocks, + _raw_unconfirmed_rollup_blocks + ) do + {status, updated_rollup_blocks} = + discover_rollup_blocks_belonging_to_one_confirmation( + first_unconfirmed_block - 1, + confirmation_desc, + outbox_config, + rollup_first_block, + new_cache + ) + + case status do + :error -> {:error, []} + # updated_rollup_blocks will contain either [] if the previous batch + # already confirmed or list of unconfirmed blocks of all previous + # unconfirmed batches + :ok -> {:ok, unconfirmed_rollup_blocks ++ updated_rollup_blocks} + end + end + # Determines if a rollup block number has reached the lowest indexed block of the chain. # # ## Parameters @@ -509,29 +530,20 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.RollupBlocks do |> Enum.to_list() end - # Checks if any blocks within a specific range are identified as the top of confirmed blocks by scanning `SendRootUpdated` events. - # - # This function fetches logs for `SendRootUpdated` events within the specified - # L1 block range to determine if any rollup blocks within the given rollup block - # range are mentioned in the events, indicating the top of confirmed blocks up - # to that log. It uses caching to minimize `eth_getLogs` calls. + # Scans `SendRootUpdated` events in the given L1 block range to find the highest + # rollup block within the specified range that has been confirmed. Uses caching + # to minimize `eth_getLogs` calls. # # ## Parameters - # - A tuple `{rollup_start_block, rollup_end_block}` specifying the rollup block - # range to check for confirmations - # - A tuple `{log_start, log_end}` specifying the L1 block range to fetch logs. - # - `l1_outbox_config`: Configuration for the Arbitrum Outbox contract. - # - `cache`: A cache of previously fetched logs to reduce `eth_getLogs` calls. + # - `{rollup_start_block, rollup_end_block}`: Range of rollup blocks to check + # - `{log_start, log_end}`: Range of L1 blocks to scan for events + # - `l1_outbox_config`: Arbitrum Outbox contract configuration + # - `cache`: Cache of previously fetched logs # # ## Returns - # - A tuple `{:ok, latest_block_confirmed, new_cache, logs_length}`: - # - `latest_block_confirmed` is the highest rollup block number confirmed within - # the specified range. - # - A tuple `{:ok, nil, new_cache, logs_length}` if no rollup blocks within the - # specified range are confirmed. - # - A tuple `{:error, nil, new_cache, logs_length}` if during parsing logs a rollup - # block with given hash is not being found in the database. - # For all three cases the `new_cache` contains the updated logs cache. + # - `{:ok, block_num, new_cache, logs_length}`: Found confirmed block in range + # - `{:ok, nil, new_cache, logs_length}`: No confirmed blocks in range + # - `{:error, nil, new_cache, logs_length}`: Block hash resolution failed @spec do_check_if_batch_confirmed( {non_neg_integer(), non_neg_integer()}, {non_neg_integer(), non_neg_integer()}, @@ -545,48 +557,37 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.RollupBlocks do {:ok, nil | non_neg_integer(), EventsUtils.cached_logs(), non_neg_integer()} | {:error, nil, EventsUtils.cached_logs(), non_neg_integer()} defp do_check_if_batch_confirmed( - {rollup_start_block, rollup_end_block}, + batch_block_range, {log_start, log_end}, l1_outbox_config, cache ) do - # The logs in the given L1 blocks range - {logs, new_cache} = - EventsUtils.get_logs_for_confirmations( - log_start, - log_end, - l1_outbox_config.outbox_address, - l1_outbox_config.json_rpc_named_arguments, - cache - ) - - # For every discovered event check if the rollup block in the confirmation - # is within the specified range which usually means that the event - # is the confirmation of the batch described by the range. - {status, latest_block_confirmed} = - logs - |> Enum.reduce_while({:ok, nil}, fn event, _acc -> - log_debug("Examining the transaction #{event["transactionHash"]}") - - rollup_block_hash = EventsUtils.send_root_updated_event_parse(event) - rollup_block_num = DbSettlement.rollup_block_hash_to_num(rollup_block_hash) - - case rollup_block_num do - nil -> - log_warning("The rollup block ##{rollup_block_hash} not found") - {:halt, {:error, nil}} - - value when value >= rollup_start_block and value <= rollup_end_block -> - log_debug("The rollup block ##{rollup_block_num} within the range") - {:halt, {:ok, rollup_block_num}} - - _ -> - log_debug("The rollup block ##{rollup_block_num} outside of the range") - {:cont, {:ok, nil}} - end - end) + case EventsUtils.fetch_and_sort_confirmations_logs(log_start, log_end, l1_outbox_config, cache) do + {:error, nil, new_cache, logs_length} -> + {:error, nil, new_cache, logs_length} + + # For every discovered event check if the rollup block in the confirmation + # is within the specified range which usually means that the event + # is the confirmation of the batch described by the range. + {:ok, sorted_block_numbers, new_cache, logs_length} -> + latest_block_confirmed = find_first_block_in_range(sorted_block_numbers, batch_block_range) + {:ok, latest_block_confirmed, new_cache, logs_length} + end + end - {status, latest_block_confirmed, new_cache, length(logs)} + # Finds the first block number from the sorted list that falls within the specified range. + @spec find_first_block_in_range([non_neg_integer()], {non_neg_integer(), non_neg_integer()}) :: + non_neg_integer() | nil + defp find_first_block_in_range(sorted_block_numbers, {start_block, end_block}) do + Enum.find_value(sorted_block_numbers, nil, fn block_num -> + if block_num >= start_block and block_num <= end_block do + log_debug("The rollup block ##{block_num} within the range") + block_num + else + log_debug("The rollup block ##{block_num} outside of the range") + nil + end + end) end # Simplifies the process of updating counters for the `eth_getLogs` requests diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/tasks.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/tasks.ex index 0a61e39b4f4a..b00212ded9bc 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/tasks.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/confirmations/tasks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Tasks do @moduledoc """ Handles the discovery and processing of new and historical confirmations of rollup blocks for an Arbitrum rollup. @@ -393,7 +394,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.Confirmations.Tasks do # The situation when the interim start block is not equal to the start block # means that the confirmations gap has not been inspected fully yet. It is # necessary to continue the confirmations discovery from the interim start - # block to the the block predecessor of the current start block. + # block to the block predecessor of the current start block. {retcode, state_for_next_iteration_historical(state, start_block - 1, interim_start_block)} {:confirmation_missed, _} -> diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/historical_messages_on_l2.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/historical_messages_on_l2.ex index 2803b7477b29..ace97ac08eb1 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/historical_messages_on_l2.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/historical_messages_on_l2.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do @moduledoc """ Handles the discovery and processing of historical messages between Layer 1 (L1) and Layer 2 (L2) within an Arbitrum rollup. @@ -123,7 +124,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.HistoricalMessagesOnL2 do logs = DbMessages.logs_for_missed_messages_from_l2(start_block, end_block) - unless logs == [] do + if logs != [] do messages = logs |> Messaging.handle_filtered_l2_to_l1_messages(__MODULE__) diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/l1_finalization.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/l1_finalization.ex index 86d3cdadbf7e..b7b35691ca90 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/l1_finalization.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/l1_finalization.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.L1Finalization do @moduledoc """ Oversees the finalization of lifecycle transactions on Layer 1 (L1) for Arbitrum rollups. @@ -80,7 +81,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.L1Finalization do lifecycle_transactions = Db.lifecycle_unfinalized_transactions(safe_block) - if length(lifecycle_transactions) > 0 do + unless Enum.empty?(lifecycle_transactions) do log_info("Discovered #{length(lifecycle_transactions)} lifecycle transaction to be finalized") updated_lifecycle_transactions = diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_l1_executions.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_l1_executions.ex index 0bdc6e8bbe20..4aa04c6c881d 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_l1_executions.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_l1_executions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.NewL1Executions do @moduledoc """ Coordinates the discovery and processing of new and historical L2-to-L1 message executions for an Arbitrum rollup. @@ -245,7 +246,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewL1Executions do {lifecycle_transactions, executions} = get_executions_from_logs(logs, l1_rpc_config) - unless executions == [] do + if executions != [] do log_info("Executions for #{length(executions)} L2 messages will be imported") {:ok, _} = @@ -263,7 +264,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewL1Executions do # the execution transactions that have already been indexed. messages = get_relayed_messages() - unless messages == [] do + if messages != [] do log_info("Marking #{length(messages)} l2-to-l1 messages as completed") {:ok, _} = @@ -286,7 +287,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewL1Executions do json_rpc_named_arguments ) - if length(logs) > 0 do + unless Enum.empty?(logs) do log_debug("Found #{length(logs)} OutBoxTransactionExecuted logs") end diff --git a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex index a2a1c04524ed..a68cbb04d776 100644 --- a/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex +++ b/apps/indexer/lib/indexer/fetcher/arbitrum/workers/new_messages_to_l2.ex @@ -1,11 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do @moduledoc """ Manages the discovery and processing of new and historical L1-to-L2 messages initiated on L1 for an Arbitrum rollup. This module is responsible for identifying and importing messages that are initiated - from Layer 1 (L1) to Arbitrum's Layer 2 (L2). It handles both new messages that are - currently being sent to L2 and historical messages that were sent in the past but - have not yet been processed by the system. + from Layer 1 (L1) to Arbitrum's Layer 2 (L2). It handles three discovery scenarios: + + 1. **New messages**: Currently being sent to L2 as the chain progresses forward + 2. **Historical messages**: Sent in the past before indexing started, discovered + by working backward from the earliest known message + 3. **Missing origination**: Messages that have completion information on L2 but + lack origination transaction details from L1, typically occurring when L2 + indexing runs ahead of L1 event discovery The initiated messages are identified by analyzing logs associated with `MessageDelivered` events emitted by the Arbitrum bridge contract. These logs @@ -21,11 +27,14 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do import Indexer.Fetcher.Arbitrum.Utils.Logging, only: [log_info: 1, log_debug: 1] + alias Indexer.Fetcher.Arbitrum.Utils.Db.Messages, as: DbMessages + alias Indexer.Fetcher.Arbitrum.Utils.Helper, as: ArbitrumHelper alias Indexer.Fetcher.Arbitrum.Utils.Rpc alias Indexer.Helper, as: IndexerHelper alias Explorer.Chain alias Explorer.Chain.Arbitrum + alias Explorer.Chain.Arbitrum.Message, as: ArbitrumMessage alias Explorer.Chain.Events.Publisher require Logger @@ -45,17 +54,15 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do ## Parameters - A map containing: - `config`: Configuration settings including JSON RPC arguments for L1, Arbitrum - bridge address, RPC block range, and chunk size for RPC calls. - - `data`: Contains the starting block number for new L1-to-L2 message discovery. + bridge address, RPC block range, and chunk size for RPC calls. + - `data`: Contains the starting block number for new L1-to-L2 message discovery + and the end block number for historical messages discovery. ## Returns - - `{:ok, end_block}`: On successful discovery and processing, where `end_block` - indicates the necessity to consider next block range in the - following iteration of new message discovery. - - `{:ok, start_block - 1}`: when no new blocks on L1 produced from the last - iteration of the new message discovery. + - `{:ok, updated_state}` with `task_data.check_new.start_block` moved forward + when work was done, or left unchanged when no new blocks were present. """ - @spec discover_new_messages_to_l2(%{ + @spec check_new(%{ :config => %{ :json_l1_rpc_named_arguments => EthereumJSONRPC.json_rpc_named_arguments(), :l1_bridge_address => binary(), @@ -63,13 +70,14 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do :l1_rpc_chunk_size => non_neg_integer(), optional(any()) => any() }, - :data => %{ - :new_msg_to_l2_start_block => non_neg_integer(), + :task_data => %{ + :check_new => %{start_block: non_neg_integer()}, + :check_historical => %{end_block: non_neg_integer()}, optional(any()) => any() }, optional(any()) => any() - }) :: {:ok, non_neg_integer()} - def discover_new_messages_to_l2( + }) :: {:ok, map()} + def check_new( %{ config: %{ json_l1_rpc_named_arguments: json_rpc_named_arguments, @@ -77,39 +85,61 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do l1_rpc_block_range: rpc_block_range, l1_bridge_address: bridge_address }, - data: %{new_msg_to_l2_start_block: start_block} - } = _state + task_data: %{ + check_new: %{start_block: start_block}, + check_historical: %{end_block: historical_msg_to_l2_end_block} + } + } = state ) do - # Requesting the "latest" block instead of "safe" allows to get messages originated to L2 - # much earlier than they will be seen by the Arbitrum Sequencer. - {:ok, latest_block} = - IndexerHelper.get_block_number_by_tag( - "latest", + # It is necessary to revisit some of the previous blocks to ensure that + # no information is missed due to reorgs or RPC node inconsistency behind + # a load balancer. + {safe_start_block, end_block} = + Rpc.safe_start_and_end_blocks( + start_block, + historical_msg_to_l2_end_block, json_rpc_named_arguments, - Rpc.get_resend_attempts() + rpc_block_range ) - end_block = min(start_block + rpc_block_range - 1, latest_block) - - if start_block <= end_block do - log_info("Block range for discovery new messages from L1: #{start_block}..#{end_block}") + if safe_start_block <= end_block do + log_info("Block range for discovery new messages from L1: #{safe_start_block}..#{end_block}") + # Since the block range to discover messages could be wider than `rpc_block_range` + # it is required to divide it in smaller chunks. + # credo:disable-for-lines:16 Credo.Check.Refactor.PipeChainStart new_messages_amount = - discover( - bridge_address, - start_block, + ArbitrumHelper.execute_for_block_range_in_chunks( + safe_start_block, end_block, - json_rpc_named_arguments, - chunk_size + rpc_block_range, + fn chunk_start, chunk_end -> + discover( + bridge_address, + chunk_start, + chunk_end, + json_rpc_named_arguments, + chunk_size + ) + end ) + |> Enum.reduce(0, fn {_range, amount}, acc -> acc + amount end) if new_messages_amount > 0 do Publisher.broadcast(%{new_messages_to_arbitrum_amount: new_messages_amount}, :realtime) end - {:ok, end_block} + # Cursor is moved forward for the next iteration of the new messages discovery + updated_state = + state + |> ArbitrumHelper.update_fetcher_task_data(:check_new, %{ + start_block: end_block + 1 + }) + + # Advance the new-message cursor for the next live run. + {:ok, updated_state} else - {:ok, start_block - 1} + {:ok, state} end end @@ -122,22 +152,23 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do from both the log and the corresponding L1 transaction, and importing them into the database. + It updates the end-block cursor and marks historical work complete when the init + block is reached. + ## Parameters - A map containing: - `config`: Configuration settings including JSON RPC arguments for L1, Arbitrum - bridge address, rollup initialization block, block range, and chunk - size for RPC calls. - - `data`: Contains the end block for historical L1-to-L2 message discovery. + bridge address, rollup initialization block, block range, and chunk size for + RPC calls. + - `task_data`: where `check_historical` contains the end block for historical + L1-to-L2 message discovery. ## Returns - - `{:ok, start_block}`: On successful discovery and processing, where `start_block` - indicates the necessity to consider another block range in - the next iteration of message discovery. - - `{:ok, l1_rollup_init_block}`: If the discovery process already reached rollup - initialization block and no discovery action was - necessary. + - `{:ok, updated_state}` with the historical cursor moved backward and + `completed_tasks.check_historical` set when the init block boundary is + reached. """ - @spec discover_historical_messages_to_l2(%{ + @spec check_historical(%{ :config => %{ :json_l1_rpc_named_arguments => EthereumJSONRPC.json_rpc_named_arguments(), :l1_bridge_address => binary(), @@ -146,13 +177,11 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do :l1_rpc_chunk_size => non_neg_integer(), optional(any()) => any() }, - :data => %{ - :historical_msg_to_l2_end_block => non_neg_integer(), - optional(any()) => any() - }, + :task_data => %{:check_historical => %{end_block: non_neg_integer()}, optional(any()) => any()}, + :completed_tasks => %{:check_historical => boolean(), optional(any()) => any()}, optional(any()) => any() - }) :: {:ok, non_neg_integer()} - def discover_historical_messages_to_l2( + }) :: {:ok, map()} + def check_historical( %{ config: %{ json_l1_rpc_named_arguments: json_rpc_named_arguments, @@ -161,8 +190,8 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do l1_bridge_address: bridge_address, l1_rollup_init_block: l1_rollup_init_block }, - data: %{historical_msg_to_l2_end_block: end_block} - } = _state + task_data: %{check_historical: %{end_block: end_block}} + } = state ) do if end_block >= l1_rollup_init_block do start_block = max(l1_rollup_init_block, end_block - rpc_block_range + 1) @@ -177,12 +206,256 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do chunk_size ) - {:ok, start_block} + updated_state = + state + |> ArbitrumHelper.update_fetcher_task_data(:check_historical, %{ + end_block: start_block - 1 + }) + |> set_historical_completion(false) + + # Move the historical cursor backward for the next catchup run. + {:ok, updated_state} else - {:ok, l1_rollup_init_block} + # The rollup init block is reached, flag that next iteration of + # historical messages discovery is not needed. + updated_state = + state + |> ArbitrumHelper.update_fetcher_task_data(:check_historical, %{ + end_block: l1_rollup_init_block - 1 + }) + |> set_historical_completion(true) + + # Stop scheduling historical checks because the rollup init block was reached. + {:ok, updated_state} end end + @doc """ + Discovers L1-to-L2 messages that have completion transactions on L2 but are missing origination transaction information from L1. + + This function works backward from the most recent fully indexed message, + checking ranges of message IDs for those marked as `:relayed` but lacking + L1 origination data. For each missing message found, it determines the + appropriate L1 block range to search, discovers the originating transaction, + and imports it. + + The function processes messages in order from highest to lowest ID within + each range. When multiple missing messages are found, each is processed + sequentially, with L1 block bounds recalculated after each import to use + newly discovered messages as range delimiters. + + Note: When consecutive messages are missing (no fully indexed message between + them), processing one may also import others in the same L1 block range. This + results in some messages being imported multiple times, which is harmless due + to Chain.import's upsert behavior. + + The cursor advances by the configured range size with each iteration. The + task completes and stops when the next iteration's range would extend below + the earliest discovered message ID captured during initialization. + + **Precondition:** This function assumes it is only called when there are + fully indexed messages to work from (i.e., `end_message_id` is not nil). + The task initialization logic ensures this function is never called when + there's no work to do. + + ## Parameters + - A map containing: + - `config`: Configuration settings including: + - JSON RPC arguments for L1 + - L1 bridge address + - L1 rollup initialization block + - L1 RPC chunk size for batch requests + - Message ID range window size + - `task_data`: Contains: + - `check_missing_origination.end_message_id`: Upper bound for the next + message ID range to check (always a valid non-negative integer when this + function is called; task is disabled at init if no fully indexed messages exist) + - `check_missing_origination.earliest_discovered_message_id`: Lower bound + where discovery should stop (defaults to 0 if no historical messages discovered yet) + - `check_missing_origination.safe_l1_block`: Safe L1 block number used + as fallback upper bound when determining L1 block ranges + - `completed_tasks`: Tracking map for task completion status + + ## Returns + - `{:ok, updated_state}` where: + - `task_data.check_missing_origination.end_message_id` is moved backward + by the range size + - `completed_tasks.check_missing_origination` is set to `true` when the + next iteration would go below the earliest discovered message boundary + """ + @spec check_missing_origination(%{ + :config => %{ + :json_l1_rpc_named_arguments => EthereumJSONRPC.json_rpc_named_arguments(), + :l1_bridge_address => binary(), + :l1_rollup_init_block => non_neg_integer(), + :l1_rpc_chunk_size => non_neg_integer(), + :missed_message_ids_range => non_neg_integer(), + optional(any()) => any() + }, + :task_data => %{ + :check_missing_origination => %{ + end_message_id: non_neg_integer(), + earliest_discovered_message_id: non_neg_integer(), + safe_l1_block: non_neg_integer() + }, + optional(any()) => any() + }, + :completed_tasks => %{:check_missing_origination => boolean(), optional(any()) => any()}, + optional(any()) => any() + }) :: {:ok, map()} + def check_missing_origination( + %{ + config: %{ + json_l1_rpc_named_arguments: json_rpc_named_arguments, + l1_rpc_chunk_size: chunk_size, + l1_bridge_address: bridge_address, + l1_rollup_init_block: l1_rollup_init_block, + missed_message_ids_range: missed_message_ids_range + }, + task_data: %{ + check_missing_origination: %{ + end_message_id: end_message_id, + earliest_discovered_message_id: earliest_discovered_message_id, + safe_l1_block: safe_l1_block + } + } + } = state + ) do + # Calculate the message ID range to check: [start_message_id, end_message_id] + # Clamp the range to at least 1 to ensure start_message_id <= end_message_id + normalized_range = max(1, missed_message_ids_range) + start_message_id = max(0, end_message_id - normalized_range + 1) + + log_info("Checking message ID range #{start_message_id}..#{end_message_id} for missing origination information") + + # Query for messages in range that have completion but missing origination + missing_origination_message_ids = + DbMessages.messages_to_l2_completed_but_originating_info_missed(start_message_id, end_message_id) + + unless Enum.empty?(missing_origination_message_ids) do + log_info( + "Found #{length(missing_origination_message_ids)} messages with missing origination in range #{start_message_id}..#{end_message_id}" + ) + + # Process each missing message (already in descending order from highest to lowest ID). + # + # Note on duplicate imports: When consecutive messages are missing (no fully indexed + # message between them), processing the higher-ID message may also discover and import + # lower-ID messages within the same L1 block range. For example: + # - Messages A(indexed), B(missing), C(missing), D(indexed) + # - Processing C: L1 range spans A..D, both B and C are discovered and imported + # - Processing B: B is imported again (already has origination from previous step) + # + # This is expected and harmless - Chain.import performs upserts, so reimporting + # the same message data is idempotent. Duplicate imports only occur with consecutive + # missing messages; alternating indexed/missing patterns naturally prevent this. + missing_origination_message_ids + |> Enum.each(fn message_id -> + discover_and_import_single_missing_message( + message_id, + l1_rollup_init_block, + safe_l1_block, + bridge_address, + json_rpc_named_arguments, + chunk_size + ) + end) + end + + # Move cursor backward for next iteration + next_end_message_id = start_message_id - 1 + + # Check if next iteration would go below the earliest discovered message + should_complete = next_end_message_id < earliest_discovered_message_id + + if should_complete do + log_info("Missing origination discovery complete") + end + + updated_state = + state + |> ArbitrumHelper.update_fetcher_task_data(:check_missing_origination, %{ + end_message_id: next_end_message_id + }) + |> set_missing_origination_completion(should_complete) + + {:ok, updated_state} + end + + # Discovers and imports origination information for a single L1-to-L2 message + # that is missing such information. + # + # This function determines the appropriate L1 block range to search by finding + # the closest preceding and following messages with known origination blocks. + # It then fetches L1 logs within that range and filters to find only the + # target message, excluding any already-indexed messages. + # + # The filter bounds are derived from the discovered boundary messages: + # - `lower_bound.message_id`: May be nil if no preceding message exists; -1 is + # used as fallback (effectively "no lower bound" since all message IDs >= 0) + # - `higher_bound.message_id`: Guaranteed to be non-nil because: + # 1. check_missing_origination only runs when end_message_id is not nil + # 2. end_message_id = highest_safe_fully_indexed_msg_id - 1 + # 3. highest_safe_fully_indexed_msg_id has BOTH origination AND completion + # 4. All missing messages have IDs < highest_safe_fully_indexed_msg_id + # 5. l1_block_of_closest_following_message_to_l2 will always find at least + # highest_safe_fully_indexed_msg_id (or something closer) + # + # ## Parameters + # - `message_id`: The ID of the message to discover origination for + # - `l1_rollup_init_block`: Fallback minimum L1 block if no preceding message + # - `safe_l1_block`: Fallback maximum L1 block if no following message + # - `bridge_address`: L1 bridge contract address for log filtering + # - `json_rpc_named_arguments`: RPC configuration + # - `chunk_size`: Batch size for RPC requests + # + # ## Returns + # - `:ok` after attempting to discover and import the message + defp discover_and_import_single_missing_message( + message_id, + l1_rollup_init_block, + safe_l1_block, + bridge_address, + json_rpc_named_arguments, + chunk_size + ) do + log_debug("Discovering origination for message ID #{message_id}") + + # Determine L1 block range for this message using database helper + %{lower: lower_bound, higher: higher_bound} = + DbMessages.l1_block_range_for_message_to_l2(message_id, l1_rollup_init_block, safe_l1_block) + + l1_start_block = lower_bound.block_number + l1_end_block = higher_bound.block_number + + log_info( + "Searching L1 blocks #{l1_start_block}..#{l1_end_block} for message ID #{message_id} " <> + "(bounded by message IDs #{inspect(lower_bound.message_id)}..#{inspect(higher_bound.message_id)})" + ) + + # Build filter range from discovered bounds. + # - lower_bound.message_id may be nil if no preceding message exists; use -1 + # as fallback since message IDs are non-negative, so (msg_id > -1) is always true + # - higher_bound.message_id is guaranteed non-nil (see function documentation above) + filter_range = %{ + higher_than: lower_bound.message_id || -1, + lower_than: higher_bound.message_id + } + + # Discover messages in the L1 block range, filtering to only messages + # within the discovered bounds (exclusive) + discover( + bridge_address, + l1_start_block, + l1_end_block, + json_rpc_named_arguments, + chunk_size, + filter_range + ) + + :ok + end + # Discovers and imports L1-to-L2 messages initiated on L1 within a specified block range. # # This function discovers messages initiated on L1 for transferring information from L1 to L2 @@ -191,16 +464,32 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do # details. For information not present in the events, RPC calls are made to fetch additional # transaction details. The discovered messages are then imported into the database. # + # When `only_messages_in_range` is provided, discovered messages are filtered to only + # include those with message IDs strictly between the specified bounds (exclusive). + # This is used for targeted discovery of specific missing messages. + # # ## Parameters # - `bridge_address`: The address of the Arbitrum bridge contract used to filter the logs. # - `start_block`: The starting block number for log retrieval. # - `end_block`: The ending block number for log retrieval. # - `json_rpc_named_argument`: Configuration parameters for the JSON RPC connection. # - `chunk_size`: The size of chunks for processing RPC calls in batches. + # - `only_messages_in_range`: Optional map with `:higher_than` and `:lower_than` message + # ID bounds for filtering. When present, only messages with IDs strictly between these + # bounds (exclusive) are imported. Pass `nil` to import all discovered messages. # # ## Returns - # - amount of discovered messages - defp discover(bridge_address, start_block, end_block, json_rpc_named_argument, chunk_size) do + # - amount of discovered messages that were attempted to be imported + defp discover( + bridge_address, + start_block, + end_block, + json_rpc_named_argument, + chunk_size, + only_messages_in_range \\ nil + ) + + defp discover(bridge_address, start_block, end_block, json_rpc_named_argument, chunk_size, only_messages_in_range) do logs = get_logs_for_l1_to_l2_messages( start_block, @@ -209,19 +498,27 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do json_rpc_named_argument ) - messages = get_messages_from_logs(logs, json_rpc_named_argument, chunk_size) + messages = get_messages_from_logs(logs, json_rpc_named_argument, chunk_size, only_messages_in_range) - unless messages == [] do - log_info("Origins of #{length(messages)} L1-to-L2 messages will be imported") - end + case messages do + [] -> + 0 - {:ok, _} = - Chain.import(%{ - arbitrum_messages: %{params: messages}, - timeout: :infinity - }) + _ -> + log_info("Origins of #{length(messages)} L1-to-L2 messages will be imported") + + {:ok, import_result} = + Chain.import(%{ + arbitrum_messages: %{params: messages}, + timeout: :infinity + }) - length(messages) + # Count the actual imported records returned by the runner; the input `messages` + # length can overstate inserts when conflicts/updates happen during import. + import_result + |> Map.get(ArbitrumMessage.insert_result_key(), []) + |> length() + end end # Retrieves logs representing the `MessageDelivered` events. @@ -236,7 +533,7 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do json_rpc_named_arguments ) - if length(logs) > 0 do + unless Enum.empty?(logs) do log_debug("Found #{length(logs)} MessageDelivered logs") end @@ -253,10 +550,16 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do # address and setting the status to `:initiated`, making them ready for database # import. # + # When `only_messages_in_range` is provided, messages are filtered to only include + # those with message IDs strictly between the specified bounds (exclusive). This + # filtering happens after message construction but before database import. + # # ## Parameters # - `logs`: A list of log entries to be processed. # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection. # - `chunk_size`: The size of chunks for batch processing transactions. + # - `only_messages_in_range`: Optional map with `:higher_than` and `:lower_than` + # message ID bounds for filtering. Pass `nil` to include all messages. # # ## Returns # - A list of maps describing discovered messages compatible with the database @@ -264,24 +567,68 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do @spec get_messages_from_logs( [%{String.t() => any()}], EthereumJSONRPC.json_rpc_named_arguments(), - non_neg_integer() + non_neg_integer(), + %{higher_than: non_neg_integer(), lower_than: non_neg_integer()} | nil ) :: [Arbitrum.Message.to_import()] - defp get_messages_from_logs(logs, json_rpc_named_arguments, chunk_size) + defp get_messages_from_logs(logs, json_rpc_named_arguments, chunk_size, only_messages_in_range) - defp get_messages_from_logs([], _, _), do: [] + defp get_messages_from_logs([], _, _, _), do: [] - defp get_messages_from_logs(logs, json_rpc_named_arguments, chunk_size) do + defp get_messages_from_logs(logs, json_rpc_named_arguments, chunk_size, only_messages_in_range) do {messages, transactions_requests} = parse_logs_for_l1_to_l2_messages(logs) transactions_to_from = Rpc.execute_transactions_requests_and_get_from(transactions_requests, json_rpc_named_arguments, chunk_size) - Enum.map(messages, fn msg -> - Map.merge(msg, %{ - originator_address: transactions_to_from[msg.originating_transaction_hash], - status: :initiated - }) - end) + complete_messages = + Enum.map(messages, fn msg -> + Map.merge(msg, %{ + originator_address: transactions_to_from[msg.originating_transaction_hash], + status: :initiated + }) + end) + + filter_messages_by_id_range(complete_messages, only_messages_in_range) + end + + # Filters messages to only include those with message IDs strictly between the + # specified bounds (exclusive). + # + # This filtering is used when discovering specific missing messages to ensure + # only the target messages are imported, excluding any messages that already + # have origination information in the database. + # + # When `range` is `nil`, returns all messages without filtering. + # + # ## Parameters + # - `messages`: List of message maps to filter + # - `range`: Map with `:higher_than` and `:lower_than` message ID bounds, or `nil`. + # Note: `higher_than` can be -1 when no preceding message exists (acts as "no lower bound" + # since all message IDs are non-negative). + # + # ## Returns + # - Filtered list of messages with IDs in the exclusive range, or all messages if range is `nil` + @spec filter_messages_by_id_range( + [Arbitrum.Message.to_import()], + %{higher_than: integer(), lower_than: non_neg_integer()} | nil + ) :: [Arbitrum.Message.to_import()] + defp filter_messages_by_id_range(messages, range) + + defp filter_messages_by_id_range(messages, nil), do: messages + + defp filter_messages_by_id_range(messages, %{higher_than: lower_bound, lower_than: upper_bound}) do + filtered = + Enum.filter(messages, fn msg -> + msg.message_id > lower_bound and msg.message_id < upper_bound + end) + + if length(filtered) < length(messages) do + filtered_out_count = length(messages) - length(filtered) + + log_debug("Filtered out #{filtered_out_count} messages outside ID range #{lower_bound + 1}..#{upper_bound - 1}") + end + + filtered end # Parses logs to extract L1-to-L2 message details and prepares RPC requests for transaction data. @@ -354,4 +701,48 @@ defmodule Indexer.Fetcher.Arbitrum.Workers.NewMessagesToL2 do {message_index, kind, Timex.from_unix(timestamp)} end + + # Marks historical completion status in the state. + @spec set_historical_completion( + %{:completed_tasks => %{:check_historical => boolean(), optional(any()) => any()}, optional(any()) => any()}, + boolean() + ) :: %{ + :completed_tasks => %{:check_historical => boolean(), optional(any()) => any()}, + optional(any()) => any() + } + defp set_historical_completion(state, completed?) do + updated_completed_tasks = + state + |> Map.get(:completed_tasks, %{}) + |> Map.put(:check_historical, completed?) + + %{state | completed_tasks: updated_completed_tasks} + end + + # Marks missing origination discovery completion status in the state. + # + # ## Parameters + # - `state`: The current fetcher state + # - `completed?`: Boolean indicating if the task has completed + # + # ## Returns + # - Updated state map with the completion flag set + @spec set_missing_origination_completion( + %{ + :completed_tasks => %{:check_missing_origination => boolean(), optional(any()) => any()}, + optional(any()) => any() + }, + boolean() + ) :: %{ + :completed_tasks => %{:check_missing_origination => boolean(), optional(any()) => any()}, + optional(any()) => any() + } + defp set_missing_origination_completion(state, completed?) do + updated_completed_tasks = + state + |> Map.get(:completed_tasks, %{}) + |> Map.put(:check_missing_origination, completed?) + + %{state | completed_tasks: updated_completed_tasks} + end end diff --git a/apps/indexer/lib/indexer/fetcher/beacon/blob.ex b/apps/indexer/lib/indexer/fetcher/beacon/blob.ex index b42a49f4824e..9909f52c7b60 100644 --- a/apps/indexer/lib/indexer/fetcher/beacon/blob.ex +++ b/apps/indexer/lib/indexer/fetcher/beacon/blob.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Beacon.Blob do @moduledoc """ Fills beacon_blobs DB table. @@ -8,9 +9,9 @@ defmodule Indexer.Fetcher.Beacon.Blob do require Logger - alias Explorer.Repo alias Explorer.Chain.Beacon.{Blob, Reader} alias Explorer.Chain.Data + alias Explorer.Repo alias Indexer.{BufferedTask, Tracer} alias Indexer.Fetcher.Beacon.Blob.Supervisor, as: BlobSupervisor alias Indexer.Fetcher.Beacon.Client diff --git a/apps/indexer/lib/indexer/fetcher/beacon/client.ex b/apps/indexer/lib/indexer/fetcher/beacon/client.ex index 294730eda7fa..e3adc07e793f 100644 --- a/apps/indexer/lib/indexer/fetcher/beacon/client.ex +++ b/apps/indexer/lib/indexer/fetcher/beacon/client.ex @@ -1,18 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Beacon.Client do @moduledoc """ HTTP Client for Beacon Chain RPC """ - alias HTTPoison.Response require Logger + alias Explorer.HttpClient + @request_error_msg "Error while sending request to beacon rpc" - def http_get_request(url) do - case Application.get_env(:explorer, :http_adapter).get(url) do - {:ok, %Response{body: body, status_code: 200}} -> + defp http_get_request(url) do + case HttpClient.get(url) do + {:ok, %{body: body, status_code: 200}} -> Jason.decode(body) - {:ok, %Response{body: body, status_code: _}} -> + {:ok, %{body: body, status_code: _}} -> {:error, body} {:error, error} -> @@ -78,10 +80,24 @@ defmodule Indexer.Fetcher.Beacon.Client do http_get_request(header_url(slot)) end + @spec get_spec :: {:error, any()} | {:ok, any()} + def get_spec do + http_get_request(spec_url()) + end + + @spec get_pending_deposits(integer() | String.t()) :: {:error, any()} | {:ok, any()} + def get_pending_deposits(slot) do + http_get_request(pending_deposits_url(slot)) + end + def blob_sidecars_url(slot), do: "#{base_url()}" <> "/eth/v1/beacon/blob_sidecars/" <> to_string(slot) def header_url(slot), do: "#{base_url()}" <> "/eth/v1/beacon/headers/" <> to_string(slot) + defp pending_deposits_url(epoch), do: "#{base_url()}/eth/v1/beacon/states/#{epoch}/pending_deposits" + + defp spec_url, do: "#{base_url()}/eth/v1/config/spec" + def base_url do Application.get_env(:indexer, Indexer.Fetcher.Beacon)[:beacon_rpc] end diff --git a/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex b/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex new file mode 100644 index 000000000000..354997151854 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/beacon/deposit.ex @@ -0,0 +1,640 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Beacon.Deposit do + @moduledoc """ + Fetches deposit data from the beacon chain. + """ + + use GenServer + use Indexer.Fetcher + + require Logger + + import Ecto.Query + + alias ABI.Event + alias Ecto.Changeset + alias EthereumJSONRPC.Block, as: EthereumJSONRPCBlock + alias Explorer.Chain.Beacon.Deposit + alias Explorer.Chain.{Block, Data, Hash, Wei} + alias Explorer.Repo + alias Indexer.Fetcher.Beacon.Client + alias Indexer.Helper + + defstruct [ + :interval, + :batch_size, + :deposit_contract_address_hash, + :domain_deposit, + :genesis_fork_version, + :deposit_index, + :last_processed_log_block_number, + :last_processed_log_index, + :json_rpc_named_arguments + ] + + def start_link([init_opts, server_opts]) do + GenServer.start_link(__MODULE__, init_opts, server_opts) + end + + @impl GenServer + def init(opts) do + Logger.metadata(fetcher: :beacon_deposit) + + json_rpc_named_arguments = opts[:json_rpc_named_arguments] + + if !json_rpc_named_arguments do + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.init to allow for json_rpc calls when running." + end + + {:ok, nil, {:continue, json_rpc_named_arguments}} + end + + @impl GenServer + def handle_continue(json_rpc_named_arguments, _state) do + chain_id = Application.get_env(:indexer, :chain_id) + + case Client.get_spec() do + {:ok, + %{ + "data" => %{ + "DEPOSIT_CHAIN_ID" => ^chain_id, + "DEPOSIT_CONTRACT_ADDRESS" => deposit_contract_address_hash, + "DOMAIN_DEPOSIT" => "0x" <> domain_deposit_hex, + "GENESIS_FORK_VERSION" => "0x" <> genesis_fork_version_hex + } + }} -> + last_processed_deposit = Deposit.get_latest_deposit() || %{index: -1, block_number: -1, log_index: -1} + + state = %__MODULE__{ + interval: Application.get_env(:indexer, __MODULE__)[:interval], + batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size], + deposit_contract_address_hash: deposit_contract_address_hash, + domain_deposit: Base.decode16!(domain_deposit_hex, case: :mixed), + genesis_fork_version: Base.decode16!(genesis_fork_version_hex, case: :mixed), + deposit_index: last_processed_deposit.index, + last_processed_log_block_number: last_processed_deposit.block_number, + last_processed_log_index: last_processed_deposit.log_index, + json_rpc_named_arguments: json_rpc_named_arguments + } + + Process.send_after(self(), :process_logs, state.interval) + + {:noreply, state} + + {:ok, + %{ + "data" => %{ + "DEPOSIT_CHAIN_ID" => chain_id, + "DEPOSIT_CONTRACT_ADDRESS" => _deposit_contract_address_hash, + "DOMAIN_DEPOSIT" => "0x" <> _domain_deposit_hex, + "GENESIS_FORK_VERSION" => "0x" <> _genesis_fork_version_hex + } + }} -> + Logger.error("Misconfigured CHAIN_ID or INDEXER_BEACON_RPC_URL, CHAIN_ID from the node: #{inspect(chain_id)}") + {:stop, :wrong_chain_id, nil} + + {:ok, data} -> + Logger.error("Unexpected format on beacon spec endpoint: #{inspect(data)}") + {:stop, :unexpected_format, nil} + + {:error, reason} -> + Logger.error("Failed to fetch beacon spec: #{inspect(reason)}") + {:stop, :fetch_failed, nil} + end + end + + @impl GenServer + def handle_cast({:lost_consensus, block_number}, %__MODULE__{} = state) do + {_deleted_deposits_count, deleted_deposits} = + Repo.delete_all( + from( + d in Deposit, + where: d.block_number > ^block_number, + select: d.index + ), + timeout: :infinity + ) + + deposit_index = Enum.min(deleted_deposits, fn -> state.deposit_index + 1 end) + + {:noreply, + %{ + state + | deposit_index: deposit_index - 1, + last_processed_log_block_number: block_number, + last_processed_log_index: -1 + }} + rescue + postgrex_error in Postgrex.Error -> + Logger.error( + "Error while trying to delete reorged Beacon Deposits: #{Exception.format(:error, postgrex_error, __STACKTRACE__)}. Retrying." + ) + + GenServer.cast(self(), {:lost_consensus, block_number}) + {:noreply, state} + end + + @impl GenServer + def handle_info( + :process_logs, + %__MODULE__{ + interval: interval, + batch_size: batch_size, + deposit_contract_address_hash: deposit_contract_address_hash, + domain_deposit: domain_deposit, + genesis_fork_version: genesis_fork_version, + deposit_index: deposit_index, + last_processed_log_block_number: last_processed_log_block_number, + last_processed_log_index: last_processed_log_index, + json_rpc_named_arguments: json_rpc_named_arguments + } = state + ) do + deposits = + deposit_contract_address_hash + |> Deposit.get_logs_with_deposits( + last_processed_log_block_number, + last_processed_log_index, + batch_size + ) + |> Enum.map(&db_log_to_deposit/1) + + result = + case find_missing_ranges(deposit_index, deposits) do + [_ | _] = missing_ranges -> + Logger.error( + "Non-sequential deposits detected, missing ranges are: #{inspect(missing_ranges)}, trying to fetch from the node" + ) + + case fetch_and_process_logs_from_node( + deposit_index, + last_processed_log_block_number, + missing_ranges, + deposits, + deposit_contract_address_hash, + json_rpc_named_arguments + ) do + {:ok, deposits} -> + {:ok, deposits} + + {:error, reason} -> + Logger.error("Failed to fetch deposit logs from node: #{inspect(reason)}") + Process.send_after(self(), :process_logs, interval * 30) + :error + end + + _ -> + {:ok, deposits} + end + + case result do + {:ok, deposits} -> + {deposits_count, _} = + Repo.insert_all(Deposit, set_status(deposits, domain_deposit, genesis_fork_version), + on_conflict: :replace_all, + conflict_target: [:index] + ) + + if deposits_count < batch_size do + Process.send_after(self(), :process_logs, interval) + else + Process.send(self(), :process_logs, []) + end + + last_deposit = + List.last(deposits, %{ + index: state.deposit_index, + block_number: state.last_processed_log_block_number, + log_index: state.last_processed_log_index + }) + + {:noreply, + %__MODULE__{ + state + | deposit_index: last_deposit.index, + last_processed_log_block_number: last_deposit.block_number, + last_processed_log_index: last_deposit.log_index + }} + + _ -> + {:noreply, state} + end + end + + defp fetch_and_process_logs_from_node( + last_processed_deposit_index, + last_processed_deposit_block_number, + missing_ranges, + deposits, + deposit_contract_address_hash, + json_rpc_named_arguments + ) do + with {:ok, deposits_from_node} <- + missing_ranges + |> Task.async_stream( + fn {from_deposit_index, to_deposit_index} -> + do_fetch_and_process_logs_from_node( + last_processed_deposit_index, + last_processed_deposit_block_number, + from_deposit_index, + to_deposit_index, + deposits, + deposit_contract_address_hash, + json_rpc_named_arguments + ) + end, + max_concurrency: 5, + timeout: :infinity + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, deposits_from_node}}, {:ok, acc} -> + {:cont, {:ok, deposits_from_node ++ acc}} + + {:ok, {:error, reason}}, _ -> + {:halt, {:error, reason}} + + {:exit, reason}, _ -> + {:halt, {:error, reason}} + end), + merged_deposits = + deposits + |> Map.new(fn d -> {d.index, d} end) + |> Map.merge(deposits_from_node |> Map.new(fn d -> {d.index, d} end)) + |> Map.values() + |> Enum.sort_by(& &1.index), + [] <- find_missing_ranges(last_processed_deposit_index, merged_deposits) do + {:ok, merged_deposits} + else + [_ | _] = missing_ranges -> + Logger.error("Still missing deposit ranges after fetching from the node: #{inspect(missing_ranges)}") + {:error, :missing_ranges_after_node_fetch} + + err -> + err + end + end + + defp do_fetch_and_process_logs_from_node( + last_processed_deposit_index, + last_processed_deposit_block_number, + from_deposit_index, + to_deposit_index, + deposits, + deposit_contract_address_hash, + json_rpc_named_arguments + ) do + from_deposit_block_number = + if last_processed_deposit_index == from_deposit_index do + last_processed_deposit_block_number + else + Enum.find(deposits, fn %{index: i} -> i == from_deposit_index end).block_number + end + + to_deposit_block_number = Enum.find(deposits, fn %{index: i} -> i == to_deposit_index end).block_number + + with {:ok, logs} <- + Helper.get_logs( + max(0, from_deposit_block_number), + max(0, to_deposit_block_number), + to_string(deposit_contract_address_hash), + [Deposit.event_signature()], + json_rpc_named_arguments + ) do + node_logs_to_deposits(logs, json_rpc_named_arguments) + end + end + + defp node_logs_to_deposits(logs, json_rpc_named_arguments) do + blocks_from_db = + logs + |> Enum.map(fn l -> l["blockHash"] end) + |> Enum.uniq() + |> Block.by_hashes_query() + |> Repo.all() + |> Map.new(fn b -> {b.hash, b} end) + + logs_with_missing_blocks = + logs + |> Enum.reject(fn l -> + {:ok, l_block_hash} = Hash.Full.cast(l["blockHash"]) + Map.has_key?(blocks_from_db, l_block_hash) + end) + + blocks_from_node = + logs_with_missing_blocks + |> Helper.get_blocks_by_events(json_rpc_named_arguments, 3) + |> Map.new(fn b -> + b = b |> EthereumJSONRPCBlock.to_elixir() |> EthereumJSONRPCBlock.elixir_to_params() + b = %Block{} |> Changeset.cast(b, ~w(hash number timestamp)a) |> Changeset.apply_changes() + {b.hash, b} + end) + + blocks = Map.merge(blocks_from_db, blocks_from_node) + + logs + |> Enum.reduce_while({:ok, []}, fn l, {:ok, acc} -> + case node_log_to_deposit(l, blocks) do + {:ok, deposit} -> + {:cont, {:ok, [deposit | acc]}} + + {:error, reason} -> + Logger.error("Failed to decode deposit log from node: #{inspect(reason)}") + {:halt, {:error, :missing_block}} + end + end) + end + + @abi ABI.parse_specification( + [ + %{ + "anonymous" => false, + "inputs" => [ + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "pubkey", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "withdrawal_credentials", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "amount", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "signature", + "type" => "bytes" + }, + %{ + "indexed" => false, + "internalType" => "bytes", + "name" => "index", + "type" => "bytes" + } + ], + "name" => "DepositEvent", + "type" => "event" + } + ], + include_events?: true + ) + + defp db_log_to_deposit(log) do + do_log_to_deposit( + log.first_topic, + log.data, + log.from_address_hash, + log.transaction_hash, + log.block_hash, + log.block_number, + log.block_timestamp, + log.index + ) + end + + defp node_log_to_deposit( + %{ + "topics" => [str_first_topic], + "data" => str_data, + "address" => str_from_address_hash, + "transactionHash" => str_transaction_hash, + "blockHash" => str_block_hash, + "logIndex" => str_log_index + }, + blocks + ) do + {:ok, block_hash} = Hash.Full.cast(str_block_hash) + + case blocks[block_hash] do + nil -> + {:error, :missing_block} + + block -> + {:ok, first_topic} = Data.cast(str_first_topic) + {:ok, data} = Data.cast(str_data) + {:ok, from_address_hash} = Hash.Address.cast(str_from_address_hash) + {:ok, transaction_hash} = Hash.Full.cast(str_transaction_hash) + + {:ok, + do_log_to_deposit( + first_topic, + data, + from_address_hash, + transaction_hash, + block.hash, + block.number, + block.timestamp, + EthereumJSONRPC.quantity_to_integer(str_log_index) + )} + end + end + + defp do_log_to_deposit( + first_topic, + data, + from_address_hash, + transaction_hash, + block_hash, + block_number, + block_timestamp, + log_index + ) do + {_, + [ + {"pubkey", "bytes", false, pubkey}, + {"withdrawal_credentials", "bytes", false, withdrawal_credentials}, + {"amount", "bytes", false, <>}, + {"signature", "bytes", false, signature}, + {"index", "bytes", false, <>} + ]} = + Event.find_and_decode( + @abi, + first_topic.bytes, + nil, + nil, + nil, + data.bytes + ) + + %{ + pubkey: %Data{bytes: pubkey}, + withdrawal_credentials: %Data{bytes: withdrawal_credentials}, + amount: amount |> Decimal.new() |> Wei.from(:gwei), + signature: %Data{bytes: signature}, + index: index, + from_address_hash: from_address_hash, + transaction_hash: transaction_hash, + block_hash: block_hash, + block_number: block_number, + block_timestamp: block_timestamp, + log_index: log_index, + inserted_at: DateTime.utc_now(), + updated_at: DateTime.utc_now() + } + end + + defp find_missing_ranges(last_processed_deposit_index, deposits) do + result = + Enum.reduce(deposits, %{index: last_processed_deposit_index, gaps: []}, fn + %{index: i}, %{index: prev, gaps: gaps} when i - prev <= 1 -> + %{index: i, gaps: gaps} + + %{index: i}, %{index: prev, gaps: gaps} -> + %{index: i, gaps: [{prev, i} | gaps]} + end) + + Enum.reverse(result.gaps) + end + + defp set_status(deposits, domain_deposit, genesis_fork_version) do + {deposits_to_query, deposits_acc, _valid_pubkeys_acc} = + Enum.reduce(deposits, {[], [], MapSet.new()}, fn deposit, {deposit_to_query, deposits_acc, valid_pubkeys_acc} -> + valid_signature? = verify(deposit, domain_deposit, genesis_fork_version) + + new_valid_pubkeys_acc = + if valid_signature? do + MapSet.put(valid_pubkeys_acc, deposit.pubkey) + else + valid_pubkeys_acc + end + + if MapSet.member?(valid_pubkeys_acc, deposit.pubkey) or valid_signature? do + {deposit_to_query, [Map.put(deposit, :status, :pending) | deposits_acc], new_valid_pubkeys_acc} + else + {[deposit | deposit_to_query], deposits_acc, valid_pubkeys_acc} + end + end) + + deposits_to_query_pubkeys = Enum.map(deposits_to_query, & &1.pubkey) + + query = + from(deposit in Deposit, + where: deposit.status != :invalid, + where: deposit.pubkey in ^deposits_to_query_pubkeys, + select: deposit.pubkey + ) + + valid_pubkeys = query |> Repo.all() |> MapSet.new() + + deposits_with_status = + deposits_to_query + |> Enum.map(fn deposit -> + if MapSet.member?(valid_pubkeys, deposit.pubkey) do + Map.put(deposit, :status, :pending) + else + Map.put(deposit, :status, :invalid) + end + end) + + deposits_with_status ++ deposits_acc + end + + @zero_genesis_validators_root :binary.copy(<<0x00>>, 32) + + defp verify(deposit, domain_deposit, genesis_fork_version) do + deposit_message_root = + hash_tree_root_deposit_message( + deposit.pubkey.bytes, + deposit.withdrawal_credentials.bytes, + deposit.amount |> Wei.to(:gwei) |> Decimal.to_integer() + ) + + domain = + compute_domain( + domain_deposit, + genesis_fork_version, + @zero_genesis_validators_root + ) + + signing_root = compute_signing_root(deposit_message_root, domain) + + ExEthBls.verify(deposit.pubkey.bytes, signing_root, deposit.signature.bytes) + end + + defp hash_tree_root_deposit_message(pubkey, withdrawal_credentials, amount) do + pubkey_packed = pack_basic_type(pubkey) + pubkey_root = merkleize_chunks(pubkey_packed) + + wc_packed = pack_basic_type(withdrawal_credentials) + wc_root = merkleize_chunks(wc_packed) + + amount_bytes = <> + amount_packed = pack_basic_type(amount_bytes) + amount_root = merkleize_chunks(amount_packed) + + field_roots = [pubkey_root, wc_root, amount_root] + merkleize_chunks(field_roots) + end + + defp pack_basic_type(value) do + chunk_size = 32 + padding_needed = rem(chunk_size - rem(byte_size(value), chunk_size), chunk_size) + padded = value <> :binary.copy(<<0>>, padding_needed) + + for <>, do: chunk + end + + defp merkleize_chunks([chunk]), do: chunk + + defp merkleize_chunks(chunks) when is_list(chunks) do + padded_chunks = pad_to_next_power_of_two(chunks) + merkleize_recursive(padded_chunks) + end + + defp merkleize_recursive([single_chunk]), do: single_chunk + + defp merkleize_recursive(chunks) do + next_level = + chunks + |> Enum.chunk_every(2) + |> Enum.map(fn + [left, right] -> :crypto.hash(:sha256, left <> right) + [single] -> single + end) + + merkleize_recursive(next_level) + end + + defp pad_to_next_power_of_two(list) do + length = length(list) + next_power = next_power_of_two(length) + padding_needed = next_power - length + zero_chunk = :binary.copy(<<0>>, 32) + list ++ List.duplicate(zero_chunk, padding_needed) + end + + defp next_power_of_two(n) when n <= 1, do: 1 + + defp next_power_of_two(n) do + 2 |> :math.pow(:math.ceil(:math.log2(n))) |> round() + end + + defp compute_domain(domain_type, fork_version, genesis_validators_root) do + fork_data_root = compute_container_hash_tree_root([fork_version, genesis_validators_root]) + domain_type <> binary_part(fork_data_root, 0, 28) + end + + defp compute_signing_root(deposit_message_root, domain) do + compute_container_hash_tree_root([deposit_message_root, domain]) + end + + defp compute_container_hash_tree_root(field_values) do + field_roots = + field_values + |> Enum.map(fn value -> + value + |> pack_basic_type() + |> merkleize_chunks() + end) + + merkleize_chunks(field_roots) + end +end diff --git a/apps/indexer/lib/indexer/fetcher/beacon/deposit/status.ex b/apps/indexer/lib/indexer/fetcher/beacon/deposit/status.ex new file mode 100644 index 000000000000..359e357f567e --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/beacon/deposit/status.ex @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Beacon.Deposit.Status do + @moduledoc """ + Fetches the status of beacon deposits. + """ + + use GenServer + use Indexer.Fetcher + + require Logger + + import Ecto.Query + + alias Ecto.Multi + alias Explorer.Chain.Beacon.Deposit + alias Explorer.Chain.Beacon.Deposit.Pending, as: PendingDeposit + alias Explorer.Chain.Wei + alias Explorer.Repo + alias Indexer.Fetcher.Beacon.{Blob, Client} + + def start_link(arguments, gen_server_options \\ []) do + GenServer.start_link(__MODULE__, arguments, gen_server_options) + end + + @impl GenServer + def init(_state) do + Logger.metadata(fetcher: :beacon_deposit_status) + + Process.send(self(), :fetch_queued_deposits, []) + + {:ok, nil} + end + + @impl GenServer + def handle_info(:fetch_queued_deposits, _state) do + case Client.get_pending_deposits("head") do + {:ok, %{"data" => pending_deposits}} -> mark_completed_deposits(pending_deposits) + {:error, reason} -> Logger.error("Failed to fetch pending deposits: #{inspect(reason)}") + end + + config = Application.get_env(:indexer, __MODULE__) + epoch_duration = config[:epoch_duration] + reference_timestamp = config[:reference_timestamp] + + current_time = System.os_time(:second) + epochs_elapsed = div(current_time - reference_timestamp, epoch_duration) + next_epoch_timestamp = (epochs_elapsed + 1) * epoch_duration + reference_timestamp + + timer = + Process.send_after( + self(), + :fetch_queued_deposits, + :timer.seconds(next_epoch_timestamp - current_time + 1) + ) + + {:noreply, timer} + end + + defp mark_completed_deposits(pending_deposits) do + {changes_list, max_block_timestamp} = + pending_deposits + |> Enum.reduce({[], DateTime.from_unix!(0)}, fn deposit, {acc, max_block_timestamp} -> + {slot, ""} = Integer.parse(deposit["slot"]) + block_timestamp = slot |> slot_to_timestamp() |> DateTime.from_unix!() + {amount, ""} = Integer.parse(deposit["amount"]) + + {[ + PendingDeposit.changeset(%PendingDeposit{}, %{ + pubkey: deposit["pubkey"], + withdrawal_credentials: deposit["withdrawal_credentials"], + amount: amount |> Decimal.new() |> Wei.from(:gwei) |> Wei.to(:wei), + signature: deposit["signature"], + block_timestamp: block_timestamp + }).changes + | acc + ], Enum.max([block_timestamp, max_block_timestamp], &DateTime.after?/2)} + end) + + Multi.new() + |> Multi.run(:create_temp_beacon_pending_deposits_table, fn repo, _changes -> + repo.query(""" + CREATE TEMP TABLE temp_beacon_pending_deposits ( + pubkey bytea, + withdrawal_credentials bytea, + amount numeric, + signature bytea, + block_timestamp timestamp + ) ON COMMIT DROP + """) + end) + |> Multi.run(:insert_pending_deposits, fn repo, _changes -> + {:ok, repo.safe_insert_all(PendingDeposit, changes_list, [])} + end) + |> Multi.update_all( + :mark_completed_deposits, + from(d in Deposit, + as: :deposit, + where: d.status == :pending, + where: d.block_timestamp <= ^max_block_timestamp, + where: + not exists( + from(pd in PendingDeposit, + where: pd.pubkey == parent_as(:deposit).pubkey, + where: pd.withdrawal_credentials == parent_as(:deposit).withdrawal_credentials, + where: pd.amount == parent_as(:deposit).amount, + where: pd.signature == parent_as(:deposit).signature, + where: pd.block_timestamp == parent_as(:deposit).block_timestamp + ) + ) + ), + [set: [status: :completed, updated_at: DateTime.utc_now()]], + timeout: :infinity + ) + |> Repo.transaction() + end + + defp slot_to_timestamp(slot) do + config = Application.get_env(:indexer, Blob) + slot_duration = config[:slot_duration] + reference_slot = config[:reference_slot] + reference_timestamp = config[:reference_timestamp] + (slot - reference_slot) * slot_duration + reference_timestamp + end +end diff --git a/apps/indexer/lib/indexer/fetcher/blackfort/validator.ex b/apps/indexer/lib/indexer/fetcher/blackfort/validator.ex index bd7715d52e4b..f18c9c4a0c0c 100644 --- a/apps/indexer/lib/indexer/fetcher/blackfort/validator.ex +++ b/apps/indexer/lib/indexer/fetcher/blackfort/validator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Blackfort.Validator do @moduledoc """ GenServer responsible for updating the list of blackfort validators in the database. diff --git a/apps/indexer/lib/indexer/fetcher/block_reward.ex b/apps/indexer/lib/indexer/fetcher/block_reward.ex index bbe62314b44b..f4de839d894d 100644 --- a/apps/indexer/lib/indexer/fetcher/block_reward.ex +++ b/apps/indexer/lib/indexer/fetcher/block_reward.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.BlockReward do @moduledoc """ Fetches `t:Explorer.Chain.Block.Reward.t/0` for a given `t:Explorer.Chain.Block.block_number/0`. @@ -45,7 +46,7 @@ defmodule Indexer.Fetcher.BlockReward do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -62,7 +63,7 @@ defmodule Indexer.Fetcher.BlockReward do @impl BufferedTask def init(initial, reducer, _) do {:ok, final} = - Chain.stream_blocks_without_rewards( + Block.stream_blocks_without_rewards( initial, fn %{number: number}, acc -> reducer.(number, acc) @@ -111,7 +112,7 @@ defmodule Indexer.Fetcher.BlockReward do defp hash_string_by_number(numbers) when is_list(numbers) do numbers - |> Chain.block_hash_by_number() + |> Block.block_hash_by_number() |> Enum.into(%{}, fn {number, hash} -> {number, to_string(hash)} end) @@ -189,10 +190,10 @@ defmodule Indexer.Fetcher.BlockReward do timestamp_by_block_hash = beneficiaries_params |> Enum.map(& &1.block_hash) - |> Chain.timestamp_by_block_hash() + |> Block.timestamp_by_block_hash() Enum.map(beneficiaries_params, fn %{block_hash: block_hash_str} = beneficiary -> - {:ok, block_hash} = Chain.string_to_block_hash(block_hash_str) + {:ok, block_hash} = Chain.string_to_full_hash(block_hash_str) case timestamp_by_block_hash do %{^block_hash => block_timestamp} -> @@ -209,7 +210,7 @@ defmodule Indexer.Fetcher.BlockReward do beneficiaries_params |> Stream.filter(&(&1.address_type == :validator)) |> Enum.map(& &1.block_hash) - |> Chain.gas_payment_by_block_hash() + |> Block.gas_payment_by_block_hash() Enum.map(beneficiaries_params, fn %{block_hash: block_hash, address_type: address_type} = beneficiary -> if address_type == :validator do diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations.ex index 11cd35dd6a36..2b1d70cef6a9 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations.ex @@ -1,25 +1,31 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.EpochBlockOperations do @moduledoc """ Tracks epoch blocks awaiting processing by the epoch fetcher. """ - import Explorer.Chain.Celo.Helper, - only: [ - epoch_block_number?: 1, - premigration_block_number?: 1 - ] + import Ecto.Query, only: [from: 2] - alias Explorer.Chain - alias Explorer.Chain.Block - alias Explorer.Chain.Celo.PendingEpochBlockOperation - alias Indexer.Fetcher.Celo.EpochBlockOperations.Supervisor, as: EpochBlockOperationsSupervisor - alias Indexer.Transform.Addresses + alias Ecto.Multi + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Block, Import} + alias Explorer.Chain.Celo.{Epoch, Helper} + alias Explorer.Chain.Celo.Reader.EpochManager alias Indexer.{BufferedTask, Tracer} + alias Indexer.Transform.Addresses + + alias Explorer.Chain.Import.Runner.Celo.{ + AggregatedElectionRewards, + ElectionRewards, + EpochRewards, + Epochs + } alias Indexer.Fetcher.Celo.EpochBlockOperations.{ - DelegatedPayments, + DelegatedPaymentsPriorL2Migration, Distributions, - ValidatorAndGroupPayments, + ValidatorAndGroupPaymentsPostL2Migration, + ValidatorAndGroupPaymentsPriorL2Migration, VoterPayments } @@ -37,7 +43,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec` " <> "to allow for json_rpc calls when running." @@ -53,11 +59,11 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations do def defaults do [ - poll: false, + poll: true, flush_interval: :timer.seconds(3), max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, - task_supervisor: Indexer.Fetcher.Celo.EpochBlockOperations.TaskSupervisor, + task_supervisor: __MODULE__.TaskSupervisor, metadata: [fetcher: :celo_epoch_rewards] ] end @@ -68,15 +74,12 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations do integer() ) :: :ok def async_fetch(entries, realtime?, timeout \\ 5000) when is_list(entries) do - if EpochBlockOperationsSupervisor.disabled?() do + if __MODULE__.Supervisor.disabled?() do :ok else filtered_entries = - Enum.filter( - entries, - &(epoch_block_number?(&1.block_number) && - premigration_block_number?(&1.block_number)) - ) + entries + |> Enum.filter(&(&1.start_processing_block_hash && &1.end_processing_block_hash)) BufferedTask.buffer(__MODULE__, filtered_entries, realtime?, timeout) end @@ -85,7 +88,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations do @impl BufferedTask def init(initial, reducer, _json_rpc_named_arguments) do {:ok, final} = - PendingEpochBlockOperation.stream_premigration_epoch_blocks_with_unfetched_rewards( + Epoch.stream_unfetched_epochs( initial, reducer, true @@ -101,56 +104,197 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations do service: :indexer, tracer: Tracer ) - def run(pending_operations, json_rpc_named_arguments) do - Enum.each(pending_operations, fn pending_operation -> - fetch(pending_operation, json_rpc_named_arguments) + def run(epochs, json_rpc_named_arguments) do + epochs + |> Repo.preload([ + :start_processing_block, + :end_processing_block + ]) + |> Enum.each(fn epoch -> + epoch + |> fetch(json_rpc_named_arguments) end) :ok end - defp fetch(pending_operation, json_rpc_named_arguments) do - {:ok, distributions} = Distributions.fetch(pending_operation) - {:ok, validator_and_group_payments} = ValidatorAndGroupPayments.fetch(pending_operation) + defp fetch(epoch, json_rpc_named_arguments) do + election_rewards_params = fetch_election_rewards_params(epoch, json_rpc_named_arguments) + aggregated_election_rewards_params = aggregate_election_rewards(election_rewards_params) + epoch_params = fetch_epoch_params(epoch) + {:ok, distributions_params} = Distributions.fetch(epoch) + + epochs_params = + (epoch.number + 1) + |> Epoch.epoch_by_number_query() + |> Repo.exists?() + |> if do + [epoch_params] + else + next_epoch_params = %{ + number: epoch_params.number + 1, + start_block_number: epoch_params.end_block_number + 1 + } + + [epoch_params, next_epoch_params] + end + + addresses_params = + Addresses.extract_addresses(%{ + celo_election_rewards: election_rewards_params + }) + + {:ok, _imported_addresses} = Chain.import(%{addresses: %{params: addresses_params}}) + + {:ok, import_multi} = + Import.all_single_multi( + [ + Epochs, + ElectionRewards, + EpochRewards, + AggregatedElectionRewards + ], + %{ + celo_epoch_rewards: %{params: [distributions_params]}, + celo_election_rewards: %{params: election_rewards_params}, + celo_epochs: %{params: epochs_params}, + celo_aggregated_election_rewards: %{params: aggregated_election_rewards_params} + } + ) + + Multi.new() + |> Multi.run( + :acquire_processing_blocks, + fn repo, _changes -> + acquire_processing_blocks(repo, epoch) + end + ) + |> Multi.append(import_multi) + |> Repo.transaction() + |> case do + {:ok, results} -> + Logger.info("Successfully fetched and imported epoch rewards for epoch number: #{epoch.number}") + {:ok, results} + + {:error, :acquire_processing_blocks, :processing_blocks_not_consensus, _changes} -> + Logger.error( + "Skipped importing epoch rewards for epoch #{epoch.number} since processing blocks are not consensus" + ) + + {:error, :processing_blocks_not_consensus} + + {:error, operation, reason, _changes} -> + Logger.error("Failed importing epoch rewards for epoch #{epoch.number} on #{operation}: #{inspect(reason)}") + {:error, reason} + end + end + + defp aggregate_election_rewards(election_rewards_params) do + election_rewards_params + |> Enum.group_by(&{&1.epoch_number, &1.type}) + |> Enum.map(fn {{epoch_number, type}, rewards} -> + %{ + epoch_number: epoch_number, + type: type, + count: length(rewards), + sum: + rewards + |> Enum.reduce( + Decimal.new(0), + fn r, acc -> Decimal.add(acc, r.amount) end + ) + } + end) + end + defp fetch_election_rewards_params(epoch, json_rpc_named_arguments) do {:ok, voter_payments} = VoterPayments.fetch( - pending_operation, + epoch, json_rpc_named_arguments ) - {:ok, delegated_payments} = - validator_and_group_payments - |> Enum.filter(&(&1.type == :validator)) - |> Enum.map(& &1.account_address_hash) - |> DelegatedPayments.fetch( - pending_operation, - json_rpc_named_arguments + {:ok, validator_and_group_payments} = + if Helper.pre_migration_block_number?(epoch.start_processing_block.number) do + ValidatorAndGroupPaymentsPriorL2Migration.fetch(epoch) + else + ValidatorAndGroupPaymentsPostL2Migration.fetch(epoch) + end + + {:ok, delegated_payments_prior_l2_migration} = + if Helper.pre_migration_block_number?(epoch.start_processing_block.number) do + validator_and_group_payments + |> Enum.filter(&(&1.type == :validator)) + |> Enum.map(& &1.account_address_hash) + |> DelegatedPaymentsPriorL2Migration.fetch( + epoch, + json_rpc_named_arguments + ) + else + {:ok, []} + end + + [ + voter_payments, + validator_and_group_payments, + delegated_payments_prior_l2_migration + ] + |> Enum.concat() + |> Enum.filter(&(&1.amount > 0)) + end + + defp acquire_processing_blocks(repo, epoch) do + # First verify start block consensus and lock it to prevent changes during our operation + + query = + from(b in Block.consensus_blocks_query(), + where: + b.hash in ^[ + epoch.start_processing_block_hash, + epoch.end_processing_block_hash + ], + order_by: [asc: b.hash], + lock: "FOR SHARE" ) - election_rewards = - [ - validator_and_group_payments, - voter_payments, - delegated_payments - ] - |> Enum.concat() - |> Enum.filter(&(&1.amount > 0)) + premigration? = Helper.pre_migration_block_number?(epoch.start_processing_block.number) - addresses_params = - Addresses.extract_addresses(%{ - celo_election_rewards: election_rewards - }) + query + |> repo.all() + |> case do + [_] = blocks when premigration? -> + {:ok, blocks} - {:ok, imported} = - Chain.import(%{ - addresses: %{params: addresses_params}, - celo_election_rewards: %{params: election_rewards}, - celo_epoch_rewards: %{params: [distributions]} - }) + [_, _] = blocks -> + {:ok, blocks} + + [] -> + {:error, :processing_blocks_not_consensus} + end + end - Logger.info("Fetched epoch rewards for block number: #{pending_operation.block_number}") + defp fetch_epoch_params(epoch) do + params = %{number: epoch.number, fetched?: true} - {:ok, imported} + if Helper.pre_migration_block_number?(epoch.start_processing_block.number) do + { + start_block_number, + end_block_number + } = Helper.epoch_number_to_block_range(epoch.number) + + params + |> Map.put(:start_block_number, start_block_number) + |> Map.put(:end_block_number, end_block_number) + else + {:ok, start_block_number} = + EpochManager.fetch_first_block_at_epoch(epoch.number) + + {:ok, end_block_number} = + EpochManager.fetch_last_block_at_epoch(epoch.number) + + params + |> Map.put(:start_block_number, start_block_number) + |> Map.put(:end_block_number, end_block_number) + end end end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/core_contract_version.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/core_contract_version.ex index d6c011de9b12..b23ad15a8c4d 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/core_contract_version.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/core_contract_version.ex @@ -1,8 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.EpochBlockOperations.CoreContractVersion do @moduledoc """ Fetches the version of the celo core contract. """ - import Indexer.Fetcher.Celo.Helper, only: [abi_to_method_id: 1] + import Explorer.Helper, only: [abi_to_method_id: 1] import Indexer.Helper, only: [read_contracts_with_retries: 5] @repeated_request_max_retries 3 diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/delegated_payments.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/delegated_payments_prior_l2_migration.ex similarity index 82% rename from apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/delegated_payments.ex rename to apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/delegated_payments_prior_l2_migration.ex index 8569019a3b42..58ebfd553ff4 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/delegated_payments.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/delegated_payments_prior_l2_migration.ex @@ -1,14 +1,23 @@ -defmodule Indexer.Fetcher.Celo.EpochBlockOperations.DelegatedPayments do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Celo.EpochBlockOperations.DelegatedPaymentsPriorL2Migration do @moduledoc """ Fetches delegated validator payments for the epoch block. """ import Ecto.Query, only: [from: 2] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - import Indexer.Fetcher.Celo.Helper, only: [abi_to_method_id: 1] - import Indexer.Helper, only: [read_contracts_with_retries: 4] + import Explorer.Helper, only: [abi_to_method_id: 1] + + import Indexer.Helper, + only: [ + read_contracts_with_retries_by_chunks: 3, + read_contracts_with_retries: 4 + ] + + alias Explorer.Chain.{Block, Hash, TokenTransfer} alias Explorer.Chain.Cache.CeloCoreContracts - alias Explorer.Chain.{Hash, TokenTransfer} + alias Explorer.Chain.Celo.Epoch + alias Explorer.Chain.Wei alias Explorer.Repo alias Indexer.Fetcher.Celo.EpochBlockOperations.CoreContractVersion @@ -17,6 +26,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.DelegatedPayments do @mint_address_hash_string burn_address_hash_string() @repeated_request_max_retries 3 + @requests_chunk_size 100 # The method `getPaymentDelegation` was introduced in the following. Thus, we # set version hardcoded in `getVersionNumber` method. @@ -43,17 +53,14 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.DelegatedPayments do @spec fetch( [EthereumJSONRPC.address()], - %{ - :block_hash => EthereumJSONRPC.hash(), - :block_number => EthereumJSONRPC.block_number() - }, + Epoch.t(), EthereumJSONRPC.json_rpc_named_arguments() ) :: {:ok, list()} | {:error, any()} def fetch( validator_addresses, - %{block_number: block_number, block_hash: block_hash} = _pending_operation, + %Epoch{start_processing_block: %Block{number: block_number, hash: block_hash}} = epoch, json_rpc_named_arguments ) do with {:ok, accounts_contract_address} <- @@ -98,12 +105,12 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.DelegatedPayments do |> Enum.filter(&match?({_, {:ok, [_, fraction]}} when fraction > 0, &1)) |> Enum.map(fn {validator_address, {:ok, [beneficiary_address, _]}} -> - amount = Map.get(beneficiary_address_to_amount, beneficiary_address, 0) + amount = beneficiary_address_to_amount |> Map.get(beneficiary_address, 0) %{ - block_hash: block_hash, + epoch_number: epoch.number, account_address_hash: beneficiary_address, - amount: amount, + amount: %Wei{value: amount}, associated_account_address_hash: validator_address, type: :delegated_payment } @@ -153,14 +160,20 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.DelegatedPayments do &%{ contract_address: accounts_contract_address, method_id: @get_payment_delegation_method_id, - args: [&1], + args: [to_string(&1)], block_number: block_number } ) - |> read_contracts_with_retries( - @get_payment_delegation_abi, - json_rpc_named_arguments, - @repeated_request_max_retries + |> read_contracts_with_retries_by_chunks( + @requests_chunk_size, + fn requests -> + read_contracts_with_retries( + requests, + @get_payment_delegation_abi, + json_rpc_named_arguments, + @repeated_request_max_retries + ) + end ) end end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/distributions.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/distributions.ex index e21ce3f535b4..8a4019209baf 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/distributions.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/distributions.ex @@ -1,44 +1,40 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.EpochBlockOperations.Distributions do @moduledoc """ Fetches Reserve bolster, Community, and Carbon offsetting distributions for the epoch block. """ + use Utils.RuntimeEnvHelper, + celo_unreleased_treasury_contract_address: [ + :explorer, + [:celo, :unreleased_treasury_contract_address] + ] + import Ecto.Query, only: [from: 2, subquery: 1] + import Explorer.Chain.Celo.Helper, only: [pre_migration_block_number?: 1] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - alias Explorer.Repo - alias Explorer.Chain.{ + Block, Cache.CeloCoreContracts, Hash, TokenTransfer } - @mint_address_hash_string burn_address_hash_string() + alias Explorer.Chain.Celo.Epoch + alias Explorer.Repo - @spec fetch(%{ - :block_hash => EthereumJSONRPC.hash(), - :block_number => EthereumJSONRPC.block_number() - }) :: + @spec fetch(Epoch.t()) :: {:ok, map()} | {:error, :multiple_transfers_to_same_address} - def fetch(%{block_number: block_number, block_hash: block_hash} = _pending_operation) do - {:ok, celo_token_contract_address_hash} = CeloCoreContracts.get_address(:celo_token, block_number) + def fetch(%{end_processing_block: %Block{number: block_number, hash: block_hash}} = epoch) do {:ok, reserve_contract_address_hash} = CeloCoreContracts.get_address(:reserve, block_number) {:ok, community_contract_address_hash} = CeloCoreContracts.get_address(:governance, block_number) {:ok, %{"address" => carbon_offsetting_contract_address_hash}} = CeloCoreContracts.get_event(:epoch_rewards, :carbon_offsetting_fund_set, block_number) - celo_mint_transfers_query = - from( - tt in TokenTransfer.only_consensus_transfers_query(), - where: - tt.block_hash == ^block_hash and - tt.token_contract_address_hash == ^celo_token_contract_address_hash and - tt.from_address_hash == ^@mint_address_hash_string and - is_nil(tt.transaction_hash) - ) + celo_distributions_query = celo_distributions_query(block_hash, block_number) # Every epoch has at least one CELO transfer from the zero address to the # reserve. This is how cUSD is minted before it is distributed to @@ -49,7 +45,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.Distributions do from( tt in subquery( from( - tt in subquery(celo_mint_transfers_query), + tt in subquery(celo_distributions_query), where: tt.to_address_hash == ^reserve_contract_address_hash, order_by: tt.log_index, offset: 1 @@ -60,7 +56,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.Distributions do query = from( - tt in subquery(celo_mint_transfers_query), + tt in subquery(celo_distributions_query), where: tt.to_address_hash in ^[ community_contract_address_hash, @@ -91,11 +87,33 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.Distributions do key = Map.get(address_to_key, address |> Hash.to_string()) Map.put(acc, key, log_index) end) - |> Map.put(:block_hash, block_hash) + |> Map.put(:epoch_number, epoch.number) {:ok, distributions} else {:error, :multiple_transfers_to_same_address} end end + + defp celo_distributions_query(block_hash, block_number) do + {:ok, celo_token_contract_address_hash} = CeloCoreContracts.get_address(:celo_token, block_number) + + query = + from( + tt in TokenTransfer.only_consensus_transfers_query(), + where: + tt.block_hash == ^block_hash and + tt.token_contract_address_hash == ^celo_token_contract_address_hash + ) + + if pre_migration_block_number?(block_number) do + celo_sender_address_hash = burn_address_hash_string() + + from(tt in query, where: tt.from_address_hash == ^celo_sender_address_hash and is_nil(tt.transaction_hash)) + else + celo_sender_address_hash = celo_unreleased_treasury_contract_address() + + from(tt in query, where: tt.from_address_hash == ^celo_sender_address_hash) + end + end end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments.ex deleted file mode 100644 index f677d7439442..000000000000 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments.ex +++ /dev/null @@ -1,67 +0,0 @@ -defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPayments do - @moduledoc """ - Fetches validator and group payments for the epoch block. - """ - import Ecto.Query, only: [from: 2] - - alias Explorer.Chain.Cache.CeloCoreContracts - alias Explorer.Chain.Log - alias Explorer.Repo - alias Indexer.Transform.Celo.ValidatorEpochPaymentDistributions - - @spec fetch(%{ - :block_hash => EthereumJSONRPC.hash(), - :block_number => EthereumJSONRPC.block_number() - }) :: {:ok, list()} - def fetch(%{block_number: block_number, block_hash: block_hash} = _pending_operation) do - epoch_payment_distributions_signature = ValidatorEpochPaymentDistributions.signature() - {:ok, validators_contract_address} = CeloCoreContracts.get_address(:validators, block_number) - - query = - from( - log in Log, - where: - log.block_hash == ^block_hash and - log.address_hash == ^validators_contract_address and - log.first_topic == ^epoch_payment_distributions_signature and - is_nil(log.transaction_hash), - select: log - ) - - payments = - query - |> Repo.all() - |> ValidatorEpochPaymentDistributions.parse() - |> process_distribution_events(block_hash) - - {:ok, payments} - end - - defp process_distribution_events(distribution_events, block_hash) do - distribution_events - |> Enum.map(fn %{ - validator_address: validator_address, - validator_payment: validator_payment, - group_address: group_address, - group_payment: group_payment - } -> - [ - %{ - block_hash: block_hash, - account_address_hash: validator_address, - amount: validator_payment, - associated_account_address_hash: group_address, - type: :validator - }, - %{ - block_hash: block_hash, - account_address_hash: group_address, - amount: group_payment, - associated_account_address_hash: validator_address, - type: :group - } - ] - end) - |> Enum.concat() - end -end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex new file mode 100644 index 000000000000..574574498c34 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_post_l2_migration.ex @@ -0,0 +1,328 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPaymentsPostL2Migration do + @moduledoc """ + Fetches validator and group payments for the epoch post L2 migration. + """ + require Logger + + use Utils.RuntimeEnvHelper, + epoch_manager_contract_address_hash: [ + :explorer, + [:celo, :epoch_manager_contract_address] + ], + validators_contract_address_hash: [ + :explorer, + [:celo, :validators_contract_address] + ], + json_rpc_named_arguments: [:indexer, :json_rpc_named_arguments] + + import Indexer.Helper, + only: [ + read_contracts_with_retries_by_chunks: 3, + read_contracts_with_retries: 4 + ] + + import Ecto.Query, only: [from: 2] + import Explorer.Helper, only: [abi_to_method_id: 1] + + alias Explorer.Chain.{Block, Log} + alias Explorer.Chain.Celo.Epoch + alias Explorer.Repo + + @repeated_request_max_retries 3 + @requests_chunk_size 100 + + @number_of_elected_in_current_set_abi [ + %{ + "name" => "numberOfElectedInCurrentSet", + "type" => "function", + "stateMutability" => "view", + "inputs" => [], + "outputs" => [%{"type" => "uint256"}] + } + ] + + @get_elected_account_by_index_abi [ + %{ + "name" => "getElectedAccountByIndex", + "type" => "function", + "stateMutability" => "view", + "inputs" => [%{"type" => "uint256"}], + "outputs" => [%{"type" => "address"}] + } + ] + + @validator_pending_payments_abi [ + %{ + "name" => "validatorPendingPayments", + "type" => "function", + "stateMutability" => "view", + "inputs" => [%{"type" => "address"}], + "outputs" => [%{"type" => "uint256"}] + } + ] + + @get_validators_group_abi [ + %{ + "name" => "getValidatorsGroup", + "type" => "function", + "stateMutability" => "view", + "inputs" => [%{"type" => "address"}], + "outputs" => [%{"type" => "address"}] + } + ] + + @get_validator_group_abi [ + %{ + "name" => "getValidatorGroup", + "type" => "function", + "stateMutability" => "view", + "inputs" => [%{"type" => "address"}], + "outputs" => [ + %{"type" => "address[]"}, + %{"type" => "uint256"}, + %{"type" => "uint256"}, + %{"type" => "uint256"}, + %{"type" => "uint256[]"}, + %{"type" => "uint256"}, + %{"type" => "uint256"} + ] + } + ] + + @validator_epoch_payment_distributed_event_topic "0xee2788e7abedfc61d9608e143b172de1a608a4298b06ed8c84838aa0ad6bd136" + + def fetch(%Epoch{number: epoch_number, start_processing_block: %Block{number: block_number, hash: block_hash}}) do + with false <- + check_if_validator_payment_distributed_events_exist(block_hash), + {:ok, length} <- number_of_elected_in_current_set(block_number), + {:ok, account_address_hashes} <- get_elected_accounts(block_number, length), + {:ok, payments_before} <- get_allocated_payments(account_address_hashes, block_number - 1), + {:ok, payments_after} <- get_allocated_payments(account_address_hashes, block_number), + {:ok, group_address_hashes} <- get_validator_groups(account_address_hashes, block_number), + unique_group_address_hashes = Enum.uniq(group_address_hashes), + {:ok, group_commissions} <- get_validator_group_commissions(unique_group_address_hashes, block_number) do + account_address_hash_to_group_address_hash = + account_address_hashes + |> Enum.zip(group_address_hashes) + |> Map.new() + + group_address_hash_to_commission = + unique_group_address_hashes + |> Enum.zip(group_commissions) + |> Map.new() + + payments = Enum.zip_with(payments_after, payments_before, &(&1 - &2)) + + params = + account_address_hashes + |> Enum.zip(payments) + |> Enum.flat_map(fn {account_address_hash, payment} -> + group_address_hash = Map.get(account_address_hash_to_group_address_hash, account_address_hash) + + base = Decimal.new(1, 1, 24) + + commission = + group_address_hash_to_commission + |> Map.get(group_address_hash) + |> Decimal.new() + |> Decimal.div(base) + + payment = payment |> Decimal.new() + group_payment = payment |> Decimal.mult(commission) + validator_payment = payment |> Decimal.sub(group_payment) + + [ + %{ + epoch_number: epoch_number, + account_address_hash: account_address_hash, + amount: validator_payment, + associated_account_address_hash: group_address_hash, + type: :validator + }, + %{ + epoch_number: epoch_number, + account_address_hash: group_address_hash, + amount: group_payment, + associated_account_address_hash: account_address_hash, + type: :group + } + ] + end) + + {:ok, params} + else + true -> + # TODO: As invariant, we assume that the validator payment distributed + # event is not present in the block. If it is present, this is a corner + # case that should be addressed in the future. + Logger.error("Validator payment distributed event exists for block hash: #{block_hash}. Aborting.") + {:error, "Validator payment distributed event exists"} + + {:error, reason} -> + Logger.error("Failed to fetch validator and group payments: #{inspect(reason)}") + {:error, reason} + end + end + + @spec number_of_elected_in_current_set(EthereumJSONRPC.block_number()) :: + {:ok, integer()} | {:error, any()} + defp number_of_elected_in_current_set(block_number) do + method_id = @number_of_elected_in_current_set_abi |> abi_to_method_id() + + [ + %{ + contract_address: epoch_manager_contract_address_hash(), + method_id: method_id, + args: [], + block_number: block_number + } + ] + |> read_contracts_with_retries( + @number_of_elected_in_current_set_abi, + json_rpc_named_arguments(), + @repeated_request_max_retries + ) + |> case do + {[ok: [value]], []} -> + {:ok, value} + + {_, errors} -> + {:error, errors} + end + end + + defp get_elected_accounts(block_number, length) when length > 0 do + method_id = @get_elected_account_by_index_abi |> abi_to_method_id() + + 0..(length - 1) + |> Enum.map(fn index -> + %{ + contract_address: epoch_manager_contract_address_hash(), + method_id: method_id, + args: [index], + block_number: block_number + } + end) + |> read_contract(@get_elected_account_by_index_abi) + |> case do + {responses, []} -> + address_hashes = + responses + |> Enum.map(fn {:ok, [address_hash]} -> address_hash end) + + {:ok, address_hashes} + + {_, errors} -> + {:error, errors} + end + end + + defp get_allocated_payments(account_address_hashes, block_number) do + method_id = @validator_pending_payments_abi |> abi_to_method_id() + + account_address_hashes + |> Enum.map(fn address_hash -> + %{ + contract_address: epoch_manager_contract_address_hash(), + method_id: method_id, + args: [address_hash], + block_number: block_number + } + end) + |> read_contract(@validator_pending_payments_abi) + |> case do + {responses, []} -> + values = + responses + |> Enum.map(fn {:ok, [value]} -> value end) + + {:ok, values} + + {_, errors} -> + {:error, errors} + end + end + + defp get_validator_groups(account_address_hashes, block_number) do + method_id = @get_validators_group_abi |> abi_to_method_id() + + account_address_hashes + |> Enum.map(fn address_hash -> + %{ + contract_address: validators_contract_address_hash(), + method_id: method_id, + args: [address_hash], + block_number: block_number + } + end) + |> read_contract(@get_validators_group_abi) + |> case do + {responses, []} -> + values = + responses + |> Enum.map(fn {:ok, [value]} -> value end) + + {:ok, values} + + {_, errors} -> + {:error, errors} + end + end + + defp get_validator_group_commissions(unique_group_address_hashes, block_number) do + method_id = @get_validator_group_abi |> abi_to_method_id() + + unique_group_address_hashes + |> Enum.map(fn address_hash -> + %{ + contract_address: validators_contract_address_hash(), + method_id: method_id, + args: [address_hash], + block_number: block_number + } + end) + |> read_contract(@get_validator_group_abi) + |> case do + {responses, []} -> + values = + responses + |> Enum.map(fn {:ok, [_, value | _]} -> value end) + + {:ok, values} + + {_, errors} -> + {:error, errors} + end + end + + defp read_contract(requests, abi) do + read_contracts_with_retries_by_chunks( + requests, + @requests_chunk_size, + fn chunk -> + read_contracts_with_retries( + chunk, + abi, + json_rpc_named_arguments(), + @repeated_request_max_retries + ) + end + ) + end + + defp check_if_validator_payment_distributed_events_exist(block_hash) do + query = + from( + log in Log, + where: [ + block_hash: ^block_hash, + address_hash: ^epoch_manager_contract_address_hash(), + first_topic: ^@validator_epoch_payment_distributed_event_topic + ] + ) + + query + |> Repo.exists?() + end +end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex new file mode 100644 index 000000000000..4636e723dc77 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/validator_and_group_payments_prior_l2_migration.ex @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Celo.EpochBlockOperations.ValidatorAndGroupPaymentsPriorL2Migration do + @moduledoc """ + Fetches validator and group payments for the epoch block. + """ + import Ecto.Query, only: [from: 2] + + alias Explorer.Chain.{Block, Celo.Epoch, Log} + alias Explorer.Chain.Cache.CeloCoreContracts + alias Explorer.Repo + alias Indexer.Transform.Celo.ValidatorEpochPaymentDistributions + + @spec fetch(Epoch.t()) :: {:ok, list()} + def fetch(%Epoch{start_processing_block: %Block{number: block_number, hash: block_hash}} = epoch) do + epoch_payment_distributions_signature = ValidatorEpochPaymentDistributions.signature() + {:ok, validators_contract_address} = CeloCoreContracts.get_address(:validators, block_number) + + query = + from( + log in Log, + where: + log.block_hash == ^block_hash and + log.address_hash == ^validators_contract_address and + log.first_topic == ^epoch_payment_distributions_signature and + is_nil(log.transaction_hash), + select: log + ) + + payments = + query + |> Repo.all() + |> ValidatorEpochPaymentDistributions.parse() + |> Enum.flat_map(fn %{ + validator_address: validator_address, + validator_payment: validator_payment, + group_address: group_address, + group_payment: group_payment + } -> + [ + %{ + epoch_number: epoch.number, + account_address_hash: validator_address, + amount: validator_payment, + associated_account_address_hash: group_address, + type: :validator + }, + %{ + epoch_number: epoch.number, + account_address_hash: group_address, + amount: group_payment, + associated_account_address_hash: validator_address, + type: :group + } + ] + end) + + {:ok, payments} + end +end diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex index d526feb0c7e7..2c2928a465a5 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_block_operations/voter_payments.ex @@ -1,18 +1,28 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do @moduledoc """ Fetches voter payments for the epoch block. """ - import Ecto.Query, only: [from: 2] + import Ecto.Query, only: [from: 2, select: 3] - import Explorer.Helper, only: [decode_data: 2] - import Indexer.Fetcher.Celo.Helper, only: [abi_to_method_id: 1] - import Indexer.Helper, only: [read_contracts_with_retries: 4] + import Explorer.Helper, + only: [ + decode_data: 2, + abi_to_method_id: 1 + ] + + import Indexer.Helper, + only: [ + read_contracts_with_retries_by_chunks: 3, + read_contracts_with_retries: 4 + ] alias Explorer.Repo alias Indexer.Fetcher.Celo.ValidatorGroupVotes alias Explorer.Chain.{ Cache.CeloCoreContracts, + Celo.Epoch, Celo.ValidatorGroupVote, Hash, Log @@ -22,6 +32,8 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do @repeated_request_max_retries 3 + @requests_chunk_size 100 + @epoch_rewards_distributed_to_voters_topic "0x91ba34d62474c14d6c623cd322f4256666c7a45b7fdaa3378e009d39dfcec2a7" @get_active_votes_for_group_by_account_abi [ @@ -44,38 +56,29 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do @get_active_votes_for_group_by_account_method_id @get_active_votes_for_group_by_account_abi |> abi_to_method_id() - @spec fetch( - %{ - :block_hash => EthereumJSONRPC.hash(), - :block_number => EthereumJSONRPC.block_number() - }, - EthereumJSONRPC.json_rpc_named_arguments() - ) :: {:error, list()} | {:ok, list()} + @spec fetch(Epoch.t(), EthereumJSONRPC.json_rpc_named_arguments()) :: + {:error, list()} | {:ok, list()} def fetch( - %{block_number: block_number, block_hash: block_hash} = pending_operation, + %Epoch{start_processing_block: start_block, end_processing_block: end_block} = epoch, json_rpc_named_arguments ) do - :ok = ValidatorGroupVotes.fetch(block_number) + :ok = ValidatorGroupVotes.fetch(end_block.number) - {:ok, election_contract_address} = CeloCoreContracts.get_address(:election, block_number) + {:ok, election_contract_address} = CeloCoreContracts.get_address(:election, start_block.number) elected_groups_query = - from( - l in Log, - where: - l.block_hash == ^block_hash and - l.address_hash == ^election_contract_address and - l.first_topic == ^@epoch_rewards_distributed_to_voters_topic and - is_nil(l.transaction_hash), - select: fragment("SUBSTRING(? from 13)", l.second_topic) - ) + start_block.number + |> epoch_rewards_distributed_to_voters_query(end_block.number) + |> select([l], fragment("SUBSTRING(? from 13)", l.second_topic)) + + end_block_number = end_block.number query = from( v in ValidatorGroupVote, where: v.group_address_hash in subquery(elected_groups_query) and - v.block_number <= ^block_number, + v.block_number <= ^end_block_number, distinct: true, select: {v.account_address_hash, v.group_address_hash} ) @@ -94,7 +97,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do requests = accounts_with_activated_votes |> Enum.map(fn {account_address_hash, group_address_hash} -> - (block_number - 1)..block_number + (end_block_number - 1)..end_block_number |> Enum.map(fn block_number -> %{ contract_address: election_contract_address, @@ -110,11 +113,17 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do |> Enum.concat() {responses, []} = - read_contracts_with_retries( + read_contracts_with_retries_by_chunks( requests, - @get_active_votes_for_group_by_account_abi, - json_rpc_named_arguments, - @repeated_request_max_retries + @requests_chunk_size, + fn requests -> + read_contracts_with_retries( + requests, + @get_active_votes_for_group_by_account_abi, + json_rpc_named_arguments, + @repeated_request_max_retries + ) + end ) diffs = @@ -138,7 +147,7 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do diffs, fn {account_address_hash, group_address_hash}, diff -> %{ - block_hash: block_hash, + epoch_number: epoch.number, account_address_hash: account_address_hash, amount: diff, associated_account_address_hash: group_address_hash, @@ -147,29 +156,35 @@ defmodule Indexer.Fetcher.Celo.EpochBlockOperations.VoterPayments do end ) - ok_or_error = validate_voter_rewards(pending_operation, rewards) + ok_or_error = validate_voter_rewards(start_block.number, end_block.number, rewards) {ok_or_error, rewards} end + defp epoch_rewards_distributed_to_voters_query(start_block_number, end_block_number) do + {:ok, election_contract_address} = CeloCoreContracts.get_address(:election, start_block_number) + + from( + l in Log, + where: + l.block_number >= ^start_block_number and + l.block_number <= ^end_block_number and + l.address_hash == ^election_contract_address and + l.first_topic == ^@epoch_rewards_distributed_to_voters_topic + ) + end + # Validates voter rewards by comparing the sum of what we got from the # `EpochRewardsDistributedToVoters` event and the sum of what we calculated # manually by fetching the votes for each account that has or had an activated # vote. - defp validate_voter_rewards(pending_operation, voter_rewards) do + defp validate_voter_rewards(start_block_number, end_block_number, voter_rewards) do manual_voters_total = voter_rewards |> Enum.map(& &1.amount) |> Enum.sum() - {:ok, election_contract_address} = CeloCoreContracts.get_address(:election, pending_operation.block_number) query = - from( - l in Log, - where: - l.block_hash == ^pending_operation.block_hash and - l.address_hash == ^election_contract_address and - l.first_topic == ^@epoch_rewards_distributed_to_voters_topic and - is_nil(l.transaction_hash), - select: l.data - ) + start_block_number + |> epoch_rewards_distributed_to_voters_query(end_block_number) + |> select([l], l.data) voter_rewards_from_event_total = query diff --git a/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex b/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex index d7ab7b592494..d6c5cc9b4b40 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/epoch_logs.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.EpochLogs do @moduledoc """ Fetches logs that are not linked to transaction, but to the block. @@ -6,9 +7,12 @@ defmodule Indexer.Fetcher.Celo.EpochLogs do import Explorer.Chain.Celo.Helper, only: [ epoch_block_number?: 1, - premigration_block_number?: 1 + pre_migration_block_number?: 1 ] + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + alias EthereumJSONRPC.{Logs, Transport} alias Explorer.Chain.Cache.CeloCoreContracts alias Indexer.Helper, as: IndexerHelper @@ -44,7 +48,7 @@ defmodule Indexer.Fetcher.Celo.EpochLogs do def fetch(blocks, json_rpc_named_arguments) def fetch(blocks, json_rpc_named_arguments) do - if Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() == {:optimism, :celo} do do_fetch(blocks, json_rpc_named_arguments) else [] @@ -58,7 +62,7 @@ defmodule Indexer.Fetcher.Celo.EpochLogs do defp do_fetch(blocks, json_rpc_named_arguments) do requests = blocks - |> Enum.filter(&premigration_block_number?(&1.number)) + |> Enum.filter(&pre_migration_block_number?(&1.number)) |> Enum.reduce({[], 0}, &blocks_reducer/2) |> elem(0) |> Enum.reverse() diff --git a/apps/indexer/lib/indexer/fetcher/celo/helper.ex b/apps/indexer/lib/indexer/fetcher/celo/helper.ex deleted file mode 100644 index ec178397cd1f..000000000000 --- a/apps/indexer/lib/indexer/fetcher/celo/helper.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule Indexer.Fetcher.Celo.Helper do - @moduledoc """ - Helper functions for the Celo fetchers. - """ - - @doc """ - Extracts the method ID from an ABI specification. - - ## Parameters - - `method` ([map()] | map()): The ABI specification, either as a single map - or a list containing one map. - - ## Returns - - `binary()`: The method ID extracted from the ABI specification. - - ## Examples - - iex> Indexer.Fetcher.Celo.Helper.abi_to_method_id([%{"name" => "transfer", "type" => "function", "inputs" => [%{"name" => "to", "type" => "address"}]}]) - <<26, 105, 82, 48>> - - """ - @spec abi_to_method_id([map()] | map()) :: binary() - def abi_to_method_id([method]), do: abi_to_method_id(method) - - def abi_to_method_id(method) when is_map(method) do - [parsed_method] = ABI.parse_specification([method]) - parsed_method.method_id - end -end diff --git a/apps/indexer/lib/indexer/fetcher/celo/legacy/account.ex b/apps/indexer/lib/indexer/fetcher/celo/legacy/account.ex new file mode 100644 index 000000000000..7a15ed786a61 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/celo/legacy/account.ex @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Celo.Legacy.Account do + @moduledoc """ + Asynchronously fetches Celo blockchain account data from event logs and + imports them into the database. + + This fetcher processes blockchain logs to extract account-related events, + retrieves detailed account information from Celo smart contracts, and performs + bulk imports of account data. It supports batching and concurrency for + efficient processing. + + The fetcher integrates with the BufferedTask system to handle asynchronous + processing and automatic retry logic for failed operations. + + ## Note + + This implementation is ported from Celo's fork of Blockscout and could be + revised in future iterations. + """ + + alias Explorer.Chain + alias Explorer.Chain.Celo.PendingAccountOperation + alias Indexer.BufferedTask + alias Indexer.Fetcher.Celo.Legacy.Account.Reader, as: AccountReader + alias Indexer.Transform.Addresses + + require Logger + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + @behaviour BufferedTask + + @doc false + def child_spec([init_options, gen_server_options]) do + {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) + + if !state do + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec` " <> + "to allow for json_rpc calls when running." + end + + merged_init_opts = + defaults() + |> Keyword.merge(mergeable_init_options) + |> Keyword.put(:state, state) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + def defaults do + [ + poll: true, + flush_interval: :timer.seconds(3), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency], + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size], + task_supervisor: __MODULE__.TaskSupervisor, + metadata: [fetcher: :celo_accounts] + ] + end + + @doc """ + Asynchronously enqueues processing of Celo account operations. + + ## Parameters + - `operations`: List of `PendingAccountOperation` structs to process + - `realtime?`: Boolean indicating if this is realtime processing + - `timeout`: Timeout for buffering tasks (default: 5000ms) + + ## Returns + - `:ok` once the operations have been enqueued + """ + @spec async_fetch([PendingAccountOperation.t()], boolean(), timeout()) :: :ok + def async_fetch(operations, realtime?, timeout \\ 5000) when is_list(operations) do + if __MODULE__.Supervisor.disabled?() do + :ok + else + unique_operations = Enum.uniq_by(operations, & &1.address_hash) + BufferedTask.buffer(__MODULE__, unique_operations, realtime?, timeout) + end + end + + @impl BufferedTask + def init(initial, reducer, _) do + {:ok, final} = + PendingAccountOperation.stream( + initial, + reducer, + true + ) + + final + end + + @impl BufferedTask + def run(accounts, _json_rpc_named_arguments) do + accounts + |> fetch_from_blockchain() + |> import_accounts() + |> case do + :ok -> :ok + :error -> {:retry, accounts} + end + end + + defp fetch_from_blockchain(operations) do + operations + |> Enum.map(fn + %{voter: _} = account -> + Map.put(account, :error, :unresolved_voter) + + account -> + account.address_hash + |> to_string() + |> AccountReader.fetch() + |> case do + {:ok, data} -> data + _ -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp import_accounts(accounts) do + addresses = + Addresses.extract_addresses(%{ + celo_accounts: accounts + }) + + import_params = %{ + addresses: %{ + params: addresses, + timeout: :infinity + }, + celo_accounts: %{ + params: accounts, + timeout: :infinity + } + } + + case Chain.import(import_params) do + {:ok, _imported} -> + Logger.info("Imported #{Enum.count(accounts)} Celo accounts.") + + accounts + |> Enum.map(& &1.address_hash) + |> PendingAccountOperation.delete_by_address_hashes() + + :ok + + {:error, reason} -> + Logger.error(fn -> ["failed to import Celo account data: ", inspect(reason)] end) + + :error + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/celo/legacy/account/reader.ex b/apps/indexer/lib/indexer/fetcher/celo/legacy/account/reader.ex new file mode 100644 index 000000000000..c11d7b53ea2b --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/celo/legacy/account/reader.ex @@ -0,0 +1,268 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Celo.Legacy.Account.Reader do + @moduledoc """ + Reads Celo account data from core smart contracts. + + This module provides functionality to fetch account information including + name, metadata URL, locked gold amounts, and validator status from the + Celo blockchain's core contracts. + """ + require Logger + + use Utils.RuntimeEnvHelper, + accounts_contract_address_hash: [:explorer, [:celo, :accounts_contract_address]], + locked_gold_contract_address_hash: [:explorer, [:celo, :locked_gold_contract_address]], + validators_contract_address_hash: [:explorer, [:celo, :validators_contract_address]], + json_rpc_named_arguments: [:indexer, :json_rpc_named_arguments] + + import Explorer.Helper, only: [abi_to_method_id: 1] + + import Indexer.Helper, + only: [read_contracts_with_retries: 4] + + @repeated_request_max_retries 3 + + @abi %{ + accounts: %{ + get_name: %{ + "constant" => true, + "inputs" => [ + %{"internalType" => "address", "name" => "account", "type" => "address"} + ], + "name" => "getName", + "outputs" => [ + %{"internalType" => "string", "name" => "", "type" => "string"} + ], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + get_metadata_url: %{ + "constant" => true, + "inputs" => [ + %{"internalType" => "address", "name" => "account", "type" => "address"} + ], + "name" => "getMetadataURL", + "outputs" => [ + %{"internalType" => "string", "name" => "", "type" => "string"} + ], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + get_vote_signer: %{ + "constant" => true, + "inputs" => [%{"name" => "account", "type" => "address"}], + "name" => "getVoteSigner", + "outputs" => [%{"name" => "", "type" => "address"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + get_validator_signer: %{ + "constant" => true, + "inputs" => [%{"name" => "account", "type" => "address"}], + "name" => "getValidatorSigner", + "outputs" => [%{"name" => "", "type" => "address"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + get_attestation_signer: %{ + "constant" => true, + "inputs" => [%{"name" => "account", "type" => "address"}], + "name" => "getAttestationSigner", + "outputs" => [%{"name" => "", "type" => "address"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + } + }, + locked_gold: %{ + get_account_total_locked_gold: %{ + "constant" => true, + "inputs" => [ + %{"internalType" => "address", "name" => "account", "type" => "address"} + ], + "name" => "getAccountTotalLockedGold", + "outputs" => [ + %{"internalType" => "uint256", "name" => "", "type" => "uint256"} + ], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + }, + get_account_nonvoting_locked_gold: %{ + "constant" => true, + "inputs" => [ + %{"internalType" => "address", "name" => "account", "type" => "address"} + ], + "name" => "getAccountNonvotingLockedGold", + "outputs" => [ + %{"internalType" => "uint256", "name" => "", "type" => "uint256"} + ], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + } + }, + validators: %{ + is_validator: %{ + "constant" => true, + "inputs" => [%{"name" => "_someone", "type" => "address"}], + "name" => "isValidator", + "outputs" => [%{"name" => "", "type" => "bool"}], + "payable" => false, + "signature" => "0xfacd743b", + "stateMutability" => "view", + "type" => "function" + }, + is_validator_group: %{ + "constant" => true, + "inputs" => [ + %{"internalType" => "address", "name" => "account", "type" => "address"} + ], + "name" => "isValidatorGroup", + "outputs" => [%{"internalType" => "bool", "name" => "", "type" => "bool"}], + "payable" => false, + "stateMutability" => "view", + "type" => "function" + } + } + } + + @abis @abi |> Map.values() |> Enum.flat_map(&Map.values/1) + + @doc """ + Read Celo account data from core smart contracts. + """ + @spec fetch(String.t()) :: {:ok, map()} | :error + def fetch(account_address) do + account_address + |> do_fetch() + |> case do + {:ok, + [ + {:ok, [name]}, + {:ok, [url]}, + {:ok, [locked_gold]}, + {:ok, [nonvoting_locked_gold]}, + {:ok, [is_validator]}, + {:ok, [is_validator_group]}, + {:ok, [vote_signer]}, + {:ok, [validator_signer]}, + {:ok, [attestation_signer]} + ]} -> + type = + cond do + is_validator -> + :validator + + is_validator_group -> + :group + + true -> + :regular + end + + {:ok, + %{ + address_hash: account_address, + name: truncate(name), + metadata_url: truncate(url), + locked_celo: locked_gold, + nonvoting_locked_celo: nonvoting_locked_gold, + type: type, + vote_signer_address_hash: normalize_signer(account_address, vote_signer), + validator_signer_address_hash: normalize_signer(account_address, validator_signer), + attestation_signer_address_hash: normalize_signer(account_address, attestation_signer) + }} + + {:error, errors} -> + Logger.error(fn -> + ["Failed to fetch Celo account data for ", account_address, ": ", inspect(errors)] + end) + + :error + end + end + + @spec truncate(binary()) :: binary() + defp truncate(binary) when is_binary(binary) do + String.slice(binary, 0, 255) + end + + @spec normalize_signer(String.t(), String.t()) :: String.t() | nil + defp normalize_signer(address_hash, signer_address_hash) do + if address_hash == signer_address_hash do + nil + else + signer_address_hash + end + end + + @spec do_fetch(String.t()) :: {:ok, keyword()} | {:error, any()} + defp do_fetch(account_address) do + requests = [ + %{ + contract_address: accounts_contract_address_hash(), + method_id: abi_to_method_id(@abi.accounts.get_name), + args: [account_address] + }, + %{ + contract_address: accounts_contract_address_hash(), + method_id: abi_to_method_id(@abi.accounts.get_metadata_url), + args: [account_address] + }, + %{ + contract_address: locked_gold_contract_address_hash(), + method_id: abi_to_method_id(@abi.locked_gold.get_account_total_locked_gold), + args: [account_address] + }, + %{ + contract_address: locked_gold_contract_address_hash(), + method_id: abi_to_method_id(@abi.locked_gold.get_account_nonvoting_locked_gold), + args: [account_address] + }, + %{ + contract_address: validators_contract_address_hash(), + method_id: abi_to_method_id(@abi.validators.is_validator), + args: [account_address] + }, + %{ + contract_address: validators_contract_address_hash(), + method_id: abi_to_method_id(@abi.validators.is_validator_group), + args: [account_address] + }, + %{ + contract_address: accounts_contract_address_hash(), + method_id: abi_to_method_id(@abi.accounts.get_vote_signer), + args: [account_address] + }, + %{ + contract_address: accounts_contract_address_hash(), + method_id: abi_to_method_id(@abi.accounts.get_validator_signer), + args: [account_address] + }, + %{ + contract_address: accounts_contract_address_hash(), + method_id: abi_to_method_id(@abi.accounts.get_attestation_signer), + args: [account_address] + } + ] + + requests + |> read_contracts_with_retries( + @abis, + json_rpc_named_arguments(), + @repeated_request_max_retries + ) + |> case do + {responses, []} -> + {:ok, responses} + + {_responses, errors} -> + {:error, errors} + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/celo/validator_group_votes.ex b/apps/indexer/lib/indexer/fetcher/celo/validator_group_votes.ex index 0369dfba071d..2f4b038d5523 100644 --- a/apps/indexer/lib/indexer/fetcher/celo/validator_group_votes.ex +++ b/apps/indexer/lib/indexer/fetcher/celo/validator_group_votes.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.ValidatorGroupVotes do @moduledoc """ Fetches validator group votes from the Celo blockchain. @@ -30,7 +31,7 @@ defmodule Indexer.Fetcher.Celo.ValidatorGroupVotes do @spec fetch(block_number :: EthereumJSONRPC.block_number()) :: :ok def fetch(block_number) do - GenServer.call(__MODULE__, {:fetch, block_number}) + GenServer.call(__MODULE__, {:fetch, block_number}, 60_000) end def child_spec(start_link_arguments) do @@ -211,7 +212,8 @@ defmodule Indexer.Fetcher.Celo.ValidatorGroupVotes do type: type, block_number: log.block_number, block_hash: log.block_hash, - transaction_hash: log.transaction_hash + transaction_hash: log.transaction_hash, + log_index: log.index } end end diff --git a/apps/indexer/lib/indexer/fetcher/coin_balance/catchup.ex b/apps/indexer/lib/indexer/fetcher/coin_balance/catchup.ex index 8487c7b0d187..dad25014b870 100644 --- a/apps/indexer/lib/indexer/fetcher/coin_balance/catchup.ex +++ b/apps/indexer/lib/indexer/fetcher/coin_balance/catchup.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.CoinBalance.Catchup do @moduledoc """ Fetches `t:Explorer.Chain.Address.CoinBalance.t/0` and updates `t:Explorer.Chain.Address.t/0` `fetched_coin_balance` and @@ -7,7 +8,7 @@ defmodule Indexer.Fetcher.CoinBalance.Catchup do use Indexer.Fetcher, restart: :permanent use Spandex.Decorators - alias Explorer.Chain + alias Explorer.Chain.Address.CoinBalance alias Explorer.Chain.{Block, Hash} alias Indexer.{BufferedTask, Tracer} alias Indexer.Fetcher.CoinBalance.Catchup.Supervisor, as: CoinBalanceSupervisor @@ -41,7 +42,7 @@ defmodule Indexer.Fetcher.CoinBalance.Catchup do @impl BufferedTask def init(initial, reducer, _) do {:ok, final} = - Chain.stream_unfetched_balances( + CoinBalance.stream_unfetched_balances( initial, fn address_fields, acc -> address_fields diff --git a/apps/indexer/lib/indexer/fetcher/coin_balance/helper.ex b/apps/indexer/lib/indexer/fetcher/coin_balance/helper.ex index d06fee67fdc2..86a251cf020e 100644 --- a/apps/indexer/lib/indexer/fetcher/coin_balance/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/coin_balance/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.CoinBalance.Helper do @moduledoc """ Common functions for `Indexer.Fetcher.CoinBalance.Catchup` and `Indexer.Fetcher.CoinBalance.Realtime` modules @@ -18,7 +19,7 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do def child_spec([init_options, gen_server_options], defaults, module) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{module}.child_spec " <> "to allow for json_rpc calls when running." @@ -98,7 +99,12 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do |> Enum.group_by(fn %{address_hash: address_hash} -> address_hash end) |> Map.values() |> Stream.map(&Enum.max_by(&1, fn %{block_number: block_number} -> block_number end)) - |> Enum.map(fn %{address_hash: address_hash, block_number: block_number, value: value, value_fetched_at: value_fetched_at} -> + |> Enum.map(fn %{ + address_hash: address_hash, + block_number: block_number, + value: value, + value_fetched_at: value_fetched_at + } -> %{ address_hash: address_hash, block_number: block_number, @@ -107,7 +113,7 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do value_fetched_at: value_fetched_at, token_type: "ERC-20", token_id: nil, - retries_count: 0, + retries_count: 0 } end) end @@ -117,7 +123,12 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do |> Enum.group_by(fn %{address_hash: address_hash} -> address_hash end) |> Map.values() |> Stream.map(&Enum.max_by(&1, fn %{block_number: block_number} -> block_number end)) - |> Enum.map(fn %{address_hash: address_hash, block_number: block_number, value: value, value_fetched_at: value_fetched_at} -> + |> Enum.map(fn %{ + address_hash: address_hash, + block_number: block_number, + value: value, + value_fetched_at: value_fetched_at + } -> %{ address_hash: address_hash, block_number: block_number, @@ -125,7 +136,7 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do value: value, value_fetched_at: value_fetched_at, token_type: "ERC-20", - token_id: nil, + token_id: nil } end) end @@ -143,7 +154,8 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do address_token_balances_params = balances_params_to_address_token_balances_params(importable_balances_params) - address_current_token_balances_params = balances_params_to_address_current_token_balances_params(importable_balances_params) + address_current_token_balances_params = + balances_params_to_address_current_token_balances_params(importable_balances_params) token_balances = TokenBalances.to_address_current_token_balances(address_current_token_balances_params) @@ -155,7 +167,7 @@ defmodule Indexer.Fetcher.CoinBalance.Helper do address_token_balances: %{params: address_token_balances_params}, address_current_token_balances: %{ params: token_balances - }, + } }) end diff --git a/apps/indexer/lib/indexer/fetcher/coin_balance/realtime.ex b/apps/indexer/lib/indexer/fetcher/coin_balance/realtime.ex index ebd055e2f150..f56498f55f55 100644 --- a/apps/indexer/lib/indexer/fetcher/coin_balance/realtime.ex +++ b/apps/indexer/lib/indexer/fetcher/coin_balance/realtime.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.CoinBalance.Realtime do @moduledoc """ Separate version of `Indexer.Fetcher.CoinBalance.Catchup` for fetching balances from realtime block fetcher @@ -9,6 +10,7 @@ defmodule Indexer.Fetcher.CoinBalance.Realtime do alias Explorer.Chain.{Block, Hash} alias Indexer.{BufferedTask, Tracer} alias Indexer.Fetcher.CoinBalance.Helper + alias Indexer.Fetcher.CoinBalance.Realtime.Supervisor, as: CoinBalanceSupervisor @behaviour BufferedTask @@ -22,9 +24,13 @@ defmodule Indexer.Fetcher.CoinBalance.Realtime do %{required(:address_hash) => Hash.Address.t(), required(:block_number) => Block.block_number()} ]) :: :ok def async_fetch_balances(balance_fields) when is_list(balance_fields) do - entries = Enum.map(balance_fields, &Helper.entry/1) + if CoinBalanceSupervisor.disabled?() do + :ok + else + entries = Enum.map(balance_fields, &Helper.entry/1) - BufferedTask.buffer(__MODULE__, entries, true) + BufferedTask.buffer(__MODULE__, entries, true) + end end def child_spec(params) do diff --git a/apps/indexer/lib/indexer/fetcher/contract_code.ex b/apps/indexer/lib/indexer/fetcher/contract_code.ex index 657ae5285bb2..54fe4afaf829 100644 --- a/apps/indexer/lib/indexer/fetcher/contract_code.ex +++ b/apps/indexer/lib/indexer/fetcher/contract_code.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ContractCode do @moduledoc """ Fetches `contract_code` `t:Explorer.Chain.Address.t/0`. @@ -79,7 +80,7 @@ defmodule Indexer.Fetcher.ContractCode do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -195,6 +196,23 @@ defmodule Indexer.Fetcher.ContractCode do code_addresses_params = Addresses.extract_addresses(%{codes: params}) {:ok, code_addresses_params} + {:ok, %{params_list: params, errors: errors}} -> + unique_errors = + errors + |> Enum.map(fn + %{message: message} -> + message + + error -> + inspect(error) + end) + |> Enum.uniq() + + Logger.error(fn -> ["failed to fetch some contract codes: ", inspect(unique_errors)] end) + + code_addresses_params = Addresses.extract_addresses(%{codes: params}) + {:ok, code_addresses_params} + error -> error end @@ -241,6 +259,9 @@ defmodule Indexer.Fetcher.ContractCode do Accounts.drop(addresses) {:ok, addresses} + {:ok, _} -> + {:ok, []} + {:error, step, reason, _changes_so_far} -> Logger.error( fn -> @@ -273,7 +294,7 @@ defmodule Indexer.Fetcher.ContractCode do defp zilliqa_verify_scilla_contracts(entries, addresses) do zilliqa_contract_address_hashes = entries - |> Enum.filter(&ZilliqaHelper.scilla_transaction?(&1.type)) + |> Enum.filter(&(ZilliqaHelper.scilla_transaction?(&1.type) and &1.status == :ok)) |> MapSet.new(& &1.created_contract_address_hash) addresses diff --git a/apps/indexer/lib/indexer/fetcher/current_token_balance_importer.ex b/apps/indexer/lib/indexer/fetcher/current_token_balance_importer.ex new file mode 100644 index 000000000000..7e9727a51bbc --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/current_token_balance_importer.ex @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.CurrentTokenBalanceImporter do + @moduledoc """ + Periodically updates current token balances accumulated from block fetcher + """ + + use GenServer + + require Logger + + alias Explorer.Chain + alias Indexer.Block.Fetcher + + @default_update_interval :timer.minutes(1) + + def child_spec(_) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, []}, + type: :worker, + restart: :permanent, + shutdown: Application.get_env(:indexer, :graceful_shutdown_period) + } + end + + def start_link do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + Process.flag(:trap_exit, true) + schedule_next_update() + + {:ok, %{}} + end + + def add(ctb_params, realtime? \\ false) do + GenServer.cast(__MODULE__, {:add, ctb_params, realtime?}) + end + + def handle_cast({:add, ctb_params, realtime?}, state) do + result_state = + Enum.reduce(ctb_params, state, fn params, acc -> + key = {params.address_hash, params.token_contract_address_hash, params.token_id} + existing_record = acc[key] + + cond do + is_nil(existing_record) -> + Map.put(acc, key, {params, realtime?}) + + elem(existing_record, 0).block_number <= params.block_number -> + Map.put(acc, key, {params, elem(existing_record, 1) or realtime?}) + + true -> + acc + end + end) + + {:noreply, result_state} + end + + def handle_info(:update, ctb_map) do + Logger.info("[CurrentTokenBalanceImporter] importing #{Enum.count(ctb_map)} balances") + result_state = do_update(ctb_map) + schedule_next_update() + {:noreply, result_state} + rescue + exception -> + error = Exception.format(:error, exception, __STACKTRACE__) + log_error(error) + schedule_next_update() + + {:noreply, ctb_map} + end + + def handle_info({_ref, _result}, state) do + {:noreply, state} + end + + def handle_info({:EXIT, _pid, :normal}, state) do + {:noreply, state} + end + + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do + {:noreply, state} + end + + def terminate(reason, state) do + log_error(reason) + do_update(state) + end + + defp do_update(ctb_map) when ctb_map == %{}, do: ctb_map + + defp do_update(ctb_map) do + ctb_params = + ctb_map + |> Map.values() + |> Enum.map(&elem(&1, 0)) + + case Chain.import(%{address_current_token_balances: %{params: ctb_params}, timeout: :infinity}) do + {:ok, %{address_current_token_balances: imported}} -> + {realtime_imported, catchup_imported} = + Enum.split_with( + imported, + &elem(ctb_map[{&1.address_hash, &1.token_contract_address_hash, &1.token_id}] || {nil, false}, 1) + ) + + Fetcher.async_import_current_token_balances(%{address_current_token_balances: realtime_imported}, true) + Fetcher.async_import_current_token_balances(%{address_current_token_balances: catchup_imported}, false) + + Logger.info("[CurrentTokenBalanceImporter] imported #{Enum.count(ctb_params)} balances") + + %{} + + error -> + log_error(inspect(error)) + ctb_map + end + end + + defp schedule_next_update do + Process.send_after(self(), :update, @default_update_interval) + end + + defp log_error(error) do + Logger.error("[CurrentTokenBalanceImporter] Failed to update current token balances: #{inspect(error)}, retrying") + end +end diff --git a/apps/indexer/lib/indexer/fetcher/empty_blocks_sanitizer.ex b/apps/indexer/lib/indexer/fetcher/empty_blocks_sanitizer.ex index e14606a95955..83466e703a7f 100644 --- a/apps/indexer/lib/indexer/fetcher/empty_blocks_sanitizer.ex +++ b/apps/indexer/lib/indexer/fetcher/empty_blocks_sanitizer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.EmptyBlocksSanitizer do @moduledoc """ Periodically checks empty blocks starting from the head of the chain, detects for which blocks transactions should be refetched @@ -14,16 +15,17 @@ defmodule Indexer.Fetcher.EmptyBlocksSanitizer do alias EthereumJSONRPC.Block.ByNumber alias EthereumJSONRPC.Blocks - alias Explorer.Repo - alias Explorer.Chain.{Block, Hash, PendingBlockOperation, PendingOperationsHelper, Transaction} + alias Explorer.Chain.{Block, Hash, PendingOperationsHelper, Transaction} alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Repo @update_timeout 60_000 @interval :timer.seconds(10) + @batch_size 10 + @head_offset 1000 - defstruct interval: @interval, - json_rpc_named_arguments: [] + defstruct json_rpc_named_arguments: [] def child_spec([init_arguments]) do child_spec([init_arguments, []]) @@ -44,22 +46,21 @@ defmodule Indexer.Fetcher.EmptyBlocksSanitizer do @impl GenServer def init(opts) when is_list(opts) do - interval = Application.get_env(:indexer, __MODULE__)[:interval] + # For the first call we want it to start immediately + # (don't affect implementation in any way, but helps tests not to flake) + Kernel.send(self(), :sanitize_empty_blocks) state = %__MODULE__{ - json_rpc_named_arguments: Keyword.fetch!(opts, :json_rpc_named_arguments), - interval: interval || @interval + json_rpc_named_arguments: Keyword.fetch!(opts, :json_rpc_named_arguments) } - Process.send_after(self(), :sanitize_empty_blocks, state.interval) - {:ok, state} end @impl GenServer def handle_info( :sanitize_empty_blocks, - %{interval: interval, json_rpc_named_arguments: json_rpc_named_arguments} = state + %{json_rpc_named_arguments: json_rpc_named_arguments} = state ) do Logger.info("Start sanitizing of empty blocks. Batch size is #{limit()}", fetcher: :empty_blocks_to_refetch @@ -67,7 +68,7 @@ defmodule Indexer.Fetcher.EmptyBlocksSanitizer do sanitize_empty_blocks(json_rpc_named_arguments) - Process.send_after(self(), :sanitize_empty_blocks, interval) + Process.send_after(self(), :sanitize_empty_blocks, interval()) {:noreply, state} end @@ -80,15 +81,16 @@ defmodule Indexer.Fetcher.EmptyBlocksSanitizer do block in Block, where: block.hash in subquery(unprocessed_non_empty_blocks_query) ), - set: [is_empty: false, updated_at: Timex.now()] + [set: [is_empty: false, updated_at: Timex.now()]], + timeout: :infinity ) unprocessed_empty_blocks_list = unprocessed_empty_blocks_list_query(limit()) - unless Enum.empty?(unprocessed_empty_blocks_list) do + if !Enum.empty?(unprocessed_empty_blocks_list) do blocks_response = unprocessed_empty_blocks_list - |> Enum.map(fn {block_number, _} -> %{number: integer_to_quantity(block_number)} end) + |> Enum.map(fn %{number: block_number} -> %{number: integer_to_quantity(block_number)} end) |> id_to_params() |> Blocks.requests(&ByNumber.request(&1, false, false)) |> json_rpc(json_rpc_named_arguments) @@ -196,8 +198,8 @@ defmodule Indexer.Fetcher.EmptyBlocksSanitizer do case PendingOperationsHelper.pending_operations_type() do "blocks" -> - PendingBlockOperation - |> where([po], po.block_hash in ^block_hashes) + block_hashes + |> PendingOperationsHelper.block_hash_in_query() |> Repo.delete_all() "transactions" -> @@ -208,50 +210,74 @@ defmodule Indexer.Fetcher.EmptyBlocksSanitizer do {:error, %{exception: postgrex_error}} end - @head_offset 1000 defp consensus_blocks_with_nil_is_empty_query(limit) do - safe_block_number = BlockNumber.get_max() - @head_offset + safe_block_number = BlockNumber.get_max() - head_offset() from(block in Block, + as: :block, + select: %{hash: block.hash, number: block.number}, where: is_nil(block.is_empty), where: block.number <= ^safe_block_number, where: block.consensus == true, where: block.refetch_needed == false, - order_by: [asc: block.hash], limit: ^limit ) end + defp any_block_transactions_query do + # NOTE: relies on parent_as(:block) set by the caller query (see consensus_blocks_with_nil_is_empty_query/1) + from( + t in Transaction, + select: 1, + where: parent_as(:block).hash == t.block_hash + ) + end + defp unprocessed_non_empty_blocks_query(limit) do - blocks_query = consensus_blocks_with_nil_is_empty_query(limit) - - from(q in subquery(blocks_query), - inner_join: transaction in Transaction, - on: q.number == transaction.block_number, - select: q.hash, - order_by: [asc: q.hash], - lock: fragment("FOR NO KEY UPDATE OF ?", q) + candidate_blocks_query = consensus_blocks_with_nil_is_empty_query(limit) + + non_empty_blocks_query = + from( + block in candidate_blocks_query, + where: exists(any_block_transactions_query()) + ) + + # Inner Join is required in order to lock only `blocks` table. + # As `non_empty_blocks_query` has WHERE condition on `transactions` table, + # if you apply lock to the query, the `transactions` table is also locked + # and that results in obtaining locks before the sort. + from( + block in Block, + inner_join: non_empty_block in subquery(non_empty_blocks_query), + on: block.hash == non_empty_block.hash, + select: %{hash: block.hash}, + order_by: [asc: block.hash], + lock: fragment("FOR NO KEY UPDATE OF ?", block) ) end defp unprocessed_empty_blocks_list_query(limit) do - blocks_query = consensus_blocks_with_nil_is_empty_query(limit) + candidate_blocks_query = consensus_blocks_with_nil_is_empty_query(limit) - query = - from(q in subquery(blocks_query), - left_join: transaction in Transaction, - on: q.number == transaction.block_number, - where: is_nil(transaction.block_number), - select: {q.number, q.hash}, - distinct: q.number, - order_by: [asc: q.hash] + empty_blocks_query = + from( + block in candidate_blocks_query, + where: not exists(any_block_transactions_query()) ) - query + empty_blocks_query |> Repo.all(timeout: :infinity) end defp limit do - Application.get_env(:indexer, __MODULE__)[:batch_size] + Application.get_env(:indexer, __MODULE__)[:batch_size] || @batch_size + end + + defp interval do + Application.get_env(:indexer, __MODULE__)[:interval] || @interval + end + + defp head_offset do + Application.get_env(:indexer, __MODULE__)[:head_offset] || @head_offset end end diff --git a/apps/indexer/lib/indexer/fetcher/filecoin/address_info.ex b/apps/indexer/lib/indexer/fetcher/filecoin/address_info.ex index 3317b17c1510..eac3322e407f 100644 --- a/apps/indexer/lib/indexer/fetcher/filecoin/address_info.ex +++ b/apps/indexer/lib/indexer/fetcher/filecoin/address_info.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Filecoin.AddressInfo do @moduledoc """ A task for fetching Filecoin addresses info in the Address table using the @@ -65,7 +66,7 @@ defmodule Indexer.Fetcher.Filecoin.AddressInfo do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec` " <> "to allow for json_rpc calls when running." @@ -118,7 +119,7 @@ defmodule Indexer.Fetcher.Filecoin.AddressInfo do @impl BufferedTask @decorate trace( name: "fetch", - resource: "Indexer.Fetcher.InternalTransaction.run/2", + resource: "Indexer.Fetcher.Filecoin.AddressInfo.run/2", service: :indexer, tracer: Tracer ) @@ -204,12 +205,7 @@ defmodule Indexer.Fetcher.Filecoin.AddressInfo do {:ok, maybe_actor_type_string} <- Map.fetch(body_json, "actor_type") do robust_address_string = if maybe_robust_address_string in ["", ""] do - operation.address_hash - |> NativeAddress.cast() - |> case do - {:ok, native_address} -> to_string(native_address) - _ -> nil - end + cast_native_address(operation.address_hash) else maybe_robust_address_string end @@ -239,6 +235,15 @@ defmodule Indexer.Fetcher.Filecoin.AddressInfo do end end + defp cast_native_address(address_hash) do + address_hash + |> NativeAddress.cast() + |> case do + {:ok, native_address} -> to_string(native_address) + _ -> nil + end + end + @spec full_fetch_address_info_using_filfox_api(PendingAddressOperation.t()) :: {:ok, :full, filecoin_address_params()} | :error defp full_fetch_address_info_using_filfox_api(operation) do diff --git a/apps/indexer/lib/indexer/fetcher/filecoin/beryx_api.ex b/apps/indexer/lib/indexer/fetcher/filecoin/beryx_api.ex index 90445acce52a..f2f035a14096 100644 --- a/apps/indexer/lib/indexer/fetcher/filecoin/beryx_api.ex +++ b/apps/indexer/lib/indexer/fetcher/filecoin/beryx_api.ex @@ -1,11 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Filecoin.BeryxAPI do @moduledoc """ Interacts with the Beryx API to fetch account information based on an Ethereum address hash """ - alias Explorer.Helper - alias HTTPoison.Response + alias Explorer.{Helper, HttpClient} @doc """ Fetches account information for a given Ethereum address hash from the Beryx API. @@ -16,12 +16,12 @@ defmodule Indexer.Fetcher.Filecoin.BeryxAPI do ## Returns - `{:ok, map()}`: On success, returns the account information as a map. - `{:error, integer(), map()}`: On failure, returns the HTTP status code and the error message as a map. - - `{:error, HTTPoison.Error.t()}`: On network or other HTTP errors, returns the error structure. + - `{:error, any()}`: On network or other HTTP errors, returns the error reason. """ @spec fetch_address_info(EthereumJSONRPC.address()) :: {:ok, map()} | {:error, integer(), map()} - | {:error, HTTPoison.Error.t()} + | {:error, any()} def fetch_address_info(eth_address_hash) do config = Application.get_env(:indexer, __MODULE__) base_url = config |> Keyword.get(:base_url) |> String.trim_trailing("/") @@ -34,16 +34,16 @@ defmodule Indexer.Fetcher.Filecoin.BeryxAPI do {"Content-Type", "application/json"} ] - case HTTPoison.get(url, headers) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url, headers) do + {:ok, %{body: body, status_code: 200}} -> json = Helper.decode_json(body) {:ok, json} - {:ok, %Response{body: body, status_code: status_code}} -> + {:ok, %{body: body, status_code: status_code}} -> json = Helper.decode_json(body) {:error, status_code, json} - {:error, %HTTPoison.Error{}} = error -> + error -> error end end diff --git a/apps/indexer/lib/indexer/fetcher/filecoin/filfox_api.ex b/apps/indexer/lib/indexer/fetcher/filecoin/filfox_api.ex index 62772f0e90fa..62144be656ba 100644 --- a/apps/indexer/lib/indexer/fetcher/filecoin/filfox_api.ex +++ b/apps/indexer/lib/indexer/fetcher/filecoin/filfox_api.ex @@ -1,11 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Filecoin.FilfoxAPI do @moduledoc """ Interacts with the Filfox API to fetch account information based on an Ethereum address hash """ - alias Explorer.Helper - alias HTTPoison.Response + alias Explorer.{Helper, HttpClient} @doc """ Fetches account information for a given Ethereum address hash from the Filfox API. @@ -16,12 +16,12 @@ defmodule Indexer.Fetcher.Filecoin.FilfoxAPI do ## Returns - `{:ok, map()}`: On success, returns the account information as a map. - `{:error, integer(), map()}`: On failure, returns the HTTP status code and the error message as a map. - - `{:error, HTTPoison.Error.t()}`: On network or other HTTP errors, returns the error structure. + - `{:error, any()}`: On network or other HTTP errors, returns the error reason. """ @spec fetch_address_info(EthereumJSONRPC.address()) :: {:ok, map()} | {:error, integer(), map()} - | {:error, HTTPoison.Error.t()} + | {:error, any()} def fetch_address_info(eth_address_hash) do config = Application.get_env(:indexer, __MODULE__) base_url = config |> Keyword.get(:base_url) |> String.trim_trailing("/") @@ -32,16 +32,16 @@ defmodule Indexer.Fetcher.Filecoin.FilfoxAPI do {"Content-Type", "application/json"} ] - case HTTPoison.get(url, headers) do - {:ok, %Response{body: body, status_code: 200}} -> + case HttpClient.get(url, headers) do + {:ok, %{body: body, status_code: 200}} -> json = Helper.decode_json(body) {:ok, json} - {:ok, %Response{body: body, status_code: status_code}} -> + {:ok, %{body: body, status_code: status_code}} -> json = Helper.decode_json(body) {:error, status_code, json} - {:error, %HTTPoison.Error{}} = error -> + error -> error end end diff --git a/apps/indexer/lib/indexer/fetcher/internal_transaction.ex b/apps/indexer/lib/indexer/fetcher/internal_transaction.ex index 2c6d490f6571..dfec3f1c6a4e 100644 --- a/apps/indexer/lib/indexer/fetcher/internal_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/internal_transaction.ex @@ -1,4 +1,4 @@ -# credo:disable-for-this-file +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.InternalTransaction do @moduledoc """ Fetches and indexes `t:Explorer.Chain.InternalTransaction.t/0`. @@ -9,11 +9,14 @@ defmodule Indexer.Fetcher.InternalTransaction do use Indexer.Fetcher, restart: :permanent use Spandex.Decorators + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + require Logger import Indexer.Block.Fetcher, only: [ - async_import_coin_balances: 2, + async_import_coin_balances: 1, async_import_token_balances: 2, token_transfers_merge_token: 2 ] @@ -23,9 +26,8 @@ defmodule Indexer.Fetcher.InternalTransaction do alias Explorer.Chain.{Block, Hash, PendingBlockOperation, PendingTransactionOperation, Transaction} alias Explorer.Chain.Cache.{Accounts, Blocks} alias Indexer.{BufferedTask, Tracer} - alias Indexer.Fetcher.InternalTransaction.Supervisor, as: InternalTransactionSupervisor - alias Indexer.Transform.Celo.TransactionTokenTransfers, as: CeloTransactionTokenTransfers alias Indexer.Transform.{AddressCoinBalances, Addresses, AddressTokenBalances} + alias Indexer.Transform.Celo.TransactionTokenTransfers, as: CeloTransactionTokenTransfers @behaviour BufferedTask @@ -48,12 +50,17 @@ defmodule Indexer.Fetcher.InternalTransaction do *Note*: The internal transactions for individual transactions cannot be paginated, so the total number of internal transactions that could be produced is unknown. """ - @spec async_fetch([Block.block_number()], [Transaction.t()], boolean()) :: :ok - def async_fetch(block_numbers, transactions, realtime?, timeout \\ 5000) when is_list(block_numbers) do - if InternalTransactionSupervisor.disabled?() do + @spec async_fetch([Block.block_number()], [Transaction.t()], boolean(), integer()) :: :ok + def async_fetch(block_numbers, transactions, realtime?, timeout \\ 5000) + when is_list(block_numbers) do + if disabled?() do :ok else - data = data_for_buffer(block_numbers, transactions) + data = + block_numbers + |> data_for_buffer(transactions) + |> RangesHelper.filter_traceable_block_numbers() + BufferedTask.buffer(__MODULE__, data, realtime?, timeout) end end @@ -63,7 +70,7 @@ defmodule Indexer.Fetcher.InternalTransaction do case queue_data_type(json_rpc_named_arguments) do :block_number -> block_numbers - :transaction_params -> Enum.map(transactions, &Map.take(&1, [:block_number, :hash, :index])) + :transaction_params -> Enum.map(transactions, &Map.take(&1, [:block_number, :hash, :index, :type])) end end @@ -71,7 +78,7 @@ defmodule Indexer.Fetcher.InternalTransaction do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -89,16 +96,42 @@ defmodule Indexer.Fetcher.InternalTransaction do def init(initial, reducer, json_rpc_named_arguments) do stream_reducer = RangesHelper.stream_reducer_traceable(reducer) - {:ok, final} = - case queue_data_type(json_rpc_named_arguments) do - :block_number -> - PendingBlockOperation.stream_blocks_with_unfetched_internal_transactions(initial, stream_reducer) + if disabled?() do + {:ok, final} = + case queue_data_type(json_rpc_named_arguments) do + :block_number -> + PendingBlockOperation.stream_blocks_with_unfetched_internal_transactions( + initial, + stream_reducer, + false, + true + ) - :transaction_params -> - PendingTransactionOperation.stream_transactions_with_unfetched_internal_transactions(initial, stream_reducer) - end + :transaction_params -> + PendingTransactionOperation.stream_transactions_with_unfetched_internal_transactions( + initial, + stream_reducer, + false, + true + ) + end - final + final + else + {:ok, final} = + case queue_data_type(json_rpc_named_arguments) do + :block_number -> + PendingBlockOperation.stream_blocks_with_unfetched_internal_transactions(initial, stream_reducer) + + :transaction_params -> + PendingTransactionOperation.stream_transactions_with_unfetched_internal_transactions( + initial, + stream_reducer + ) + end + + final + end end defp params(%{block_number: block_number, hash: hash, index: index}) when is_integer(block_number) do @@ -166,7 +199,7 @@ defmodule Indexer.Fetcher.InternalTransaction do block_numbers_or_transactions |> check_and_filter_block_numbers() - |> fetch_block_internal_transactions(json_rpc_named_arguments) + |> EthereumJSONRPC.fetch_block_internal_transactions(json_rpc_named_arguments) :transaction_params -> Logger.debug("fetching internal transactions by transactions") @@ -182,42 +215,6 @@ defmodule Indexer.Fetcher.InternalTransaction do end end - # TODO: remove this function after the migration of internal transactions PK to [:block_hash, :transaction_index, :index] - defp fetch_block_internal_transactions(block_numbers, json_rpc_named_arguments) do - variant = Keyword.fetch!(json_rpc_named_arguments, :variant) - - if variant in block_traceable_variants() do - EthereumJSONRPC.fetch_block_internal_transactions(block_numbers, json_rpc_named_arguments) - else - Enum.reduce(block_numbers, {:ok, []}, fn - block_number, {:ok, acc_list} -> - block_number - |> Chain.get_transactions_of_block_number() - |> filter_non_traceable_transactions() - |> Enum.map(¶ms/1) - |> case do - [] -> - {:ok, []} - - transactions -> - try do - EthereumJSONRPC.fetch_internal_transactions(transactions, json_rpc_named_arguments) - catch - :exit, error -> - {:error, error, __STACKTRACE__} - end - end - |> case do - {:ok, internal_transactions} -> {:ok, internal_transactions ++ acc_list} - error_or_ignore -> error_or_ignore - end - - _, error_or_ignore -> - error_or_ignore - end) - end - end - @default_block_traceable_variants [ EthereumJSONRPC.Nethermind, EthereumJSONRPC.Erigon, @@ -225,7 +222,11 @@ defmodule Indexer.Fetcher.InternalTransaction do EthereumJSONRPC.RSK, EthereumJSONRPC.Filecoin ] - defp block_traceable_variants do + @doc """ + Returns the list of JSON-RPC variants that support block-traceable internal transactions. + """ + @spec block_traceable_variants() :: [module()] + def block_traceable_variants do if Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth)[:block_traceable?] do [EthereumJSONRPC.Geth | @default_block_traceable_variants] else @@ -246,31 +247,10 @@ defmodule Indexer.Fetcher.InternalTransaction do end end - def import_first_trace(internal_transactions_params) do - imports = - Chain.import(%{ - internal_transactions: %{params: internal_transactions_params, with: :blockless_changeset}, - timeout: :infinity - }) - - case imports do - {:error, step, reason, _changes_so_far} -> - Logger.error( - fn -> - [ - "failed to import first trace for transaction: ", - inspect(reason) - ] - end, - step: step - ) - end - end - defp filter_block_numbers(block_numbers, :block_number, json_rpc_named_arguments) do block_numbers |> Enum.uniq() - |> Chain.filter_non_refetch_needed_block_numbers() + |> Block.filter_non_refetch_needed_block_numbers() |> RangesHelper.filter_traceable_block_numbers() |> drop_genesis(json_rpc_named_arguments) end @@ -304,7 +284,7 @@ defmodule Indexer.Fetcher.InternalTransaction do defp fetch_internal_transactions_by_transactions(transactions, json_rpc_named_arguments) do transactions - |> filter_non_traceable_transactions() + |> Transaction.filter_non_traceable_transactions() |> Enum.map(¶ms/1) |> case do [] -> @@ -320,19 +300,14 @@ defmodule Indexer.Fetcher.InternalTransaction do end end - @zetachain_non_traceable_type 88 - defp filter_non_traceable_transactions(transactions) do - case Application.get_env(:explorer, :chain_type) do - :zetachain -> Enum.reject(transactions, &(&1.type == @zetachain_non_traceable_type)) - _ -> transactions - end - end - defp safe_import_internal_transaction(internal_transactions_params, block_numbers, data_type) do import_internal_transaction(internal_transactions_params, block_numbers, data_type) rescue - Postgrex.Error -> - handle_foreign_key_violation(internal_transactions_params, block_numbers, data_type) + exception in Postgrex.Error -> + Logger.error( + "Error on internal transactions import: #{inspect(exception)}, block numbers: #{inspect(block_numbers)}" + ) + {:retry, block_numbers} end @@ -344,11 +319,6 @@ defmodule Indexer.Fetcher.InternalTransaction do internal_transactions: internal_transactions_params_marked }) - address_hash_to_block_number = - Enum.into(addresses_params, %{}, fn %{fetched_coin_balance_block_number: block_number, hash: hash} -> - {String.downcase(hash), block_number} - end) - address_coin_balances_params_set = AddressCoinBalances.params_set(%{internal_transactions_params: internal_transactions_params_marked}) @@ -368,11 +338,11 @@ defmodule Indexer.Fetcher.InternalTransaction do celo_token_transfers_params = %{token_transfers: celo_token_transfers, tokens: celo_tokens} = - if Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() == {:optimism, :celo} do block_number_to_block_hash = transactions_params_or_unique_numbers |> data_to_block_numbers(data_type) - |> Chain.block_hash_by_number() + |> Block.block_hash_by_number() |> Map.new(fn {block_number, block_hash} -> {block_number, Hash.to_string(block_hash)} @@ -401,9 +371,7 @@ defmodule Indexer.Fetcher.InternalTransaction do Accounts.drop(imported[:addresses]) Blocks.drop_nonconsensus(imported[:remove_consensus_of_missing_transactions_blocks]) - async_import_coin_balances(imported, %{ - address_hash_to_fetched_balance_block_number: address_hash_to_block_number - }) + async_import_coin_balances(imported) async_import_celo_token_balances(celo_token_transfers_params) @@ -419,8 +387,6 @@ defmodule Indexer.Fetcher.InternalTransaction do error_count: Enum.count(transactions_params_or_unique_numbers) ) - handle_unique_key_violation(reason, transactions_params_or_unique_numbers, data_type) - # re-queue the de-duped entries {:retry, transactions_params_or_unique_numbers} end @@ -462,43 +428,6 @@ defmodule Indexer.Fetcher.InternalTransaction do # don't count itself as a parent defp has_failed_parent?(_failed_parent_paths, [], _reverse_path_acc), do: false - defp handle_unique_key_violation( - %{exception: %{postgres: %{code: :unique_violation}}}, - transactions_params_or_unique_numbers, - data_type - ) do - block_numbers = data_to_block_numbers(transactions_params_or_unique_numbers, data_type) - - Block.set_refetch_needed(block_numbers) - - Logger.error(fn -> - [ - "unique_violation on internal transactions import, #{data_type} identifiers: ", - inspect(transactions_params_or_unique_numbers) - ] - end) - end - - defp handle_unique_key_violation(_reason, _identifiers, _data_type), do: :ok - - defp handle_foreign_key_violation(internal_transactions_params, block_numbers_or_transactions, data_type) do - block_numbers = data_to_block_numbers(block_numbers_or_transactions, data_type) - - Block.set_refetch_needed(block_numbers) - - transaction_hashes = - internal_transactions_params - |> Enum.map(&to_string(&1.transaction_hash)) - |> Enum.uniq() - - Logger.error(fn -> - [ - "foreign_key_violation on internal transactions import, foreign transactions hashes: ", - Enum.join(transaction_hashes, ", ") - ] - end) - end - defp handle_not_found_transaction(errors) when is_list(errors) do Enum.each(errors, &handle_not_found_transaction/1) end @@ -520,20 +449,13 @@ defmodule Indexer.Fetcher.InternalTransaction do defp invalidate_block_from_error(_error_data), do: :ok - defp queue_data_type(_json_rpc_named_arguments) do - # TODO: bring back after the migration of internal transactions PK to [:block_hash, :transaction_index, :index] - # variant = Keyword.fetch!(json_rpc_named_arguments, :variant) - - # if variant in block_traceable_variants() do - # :block_number - # else - # :transaction_params - # end + defp queue_data_type(json_rpc_named_arguments) do + variant = Keyword.fetch!(json_rpc_named_arguments, :variant) - if Application.get_env(:explorer, :non_existing_variable, false) do - :transaction_params - else + if variant in block_traceable_variants() do :block_number + else + :transaction_params end end @@ -547,7 +469,7 @@ defmodule Indexer.Fetcher.InternalTransaction do def defaults do [ - poll: false, + poll: true, flush_interval: :timer.seconds(3), max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, @@ -557,26 +479,38 @@ defmodule Indexer.Fetcher.InternalTransaction do end defp async_import_celo_token_balances(%{token_transfers: token_transfers, tokens: tokens}) do - if Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() == {:optimism, :celo} do token_transfers_with_token = token_transfers_merge_token(token_transfers, tokens) address_token_balances = %{token_transfers_params: token_transfers_with_token} |> AddressTokenBalances.params_set() - |> Enum.map(fn %{address_hash: address_hash, token_contract_address_hash: token_contract_address_hash} = entry -> - with {:ok, address_hash} <- Hash.Address.cast(address_hash), - {:ok, token_contract_address_hash} <- Hash.Address.cast(token_contract_address_hash) do - entry - |> Map.put(:address_hash, address_hash) - |> Map.put(:token_contract_address_hash, token_contract_address_hash) - else - error -> Logger.error("Failed to cast string to hash: #{inspect(error)}") - end - end) + |> Enum.map(&cast_address_hashes_for_token_balance/1) async_import_token_balances(%{address_token_balances: address_token_balances}, false) else :ok end end + + defp cast_address_hashes_for_token_balance(entry) do + with {:ok, address_hash} <- Hash.Address.cast(entry.address_hash), + {:ok, token_contract_address_hash} <- Hash.Address.cast(entry.token_contract_address_hash) do + entry + |> Map.put(:address_hash, address_hash) + |> Map.put(:token_contract_address_hash, token_contract_address_hash) + else + error -> Logger.error("Failed to cast string to hash: #{inspect(error)}") + end + end + + @doc """ + Returns whether the internal transaction fetcher is disabled. + + This can be used to conditionally disable fetching internal transactions, for example, in a staging environment where the load on the JSON-RPC should be minimized. + """ + @spec disabled? :: boolean() + def disabled? do + Application.get_env(:indexer, __MODULE__, [])[:disabled?] == true + end end diff --git a/apps/indexer/lib/indexer/fetcher/internal_transaction/delete_queue.ex b/apps/indexer/lib/indexer/fetcher/internal_transaction/delete_queue.ex new file mode 100644 index 000000000000..e7d6ae19a813 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/internal_transaction/delete_queue.ex @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.InternalTransaction.DeleteQueue do + @moduledoc """ + Deletes internal transactions for block from the queue and inserts new pending operations for them. + """ + + require Logger + + use Indexer.Fetcher, restart: :permanent + + import Ecto.Query + + alias Explorer.Repo + + alias Explorer.Chain.{InternalTransaction, PendingOperationsHelper} + + alias Explorer.Chain.InternalTransaction.DeleteQueue + alias Indexer.BufferedTask + alias Indexer.Fetcher.InternalTransaction, as: InternalTransactionFetcher + alias Indexer.Helper, as: IndexerHelper + + @behaviour BufferedTask + + @default_max_batch_size 100 + @default_max_concurrency 1 + @default_threshold :timer.minutes(10) + + @doc false + def child_spec([init_options, gen_server_options]) do + merged_init_opts = + defaults() + |> Keyword.merge(init_options) + |> Keyword.put_new(:state, []) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + {:ok, acc} = + DeleteQueue.stream_data( + initial_acc, + fn data, acc -> + IndexerHelper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) + end, + Application.get_env(:indexer, __MODULE__)[:threshold] || @default_threshold + ) + + acc + end + + @impl BufferedTask + def run(block_numbers, _state) when is_list(block_numbers) do + result = + Repo.transaction(fn -> + DeleteQueue + |> where([dq], dq.block_number in ^block_numbers) + |> Repo.delete_all(timeout: :infinity) + + InternalTransaction + |> where([it], it.block_number in ^block_numbers) + |> Repo.delete_all(timeout: :infinity) + + PendingOperationsHelper.insert_pending_operations(block_numbers) + end) + + case result do + {:ok, {block_numbers, transactions}} -> + if not is_nil(Process.whereis(InternalTransactionFetcher)) do + InternalTransactionFetcher.async_fetch(block_numbers, transactions, true) + end + + :ok + + {:error, error} -> + Logger.error("Unable to clean internal transactions for reorg: #{inspect(error)}") + {:retry, block_numbers} + end + end + + defp defaults do + [ + flush_interval: :timer.seconds(10), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/multichain_search_db/balances_export_queue.ex b/apps/indexer/lib/indexer/fetcher/multichain_search_db/balances_export_queue.ex new file mode 100644 index 000000000000..c9ba1afe4f89 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/multichain_search_db/balances_export_queue.ex @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue do + @moduledoc """ + Exports token and coin balances to Multichain Search DB service from the queue. + """ + + require Logger + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + alias Explorer.Chain.{Hash, MultichainSearchDb.BalancesExportQueue} + alias Explorer.Chain.Wei + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + + alias Indexer.BufferedTask + alias Indexer.Helper, as: IndexerHelper + + @behaviour BufferedTask + + @delete_queries_chunk_size 10 + @default_max_batch_size 3000 + @default_max_concurrency 10 + @failed_to_re_export_data_error "Batch balances export retry to the Multichain Search DB failed" + + @doc false + def child_spec([init_options, gen_server_options]) do + merged_init_opts = + defaults() + |> Keyword.merge(init_options) + |> Keyword.merge(state: []) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + {:ok, acc} = + BalancesExportQueue.stream_multichain_db_balances_batch( + initial_acc, + fn data, acc -> + IndexerHelper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) + end, + true + ) + + acc + end + + @impl BufferedTask + def run(data, _json_rpc_named_arguments) when is_list(data) do + prepared_export_data = prepare_export_data(data) + + export_data_to_multichain(prepared_export_data) + end + + defp export_data_to_multichain(prepared_export_data) do + case MultichainSearch.batch_import(prepared_export_data) do + {:ok, {:chunks_processed, result}} -> + all_balances = + result + |> Enum.flat_map(fn params -> + coin_balances = prepare_coin_balances_for_db_query(params[:address_coin_balances]) + token_balances = prepare_token_balances_for_db_query(params[:address_token_balances]) + + coin_balances ++ token_balances + end) + + if !Enum.empty?(all_balances) do + all_balances + |> Enum.sort_by(&{&1.address_hash, &1.token_contract_address_hash_or_native, &1.token_id}) + |> Enum.chunk_every(@delete_queries_chunk_size) + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + |> Enum.each(fn chunk_items -> + chunk_items + |> BalancesExportQueue.delete_elements_from_queue_by_params() + |> Repo.transact() + end) + end + + :ok + + {:error, retry} -> + Logger.error(fn -> + ["#{@failed_to_re_export_data_error}", "#{inspect(prepared_export_data)}"] + end) + + {:retry, retry} + end + end + + defp prepare_token_balances_for_db_query(token_balances) do + token_balances + |> Enum.map(fn token_balance -> + %{ + address_hash: token_balance.address_hash, + token_contract_address_hash_or_native: token_balance.token_address_hash, + token_id: + if(is_nil(token_balance.token_id), + do: nil, + else: token_balance.token_id |> Decimal.to_integer() + ), + value: + if(is_nil(token_balance.value), + do: nil, + else: token_balance.value |> Wei.dump() |> elem(1) |> Decimal.to_integer() + ) + } + end) + end + + defp prepare_coin_balances_for_db_query(coin_balances) do + coin_balances + |> Enum.map(fn coin_balance -> + %{ + address_hash: coin_balance.address_hash, + token_contract_address_hash_or_native: "native", + token_id: nil, + value: + if(is_nil(coin_balance.value), + do: nil, + else: coin_balance.value |> Wei.dump() |> elem(1) |> Decimal.to_integer() + ) + } + end) + end + + @doc """ + Prepares export data by separating balances into coin and token balances. + + ## Parameters + + - `export_data`: A list of maps, each containing: + - `:address_hash` - The address hash of Hash.Address.t(). + - `:token_contract_address_hash_or_native` - The token contract address hash as a binary, or the string `"native"` for native coins. + - `:value` - The balance value as a `Decimal.t()`. + - `:token_id` (optional) - The token ID, present for token balances. + + ## Returns + + A map with the following keys: + - `:address_coin_balances` - A list of maps with `:address_hash` and `:value` for native coin balances. + - `:address_token_balances` - A list of maps with `:address_hash`, `:token_contract_address_hash`, `:token_id`, and `:value` for token balances. + + Native coin balances are grouped under `:address_coin_balances`, while token balances are grouped under `:address_token_balances`. The function also converts binary hashes to string representations using the `Hash` struct. + """ + @spec prepare_export_data([ + %{ + address_hash: Hash.Address.t(), + token_contract_address_hash_or_native: binary(), + value: Decimal.t() | nil, + token_id: Decimal.t() | nil + } + ]) :: %{ + address_coin_balances: list(), + address_token_balances: list() + } + def prepare_export_data(export_data) do + pre_prepared_export_data = + export_data + |> Enum.reduce( + %{ + address_coin_balances: [], + address_token_balances: [] + }, + fn res, acc -> + case res.token_contract_address_hash_or_native do + "native" -> + acc + |> Map.update( + :address_coin_balances, + [%{address_hash: res.address_hash, value: res.value}], + &[%{address_hash: res.address_hash, value: res.value} | &1] + ) + + _ -> + acc + |> Map.update( + :address_token_balances, + [ + %{ + address_hash: res.address_hash, + token_contract_address_hash: + to_string(%Hash{byte_count: 20, bytes: res.token_contract_address_hash_or_native}), + token_id: res.token_id, + value: res.value + } + ], + &[ + %{ + address_hash: res.address_hash, + token_contract_address_hash: + to_string(%Hash{byte_count: 20, bytes: res.token_contract_address_hash_or_native}), + token_id: res.token_id, + value: res.value + } + | &1 + ] + ) + end + end + ) + + pre_prepared_export_data + end + + defp defaults do + [ + flush_interval: :timer.seconds(10), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/multichain_search_db/counters_export_queue.ex b/apps/indexer/lib/indexer/fetcher/multichain_search_db/counters_export_queue.ex new file mode 100644 index 000000000000..ece10ba86655 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/multichain_search_db/counters_export_queue.ex @@ -0,0 +1,137 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.CountersExportQueue do + @moduledoc """ + Exports blockchain data to Multichain Search DB service from the queue. + """ + + require Logger + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + alias Explorer.Chain.MultichainSearchDb.CountersExportQueue + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + + alias Indexer.{BufferedTask, Helper} + + @behaviour BufferedTask + + @default_max_batch_size 1000 + @default_max_concurrency 10 + @delete_queries_chunk_size 10 + @failed_to_export_data_error "Batch counters export attempt to the Multichain Search DB failed" + @fetcher_name :multichain_search_db_counters_export_queue + @queue_size_info "Queue size" + @successfully_sent_info "Successfully sent" + + @doc false + def child_spec([init_options, gen_server_options]) do + merged_init_opts = + defaults() + |> Keyword.merge(init_options) + |> Keyword.merge(state: []) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + {:ok, acc} = + CountersExportQueue.stream_multichain_db_counters_batch( + initial_acc, + fn data, acc -> + Helper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) + end, + true + ) + + acc + end + + @impl BufferedTask + def run(items_from_db_queue, _) when is_list(items_from_db_queue) do + case MultichainSearch.batch_export_counters(items_from_db_queue) do + {:ok, {:chunks_processed, chunks}} -> + chunks + |> Enum.map(fn chunk -> chunk.counters end) + |> List.flatten() + |> Enum.map(&MultichainSearch.counter_http_item_to_queue_item(&1)) + |> delete_queue_items() + |> log_queue_size() + + {:error, data_to_retry} -> + Logger.error(fn -> + ["#{@failed_to_export_data_error}", "#{inspect(data_to_retry.counters)}"] + end) + + queue_items_to_retry = + data_to_retry.counters + |> Enum.map(&MultichainSearch.counter_http_item_to_queue_item(&1)) + + items_from_db_queue + |> Enum.reject(fn item_to_export -> + Enum.any?( + queue_items_to_retry, + &(&1.timestamp == item_to_export.timestamp and &1.counter_type == item_to_export.counter_type) + ) + end) + |> delete_queue_items() + |> log_queue_size() + + {:retry, queue_items_to_retry} + end + end + + # Removes items successfully sent to Multichain service from db queue. + # The list is split into small chunks to prevent db deadlocks. + # + # ## Parameters + # - `items`: The list of queue items to delete from the queue. + # + # ## Returns + # - The `items` list. + @spec delete_queue_items([map()]) :: [map()] + defp delete_queue_items(items) do + items + |> Enum.chunk_every(@delete_queries_chunk_size) + |> Enum.each(fn chunk_items -> + chunk_items + |> CountersExportQueue.delete_query() + |> Repo.transaction() + end) + + items + end + + # Logs the number of the current queue size and the number of successfully sent items. + # + # ## Parameters + # - `items_successful`: The list of items successfully sent to Multichain service. + # + # ## Returns + # - `:ok` + @spec log_queue_size(list()) :: any() + defp log_queue_size(items_successful) do + Logger.info( + fn -> + [ + "#{@queue_size_info}: ", + "#{CountersExportQueue.queue_size()}, ", + "#{@successfully_sent_info}: ", + "#{Enum.count(items_successful)}" + ] + end, + fetcher: @fetcher_name + ) + end + + defp defaults do + [ + flush_interval: :timer.seconds(10), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/multichain_search_db/counters_fetcher.ex b/apps/indexer/lib/indexer/fetcher/multichain_search_db/counters_fetcher.ex new file mode 100644 index 000000000000..8680cea12d14 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/multichain_search_db/counters_fetcher.ex @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.CountersFetcher do + @moduledoc """ + Fetches counters and adds them to a queue to send to Multichain Search DB service. + """ + + use GenServer + use Indexer.Fetcher + + require Logger + + import Ecto.Query, + only: [ + from: 2 + ] + + alias Explorer.Chain.{Address, Transaction} + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.Transaction.History.{Historian, TransactionStats} + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + + @fetcher_name :multichain_search_db_counters_fetcher + + def child_spec(start_link_arguments) do + spec = %{ + id: __MODULE__, + start: {__MODULE__, :start_link, start_link_arguments}, + restart: :transient, + type: :worker + } + + Supervisor.child_spec(spec, []) + end + + def start_link(args, gen_server_options \\ []) do + GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) + end + + @impl GenServer + def init(_args) do + {:ok, %{}, {:continue, nil}} + end + + @impl GenServer + def handle_continue(_, _state) do + Logger.metadata(fetcher: @fetcher_name) + + # two-second pause needed to avoid exceeding Supervisor restart intensity when DB issues + :timer.sleep(2000) + + Process.send(self(), :try_to_fetch_yesterday_counters, []) + + {:noreply, %{}} + end + + @impl GenServer + def handle_info(:try_to_fetch_yesterday_counters, _state) do + today = Date.utc_today() + yesterday = Date.add(today, -1) + + Logger.info("Waiting for transaction stats to be collected for #{yesterday}...") + + last_save_records_date = + Historian.transaction_stats_last_save_records_timestamp() + |> LastFetchedCounter.get() + |> Decimal.to_integer() + |> DateTime.from_unix!() + |> DateTime.to_date() + + if last_save_records_date == today do + # Historian module worked today, so we can use its results + # for yesterday's number of transactions + number_of_transactions = + yesterday + |> TransactionStats.by_date_range(yesterday) + |> Enum.at(0, %{}) + |> Map.get(:number_of_transactions, 0) + + Process.send(self(), :fetch_yesterday_counters, []) + + {:noreply, %{number_of_transactions: number_of_transactions, yesterday: yesterday}} + else + # the stats is not ready yet, so wait for 1 minute and try again + Process.send_after(self(), :try_to_fetch_yesterday_counters, 60_000) + {:noreply, %{}} + end + end + + @impl GenServer + def handle_info(:fetch_yesterday_counters, %{number_of_transactions: number_of_transactions, yesterday: yesterday}) do + yesterday_dt = DateTime.new!(yesterday, Time.new!(23, 59, 59, 0)) + + daily_transactions_number = number_of_transactions + + total_transactions_number = + Repo.aggregate( + from(t in Transaction, where: t.block_timestamp <= ^yesterday_dt and t.block_consensus == true), + :count, + :hash, + timeout: :infinity + ) + + total_addresses_number = + Repo.aggregate(from(a in Address, where: a.inserted_at <= ^yesterday_dt), :count, timeout: :infinity) + + Logger.info("Transaction stats is now available for #{yesterday}:") + Logger.info("daily_transactions_number = #{daily_transactions_number}") + Logger.info("total_transactions_number = #{total_transactions_number}") + Logger.info("total_addresses_number = #{total_addresses_number}") + + MultichainSearch.send_counters_to_queue( + %{ + yesterday_dt => %{ + daily_transactions_number: to_string(daily_transactions_number), + total_transactions_number: to_string(total_transactions_number), + total_addresses_number: to_string(total_addresses_number) + } + }, + :global + ) + + Logger.info("Waiting for the next day...") + Process.send_after(self(), :try_to_fetch_yesterday_counters, calculate_delay_until_next_midnight()) + + {:noreply, %{}} + end + + defp calculate_delay_until_next_midnight do + now = DateTime.utc_now() + tomorrow = DateTime.new!(Date.add(Date.utc_today(), 1), Time.new!(0, 0, 1, 0), now.time_zone) + + DateTime.diff(tomorrow, now, :millisecond) + end +end diff --git a/apps/indexer/lib/indexer/fetcher/multichain_search_db/main_export_queue.ex b/apps/indexer/lib/indexer/fetcher/multichain_search_db/main_export_queue.ex new file mode 100644 index 000000000000..e8809cab5d77 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/multichain_search_db/main_export_queue.ex @@ -0,0 +1,291 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.MainExportQueue do + @moduledoc """ + Exports blockchain data to Multichain Search DB service from the queue. + """ + + require Logger + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Address, Hash, MultichainSearchDb.MainExportQueue, Transaction} + alias Explorer.MicroserviceInterfaces.MultichainSearch + + alias Indexer.BufferedTask + alias Indexer.Helper, as: IndexerHelper + + @behaviour BufferedTask + + @delete_queries_chunk_size 100 + @default_max_batch_size 3000 + @default_max_concurrency 10 + @failed_to_re_export_data_error "Batch main export retry to the Multichain Search DB failed" + + @doc false + def child_spec([init_options, gen_server_options]) do + merged_init_opts = + defaults() + |> Keyword.merge(init_options) + |> Keyword.merge(state: []) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + {:ok, acc} = + MainExportQueue.stream_multichain_db_data_batch( + initial_acc, + fn data, acc -> + IndexerHelper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) + end, + true + ) + + acc + end + + @impl BufferedTask + def run( + %{ + addresses: _addresses, + transactions: _transactions, + block_ranges: _block_ranges, + block_hashes: _block_hashes + } = prepared_export_data, + _json_rpc_named_arguments + ) do + export_data_to_multichain(prepared_export_data) + end + + @impl BufferedTask + def run(data, _json_rpc_named_arguments) when is_list(data) do + prepared_export_data = prepare_export_data(data) + + export_data_to_multichain(prepared_export_data) + end + + defp export_data_to_multichain(prepared_export_data) do + case MultichainSearch.batch_import(prepared_export_data) do + {:ok, {:chunks_processed, result}} -> + all_hashes = + result + |> Enum.flat_map(fn params -> + hashes = prepare_hashes_for_db_query(params[:hashes], :full) + addresses = prepare_hashes_for_db_query(params[:addresses], :address) + + hashes ++ addresses + end) + + all_hashes + |> Enum.chunk_every(@delete_queries_chunk_size) + |> Enum.each(fn chunk_items -> + chunk_items + |> MainExportQueue.by_hashes_query() + |> Repo.delete_all() + end) + + :ok + + {:error, data_to_retry} -> + Logger.error(fn -> + ["#{@failed_to_re_export_data_error}", "#{inspect(prepared_export_data)}"] + end) + + hashes = all_hashes(prepared_export_data) + failed_hashes = failed_hashes(data_to_retry) + + successful_hashes = hashes -- failed_hashes + + successful_hash_binaries = + successful_hashes + |> Enum.map(fn hash -> + "0x" <> hex = hash + Base.decode16!(hex, case: :mixed) + end) + + successful_hash_binaries + |> MainExportQueue.by_hashes_query() + |> Repo.delete_all() + + {:retry, data_to_retry} + end + end + + defp prepare_hashes_for_db_query(entities, entity_type) do + entities + |> Enum.map(fn entity -> + fun = if entity_type == :address, do: :string_to_address_hash, else: :string_to_full_hash + + case apply(Chain, fun, [Map.get(entity, :hash)]) do + {:ok, hash} -> hash.bytes + {:error, _} -> nil + end + end) + |> Enum.reject(&is_nil/1) + end + + defp all_hashes(prepared_export_data) do + transaction_hashes = + prepared_export_data[:transactions] |> Enum.map(&Map.get(&1, :hash)) + + block_hashes = prepared_export_data[:block_hashes] |> Enum.map(&to_string(&1)) + + address_hashes = prepared_export_data[:addresses] |> Enum.map(&to_string(Map.get(&1, :hash))) + + transaction_hashes ++ block_hashes ++ address_hashes + end + + defp failed_hashes(data_to_retry) do + block_transaction_hashes = + data_to_retry.hashes + |> Enum.map(&Map.get(&1, :hash)) + + address_hashes = + data_to_retry.addresses + |> Enum.map(&Map.get(&1, :hash)) + + block_transaction_hashes ++ address_hashes + end + + @doc """ + Prepares export data from a list of maps containing `:hash`, `:hash_type`, and `:block_range` keys. + + Processes each entry by its `:hash_type` (`:address`, `:block`, or `:transaction`), accumulating the corresponding hashes and block ranges. + Converts address hashes to address structs and returns a map with the following keys: + + - `:addresses` - a list of `Address.t()` structs derived from address hashes. + - `:transactions` - a list of `Transaction.t()` structs or maps with transaction hash and hash type. + - `:block_ranges` - a list of maps with `:min_block_number` and `:max_block_number` as strings. + - `:block_hashes` - a list of `Hash.t()` structs for block hashes. + + ## Parameters + + - `export_data`: a list of maps, each containing: + - `:hash` (binary): the hash value. + - `:hash_type` (atom): the type of hash (`:address`, `:block`, or `:transaction`). + - `:block_range` (any): the block range associated with the hash. + + ## Returns + + A map with prepared export data, including addresses, transactions, block ranges, and block hashes. + """ + @spec prepare_export_data([%{hash: binary(), hash_type: atom(), block_range: any()}]) :: %{ + addresses: [Address.t()], + transactions: [Transaction.t() | %{hash: String.t(), hash_type: String.t()}], + block_ranges: [%{min_block_number: String.t(), max_block_number: String.t()}], + block_hashes: [Hash.t()] + } + def prepare_export_data(export_data) do + pre_prepared_export_data = + export_data + |> Enum.reduce( + %{ + address_hashes: [], + block_hashes: [], + transactions: [], + block_ranges: [ + %{ + min_block_number: nil, + max_block_number: nil + } + ] + }, + fn res, acc -> + case res.hash_type do + :address -> + acc + |> Map.update( + :address_hashes, + [%Hash{byte_count: 20, bytes: res.hash}], + &[%Hash{byte_count: 20, bytes: res.hash} | &1] + ) + |> maybe_update_block_ranges_in_params_map(res.block_range) + + :block -> + acc + |> Map.update( + :block_hashes, + [%Hash{byte_count: 32, bytes: res.hash}], + &[%Hash{byte_count: 32, bytes: res.hash} | &1] + ) + |> maybe_update_block_ranges_in_params_map(res.block_range) + + :transaction -> + acc + |> Map.update( + :transactions, + [%{hash: to_string(%Hash{byte_count: 32, bytes: res.hash})}], + &[%{hash: to_string(%Hash{byte_count: 32, bytes: res.hash})} | &1] + ) + |> maybe_update_block_ranges_in_params_map(res.block_range) + end + end + ) + + addresses = + pre_prepared_export_data.address_hashes + |> Chain.hashes_to_addresses() + + pre_prepared_export_data + |> Map.put(:addresses, addresses) + |> Map.drop([:address_hashes]) + |> (&if( + Map.get(&1, :block_ranges) == [ + %{ + max_block_number: nil, + min_block_number: nil + } + ], + do: Map.drop(&1, [:block_ranges]), + else: &1 + )).() + end + + defp defaults do + [ + flush_interval: :timer.seconds(10), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor + ] + end + + defp maybe_update_block_ranges_in_params_map(params_map, nil), do: params_map + + defp maybe_update_block_ranges_in_params_map(params_map, block_range) do + params_map + |> Map.update!( + :block_ranges, + &[ + %{ + min_block_number: to_string(min(block_range.from, parse_block_number(&1, :min_block_number))), + max_block_number: to_string(max(block_range.to, parse_block_number(&1, :max_block_number))) + } + ] + ) + end + + defp parse_block_number(nil, _), do: 0 + + defp parse_block_number([%{min_block_number: nil}], :min_block_number), do: nil + + defp parse_block_number([%{max_block_number: nil}], :max_block_number), do: 0 + + defp parse_block_number( + [ + %{ + min_block_number: _, + max_block_number: _ + } = block_range + ], + type + ) do + case Integer.parse(block_range[type]) do + {num, _} -> num + :error -> 0 + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/multichain_search_db/token_info_export_queue.ex b/apps/indexer/lib/indexer/fetcher/multichain_search_db/token_info_export_queue.ex new file mode 100644 index 000000000000..5d933572896a --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/multichain_search_db/token_info_export_queue.ex @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue do + @moduledoc """ + Exports blockchain data to Multichain Search DB service from the queue. + """ + + require Logger + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + alias Explorer.Chain.MultichainSearchDb.TokenInfoExportQueue + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + + alias Indexer.{BufferedTask, Helper} + + @behaviour BufferedTask + + @default_max_batch_size 1000 + @default_max_concurrency 10 + @failed_to_export_data_error "Batch token info export attempt to the Multichain Search DB failed" + @fetcher_name :multichain_search_db_token_info_export_queue + @queue_size_info "Queue size" + @successfully_sent_info "Successfully sent" + + @doc false + def child_spec([init_options, gen_server_options]) do + merged_init_opts = + defaults() + |> Keyword.merge(init_options) + |> Keyword.merge(state: []) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + {:ok, acc} = + TokenInfoExportQueue.stream_multichain_db_token_info_batch( + initial_acc, + fn data, acc -> + Helper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) + end, + true + ) + + acc + end + + @impl BufferedTask + def run(items_from_db_queue, _) when is_list(items_from_db_queue) do + case MultichainSearch.batch_export_token_info(items_from_db_queue) do + {:ok, {:chunks_processed, chunks}} -> + chunks + |> Enum.map(fn chunk -> chunk.tokens end) + |> List.flatten() + |> Enum.map(&MultichainSearch.token_info_http_item_to_queue_item(&1)) + |> delete_queue_items() + |> log_queue_size() + + {:error, data_to_retry} -> + Logger.error(fn -> + ["#{@failed_to_export_data_error}", "#{inspect(data_to_retry.tokens)}"] + end) + + queue_items_to_retry = + data_to_retry.tokens + |> Enum.map(&MultichainSearch.token_info_http_item_to_queue_item(&1)) + + items_from_db_queue + |> Enum.reject(fn item_to_export -> + Enum.any?( + queue_items_to_retry, + &(&1.address_hash == item_to_export.address_hash and &1.data_type == item_to_export.data_type) + ) + end) + |> delete_queue_items() + |> log_queue_size() + + {:retry, queue_items_to_retry} + end + end + + # Removes items successfully sent to Multichain service from db queue. + # + # ## Parameters + # - `items`: The list of queue items to delete from the queue. + # + # ## Returns + # - The `items` list. + @spec delete_queue_items([map()]) :: [map()] + defp delete_queue_items(items) do + items + |> Enum.each(fn item -> + item + |> TokenInfoExportQueue.delete_query() + |> Repo.delete_all() + end) + + items + end + + # Logs the number of the current queue size and the number of successfully sent items. + # + # ## Parameters + # - `items_successful`: The list of items successfully sent to Multichain service. + # + # ## Returns + # - `:ok` + @spec log_queue_size(list()) :: any() + defp log_queue_size(items_successful) do + Logger.info( + fn -> + [ + "#{@queue_size_info}: ", + "#{TokenInfoExportQueue.queue_size()}, ", + "#{@successfully_sent_info}: ", + "#{Enum.count(items_successful)}" + ] + end, + fetcher: @fetcher_name + ) + end + + defp defaults do + [ + flush_interval: :timer.seconds(10), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/coin_balance.ex b/apps/indexer/lib/indexer/fetcher/on_demand/coin_balance.ex index c9e868ca2b4b..59e621c0ac01 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/coin_balance.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/coin_balance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.CoinBalance do @moduledoc """ Ensures that we have a reasonably up to date coin balance for a given address. @@ -13,9 +14,10 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalance do require Logger + alias EthereumJSONRPC.FetchedBalances alias Explorer.{Chain, Repo} alias Explorer.Chain.{Address, Hash} - alias Explorer.Chain.Address.{CoinBalance, CoinBalanceDaily} + alias Explorer.Chain.Address.CoinBalance alias Explorer.Chain.Cache.{Accounts, BlockNumber} alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Utility.RateLimiter @@ -78,7 +80,7 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalance do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -107,7 +109,7 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalance do |> Enum.uniq() case fetch_balances(all_balances_params, json_rpc_named_arguments) do - {:ok, %{params_list: params_list}} -> + {:ok, %FetchedBalances{params_list: params_list}} -> params_map = Map.new(params_list, fn params -> {{params.block_number, params.address_hash}, params} end) entries_by_type[:fetch_and_update] @@ -157,62 +159,32 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalance do {:stale, 0} end - defp do_trigger_fetch(address, latest_block_number, stale_balance_window) do - latest_by_day = CoinBalanceDaily.latest_by_day_query(address.hash) - - latest = CoinBalance.latest_coin_balance_query(address.hash, stale_balance_window) - - do_trigger_balance_fetch_query(address, latest_block_number, stale_balance_window, latest, latest_by_day) - end - - defp do_trigger_historic_fetch(address_hash, block_number) do - BufferedTask.buffer(__MODULE__, [{:fetch_and_import, block_number, to_string(address_hash)}], false) + defp do_trigger_fetch(address, latest_block_number, stale_balance_window) + when address.fetched_coin_balance_block_number < stale_balance_window do + BufferedTask.buffer(__MODULE__, [{:fetch_and_update, latest_block_number, to_string(address.hash)}], false) - {:stale, 0} + {:stale, latest_block_number} end - defp do_trigger_balance_fetch_query( - address, - latest_block_number, - stale_balance_window, - query_balances, - query_balances_daily - ) do - if address.fetched_coin_balance_block_number < stale_balance_window do - do_trigger_balance_daily_fetch_query(address, latest_block_number, query_balances_daily) - BufferedTask.buffer(__MODULE__, [{:fetch_and_update, latest_block_number, to_string(address.hash)}], false) - - {:stale, latest_block_number} - else - case Repo.one(query_balances) do - nil -> - # There is no recent coin balance to fetch, so we check to see how old the - # balance is on the address. If it is too old, we check again, just to be safe. - do_trigger_balance_daily_fetch_query(address, latest_block_number, query_balances_daily) + defp do_trigger_fetch(address, _latest_block_number, stale_balance_window) + when address.fetched_coin_balance_block_number >= stale_balance_window do + latest_balances_query = CoinBalance.latest_coin_balance_query(address.hash, stale_balance_window) - :current + case Repo.replica().one(latest_balances_query) do + %CoinBalance{value_fetched_at: nil, block_number: block_number} -> + BufferedTask.buffer(__MODULE__, [{:fetch_and_import, block_number, to_string(address.hash)}], false) - %CoinBalance{value_fetched_at: nil, block_number: block_number} -> - BufferedTask.buffer(__MODULE__, [{:fetch_and_import, block_number, to_string(address.hash)}], false) + {:pending, block_number} - {:pending, block_number} - - %CoinBalance{} -> - do_trigger_balance_daily_fetch_query(address, latest_block_number, query_balances_daily) - - :current - end + _ -> + :current end end - defp do_trigger_balance_daily_fetch_query(address, latest_block_number, query) do - if Repo.one(query) == nil do - BufferedTask.buffer( - __MODULE__, - [{:fetch_and_import_daily_balances, latest_block_number, to_string(address.hash)}], - false - ) - end + defp do_trigger_historic_fetch(address_hash, block_number) do + BufferedTask.buffer(__MODULE__, [{:fetch_and_import, block_number, to_string(address_hash)}], false) + + {:stale, 0} end defp fetch_balances(params, json_rpc_named_arguments) do diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex b/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex index 3f7bfcd72c80..246d943389db 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.ContractCode do @moduledoc """ Ensures that we have a smart-contract bytecode indexed. @@ -11,9 +12,11 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do import EthereumJSONRPC, only: [fetch_codes: 2] alias Explorer.Chain - alias Explorer.Chain.Address + alias Explorer.Chain.{Address, Data, Hash} + alias Explorer.Chain.Cache.Accounts alias Explorer.Chain.Cache.Counters.Helper alias Explorer.Chain.Events.Publisher + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Utility.{AddressContractCodeFetchAttempt, RateLimiter} alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand @@ -21,7 +24,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do @spec trigger_fetch(String.t() | nil, Address.t()) :: :ok def trigger_fetch(caller \\ nil, address) do - if is_nil(address.contract_code) do + if is_nil(address.contract_code) or Address.eoa_with_code?(address) do case RateLimiter.check_rate(caller, :on_demand) do :allow -> GenServer.cast(__MODULE__, {:fetch, address}) :deny -> :ok @@ -31,6 +34,26 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do end end + @doc """ + Checks database for bytecode first, then fetches from RPC if not found. + This function handles the complete verification flow including database fallback. + + Returns `{:ok, bytecode}` if bytecode is found, or `:error` otherwise. + """ + @spec get_or_fetch_bytecode(Hash.Address.t()) :: + {:ok, String.t()} | :error + def get_or_fetch_bytecode(caller \\ nil, address_hash) do + with {:ok, %Address{} = address} <- Chain.hash_to_address(address_hash, []), + fetch? = is_nil(address.contract_code) or Address.eoa_with_code?(address), + {true, _} <- {fetch?, address.contract_code}, + :allow <- RateLimiter.check_rate(caller, :on_demand) do + GenServer.call(__MODULE__, {:fetch, address}) + else + {false, bytecode} -> {:ok, bytecode} + _ -> :error + end + end + # Attempts to fetch the contract code for a given address. # # This function checks if the contract code needs to be fetched and if enough time @@ -45,7 +68,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do # `:ok` in all cases. @spec fetch_contract_code(Address.t(), %{ json_rpc_named_arguments: EthereumJSONRPC.json_rpc_named_arguments() - }) :: :ok + }) :: {:ok, String.t() | nil} | :error defp fetch_contract_code(address, state) do with {:need_to_fetch, true} <- {:need_to_fetch, fetch?(address)}, {:retries_number, {retries_number, updated_at}} <- @@ -58,14 +81,13 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do fetch_and_broadcast_bytecode(address, state) else {:need_to_fetch, false} -> - :ok + :error {:retries_number, nil} -> fetch_and_broadcast_bytecode(address, state) - :ok {:retry, false} -> - :ok + :error end end @@ -96,7 +118,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do # `:ok` (the function always returns `:ok`, actual results are handled via side effects) @spec fetch_and_broadcast_bytecode(Address.t(), %{ json_rpc_named_arguments: EthereumJSONRPC.json_rpc_named_arguments() - }) :: :ok + }) :: {:ok, String.t() | nil} | :error defp fetch_and_broadcast_bytecode(address, %{json_rpc_named_arguments: _} = state) do with {:fetched_code, {:ok, %EthereumJSONRPC.FetchedCodes{params_list: fetched_codes}}} <- {:fetched_code, @@ -106,24 +128,46 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do )}, contract_code_object = List.first(fetched_codes), false <- is_nil(contract_code_object), - true <- contract_code_object.code !== "0x" do - case Chain.import(%{addresses: %{params: [%{hash: address.hash, contract_code: contract_code_object.code}]}}) do - {:ok, _} -> - Publisher.broadcast(%{fetched_bytecode: [address.hash, contract_code_object.code]}, :on_demand) + {:ok, fetched_code} <- + (contract_code_object.code == "0x" && {:ok, nil}) || Data.cast(contract_code_object.code), + true <- fetched_code != address.contract_code, + {:ok, %{addresses: addresses}} <- + Chain.import(%{ + addresses: %{ + params: [%{hash: address.hash, contract_code: fetched_code}], + on_conflict: {:replace, [:contract_code, :updated_at]}, + fields_to_update: [:contract_code] + } + }) do + Accounts.drop(addresses) + + # Update EIP7702 proxy addresses to avoid inconsistencies between addresses and proxy_implementations tables. + # Other proxy types are not handled here, since their bytecode doesn't change the way EIP7702 bytecode does. + cond do + Address.smart_contract?(address) and !Address.eoa_with_code?(address) -> + :ok + + is_nil(fetched_code) -> + Implementation.delete_implementations([address.hash]) + + true -> + Implementation.upsert_eip7702_implementations(addresses) + end - ContractCreatorOnDemand.trigger_fetch(address) + Publisher.broadcast(%{fetched_bytecode: [address.hash, contract_code_object.code]}, :on_demand) - AddressContractCodeFetchAttempt.delete(address.hash) + ContractCreatorOnDemand.trigger_fetch(address) - error -> - Logger.error(fn -> "Error while setting address #{address.hash} deployed bytecode: #{inspect(error)}" end) - end + AddressContractCodeFetchAttempt.delete(address.hash) + + {:ok, fetched_code} else {:fetched_code, {:error, _}} -> - :ok + :error _ -> AddressContractCodeFetchAttempt.insert_retries_number(address.hash) + :error end end @@ -143,6 +187,23 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do {:noreply, state} end + @impl true + def handle_call({:fetch, address}, _from, state) do + result = fetch_contract_code(address, state) + {:reply, result, state} + end + + @impl true + def handle_info({:DOWN, _ref, :process, _pid, :normal}, state) do + {:noreply, state} + end + + def handle_info(message, state) do + Logger.warning("Unexpected message received in handle_info/2: #{inspect(message)}") + + {:noreply, state} + end + # An initial threshold to fetch smart-contract bytecode on-demand @spec update_threshold_ms() :: non_neg_integer() defp update_threshold_ms do diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/contract_creator.ex b/apps/indexer/lib/indexer/fetcher/on_demand/contract_creator.ex index c75a1770c5dc..ad99e07bc19e 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/contract_creator.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/contract_creator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.ContractCreator do @moduledoc """ Ensures that we have a smart-contract creator address indexed. @@ -11,38 +12,53 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do import EthereumJSONRPC, only: [id_to_params: 1, integer_to_quantity: 1, json_rpc: 2] alias EthereumJSONRPC.Nonce - alias Explorer.Chain.Address + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.Chain.{Address, Block, PendingOperationsHelper} alias Explorer.Chain.Cache.BlockNumber - alias Explorer.Utility.MissingRangesManipulator - - import Indexer.Block.Fetcher, - only: [ - async_import_internal_transactions: 2 - ] + alias Explorer.Utility.MissingBlockRange + alias Indexer.Fetcher.InternalTransaction @table_name :contract_creator_lookup @pending_blocks_cache_key "pending_blocks" + @max_json_rpc_retries 5 def start_link(_) do - :ets.new(@table_name, [ - :set, - :named_table, - :public, - read_concurrency: true, - write_concurrency: true - ]) + if :ets.whereis(@table_name) == :undefined do + :ets.new(@table_name, [ + :set, + :named_table, + :public, + read_concurrency: true, + write_concurrency: true + ]) + end GenServer.start_link(__MODULE__, [], name: __MODULE__) end @spec trigger_fetch(Address.t()) :: :ok | :ignore def trigger_fetch(address) do + if :ets.whereis(@table_name) == :undefined do + Logger.warning( + "ContractCreator ETS table is not available, skipping on-demand fetch for address #{to_string(address.hash)}" + ) + + :ignore + else + do_trigger_fetch(address) + end + end + + @spec do_trigger_fetch(Address.t()) :: :ok | :ignore + defp do_trigger_fetch(address) do # we expect here, that address has 'contract_creation_internal_transaction' and 'contract_creation_transaction' preloads creation_transaction = Address.creation_transaction(address) creator_hash = creation_transaction && creation_transaction.from_address_hash with false <- is_nil(address.contract_code), true <- is_nil(creator_hash), + false <- Address.eoa_with_code?(address), + true <- table_exists?(), {:address_lookup, [{_, contract_creation_block_number}]} <- {:address_lookup, :ets.lookup(@table_name, address_cache_name(address.hash))}, {:pending_blocks_lookup, [{@pending_blocks_cache_key, blocks}]} <- @@ -61,7 +77,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do end end - @spec fetch_contract_creator_address_hash(Explorer.Chain.Hash.Address.t()) :: :ok + @spec fetch_contract_creator_address_hash(Explorer.Chain.Hash.Address.t()) :: non_neg_integer() | :error defp fetch_contract_creator_address_hash(address_hash) do max_block_number = BlockNumber.get_max() @@ -71,37 +87,10 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do previous_nonce: nil } - contract_creation_block_number = find_contract_creation_block_number(initial_block_ranges, address_hash) - - pending_blocks = - case pending_blocks_cache() do - [] -> - [] - - [{_, pending_blocks}] -> - pending_blocks - end - - updated_pending_blocks = - case Enum.member?(pending_blocks, contract_creation_block_number) do - true -> - pending_blocks - - false -> - [ - %{block_number: contract_creation_block_number, address_hash_string: to_string(address_hash)} - | pending_blocks - ] - end - - :ets.insert(@table_name, {@pending_blocks_cache_key, updated_pending_blocks}) - - # Change `1` to specific label when `priority` field becomes `Ecto.Enum`. - MissingRangesManipulator.add_ranges_by_block_numbers([contract_creation_block_number], 1) + find_contract_creation_block_number(initial_block_ranges, address_hash, @max_json_rpc_retries) end - defp find_contract_creation_block_number(block_ranges, address_hash) do - :ets.insert(@table_name, {address_cache_name(address_hash), :in_progress}) + defp find_contract_creation_block_number(block_ranges, address_hash, retries_left) do json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) medium = trunc((block_ranges.right - block_ranges.left) / 2) medium_position = block_ranges.left + medium @@ -110,32 +99,49 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do id_to_params = id_to_params([params]) - with {:ok, response} <- - params - |> Map.merge(%{id: 0}) - |> Nonce.request() - |> json_rpc(json_rpc_named_arguments) do - case Nonce.from_response(%{id: 0, result: response}, id_to_params) do - {:ok, %{nonce: 0}} -> - left_new = new_left_position(medium, medium_position) - block_ranges = Map.put(block_ranges, :left, left_new) + case params + |> Map.merge(%{id: 0}) + |> Nonce.request() + |> json_rpc(json_rpc_named_arguments) do + {:ok, response} -> + case Nonce.from_response(%{id: 0, result: response}, id_to_params) do + {:ok, %{nonce: 0}} -> + left_new = new_left_position(medium, medium_position) + block_ranges = Map.put(block_ranges, :left, left_new) - maybe_continue_binary_search(block_ranges, address_hash, 0) + maybe_continue_binary_search(block_ranges, address_hash, 0, retries_left) - {:ok, %{nonce: nonce}} when nonce > 0 -> - right_new = new_right_position(medium, medium_position) - block_ranges = Map.put(block_ranges, :right, right_new) + {:ok, %{nonce: nonce}} when nonce > 0 -> + right_new = new_right_position(medium, medium_position) + block_ranges = Map.put(block_ranges, :right, right_new) - maybe_continue_binary_search(block_ranges, address_hash, nonce) + maybe_continue_binary_search(block_ranges, address_hash, nonce, retries_left) - _ -> - Logger.error("Error while fetching 'eth_getTransactionCount' for address #{to_string(address_hash)}") - :timer.sleep(1000) - find_contract_creation_block_number(block_ranges, address_hash) - end + _ -> + Logger.error("Error while fetching 'eth_getTransactionCount' for address #{to_string(address_hash)}") + retry_find_contract_creation_block_number(block_ranges, address_hash, retries_left) + end + + {:error, reason} -> + Logger.error( + "Error while fetching 'eth_getTransactionCount' for address #{to_string(address_hash)}: #{inspect(reason)}" + ) + + retry_find_contract_creation_block_number(block_ranges, address_hash, retries_left) end end + defp retry_find_contract_creation_block_number(_block_ranges, address_hash, 0) do + Logger.error("Reached max retry attempts for 'eth_getTransactionCount' for address #{to_string(address_hash)}") + + :error + end + + defp retry_find_contract_creation_block_number(block_ranges, address_hash, retries_left) do + :timer.sleep(1000) + find_contract_creation_block_number(block_ranges, address_hash, retries_left - 1) + end + defp new_left_position(medium, medium_position) do if medium == 0, do: medium_position + 1, else: medium_position end @@ -144,7 +150,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do if medium == 0, do: medium_position - 1, else: medium_position end - defp maybe_continue_binary_search(block_ranges, address_hash, nonce) do + defp maybe_continue_binary_search(block_ranges, address_hash, nonce, retries_left) do cond do block_ranges.left == block_ranges.right -> block_ranges.left @@ -154,7 +160,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do true -> block_ranges = Map.put(block_ranges, :previous_nonce, nonce) - find_contract_creation_block_number(block_ranges, address_hash) + find_contract_creation_block_number(block_ranges, address_hash, retries_left) end end @@ -165,7 +171,79 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do @impl true def handle_cast({:fetch, address}, state) do - fetch_contract_creator_address_hash(address.hash) + address_hash = address.hash + :ets.insert(@table_name, {address_cache_name(address_hash), :in_progress}) + + Task.start(fn -> + result = fetch_contract_creator_address_hash(address_hash) + GenServer.cast(__MODULE__, {:fetch_result, address_hash, result}) + end) + + {:noreply, state} + end + + @impl true + def handle_cast({:fetch_result, address_hash, contract_creation_block_number}, state) + when is_integer(contract_creation_block_number) do + address_hash_string = to_string(address_hash) + + pending_blocks = + case pending_blocks_cache() do + [] -> + [] + + [{_, pending_blocks}] -> + pending_blocks + end + + updated_pending_blocks = [ + %{block_number: contract_creation_block_number, address_hash_string: address_hash_string} + | Enum.reject(pending_blocks, fn %{address_hash_string: addr} -> addr == address_hash_string end) + ] + + :ets.insert(@table_name, {address_cache_name(address_hash), contract_creation_block_number}) + :ets.insert(@table_name, {@pending_blocks_cache_key, updated_pending_blocks}) + + # Change `1` to specific label when `priority` field becomes `Ecto.Enum`. + priority = 1 + + if InternalTransaction.disabled?() do + if Block.indexed?(contract_creation_block_number) do + # credo:disable-for-lines:2 Credo.Check.Refactor.Nesting + if RangesHelper.traceable_block_number?(contract_creation_block_number) do + PendingOperationsHelper.insert_pending_operations([contract_creation_block_number], priority) + end + else + MissingBlockRange.add_ranges_by_block_numbers([contract_creation_block_number], priority) + end + else + unless Block.indexed?(contract_creation_block_number) do + MissingBlockRange.add_ranges_by_block_numbers([contract_creation_block_number], priority) + end + end + + {:noreply, state} + end + + @impl true + def handle_cast({:fetch_result, address_hash, unexpected_value}, state) do + Logger.error( + "Unexpected contract creation block number for address #{to_string(address_hash)}: #{inspect(unexpected_value)}" + ) + + :ets.delete(@table_name, address_cache_name(address_hash)) + + {:noreply, state} + end + + @impl true + def handle_cast({:update_pending_contract_creator_cache, imported}, state) do + imported_block_numbers = + imported + |> Map.get(:blocks, []) + |> Enum.map(&Map.get(&1, :number)) + + maybe_update_pending_contract_creator_cache(imported_block_numbers) {:noreply, state} end @@ -184,46 +262,46 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do # - `[{String.t(), [map()]}]`: A list of tuples containing block identifiers and their associated data. @spec pending_blocks_cache() :: [{String.t(), [map()]}] - defp pending_blocks_cache, do: :ets.lookup(@table_name, @pending_blocks_cache_key) + defp pending_blocks_cache do + if table_exists?() do + :ets.lookup(@table_name, @pending_blocks_cache_key) + else + [] + end + end + + defp table_exists? do + :ets.whereis(@table_name) != :undefined + end @doc """ Asynchronously updates value of ETS cache :contract_creator_lookup for key "pending_blocks": removes block from the cache since the block has been imported. """ - @spec async_update_cache_of_contract_creator_on_demand(map()) :: Task.t() + @spec async_update_cache_of_contract_creator_on_demand(map()) :: :ok def async_update_cache_of_contract_creator_on_demand(imported) do - Task.async(fn -> - imported_block_numbers = - imported - |> Map.get(:blocks, []) - |> Enum.map(&Map.get(&1, :number)) - - unless Enum.empty?(imported_block_numbers) do - cache_key = @pending_blocks_cache_key - # credo:disable-for-next-line Credo.Check.Refactor.Nesting - case pending_blocks_cache() do - [{^cache_key, pending_blocks}] -> - update_pending_contract_creator_blocks(pending_blocks, imported_block_numbers, imported) - - [] -> - :ok - end - end - end) + GenServer.cast(__MODULE__, {:update_pending_contract_creator_cache, imported}) + :ok + end + + defp maybe_update_pending_contract_creator_cache([]), do: :ok + + defp maybe_update_pending_contract_creator_cache(imported_block_numbers) do + case pending_blocks_cache() do + [{@pending_blocks_cache_key, pending_blocks}] -> + update_pending_contract_creator_blocks(pending_blocks, imported_block_numbers) + + [] -> + :ok + end end - defp update_pending_contract_creator_blocks([], _imported_block_numbers, _imported), do: [] + defp update_pending_contract_creator_blocks([], _imported_block_numbers), do: [] - defp update_pending_contract_creator_blocks(pending_blocks, imported_block_numbers, imported) do + defp update_pending_contract_creator_blocks(pending_blocks, imported_block_numbers) do updated_pending_block_numbers = Enum.filter(pending_blocks, fn pending_block -> if Enum.member?(imported_block_numbers, pending_block.block_number) do - contract_creation_block = - find_contract_creation_block_in_imported(imported, pending_block.block_number) - - internal_transactions_import_params = [%{blocks: [contract_creation_block]}] - async_import_internal_transactions(internal_transactions_import_params, true) - # todo: emit event that contract creator updated for the contract. This was the purpose keeping address_hash_string in that cache key. :ets.delete(@table_name, pending_block.address_hash_string) false @@ -237,10 +315,4 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreator do {@pending_blocks_cache_key, updated_pending_block_numbers} ) end - - defp find_contract_creation_block_in_imported(imported, contract_creation_block_number) do - Enum.find(imported[:blocks], fn %Explorer.Chain.Block{number: block_number} -> - block_number == contract_creation_block_number - end) - end end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/first_trace.ex b/apps/indexer/lib/indexer/fetcher/on_demand/first_trace.ex index 13ea834560c4..36043b7c7181 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/first_trace.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/first_trace.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.FirstTrace do @moduledoc """ On demand fetcher of first transaction's trace @@ -6,18 +7,18 @@ defmodule Indexer.Fetcher.OnDemand.FirstTrace do use GenServer use Indexer.Fetcher, restart: :permanent - alias Explorer.Chain alias Explorer.Chain.{Import, InternalTransaction} alias Explorer.Chain.Import.Runner.InternalTransactions require Logger def maybe_trigger_fetch(transaction, opts \\ []) do - unless Application.get_env(:explorer, :shrink_internal_transactions_enabled) do + if !Application.get_env(:explorer, :shrink_internal_transactions_enabled) do transaction.hash |> InternalTransaction.all_transaction_to_internal_transactions(opts) |> Enum.any?(&(&1.index == 0)) - |> unless do + |> Kernel.!() + |> if do trigger_fetch(transaction) end end @@ -31,7 +32,7 @@ defmodule Indexer.Fetcher.OnDemand.FirstTrace do hash_string = to_string(transaction.hash) response = - Chain.fetch_first_trace( + InternalTransaction.fetch_first_trace( [ %{ block_hash: transaction.block_hash, diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex b/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex new file mode 100644 index 000000000000..ec606f2653ea --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/on_demand/internal_transaction.ex @@ -0,0 +1,786 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +# credo:disable-for-this-file +defmodule Indexer.Fetcher.OnDemand.InternalTransaction do + @moduledoc """ + Fetches internal transactions from node. + """ + + require Logger + + import Ecto.Query + + alias Explorer.{Chain, Etherscan, PagingOptions} + alias Explorer.Chain.{Block, BlockNumberHelper, Hash, InternalTransaction, Transaction} + alias Explorer.Chain.Cache.BlockNumber + alias Explorer.Repo + alias Explorer.Utility.{AddressIdToAddressHash, InternalTransactionsAddressPlaceholder} + alias Indexer.Fetcher.InternalTransaction, as: InternalTransactionFetcher + + @default_paging_options %PagingOptions{page_size: 50} + + @doc """ + Determines whether internal transactions should be fetched on-demand based on DB records and limit. + + ## Parameters + - `records_from_db`: List of internal transaction records from the database + - `limit`: The number of records requested + + ## Returns + - `true` if on-demand fetching is needed + - `false` if DB records are sufficient + """ + @spec should_fetch?([InternalTransaction.t()], non_neg_integer()) :: boolean() + def should_fetch?(_records, 0), do: false + + def should_fetch?(records_from_db, limit) do + if internal_transactions_fetching_disabled?() do + false + else + with true <- Enum.count(records_from_db) >= limit, + %{block_number: min_block_number} <- Enum.min_by(records_from_db, & &1.block_number), + true <- InternalTransaction.present_in_db?(min_block_number) do + false + else + _ -> not InternalTransactionsAddressPlaceholder.empty?() + end + end + end + + @doc """ + Fetches latest internal transactions. + + This function acts like `Explorer.Chain.InternalTransaction.fetch/2` without `transaction_hash` + which means that it applies paging and associations preloading and returning list of DB model records. + + ## Parameters + - `options`: Keyword list with optional keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:paging_options` - pagination options including page_size and key + + ## Returns + - List of latest InternalTransaction structs + """ + @spec fetch_latest(Keyword.t()) :: [InternalTransaction.t()] + def fetch_latest(options) do + if internal_transactions_fetching_disabled?() do + [] + else + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + from_block = Chain.from_block(options) + to_block = Chain.to_block(options) + + to_block_number = + case {paging_options, to_block} do + {%PagingOptions{key: {block_number, _, _}}, _} -> block_number + {_, block_number} when is_integer(block_number) -> block_number + _ -> BlockNumber.get_max() + end + + sort_direction = + case Keyword.get(options, :sort_direction) do + :asc -> &<=/2 + _ -> &>=/2 + end + + index_internal_transaction_desc_order = Keyword.get(options, :index_internal_transaction_desc_order, true) + + to_block_number + |> fetch_enough(from_block || 0, paging_options.page_size, options) + |> Enum.sort_by(&{&1.block_number, &1.transaction_index, &1.index}, sort_direction) + |> page_internal_transaction(paging_options, %{ + index_internal_transaction_desc_order: index_internal_transaction_desc_order + }) + |> Enum.take(paging_options.page_size) + end + end + + @doc """ + Fetches internal transactions for the given transaction from node. + + This function acts like `Explorer.Chain.InternalTransaction.transaction_to_internal_transactions/2` + which means that it applies paging and associations preloading and returning list of DB model records. + + ## Parameters + - `transaction`: The transaction struct to fetch internal transactions for + - `options`: Keyword list with optional keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:paging_options` - pagination options including page_size and key + + ## Returns + - List of InternalTransaction structs for the given transaction + """ + @spec fetch_by_transaction(Transaction.t(), Keyword.t()) :: [InternalTransaction.t()] + def fetch_by_transaction(transaction, options \\ []) do + if internal_transactions_fetching_disabled?() do + [] + else + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + params = [ + %{ + block_number: transaction.block_number, + hash_data: to_string(transaction.hash), + transaction_index: transaction.index + } + ] + + case EthereumJSONRPC.fetch_internal_transactions(params, json_rpc_named_arguments) do + {:ok, internal_transactions_params} -> + internal_transactions_params + |> Enum.map(&serialize/1) + |> different_from_parent_transaction() + |> Enum.sort_by(& &1.index) + |> join_associations(necessity_by_association) + |> page_internal_transaction(paging_options) + |> Enum.take(paging_options.page_size) + |> Repo.preload(:block) + |> InternalTransaction.preload_error() + |> InternalTransaction.preload_transaction() + |> InternalTransaction.preload_addresses(options) + + :ignore -> + [transaction.block_number] + |> fetch_block_internal_transactions() + |> Enum.map(&serialize/1) + |> Enum.filter(&(&1.block_number == transaction.block_number and &1.transaction_index == transaction.index)) + |> different_from_parent_transaction() + |> Enum.sort_by(& &1.index) + |> join_associations(necessity_by_association) + |> page_internal_transaction(paging_options) + |> Enum.take(paging_options.page_size) + |> Repo.preload(:block) + |> InternalTransaction.preload_error() + |> InternalTransaction.preload_transaction() + |> InternalTransaction.preload_addresses(options) + + error -> + Logger.error( + "Failed to fetch internal transactions for transaction #{inspect(transaction.hash)}: #{inspect(error)}" + ) + + [] + end + end + end + + @doc """ + Fetches internal transactions for the given block from node. + + This function acts like `Explorer.Chain.InternalTransaction.block_to_internal_transactions/2` + which means that it applies paging and associations preloading and returning list of DB model records. + + ## Parameters + - `block_number`: The block number to fetch internal transactions for + - `options`: Keyword list with optional keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:paging_options` - pagination options including page_size and key + - `:type` - filter by transaction type + - `:call_type` - filter by call type + + ## Returns + - List of InternalTransaction structs for the given block + """ + @spec fetch_by_block(map() | non_neg_integer(), Keyword.t()) :: [InternalTransaction.t()] + def fetch_by_block(block, options \\ []) + + def fetch_by_block(block_number, options) when is_integer(block_number) do + fetch_by_block(%Block{number: block_number, hash: nil}, options) + end + + def fetch_by_block(%Block{} = block, options) do + if internal_transactions_fetching_disabled?() do + [] + else + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + type_filter = Keyword.get(options, :type) + call_type_filter = Keyword.get(options, :call_type) + unlimited? = Keyword.get(options, :unlimited) + + [block.number] + |> fetch_block_internal_transactions() + |> Enum.map(&serialize/1) + |> different_from_parent_transaction() + |> filter_by_type(type_filter, call_type_filter) + |> filter_by_call_type(call_type_filter) + |> page_block_internal_transaction(paging_options) + |> Enum.sort_by(&{&1.transaction_index, &1.index}) + |> then(&if unlimited?, do: &1, else: Enum.take(&1, paging_options.page_size)) + |> join_associations(necessity_by_association) + |> InternalTransaction.preload_error() + |> InternalTransaction.preload_transaction() + end + end + + @doc """ + Fetches internal transactions for the given address from node. + + This function acts like `Explorer.Chain.InternalTransaction.address_to_internal_transactions/2` + which means that it applies paging and associations preloading and returning list of DB model records. + + ## Parameters + - `address_hash`: The address hash to fetch internal transactions for + - `options`: Keyword list with optional keys: + - `:necessity_by_association` - associations to preload as required or optional + - `:paging_options` - pagination options including page_size and key + - `:direction` - if specified, will filter internal transactions by address type. If `:to` is specified, only + internal transactions where the "to" address matches will be returned. Likewise, if `:from` is specified, only + internal transactions where the "from" address matches will be returned. If `:direction` is omitted, internal + transactions either to or from the address will be returned. + - `:from_block` - lower boundary for block number + - `:to_block` - upper boundary for block number + + ## Returns + - List of InternalTransaction structs for the given address + """ + @spec fetch_by_address(Hash.Address.t(), Keyword.t()) :: [InternalTransaction.t()] + def fetch_by_address(address_hash, options) do + if internal_transactions_fetching_disabled?() do + [] + else + necessity_by_association = Keyword.get(options, :necessity_by_association, %{}) + direction = Keyword.get(options, :direction) + + from_block = Chain.from_block(options) + to_block = Chain.to_block(options) + + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + address_id = AddressIdToAddressHash.hash_to_id(address_hash) + + block_number_from_paging_options = + case paging_options do + %{key: {block_number, _, _}} -> block_number + _ -> nil + end + + max_block_number = + case {to_block, block_number_from_paging_options} do + {nil, nil} -> nil + {nil, key} -> key + {to, nil} -> to + {to, key} -> min(to, key) + end + + sum_mode = + case direction do + d when d in [:to, :to_address_hash] -> "tos" + d when d in [:from, :from_address_hash] -> "froms" + _ -> "both" + end + + sort_direction = Keyword.get(options, :sort_direction, :desc) + + sort_func = + case sort_direction do + :asc -> &<=/2 + _ -> &>=/2 + end + + index_internal_transaction_desc_order = Keyword.get(options, :index_internal_transaction_desc_order, true) + + address_id + |> do_fetch_for_address(max_block_number, from_block, paging_options.page_size, sum_mode, sort_direction) + |> Enum.map(&serialize/1) + |> filter_by_address(address_hash, direction) + |> different_from_parent_transaction() + |> page_internal_transaction(paging_options, %{ + index_internal_transaction_desc_order: index_internal_transaction_desc_order + }) + |> Enum.sort_by(&{&1.block_number, &1.transaction_index, &1.index}, sort_func) + |> Enum.take(paging_options.page_size) + |> join_associations(necessity_by_association) + |> Repo.preload(:block) + |> InternalTransaction.preload_error() + |> InternalTransaction.preload_transaction() + end + end + + defp do_fetch_for_address(address_id, to_block, from_block, limit, sum_mode, sort_direction, acc \\ []) + + defp do_fetch_for_address(_, to_block, from_block, _, _, _, acc) + when is_integer(from_block) and is_integer(to_block) and from_block >= to_block, do: acc + + defp do_fetch_for_address(address_id, to_block, from_block, limit, sum_mode, sort_direction, acc) do + internal_transactions = + address_id + |> get_block_numbers_for_address(to_block, from_block, limit, sum_mode, sort_direction) + |> fetch_block_internal_transactions() + + result = Enum.concat(internal_transactions, acc) + + count = + internal_transactions + |> different_from_parent_transaction() + |> Enum.count() + + if count > 0 and count < limit do + case sort_direction do + :desc -> + do_fetch_for_address( + address_id, + List.last(internal_transactions).block_number - 1, + from_block, + limit - count, + sum_mode, + sort_direction, + result + ) + + :asc -> + do_fetch_for_address( + address_id, + to_block, + List.last(internal_transactions).block_number + 1, + limit - count, + sum_mode, + sort_direction, + result + ) + end + else + result + end + end + + @doc """ + Fetches internal transactions for the given transaction from node, formatted for Etherscan API compatibility. + + ## Parameters + - `transaction`: The transaction struct to fetch internal transactions for + - `raw_options`: Map of Etherscan-compatible options including page_size + + ## Returns + - List of internal transactions serialized in Etherscan format + """ + @spec etherscan_fetch_by_transaction(Transaction.t(), map()) :: [map()] + def etherscan_fetch_by_transaction(transaction, raw_options) do + options = Map.merge(Etherscan.default_options(), raw_options) + + transaction + |> fetch_by_transaction(paging_options: %PagingOptions{page_size: options.page_size}) + |> Enum.map(ðerscan_serialize/1) + end + + @doc """ + Fetches internal transactions for the given address from node, formatted for Etherscan API compatibility. + + ## Parameters + - `address_hash`: The address hash to fetch internal transactions for + - `raw_options`: Map of Etherscan-compatible options including page_size, direction filter, and block range + + ## Returns + - List of internal transactions serialized in Etherscan format + """ + @spec etherscan_fetch_by_address(Hash.Address.t(), map()) :: [map()] + def etherscan_fetch_by_address(address_hash, raw_options) do + options = Map.merge(Etherscan.default_options(), raw_options) + + direction = + case options do + %{filter_by: "to"} -> :to + %{filter_by: "from"} -> :from + _ -> :both + end + + prepared_options = [ + paging_options: %PagingOptions{page_size: options.page_size, page_number: options.page_number}, + direction: direction, + from_block: Map.get(options, :startblock), + to_block: Map.get(options, :endblock), + sort_direction: Map.get(options, :order_by_direction), + index_internal_transaction_desc_order: Map.get(options, :order_by_direction) != :asc + ] + + address_hash + |> fetch_by_address(prepared_options) + |> Repo.preload(:block) + |> Enum.map(ðerscan_serialize/1) + end + + @doc """ + Fetches latest internal transactions from node, formatted for Etherscan API compatibility. + + ## Parameters + - `raw_options`: Map of Etherscan-compatible options including page_size, page_number, and block range + + ## Returns + - List of internal transactions serialized in Etherscan format + """ + @spec etherscan_fetch_latest(map()) :: [map()] + def etherscan_fetch_latest(raw_options) do + options = Map.merge(Etherscan.default_options(), raw_options) + + prepared_options = [ + paging_options: %PagingOptions{page_size: options.page_size, page_number: options.page_number}, + from_block: Map.get(options, :startblock), + to_block: Map.get(options, :endblock), + sort_direction: Map.get(options, :order_by_direction), + index_internal_transaction_desc_order: Map.get(options, :order_by_direction) != :asc + ] + + prepared_options + |> fetch_latest() + |> different_from_parent_transaction() + |> Repo.preload(:block) + |> Enum.map(ðerscan_serialize/1) + end + + defp fetch_enough(start_block_number, end_block_number, count, options, acc \\ []) + + defp fetch_enough(start_number, end_number, count, options, acc) when start_number >= end_number do + internal_transactions = fetch_by_block(start_number, Keyword.put(options, :unlimited, true)) + paging_options = Keyword.get(options, :paging_options, @default_paging_options) + + fetched_count = + internal_transactions + |> page_internal_transaction(paging_options, %{index_internal_transaction_desc_order: true}) + |> Enum.count() + + result = Enum.concat(acc, internal_transactions) + + if fetched_count >= count or start_number == 0 do + result + else + start_number + |> BlockNumberHelper.previous_block_number() + |> fetch_enough(end_number, count - fetched_count, options, result) + end + end + + defp fetch_enough(_start_number, _end_number, _count, _options, acc), do: acc + + defp get_block_numbers_for_address(nil, _end_block, _start_block, _limit, _sum_mode, _order), do: [] + + defp get_block_numbers_for_address(address_id, end_block, start_block, limit, sum_mode, order) do + ranked_query = + InternalTransactionsAddressPlaceholder + |> where([q], q.address_id == ^address_id) + |> then(fn query -> + if is_nil(end_block) do + query + else + where(query, [q], q.block_number <= ^end_block) + end + end) + |> then(fn query -> + if is_nil(start_block) do + query + else + where(query, [q], q.block_number >= ^start_block) + end + end) + |> windows([q], w: [order_by: [{^order, :block_number}]]) + |> select([q], %{ + block_number: q.block_number, + value: + fragment( + """ + CASE ? + WHEN 'froms' THEN ? + WHEN 'tos' THEN ? + ELSE ? + ? + END + """, + ^sum_mode, + q.count_froms, + q.count_tos, + q.count_froms, + q.count_tos + ), + running_sum: + fragment( + """ + sum( + CASE ? + WHEN 'froms' THEN ? + WHEN 'tos' THEN ? + ELSE ? + ? + END + ) OVER w + """, + ^sum_mode, + q.count_froms, + q.count_tos, + q.count_froms, + q.count_tos + ) + }) + + cut_query = + from(r in subquery(ranked_query), + select: %{ + block_number: r.block_number, + value: r.value, + running_sum: r.running_sum, + cutoff_block: + fragment( + "max(CASE WHEN ? >= ? THEN ? END) OVER ()", + r.running_sum, + ^limit, + r.block_number + ) + } + ) + + condition = + case order do + :desc -> dynamic([c], c.value > 0 and (is_nil(c.cutoff_block) or c.block_number >= c.cutoff_block)) + :asc -> dynamic([c], c.value > 0 and (is_nil(c.cutoff_block) or c.block_number <= c.cutoff_block)) + end + + final_query = + from(c in subquery(cut_query), + where: ^condition, + order_by: [{^order, c.block_number}], + select: c.block_number + ) + + Repo.all(final_query) + end + + defp filter_by_address(internal_transactions, address_hash, direction) do + Enum.filter(internal_transactions, fn internal_transaction -> + case direction do + d when d in [:to, :to_address_hash] -> + internal_transaction.to_address_hash == address_hash + + d when d in [:from, :from_address_hash] -> + internal_transaction.from_address_hash == address_hash + + _ -> + internal_transaction.to_address_hash == address_hash or + internal_transaction.from_address_hash == address_hash or + internal_transaction.created_contract_address_hash == address_hash + end + end) + end + + defp fetch_block_internal_transactions([]), do: [] + + defp fetch_block_internal_transactions(block_numbers) do + if internal_transactions_fetching_disabled?() do + [] + else + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + variant = Keyword.fetch!(json_rpc_named_arguments, :variant) + + if variant in InternalTransactionFetcher.block_traceable_variants() do + case EthereumJSONRPC.fetch_block_internal_transactions(block_numbers, json_rpc_named_arguments) do + {:ok, result} -> + result + + error -> + Logger.error( + "Failed to fetch internal transactions for blocks #{inspect(block_numbers)}: #{inspect(error)}" + ) + + [] + end + else + Enum.reduce(block_numbers, [], fn block_number, acc_list -> + block_number + |> Transaction.get_transactions_of_block_number() + |> Transaction.filter_non_traceable_transactions() + |> Enum.map( + &%{ + block_number: &1.block_number, + hash_data: to_string(&1.hash), + transaction_index: &1.index + } + ) + |> case do + [] -> + {:ok, []} + + transactions -> + try do + EthereumJSONRPC.fetch_internal_transactions(transactions, json_rpc_named_arguments) + catch + :exit, error -> + {:error, error, __STACKTRACE__} + end + end + |> case do + {:ok, internal_transactions} -> + internal_transactions ++ acc_list + + error_or_ignore -> + Logger.error( + "Failed to fetch internal transactions for block #{block_number}: #{inspect(error_or_ignore)}" + ) + + acc_list + end + end) + end + end + end + + defp internal_transactions_fetching_disabled? do + Application.get_env(:indexer, __MODULE__, [])[:disabled?] == true + end + + defp page_internal_transaction(_, _, _ \\ %{index_internal_transaction_desc_order: false}) + + defp page_internal_transaction(internal_transactions, %PagingOptions{key: nil}, _), do: internal_transactions + + defp page_internal_transaction( + internal_transactions, + %PagingOptions{key: {block_number, transaction_index, index}}, + %{ + index_internal_transaction_desc_order: false + } + ) do + Stream.filter( + internal_transactions, + &(&1.block_number < block_number or (&1.block_number == block_number and &1.transaction_index < transaction_index) or + (&1.block_number == block_number and &1.transaction_index == transaction_index and &1.index > index)) + ) + end + + defp page_internal_transaction( + internal_transactions, + %PagingOptions{key: {block_number, transaction_index, index}}, + %{ + index_internal_transaction_desc_order: true + } + ) do + Stream.filter( + internal_transactions, + &(&1.block_number < block_number or (&1.block_number == block_number and &1.transaction_index < transaction_index) or + (&1.block_number == block_number and &1.transaction_index == transaction_index and &1.index < index)) + ) + end + + defp page_internal_transaction(internal_transactions, %PagingOptions{key: {0}}, %{ + index_internal_transaction_desc_order: desc_order + }) do + if desc_order do + internal_transactions + else + Stream.filter(internal_transactions, &(&1.index > 0)) + end + end + + defp page_internal_transaction(internal_transactions, %PagingOptions{key: {index}}, %{ + index_internal_transaction_desc_order: desc_order + }) do + if desc_order do + Stream.filter(internal_transactions, &(&1.index < index)) + else + Stream.filter(internal_transactions, &(&1.index > index)) + end + end + + defp page_block_internal_transaction(internal_transactions, %PagingOptions{ + key: %{transaction_index: transaction_index, index: index} + }) do + Stream.filter( + internal_transactions, + &((&1.transaction_index == transaction_index and &1.index > index) or &1.transaction_index > transaction_index) + ) + end + + defp page_block_internal_transaction(internal_transactions, _), do: internal_transactions + + # filter by `type` is automatically ignored if `call_type_filter` is not empty, + # as applying both filter simultaneously have no sense + defp filter_by_type(internal_transactions, _, [_ | _]), do: internal_transactions + defp filter_by_type(internal_transactions, nil, _), do: internal_transactions + defp filter_by_type(internal_transactions, [], _), do: internal_transactions + + defp filter_by_type(internal_transactions, types, _) do + Stream.filter(internal_transactions, &(&1.type in types)) + end + + defp filter_by_call_type(internal_transactions, []), do: internal_transactions + defp filter_by_call_type(internal_transactions, nil), do: internal_transactions + + defp filter_by_call_type(internal_transactions, call_types) do + Stream.filter(internal_transactions, &(&1.call_type in call_types)) + end + + defp join_associations(records, necessity_by_association) + when is_list(records) and is_map(necessity_by_association) do + Enum.reduce(necessity_by_association, records, fn {association, necessity}, acc -> + join_association(acc, association, necessity) + end) + end + + defp join_association(records, [{association, nested_preload}], :optional) + when is_atom(association) do + Repo.preload(records, [{association, nested_preload}]) + end + + defp join_association(records, [{association, nested_preload}], :required) + when is_atom(association) do + records + |> Repo.preload([{association, nested_preload}]) + |> Enum.filter(fn struct -> + case Map.fetch(struct, association) do + {:ok, value} -> not is_nil(value) + :error -> true + end + end) + end + + defp join_association(records, association, :optional) do + Repo.preload(records, association) + end + + defp join_association(records, association, :required) do + records + |> Repo.preload(association) + |> Enum.filter(fn struct -> + case Map.fetch(struct, association) do + {:ok, value} -> + case value do + [] -> false + nil -> false + _ -> true + end + + :error -> + true + end + end) + end + + defp different_from_parent_transaction(internal_transactions) do + Enum.reject(internal_transactions, &(&1.type == :call and &1.index == 0)) + end + + defp serialize(internal_transaction_params) do + %InternalTransaction{} + |> InternalTransaction.changeset(internal_transaction_params) + |> Ecto.Changeset.apply_changes() + |> Map.put(:transaction_hash, internal_transaction_params[:transaction_hash]) + |> Map.put(:from_address_hash, convert_to_address_hash(internal_transaction_params[:from_address_hash])) + |> Map.put(:to_address_hash, convert_to_address_hash(internal_transaction_params[:to_address_hash])) + |> Map.put( + :created_contract_address_hash, + convert_to_address_hash(internal_transaction_params[:created_contract_address_hash]) + ) + end + + defp convert_to_address_hash(nil), do: nil + + defp convert_to_address_hash(hash_string) do + {:ok, hash} = Hash.Address.cast(hash_string) + hash + end + + defp etherscan_serialize(internal_transaction) do + internal_transaction_fields = + Etherscan.internal_transaction_fields() ++ + [:transaction_hash, :from_address_hash, :to_address_hash, :created_contract_address_hash, :error] + + internal_transaction + |> Map.take(internal_transaction_fields) + |> Map.put(:block_timestamp, internal_transaction.block.timestamp) + end +end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/neon_solana_transactions.ex b/apps/indexer/lib/indexer/fetcher/on_demand/neon_solana_transactions.ex index c2bd311a5e47..881917fb3ef4 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/neon_solana_transactions.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/neon_solana_transactions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.NeonSolanaTransactions do @moduledoc """ A caching proxy service getting linked solana transactions from NeonEVM Node. @@ -29,7 +30,7 @@ defmodule Indexer.Fetcher.OnDemand.NeonSolanaTransactions do end defp cache(transaction_hash) do - Repo.all( + Repo.replica().all( from( solanaTransaction in LinkedSolanaTransactions, where: solanaTransaction.neon_transaction_hash == ^transaction_hash.bytes, diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/nft_collection_metadata_refetch.ex b/apps/indexer/lib/indexer/fetcher/on_demand/nft_collection_metadata_refetch.ex index ef4d084d4f6f..9d1def96ad8b 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/nft_collection_metadata_refetch.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/nft_collection_metadata_refetch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.NFTCollectionMetadataRefetch do @moduledoc """ Re-fetches token metadata. diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/token_balance.ex b/apps/indexer/lib/indexer/fetcher/on_demand/token_balance.ex index 84323374c927..4a9525b4baa0 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/token_balance.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/token_balance.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.TokenBalance do @moduledoc """ Ensures that we have a reasonably up to date address tokens balance. @@ -12,7 +13,6 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Hash - alias Explorer.Helper, as: ExplorerHelper alias Explorer.Token.BalanceReader alias Explorer.Utility.RateLimiter alias Indexer.BufferedTask @@ -22,20 +22,10 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do @behaviour BufferedTask - @max_batch_size 500 - @max_concurrency 4 - @defaults [ - flush_interval: :timer.seconds(3), - max_concurrency: @max_concurrency, - max_batch_size: @max_batch_size, - task_supervisor: Indexer.Fetcher.OnDemand.TokenBalance.TaskSupervisor, - metadata: [fetcher: :token_balance_on_demand] - ] - @spec trigger_fetch(String.t() | nil, Hash.Address.t()) :: :ok def trigger_fetch(caller \\ nil, address_hash) do if not __MODULE__.Supervisor.disabled?() and RateLimiter.check_rate(caller, :on_demand) == :allow do - BufferedTask.buffer(__MODULE__, [{:fetch, address_hash}], false) + __MODULE__.AddressQueue.enqueue(address_hash) end end @@ -60,11 +50,23 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do @doc false def child_spec([init_options, gen_server_options]) do merged_init_opts = - @defaults + defaults() |> Keyword.merge(init_options) |> Keyword.put(:state, %{}) - Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + children = [ + {Task.Supervisor, name: __MODULE__.AddressQueue.TaskSupervisor}, + __MODULE__.AddressQueue, + {BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]} + ] + + %{ + id: __MODULE__, + start: + {Supervisor, :start_link, + [children, [strategy: :one_for_one, name: Module.concat(__MODULE__, InternalSupervisor)]]}, + type: :supervisor + } end @impl BufferedTask @@ -107,43 +109,27 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do defp prepare_fetch_requests(nil, _latest_block_number), do: %{nft_balances: [], ft_balances: [], tokens: %{}, balances_map: %{}} - defp prepare_fetch_requests(address_hashes, latest_block_number) do + defp prepare_fetch_requests(entries, latest_block_number) do initial_acc = %{nft_balances: [], ft_balances: [], tokens: %{}, balances_map: %{}} - case stale_balance_window(latest_block_number) do - {:error, _} -> - initial_acc - - stale_balance_window -> - address_hashes - |> Enum.uniq() - |> Chain.fetch_last_token_balances_include_unfetched() - |> delete_invalid_balances() - |> Enum.filter(fn current_token_balance -> current_token_balance.block_number < stale_balance_window end) - |> prepare_ctb_params(initial_acc, latest_block_number) - end - end - - defp prepare_ctb_params(current_token_balances, initial_acc, block_number) do - Enum.reduce(current_token_balances, initial_acc, fn %{token_id: token_id} = stale_current_token_balance, acc -> - prepared_ctb = %{ - token_contract_address_hash: - ExplorerHelper.add_0x_prefix(stale_current_token_balance.token.contract_address_hash), - address_hash: ExplorerHelper.add_0x_prefix(stale_current_token_balance.address_hash), - block_number: block_number, - token_id: token_id && Decimal.to_integer(token_id), - token_type: stale_current_token_balance.token_type - } - + Enum.reduce(entries, initial_acc, fn ctb, acc -> updated_tokens = Map.put_new( acc[:tokens], - stale_current_token_balance.token.contract_address_hash.bytes, - stale_current_token_balance.token + ctb.token_contract_address_hash.bytes, + ctb.token ) + prepared_ctb = + ctb + |> Map.drop([:token, :stale_value]) + |> Map.put(:block_number, latest_block_number) + |> Map.put(:token_id, ctb.token_id && Decimal.to_integer(ctb.token_id)) + |> Map.put(:token_contract_address_hash, to_string(ctb.token_contract_address_hash)) + |> Map.put(:address_hash, to_string(ctb.address_hash)) + result = - if stale_current_token_balance.token_type == "ERC-1155" do + if ctb.token_type == "ERC-1155" do Map.put(acc, :nft_balances, [prepared_ctb | acc[:nft_balances]]) else Map.put(acc, :ft_balances, [prepared_ctb | acc[:ft_balances]]) @@ -152,8 +138,8 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do updated_balances_map = Map.put( acc[:balances_map], - ctb_to_key(stale_current_token_balance), - stale_current_token_balance.value + ctb_to_key(ctb), + ctb.stale_value ) result @@ -162,6 +148,36 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do end) end + def prepare_batch_fetch_entries_for_buffer(address_hashes, latest_block_number) do + case stale_balance_window(latest_block_number) do + {:error, _} -> + [] + + stale_balance_window -> + address_hashes + |> Enum.uniq() + |> Chain.fetch_last_token_balances_include_unfetched() + |> delete_invalid_balances() + |> Enum.filter(fn ctb -> ctb.block_number < stale_balance_window end) + |> prepare_ctb_params_for_buffer() + end + end + + defp prepare_ctb_params_for_buffer(current_token_balances) do + Enum.map(current_token_balances, fn %{token_id: token_id} = stale_current_token_balance -> + {:fetch, + %{ + token_contract_address_hash: stale_current_token_balance.token_contract_address_hash, + address_hash: stale_current_token_balance.address_hash, + block_number: nil, + token_id: token_id, + token_type: stale_current_token_balance.token_type, + token: stale_current_token_balance.token, + stale_value: stale_current_token_balance.value + }} + end) + end + defp prepare_historic_fetch_requests(nil), do: {[], []} defp prepare_historic_fetch_requests(params) do @@ -211,7 +227,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do address_current_token_balances: %{ address_hash: to_string(address_hash), address_current_token_balances: - Enum.map(ctbs, fn ctb -> + Enum.map(ctbs, fn %CurrentTokenBalance{} = ctb -> %CurrentTokenBalance{ctb | token: tokens[ctb.token_contract_address_hash.bytes]} end) } @@ -230,7 +246,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do {{:ok, balance}, request}, acc -> params = %{ address_hash: request.address_hash, - token_contract_address_hash: request.contract_address_hash, + token_contract_address_hash: request.token_contract_address_hash, token_type: request.token_type, token_id: request.token_id, block_number: request.block_number, @@ -256,17 +272,23 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do defp filter_imported_ctbs(imported_ctbs, balances_map) do Enum.filter(imported_ctbs, fn ctb -> - if balance = balances_map[ctb_to_key(ctb)] do - Decimal.compare(balance, ctb.value) != :eq - else - Logger.error("Imported unknown balance") - true + case Map.fetch(balances_map, ctb_to_key(ctb)) do + {:ok, nil} -> + true + + {:ok, balance} -> + Decimal.compare(balance, ctb.value) != :eq + + :error -> + Logger.error("Imported unknown balance #{inspect(ctb)}") + true end end) end defp ctb_to_key(ctb) do - {ctb.token_contract_address_hash.bytes, ctb.token_type, ctb.token_id && Decimal.to_integer(ctb.token_id)} + {ctb.address_hash.bytes, ctb.token_contract_address_hash.bytes, ctb.token_type, + ctb.token_id && Decimal.to_integer(ctb.token_id)} end defp prepare_updated_balance({{:ok, updated_balance}, stale_current_token_balance}, block_number) do @@ -298,7 +320,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do nil end - defp latest_block_number do + def latest_block_number do BlockNumber.get_max() end @@ -322,4 +344,62 @@ defmodule Indexer.Fetcher.OnDemand.TokenBalance do end end end + + defp defaults do + [ + flush_interval: :timer.seconds(3), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency], + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size], + task_supervisor: Indexer.Fetcher.OnDemand.TokenBalance.TaskSupervisor, + metadata: [fetcher: :token_balance_on_demand] + ] + end +end + +defmodule Indexer.Fetcher.OnDemand.TokenBalance.AddressQueue do + @moduledoc """ + Buffers incoming address hashes from `trigger_fetch/2` and flushes them in batches. + Each batch fetches token balances for all addresses in a single DB query, + then forwards the results to the main TokenBalance BufferedTask. + """ + + @behaviour Indexer.BufferedTask + + alias Explorer.Chain.Hash + alias Indexer.BufferedTask + alias Indexer.Fetcher.OnDemand.TokenBalance + + @spec enqueue(Hash.Address.t()) :: :ok + def enqueue(address_hash) do + BufferedTask.buffer(__MODULE__, [address_hash], false) + end + + def child_spec(_opts \\ []) do + init_opts = [ + flush_interval: Application.get_env(:indexer, TokenBalance)[:address_queue_flush_interval], + max_concurrency: 1, + max_batch_size: Application.get_env(:indexer, TokenBalance)[:address_queue_batch_size], + task_supervisor: __MODULE__.TaskSupervisor, + metadata: [fetcher: :token_balance_on_demand_address_queue], + state: %{} + ] + + Supervisor.child_spec({BufferedTask, [{__MODULE__, init_opts}, [name: __MODULE__]]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial, _, _), do: initial + + @impl BufferedTask + def run(address_hashes, _) do + latest_block_number = TokenBalance.latest_block_number() + + fetch_data = TokenBalance.prepare_batch_fetch_entries_for_buffer(address_hashes, latest_block_number) + + unless Enum.empty?(fetch_data) do + BufferedTask.buffer(TokenBalance, fetch_data, false) + end + + :ok + end end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/token_instance_metadata_refetch.ex b/apps/indexer/lib/indexer/fetcher/on_demand/token_instance_metadata_refetch.ex index 882bac64bd8c..c59a16237b75 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/token_instance_metadata_refetch.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/token_instance_metadata_refetch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch do @moduledoc """ Re-fetches token instance metadata. @@ -12,9 +13,8 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch do alias Explorer.Chain.Cache.Counters.Helper, as: CountersHelper alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Token.Instance, as: TokenInstance - alias Explorer.SmartContract.Reader - alias Explorer.Token.MetadataRetriever alias Explorer.Utility.{RateLimiter, TokenInstanceMetadataRefetchAttempt} + alias Indexer.Fetcher.TokenInstance.Helper alias Indexer.NFTMediaHandler.Queue @max_delay :timer.hours(168) @@ -27,7 +27,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch do end end - defp fetch_metadata(token_instance, state) do + defp fetch_metadata(token_instance) do with {:retries_number, {retries_number, updated_at}} <- {:retries_number, TokenInstanceMetadataRefetchAttempt.get_retries_number( @@ -39,63 +39,68 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch do {:retry, CountersHelper.current_time() - updated_at_ms > threshold(retries_number)} do - fetch_and_broadcast_metadata(token_instance, state) + fetch_and_broadcast_metadata(token_instance) else {:retries_number, nil} -> - fetch_and_broadcast_metadata(token_instance, state) + fetch_and_broadcast_metadata(token_instance) {:retry, false} -> + Publisher.broadcast( + %{ + not_fetched_token_instance_metadata: [ + to_string(token_instance.token_contract_address_hash), + NFT.prepare_token_id(token_instance.token_id), + "retry_cooldown" + ] + }, + :on_demand + ) + :ok end end - defp fetch_and_broadcast_metadata(token_instance, _state) do - token_id = NFT.prepare_token_id(token_instance.token_id) - contract_address_hash_string = to_string(token_instance.token_contract_address_hash) - - request = - NFT.prepare_request( - token_instance.token.type, - contract_address_hash_string, - token_id, - false - ) - - result = - case Reader.query_contracts([request], NFT.erc_721_1155_abi(), [], false) do - [ok: [uri]] -> - {:ok, [uri]} - - _ -> - nil - end - - with {:empty_result, false} <- {:empty_result, is_nil(result)}, - {:fetched_metadata, {:ok, %{metadata: metadata}}} <- - {:fetched_metadata, - result - |> MetadataRetriever.fetch_json(token_id, nil, false) - |> MetadataRetriever.parse_fetch_json_response()} do - TokenInstance.set_metadata(token_instance, metadata) - - Publisher.broadcast( - %{fetched_token_instance_metadata: [to_string(token_instance.token_contract_address_hash), token_id, metadata]}, - :on_demand - ) - - Queue.process_new_instances([%TokenInstance{token_instance | metadata: metadata}]) - else - {:empty_result, true} -> - :ok + defp fetch_and_broadcast_metadata( + %TokenInstance{token_id: token_id, token_contract_address_hash: token_contract_address_hash} = token_instance + ) do + case Helper.batch_prepare_instances_insert_params([ + %{contract_address_hash: token_contract_address_hash, token_id: token_id} + ]) do + [%{error: nil, metadata: metadata} = result] -> + TokenInstance.set_metadata(token_instance, result) + + Publisher.broadcast( + %{ + fetched_token_instance_metadata: [ + to_string(token_contract_address_hash), + NFT.prepare_token_id(token_id), + metadata + ] + }, + :on_demand + ) + + Queue.process_new_instances([%TokenInstance{token_instance | metadata: metadata}]) - {:fetched_metadata, error} -> + [%{error: error}] -> Logger.error(fn -> - "Error while refetching metadata for {#{token_instance.token_contract_address_hash}, #{token_id}}: #{inspect(error)}" + "Error while refetching metadata for {#{token_contract_address_hash}, #{token_id}}: #{inspect(error)}" end) + Publisher.broadcast( + %{ + not_fetched_token_instance_metadata: [ + to_string(token_contract_address_hash), + NFT.prepare_token_id(token_id), + "error" + ] + }, + :on_demand + ) + TokenInstanceMetadataRefetchAttempt.insert_retries_number( - token_instance.token_contract_address_hash, - token_instance.token_id + token_contract_address_hash, + token_id ) end end @@ -111,7 +116,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch do @impl true def handle_cast({:refetch, token_instance}, state) do - fetch_metadata(token_instance, state) + fetch_metadata(token_instance) {:noreply, state} end diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex b/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex index d4281c473a92..80dc1e4b539c 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/token_total_supply.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.TokenTotalSupply do @moduledoc """ Ensures that we have a reasonably up to date token supply. @@ -9,7 +10,6 @@ defmodule Indexer.Fetcher.OnDemand.TokenTotalSupply do alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Events.Publisher alias Explorer.Chain.{Hash, Token} - alias Explorer.Helper, as: ExplorerHelper alias Explorer.Repo alias Explorer.Token.MetadataRetriever alias Explorer.Utility.RateLimiter @@ -47,11 +47,12 @@ defmodule Indexer.Fetcher.OnDemand.TokenTotalSupply do ## Implementation defp do_fetch(address_hash) when not is_nil(address_hash) do - token = Repo.get_by(Token, contract_address_hash: address_hash) + token = Repo.replica().get_by(Token, contract_address_hash: address_hash) if (token && !token.skip_metadata && is_nil(token.total_supply_updated_at_block)) or - BlockNumber.get_max() - token.total_supply_updated_at_block > @ttl_in_blocks do - token_address_hash_string = ExplorerHelper.add_0x_prefix(address_hash) + (token && !is_nil(token.total_supply_updated_at_block) && + BlockNumber.get_max() - token.total_supply_updated_at_block > @ttl_in_blocks) do + token_address_hash_string = to_string(address_hash) token_params = MetadataRetriever.get_total_supply_of(token_address_hash_string) diff --git a/apps/indexer/lib/indexer/fetcher/optimism.ex b/apps/indexer/lib/indexer/fetcher/optimism.ex index 9657fd2070bc..cf125c05e0b9 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism do @moduledoc """ Contains common functions for Optimism* fetchers. @@ -18,12 +19,14 @@ defmodule Indexer.Fetcher.Optimism do alias EthereumJSONRPC.Block.{ByHash, ByNumber} alias EthereumJSONRPC.{Blocks, Contract} + alias Explorer.Application.Constants alias Explorer.Chain.Cache.ChainId alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Explorer.Chain.RollupReorgMonitorQueue + alias Explorer.Chain.Optimism.Withdrawal alias Explorer.Repo alias Indexer.Fetcher.RollupL1ReorgMonitor alias Indexer.Helper + alias Indexer.RollupReorgMonitorQueue @empty_hash "0x0000000000000000000000000000000000000000000000000000000000000000" @@ -246,6 +249,11 @@ defmodule Indexer.Fetcher.Optimism do |> Map.get(:result, fallback_start_block) |> quantity_to_integer() + if not is_nil(optimism_portal) do + # we save the OptimismPortal contract address to use it in other modules (e.g. by `BlockScoutWeb.API.V2.OptimismView`) + Constants.set_constant_value(Withdrawal.portal_contract_address_constant(), optimism_portal) + end + {optimism_portal, start_block} _ -> @@ -308,6 +316,7 @@ defmodule Indexer.Fetcher.Optimism do Updates the last handled block hash by a fetcher. The new block hash is written to the `last_fetched_counters` table. This function accepts the block number for which the block hash must be determined. + If RPC node returns `nil` as the successful result, this function doesn't do anything. ## Parameters - `block_number`: The number of the block. @@ -319,11 +328,16 @@ defmodule Indexer.Fetcher.Optimism do """ @spec set_last_block_hash_by_number(non_neg_integer(), binary(), EthereumJSONRPC.json_rpc_named_arguments()) :: any() def set_last_block_hash_by_number(block_number, counter_type, json_rpc_named_arguments) do - [block_number] - |> get_blocks_by_numbers(json_rpc_named_arguments, Helper.infinite_retries_number()) - |> List.first() - |> Map.get("hash") - |> set_last_block_hash(counter_type) + block = + [block_number] + |> get_blocks_by_numbers(json_rpc_named_arguments, Helper.infinite_retries_number()) + |> List.first() + + if not is_nil(block) do + block + |> Map.get("hash") + |> set_last_block_hash(counter_type) + end end @doc """ @@ -391,17 +405,16 @@ defmodule Indexer.Fetcher.Optimism do created due to reorg from the corresponding table. - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection. Used to get transaction info by its hash from the RPC node. - Can be `nil` if the transaction info is not needed. - `counter_type`: Name of a record in the `last_fetched_counters` table to read the last known block hash from. ## Returns - A tuple `{last_block_number, last_transaction_hash, last_transaction}` where `last_block_number` is the last block number found in the corresponding table (0 if not found), `last_transaction_hash` is the last transaction hash found in the corresponding table (nil if not found), - `last_transaction` is the transaction info got from the RPC (nil if not found or not needed). + `last_transaction` is the transaction info got from the RPC (nil if not found). - A tuple `{:error, message}` in case the `eth_getTransactionByHash` RPC request failed. """ - @spec get_last_item(:L1 | :L2, function(), function(), EthereumJSONRPC.json_rpc_named_arguments() | nil, binary()) :: + @spec get_last_item(:L1 | :L2, function(), function(), EthereumJSONRPC.json_rpc_named_arguments(), binary()) :: {non_neg_integer(), binary() | nil, map() | nil} | {:error, any()} def get_last_item(layer, last_block_number_query_fun, remove_query_fun, json_rpc_named_arguments, counter_type) when is_function(last_block_number_query_fun, 0) and is_function(remove_query_fun, 1) do @@ -414,8 +427,6 @@ defmodule Indexer.Fetcher.Optimism do |> Kernel.||({0, nil}) with {:empty_hash, false} <- {:empty_hash, is_nil(last_transaction_hash)}, - {:empty_json_rpc_named_arguments, false} <- - {:empty_json_rpc_named_arguments, is_nil(json_rpc_named_arguments)}, {:ok, last_transaction} <- Helper.get_transaction_by_hash(last_transaction_hash, json_rpc_named_arguments), {:empty_transaction, false} <- {:empty_transaction, is_nil(last_transaction)} do {last_block_number, last_transaction_hash, last_transaction} @@ -423,9 +434,6 @@ defmodule Indexer.Fetcher.Optimism do {:empty_hash, true} -> {last_block_number, nil, nil} - {:empty_json_rpc_named_arguments, true} -> - {last_block_number, last_transaction_hash, nil} - {:error, _} = error -> error @@ -508,7 +516,7 @@ defmodule Indexer.Fetcher.Optimism do def handle_reorgs_queue(module, handle_reorg_func) do reorg_block_number = Enum.reduce_while(Stream.iterate(0, &(&1 + 1)), nil, fn _i, acc -> - number = RollupReorgMonitorQueue.reorg_block_pop(module) + number = RollupReorgMonitorQueue.pop(module) if is_nil(number) do {:halt, acc} @@ -536,7 +544,7 @@ defmodule Indexer.Fetcher.Optimism do @spec handle_realtime_l2_reorg(non_neg_integer(), module()) :: any() def handle_realtime_l2_reorg(reorg_block_number, module) do Logger.warning("L2 reorg was detected at block #{reorg_block_number}.", fetcher: module.fetcher_name()) - RollupReorgMonitorQueue.reorg_block_push(reorg_block_number, module) + RollupReorgMonitorQueue.push(reorg_block_number, module) end @doc """ diff --git a/apps/indexer/lib/indexer/fetcher/optimism/deposit.ex b/apps/indexer/lib/indexer/fetcher/optimism/deposit.ex index f2c7dd18dd29..da6caa654589 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/deposit.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/deposit.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.Deposit do @moduledoc """ Fills op_deposits DB table. @@ -10,17 +11,15 @@ defmodule Indexer.Fetcher.Optimism.Deposit do import Ecto.Query - import EthereumJSONRPC, only: [id_to_params: 1, json_rpc: 2, quantity_to_integer: 1] + import EthereumJSONRPC, only: [quantity_to_integer: 1] import Explorer.Helper, only: [decode_data: 2] - alias EthereumJSONRPC.Block.ByNumber - alias EthereumJSONRPC.Blocks alias Explorer.{Chain, Repo} alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Optimism.Deposit - alias Explorer.Chain.RollupReorgMonitorQueue alias Indexer.Fetcher.Optimism alias Indexer.Helper + alias Indexer.RollupReorgMonitorQueue # 32-byte signature of the event TransactionDeposited(address indexed from, address indexed to, uint256 indexed version, bytes opaqueData) @transaction_deposited_event "0xb3813568d9991fc951961fcb4c784893574240a28925604d09fc577c55bb7c32" @@ -117,7 +116,7 @@ defmodule Indexer.Fetcher.Optimism.Deposit do ) end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) + reorg_block = RollupReorgMonitorQueue.pop(__MODULE__) if !is_nil(reorg_block) && reorg_block > 0 do {deleted_count, _} = Repo.delete_all(from(d in Deposit, where: d.l1_block_number >= ^reorg_block)) @@ -167,19 +166,51 @@ defmodule Indexer.Fetcher.Optimism.Deposit do end end + # Prepares `TransactionDeposited` events to be imported to database. + # + # ## Parameters + # - `events`: The list of `TransactionDeposited` events got from `eth_getLogs` response. + # - `transaction_type`: L1 transaction type to correctly calculate the corresponding L2 transaction hash. + # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection. + # Used to get blocks info by their numbers from the RPC node. + # + # ## Returns + # - A list of prepared events. Each list item is a map outlining a deposit event. + @spec prepare_events(list(), non_neg_integer(), EthereumJSONRPC.json_rpc_named_arguments()) :: [map()] defp prepare_events(events, transaction_type, json_rpc_named_arguments) do - timestamps = + {timestamps, origins} = events - |> get_blocks_by_events(json_rpc_named_arguments, Helper.infinite_retries_number()) - |> Enum.reduce(%{}, fn block, acc -> + |> Helper.get_blocks_by_events(json_rpc_named_arguments, Helper.infinite_retries_number(), true) + |> Enum.reduce({%{}, %{}}, fn block, {timestamps_acc, origins_acc} -> block_number = quantity_to_integer(Map.get(block, "number")) {:ok, timestamp} = DateTime.from_unix(quantity_to_integer(Map.get(block, "timestamp"))) - Map.put(acc, block_number, timestamp) + + new_timestamps_acc = Map.put(timestamps_acc, block_number, timestamp) + + new_origins_acc = + block + |> Map.get("transactions", []) + |> Enum.reduce(origins_acc, fn transaction, acc -> + Map.put(acc, String.downcase(transaction["hash"]), transaction["from"]) + end) + + {new_timestamps_acc, new_origins_acc} end) - Enum.map(events, &event_to_deposit(&1, timestamps, transaction_type)) + Enum.map(events, &event_to_deposit(&1, timestamps, origins, transaction_type)) end + # Prepares `TransactionDeposited` event to be imported to database. + # + # ## Parameters + # - An event map outlining a `TransactionDeposited` event got from `eth_getLogs` response. + # - `timestamps`: A `block_number -> timestamp` map to get the timestamp of the event from its block info. + # - `origins`: A `transaction_hash -> origin` map to get the origin address of the event transaction. + # - `transaction_type`: L1 transaction type to correctly calculate the corresponding L2 transaction hash. + # + # ## Returns + # - A map with the event info ready to be imported to database. + @spec event_to_deposit(map(), map(), map(), non_neg_integer()) :: map() defp event_to_deposit( %{ "blockHash" => "0x" <> stripped_block_hash, @@ -190,6 +221,7 @@ defmodule Indexer.Fetcher.Optimism.Deposit do "data" => opaque_data }, timestamps, + origins, transaction_type ) do {_, prefixed_block_hash} = (String.pad_leading("", 64, "0") <> stripped_block_hash) |> String.split_at(-64) @@ -251,7 +283,7 @@ defmodule Indexer.Fetcher.Optimism.Deposit do l1_block_number: block_number, l1_block_timestamp: Map.get(timestamps, block_number), l1_transaction_hash: transaction_hash, - l1_transaction_origin: "0x" <> from_stripped, + l1_transaction_origin: Map.get(origins, String.downcase(transaction_hash)), l2_transaction_hash: l2_transaction_hash } end @@ -304,22 +336,4 @@ defmodule Indexer.Fetcher.Optimism.Deposit do def requires_l1_reorg_monitor? do Optimism.requires_l1_reorg_monitor?() end - - defp get_blocks_by_events(events, json_rpc_named_arguments, retries) do - request = - events - |> Enum.reduce(%{}, fn event, acc -> - Map.put(acc, event["blockNumber"], 0) - end) - |> Stream.map(fn {block_number, _} -> %{number: block_number} end) - |> id_to_params() - |> Blocks.requests(&ByNumber.request(&1, false, false)) - - error_message = &"Cannot fetch blocks with batch request. Error: #{inspect(&1)}. Request: #{inspect(request)}" - - case Helper.repeated_call(&json_rpc/2, [request, json_rpc_named_arguments], error_message, retries) do - {:ok, results} -> Enum.map(results, fn %{result: result} -> result end) - {:error, _} -> [] - end - end end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex b/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex index f70f82e416fd..80676562136f 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/dispute_game.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.DisputeGame do @moduledoc """ Fills op_dispute_games DB table. @@ -16,8 +17,9 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do alias EthereumJSONRPC.Contract alias Explorer.Application.Constants alias Explorer.{Chain, Helper, Repo} + alias Explorer.Chain.Data + alias Explorer.Chain.Hash.Address alias Explorer.Chain.Optimism.{DisputeGame, Withdrawal} - alias Explorer.Helper, as: ExplorerHelper alias Indexer.Fetcher.Optimism alias Indexer.Helper, as: IndexerHelper @@ -214,7 +216,7 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do query = from( game in DisputeGame, - select: %{index: game.index, address: game.address}, + select: %{index: game.index, address_hash: game.address_hash}, where: is_nil(game.resolved_at), order_by: [desc: game.index], limit: 1000 @@ -344,7 +346,7 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do ] }) - calldata = ExplorerHelper.add_0x_prefix(encoded_call) + calldata = %Data{bytes: encoded_call} Contract.eth_call_request(calldata, dispute_game_factory, index, nil, nil) end) @@ -366,7 +368,7 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do [extra_data] = Helper.decode_data(extra_data_by_index[game.index], [:bytes]) game - |> Map.put(:extra_data, ExplorerHelper.add_0x_prefix(extra_data)) + |> Map.put(:extra_data, %Data{bytes: extra_data}) |> Map.put(:resolved_at, sanitize_resolved_at(resolved_at_by_index[game.index])) |> Map.put(:status, quantity_to_integer(status_by_index[game.index])) end) @@ -385,12 +387,14 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do defp decode_games(responses) do responses |> Enum.map(fn response -> - [game_type, created_at, address] = Helper.decode_data(response.result, [{:uint, 32}, {:uint, 64}, :address]) + [game_type, created_at, address_hash] = Helper.decode_data(response.result, [{:uint, 32}, {:uint, 64}, :address]) + + {:ok, address} = Address.cast(address_hash) %{ index: response.id, game_type: game_type, - address: address, + address_hash: address, created_at: Timex.from_unix(created_at) } end) @@ -399,16 +403,7 @@ defmodule Indexer.Fetcher.Optimism.DisputeGame do defp read_extra_data(method_id, method_name, games, json_rpc_named_arguments, retries \\ 10) do requests = games - |> Enum.map(fn game -> - address = - if is_binary(game.address) do - ExplorerHelper.add_0x_prefix(game.address) - else - game.address - end - - Contract.eth_call_request(method_id, address, game.index, nil, nil) - end) + |> Enum.map(&Contract.eth_call_request(method_id, &1.address_hash, &1.index, nil, nil)) error_message = &"Cannot call #{method_name} public getter of FaultDisputeGame. Error: #{inspect(&1)}" diff --git a/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex b/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex index 2d3c675bb28b..9a3f209bc52b 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/eip1559_config_update.ex @@ -1,24 +1,31 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do @moduledoc """ Fills op_eip1559_config_updates DB table. - The table stores points when EIP-1559 denominator and multiplier were changed, - and the updated values of these parameters. The point is the L2 block number + The table stores points when EIP-1559 denominator and multiplier (and Jovian's `min_base_fee`) + were changed, and the updated values of these parameters. The point is the L2 block number and its hash. The block hash is needed to detect a possible past reorg when starting this fetcher. If the past reorg is detected, the module tries to start from the previous block and so on until a consensus block is found. The parameter values are taken from the `extraData` field of each block. They - are stored in a block starting from the block of Holocene upgrade. Each block + are stored in a block starting from the block of Holocene upgrade. The `min_base_fee` + parameter is stored there starting from the Jovian upgrade. Each block contains the parameters actual for the next blocks (until they are changed again). The `extraData` field has a format described on the page https://specs.optimism.io/protocol/holocene/exec-engine.html#dynamic-eip-1559-parameters + and + https://specs.optimism.io/protocol/jovian/exec-engine.html#minimum-base-fee-in-block-header The Holocene activation block is defined with INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP env variable setting the block timestamp. If this env is not defined, the module won't work. In this case EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR and EIP_1559_ELASTICITY_MULTIPLIER env variables will be used as fallback static values. The timestamp can be defined as `0` meaning the Holocene is activated from genesis block. + + The Jovian activation block is defined with INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP env variable + setting the block timestamp. """ use GenServer @@ -31,7 +38,9 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do alias EthereumJSONRPC.Blocks alias Explorer.Chain + alias Explorer.Chain.{Block, Hash} alias Explorer.Chain.Block.Reader.General, as: BlockGeneralReader + alias Explorer.Chain.Cache.Counters.LastFetchedCounter alias Explorer.Chain.Events.Subscriber alias Explorer.Chain.Optimism.EIP1559ConfigUpdate alias Indexer.Fetcher.Optimism @@ -39,7 +48,8 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do @fetcher_name :optimism_eip1559_config_updates @latest_block_check_interval_seconds 60 - @counter_type "optimism_eip1559_config_updates_fetcher_last_l2_block_hash" + @counter_type_last_l2_block_hash "optimism_eip1559_config_updates_fetcher_last_l2_block_hash" + @counter_type_jovian_defined "optimism_eip1559_config_updates_fetcher_jovian_defined" @empty_hash "0x0000000000000000000000000000000000000000000000000000000000000000" def child_spec(start_link_arguments) do @@ -67,6 +77,7 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do # during initialization. It checks the value of INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP env variable, waits for the # Holocene block (if the module starts before Holocene activation), defines the block range which must be scanned # to handle `extraData` fields, and retrieves the dynamic EIP-1559 parameters (denominator and multiplier) for each block. + # If the `extraData` has version byte greater than 0, the `min_base_fee` value is additionally parsed. # The changed parameter values are then written to the `op_eip1559_config_updates` database table. # # The block range is split into chunks which max size is defined by INDEXER_OPTIMISM_L2_HOLOCENE_BLOCKS_CHUNK_SIZE @@ -96,16 +107,27 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do env = Application.get_all_env(:indexer)[__MODULE__] optimism_env = Application.get_all_env(:indexer)[Indexer.Fetcher.Optimism] - timestamp = env[:holocene_timestamp_l2] + timestamp_holocene = env[:holocene_timestamp_l2] + timestamp_jovian = env[:jovian_timestamp_l2] - with false <- is_nil(timestamp), - wait_for_holocene(timestamp, json_rpc_named_arguments), + with false <- is_nil(timestamp_holocene), + {:jovian_after_holocene, true} <- {:jovian_after_holocene, timestamp_jovian >= timestamp_holocene}, + timestamp_latest = wait_for_holocene(timestamp_holocene, json_rpc_named_arguments), Subscriber.to(:blocks, :realtime), {:ok, latest_block_number} = Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()), l2_block_number = - block_number_by_timestamp(timestamp, optimism_env[:block_duration], json_rpc_named_arguments), + block_number_by_timestamp(timestamp_holocene, optimism_env[:block_duration], json_rpc_named_arguments), EIP1559ConfigUpdate.remove_invalid_updates(l2_block_number, latest_block_number), + # if we set INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP for the first time after Jovian activation, + # we reset the last handled block hash to the activation block hash to continue + # the `extraData` handling from that block + (if jovian_first_time_defined?(timestamp_jovian) and timestamp_jovian <= timestamp_latest do + timestamp_jovian + |> block_number_by_timestamp(optimism_env[:block_duration], json_rpc_named_arguments) + |> block_hash_by_number(json_rpc_named_arguments) + |> Optimism.set_last_block_hash(@counter_type_last_l2_block_hash) + end), {:ok, last_l2_block_number} <- get_last_l2_block_number(json_rpc_named_arguments) do Logger.info("l2_block_number = #{l2_block_number}") Logger.info("last_l2_block_number = #{last_l2_block_number}") @@ -118,7 +140,7 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do start_block_number: max(l2_block_number, last_l2_block_number), end_block_number: latest_block_number, chunk_size: env[:chunk_size], - timestamp: timestamp, + timestamp: timestamp_holocene, mode: :catchup, realtime_range: nil, last_realtime_block_number: nil, @@ -129,6 +151,13 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do # Holocene timestamp is not defined, so we don't start this module {:stop, :normal, %{}} + {:jovian_after_holocene, false} -> + Logger.error( + "Jovian upgrade timestamp must be greater or equal to Holocene timestamp. Please, check INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP and INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP env variables." + ) + + {:stop, :normal, %{}} + {:error, error_data} -> Logger.error("Cannot get last L2 block from RPC by its hash due to RPC error: #{inspect(error_data)}") {:stop, :normal, %{}} @@ -266,14 +295,16 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do ) end - Optimism.set_last_block_hash(@empty_hash, @counter_type) + Optimism.set_last_block_hash(@empty_hash, @counter_type_last_l2_block_hash) end defp handle_reorg(_reorg_block_number), do: :ok # Retrieves updated config parameters from the specified blocks and saves them to the database. - # The parameters are read from the `extraData` field which format is as follows: - # 1-byte version ++ 4-byte denominator ++ 4-byte elasticity + # The parameters are read from the `extraData` field which format is as follows for pre-Jovian period: + # 1-byte version ++ 4-byte denominator ++ 4-byte elasticity + # or for post-Jovian period: + # 1-byte version ++ 4-byte denominator ++ 4-byte elasticity ++ 8-byte min_base_fee # # The last handled block hash is kept in the `last_fetched_counters` table to start from that after # instance restart. @@ -290,55 +321,15 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do case fetch_blocks_by_numbers(block_numbers, json_rpc_named_arguments, false) do {:ok, %Blocks{blocks_params: blocks_params}} -> # we only keep block numbers for the existing blocks - block_numbers_existing = - block_numbers - |> Enum.filter(fn block_number -> - Enum.any?(blocks_params, fn b -> - !is_nil(b) and b.number == block_number - end) - end) + block_numbers_existing = existing_block_numbers(block_numbers, blocks_params) + blocks_by_number = blocks_params_by_number(blocks_params) last_block_number = List.last(block_numbers_existing) Enum.reduce(block_numbers_existing, 0, fn block_number, acc -> - # credo:disable-for-next-line Credo.Check.Refactor.Nesting - block = Enum.find(blocks_params, %{extra_data: "0x"}, fn b -> b.number == block_number end) - - extra_data = hash_to_binary(block.extra_data) - - return = - with {:valid_format, true} <- {:valid_format, byte_size(extra_data) >= 9}, - <> = extra_data, - {:valid_version, _version, true} <- {:valid_version, version, version == 0}, - prev_config = EIP1559ConfigUpdate.actual_config_for_block(block.number), - new_config = {denominator, elasticity}, - {:updated_config, true} <- {:updated_config, prev_config != new_config} do - update_config(block.number, block.hash, denominator, elasticity) - - Logger.info( - "Config was updated at block #{block.number}. Previous one: #{inspect(prev_config)}. New one: #{inspect(new_config)}." - ) - - acc + 1 - else - {:valid_format, false} -> - Logger.warning("extraData of the block ##{block_number} has invalid format. Ignoring it.") - acc - - {:valid_version, version, false} -> - Logger.warning("extraData of the block ##{block_number} has invalid version #{version}. Ignoring it.") - acc - - {:updated_config, false} -> - acc - end - - # credo:disable-for-next-line Credo.Check.Refactor.Nesting - if block.number == last_block_number do - Optimism.set_last_block_hash(block.hash, @counter_type) - end + block = Map.get(blocks_by_number, block_number, %{extra_data: "0x"}) - return + process_block_config(block, block_number, last_block_number, acc) end) {_, message_or_errors} -> @@ -359,21 +350,86 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do end end + defp existing_block_numbers(block_numbers, blocks_params) do + existing_numbers = + blocks_params + |> Enum.reject(&is_nil/1) + |> MapSet.new(& &1.number) + + Enum.filter(block_numbers, &MapSet.member?(existing_numbers, &1)) + end + + defp blocks_params_by_number(blocks_params) do + blocks_params + |> Enum.reject(&is_nil/1) + |> Map.new(&{&1.number, &1}) + end + + defp process_block_config(block, block_number, last_block_number, acc) do + extra_data = hash_to_binary(block.extra_data) + + return = + with {:valid_format, true} <- {:valid_format, byte_size(extra_data) >= 9}, + <> = + extra_data, + prev_config = EIP1559ConfigUpdate.actual_config_for_block(block.number), + new_config = build_eip1559_config(version, denominator, elasticity, rest_extra_data), + {:updated_config, true} <- {:updated_config, prev_config != new_config} do + update_config(block.number, block.hash, new_config) + + Logger.info( + "Config was updated at block #{block.number}. Previous one: #{inspect(prev_config)}. New one: #{inspect(new_config)}." + ) + + acc + 1 + else + {:valid_format, false} -> + Logger.warning("extraData of the block ##{block_number} has invalid format. Ignoring it.") + acc + + {:updated_config, false} -> + acc + end + + if block.number == last_block_number do + Optimism.set_last_block_hash(block.hash, @counter_type_last_l2_block_hash) + end + + return + end + + defp build_eip1559_config(version, denominator, elasticity, rest_extra_data) do + if version == 0 do + {denominator, elasticity, nil} + else + <> = rest_extra_data + {denominator, elasticity, min_base_fee} + end + end + # Inserts a new row into the `op_eip1559_config_updates` database table. # # ## Parameters # - `l2_block_number`: L2 block number of the config update. # - `l2_block_hash`: L2 block hash of the config update. - # - `base_fee_max_change_denominator`: A new value for EIP-1559 denominator. - # - `elasticity_multiplier`: A new value for EIP-1559 multiplier. - @spec update_config(non_neg_integer(), binary(), non_neg_integer(), non_neg_integer()) :: no_return() - defp update_config(l2_block_number, l2_block_hash, base_fee_max_change_denominator, elasticity_multiplier) do + # - `new_config`: A tuple containing the new config values: + # `base_fee_max_change_denominator`: A new value for EIP-1559 denominator. + # `elasticity_multiplier`: A new value for EIP-1559 multiplier. + # `min_base_fee`: A new value for the Jovian's min_base_fee. + @spec update_config(non_neg_integer(), binary(), {non_neg_integer(), non_neg_integer(), non_neg_integer() | nil}) :: + no_return() + defp update_config( + l2_block_number, + l2_block_hash, + {base_fee_max_change_denominator, elasticity_multiplier, min_base_fee} + ) do updates = [ %{ l2_block_number: l2_block_number, l2_block_hash: l2_block_hash, base_fee_max_change_denominator: base_fee_max_change_denominator, - elasticity_multiplier: elasticity_multiplier + elasticity_multiplier: elasticity_multiplier, + min_base_fee: min_base_fee } ] @@ -435,7 +491,10 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do {:ok, non_neg_integer()} | {:error, any()} defp get_last_l2_block_number(json_rpc_named_arguments) do last_l2_block_number = - Optimism.get_last_block_number_from_last_fetched_counter(json_rpc_named_arguments, @counter_type) + Optimism.get_last_block_number_from_last_fetched_counter( + json_rpc_named_arguments, + @counter_type_last_l2_block_hash + ) if is_nil(last_l2_block_number) do {last_l2_block_number, last_l2_block_hash} = EIP1559ConfigUpdate.get_last_item() @@ -580,7 +639,10 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do # ## Parameters # - `timestamp`: The timestamp of the Holocene. # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection. - @spec wait_for_holocene(non_neg_integer(), EthereumJSONRPC.json_rpc_named_arguments()) :: any() + # + # ## Returns + # - Timestamp of the latest block. + @spec wait_for_holocene(non_neg_integer(), EthereumJSONRPC.json_rpc_named_arguments()) :: non_neg_integer() defp wait_for_holocene(timestamp, json_rpc_named_arguments) do {:ok, latest_timestamp} = Helper.get_block_timestamp_by_number_or_tag(:latest, json_rpc_named_arguments, Helper.infinite_retries_number()) @@ -591,8 +653,72 @@ defmodule Indexer.Fetcher.Optimism.EIP1559ConfigUpdate do wait_for_holocene(timestamp, json_rpc_named_arguments) else Logger.info("Holocene activation detected") + latest_timestamp + end + end + + # Gets block hash by its number. + # + # First, the function tries to get the block from database. If the block is not indexed, it's got from RPC. + # + # ## Parameters + # - `block_number`: The block number we need to get hash for. + # - `json_rpc_named_arguments`: Configuration for JSON RPC connection. + # + # ## Returns + # - The block hash in form of 0x-prefixed string. Returns 0x00..00 if the hash cannot be determined. + @spec block_hash_by_number(non_neg_integer(), EthereumJSONRPC.json_rpc_named_arguments()) :: String.t() + defp block_hash_by_number(block_number, json_rpc_named_arguments) do + hash_from_db = + [block_number] + |> Block.block_hash_by_number() + |> Map.get(block_number) + + if is_nil(hash_from_db) do + [block_number] + |> Optimism.get_blocks_by_numbers(json_rpc_named_arguments, Helper.infinite_retries_number()) + |> Enum.at(0, %{}) + |> Map.get("hash", @empty_hash) + else + Hash.to_string(hash_from_db) end end + # Returns `true` if Jovian activation timestamp is defined for the first time. Otherwise, returns `false`. + # Also, remembers the fact of the Jovian timestamp definition in the database. + # + # ## Parameters + # - `timestamp`: The currently defined Jovian activation timestamp. `nil` if not defined. + # + # ## Returns + # - The boolean result. + @spec jovian_first_time_defined?(non_neg_integer() | nil) :: boolean() + defp jovian_first_time_defined?(timestamp) do + jovian_defined_now = !is_nil(timestamp) + jovian_defined_before = !is_nil(LastFetchedCounter.get(@counter_type_jovian_defined, nullable: true)) + + result = + cond do + is_nil(LastFetchedCounter.get(@counter_type_last_l2_block_hash, nullable: true)) -> + # Holocene handler never worked before, so we don't need to reset the last handled block hash + false + + !jovian_defined_before -> + # Jovian never defined before. We return `true` if the Jovian is defined now. Otherwise, return `false` + jovian_defined_now + + true -> + # Holocene handler worked before and Jovian was defined before + false + end + + if jovian_defined_now and !jovian_defined_before do + # if Jovian activation timestamp is defined for the first time, we record this in the database + LastFetchedCounter.upsert(%{counter_type: @counter_type_jovian_defined, value: 1}) + end + + result + end + def fetcher_name, do: @fetcher_name end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/helper.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/helper.ex index 3df02dbaf1dd..eb4a1d19ef69 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/interop/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.Interop.Helper do @moduledoc """ Auxiliary common functions for OP Interop indexers. diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/message.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/message.ex index 0f6fbce2408f..1ef0084f0837 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/interop/message.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/message.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.Interop.Message do @moduledoc """ Fills op_interop_messages DB table by catching `SentMessage` and `RelayedMessage` events. @@ -29,6 +30,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.Message do alias Explorer.Chain alias Explorer.Chain.Block.Reader.General, as: BlockReaderGeneral + alias Explorer.Chain.Data alias Explorer.Chain.Events.Subscriber alias Explorer.Chain.Optimism.InteropMessage alias Indexer.Fetcher.Optimism @@ -62,8 +64,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.Message do @impl GenServer def init(args) do - json_rpc_named_arguments = args[:json_rpc_named_arguments] - {:ok, %{}, {:continue, json_rpc_named_arguments}} + {:ok, %{}, {:continue, args[:json_rpc_named_arguments]}} end # Initialization function which is used instead of `init` to avoid Supervisor's stop in case of any critical issues @@ -320,6 +321,9 @@ defmodule Indexer.Fetcher.Optimism.Interop.Message do if Enum.at(event["topics"], 0) == @sent_message_event do [sender_address_hash, payload] = decode_data(event["data"], [:address, :bytes]) + [transfer_token_address_hash, transfer_from_address_hash, transfer_to_address_hash, transfer_amount] = + InteropMessage.decode_payload(payload) + %{ sender_address_hash: sender_address_hash, target_address_hash: truncate_address_hash(Enum.at(event["topics"], 2)), @@ -329,7 +333,12 @@ defmodule Indexer.Fetcher.Optimism.Interop.Message do block_number: block_number, timestamp: Map.get(timestamps, block_number), relay_chain_id: quantity_to_integer(Enum.at(event["topics"], 1)), - payload: payload + payload: %Data{bytes: payload}, + transfer_token_address_hash: transfer_token_address_hash, + transfer_from_address_hash: transfer_from_address_hash, + transfer_to_address_hash: transfer_to_address_hash, + transfer_amount: transfer_amount, + sent_to_multichain: false } else %{ @@ -432,4 +441,31 @@ defmodule Indexer.Fetcher.Optimism.Interop.Message do end def fetcher_name, do: @fetcher_name + + @doc """ + Returns a constant address of L2ToL2CrossDomainMessenger predeploy. + + ## Returns + - An address of L2ToL2CrossDomainMessenger predeploy. + """ + @spec l2tol2_cross_domain_messenger() :: String.t() + def l2tol2_cross_domain_messenger, do: @l2tol2_cross_domain_messenger + + @doc """ + Returns a max possible value for 32-bit signed integer. + + ## Returns + - A max possible value for 32-bit signed integer. + """ + @spec max_int32() :: non_neg_integer() + def max_int32, do: @max_int32 + + @doc """ + Returns a 32-byte signature of the `SentMessage` event: `SentMessage(uint256 indexed destination, address indexed target, uint256 indexed messageNonce, address sender, bytes message)`. + + ## Returns + - 32-byte signature of the `SentMessage` event. + """ + @spec sent_message_event_signature() :: String.t() + def sent_message_event_signature, do: @sent_message_event end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/message_failed.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/message_failed.ex index e101d324705e..9b2a01459776 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/interop/message_failed.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/message_failed.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do @moduledoc """ Fills op_interop_messages DB table with failed messages. @@ -33,15 +34,10 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do alias Indexer.Helper @fetcher_name :optimism_interop_messages_failed - @l2tol2_cross_domain_messenger "0x4200000000000000000000000000000000000023" - @max_int32 2_147_483_647 # 4-byte signature of the method relayMessage((address origin, uint256 blockNumber, uint256 logIndex, uint256 timestamp, uint256 chainId), bytes _sentMessage) @relay_message_method "0x8d1d298f" - # 32-byte signature of the event SentMessage(uint256 indexed destination, address indexed target, uint256 indexed messageNonce, address sender, bytes message) - @sent_message_event "0x382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f320" - def child_spec(start_link_arguments) do spec = %{ id: __MODULE__, @@ -59,8 +55,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do @impl GenServer def init(args) do - json_rpc_named_arguments = args[:json_rpc_named_arguments] - {:ok, %{}, {:continue, json_rpc_named_arguments}} + {:ok, %{}, {:continue, args[:json_rpc_named_arguments]}} end # Initialization function which is used instead of `init` to avoid Supervisor's stop in case of any critical issues @@ -312,6 +307,8 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do ] } + sent_message_event_signature = InteropMessageFetcher.sent_message_event_signature() + messages = transactions_params |> Enum.map(fn transaction -> @@ -319,7 +316,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do [ {_origin, _block_number, _log_index, _timestamp, init_chain_id}, - @sent_message_event <> sent_message_topics_and_data + ^sent_message_event_signature <> sent_message_topics_and_data ] = TypeDecoder.decode( Base.decode16!(encoded_params, case: :lower), @@ -339,7 +336,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do } end) |> Enum.filter(&(&1.relay_chain_id == current_chain_id)) - |> Enum.reject(&(&1.init_chain_id > @max_int32)) + |> Enum.reject(&(&1.init_chain_id > InteropMessageFetcher.max_int32())) {:ok, _} = Chain.import(%{ @@ -378,7 +375,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageFailed do |> String.downcase() |> String.starts_with?(@relay_message_method) - to_address == @l2tol2_cross_domain_messenger and is_relay_message_method + to_address == InteropMessageFetcher.l2tol2_cross_domain_messenger() and is_relay_message_method end) case fetch_transaction_receipts(relay_message_transactions, json_rpc_named_arguments) do diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex index 04e2e9d469d2..db1961af02b7 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/message_queue.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do @moduledoc """ Searches for incomplete messages in the `op_interop_messages` database table and sends message's data to the @@ -27,17 +28,20 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do require Logger - import Explorer.Helper, only: [add_0x_prefix: 1, hash_to_binary: 1] + import Explorer.Helper, only: [hash_to_binary: 1] import Indexer.Fetcher.Optimism.Interop.Helper, only: [log_cant_get_chain_id_from_rpc: 0] alias Explorer.Chain - alias Explorer.Chain.Hash + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.{Data, Hash} alias Explorer.Chain.Optimism.InteropMessage alias Indexer.Fetcher.Optimism alias Indexer.Helper + @counter_type "optimism_interop_messages_queue_iteration" @fetcher_name :optimism_interop_messages_queue @api_endpoint_import "/api/v2/import/optimism/interop/" + @chunk_size 1000 def child_spec(start_link_arguments) do spec = %{ @@ -56,8 +60,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do @impl GenServer def init(args) do - json_rpc_named_arguments = args[:json_rpc_named_arguments] - {:ok, %{}, {:continue, json_rpc_named_arguments}} + {:ok, %{}, {:continue, args[:json_rpc_named_arguments]}} end # Initialization function which is used instead of `init` to avoid Supervisor's stop in case of any critical issues @@ -111,6 +114,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do timeout: :timer.seconds(env[:connect_timeout]), recv_timeout: :timer.seconds(env[:recv_timeout]), export_expiration_blocks: div(env[:export_expiration] * 24 * 3600, block_duration), + iterations_done: Decimal.to_integer(LastFetchedCounter.get(@counter_type)), json_rpc_named_arguments: json_rpc_named_arguments }} else @@ -162,60 +166,90 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do timeout: timeout, recv_timeout: recv_timeout, export_expiration_blocks: export_expiration_blocks, + iterations_done: iterations_done, json_rpc_named_arguments: json_rpc_named_arguments } = state ) do - {:ok, latest_block_number} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - private_key = hash_to_binary(Application.get_all_env(:indexer)[__MODULE__][:private_key]) - updated_chainscout_map = - current_chain_id - |> InteropMessage.get_incomplete_messages(max(latest_block_number - export_expiration_blocks, 0)) - |> Enum.reduce(chainscout_map, fn message, chainscout_map_acc -> - {instance_chain_id, post_data_signed} = prepare_post_data(message, private_key) - - url_from_map = Map.get(chainscout_map_acc, instance_chain_id) - - instance_url = - with {:url_from_map_is_nil, true, _} <- {:url_from_map_is_nil, is_nil(url_from_map), url_from_map}, - info = InteropMessage.get_instance_info_by_chain_id(instance_chain_id, chainscout_api_url), - {:url_from_chainscout_avail, true} <- {:url_from_chainscout_avail, not is_nil(info)} do - info.instance_url - else - {:url_from_map_is_nil, false, url_from_map} -> - url_from_map + LastFetchedCounter.upsert(%{ + counter_type: @counter_type, + value: iterations_done + }) - {:url_from_chainscout_avail, false} -> - nil - end + # the first three iterations scan all incomplete messages, + # but subsequent scans are limited by INDEXER_OPTIMISM_INTEROP_EXPORT_EXPIRATION_DAYS env + start_block_number = + if iterations_done < 3 do + 0 + else + {:ok, latest_block_number} = + Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - with false <- is_nil(instance_url), - endpoint_url = instance_url <> @api_endpoint_import, - response = post_json_request(endpoint_url, post_data_signed, timeout, recv_timeout), - false <- is_nil(response) do - {:ok, _} = - Chain.import(%{ - optimism_interop_messages: %{params: [prepare_import(message, response)]}, - timeout: :infinity - }) - - Logger.info( - "Message details successfully sent to #{endpoint_url}. Request body: #{inspect(post_data_signed)}. Response body: #{inspect(response)}" - ) - end + max(latest_block_number - export_expiration_blocks, 0) + end - if is_nil(instance_url) do - chainscout_map_acc - else - Map.put(chainscout_map_acc, instance_chain_id, instance_url) - end + %{min: min_block_number, max: max_block_number, count: message_count} = + InteropMessage.get_incomplete_messages_stats(current_chain_id, start_block_number) + + chunks_number = ceil(message_count / @chunk_size) + chunk_range = Range.new(0, max(chunks_number - 1, 0), 1) + + updated_chainscout_map = + chunk_range + |> Enum.reduce(chainscout_map, fn current_chunk, chainscout_map_acc -> + current_chain_id + |> InteropMessage.get_incomplete_messages( + min_block_number, + max_block_number, + @chunk_size, + current_chunk * @chunk_size + ) + |> Enum.reduce(chainscout_map_acc, fn message, chainscout_map_acc_internal -> + {instance_chain_id, post_data_signed} = prepare_post_data(message, private_key) + + url_from_map = Map.get(chainscout_map_acc_internal, instance_chain_id) + + instance_url = + with {:url_from_map_is_nil, true, _} <- {:url_from_map_is_nil, is_nil(url_from_map), url_from_map}, + info = InteropMessage.get_instance_info_by_chain_id(instance_chain_id, chainscout_api_url), + {:url_from_chainscout_avail, true} <- {:url_from_chainscout_avail, not is_nil(info)} do + info.instance_url + else + {:url_from_map_is_nil, false, url_from_map} -> + url_from_map + + {:url_from_chainscout_avail, false} -> + nil + end + + with false <- is_nil(instance_url), + endpoint_url = instance_url <> @api_endpoint_import, + response = post_json_request(endpoint_url, post_data_signed, timeout, recv_timeout), + false <- is_nil(response) do + {:ok, _} = + Chain.import(%{ + optimism_interop_messages: %{params: [prepare_import(message, response)]}, + timeout: :infinity + }) + + Logger.info( + "Message details successfully sent to #{endpoint_url}. Request body: #{inspect(post_data_signed)}. Response body: #{inspect(response)}" + ) + end + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if is_nil(instance_url) do + chainscout_map_acc_internal + else + Map.put(chainscout_map_acc_internal, instance_chain_id, instance_url) + end + end) end) Process.send_after(self(), :continue, :timer.seconds(3)) - {:noreply, %{state | chainscout_map: updated_chainscout_map}} + {:noreply, %{state | chainscout_map: updated_chainscout_map, iterations_done: iterations_done + 1}} end @impl GenServer @@ -237,7 +271,6 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do @spec prepare_post_data(map(), binary()) :: {non_neg_integer(), map()} defp prepare_post_data(message, private_key) when is_nil(message.relay_transaction_hash) do timestamp = DateTime.to_unix(message.timestamp) - payload = add_0x_prefix(message.payload) data = %{ sender_address_hash: Hash.to_string(message.sender_address_hash), @@ -247,7 +280,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do init_transaction_hash: Hash.to_string(message.init_transaction_hash), timestamp: timestamp, relay_chain_id: message.relay_chain_id, - payload: payload, + payload: message.payload, signature: nil } @@ -257,7 +290,7 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do Integer.to_string(message.nonce) <> Integer.to_string(message.init_chain_id) <> data.init_transaction_hash <> - Integer.to_string(timestamp) <> Integer.to_string(message.relay_chain_id) <> payload + Integer.to_string(timestamp) <> Integer.to_string(message.relay_chain_id) <> to_string(message.payload) {:ok, {signature, _}} = data_to_sign @@ -301,9 +334,10 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do # - `{chain_id, post_data_signed}` tuple where # `chain_id` is the chain id from the input parameter. # `post_data_signed` is the `data` map with the `signature` field. + @doc false @spec set_post_data_signature(non_neg_integer(), map(), binary()) :: {non_neg_integer(), map()} - defp set_post_data_signature(chain_id, data, signature) do - {chain_id, %{data | signature: add_0x_prefix(signature)}} + def set_post_data_signature(chain_id, data, signature) do + {chain_id, %{data | signature: %Data{bytes: signature} |> to_string()}} end # Prepares a map to import to the `op_interop_messages` table based on the current handling message and @@ -343,6 +377,9 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do pl -> hash_to_binary(pl) end + [transfer_token_address_hash, transfer_from_address_hash, transfer_to_address_hash, transfer_amount] = + InteropMessage.decode_payload(payload) + %{ sender_address_hash: Map.get(response, "sender_address_hash"), target_address_hash: Map.get(response, "target_address_hash"), @@ -351,7 +388,11 @@ defmodule Indexer.Fetcher.Optimism.Interop.MessageQueue do init_transaction_hash: Map.get(response, "init_transaction_hash"), relay_chain_id: message.relay_chain_id, timestamp: timestamp, - payload: payload + payload: payload, + transfer_token_address_hash: transfer_token_address_hash, + transfer_from_address_hash: transfer_from_address_hash, + transfer_to_address_hash: transfer_to_address_hash, + transfer_amount: transfer_amount } end end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex b/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex new file mode 100644 index 000000000000..24981a315ae2 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/optimism/interop/multichain_export.ex @@ -0,0 +1,261 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Optimism.Interop.MultichainExport do + @moduledoc """ + Finds messages without `sent_to_multichain` flag in the `op_interop_messages` database table and + sends them to the Multichain service using its API. + + The found messages are combined into a batch and the batch is sent to the remote API. The batch max + size is defined with the optional INDEXER_OPTIMISM_MULTICHAIN_BATCH_SIZE env variable having + a default value. Once the found messages are sent, the module starts the next iteration for another + batch of messages, and so on. + + The Multichain API endpoint URL is defined with MICROSERVICE_MULTICHAIN_SEARCH_URL env variable. + API key for the remote service is defined with MICROSERVICE_MULTICHAIN_SEARCH_API_KEY env variable. + """ + + use GenServer + use Indexer.Fetcher + + require Logger + + import Ecto.Query + import Utils.ConfigHelper, only: [valid_url?: 1] + import Indexer.Fetcher.Optimism.Interop.Helper, only: [log_cant_get_chain_id_from_rpc: 0] + + alias Ecto.Multi + alias Explorer.Chain.Hash + alias Explorer.Chain.Optimism.InteropMessage + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + alias Indexer.Fetcher.Optimism + + @fetcher_name :optimism_interop_multichain_export + + def child_spec(start_link_arguments) do + spec = %{ + id: __MODULE__, + start: {__MODULE__, :start_link, start_link_arguments}, + restart: :transient, + type: :worker + } + + Supervisor.child_spec(spec, []) + end + + def start_link(args, gen_server_options \\ []) do + GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) + end + + @impl GenServer + def init(_args) do + {:ok, %{}, {:continue, nil}} + end + + # Initialization function which is used instead of `init` to avoid Supervisor's stop in case of any critical issues + # during initialization. It checks the value of MICROSERVICE_MULTICHAIN_SEARCH_URL and MICROSERVICE_MULTICHAIN_SEARCH_API_KEY + # env variables and starts the handling loop. + # + # Also, the function fetches the current chain id to use it in the handler. + # + # When the initialization succeeds, the `:continue` message is sent to GenServer to start the handler loop. + # + # ## Parameters + # - `_state`: Initial state of the fetcher (empty map when starting). + # + # ## Returns + # - `{:noreply, state}` when the initialization is successful and the handling can start. The `state` contains + # necessary parameters needed for the handling. + # - `{:stop, :normal, %{}}` in case of error or when one of env variables is not defined. + @impl GenServer + @spec handle_continue(nil, map()) :: {:noreply, map()} | {:stop, :normal, map()} + def handle_continue(nil, _state) do + Logger.metadata(fetcher: @fetcher_name) + + # two seconds pause needed to avoid exceeding Supervisor restart intensity when DB issues + :timer.sleep(2000) + + env = Application.get_all_env(:indexer)[__MODULE__] + + multichain_api_url = MultichainSearch.batch_import_url() + multichain_api_key = MultichainSearch.api_key() + + with {:api_url_is_valid, true} <- {:api_url_is_valid, valid_url?(multichain_api_url)}, + {:api_key_is_nil, false} <- {:api_key_is_nil, is_nil(multichain_api_key)}, + chain_id = Optimism.fetch_chain_id(), + {:chain_id_is_nil, false} <- {:chain_id_is_nil, is_nil(chain_id)} do + Process.send(self(), :continue, []) + + {:noreply, + %{ + chain_id: chain_id, + multichain_api_url: multichain_api_url, + multichain_api_key: multichain_api_key, + batch_size: env[:batch_size] + }} + else + {:api_url_is_valid, false} -> + # Multichain service API URL is not defined, so we don't start this module + Logger.warning( + "MICROSERVICE_MULTICHAIN_SEARCH_URL env variable is invalid or not defined. The module #{__MODULE__} will not start." + ) + + {:stop, :normal, %{}} + + {:api_key_is_nil, true} -> + Logger.error( + "MICROSERVICE_MULTICHAIN_SEARCH_API_KEY env variable is not defined. The module #{__MODULE__} will not start." + ) + + {:stop, :normal, %{}} + + {:chain_id_is_nil, true} -> + log_cant_get_chain_id_from_rpc() + {:stop, :normal, %{}} + end + end + + # Performs the main handling loop scanning for unsent part of messages. + # + # Details of each unsent message part are prepared and sent to the remote multichain instance through its API. + # + # ## Parameters + # - `:continue`: The GenServer message. + # - `state`: The current state of the fetcher containing the current chain ID, API URL and key, and batch size. + # + # ## Returns + # - `{:noreply, state}` tuple. + @impl GenServer + def handle_info( + :continue, + %{ + chain_id: current_chain_id, + multichain_api_url: multichain_api_url, + multichain_api_key: multichain_api_key, + batch_size: batch_size + } = state + ) do + messages = InteropMessage.get_messages_for_multichain_export(current_chain_id, batch_size) + + data = prepare_post_data(current_chain_id, messages, multichain_api_key) + + if post_json_request(multichain_api_url, data) do + {:ok, _} = + messages + |> Enum.reduce(Multi.new(), fn message, multi_acc -> + Multi.update_all( + multi_acc, + {:message, message.nonce, message.init_chain_id}, + from(m in InteropMessage, where: m.nonce == ^message.nonce and m.init_chain_id == ^message.init_chain_id), + set: [sent_to_multichain: true] + ) + end) + |> Repo.transaction() + + Logger.info("#{length(messages)} item(s) were successfully sent to the Multichain service.") + end + + if messages == [] do + Logger.info("There are no items to send to the Multichain service. Retrying in a few moments...") + end + + Process.send_after(self(), :continue, :timer.seconds(3)) + + {:noreply, state} + end + + @impl GenServer + def handle_info({ref, _result}, state) do + Process.demonitor(ref, [:flush]) + {:noreply, state} + end + + # Prepares data to send as POST request to the remote multichain API endpoint. + # + # ## Parameters + # - `current_chain_id`: The current chain ID. + # - `messages`: A list of maps containing the messages data. + # - `api_key`: API key to access the remote import endpoint. + # + # ## Returns + # - The prepared data map if the `messages` list is not empty. + # - `nil` if the `messages` list is empty. + @spec prepare_post_data(non_neg_integer(), [map()], String.t()) :: map() | nil + defp prepare_post_data(_current_chain_id, [], _api_key), do: nil + + defp prepare_post_data(current_chain_id, messages, api_key) do + interop_messages = + messages + |> Enum.map(fn message -> + if message.init_chain_id == current_chain_id do + # this is `init` part of an outgoing message + %{ + init: %{ + sender_address_hash: Hash.to_string(message.sender_address_hash), + target_address_hash: Hash.to_string(message.target_address_hash), + nonce: Integer.to_string(message.nonce), + init_chain_id: Integer.to_string(message.init_chain_id), + init_transaction_hash: Hash.to_string(message.init_transaction_hash), + timestamp: Integer.to_string(DateTime.to_unix(message.timestamp)), + relay_chain_id: Integer.to_string(message.relay_chain_id), + payload: message.payload, + transfer_token_address_hash: message.transfer_token_address_hash, + transfer_from_address_hash: message.transfer_from_address_hash, + transfer_to_address_hash: message.transfer_to_address_hash, + transfer_amount: + if(not is_nil(message.transfer_amount), do: Decimal.to_string(message.transfer_amount, :normal)) + } + } + else + # this is `relay` part of an incoming message + %{ + relay: %{ + nonce: Integer.to_string(message.nonce), + init_chain_id: Integer.to_string(message.init_chain_id), + relay_chain_id: Integer.to_string(message.relay_chain_id), + relay_transaction_hash: Hash.to_string(message.relay_transaction_hash), + failed: message.failed + } + } + end + end) + + %{ + chain_id: Integer.to_string(current_chain_id), + interop_messages: interop_messages, + api_key: api_key + } + end + + # Sends message's data to the given remote multichain instance using HTTP POST request. + # + # ## Parameters + # - `url`: URL of the remote API endpoint to send the data to. + # - `body`: A map with message's data. + # + # ## Returns + # - `true` in case of success. + # - `false` in case of failure. + @spec post_json_request(String.t(), map() | nil) :: boolean() + defp post_json_request(_url, nil), do: false + + defp post_json_request(url, body) do + timeout = 8_000 + recv_timeout = 5_000 + + client = Tesla.client([{Tesla.Middleware.Timeout, timeout: recv_timeout}], Tesla.Adapter.Mint) + json_body = Jason.encode!(body) + headers = [{"Content-Type", "application/json"}] + + # Mint adapter doesn't support sending more than 65535 bytes when using HTTP/2, so we use HTTP/1 + opts = [adapter: [timeout: recv_timeout, transport_opts: [timeout: timeout], protocols: [:http1]]] + + case Tesla.post(client, url, json_body, headers: headers, opts: opts) do + {:ok, %{status: 200}} -> + true + + reason -> + Logger.error("Cannot post HTTP request to #{url}. Reason: #{inspect(reason)}. Body: #{inspect(json_body)}") + false + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex b/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex new file mode 100644 index 000000000000..5d86b15e9424 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/optimism/operator_fee.ex @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Optimism.OperatorFee do + @moduledoc """ + Retrieves and saves operator fee for historic transactions from their receipts using RPC calls + starting from the timestamp defined in INDEXER_OPTIMISM_L2_ISTHMUS_TIMESTAMP env variable. + + If the env variable is not defined or the chain type is not :optimism, the module doesn't start. + + Once the historic transactions are handled, the module stops working and doesn't start again + after instance restarts. If there is a need to make it work again, the corresponding constant + should be manually removed from the `constants` database table. + + The transaction queue handling is adjusted by `INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_*` env variables. + """ + + require Logger + + use Indexer.Fetcher, restart: :transient + use Spandex.Decorators + + import Ecto.Query + + import EthereumJSONRPC, + only: [ + json_rpc: 2 + ] + + alias EthereumJSONRPC.Receipt + alias EthereumJSONRPC.Receipts.ByTransactionHash + alias Explorer.Application.Constants + alias Explorer.Chain.{Hash, Transaction} + alias Explorer.Repo + + alias Indexer.{BufferedTask, Helper} + + @behaviour BufferedTask + + @default_max_batch_size 100 + @default_max_concurrency 3 + @fetcher_name :optimism_operator_fee + @fetcher_finished_constant_key "optimism_operator_fee_fetcher_finished" + + @doc false + def child_spec([init_options, gen_server_options]) do + {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) + + if !state do + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> + "to allow for json_rpc calls when running." + end + + merged_init_opts = + defaults() + |> Keyword.merge(mergeable_init_options) + |> Keyword.put(:state, state) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial_acc, reducer, _) do + if Constants.get_constant_value(@fetcher_finished_constant_key) == "true" do + Logger.info("All known transactions are previously handled by #{__MODULE__} module so it won't be started.", + fetcher: @fetcher_name + ) + + Process.send(__MODULE__, :shutdown, []) + initial_acc + else + isthmus_timestamp_l2 = Application.get_env(:indexer, Indexer.Fetcher.Optimism)[:isthmus_timestamp_l2] + + {:ok, acc} = + Transaction.stream_transactions_without_operator_fee( + initial_acc, + fn data, acc -> + Helper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) + end, + isthmus_timestamp_l2 + ) + + {transactions_count, _} = acc + + if transactions_count == 0 do + Logger.info("All known transactions are handled by #{__MODULE__} module so it will be stopped.", + fetcher: @fetcher_name + ) + + Constants.set_constant_value(@fetcher_finished_constant_key, "true") + Process.send(__MODULE__, :shutdown, []) + end + + acc + end + end + + @impl BufferedTask + def run(hashes, json_rpc_named_arguments) when is_list(hashes) do + Logger.metadata(fetcher: @fetcher_name) + + requests = + hashes + |> Enum.with_index() + |> Enum.map(fn {hash, id} -> + ByTransactionHash.request(id, Hash.to_string(hash)) + end) + + error_message = &"eth_getTransactionReceipt failed. Error: #{inspect(&1)}" + + {:ok, receipts} = + Helper.repeated_call( + &json_rpc/2, + [requests, json_rpc_named_arguments], + error_message, + Helper.infinite_retries_number() + ) + + receipts + |> Enum.map(& &1.result) + |> Enum.reject(&is_nil/1) + |> Enum.map(&Receipt.to_elixir/1) + |> Enum.map(&Receipt.elixir_to_params/1) + |> Enum.each(fn receipt -> + now = DateTime.utc_now() + + Repo.update_all( + from(t in Transaction, where: t.hash == ^receipt.transaction_hash), + set: [ + operator_fee_scalar: receipt.operator_fee_scalar, + operator_fee_constant: receipt.operator_fee_constant, + updated_at: now + ] + ) + end) + end + + defp defaults do + [ + flush_interval: :timer.seconds(10), + max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/output_root.ex b/apps/indexer/lib/indexer/fetcher/optimism/output_root.ex index 49e2fed7483b..50358de6b4c0 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/output_root.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/output_root.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.OutputRoot do @moduledoc """ Fills op_output_roots DB table. @@ -15,9 +16,9 @@ defmodule Indexer.Fetcher.Optimism.OutputRoot do alias Explorer.Application.Constants alias Explorer.{Chain, Helper, Repo} alias Explorer.Chain.Optimism.{DisputeGame, OutputRoot} - alias Explorer.Chain.RollupReorgMonitorQueue alias Indexer.Fetcher.Optimism alias Indexer.Helper, as: IndexerHelper + alias Indexer.RollupReorgMonitorQueue @fetcher_name :optimism_output_roots @stop_constant_key "optimism_output_roots_stopped" @@ -120,7 +121,7 @@ defmodule Indexer.Fetcher.Optimism.OutputRoot do ) end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) + reorg_block = RollupReorgMonitorQueue.pop(__MODULE__) if !is_nil(reorg_block) && reorg_block > 0 do {deleted_count, _} = Repo.delete_all(from(r in OutputRoot, where: r.l1_block_number >= ^reorg_block)) diff --git a/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex b/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex index db6fd855273a..bab88c859496 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/transaction_batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.TransactionBatch do @moduledoc """ Fills op_transaction_batches, op_frame_sequence, and op_frame_sequence_blobs DB tables. @@ -26,19 +27,20 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do import EthereumJSONRPC, only: [fetch_blocks_by_range: 2, json_rpc: 2, quantity_to_integer: 1] - import Explorer.Helper, only: [add_0x_prefix: 1, hash_to_binary: 1, parse_integer: 1] + import Explorer.Helper, only: [hash_to_binary: 1, parse_integer: 1] alias Ecto.Multi alias EthereumJSONRPC.Block.ByHash alias EthereumJSONRPC.{Blocks, Contract} alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Block, RollupReorgMonitorQueue} + alias Explorer.Chain.{Block, Hash} alias Explorer.Chain.Events.Publisher alias Explorer.Chain.Optimism.{FrameSequence, FrameSequenceBlob} alias Explorer.Chain.Optimism.TransactionBatch, as: OptimismTransactionBatch alias Indexer.Fetcher.{Optimism, RollupL1ReorgMonitor} alias Indexer.Helper alias Indexer.Prometheus.Instrumenter + alias Indexer.RollupReorgMonitorQueue alias Varint.LEB128 @fetcher_name :optimism_transaction_batches @@ -133,6 +135,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do batch_submitter: batch_submitter, eip4844_blobs_api_url: Helper.trim_url(env[:eip4844_blobs_api_url]), celestia_blobs_api_url: Helper.trim_url(env[:celestia_blobs_api_url]), + eigenda_blobs_api_url: Helper.trim_url(env[:eigenda_blobs_api_url]), + alt_da_server_url: Helper.trim_url(env[:alt_da_server_url]), block_check_interval: block_check_interval, start_block: start_block, end_block: last_safe_block, @@ -216,6 +220,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do # - `batch_submitter`: L1 address which sends L1 batch transactions to the `batch_inbox` # - `eip4844_blobs_api_url`: URL of Blockscout Blobs API to get EIP-4844 blobs # - `celestia_blobs_api_url`: URL of the server where Celestia blobs can be read from + # - `eigenda_blobs_api_url`: URL of the server where EigenDA blobs can be read from + # - `alt_da_server_url`: URL of Alt-DA server where keccak commitment data can be read from # - `block_check_interval`: time interval for checking latest block number # - `start_block`: start block number of the block range # - `end_block`: end block number of the block range @@ -234,6 +240,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do batch_submitter: batch_submitter, eip4844_blobs_api_url: eip4844_blobs_api_url, celestia_blobs_api_url: celestia_blobs_api_url, + eigenda_blobs_api_url: eigenda_blobs_api_url, + alt_da_server_url: alt_da_server_url, block_check_interval: block_check_interval, start_block: start_block, end_block: end_block, @@ -276,7 +284,7 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do {genesis_block_l2, block_duration}, incomplete_channels_acc, {json_rpc_named_arguments, json_rpc_named_arguments_l2}, - {eip4844_blobs_api_url, celestia_blobs_api_url, chain_id_l1}, + {eip4844_blobs_api_url, celestia_blobs_api_url, eigenda_blobs_api_url, alt_da_server_url, chain_id_l1}, Helper.infinite_retries_number() ) @@ -295,7 +303,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do timeout: :infinity }) - remove_prev_frame_sequences(inserted) + removed_sequence_ids = remove_prev_frame_sequences(inserted) + sequences = Enum.reject(sequences, fn s -> s.id in removed_sequence_ids end) set_frame_sequences_view_ready(sequences) last_batch = @@ -309,8 +318,7 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do Publisher.broadcast( %{ - new_optimism_batches: - Enum.map(sequences, &FrameSequence.batch_by_internal_id(&1.id, include_blobs?: false)) + new_optimism_batches: Enum.map(sequences, &FrameSequence.batch_by_number(&1.id, include_blobs?: false)) }, :realtime ) @@ -329,7 +337,7 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do {incomplete_channels_acc, nil} end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) + reorg_block = RollupReorgMonitorQueue.pop(__MODULE__) if !is_nil(reorg_block) && reorg_block > 0 do new_incomplete_channels = handle_l1_reorg(reorg_block, new_incomplete_channels) @@ -372,11 +380,12 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do {:noreply, state} end - defp get_block_numbers_by_hashes([], _json_rpc_named_arguments_l2) do + @doc false + def get_block_numbers_by_hashes([], _json_rpc_named_arguments_l2) do %{} end - defp get_block_numbers_by_hashes(hashes, json_rpc_named_arguments_l2) do + def get_block_numbers_by_hashes(hashes, json_rpc_named_arguments_l2) do query = from( b in Block, @@ -396,7 +405,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do |> Enum.filter(fn hash -> is_nil(Map.get(number_by_hash, hash)) end) |> Enum.with_index() |> Enum.map(fn {hash, id} -> - ByHash.request(%{hash: add_0x_prefix(hash), id: id}, false) + {:ok, hash} = Hash.Full.cast(hash) + ByHash.request(%{hash: hash, id: id}, false) end) chunk_size = 50 @@ -527,7 +537,7 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do json_rpc_named_arguments_l2, blobs_api_url ) - |> Tuple.append(last_block_hash) + |> (&Tuple.insert_at(&1, tuple_size(&1), last_block_hash)).() {_, message_or_errors} -> message = @@ -577,46 +587,53 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do ) do blob_versioned_hashes |> Enum.reduce([], fn blob_hash, inputs_acc -> - with {:ok, response} <- Helper.http_get_request(blobs_api_url <> "/" <> blob_hash), - blob_data = Map.get(response, "blob_data"), - false <- is_nil(blob_data) do - # read the data from Blockscout API - decoded = - blob_data - |> hash_to_binary() - |> OptimismTransactionBatch.decode_eip4844_blob() - - if is_nil(decoded) do - Logger.warning("Cannot decode the blob #{blob_hash} taken from the Blockscout Blobs API.") - - inputs_acc - else - Logger.info( - "The input for transaction #{transaction_hash} is taken from the Blockscout Blobs API. Blob hash: #{blob_hash}" - ) - - input = %{ - bytes: decoded, - eip4844_blob_hash: blob_hash - } - - [input | inputs_acc] - end - else - _ -> - # read the data from the fallback source (beacon node) - eip4844_blobs_to_inputs_from_fallback( - transaction_hash, - blob_hash, - block_timestamp, - inputs_acc, - chain_id_l1 - ) - end + process_blob(blob_hash, transaction_hash, block_timestamp, inputs_acc, blobs_api_url, chain_id_l1) end) |> Enum.reverse() end + defp process_blob(blob_hash, transaction_hash, block_timestamp, inputs_acc, blobs_api_url, chain_id_l1) do + with {:ok, response} <- Helper.http_get_request(blobs_api_url <> "/" <> blob_hash), + blob_data = Map.get(response, "blob_data"), + false <- is_nil(blob_data) do + decode_and_process_blob(blob_data, blob_hash, transaction_hash, inputs_acc) + else + _ -> + # read the data from the fallback source (beacon node) + eip4844_blobs_to_inputs_from_fallback( + transaction_hash, + blob_hash, + block_timestamp, + inputs_acc, + chain_id_l1 + ) + end + end + + defp decode_and_process_blob(blob_data, blob_hash, transaction_hash, inputs_acc) do + # read the data from Blockscout API + decoded = + blob_data + |> hash_to_binary() + |> OptimismTransactionBatch.decode_eip4844_blob() + + if is_nil(decoded) do + Logger.warning("Cannot decode the blob #{blob_hash} taken from the Blockscout Blobs API.") + inputs_acc + else + Logger.info( + "The input for transaction #{transaction_hash} is taken from the Blockscout Blobs API. Blob hash: #{blob_hash}" + ) + + input = %{ + bytes: decoded, + eip4844_blob_hash: blob_hash + } + + [input | inputs_acc] + end + end + defp eip4844_blobs_to_inputs_from_fallback( transaction_hash, blob_hash, @@ -649,24 +666,44 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do end end - defp celestia_blob_to_input("0x" <> transaction_input, transaction_hash, blobs_api_url) do + # Gets Celestia blob data & metadata by L1 transaction input (encoding blob's height and commitment). + # The data is read from the remote `da-indexer` service. + # + # ## Parameters + # - `transaction_input`: The contents of transaction `input` field. + # - `offset`: Offset (in bytes) within the `transaction_input` where blob's height is encoded. + # - `transaction_hash`: The L1 transaction hash for logging purposes. + # - `blobs_api_url`: The URL to `da-indexer` API. + # + # ## Returns + # - A list with a map containing blob's data and metadata. + # - An empty list in case of an error (`da-indexer` didn't respond, URL is not defined, or transaction input is incorrect). + @spec celestia_blob_to_input(binary(), non_neg_integer(), String.t(), String.t()) :: [ + %{ + :bytes => binary(), + :celestia_blob_metadata => %{ + :height => non_neg_integer(), + :namespace => String.t(), + :commitment => String.t() + } + } + ] + defp celestia_blob_to_input("0x" <> transaction_input, offset, transaction_hash, blobs_api_url) do transaction_input |> Base.decode16!(case: :mixed) - |> celestia_blob_to_input(transaction_hash, blobs_api_url) + |> celestia_blob_to_input(offset, transaction_hash, blobs_api_url) end - defp celestia_blob_to_input(transaction_input, _transaction_hash, blobs_api_url) - when byte_size(transaction_input) == 1 + 8 + 32 and blobs_api_url != "" do - # the first byte encodes Celestia sign 0xCE - - # the next 8 bytes encode little-endian Celestia blob height + defp celestia_blob_to_input(transaction_input, offset, _transaction_hash, blobs_api_url) + when byte_size(transaction_input) == offset + 8 + 32 and blobs_api_url != "" do + # 8 bytes after alt-da signature encode little-endian Celestia blob height height = transaction_input - |> binary_part(1, 8) + |> binary_part(offset, 8) |> :binary.decode_unsigned(:little) # the next 32 bytes contain the commitment - commitment = binary_part(transaction_input, 1 + 8, 32) + commitment = binary_part(transaction_input, offset + 8, 32) commitment_string = Base.encode16(commitment, case: :lower) url = blobs_api_url <> "?height=#{height}&commitment=" <> commitment_string @@ -699,13 +736,13 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do end end - defp celestia_blob_to_input(_transaction_input, transaction_hash, blobs_api_url) when blobs_api_url != "" do + defp celestia_blob_to_input(_transaction_input, _offset, transaction_hash, blobs_api_url) when blobs_api_url != "" do Logger.error("L1 transaction with Celestia commitment has incorrect input length. Tx hash: #{transaction_hash}") [] end - defp celestia_blob_to_input(_transaction_input, _transaction_hash, "") do + defp celestia_blob_to_input(_transaction_input, _offset, _transaction_hash, "") do Logger.error( "Cannot read Celestia blobs from the server as the API URL is not defined. Please, check INDEXER_OPTIMISM_L1_BATCH_CELESTIA_BLOBS_API_URL env variable." ) @@ -713,13 +750,142 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do [] end + # Gets EigenDA blob data & metadata by L1 transaction input (encoding blob's certificate). + # The data is read from the remote `da-indexer` service. + # + # ## Parameters + # - `transaction_input`: The contents of transaction `input` field. + # - `offset`: Offset (in bytes) within the `transaction_input` where blob's certificate is encoded. + # - `transaction_hash`: The L1 transaction hash for logging purposes. + # - `blobs_api_url`: The URL to `da-indexer` API. + # + # ## Returns + # - A list with a map containing blob's data and certificate. + # - An empty list in case of an error (`da-indexer` didn't respond, URL is not defined, or transaction input is incorrect). + @spec eigenda_blob_to_input(binary(), non_neg_integer(), String.t(), String.t()) :: [ + %{:bytes => binary(), :eigenda_cert => binary()} + ] + defp eigenda_blob_to_input("0x" <> transaction_input, offset, transaction_hash, blobs_api_url) do + transaction_input + |> Base.decode16!(case: :mixed) + |> eigenda_blob_to_input(offset, transaction_hash, blobs_api_url) + end + + defp eigenda_blob_to_input(transaction_input, offset, transaction_hash, blobs_api_url) when blobs_api_url != "" do + <<_::binary-size(offset), cert::binary>> = transaction_input + + url = blobs_api_url <> "/0x" <> Base.encode16(cert, case: :lower) + + url_with_proxy = + case Application.get_all_env(:indexer)[__MODULE__][:eigenda_proxy_base_url] do + nil -> + url + + eigenda_proxy_base_url -> + url <> "?proxyBaseUrl=" <> Helper.trim_url(eigenda_proxy_base_url) + end + + with {:ok, response} <- Helper.http_get_request(url_with_proxy), + data = Map.get(response, "data"), + true <- !is_nil(data), + data_binary = hash_to_binary(data), + true <- byte_size(data_binary) > 0 do + [ + %{ + bytes: data_binary, + eigenda_cert: cert + } + ] + else + false -> + Logger.error("EigenDA Proxy server response is empty for the request #{url_with_proxy}") + + [] + + _ -> + Logger.warning("Transaction hash: #{transaction_hash}") + [] + end + end + + defp eigenda_blob_to_input(_transaction_input, _offset, _transaction_hash, "") do + Logger.error( + "Cannot read EigenDA blobs from the server as the API URL is not defined. Please, check INDEXER_OPTIMISM_L1_BATCH_EIGENDA_BLOBS_API_URL env variable." + ) + + [] + end + + # Gets Alt-DA data & commitment hash by L1 transaction input (encoding the commitment). + # The data is then read from the remote DA server. + # + # ## Parameters + # - `transaction_input`: The contents of transaction `input` field. + # - `transaction_hash`: The L1 transaction hash for logging purposes. + # - `da_server_url`: The URL to DA server implementing a get request. + # + # ## Returns + # - A list with a map containing commitment data and hash. + # - An empty list in case of an error (DA server didn't respond, URL is not defined, or transaction input is incorrect). + @spec alt_da_commitment_to_input(binary(), String.t(), String.t()) :: [ + %{ + :bytes => binary(), + :alt_da_commitment => String.t() + } + ] + defp alt_da_commitment_to_input("0x" <> transaction_input, transaction_hash, da_server_url) do + transaction_input + |> Base.decode16!(case: :mixed) + |> alt_da_commitment_to_input(transaction_hash, da_server_url) + end + + defp alt_da_commitment_to_input(transaction_input, _transaction_hash, da_server_url) + when byte_size(transaction_input) == 2 + 32 and da_server_url != "" do + commitment_for_url = binary_part(transaction_input, 1, 1 + 32) + commitment_for_url_string = "0x" <> Base.encode16(commitment_for_url, case: :lower) + + url = da_server_url <> "/" <> commitment_for_url_string + + with {:ok, data} <- Helper.http_get_request(url, :raw), + true <- byte_size(data) > 0 do + [ + %{ + bytes: data, + alt_da_commitment: "0x" <> Base.encode16(transaction_input, case: :lower) + } + ] + else + false -> + Logger.error("DA server response is empty for the request #{url}") + + [] + + _ -> + Logger.error("Cannot read a response from DA server for the request #{url}") + [] + end + end + + defp alt_da_commitment_to_input(_transaction_input, transaction_hash, da_server_url) when da_server_url != "" do + Logger.error("L1 transaction with Alt-DA commitment has incorrect input length. Tx hash: #{transaction_hash}") + [] + end + + defp alt_da_commitment_to_input(_transaction_input, _transaction_hash, "") do + Logger.error( + "Cannot read data from the DA server as its URL is not defined. Please, check INDEXER_OPTIMISM_L1_BATCH_ALT_DA_SERVER_URL env variable." + ) + + [] + end + defp get_transaction_batches_inner( transactions_filtered, blocks_params, {genesis_block_l2, block_duration}, incomplete_channels, json_rpc_named_arguments_l2, - {eip4844_blobs_api_url, celestia_blobs_api_url, chain_id_l1} + {eip4844_blobs_api_url, celestia_blobs_api_url, eigenda_blobs_api_url, alt_da_server_url, chain_id_l1} ) do transactions_filtered |> Enum.reduce({:ok, incomplete_channels, [], [], []}, fn transaction, @@ -728,7 +894,7 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do inputs = cond do transaction.type == 3 -> - # this is EIP-4844 transaction, so we get the inputs from the blobs + # this is EIP-4844 transaction, so we get the inputs from EIP-4844 blobs block_timestamp = get_block_timestamp_by_number(transaction.block_number, blocks_params) eip4844_blobs_to_inputs( @@ -739,9 +905,21 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do chain_id_l1 ) - first_byte(transaction.input) == 0xCE -> + commitment_alt_da_signature(transaction.input) == 0x01010C -> # this is Celestia DA transaction, so we get the data from Celestia blob - celestia_blob_to_input(transaction.input, transaction.hash, celestia_blobs_api_url) + celestia_blob_to_input(transaction.input, 3, transaction.hash, celestia_blobs_api_url) + + commitment_alt_da_signature(transaction.input) == 0x010100 -> + # this is Eigen DA transaction, so we get the data from EigenDA blob + eigenda_blob_to_input(transaction.input, 3, transaction.hash, eigenda_blobs_api_url) + + commitment_alt_da_signature(transaction.input) == 0x0100 -> + # this is Alt-DA transaction with a keccak commitment, so we get the data from a DA server + alt_da_commitment_to_input(transaction.input, transaction.hash, alt_da_server_url) + + first_byte(transaction.input) == 0xCE -> + # backward compatibility with OP Celestia Raspberry + celestia_blob_to_input(transaction.input, 1, transaction.hash, celestia_blobs_api_url) true -> # this is calldata transaction, so the data is in the transaction input @@ -794,7 +972,9 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do block_timestamp: block_timestamp, transaction_hash: transaction.hash, eip4844_blob_hash: Map.get(input, :eip4844_blob_hash), - celestia_blob_metadata: Map.get(input, :celestia_blob_metadata) + celestia_blob_metadata: Map.get(input, :celestia_blob_metadata), + eigenda_cert: Map.get(input, :eigenda_cert), + alt_da_commitment: Map.get(input, :alt_da_commitment) }) l1_timestamp = @@ -891,6 +1071,36 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do } ] + !is_nil(Map.get(frame, :eigenda_cert)) -> + # credo:disable-for-next-line /Credo.Check.Refactor.AppendSingleItem/ + new_blobs_acc ++ + [ + %{ + id: next_blob_id, + key: :crypto.hash(:sha256, frame.eigenda_cert), + type: :eigenda, + metadata: %{cert: "0x" <> Base.encode16(frame.eigenda_cert, case: :lower)}, + l1_transaction_hash: frame.transaction_hash, + l1_timestamp: frame.block_timestamp, + frame_sequence_id: frame_sequence_id + } + ] + + !is_nil(Map.get(frame, :alt_da_commitment)) -> + # credo:disable-for-next-line /Credo.Check.Refactor.AppendSingleItem/ + new_blobs_acc ++ + [ + %{ + id: next_blob_id, + key: Base.decode16!(String.trim_leading(frame.alt_da_commitment, "0x"), case: :mixed), + type: :alt_da, + metadata: %{commitment: frame.alt_da_commitment}, + l1_transaction_hash: frame.transaction_hash, + l1_timestamp: frame.block_timestamp, + frame_sequence_id: frame_sequence_id + } + ] + true -> new_blobs_acc end @@ -1317,6 +1527,8 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do # can still reference to the `op_frame_sequences` table _ -> nil end + + ids end defp set_frame_sequences_view_ready(sequences) do @@ -1422,14 +1634,38 @@ defmodule Indexer.Fetcher.Optimism.TransactionBatch do end end + # Retrieves Alt-DA signature from L1 transaction input as described in + # https://github.com/ethereum-optimism/specs/blob/main/specs/experimental/alt-da.md#example-commitments + # + # ## Parameters + # - `transaction_input`: The contents of transaction `input` field. + # + # ## Returns + # - An integer encoding the signature. + # - `nil` if the input doesn't contain Alt-DA signature. + @spec commitment_alt_da_signature(binary()) :: non_neg_integer() | nil + defp commitment_alt_da_signature("0x" <> transaction_input) do + transaction_input + |> Base.decode16!(case: :mixed) + |> commitment_alt_da_signature() + end + + defp commitment_alt_da_signature(<<0, _rest::binary>>), do: 0x00 + defp commitment_alt_da_signature(<<1, 0, _rest::binary>>), do: 0x0100 + + defp commitment_alt_da_signature(<<1, 1, da_layer_byte::size(8), _rest::binary>>), + do: :binary.decode_unsigned(<<0x01, 0x01, da_layer_byte>>) + + defp commitment_alt_da_signature(_), do: nil + defp first_byte("0x" <> transaction_input) do transaction_input |> Base.decode16!(case: :mixed) |> first_byte() end - defp first_byte(<>) do - version_byte + defp first_byte(<>) do + first_byte end defp first_byte(_transaction_input) do diff --git a/apps/indexer/lib/indexer/fetcher/optimism/withdrawal.ex b/apps/indexer/lib/indexer/fetcher/optimism/withdrawal.ex index 25b19eb8b4a6..e8d9b46c4eb6 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/withdrawal.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/withdrawal.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.Withdrawal do @moduledoc """ Fills op_withdrawals DB table. @@ -14,7 +15,8 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do import Explorer.Helper, only: [decode_data: 2, parse_integer: 1] alias Explorer.{Chain, Repo} - alias Explorer.Chain.Log + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.{Data, Hash} alias Explorer.Chain.Optimism.Withdrawal, as: OptimismWithdrawal alias Indexer.Fetcher.Optimism alias Indexer.Helper @@ -22,9 +24,6 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do @fetcher_name :optimism_withdrawals @counter_type "optimism_withdrawals_fetcher_last_l2_block_hash" - # 32-byte signature of the event MessagePassed(uint256 indexed nonce, address indexed sender, address indexed target, uint256 value, uint256 gasLimit, bytes data, bytes32 withdrawalHash) - @message_passed_event "0x02a52367d10742d8032712c1bb8e0144ff1ec5ffda1ed7d70bb05a2744955054" - def child_spec(start_link_arguments) do spec = %{ id: __MODULE__, @@ -42,11 +41,27 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do @impl GenServer def init(args) do - json_rpc_named_arguments = args[:json_rpc_named_arguments] - {:ok, %{}, {:continue, json_rpc_named_arguments}} + {:ok, %{}, {:continue, args[:json_rpc_named_arguments]}} end + # Initialization function which is used instead of `init` to avoid Supervisor's stop in case of any critical issues + # during initialization. It checks the values of env variables, gets last L2 block number to start the scanning from, + # and calculates an average block check interval (for realtime part of the logic). + # + # When the initialization succeeds, the `:continue` message is sent to GenServer to start the catchup loop + # retrieving and saving the withdrawal events. + # + # ## Parameters + # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection to L2 RPC node. + # - `state`: Initial state of the fetcher (empty map when starting). + # + # ## Returns + # - `{:noreply, state}` when the initialization is successful and the fetching can start. The `state` contains + # necessary parameters needed for the fetching. + # - `{:stop, :normal, %{}}` in case of error. @impl GenServer + @spec handle_continue(EthereumJSONRPC.json_rpc_named_arguments(), map()) :: + {:noreply, map()} | {:stop, :normal, map()} def handle_continue(json_rpc_named_arguments, state) do Logger.metadata(fetcher: @fetcher_name) @@ -73,7 +88,6 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do {:noreply, %{ start_block: max(start_block_l2, last_l2_block_number), - start_block_l2: start_block_l2, safe_block: safe_block, safe_block_is_latest: safe_block_is_latest, message_passer: env[:message_passer], @@ -112,24 +126,18 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do end end + # Performs the catchup handling loop for the specified block range. The block range is split into chunks. + # Max size of a chunk is defined by INDEXER_OPTIMISM_L2_ETH_GET_LOGS_RANGE_SIZE env variable. + # + # ## Parameters + # - `:continue`: The GenServer message. + # - `state`: The current state of the fetcher containing block range, max chunk size, etc. + # + # ## Returns + # - `{:stop, :normal, state}` tuple. @impl GenServer def handle_info( :continue, - %{ - start_block_l2: start_block_l2, - message_passer: message_passer, - json_rpc_named_arguments: json_rpc_named_arguments, - eth_get_logs_range_size: eth_get_logs_range_size - } = state - ) do - fill_msg_nonce_gaps(start_block_l2, message_passer, json_rpc_named_arguments, eth_get_logs_range_size) - Process.send(self(), :find_new_events, []) - {:noreply, state} - end - - @impl GenServer - def handle_info( - :find_new_events, %{ start_block: start_block, safe_block: safe_block, @@ -146,7 +154,14 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do if not safe_block_is_latest do # find and fill all events between "safe" and "latest" block (excluding "safe") {:ok, latest_block} = Helper.get_block_number_by_tag("latest", json_rpc_named_arguments) - fill_block_range(safe_block + 1, latest_block, message_passer, json_rpc_named_arguments, eth_get_logs_range_size) + + fill_block_range( + safe_block + 1, + latest_block, + message_passer, + json_rpc_named_arguments, + eth_get_logs_range_size + ) end {:stop, :normal, state} @@ -158,10 +173,44 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do {:noreply, state} end + @doc """ + Removes all withdrawals created starting from the given block. + + ## Parameters + - `starting_block`: The starting block number. + + ## Returns + - Nothing. + """ + @spec remove(non_neg_integer()) :: any() def remove(starting_block) do Repo.delete_all(from(w in OptimismWithdrawal, where: w.l2_block_number >= ^starting_block)) + LastFetchedCounter.delete(@counter_type) end + @doc """ + Prepares a Withdrawal map to write into database. + + ## Parameters + - `second_topic`: The second topic of the MessagePassed event containing the withdrawal message nonce. + - `data`: The data of the MessagePassed event. The data encodes the withdrawal value, gas limit, data, and hash. + - `l2_transaction_hash`: The hash of the transaction containing the MessagePassed event. + - `l2_block_number`: The number of the block containing the MessagePassed event. + + ## Returns + - The Withdrawal map ready to be imported to the database. + """ + @spec event_to_withdrawal( + Hash.t() | String.t(), + Data.t() | String.t(), + Hash.t() | String.t(), + non_neg_integer() | String.t() + ) :: %{ + :msg_nonce => Decimal.t(), + :hash => String.t(), + :l2_transaction_hash => Hash.t() | String.t(), + :l2_block_number => non_neg_integer() + } def event_to_withdrawal(second_topic, data, l2_transaction_hash, l2_block_number) do [_value, _gas_limit, _data, hash] = decode_data(data, [{:uint, 256}, {:uint, 256}, :bytes, {:bytes, 32}]) @@ -179,81 +228,46 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do } end - defp msg_nonce_gap_starts(nonce_max) do - Repo.all( - from(w in OptimismWithdrawal, - select: w.l2_block_number, - order_by: w.msg_nonce, - where: - fragment( - "NOT EXISTS (SELECT msg_nonce FROM op_withdrawals WHERE msg_nonce = (? + 1)) AND msg_nonce != ?", - w.msg_nonce, - ^nonce_max - ) - ), - timeout: :infinity - ) - end - - defp msg_nonce_gap_ends(nonce_min) do - Repo.all( - from(w in OptimismWithdrawal, - select: w.l2_block_number, - order_by: w.msg_nonce, - where: - fragment( - "NOT EXISTS (SELECT msg_nonce FROM op_withdrawals WHERE msg_nonce = (? - 1)) AND msg_nonce != ?", - w.msg_nonce, - ^nonce_min - ) - ), - timeout: :infinity - ) - end + # Scans the given block range for the MessagePassed events using RPC node and + # records the found withdrawal messages into the database. + # + # ## Parameters + # - `message_passer`: The L2 address of the L2ToL1MessagePasser contract to scan the events of. + # - `block_start`: The start block of the block range. + # - `block_end`: The end block of the block range. + # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection to L2 RPC node. + # + # ## Returns + # - The number of withdrawals found in the given block range. + @spec find_and_save_withdrawals( + binary(), + non_neg_integer(), + non_neg_integer(), + EthereumJSONRPC.json_rpc_named_arguments() + ) :: non_neg_integer() + defp find_and_save_withdrawals(message_passer, block_start, block_end, json_rpc_named_arguments) do + message_passed_event = OptimismWithdrawal.message_passed_event() + + {:ok, result} = + Helper.get_logs( + block_start, + block_end, + message_passer, + [message_passed_event], + json_rpc_named_arguments, + 0, + Helper.infinite_retries_number() + ) - defp find_and_save_withdrawals( - scan_db, - message_passer, - block_start, - block_end, - json_rpc_named_arguments - ) do withdrawals = - if scan_db do - query = - from(log in Log, - select: {log.second_topic, log.data, log.transaction_hash, log.block_number}, - where: - log.first_topic == ^@message_passed_event and log.address_hash == ^message_passer and - log.block_number >= ^block_start and log.block_number <= ^block_end - ) - - query - |> Repo.all(timeout: :infinity) - |> Enum.map(fn {second_topic, data, l2_transaction_hash, l2_block_number} -> - event_to_withdrawal(second_topic, data, l2_transaction_hash, l2_block_number) - end) - else - {:ok, result} = - Helper.get_logs( - block_start, - block_end, - message_passer, - [@message_passed_event], - json_rpc_named_arguments, - 0, - 3 - ) - - Enum.map(result, fn event -> - event_to_withdrawal( - Enum.at(event["topics"], 1), - event["data"], - event["transactionHash"], - event["blockNumber"] - ) - end) - end + Enum.map(result, fn event -> + event_to_withdrawal( + Enum.at(event["topics"], 1), + event["data"], + event["transactionHash"], + event["blockNumber"] + ) + end) {:ok, _} = Chain.import(%{ @@ -264,38 +278,53 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do Enum.count(withdrawals) end + # Scans the given block range for the MessagePassed events using RPC node and + # records the found withdrawal messages into the database. The process prints the progress logs. + # The block range is split into chunks that have a size limited by the given parameter. + # + # ## Parameters + # - `l2_block_start`: The start block of the block range. + # - `l2_block_end`: The end block of the block range. + # - `message_passer`: The L2 address of the L2ToL1MessagePasser contract to scan the events of. + # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection to L2 RPC node. + # - `eth_get_logs_range_size`: Max size of the block chunk. + # + # ## Returns + # - Nothing. + @spec fill_block_range( + non_neg_integer(), + non_neg_integer(), + binary(), + EthereumJSONRPC.json_rpc_named_arguments(), + non_neg_integer() + ) :: any() + defp fill_block_range( + l2_block_start, + l2_block_end, + _message_passer, + _json_rpc_named_arguments, + _eth_get_logs_range_size + ) + when l2_block_start > l2_block_end, do: nil + defp fill_block_range( l2_block_start, l2_block_end, message_passer, json_rpc_named_arguments, - eth_get_logs_range_size, - scan_db + eth_get_logs_range_size ) do - chunks_number = - if scan_db do - 1 - else - ceil((l2_block_end - l2_block_start + 1) / eth_get_logs_range_size) - end - + chunks_number = ceil((l2_block_end - l2_block_start + 1) / eth_get_logs_range_size) chunk_range = Range.new(0, max(chunks_number - 1, 0), 1) - Enum.reduce(chunk_range, 0, fn current_chunk, withdrawals_count_acc -> + Enum.each(chunk_range, fn current_chunk -> chunk_start = l2_block_start + eth_get_logs_range_size * current_chunk - - chunk_end = - if scan_db do - l2_block_end - else - min(chunk_start + eth_get_logs_range_size - 1, l2_block_end) - end + chunk_end = min(chunk_start + eth_get_logs_range_size - 1, l2_block_end) Helper.log_blocks_chunk_handling(chunk_start, chunk_end, l2_block_start, l2_block_end, nil, :L2) withdrawals_count = find_and_save_withdrawals( - scan_db, message_passer, chunk_start, chunk_end, @@ -311,73 +340,10 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do :L2 ) - withdrawals_count_acc + withdrawals_count + Optimism.set_last_block_hash_by_number(chunk_end, @counter_type, json_rpc_named_arguments) end) end - defp fill_block_range(start_block, end_block, message_passer, json_rpc_named_arguments, eth_get_logs_range_size) do - if start_block <= end_block do - fill_block_range(start_block, end_block, message_passer, json_rpc_named_arguments, eth_get_logs_range_size, true) - fill_msg_nonce_gaps(start_block, message_passer, json_rpc_named_arguments, eth_get_logs_range_size, false) - {last_l2_block_number, _, _} = get_last_l2_item() - - fill_block_range( - max(start_block, last_l2_block_number), - end_block, - message_passer, - json_rpc_named_arguments, - eth_get_logs_range_size, - false - ) - - Optimism.set_last_block_hash_by_number(end_block, @counter_type, json_rpc_named_arguments) - end - end - - defp fill_msg_nonce_gaps( - start_block_l2, - message_passer, - json_rpc_named_arguments, - eth_get_logs_range_size, - scan_db \\ true - ) do - nonce_min = Repo.aggregate(OptimismWithdrawal, :min, :msg_nonce) - nonce_max = Repo.aggregate(OptimismWithdrawal, :max, :msg_nonce) - - with true <- !is_nil(nonce_min) and !is_nil(nonce_max), - starts = msg_nonce_gap_starts(nonce_max), - ends = msg_nonce_gap_ends(nonce_min), - min_block_l2 = l2_block_number_by_msg_nonce(nonce_min), - {new_starts, new_ends} = - if(start_block_l2 < min_block_l2, - do: {[start_block_l2 | starts], [min_block_l2 | ends]}, - else: {starts, ends} - ), - true <- Enum.count(new_starts) == Enum.count(new_ends) do - new_starts - |> Enum.zip(new_ends) - |> Enum.each(fn {l2_block_start, l2_block_end} -> - withdrawals_count = - fill_block_range( - l2_block_start, - l2_block_end, - message_passer, - json_rpc_named_arguments, - eth_get_logs_range_size, - scan_db - ) - - if withdrawals_count > 0 do - log_fill_msg_nonce_gaps(scan_db, l2_block_start, l2_block_end, withdrawals_count) - end - end) - - if scan_db do - fill_msg_nonce_gaps(start_block_l2, message_passer, json_rpc_named_arguments, eth_get_logs_range_size, false) - end - end - end - # Determines the last saved L2 block number, the last saved transaction hash, and the transaction info for withdrawals. # # Utilized to start fetching from a correct block number after reorg has occurred. @@ -385,17 +351,16 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do # ## Parameters # - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection. # Used to get transaction info by its hash from the RPC node. - # Can be `nil` if the transaction info is not needed. # # ## Returns # - A tuple `{last_block_number, last_transaction_hash, last_transaction}` where # `last_block_number` is the last block number found in the corresponding table (0 if not found), # `last_transaction_hash` is the last transaction hash found in the corresponding table (nil if not found), - # `last_transaction` is the transaction info got from the RPC (nil if not found or not needed). + # `last_transaction` is the transaction info got from the RPC (nil if not found). # - A tuple `{:error, message}` in case the `eth_getTransactionByHash` RPC request failed. - @spec get_last_l2_item(EthereumJSONRPC.json_rpc_named_arguments() | nil) :: + @spec get_last_l2_item(EthereumJSONRPC.json_rpc_named_arguments()) :: {non_neg_integer(), binary() | nil, map() | nil} | {:error, any()} - defp get_last_l2_item(json_rpc_named_arguments \\ nil) do + defp get_last_l2_item(json_rpc_named_arguments) do Optimism.get_last_item( :L2, &OptimismWithdrawal.last_withdrawal_l2_block_number_query/0, @@ -404,16 +369,4 @@ defmodule Indexer.Fetcher.Optimism.Withdrawal do @counter_type ) end - - defp log_fill_msg_nonce_gaps(scan_db, l2_block_start, l2_block_end, withdrawals_count) do - find_place = if scan_db, do: "in DB", else: "through RPC" - - Logger.info( - "Filled gaps between L2 blocks #{l2_block_start} and #{l2_block_end}. #{withdrawals_count} event(s) were found #{find_place} and written to op_withdrawals table." - ) - end - - defp l2_block_number_by_msg_nonce(nonce) do - Repo.one(from(w in OptimismWithdrawal, select: w.l2_block_number, where: w.msg_nonce == ^nonce)) - end end diff --git a/apps/indexer/lib/indexer/fetcher/optimism/withdrawal_event.ex b/apps/indexer/lib/indexer/fetcher/optimism/withdrawal_event.ex index 4f26648d6daa..40b037b54413 100644 --- a/apps/indexer/lib/indexer/fetcher/optimism/withdrawal_event.ex +++ b/apps/indexer/lib/indexer/fetcher/optimism/withdrawal_event.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do @moduledoc """ Fills op_withdrawal_events DB table. @@ -10,15 +11,13 @@ defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do import Ecto.Query - import EthereumJSONRPC, only: [id_to_params: 1, json_rpc: 2, quantity_to_integer: 1] + import EthereumJSONRPC, only: [quantity_to_integer: 1] - alias EthereumJSONRPC.Block.ByNumber - alias EthereumJSONRPC.Blocks alias Explorer.{Chain, Repo} alias Explorer.Chain.Optimism.WithdrawalEvent - alias Explorer.Chain.RollupReorgMonitorQueue alias Indexer.Fetcher.Optimism alias Indexer.Helper + alias Indexer.RollupReorgMonitorQueue @fetcher_name :optimism_withdrawal_events @counter_type "optimism_withdrawal_events_fetcher_last_l1_block_hash" @@ -126,7 +125,7 @@ defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do ) end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) + reorg_block = RollupReorgMonitorQueue.pop(__MODULE__) if !is_nil(reorg_block) && reorg_block > 0 do {deleted_count, _} = Repo.delete_all(from(we in WithdrawalEvent, where: we.l1_block_number >= ^reorg_block)) @@ -191,10 +190,19 @@ defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do end) end + # Prepares withdrawal events from `eth_getLogs` response to be imported to DB. + # + # ## Parameters + # - `events`: The list of L1 withdrawal events from `eth_getLogs` response. + # - `json_rpc_named_arguments`: JSON-RPC configuration containing transport options for L1. + # + # ## Returns + # - A list of `WithdrawalEvent` maps. + @spec prepare_events([map()], EthereumJSONRPC.json_rpc_named_arguments()) :: [WithdrawalEvent.to_import()] defp prepare_events(events, json_rpc_named_arguments) do blocks = events - |> get_blocks_by_events(json_rpc_named_arguments, Helper.infinite_retries_number()) + |> Helper.get_blocks_by_events(json_rpc_named_arguments, Helper.infinite_retries_number(), true) transaction_hashes = events @@ -220,16 +228,16 @@ defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do |> Enum.map(fn event -> transaction_hash = event["transactionHash"] - {l1_event_type, game_index} = + {l1_event_type, game_index, game_address_hash} = if Enum.member?([@withdrawal_proven_event, @withdrawal_proven_event_blast], Enum.at(event["topics"], 0)) do - game_index = + {game_index, game_address_hash} = input_by_hash |> Map.get(transaction_hash) - |> input_to_game_index() + |> input_to_game_index_or_address_hash() - {"WithdrawalProven", game_index} + {:WithdrawalProven, game_index, game_address_hash} else - {"WithdrawalFinalized", nil} + {:WithdrawalFinalized, nil, nil} end l1_block_number = quantity_to_integer(event["blockNumber"]) @@ -240,20 +248,12 @@ defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do l1_timestamp: Map.get(timestamps, l1_block_number), l1_transaction_hash: transaction_hash, l1_block_number: l1_block_number, - game_index: game_index + game_index: game_index, + game_address_hash: game_address_hash } end) - |> Enum.reduce(%{}, fn e, acc -> - key = {e.withdrawal_hash, e.l1_event_type} - prev_game_index = Map.get(acc, key, %{game_index: 0}).game_index - - if prev_game_index < e.game_index or is_nil(prev_game_index) do - Map.put(acc, key, e) - else - acc - end - end) - |> Map.values() + |> MapSet.new() + |> MapSet.to_list() end @doc """ @@ -305,44 +305,68 @@ defmodule Indexer.Fetcher.Optimism.WithdrawalEvent do Optimism.requires_l1_reorg_monitor?() end - defp get_blocks_by_events(events, json_rpc_named_arguments, retries) do - request = - events - |> Enum.reduce(%{}, fn event, acc -> - Map.put(acc, event["blockNumber"], 0) - end) - |> Stream.map(fn {block_number, _} -> %{number: block_number} end) - |> id_to_params() - |> Blocks.requests(&ByNumber.request(&1, true, false)) - - error_message = &"Cannot fetch blocks with batch request. Error: #{inspect(&1)}. Request: #{inspect(request)}" - - case Helper.repeated_call(&json_rpc/2, [request, json_rpc_named_arguments], error_message, retries) do - {:ok, results} -> Enum.map(results, fn %{result: result} -> result end) - {:error, _} -> [] - end - end - - defp input_to_game_index(input) do + # Parses input of the prove L1 transaction and retrieves dispute game index or contract address hash + # (depending on whether Super Roots are active) from that. + # + # ## Parameters + # - `input`: The L1 transaction input in form of `0x` string. + # + # ## Returns + # - `{game_index, game_address_hash}` tuple where one of the elements is not `nil`, but another one is `nil` (and vice versa). + # Both elements can be `nil` if the input cannot be parsed (or has unsupported format). + @spec input_to_game_index_or_address_hash(String.t()) :: {non_neg_integer() | nil, String.t() | nil} + defp input_to_game_index_or_address_hash(input) do method_signature = String.slice(input, 0..9) - if method_signature == "0x4870496f" do - # the signature of `proveWithdrawalTransaction(tuple _transaction, uint256 _disputeGameIndex, tuple _outputRootProof, bytes[] _withdrawalProof)` method + case method_signature do + "0x4870496f" -> + # the signature of `proveWithdrawalTransaction(tuple _transaction, uint256 _disputeGameIndex, tuple _outputRootProof, bytes[] _withdrawalProof)` method + {game_index, ""} = + method_signature + |> slice_game_index_or_address_hash(input) + |> Integer.parse(16) - # to get (slice) `_disputeGameIndex` from the transaction input, we need to know its offset in the input string (represented as 0x...): - # offset = 10 symbols of signature (incl. `0x` prefix) + 64 symbols (representing 32 bytes) of the `_transaction` tuple offset, totally is 74 - game_index_offset = String.length(method_signature) + 32 * 2 - game_index_length = 32 * 2 + {game_index, nil} - game_index_range_start = game_index_offset - game_index_range_end = game_index_range_start + game_index_length - 1 + "0x8c90dd65" -> + # the signature of `proveWithdrawalTransaction(tuple _transaction, address _disputeGameProxy, uint256 _outputRootIndex, tuple _superRootProof, tuple _outputRootProof, bytes[] _withdrawalProof)` method + game_address_hash = + method_signature + |> slice_game_index_or_address_hash(input) + |> String.trim_leading("000000000000000000000000") + |> String.pad_leading(42, "0x") - {game_index, ""} = - input - |> String.slice(game_index_range_start..game_index_range_end) - |> Integer.parse(16) + {nil, game_address_hash} - game_index + _ -> + {nil, nil} end end + + # Gets (slices) the dispute game index or its address hash from the transaction input represented as `0x` string. + # + # The input is calldata for either + # `proveWithdrawalTransaction(tuple _transaction, uint256 _disputeGameIndex, tuple _outputRootProof, bytes[] _withdrawalProof)` + # or + # `proveWithdrawalTransaction(tuple _transaction, address _disputeGameProxy, uint256 _outputRootIndex, tuple _superRootProof, tuple _outputRootProof, bytes[] _withdrawalProof)` + # method. + # + # ## Parameters + # - `method_signature`: The method signature string (including `0x` prefix). + # - `input`: The input string (including `0x` prefix). + # + # ## Returns + # - The slice of the input containing dispute game index or address hash. + @spec slice_game_index_or_address_hash(String.t(), String.t()) :: String.t() + defp slice_game_index_or_address_hash(method_signature, input) do + # to get (slice) the index or address from the transaction input, we need to know its offset in the input string (represented as 0x...): + # offset = signature_length (10 symbols including `0x`) + 64 symbols (representing 32 bytes) of the `_transaction` tuple offset, totally is 74 + offset = String.length(method_signature) + 32 * 2 + length = 32 * 2 + + range_start = offset + range_end = range_start + length - 1 + + String.slice(input, range_start..range_end) + end end diff --git a/apps/indexer/lib/indexer/fetcher/pending_block_operations_sanitizer.ex b/apps/indexer/lib/indexer/fetcher/pending_block_operations_sanitizer.ex index 9d77b55c21ee..911bfc679c4e 100644 --- a/apps/indexer/lib/indexer/fetcher/pending_block_operations_sanitizer.ex +++ b/apps/indexer/lib/indexer/fetcher/pending_block_operations_sanitizer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.PendingBlockOperationsSanitizer do @moduledoc """ Set block_number for pending block operations that have it empty @@ -7,8 +8,8 @@ defmodule Indexer.Fetcher.PendingBlockOperationsSanitizer do import Ecto.Query - alias Explorer.{Chain, Repo} - alias Explorer.Chain.PendingBlockOperation + alias Explorer.Chain.{PendingBlockOperation, Transaction} + alias Explorer.Repo alias Indexer.Fetcher.InternalTransaction @interval :timer.seconds(1) @@ -60,7 +61,7 @@ defmodule Indexer.Fetcher.PendingBlockOperationsSanitizer do |> update([pbo, po, b], set: [block_number: b.number]) |> Repo.update_all([], timeout: @timeout) - transactions = Chain.get_transactions_of_block_numbers(block_numbers) + transactions = Transaction.get_transactions_of_block_numbers(block_numbers) InternalTransaction.async_fetch(block_numbers, transactions, false) diff --git a/apps/indexer/lib/indexer/fetcher/pending_transaction.ex b/apps/indexer/lib/indexer/fetcher/pending_transaction.ex index 7ac5628efbec..95acce2db1dd 100644 --- a/apps/indexer/lib/indexer/fetcher/pending_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/pending_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.PendingTransaction do @moduledoc """ Fetches pending transactions and imports them. diff --git a/apps/indexer/lib/indexer/fetcher/polygon_edge.ex b/apps/indexer/lib/indexer/fetcher/polygon_edge.ex deleted file mode 100644 index 835cb56ced18..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_edge.ex +++ /dev/null @@ -1,621 +0,0 @@ -defmodule Indexer.Fetcher.PolygonEdge do - @moduledoc """ - Contains common functions for PolygonEdge.* fetchers. - """ - - # todo: this module is deprecated and should be removed - - use GenServer - use Indexer.Fetcher - - require Logger - - import Ecto.Query - - import EthereumJSONRPC, - only: [json_rpc: 2, integer_to_quantity: 1, request: 1] - - import Explorer.Helper, only: [parse_integer: 1] - - alias Explorer.{Chain, Repo} - alias Indexer.Helper - alias Indexer.Fetcher.PolygonEdge.{Deposit, DepositExecute, Withdrawal, WithdrawalExit} - - @fetcher_name :polygon_edge - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(_args) do - Logger.metadata(fetcher: @fetcher_name) - :ignore - end - - @spec init_l1( - Explorer.Chain.PolygonEdge.Deposit | Explorer.Chain.PolygonEdge.WithdrawalExit, - list(), - pid(), - binary(), - binary(), - binary(), - binary() - ) :: {:ok, map()} | :ignore - def init_l1(table, env, pid, contract_address, contract_name, table_name, entity_name) - when table in [Explorer.Chain.PolygonEdge.Deposit, Explorer.Chain.PolygonEdge.WithdrawalExit] do - with {:start_block_l1_undefined, false} <- {:start_block_l1_undefined, is_nil(env[:start_block_l1])}, - polygon_edge_l1_rpc = Application.get_all_env(:indexer)[Indexer.Fetcher.PolygonEdge][:polygon_edge_l1_rpc], - {:rpc_l1_undefined, false} <- {:rpc_l1_undefined, is_nil(polygon_edge_l1_rpc)}, - {:contract_is_valid, true} <- {:contract_is_valid, Helper.address_correct?(contract_address)}, - start_block_l1 = parse_integer(env[:start_block_l1]), - false <- is_nil(start_block_l1), - true <- start_block_l1 > 0, - {last_l1_block_number, last_l1_transaction_hash} <- get_last_l1_item(table), - {:start_block_l1_valid, true} <- - {:start_block_l1_valid, start_block_l1 <= last_l1_block_number || last_l1_block_number == 0}, - json_rpc_named_arguments = json_rpc_named_arguments(polygon_edge_l1_rpc), - {:ok, last_l1_transaction} <- - Helper.get_transaction_by_hash( - last_l1_transaction_hash, - json_rpc_named_arguments, - Helper.infinite_retries_number() - ), - {:l1_transaction_not_found, false} <- - {:l1_transaction_not_found, !is_nil(last_l1_transaction_hash) && is_nil(last_l1_transaction)}, - {:ok, block_check_interval, last_safe_block} <- - Helper.get_block_check_interval(json_rpc_named_arguments) do - start_block = max(start_block_l1, last_l1_block_number) - - Process.send(pid, :continue, []) - - {:ok, - %{ - contract_address: contract_address, - block_check_interval: block_check_interval, - start_block: start_block, - end_block: last_safe_block, - json_rpc_named_arguments: json_rpc_named_arguments - }} - else - {:start_block_l1_undefined, true} -> - # the process shouldn't start if the start block is not defined - :ignore - - {:rpc_l1_undefined, true} -> - Logger.error("L1 RPC URL is not defined.") - :ignore - - {:contract_is_valid, false} -> - Logger.error("#{contract_name} contract address is invalid or not defined.") - :ignore - - {:start_block_l1_valid, false} -> - Logger.error("Invalid L1 Start Block value. Please, check the value and #{table_name} table.") - - :ignore - - {:error, error_data} -> - Logger.error( - "Cannot get last L1 transaction from RPC by its hash, last safe block, or block timestamp by its number due to RPC error: #{inspect(error_data)}" - ) - - :ignore - - {:l1_transaction_not_found, true} -> - Logger.error( - "Cannot find last L1 transaction from RPC by its hash. Probably, there was a reorg on L1 chain. Please, check #{table_name} table." - ) - - :ignore - - _ -> - Logger.error("#{entity_name} L1 Start Block is invalid or zero.") - :ignore - end - end - - @spec init_l2( - Explorer.Chain.PolygonEdge.DepositExecute | Explorer.Chain.PolygonEdge.Withdrawal, - list(), - pid(), - binary(), - binary(), - binary(), - binary(), - list() - ) :: {:ok, map()} | :ignore - def init_l2(table, env, pid, contract_address, contract_name, table_name, entity_name, json_rpc_named_arguments) - when table in [Explorer.Chain.PolygonEdge.DepositExecute, Explorer.Chain.PolygonEdge.Withdrawal] do - with {:start_block_l2_undefined, false} <- {:start_block_l2_undefined, is_nil(env[:start_block_l2])}, - {:contract_address_valid, true} <- {:contract_address_valid, Helper.address_correct?(contract_address)}, - start_block_l2 = parse_integer(env[:start_block_l2]), - false <- is_nil(start_block_l2), - true <- start_block_l2 > 0, - {last_l2_block_number, last_l2_transaction_hash} <- get_last_l2_item(table), - {safe_block, safe_block_is_latest} = get_safe_block(json_rpc_named_arguments), - {:start_block_l2_valid, true} <- - {:start_block_l2_valid, - (start_block_l2 <= last_l2_block_number || last_l2_block_number == 0) && start_block_l2 <= safe_block}, - {:ok, last_l2_transaction} <- - Helper.get_transaction_by_hash( - last_l2_transaction_hash, - json_rpc_named_arguments, - Helper.infinite_retries_number() - ), - {:l2_transaction_not_found, false} <- - {:l2_transaction_not_found, !is_nil(last_l2_transaction_hash) && is_nil(last_l2_transaction)} do - Process.send(pid, :continue, []) - - {:ok, - %{ - start_block: max(start_block_l2, last_l2_block_number), - start_block_l2: start_block_l2, - safe_block: safe_block, - safe_block_is_latest: safe_block_is_latest, - contract_address: contract_address, - json_rpc_named_arguments: json_rpc_named_arguments - }} - else - {:start_block_l2_undefined, true} -> - # the process shouldn't start if the start block is not defined - :ignore - - {:contract_address_valid, false} -> - Logger.error("#{contract_name} contract address is invalid or not defined.") - :ignore - - {:start_block_l2_valid, false} -> - Logger.error("Invalid L2 Start Block value. Please, check the value and #{table_name} table.") - - :ignore - - {:error, error_data} -> - Logger.error("Cannot get last L2 transaction from RPC by its hash due to RPC error: #{inspect(error_data)}") - - :ignore - - {:l2_transaction_not_found, true} -> - Logger.error( - "Cannot find last L2 transaction from RPC by its hash. Probably, there was a reorg on L2 chain. Please, check #{table_name} table." - ) - - :ignore - - _ -> - Logger.error("#{entity_name} L2 Start Block is invalid or zero.") - :ignore - end - end - - @spec handle_continue(map(), binary(), Deposit | WithdrawalExit) :: {:noreply, map()} - def handle_continue( - %{ - contract_address: contract_address, - block_check_interval: block_check_interval, - start_block: start_block, - end_block: end_block, - json_rpc_named_arguments: json_rpc_named_arguments - } = state, - event_signature, - calling_module - ) - when calling_module in [Deposit, WithdrawalExit] do - time_before = Timex.now() - - eth_get_logs_range_size = - Application.get_all_env(:indexer)[Indexer.Fetcher.PolygonEdge][:polygon_edge_eth_get_logs_range_size] - - chunks_number = ceil((end_block - start_block + 1) / eth_get_logs_range_size) - chunk_range = Range.new(0, max(chunks_number - 1, 0), 1) - - last_written_block = - chunk_range - |> Enum.reduce_while(start_block - 1, fn current_chunk, _ -> - chunk_start = start_block + eth_get_logs_range_size * current_chunk - chunk_end = min(chunk_start + eth_get_logs_range_size - 1, end_block) - - if chunk_end >= chunk_start do - Helper.log_blocks_chunk_handling(chunk_start, chunk_end, start_block, end_block, nil, :L1) - - {:ok, result} = - get_logs( - chunk_start, - chunk_end, - contract_address, - event_signature, - json_rpc_named_arguments, - Helper.infinite_retries_number() - ) - - {events, event_name} = - result - |> calling_module.prepare_events(json_rpc_named_arguments) - |> import_events(calling_module) - - Helper.log_blocks_chunk_handling( - chunk_start, - chunk_end, - start_block, - end_block, - "#{Enum.count(events)} #{event_name} event(s)", - :L1 - ) - end - - {:cont, chunk_end} - end) - - new_start_block = last_written_block + 1 - - {:ok, new_end_block} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - - delay = - if new_end_block == last_written_block do - # there is no new block, so wait for some time to let the chain issue the new block - max(block_check_interval - Timex.diff(Timex.now(), time_before, :milliseconds), 0) - else - 0 - end - - Process.send_after(self(), :continue, delay) - - {:noreply, %{state | start_block: new_start_block, end_block: new_end_block}} - end - - @spec fill_block_range(integer(), integer(), DepositExecute | Withdrawal, binary(), list(), boolean()) :: integer() - def fill_block_range( - l2_block_start, - l2_block_end, - calling_module, - contract_address, - json_rpc_named_arguments, - scan_db - ) - when calling_module in [ - DepositExecute, - Withdrawal - ] do - eth_get_logs_range_size = - Application.get_all_env(:indexer)[Indexer.Fetcher.PolygonEdge][:polygon_edge_eth_get_logs_range_size] - - chunks_number = - if scan_db do - 1 - else - ceil((l2_block_end - l2_block_start + 1) / eth_get_logs_range_size) - end - - chunk_range = Range.new(0, max(chunks_number - 1, 0), 1) - - Enum.reduce(chunk_range, 0, fn current_chunk, count_acc -> - chunk_start = l2_block_start + eth_get_logs_range_size * current_chunk - - chunk_end = - if scan_db do - l2_block_end - else - min(chunk_start + eth_get_logs_range_size - 1, l2_block_end) - end - - Helper.log_blocks_chunk_handling(chunk_start, chunk_end, l2_block_start, l2_block_end, nil, :L2) - - count = - calling_module.find_and_save_entities( - scan_db, - contract_address, - chunk_start, - chunk_end, - json_rpc_named_arguments - ) - - event_name = - if calling_module == Indexer.Fetcher.PolygonEdge.DepositExecute do - "StateSyncResult" - else - "L2StateSynced" - end - - Helper.log_blocks_chunk_handling( - chunk_start, - chunk_end, - l2_block_start, - l2_block_end, - "#{count} #{event_name} event(s)", - :L2 - ) - - count_acc + count - end) - end - - @spec fill_block_range(integer(), integer(), {module(), module()}, binary(), list()) :: any() - def fill_block_range(start_block, end_block, {module, table}, contract_address, json_rpc_named_arguments) do - if start_block <= end_block do - fill_block_range(start_block, end_block, module, contract_address, json_rpc_named_arguments, true) - - fill_msg_id_gaps( - start_block, - table, - module, - contract_address, - json_rpc_named_arguments, - false - ) - - {last_l2_block_number, _} = get_last_l2_item(table) - - fill_block_range( - max(start_block, last_l2_block_number), - end_block, - module, - contract_address, - json_rpc_named_arguments, - false - ) - end - end - - @spec fill_msg_id_gaps(integer(), module(), module(), binary(), list(), boolean()) :: no_return() - def fill_msg_id_gaps( - start_block_l2, - table, - calling_module, - contract_address, - json_rpc_named_arguments, - scan_db \\ true - ) do - id_min = Repo.aggregate(table, :min, :msg_id) - id_max = Repo.aggregate(table, :max, :msg_id) - - with true <- !is_nil(id_min) and !is_nil(id_max), - starts = msg_id_gap_starts(id_max, table), - ends = msg_id_gap_ends(id_min, table), - min_block_l2 = l2_block_number_by_msg_id(id_min, table), - {new_starts, new_ends} = - if(start_block_l2 < min_block_l2, - do: {[start_block_l2 | starts], [min_block_l2 | ends]}, - else: {starts, ends} - ), - true <- Enum.count(new_starts) == Enum.count(new_ends) do - ranges = Enum.zip(new_starts, new_ends) - - invalid_range_exists = Enum.any?(ranges, fn {l2_block_start, l2_block_end} -> l2_block_start > l2_block_end end) - - ranges_final = - with {:ranges_are_invalid, true} <- {:ranges_are_invalid, invalid_range_exists}, - {max_block_l2, _} = get_last_l2_item(table), - {:start_block_l2_is_min, true} <- {:start_block_l2_is_min, start_block_l2 <= max_block_l2} do - [{start_block_l2, max_block_l2}] - else - {:ranges_are_invalid, false} -> ranges - {:start_block_l2_is_min, false} -> [] - end - - ranges_final - |> Enum.each(fn {l2_block_start, l2_block_end} -> - count = - fill_block_range( - l2_block_start, - l2_block_end, - calling_module, - contract_address, - json_rpc_named_arguments, - scan_db - ) - - if count > 0 do - log_fill_msg_id_gaps(scan_db, l2_block_start, l2_block_end, table, count) - end - end) - - if scan_db do - fill_msg_id_gaps(start_block_l2, table, calling_module, contract_address, json_rpc_named_arguments, false) - end - end - end - - defp log_fill_msg_id_gaps(scan_db, l2_block_start, l2_block_end, table, count) do - find_place = if scan_db, do: "in DB", else: "through RPC" - table_name = table.__schema__(:source) - - Logger.info( - "Filled gaps between L2 blocks #{l2_block_start} and #{l2_block_end}. #{count} event(s) were found #{find_place} and written to #{table_name} table." - ) - end - - defp msg_id_gap_starts(id_max, table) - when table in [Explorer.Chain.PolygonEdge.DepositExecute, Explorer.Chain.PolygonEdge.Withdrawal] do - query = - if table == Explorer.Chain.PolygonEdge.DepositExecute do - from(item in table, - select: item.l2_block_number, - order_by: item.msg_id, - where: - fragment( - "NOT EXISTS (SELECT msg_id FROM polygon_edge_deposit_executes WHERE msg_id = (? + 1)) AND msg_id != ?", - item.msg_id, - ^id_max - ) - ) - else - from(item in table, - select: item.l2_block_number, - order_by: item.msg_id, - where: - fragment( - "NOT EXISTS (SELECT msg_id FROM polygon_edge_withdrawals WHERE msg_id = (? + 1)) AND msg_id != ?", - item.msg_id, - ^id_max - ) - ) - end - - Repo.all(query) - end - - defp msg_id_gap_ends(id_min, table) - when table in [Explorer.Chain.PolygonEdge.DepositExecute, Explorer.Chain.PolygonEdge.Withdrawal] do - query = - if table == Explorer.Chain.PolygonEdge.DepositExecute do - from(item in table, - select: item.l2_block_number, - order_by: item.msg_id, - where: - fragment( - "NOT EXISTS (SELECT msg_id FROM polygon_edge_deposit_executes WHERE msg_id = (? - 1)) AND msg_id != ?", - item.msg_id, - ^id_min - ) - ) - else - from(item in table, - select: item.l2_block_number, - order_by: item.msg_id, - where: - fragment( - "NOT EXISTS (SELECT msg_id FROM polygon_edge_withdrawals WHERE msg_id = (? - 1)) AND msg_id != ?", - item.msg_id, - ^id_min - ) - ) - end - - Repo.all(query) - end - - defp get_safe_block(json_rpc_named_arguments) do - case Helper.get_block_number_by_tag("safe", json_rpc_named_arguments) do - {:ok, safe_block} -> - {safe_block, false} - - {:error, :not_found} -> - {:ok, latest_block} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - - {latest_block, true} - end - end - - @spec get_logs( - non_neg_integer() | binary(), - non_neg_integer() | binary(), - binary(), - binary(), - list(), - non_neg_integer() - ) :: {:ok, list()} | {:error, term()} - def get_logs(from_block, to_block, address, topic0, json_rpc_named_arguments, retries) do - # TODO: use the function from the Indexer.Helper module - processed_from_block = if is_integer(from_block), do: integer_to_quantity(from_block), else: from_block - processed_to_block = if is_integer(to_block), do: integer_to_quantity(to_block), else: to_block - - req = - request(%{ - id: 0, - method: "eth_getLogs", - params: [ - %{ - :fromBlock => processed_from_block, - :toBlock => processed_to_block, - :address => address, - :topics => [topic0] - } - ] - }) - - error_message = &"Cannot fetch logs for the block range #{from_block}..#{to_block}. Error: #{inspect(&1)}" - - Helper.repeated_call(&json_rpc/2, [req, json_rpc_named_arguments], error_message, retries) - end - - defp get_last_l1_item(table) do - query = - from(item in table, - select: {item.l1_block_number, item.l1_transaction_hash}, - order_by: [desc: item.msg_id], - limit: 1 - ) - - query - |> Repo.one() - |> Kernel.||({0, nil}) - end - - @spec get_last_l2_item(module()) :: {non_neg_integer(), binary() | nil} - def get_last_l2_item(table) do - query = - from(item in table, - select: {item.l2_block_number, item.l2_transaction_hash}, - order_by: [desc: item.msg_id], - limit: 1 - ) - - query - |> Repo.one() - |> Kernel.||({0, nil}) - end - - defp json_rpc_named_arguments(polygon_edge_l1_rpc) do - [ - transport: EthereumJSONRPC.HTTP, - transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, - urls: [polygon_edge_l1_rpc], - http_options: [ - recv_timeout: :timer.minutes(10), - timeout: :timer.minutes(10), - hackney: [pool: :ethereum_jsonrpc] - ] - ] - ] - end - - defp l2_block_number_by_msg_id(id, table) do - Repo.one(from(item in table, select: item.l2_block_number, where: item.msg_id == ^id)) - end - - defp import_events(events, calling_module) do - # here we explicitly check CHAIN_TYPE as Dialyzer throws an error otherwise - {import_data, event_name} = - case Application.get_env(:explorer, :chain_type) == :polygon_edge && calling_module do - Deposit -> - {%{polygon_edge_deposits: %{params: events}, timeout: :infinity}, "StateSynced"} - - WithdrawalExit -> - {%{polygon_edge_withdrawal_exits: %{params: events}, timeout: :infinity}, "ExitProcessed"} - - _ -> - {%{}, ""} - end - - {:ok, _} = Chain.import(import_data) - - {events, event_name} - end - - @spec repeated_request(list(), any(), list(), non_neg_integer()) :: {:ok, any()} | {:error, atom()} - def repeated_request(req, error_message, json_rpc_named_arguments, retries) do - Helper.repeated_call(&json_rpc/2, [req, json_rpc_named_arguments], error_message, retries) - end - - @doc """ - Returns L1 RPC URL for a Polygon Edge module. - """ - @spec l1_rpc_url() :: binary() - def l1_rpc_url do - Application.get_all_env(:indexer)[__MODULE__][:polygon_edge_l1_rpc] - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_edge/deposit.ex b/apps/indexer/lib/indexer/fetcher/polygon_edge/deposit.ex deleted file mode 100644 index 0ebade665768..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_edge/deposit.ex +++ /dev/null @@ -1,162 +0,0 @@ -defmodule Indexer.Fetcher.PolygonEdge.Deposit do - @moduledoc """ - Fills polygon_edge_deposits DB table. - """ - - # todo: this module is deprecated and should be removed - - use GenServer - use Indexer.Fetcher - - require Logger - - import EthereumJSONRPC, only: [id_to_params: 1, quantity_to_integer: 1] - import Explorer.Helper, only: [decode_data: 2] - - alias ABI.TypeDecoder - alias EthereumJSONRPC.Block.ByNumber - alias EthereumJSONRPC.Blocks - alias Explorer.Chain.PolygonEdge.Deposit - alias Indexer.Fetcher.PolygonEdge - alias Indexer.Helper - - @fetcher_name :polygon_edge_deposit - - # 32-byte signature of the event StateSynced(uint256 indexed id, address indexed sender, address indexed receiver, bytes data) - @state_synced_event "0xd1d7f6609674cc5871fdb4b0bcd4f0a214118411de9e38983866514f22659165" - - # 32-byte representation of deposit signature, keccak256("DEPOSIT") - @deposit_signature "87a7811f4bfedea3d341ad165680ae306b01aaeacc205d227629cf157dd9f821" - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(_args) do - {:ok, %{}, {:continue, :ok}} - end - - @impl GenServer - def handle_continue(:ok, state) do - Logger.metadata(fetcher: @fetcher_name) - - env = Application.get_all_env(:indexer)[__MODULE__] - - case PolygonEdge.init_l1( - Deposit, - env, - self(), - env[:state_sender], - "State Sender", - "polygon_edge_deposits", - "Deposits" - ) do - :ignore -> {:stop, :normal, state} - {:ok, new_state} -> {:noreply, new_state} - end - end - - @impl GenServer - def handle_info(:continue, state) do - PolygonEdge.handle_continue(state, @state_synced_event, __MODULE__) - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - @spec prepare_events(list(), list()) :: list() - def prepare_events(events, json_rpc_named_arguments) do - Enum.map(events, fn event -> - [data_bytes] = decode_data(event["data"], [:bytes]) - - sig = binary_part(data_bytes, 0, 32) - - l1_block_number = quantity_to_integer(event["blockNumber"]) - - {from, to, l1_timestamp} = - if Base.encode16(sig, case: :lower) == @deposit_signature do - timestamps = get_timestamps_by_events(events, json_rpc_named_arguments) - - [_sig, _root_token, sender, receiver, _amount] = - TypeDecoder.decode_raw(data_bytes, [{:bytes, 32}, :address, :address, :address, {:uint, 256}]) - - {sender, receiver, Map.get(timestamps, l1_block_number)} - else - {nil, nil, nil} - end - - %{ - msg_id: quantity_to_integer(Enum.at(event["topics"], 1)), - from: from, - to: to, - l1_transaction_hash: event["transactionHash"], - l1_timestamp: l1_timestamp, - l1_block_number: l1_block_number - } - end) - end - - @doc """ - Returns L1 RPC URL for this module. - """ - @spec l1_rpc_url() :: binary() | nil - def l1_rpc_url do - PolygonEdge.l1_rpc_url() - end - - @doc """ - Determines if `Indexer.Fetcher.RollupL1ReorgMonitor` module must be up - for this module. - - ## Returns - - `true` if the reorg monitor must be active, `false` otherwise. - """ - @spec requires_l1_reorg_monitor?() :: boolean() - def requires_l1_reorg_monitor? do - module_config = Application.get_all_env(:indexer)[__MODULE__] - not is_nil(module_config[:start_block_l1]) - end - - defp get_blocks_by_events(events, json_rpc_named_arguments, retries) do - request = - events - |> Enum.reduce(%{}, fn event, acc -> - Map.put(acc, event["blockNumber"], 0) - end) - |> Stream.map(fn {block_number, _} -> %{number: block_number} end) - |> id_to_params() - |> Blocks.requests(&ByNumber.request(&1, false, false)) - - error_message = &"Cannot fetch blocks with batch request. Error: #{inspect(&1)}. Request: #{inspect(request)}" - - case PolygonEdge.repeated_request(request, error_message, json_rpc_named_arguments, retries) do - {:ok, results} -> Enum.map(results, fn %{result: result} -> result end) - {:error, _} -> [] - end - end - - defp get_timestamps_by_events(events, json_rpc_named_arguments) do - events - |> get_blocks_by_events(json_rpc_named_arguments, Helper.infinite_retries_number()) - |> Enum.reduce(%{}, fn block, acc -> - block_number = quantity_to_integer(Map.get(block, "number")) - {:ok, timestamp} = DateTime.from_unix(quantity_to_integer(Map.get(block, "timestamp"))) - Map.put(acc, block_number, timestamp) - end) - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_edge/deposit_execute.ex b/apps/indexer/lib/indexer/fetcher/polygon_edge/deposit_execute.ex deleted file mode 100644 index 90159b16ccd2..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_edge/deposit_execute.ex +++ /dev/null @@ -1,226 +0,0 @@ -defmodule Indexer.Fetcher.PolygonEdge.DepositExecute do - @moduledoc """ - Fills polygon_edge_deposit_executes DB table. - """ - - # todo: this module is deprecated and should be removed - - use GenServer - use Indexer.Fetcher - - require Logger - - import Ecto.Query - - import EthereumJSONRPC, only: [quantity_to_integer: 1] - import Indexer.Fetcher.PolygonEdge, only: [fill_block_range: 5] - import Indexer.Helper, only: [log_topic_to_string: 1] - - alias Explorer.{Chain, Repo} - alias Explorer.Chain.Log - alias Explorer.Chain.PolygonEdge.DepositExecute - alias Indexer.Fetcher.PolygonEdge - alias Indexer.Helper - - @fetcher_name :polygon_edge_deposit_execute - - # 32-byte signature of the event StateSyncResult(uint256 indexed counter, bool indexed status, bytes message) - @state_sync_result_event "0x31c652130602f3ce96ceaf8a4c2b8b49f049166c6fcf2eb31943a75ec7c936ae" - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(args) do - {:ok, %{}, {:continue, args}} - end - - @impl GenServer - def handle_continue(args, state) do - Logger.metadata(fetcher: @fetcher_name) - - json_rpc_named_arguments = args[:json_rpc_named_arguments] - env = Application.get_all_env(:indexer)[__MODULE__] - - case PolygonEdge.init_l2( - DepositExecute, - env, - self(), - env[:state_receiver], - "StateReceiver", - "polygon_edge_deposit_executes", - "Deposit Executes", - json_rpc_named_arguments - ) do - :ignore -> {:stop, :normal, state} - {:ok, new_state} -> {:noreply, new_state} - end - end - - @impl GenServer - def handle_info( - :continue, - %{ - start_block_l2: start_block_l2, - contract_address: contract_address, - json_rpc_named_arguments: json_rpc_named_arguments - } = state - ) do - PolygonEdge.fill_msg_id_gaps( - start_block_l2, - DepositExecute, - __MODULE__, - contract_address, - json_rpc_named_arguments - ) - - Process.send(self(), :find_new_events, []) - {:noreply, state} - end - - @impl GenServer - def handle_info( - :find_new_events, - %{ - start_block: start_block, - safe_block: safe_block, - safe_block_is_latest: safe_block_is_latest, - contract_address: contract_address, - json_rpc_named_arguments: json_rpc_named_arguments - } = state - ) do - # find and fill all events between start_block and "safe" block - # the "safe" block can be "latest" (when safe_block_is_latest == true) - fill_block_range( - start_block, - safe_block, - {__MODULE__, DepositExecute}, - contract_address, - json_rpc_named_arguments - ) - - if not safe_block_is_latest do - # find and fill all events between "safe" and "latest" block (excluding "safe") - {:ok, latest_block} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - - fill_block_range( - safe_block + 1, - latest_block, - {__MODULE__, DepositExecute}, - contract_address, - json_rpc_named_arguments - ) - end - - {:stop, :normal, state} - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - @spec remove(non_neg_integer()) :: no_return() - def remove(starting_block) do - Repo.delete_all(from(de in DepositExecute, where: de.l2_block_number >= ^starting_block)) - end - - @spec event_to_deposit_execute(binary(), binary(), binary(), binary()) :: map() - def event_to_deposit_execute(second_topic, third_topic, l2_transaction_hash, l2_block_number) do - msg_id = - second_topic - |> log_topic_to_string() - |> quantity_to_integer() - - status = - third_topic - |> log_topic_to_string() - |> quantity_to_integer() - - %{ - msg_id: msg_id, - l2_transaction_hash: l2_transaction_hash, - l2_block_number: quantity_to_integer(l2_block_number), - success: status != 0 - } - end - - @spec find_and_save_entities(boolean(), binary(), non_neg_integer(), non_neg_integer(), list()) :: non_neg_integer() - def find_and_save_entities( - scan_db, - state_receiver, - block_start, - block_end, - json_rpc_named_arguments - ) do - executes = - if scan_db do - query = - from(log in Log, - select: {log.second_topic, log.third_topic, log.transaction_hash, log.block_number}, - where: - log.first_topic == ^@state_sync_result_event and log.address_hash == ^state_receiver and - log.block_number >= ^block_start and log.block_number <= ^block_end - ) - - query - |> Repo.all(timeout: :infinity) - |> Enum.map(fn {second_topic, third_topic, l2_transaction_hash, l2_block_number} -> - event_to_deposit_execute(second_topic, third_topic, l2_transaction_hash, l2_block_number) - end) - else - {:ok, result} = - PolygonEdge.get_logs( - block_start, - block_end, - state_receiver, - @state_sync_result_event, - json_rpc_named_arguments, - Helper.infinite_retries_number() - ) - - Enum.map(result, fn event -> - event_to_deposit_execute( - Enum.at(event["topics"], 1), - Enum.at(event["topics"], 2), - event["transactionHash"], - event["blockNumber"] - ) - end) - end - - # here we explicitly check CHAIN_TYPE as Dialyzer throws an error otherwise - import_options = - if Application.get_env(:explorer, :chain_type) == :polygon_edge do - %{ - polygon_edge_deposit_executes: %{params: executes}, - timeout: :infinity - } - else - %{} - end - - {:ok, _} = Chain.import(import_options) - - Enum.count(executes) - end - - @spec state_sync_result_event_signature() :: binary() - def state_sync_result_event_signature do - @state_sync_result_event - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_edge/withdrawal.ex b/apps/indexer/lib/indexer/fetcher/polygon_edge/withdrawal.ex deleted file mode 100644 index 47f6661830e5..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_edge/withdrawal.ex +++ /dev/null @@ -1,241 +0,0 @@ -defmodule Indexer.Fetcher.PolygonEdge.Withdrawal do - @moduledoc """ - Fills polygon_edge_withdrawals DB table. - """ - - # todo: this module is deprecated and should be removed - - use GenServer - use Indexer.Fetcher - - require Logger - - import Ecto.Query - - import EthereumJSONRPC, only: [quantity_to_integer: 1] - import Explorer.Helper, only: [decode_data: 2] - import Indexer.Fetcher.PolygonEdge, only: [fill_block_range: 5] - import Indexer.Helper, only: [log_topic_to_string: 1] - - alias ABI.TypeDecoder - alias Explorer.{Chain, Repo} - alias Explorer.Chain.Log - alias Explorer.Chain.PolygonEdge.Withdrawal - alias Indexer.Fetcher.PolygonEdge - alias Indexer.Helper - - @fetcher_name :polygon_edge_withdrawal - - # 32-byte signature of the event L2StateSynced(uint256 indexed id, address indexed sender, address indexed receiver, bytes data) - @l2_state_synced_event "0xedaf3c471ebd67d60c29efe34b639ede7d6a1d92eaeb3f503e784971e67118a5" - - # 32-byte representation of withdrawal signature, keccak256("WITHDRAW") - @withdrawal_signature "7a8dc26796a1e50e6e190b70259f58f6a4edd5b22280ceecc82b687b8e982869" - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(args) do - {:ok, %{}, {:continue, args}} - end - - @impl GenServer - def handle_continue(args, state) do - Logger.metadata(fetcher: @fetcher_name) - - json_rpc_named_arguments = args[:json_rpc_named_arguments] - env = Application.get_all_env(:indexer)[__MODULE__] - - case PolygonEdge.init_l2( - Withdrawal, - env, - self(), - env[:state_sender], - "L2StateSender", - "polygon_edge_withdrawals", - "Withdrawals", - json_rpc_named_arguments - ) do - :ignore -> {:stop, :normal, state} - {:ok, new_state} -> {:noreply, new_state} - end - end - - @impl GenServer - def handle_info( - :continue, - %{ - start_block_l2: start_block_l2, - contract_address: contract_address, - json_rpc_named_arguments: json_rpc_named_arguments - } = state - ) do - PolygonEdge.fill_msg_id_gaps( - start_block_l2, - Withdrawal, - __MODULE__, - contract_address, - json_rpc_named_arguments - ) - - Process.send(self(), :find_new_events, []) - {:noreply, state} - end - - @impl GenServer - def handle_info( - :find_new_events, - %{ - start_block: start_block, - safe_block: safe_block, - safe_block_is_latest: safe_block_is_latest, - contract_address: contract_address, - json_rpc_named_arguments: json_rpc_named_arguments - } = state - ) do - # find and fill all events between start_block and "safe" block - # the "safe" block can be "latest" (when safe_block_is_latest == true) - fill_block_range( - start_block, - safe_block, - {__MODULE__, Withdrawal}, - contract_address, - json_rpc_named_arguments - ) - - if not safe_block_is_latest do - # find and fill all events between "safe" and "latest" block (excluding "safe") - {:ok, latest_block} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - - fill_block_range( - safe_block + 1, - latest_block, - {__MODULE__, Withdrawal}, - contract_address, - json_rpc_named_arguments - ) - end - - {:stop, :normal, state} - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - @spec remove(non_neg_integer()) :: no_return() - def remove(starting_block) do - Repo.delete_all(from(w in Withdrawal, where: w.l2_block_number >= ^starting_block)) - end - - @spec event_to_withdrawal(binary(), map(), binary(), binary()) :: map() - def event_to_withdrawal(second_topic, data, l2_transaction_hash, l2_block_number) do - msg_id = - second_topic - |> log_topic_to_string() - |> quantity_to_integer() - - [data_bytes] = decode_data(data, [:bytes]) - - sig = binary_part(data_bytes, 0, 32) - - {from, to} = - if Base.encode16(sig, case: :lower) == @withdrawal_signature do - [_sig, _root_token, sender, receiver, _amount] = - TypeDecoder.decode_raw(data_bytes, [{:bytes, 32}, :address, :address, :address, {:uint, 256}]) - - {sender, receiver} - else - {nil, nil} - end - - %{ - msg_id: msg_id, - from: from, - to: to, - l2_transaction_hash: l2_transaction_hash, - l2_block_number: quantity_to_integer(l2_block_number) - } - end - - @spec find_and_save_entities(boolean(), binary(), non_neg_integer(), non_neg_integer(), list()) :: non_neg_integer() - def find_and_save_entities( - scan_db, - state_sender, - block_start, - block_end, - json_rpc_named_arguments - ) do - withdrawals = - if scan_db do - query = - from(log in Log, - select: {log.second_topic, log.data, log.transaction_hash, log.block_number}, - where: - log.first_topic == ^@l2_state_synced_event and log.address_hash == ^state_sender and - log.block_number >= ^block_start and log.block_number <= ^block_end - ) - - query - |> Repo.all(timeout: :infinity) - |> Enum.map(fn {second_topic, data, l2_transaction_hash, l2_block_number} -> - event_to_withdrawal(second_topic, data, l2_transaction_hash, l2_block_number) - end) - else - {:ok, result} = - PolygonEdge.get_logs( - block_start, - block_end, - state_sender, - @l2_state_synced_event, - json_rpc_named_arguments, - Helper.infinite_retries_number() - ) - - Enum.map(result, fn event -> - event_to_withdrawal( - Enum.at(event["topics"], 1), - event["data"], - event["transactionHash"], - event["blockNumber"] - ) - end) - end - - # here we explicitly check CHAIN_TYPE as Dialyzer throws an error otherwise - import_options = - if Application.get_env(:explorer, :chain_type) == :polygon_edge do - %{ - polygon_edge_withdrawals: %{params: withdrawals}, - timeout: :infinity - } - else - %{} - end - - {:ok, _} = Chain.import(import_options) - - Enum.count(withdrawals) - end - - @spec l2_state_synced_event_signature() :: binary() - def l2_state_synced_event_signature do - @l2_state_synced_event - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_edge/withdrawal_exit.ex b/apps/indexer/lib/indexer/fetcher/polygon_edge/withdrawal_exit.ex deleted file mode 100644 index 64d82b394b3b..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_edge/withdrawal_exit.ex +++ /dev/null @@ -1,106 +0,0 @@ -defmodule Indexer.Fetcher.PolygonEdge.WithdrawalExit do - @moduledoc """ - Fills polygon_edge_withdrawal_exits DB table. - """ - - # todo: this module is deprecated and should be removed - - use GenServer - use Indexer.Fetcher - - require Logger - - import EthereumJSONRPC, only: [quantity_to_integer: 1] - - alias Explorer.Chain.PolygonEdge.WithdrawalExit - alias Indexer.Fetcher.PolygonEdge - - @fetcher_name :polygon_edge_withdrawal_exit - - # 32-byte signature of the event ExitProcessed(uint256 indexed id, bool indexed success, bytes returnData) - @exit_processed_event "0x8bbfa0c9bee3785c03700d2a909592286efb83fc7e7002be5764424b9842f7ec" - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(_args) do - {:ok, %{}, {:continue, :ok}} - end - - @impl GenServer - def handle_continue(:ok, state) do - Logger.metadata(fetcher: @fetcher_name) - - env = Application.get_all_env(:indexer)[__MODULE__] - - case PolygonEdge.init_l1( - WithdrawalExit, - env, - self(), - env[:exit_helper], - "Exit Helper", - "polygon_edge_withdrawal_exits", - "Withdrawals" - ) do - :ignore -> {:stop, :normal, state} - {:ok, new_state} -> {:noreply, new_state} - end - end - - @impl GenServer - def handle_info(:continue, state) do - PolygonEdge.handle_continue(state, @exit_processed_event, __MODULE__) - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - @spec prepare_events(list(), list()) :: list() - def prepare_events(events, _) do - Enum.map(events, fn event -> - %{ - msg_id: quantity_to_integer(Enum.at(event["topics"], 1)), - l1_transaction_hash: event["transactionHash"], - l1_block_number: quantity_to_integer(event["blockNumber"]), - success: quantity_to_integer(Enum.at(event["topics"], 2)) != 0 - } - end) - end - - @doc """ - Returns L1 RPC URL for this module. - """ - @spec l1_rpc_url() :: binary() | nil - def l1_rpc_url do - PolygonEdge.l1_rpc_url() - end - - @doc """ - Determines if `Indexer.Fetcher.RollupL1ReorgMonitor` module must be up - for this module. - - ## Returns - - `true` if the reorg monitor must be active, `false` otherwise. - """ - @spec requires_l1_reorg_monitor?() :: boolean() - def requires_l1_reorg_monitor? do - module_config = Application.get_all_env(:indexer)[__MODULE__] - not is_nil(module_config[:start_block_l1]) - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge.ex b/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge.ex deleted file mode 100644 index eb6257616d75..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge.ex +++ /dev/null @@ -1,472 +0,0 @@ -defmodule Indexer.Fetcher.PolygonZkevm.Bridge do - @moduledoc """ - Contains common functions for Indexer.Fetcher.PolygonZkevm.Bridge* modules. - """ - - require Logger - - import EthereumJSONRPC, - only: [ - quantity_to_integer: 1, - timestamp_to_datetime: 1 - ] - - import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - - import Explorer.Helper, only: [add_0x_prefix: 1, decode_data: 2] - - alias EthereumJSONRPC.Logs - alias Explorer.Chain - alias Explorer.Chain.PolygonZkevm.Reader - alias Indexer.Helper, as: IndexerHelper - alias Indexer.Transform.Addresses - - # 32-byte signature of the event BridgeEvent(uint8 leafType, uint32 originNetwork, address originAddress, uint32 destinationNetwork, address destinationAddress, uint256 amount, bytes metadata, uint32 depositCount) - @bridge_event "0x501781209a1f8899323b96b4ef08b168df93e0a90c673d1e4cce39366cb62f9b" - @bridge_event_params [{:uint, 8}, {:uint, 32}, :address, {:uint, 32}, :address, {:uint, 256}, :bytes, {:uint, 32}] - - # 32-byte signature of the event ClaimEvent(uint32 index, uint32 originNetwork, address originAddress, address destinationAddress, uint256 amount) - @claim_event_v1 "0x25308c93ceeed162da955b3f7ce3e3f93606579e40fb92029faa9efe27545983" - @claim_event_v1_params [{:uint, 32}, {:uint, 32}, :address, :address, {:uint, 256}] - - # 32-byte signature of the event ClaimEvent(uint256 globalIndex, uint32 originNetwork, address originAddress, address destinationAddress, uint256 amount) - @claim_event_v2 "0x1df3f2a973a00d6635911755c260704e95e8a5876997546798770f76396fda4d" - @claim_event_v2_params [{:uint, 256}, {:uint, 32}, :address, :address, {:uint, 256}] - - @symbol_method_selector "95d89b41" - @decimals_method_selector "313ce567" - - @erc20_abi [ - %{ - "constant" => true, - "inputs" => [], - "name" => "symbol", - "outputs" => [%{"name" => "", "type" => "string"}], - "payable" => false, - "stateMutability" => "view", - "type" => "function" - }, - %{ - "constant" => true, - "inputs" => [], - "name" => "decimals", - "outputs" => [%{"name" => "", "type" => "uint8"}], - "payable" => false, - "stateMutability" => "view", - "type" => "function" - } - ] - - @doc """ - Filters the given list of events keeping only `BridgeEvent` and `ClaimEvent` ones - emitted by the bridge contract. - """ - @spec filter_bridge_events(list(), binary()) :: list() - def filter_bridge_events(events, bridge_contract) do - Enum.filter(events, fn event -> - IndexerHelper.address_hash_to_string(event.address_hash, true) == bridge_contract and - Enum.member?( - [@bridge_event, @claim_event_v1, @claim_event_v2], - IndexerHelper.log_topic_to_string(event.first_topic) - ) - end) - end - - @doc """ - Fetches `BridgeEvent` and `ClaimEvent` events of the bridge contract from an RPC node - for the given range of blocks. - """ - @spec get_logs_all({non_neg_integer(), non_neg_integer()}, binary(), list()) :: list() - def get_logs_all({chunk_start, chunk_end}, bridge_contract, json_rpc_named_arguments) do - {:ok, result} = - IndexerHelper.get_logs( - chunk_start, - chunk_end, - bridge_contract, - [[@bridge_event, @claim_event_v1, @claim_event_v2]], - json_rpc_named_arguments, - 0, - IndexerHelper.infinite_retries_number() - ) - - Logs.elixir_to_params(result) - end - - @doc """ - Imports the given zkEVM bridge operations into database. - Used by Indexer.Fetcher.PolygonZkevm.BridgeL1 and Indexer.Fetcher.PolygonZkevm.BridgeL2 fetchers. - Doesn't return anything. - """ - @spec import_operations(list()) :: no_return() - def import_operations(operations) do - addresses = - Addresses.extract_addresses(%{ - polygon_zkevm_bridge_operations: operations - }) - - {:ok, _} = - Chain.import(%{ - addresses: %{params: addresses, on_conflict: :nothing}, - polygon_zkevm_bridge_operations: %{params: operations}, - timeout: :infinity - }) - end - - @doc """ - Converts the list of zkEVM bridge events to the list of operations - preparing them for importing to the database. - """ - @spec prepare_operations( - list(), - non_neg_integer(), - non_neg_integer(), - non_neg_integer() | nil, - non_neg_integer(), - list() | nil, - list(), - map() | nil - ) :: - list() - def prepare_operations( - events, - rollup_network_id_l1, - rollup_network_id_l2, - rollup_index_l1, - rollup_index_l2, - json_rpc_named_arguments, - json_rpc_named_arguments_l1, - block_to_timestamp \\ nil - ) do - is_l1 = json_rpc_named_arguments == json_rpc_named_arguments_l1 - - events = filter_events(events, is_l1, rollup_network_id_l1, rollup_network_id_l2, rollup_index_l1, rollup_index_l2) - - {block_to_timestamp, token_address_to_id} = - if is_nil(block_to_timestamp) do - # this function is called by the catchup indexer, - # so here we can use RPC calls as it's not so critical for delays as in realtime - bridge_events = Enum.filter(events, fn event -> event.first_topic == @bridge_event end) - l1_token_addresses = l1_token_addresses_from_bridge_events(bridge_events, rollup_network_id_l2) - - { - blocks_to_timestamps(bridge_events, json_rpc_named_arguments), - token_addresses_to_ids(l1_token_addresses, json_rpc_named_arguments_l1) - } - else - # this function is called in realtime by the transformer, - # so we don't use RPC calls to avoid delays and fetch token data - # in a separate fetcher - {block_to_timestamp, %{}} - end - - events - |> Enum.map(fn event -> - {index, l1_token_id, l1_token_address, l2_token_address, amount, block_number, block_timestamp} = - case event.first_topic do - @bridge_event -> - { - {l1_token_address, l2_token_address}, - amount, - deposit_count, - _destination_network - } = bridge_event_parse(event, rollup_network_id_l2) - - l1_token_id = Map.get(token_address_to_id, l1_token_address) - block_number = quantity_to_integer(event.block_number) - block_timestamp = Map.get(block_to_timestamp, block_number) - - # credo:disable-for-lines:2 Credo.Check.Refactor.Nesting - l1_token_address = - if is_nil(l1_token_id) do - l1_token_address - end - - {deposit_count, l1_token_id, l1_token_address, l2_token_address, amount, block_number, block_timestamp} - - @claim_event_v1 -> - {index, amount} = claim_event_v1_parse(event) - {index, nil, nil, nil, amount, nil, nil} - - @claim_event_v2 -> - {_mainnet_bit, _rollup_idx, index, _origin_network, amount} = claim_event_v2_parse(event) - {index, nil, nil, nil, amount, nil, nil} - end - - result = %{ - type: operation_type(event.first_topic, is_l1), - index: index, - amount: amount - } - - transaction_hash_field = - if is_l1 do - :l1_transaction_hash - else - :l2_transaction_hash - end - - result - |> extend_result(transaction_hash_field, event.transaction_hash) - |> extend_result(:l1_token_id, l1_token_id) - |> extend_result(:l1_token_address, l1_token_address) - |> extend_result(:l2_token_address, l2_token_address) - |> extend_result(:block_number, block_number) - |> extend_result(:block_timestamp, block_timestamp) - end) - end - - defp blocks_to_timestamps(events, json_rpc_named_arguments) do - events - |> IndexerHelper.get_blocks_by_events(json_rpc_named_arguments, IndexerHelper.infinite_retries_number()) - |> Enum.reduce(%{}, fn block, acc -> - block_number = quantity_to_integer(Map.get(block, "number")) - timestamp = timestamp_to_datetime(Map.get(block, "timestamp")) - Map.put(acc, block_number, timestamp) - end) - end - - defp bridge_event_parse(event, rollup_network_id_l2) do - [ - leaf_type, - origin_network, - origin_address, - destination_network, - _destination_address, - amount, - _metadata, - deposit_count - ] = decode_data(event.data, @bridge_event_params) - - {token_address_by_origin_address(origin_address, origin_network, leaf_type, rollup_network_id_l2), amount, - deposit_count, destination_network} - end - - defp claim_event_v1_parse(event) do - [index, _origin_network, _origin_address, _destination_address, amount] = - decode_data(event.data, @claim_event_v1_params) - - {index, amount} - end - - defp claim_event_v2_parse(event) do - [global_index, origin_network, _origin_address, _destination_address, amount] = - decode_data(event.data, @claim_event_v2_params) - - mainnet_bit = Bitwise.band(Bitwise.bsr(global_index, 64), 1) - - bitmask_4bytes = 0xFFFFFFFF - - rollup_index = Bitwise.band(Bitwise.bsr(global_index, 32), bitmask_4bytes) - - index = Bitwise.band(global_index, bitmask_4bytes) - - {mainnet_bit, rollup_index, index, origin_network, amount} - end - - defp filter_events(events, is_l1, rollup_network_id_l1, rollup_network_id_l2, rollup_index_l1, rollup_index_l2) do - Enum.filter(events, fn event -> - case {event.first_topic, is_l1} do - {@bridge_event, true} -> filter_bridge_event_l1(event, rollup_network_id_l2) - {@bridge_event, false} -> filter_bridge_event_l2(event, rollup_network_id_l1, rollup_network_id_l2) - {@claim_event_v2, true} -> filter_claim_event_l1(event, rollup_index_l2) - {@claim_event_v2, false} -> filter_claim_event_l2(event, rollup_network_id_l1, rollup_index_l1) - _ -> true - end - end) - end - - defp filter_bridge_event_l1(event, rollup_network_id_l2) do - {_, _, _, destination_network} = bridge_event_parse(event, rollup_network_id_l2) - # skip the Deposit event if it's for another rollup - destination_network == rollup_network_id_l2 - end - - defp filter_bridge_event_l2(event, rollup_network_id_l1, rollup_network_id_l2) do - {_, _, _, destination_network} = bridge_event_parse(event, rollup_network_id_l2) - # skip the Withdrawal event if it's for another L1 chain - destination_network == rollup_network_id_l1 - end - - defp filter_claim_event_l1(event, rollup_index_l2) do - {mainnet_bit, rollup_idx, _index, _origin_network, _amount} = claim_event_v2_parse(event) - - if mainnet_bit != 0 do - Logger.error( - "L1 ClaimEvent has non-zero mainnet bit in the transaction #{event.transaction_hash}. This event will be ignored." - ) - end - - # skip the Withdrawal event if it's for another rollup or the source network is Ethereum Mainnet - rollup_idx == rollup_index_l2 and mainnet_bit == 0 - end - - defp filter_claim_event_l2(event, rollup_network_id_l1, rollup_index_l1) do - {mainnet_bit, rollup_idx, _index, origin_network, _amount} = claim_event_v2_parse(event) - - # skip the Deposit event if it's from another L1 chain - (mainnet_bit == 1 and rollup_network_id_l1 == 0) or - (mainnet_bit == 0 and (rollup_idx == rollup_index_l1 or origin_network == rollup_network_id_l1)) - end - - defp l1_token_addresses_from_bridge_events(events, rollup_network_id_l2) do - events - |> Enum.reduce(%MapSet{}, fn event, acc -> - case bridge_event_parse(event, rollup_network_id_l2) do - {{nil, _}, _, _, _} -> acc - {{token_address, nil}, _, _, _} -> MapSet.put(acc, token_address) - end - end) - |> MapSet.to_list() - end - - defp operation_type(first_topic, is_l1) do - if first_topic == @bridge_event do - if is_l1, do: :deposit, else: :withdrawal - else - if is_l1, do: :withdrawal, else: :deposit - end - end - - @doc """ - Fetches L1 token data for the given token addresses, - builds `L1 token address -> L1 token id` map for them, - and writes the data to the database. Returns the resulting map. - """ - @spec token_addresses_to_ids(list(), list()) :: map() - def token_addresses_to_ids(l1_token_addresses, json_rpc_named_arguments) do - token_data = - l1_token_addresses - |> get_token_data(json_rpc_named_arguments) - - tokens_existing = - token_data - |> Map.keys() - |> Reader.token_addresses_to_ids_from_db() - - tokens_to_insert = - token_data - |> Enum.reject(fn {address, _} -> Map.has_key?(tokens_existing, address) end) - |> Enum.map(fn {address, data} -> Map.put(data, :address, address) end) - - {:ok, inserts} = - Chain.import(%{ - polygon_zkevm_bridge_l1_tokens: %{params: tokens_to_insert}, - timeout: :infinity - }) - - tokens_inserted = Map.get(inserts, :insert_polygon_zkevm_bridge_l1_tokens, []) - - # we need to query not inserted tokens from DB separately as they - # could be inserted by another module at the same time (a race condition). - # this is an unlikely case but we handle it here as well - tokens_not_inserted = - tokens_to_insert - |> Enum.reject(fn token -> - Enum.any?(tokens_inserted, fn inserted -> - token.address == IndexerHelper.address_hash_to_string(inserted.address) - end) - end) - |> Enum.map(& &1.address) - - tokens_inserted_outside = Reader.token_addresses_to_ids_from_db(tokens_not_inserted) - - tokens_inserted - |> Enum.reduce(%{}, fn t, acc -> Map.put(acc, IndexerHelper.address_hash_to_string(t.address), t.id) end) - |> Map.merge(tokens_existing) - |> Map.merge(tokens_inserted_outside) - end - - defp token_address_by_origin_address(origin_address, origin_network, leaf_type, rollup_network_id_l2) do - with true <- leaf_type != 1, - token_address = add_0x_prefix(origin_address), - true <- token_address != burn_address_hash_string() do - if origin_network != rollup_network_id_l2 do - # this is L1 address - {token_address, nil} - else - # this is L2 address - {nil, token_address} - end - else - _ -> {nil, nil} - end - end - - defp get_token_data(token_addresses, json_rpc_named_arguments) do - # first, we're trying to read token data from the DB. - # if tokens are not in the DB, read them through RPC. - token_addresses - |> Reader.get_token_data_from_db() - |> get_token_data_from_rpc(json_rpc_named_arguments) - end - - defp get_token_data_from_rpc({token_data, token_addresses}, json_rpc_named_arguments) do - {requests, responses} = get_token_data_request_symbol_decimals(token_addresses, json_rpc_named_arguments) - - requests - |> Enum.zip(responses) - |> Enum.reduce(token_data, fn {request, {status, response} = _resp}, token_data_acc -> - if status == :ok do - response = parse_response(response) - - address = IndexerHelper.address_hash_to_string(request.contract_address, true) - - new_data = get_new_data(token_data_acc[address] || %{}, request, response) - - Map.put(token_data_acc, address, new_data) - else - token_data_acc - end - end) - end - - defp get_token_data_request_symbol_decimals(token_addresses, json_rpc_named_arguments) do - requests = - token_addresses - |> Enum.map(fn address -> - # we will call symbol() and decimals() public getters - Enum.map([@symbol_method_selector, @decimals_method_selector], fn method_id -> - %{ - contract_address: address, - method_id: method_id, - args: [] - } - end) - end) - |> List.flatten() - - {responses, error_messages} = - IndexerHelper.read_contracts_with_retries(requests, @erc20_abi, json_rpc_named_arguments, 3) - - if not Enum.empty?(error_messages) or Enum.count(requests) != Enum.count(responses) do - Logger.warning( - "Cannot read symbol and decimals of an ERC-20 token contract. Error messages: #{Enum.join(error_messages, ", ")}. Addresses: #{Enum.join(token_addresses, ", ")}" - ) - end - - {requests, responses} - end - - defp get_new_data(data, request, response) do - if atomized_key(request.method_id) == :symbol do - Map.put(data, :symbol, Reader.sanitize_symbol(response)) - else - Map.put(data, :decimals, Reader.sanitize_decimals(response)) - end - end - - defp extend_result(result, _key, value) when is_nil(value), do: result - defp extend_result(result, key, value) when is_atom(key), do: Map.put(result, key, value) - - defp atomized_key("symbol"), do: :symbol - defp atomized_key("decimals"), do: :decimals - defp atomized_key(@symbol_method_selector), do: :symbol - defp atomized_key(@decimals_method_selector), do: :decimals - - defp parse_response(response) do - case response do - [item] -> item - items -> items - end - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l1.ex b/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l1.ex deleted file mode 100644 index 1e45795d35ff..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l1.ex +++ /dev/null @@ -1,275 +0,0 @@ -defmodule Indexer.Fetcher.PolygonZkevm.BridgeL1 do - @moduledoc """ - Fills polygon_zkevm_bridge DB table. - """ - - use GenServer - use Indexer.Fetcher - - require Logger - - import Ecto.Query - import Explorer.Helper, only: [parse_integer: 1] - - import Indexer.Fetcher.PolygonZkevm.Bridge, - only: [get_logs_all: 3, import_operations: 1, prepare_operations: 7] - - alias Explorer.Chain.PolygonZkevm.{Bridge, Reader} - alias Explorer.Chain.RollupReorgMonitorQueue - alias Explorer.Repo - alias Indexer.Fetcher.RollupL1ReorgMonitor - alias Indexer.Helper - - @eth_get_logs_range_size 1000 - @fetcher_name :polygon_zkevm_bridge_l1 - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(_args) do - {:ok, %{}, {:continue, :ok}} - end - - @impl GenServer - def handle_continue(_, state) do - Logger.metadata(fetcher: @fetcher_name) - # two seconds pause needed to avoid exceeding Supervisor restart intensity when DB issues - Process.send_after(self(), :init_with_delay, 2000) - {:noreply, state} - end - - @impl GenServer - def handle_info(:init_with_delay, _state) do - env = Application.get_all_env(:indexer)[__MODULE__] - env_l2 = Application.get_all_env(:indexer)[Indexer.Fetcher.PolygonZkevm.BridgeL2] - - with {:start_block_undefined, false} <- {:start_block_undefined, is_nil(env[:start_block])}, - _ <- RollupL1ReorgMonitor.wait_for_start(__MODULE__), - rpc = env[:rpc], - {:rpc_undefined, false} <- {:rpc_undefined, is_nil(rpc)}, - {:rollup_network_id_l1_is_valid, true} <- - {:rollup_network_id_l1_is_valid, !is_nil(env[:rollup_network_id_l1]) and env[:rollup_network_id_l1] >= 0}, - {:rollup_network_id_l2_is_valid, true} <- - {:rollup_network_id_l2_is_valid, - !is_nil(env_l2[:rollup_network_id_l2]) and env_l2[:rollup_network_id_l2] > 0}, - {:rollup_index_l2_undefined, false} <- {:rollup_index_l2_undefined, is_nil(env_l2[:rollup_index_l2])}, - {:bridge_contract_address_is_valid, true} <- - {:bridge_contract_address_is_valid, Helper.address_correct?(env[:bridge_contract])}, - start_block = parse_integer(env[:start_block]), - false <- is_nil(start_block), - true <- start_block > 0, - {last_l1_block_number, last_l1_transaction_hash} = Reader.last_l1_item(), - json_rpc_named_arguments = Helper.json_rpc_named_arguments(rpc), - {:ok, block_check_interval, safe_block} <- Helper.get_block_check_interval(json_rpc_named_arguments), - {:start_block_valid, true, _, _} <- - {:start_block_valid, - (start_block <= last_l1_block_number || last_l1_block_number == 0) && start_block <= safe_block, - last_l1_block_number, safe_block}, - {:ok, last_l1_transaction} <- - Helper.get_transaction_by_hash(last_l1_transaction_hash, json_rpc_named_arguments), - {:l1_transaction_not_found, false} <- - {:l1_transaction_not_found, !is_nil(last_l1_transaction_hash) && is_nil(last_l1_transaction)} do - Process.send(self(), :continue, []) - - {:noreply, - %{ - block_check_interval: block_check_interval, - bridge_contract: env[:bridge_contract], - json_rpc_named_arguments: json_rpc_named_arguments, - end_block: safe_block, - start_block: max(start_block, last_l1_block_number), - rollup_network_id_l1: env[:rollup_network_id_l1], - rollup_network_id_l2: env_l2[:rollup_network_id_l2], - rollup_index_l1: env[:rollup_index_l1], - rollup_index_l2: env_l2[:rollup_index_l2] - }} - else - {:start_block_undefined, true} -> - # the process shouldn't start if the start block is not defined - {:stop, :normal, %{}} - - {:rpc_undefined, true} -> - Logger.error("L1 RPC URL is not defined.") - {:stop, :normal, %{}} - - {:rollup_network_id_l1_is_valid, false} -> - Logger.error( - "Invalid network ID for L1. Please, check INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NETWORK_ID env variable." - ) - - {:stop, :normal, %{}} - - {:rollup_network_id_l2_is_valid, false} -> - Logger.error( - "Invalid network ID for L2. Please, check INDEXER_POLYGON_ZKEVM_L2_BRIDGE_NETWORK_ID env variable." - ) - - {:stop, :normal, %{}} - - {:rollup_index_l2_undefined, true} -> - Logger.error( - "Rollup index is undefined for L2. Please, check INDEXER_POLYGON_ZKEVM_L2_BRIDGE_ROLLUP_INDEX env variable." - ) - - {:stop, :normal, %{}} - - {:bridge_contract_address_is_valid, false} -> - Logger.error("PolygonZkEVMBridge contract address is invalid or not defined.") - {:stop, :normal, %{}} - - {:start_block_valid, false, last_l1_block_number, safe_block} -> - Logger.error("Invalid L1 Start Block value. Please, check the value and polygon_zkevm_bridge table.") - Logger.error("last_l1_block_number = #{inspect(last_l1_block_number)}") - Logger.error("safe_block = #{inspect(safe_block)}") - {:stop, :normal, %{}} - - {:error, error_data} -> - Logger.error( - "Cannot get last L1 transaction from RPC by its hash, latest block, or block timestamp by its number due to RPC error: #{inspect(error_data)}" - ) - - {:stop, :normal, %{}} - - {:l1_transaction_not_found, true} -> - Logger.error( - "Cannot find last L1 transaction from RPC by its hash. Probably, there was a reorg on L1 chain. Please, check polygon_zkevm_bridge table." - ) - - {:stop, :normal, %{}} - - _ -> - Logger.error("L1 Start Block is invalid or zero.") - {:stop, :normal, %{}} - end - end - - @impl GenServer - def handle_info( - :continue, - %{ - bridge_contract: bridge_contract, - block_check_interval: block_check_interval, - start_block: start_block, - end_block: end_block, - json_rpc_named_arguments: json_rpc_named_arguments, - rollup_network_id_l1: rollup_network_id_l1, - rollup_network_id_l2: rollup_network_id_l2, - rollup_index_l1: rollup_index_l1, - rollup_index_l2: rollup_index_l2 - } = state - ) do - time_before = Timex.now() - - last_written_block = - start_block..end_block - |> Enum.chunk_every(@eth_get_logs_range_size) - |> Enum.reduce_while(start_block - 1, fn current_chunk, _ -> - chunk_start = List.first(current_chunk) - chunk_end = List.last(current_chunk) - - if chunk_start <= chunk_end do - Helper.log_blocks_chunk_handling(chunk_start, chunk_end, start_block, end_block, nil, :L1) - - operations = - {chunk_start, chunk_end} - |> get_logs_all(bridge_contract, json_rpc_named_arguments) - |> prepare_operations( - rollup_network_id_l1, - rollup_network_id_l2, - rollup_index_l1, - rollup_index_l2, - json_rpc_named_arguments, - json_rpc_named_arguments - ) - - import_operations(operations) - - Helper.log_blocks_chunk_handling( - chunk_start, - chunk_end, - start_block, - end_block, - "#{Enum.count(operations)} L1 operation(s)", - :L1 - ) - end - - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) - - if !is_nil(reorg_block) && reorg_block > 0 do - reorg_handle(reorg_block) - {:halt, if(reorg_block <= chunk_end, do: reorg_block - 1, else: chunk_end)} - else - {:cont, chunk_end} - end - end) - - new_start_block = last_written_block + 1 - - {:ok, new_end_block} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()) - - delay = - if new_end_block == last_written_block do - # there is no new block, so wait for some time to let the chain issue the new block - max(block_check_interval - Timex.diff(Timex.now(), time_before, :milliseconds), 0) - else - 0 - end - - Process.send_after(self(), :continue, delay) - - {:noreply, %{state | start_block: new_start_block, end_block: new_end_block}} - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - @doc """ - Returns L1 RPC URL for this module. - """ - @spec l1_rpc_url() :: binary() - def l1_rpc_url do - Application.get_all_env(:indexer)[__MODULE__][:rpc] - end - - @doc """ - Determines if `Indexer.Fetcher.RollupL1ReorgMonitor` module must be up - for this module. - - ## Returns - - `true` if the reorg monitor must be active, `false` otherwise. - """ - @spec requires_l1_reorg_monitor?() :: boolean() - def requires_l1_reorg_monitor? do - module_config = Application.get_all_env(:indexer)[__MODULE__] - not is_nil(module_config[:start_block]) - end - - defp reorg_handle(reorg_block) do - {deleted_count, _} = - Repo.delete_all(from(b in Bridge, where: b.type == :deposit and b.block_number >= ^reorg_block)) - - if deleted_count > 0 do - Logger.warning( - "As L1 reorg was detected, some deposits with block_number >= #{reorg_block} were removed from polygon_zkevm_bridge table. Number of removed rows: #{deleted_count}." - ) - end - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l1_tokens.ex b/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l1_tokens.ex deleted file mode 100644 index c208b9f6c7b0..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l1_tokens.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule Indexer.Fetcher.PolygonZkevm.BridgeL1Tokens do - @moduledoc """ - Fetches information about L1 tokens for zkEVM bridge. - """ - - use Indexer.Fetcher, restart: :permanent - use Spandex.Decorators - - import Ecto.Query - - alias Explorer.Repo - alias Indexer.{BufferedTask, Helper} - alias Indexer.Fetcher.PolygonZkevm.{Bridge, BridgeL1} - - @behaviour BufferedTask - - @default_max_batch_size 1 - @default_max_concurrency 10 - - @doc false - def child_spec([init_options, gen_server_options]) do - rpc = Application.get_all_env(:indexer)[BridgeL1][:rpc] - json_rpc_named_arguments = Helper.json_rpc_named_arguments(rpc) - - merged_init_opts = - defaults() - |> Keyword.merge(init_options) - |> Keyword.merge(state: json_rpc_named_arguments) - - Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) - end - - @impl BufferedTask - def init(_, _, _) do - {0, []} - end - - @impl BufferedTask - def run(l1_token_addresses, json_rpc_named_arguments) when is_list(l1_token_addresses) do - l1_token_addresses - |> Bridge.token_addresses_to_ids(json_rpc_named_arguments) - |> Enum.each(fn {l1_token_address, l1_token_id} -> - Repo.update_all( - from(b in Explorer.Chain.PolygonZkevm.Bridge, where: b.l1_token_address == ^l1_token_address), - set: [l1_token_id: l1_token_id, l1_token_address: nil] - ) - end) - end - - @doc """ - Fetches L1 token data asynchronously. - """ - def async_fetch(data) do - async_fetch(data, Application.get_env(:indexer, __MODULE__.Supervisor)[:enabled]) - end - - def async_fetch(_data, false), do: :ok - - def async_fetch(operations, _enabled) do - l1_token_addresses = - operations - |> Enum.reject(fn operation -> is_nil(operation.l1_token_address) end) - |> Enum.map(fn operation -> operation.l1_token_address end) - |> Enum.uniq() - - BufferedTask.buffer(__MODULE__, l1_token_addresses, true) - end - - defp defaults do - [ - flush_interval: 100, - max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, - max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, - poll: false, - task_supervisor: __MODULE__.TaskSupervisor - ] - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l2.ex b/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l2.ex deleted file mode 100644 index 983f69a39cc1..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/bridge_l2.ex +++ /dev/null @@ -1,223 +0,0 @@ -defmodule Indexer.Fetcher.PolygonZkevm.BridgeL2 do - @moduledoc """ - Fills polygon_zkevm_bridge DB table. - """ - - use GenServer - use Indexer.Fetcher - - require Logger - - import Ecto.Query - import Explorer.Helper, only: [parse_integer: 1] - - import Indexer.Fetcher.PolygonZkevm.Bridge, - only: [get_logs_all: 3, import_operations: 1, prepare_operations: 7] - - alias Explorer.Chain.PolygonZkevm.{Bridge, Reader} - alias Explorer.Repo - alias Indexer.Fetcher.PolygonZkevm.BridgeL1 - alias Indexer.Helper - - @eth_get_logs_range_size 1000 - @fetcher_name :polygon_zkevm_bridge_l2 - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(args) do - json_rpc_named_arguments = args[:json_rpc_named_arguments] - {:ok, %{}, {:continue, json_rpc_named_arguments}} - end - - @impl GenServer - def handle_continue(json_rpc_named_arguments, _state) do - Logger.metadata(fetcher: @fetcher_name) - # two seconds pause needed to avoid exceeding Supervisor restart intensity when DB issues - Process.send_after(self(), :init_with_delay, 2000) - {:noreply, %{json_rpc_named_arguments: json_rpc_named_arguments}} - end - - @impl GenServer - def handle_info(:init_with_delay, %{json_rpc_named_arguments: json_rpc_named_arguments} = state) do - env = Application.get_all_env(:indexer)[__MODULE__] - env_l1 = Application.get_all_env(:indexer)[BridgeL1] - - with {:start_block_undefined, false} <- {:start_block_undefined, is_nil(env[:start_block])}, - rpc_l1 = env_l1[:rpc], - {:rpc_l1_undefined, false} <- {:rpc_l1_undefined, is_nil(rpc_l1)}, - {:rollup_network_id_l1_is_valid, true} <- - {:rollup_network_id_l1_is_valid, - !is_nil(env_l1[:rollup_network_id_l1]) and env_l1[:rollup_network_id_l1] >= 0}, - {:rollup_network_id_l2_is_valid, true} <- - {:rollup_network_id_l2_is_valid, !is_nil(env[:rollup_network_id_l2]) and env[:rollup_network_id_l2] > 0}, - {:rollup_index_l2_undefined, false} <- {:rollup_index_l2_undefined, is_nil(env[:rollup_index_l2])}, - {:bridge_contract_address_is_valid, true} <- - {:bridge_contract_address_is_valid, Helper.address_correct?(env[:bridge_contract])}, - start_block = parse_integer(env[:start_block]), - false <- is_nil(start_block), - true <- start_block > 0, - {last_l2_block_number, last_l2_transaction_hash} = Reader.last_l2_item(), - {:ok, latest_block} = - Helper.get_block_number_by_tag("latest", json_rpc_named_arguments, Helper.infinite_retries_number()), - {:start_block_valid, true} <- - {:start_block_valid, - (start_block <= last_l2_block_number || last_l2_block_number == 0) && start_block <= latest_block}, - {:ok, last_l2_transaction} <- - Helper.get_transaction_by_hash(last_l2_transaction_hash, json_rpc_named_arguments), - {:l2_transaction_not_found, false} <- - {:l2_transaction_not_found, !is_nil(last_l2_transaction_hash) && is_nil(last_l2_transaction)} do - Process.send(self(), :continue, []) - - {:noreply, - %{ - bridge_contract: env[:bridge_contract], - json_rpc_named_arguments: json_rpc_named_arguments, - json_rpc_named_arguments_l1: Helper.json_rpc_named_arguments(rpc_l1), - end_block: latest_block, - start_block: max(start_block, last_l2_block_number), - rollup_network_id_l1: env_l1[:rollup_network_id_l1], - rollup_network_id_l2: env[:rollup_network_id_l2], - rollup_index_l1: env_l1[:rollup_index_l1], - rollup_index_l2: env[:rollup_index_l2] - }} - else - {:start_block_undefined, true} -> - # the process shouldn't start if the start block is not defined - {:stop, :normal, state} - - {:rpc_l1_undefined, true} -> - Logger.error("L1 RPC URL is not defined.") - {:stop, :normal, state} - - {:rollup_network_id_l1_is_valid, false} -> - Logger.error( - "Invalid network ID for L1. Please, check INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NETWORK_ID env variable." - ) - - {:stop, :normal, %{}} - - {:rollup_network_id_l2_is_valid, false} -> - Logger.error( - "Invalid network ID for L2. Please, check INDEXER_POLYGON_ZKEVM_L2_BRIDGE_NETWORK_ID env variable." - ) - - {:stop, :normal, %{}} - - {:rollup_index_l2_undefined, true} -> - Logger.error( - "Rollup index is undefined for L2. Please, check INDEXER_POLYGON_ZKEVM_L2_BRIDGE_ROLLUP_INDEX env variable." - ) - - {:stop, :normal, %{}} - - {:bridge_contract_address_is_valid, false} -> - Logger.error("PolygonZkEVMBridge contract address is invalid or not defined.") - {:stop, :normal, state} - - {:start_block_valid, false} -> - Logger.error("Invalid L2 Start Block value. Please, check the value and polygon_zkevm_bridge table.") - {:stop, :normal, state} - - {:error, error_data} -> - Logger.error( - "Cannot get last L2 transaction from RPC by its hash or latest block due to RPC error: #{inspect(error_data)}" - ) - - {:stop, :normal, state} - - {:l2_transaction_not_found, true} -> - Logger.error( - "Cannot find last L2 transaction from RPC by its hash. Probably, there was a reorg on L2 chain. Please, check polygon_zkevm_bridge table." - ) - - {:stop, :normal, state} - - _ -> - Logger.error("L2 Start Block is invalid or zero.") - {:stop, :normal, state} - end - end - - @impl GenServer - def handle_info( - :continue, - %{ - bridge_contract: bridge_contract, - start_block: start_block, - end_block: end_block, - json_rpc_named_arguments: json_rpc_named_arguments, - json_rpc_named_arguments_l1: json_rpc_named_arguments_l1, - rollup_network_id_l1: rollup_network_id_l1, - rollup_network_id_l2: rollup_network_id_l2, - rollup_index_l1: rollup_index_l1, - rollup_index_l2: rollup_index_l2 - } = state - ) do - start_block..end_block - |> Enum.chunk_every(@eth_get_logs_range_size) - |> Enum.each(fn current_chunk -> - chunk_start = List.first(current_chunk) - chunk_end = List.last(current_chunk) - - if chunk_start <= chunk_end do - Helper.log_blocks_chunk_handling(chunk_start, chunk_end, start_block, end_block, nil, :L2) - - operations = - {chunk_start, chunk_end} - |> get_logs_all(bridge_contract, json_rpc_named_arguments) - |> prepare_operations( - rollup_network_id_l1, - rollup_network_id_l2, - rollup_index_l1, - rollup_index_l2, - json_rpc_named_arguments, - json_rpc_named_arguments_l1 - ) - - import_operations(operations) - - Helper.log_blocks_chunk_handling( - chunk_start, - chunk_end, - start_block, - end_block, - "#{Enum.count(operations)} L2 operation(s)", - :L2 - ) - end - end) - - {:stop, :normal, state} - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - def reorg_handle(reorg_block) do - {deleted_count, _} = - Repo.delete_all(from(b in Bridge, where: b.type == :withdrawal and b.block_number >= ^reorg_block)) - - if deleted_count > 0 do - Logger.warning( - "As L2 reorg was detected, some withdrawals with block_number >= #{reorg_block} were removed from polygon_zkevm_bridge table. Number of removed rows: #{deleted_count}." - ) - end - end -end diff --git a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/transaction_batch.ex b/apps/indexer/lib/indexer/fetcher/polygon_zkevm/transaction_batch.ex deleted file mode 100644 index 6efeff072e7c..000000000000 --- a/apps/indexer/lib/indexer/fetcher/polygon_zkevm/transaction_batch.ex +++ /dev/null @@ -1,341 +0,0 @@ -defmodule Indexer.Fetcher.PolygonZkevm.TransactionBatch do - @moduledoc """ - Fills polygon_zkevm_transaction_batches DB table. - """ - - use GenServer - use Indexer.Fetcher - - require Logger - - import EthereumJSONRPC, only: [integer_to_quantity: 1, json_rpc: 2, quantity_to_integer: 1] - - alias Explorer.Chain - alias Explorer.Chain.Events.Publisher - alias Explorer.Chain.PolygonZkevm.Reader - alias Indexer.Helper - alias Indexer.Prometheus.Instrumenter - - @zero_hash "0000000000000000000000000000000000000000000000000000000000000000" - - def child_spec(start_link_arguments) do - spec = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments}, - restart: :transient, - type: :worker - } - - Supervisor.child_spec(spec, []) - end - - def start_link(args, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) - end - - @impl GenServer - def init(args) do - Logger.metadata(fetcher: :polygon_zkevm_transaction_batches) - - config = Application.get_all_env(:indexer)[Indexer.Fetcher.PolygonZkevm.TransactionBatch] - chunk_size = config[:chunk_size] - recheck_interval = config[:recheck_interval] - - # two seconds pause needed to avoid exceeding Supervisor restart intensity when DB issues - Process.send_after(self(), :continue, 2000) - - {:ok, - %{ - chunk_size: chunk_size, - json_rpc_named_arguments: args[:json_rpc_named_arguments], - prev_latest_batch_number: 0, - prev_virtual_batch_number: 0, - prev_verified_batch_number: 0, - recheck_interval: recheck_interval - }} - end - - @impl GenServer - def handle_info( - :continue, - %{ - chunk_size: chunk_size, - json_rpc_named_arguments: json_rpc_named_arguments, - prev_latest_batch_number: prev_latest_batch_number, - prev_virtual_batch_number: prev_virtual_batch_number, - prev_verified_batch_number: prev_verified_batch_number, - recheck_interval: recheck_interval - } = state - ) do - {latest_batch_number, virtual_batch_number, verified_batch_number} = - fetch_latest_batch_numbers(json_rpc_named_arguments) - - {new_state, handle_duration} = - if latest_batch_number > prev_latest_batch_number or virtual_batch_number > prev_virtual_batch_number or - verified_batch_number > prev_verified_batch_number do - start_batch_number = Reader.last_verified_batch_number() + 1 - end_batch_number = latest_batch_number - - log_message = - "" - |> make_log_message(latest_batch_number, prev_latest_batch_number, "latest") - |> make_log_message(virtual_batch_number, prev_virtual_batch_number, "virtual") - |> make_log_message(verified_batch_number, prev_verified_batch_number, "verified") - - Logger.info(log_message <> "Handling the batch range #{start_batch_number}..#{end_batch_number}.") - - {handle_duration, _} = - :timer.tc(fn -> - handle_batch_range(start_batch_number, end_batch_number, json_rpc_named_arguments, chunk_size) - end) - - { - %{ - state - | prev_latest_batch_number: latest_batch_number, - prev_virtual_batch_number: virtual_batch_number, - prev_verified_batch_number: verified_batch_number - }, - div(handle_duration, 1000) - } - else - {state, 0} - end - - Process.send_after(self(), :continue, max(:timer.seconds(recheck_interval) - handle_duration, 0)) - - {:noreply, new_state} - end - - @impl GenServer - def handle_info({ref, _result}, state) do - Process.demonitor(ref, [:flush]) - {:noreply, state} - end - - defp handle_batch_range(start_batch_number, end_batch_number, json_rpc_named_arguments, chunk_size) do - start_batch_number..end_batch_number - |> Enum.chunk_every(chunk_size) - |> Enum.each(fn chunk -> - chunk_start = List.first(chunk) - chunk_end = List.last(chunk) - - log_batches_chunk_handling(chunk_start, chunk_end, start_batch_number, end_batch_number) - fetch_and_save_batches(chunk_start, chunk_end, json_rpc_named_arguments) - end) - end - - defp log_batches_chunk_handling(chunk_start, chunk_end, start_block, end_block) do - target_range = - if chunk_start != start_block or chunk_end != end_block do - percentage = - (chunk_end - start_block + 1) - |> Decimal.div(end_block - start_block + 1) - |> Decimal.mult(100) - |> Decimal.round(2) - |> Decimal.to_string() - - " Target range: #{start_block}..#{end_block}. Progress: #{percentage}%" - else - "" - end - - if chunk_start == chunk_end do - Logger.info("Handling batch ##{chunk_start}.#{target_range}") - else - Logger.info("Handling batch range #{chunk_start}..#{chunk_end}.#{target_range}") - end - end - - defp make_log_message(prev_message, batch_number, prev_batch_number, type) do - if batch_number > prev_batch_number do - prev_message <> - "Found a new #{type} batch number #{batch_number}. Previous #{type} batch number is #{prev_batch_number}. " - else - prev_message - end - end - - defp fetch_and_save_batches(batch_start, batch_end, json_rpc_named_arguments) do - # For every batch from batch_start to batch_end request the batch info - requests = - batch_start - |> Range.new(batch_end, 1) - |> Enum.map(fn batch_number -> - EthereumJSONRPC.request(%{ - id: batch_number, - method: "zkevm_getBatchByNumber", - params: [integer_to_quantity(batch_number), false] - }) - end) - - error_message = - &"Cannot call zkevm_getBatchByNumber for the batch range #{batch_start}..#{batch_end}. Error: #{inspect(&1)}" - - {:ok, responses} = Helper.repeated_call(&json_rpc/2, [requests, json_rpc_named_arguments], error_message, 3) - - # For every batch info extract batches' L1 sequence transaction and L1 verify transaction - {sequence_hashes, verify_hashes} = - responses - |> Enum.reduce({[], []}, fn res, {sequences, verifies} = _acc -> - send_sequences_transaction_hash = get_transaction_hash(res.result, "sendSequencesTxHash") - verify_batch_transaction_hash = get_transaction_hash(res.result, "verifyBatchTxHash") - - sequences = - if send_sequences_transaction_hash != @zero_hash do - [Base.decode16!(send_sequences_transaction_hash, case: :mixed) | sequences] - else - sequences - end - - verifies = - if verify_batch_transaction_hash != @zero_hash do - [Base.decode16!(verify_batch_transaction_hash, case: :mixed) | verifies] - else - verifies - end - - {sequences, verifies} - end) - - # All L1 transactions in one list without repetition - l1_transaction_hashes = Enum.uniq(sequence_hashes ++ verify_hashes) - - # Receive all IDs for L1 transactions - hash_to_id = - l1_transaction_hashes - |> Reader.lifecycle_transactions() - |> Enum.reduce(%{}, fn {hash, id}, acc -> - Map.put(acc, hash.bytes, id) - end) - - # For every batch build batch representation, collect associated L1 and L2 transactions - {batches_to_import, l2_transactions_to_import, l1_transactions_to_import, _, _} = - responses - |> Enum.reduce({[], [], [], Reader.next_id(), hash_to_id}, fn res, - {batches, l2_transactions, l1_transactions, next_id, - hash_to_id} = _acc -> - number = quantity_to_integer(Map.get(res.result, "number")) - - # the timestamp is undefined for unfinalized batches - timestamp = - case DateTime.from_unix(quantity_to_integer(Map.get(res.result, "timestamp", 0xFFFFFFFFFFFFFFFF))) do - {:ok, ts} -> ts - _ -> nil - end - - l2_transaction_hashes = Map.get(res.result, "transactions") - global_exit_root = Map.get(res.result, "globalExitRoot") - acc_input_hash = Map.get(res.result, "accInputHash") - state_root = Map.get(res.result, "stateRoot") - - # Get ID for sequence transaction (new ID if the batch is just sequenced) - {sequence_id, l1_transactions, next_id, hash_to_id} = - res.result - |> get_transaction_hash("sendSequencesTxHash") - |> handle_transaction_hash(hash_to_id, next_id, l1_transactions, false) - - # Get ID for verify transaction (new ID if the batch is just verified) - {verify_id, l1_transactions, next_id, hash_to_id} = - res.result - |> get_transaction_hash("verifyBatchTxHash") - |> handle_transaction_hash(hash_to_id, next_id, l1_transactions, true) - - # Associate every transaction from batch with the batch number - l2_transactions_append = - l2_transaction_hashes - |> Kernel.||([]) - |> Enum.map(fn l2_transaction_hash -> - %{ - batch_number: number, - hash: l2_transaction_hash - } - end) - - batch = %{ - number: number, - timestamp: timestamp, - l2_transactions_count: Enum.count(l2_transactions_append), - global_exit_root: global_exit_root, - acc_input_hash: acc_input_hash, - state_root: state_root, - sequence_id: sequence_id, - verify_id: verify_id - } - - {[batch | batches], l2_transactions ++ l2_transactions_append, l1_transactions, next_id, hash_to_id} - end) - - # Update batches list, L1 transactions list and L2 transaction list - {:ok, _} = - Chain.import(%{ - polygon_zkevm_lifecycle_transactions: %{params: l1_transactions_to_import}, - polygon_zkevm_transaction_batches: %{params: batches_to_import}, - polygon_zkevm_batch_transactions: %{params: l2_transactions_to_import}, - timeout: :infinity - }) - - last_batch = - batches_to_import - |> Enum.max_by(& &1.number, fn -> nil end) - - if last_batch do - Instrumenter.set_latest_batch(last_batch.number, last_batch.timestamp) - end - - confirmed_batches = - Enum.filter(batches_to_import, fn batch -> not is_nil(batch.sequence_id) and batch.sequence_id > 0 end) - - # Publish update for open batches Views in BS app with the new confirmed batches - if not Enum.empty?(confirmed_batches) do - Publisher.broadcast([{:zkevm_confirmed_batches, confirmed_batches}], :realtime) - end - end - - defp fetch_latest_batch_numbers(json_rpc_named_arguments) do - requests = [ - EthereumJSONRPC.request(%{id: 0, method: "zkevm_batchNumber", params: []}), - EthereumJSONRPC.request(%{id: 1, method: "zkevm_virtualBatchNumber", params: []}), - EthereumJSONRPC.request(%{id: 2, method: "zkevm_verifiedBatchNumber", params: []}) - ] - - error_message = &"Cannot call zkevm_batchNumber. Error: #{inspect(&1)}" - - {:ok, responses} = Helper.repeated_call(&json_rpc/2, [requests, json_rpc_named_arguments], error_message, 3) - - latest_batch_number = - Enum.find_value(responses, fn resp -> if resp.id == 0, do: quantity_to_integer(resp.result) end) - - virtual_batch_number = - Enum.find_value(responses, fn resp -> if resp.id == 1, do: quantity_to_integer(resp.result) end) - - verified_batch_number = - Enum.find_value(responses, fn resp -> if resp.id == 2, do: quantity_to_integer(resp.result) end) - - {latest_batch_number, virtual_batch_number, verified_batch_number} - end - - defp get_transaction_hash(result, type) do - case Map.get(result, type) do - "0x" <> transaction_hash -> transaction_hash - nil -> @zero_hash - end - end - - defp handle_transaction_hash(encoded_transaction_hash, hash_to_id, next_id, l1_transactions, is_verify) do - if encoded_transaction_hash != @zero_hash do - transaction_hash = Base.decode16!(encoded_transaction_hash, case: :mixed) - - id = Map.get(hash_to_id, transaction_hash) - - if is_nil(id) do - {next_id, [%{id: next_id, hash: transaction_hash, is_verify: is_verify} | l1_transactions], next_id + 1, - Map.put(hash_to_id, transaction_hash, next_id)} - else - {id, l1_transactions, next_id, hash_to_id} - end - else - {nil, l1_transactions, next_id, hash_to_id} - end - end -end diff --git a/apps/indexer/lib/indexer/fetcher/replaced_transaction.ex b/apps/indexer/lib/indexer/fetcher/replaced_transaction.ex index 152e719340d6..fa02738e8aa6 100644 --- a/apps/indexer/lib/indexer/fetcher/replaced_transaction.ex +++ b/apps/indexer/lib/indexer/fetcher/replaced_transaction.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ReplacedTransaction do @moduledoc """ Finds and updates replaced transactions. @@ -8,8 +9,7 @@ defmodule Indexer.Fetcher.ReplacedTransaction do require Logger - alias Explorer.Chain - alias Explorer.Chain.Hash + alias Explorer.Chain.{Hash, Transaction} alias Indexer.{BufferedTask, Tracer} alias Indexer.Fetcher.ReplacedTransaction.Supervisor, as: ReplacedTransactionSupervisor @@ -58,7 +58,7 @@ defmodule Indexer.Fetcher.ReplacedTransaction do def init(initial, reducer, _) do {:ok, final} = [:block_hash, :nonce, :from_address_hash, :hash] - |> Chain.stream_pending_transactions( + |> Transaction.stream_pending_transactions( initial, fn transaction_fields, acc -> transaction_fields @@ -80,7 +80,11 @@ defmodule Indexer.Fetcher.ReplacedTransaction do {block_hash_bytes, nonce, from_address_hash_bytes} end - defp pending_entry(%{hash: %Hash{bytes: hash}, nonce: nonce, from_address_hash: %Hash{bytes: from_address_hash_bytes}}) do + defp pending_entry(%{ + hash: %Hash{bytes: hash}, + nonce: nonce, + from_address_hash: %Hash{bytes: from_address_hash_bytes} + }) do {:pending, nonce, from_address_hash_bytes, hash} end @@ -115,11 +119,11 @@ defmodule Indexer.Fetcher.ReplacedTransaction do pending |> Enum.map(&pending_params/1) - |> Chain.find_and_update_replaced_transactions() + |> Transaction.find_and_update_replaced_transactions() realtime |> Enum.map(¶ms/1) - |> Chain.update_replaced_transactions() + |> Transaction.update_replaced_transactions() :ok rescue diff --git a/apps/indexer/lib/indexer/fetcher/rollup_l1_reorg_monitor.ex b/apps/indexer/lib/indexer/fetcher/rollup_l1_reorg_monitor.ex index 39b9f84fa758..24917ca9ecd6 100644 --- a/apps/indexer/lib/indexer/fetcher/rollup_l1_reorg_monitor.ex +++ b/apps/indexer/lib/indexer/fetcher/rollup_l1_reorg_monitor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.RollupL1ReorgMonitor do @moduledoc """ A module to monitor and catch L1 reorgs and make queue of the reorg blocks @@ -14,8 +15,8 @@ defmodule Indexer.Fetcher.RollupL1ReorgMonitor do require Logger alias Explorer.Chain.Cache.LatestL1BlockNumber - alias Explorer.Chain.RollupReorgMonitorQueue alias Indexer.Helper + alias Indexer.RollupReorgMonitorQueue @fetcher_name :rollup_l1_reorg_monitor @start_recheck_period_seconds 3 @@ -32,17 +33,6 @@ defmodule Indexer.Fetcher.RollupL1ReorgMonitor do Indexer.Fetcher.Optimism.WithdrawalEvent ] - :polygon_edge -> - [ - Indexer.Fetcher.PolygonEdge.Deposit, - Indexer.Fetcher.PolygonEdge.WithdrawalExit - ] - - :polygon_zkevm -> - [ - Indexer.Fetcher.PolygonZkevm.BridgeL1 - ] - :scroll -> [ Indexer.Fetcher.Scroll.Batch, @@ -106,6 +96,9 @@ defmodule Indexer.Fetcher.RollupL1ReorgMonitor do def handle_continue(:ok, _state) do Logger.metadata(fetcher: @fetcher_name) + # two seconds pause needed to avoid exceeding Supervisor restart intensity when RPC issues + :timer.sleep(2000) + modules_using_reorg_monitor = modules_can_use_reorg_monitor() |> Enum.filter(& &1.requires_l1_reorg_monitor?()) @@ -166,7 +159,7 @@ defmodule Indexer.Fetcher.RollupL1ReorgMonitor do if latest < prev_latest do Logger.warning("Reorg detected: previous latest block ##{prev_latest}, current latest block ##{latest}.") - Enum.each(modules, &RollupReorgMonitorQueue.reorg_block_push(latest, &1)) + Enum.each(modules, &RollupReorgMonitorQueue.push(latest, &1)) end Process.send_after(self(), :reorg_monitor, block_check_interval) diff --git a/apps/indexer/lib/indexer/fetcher/rootstock_data.ex b/apps/indexer/lib/indexer/fetcher/rootstock_data.ex index 09b683e6563a..4e44995f4160 100644 --- a/apps/indexer/lib/indexer/fetcher/rootstock_data.ex +++ b/apps/indexer/lib/indexer/fetcher/rootstock_data.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.RootstockData do @moduledoc """ Refetch `minimum_gas_price`, `bitcoin_merged_mining_header`, `bitcoin_merged_mining_coinbase_transaction`, @@ -48,7 +49,7 @@ defmodule Indexer.Fetcher.RootstockData do json_rpc_named_arguments = opts[:json_rpc_named_arguments] - unless json_rpc_named_arguments do + if !json_rpc_named_arguments do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.init to allow for json_rpc calls when running." end diff --git a/apps/indexer/lib/indexer/fetcher/scroll/batch.ex b/apps/indexer/lib/indexer/fetcher/scroll/batch.ex index da6d6054dcb1..68bf0362c111 100644 --- a/apps/indexer/lib/indexer/fetcher/scroll/batch.ex +++ b/apps/indexer/lib/indexer/fetcher/scroll/batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Scroll.Batch do @moduledoc """ The module for scanning L1 RPC node for the `CommitBatch` and `FinalizeBatch` events @@ -24,14 +25,14 @@ defmodule Indexer.Fetcher.Scroll.Batch do alias ABI.{FunctionSelector, TypeDecoder} alias Ecto.Multi alias EthereumJSONRPC.Logs + alias Explorer.{Chain, Repo} alias Explorer.Chain.Block.Range, as: BlockRange - alias Explorer.Chain.RollupReorgMonitorQueue alias Explorer.Chain.Scroll.{Batch, BatchBundle, Reader} - alias Explorer.{Chain, Repo} alias Indexer.Fetcher.RollupL1ReorgMonitor alias Indexer.Fetcher.Scroll.Helper, as: ScrollHelper alias Indexer.Helper alias Indexer.Prometheus.Instrumenter + alias Indexer.RollupReorgMonitorQueue # 32-byte signature of the event CommitBatch(uint256 indexed batchIndex, bytes32 indexed batchHash) @commit_batch_event "0x2c32d4ae151744d0bf0b9464a3e897a1d17ed2f1af71f7c9a75f12ce0d28238f" @@ -245,7 +246,7 @@ defmodule Indexer.Fetcher.Scroll.Batch do ) end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) + reorg_block = RollupReorgMonitorQueue.pop(__MODULE__) if !is_nil(reorg_block) && reorg_block > 0 do reorg_handle(reorg_block) diff --git a/apps/indexer/lib/indexer/fetcher/scroll/bridge.ex b/apps/indexer/lib/indexer/fetcher/scroll/bridge.ex index 9b37d023e598..c5840ac6a17c 100644 --- a/apps/indexer/lib/indexer/fetcher/scroll/bridge.ex +++ b/apps/indexer/lib/indexer/fetcher/scroll/bridge.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Scroll.Bridge do @moduledoc """ Contains common functions for Indexer.Fetcher.Scroll.Bridge* modules. @@ -15,9 +16,9 @@ defmodule Indexer.Fetcher.Scroll.Bridge do alias EthereumJSONRPC.Logs alias Explorer.Chain - alias Explorer.Chain.RollupReorgMonitorQueue alias Indexer.Fetcher.Scroll.BridgeL1 alias Indexer.Helper, as: IndexerHelper + alias Indexer.RollupReorgMonitorQueue # 32-byte signature of the event SentMessage(address indexed sender, address indexed target, uint256 value, uint256 messageNonce, uint256 gasLimit, bytes message) @sent_message_event "0x104371f3b442861a2a7b82a070afbbaab748bb13757bf47769e170e37809ec1e" @@ -108,7 +109,7 @@ defmodule Indexer.Fetcher.Scroll.Bridge do ) end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(module) + reorg_block = RollupReorgMonitorQueue.pop(module) if !is_nil(reorg_block) && reorg_block > 0 do # credo:disable-for-next-line Credo.Check.Refactor.Nesting diff --git a/apps/indexer/lib/indexer/fetcher/scroll/bridge_l1.ex b/apps/indexer/lib/indexer/fetcher/scroll/bridge_l1.ex index 408cc72b8464..760fc9985b8d 100644 --- a/apps/indexer/lib/indexer/fetcher/scroll/bridge_l1.ex +++ b/apps/indexer/lib/indexer/fetcher/scroll/bridge_l1.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Scroll.BridgeL1 do @moduledoc """ The module for scanning Scroll RPC node on L1 for the message logs (events), parsing them, diff --git a/apps/indexer/lib/indexer/fetcher/scroll/bridge_l2.ex b/apps/indexer/lib/indexer/fetcher/scroll/bridge_l2.ex index 74504bbdeeab..49a106112972 100644 --- a/apps/indexer/lib/indexer/fetcher/scroll/bridge_l2.ex +++ b/apps/indexer/lib/indexer/fetcher/scroll/bridge_l2.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Scroll.BridgeL2 do @moduledoc """ The module for scanning Scroll RPC node on L2 for the message logs (events), parsing them, @@ -21,11 +22,11 @@ defmodule Indexer.Fetcher.Scroll.BridgeL2 do import Ecto.Query - alias Explorer.Chain.RollupReorgMonitorQueue alias Explorer.Chain.Scroll.{Bridge, Reader} alias Explorer.Repo alias Indexer.Fetcher.Scroll.Bridge, as: BridgeFetcher alias Indexer.Helper + alias Indexer.RollupReorgMonitorQueue @fetcher_name :scroll_bridge_l2 @@ -150,6 +151,6 @@ defmodule Indexer.Fetcher.Scroll.BridgeL2 do ) end - RollupReorgMonitorQueue.reorg_block_push(reorg_block, __MODULE__) + RollupReorgMonitorQueue.push(reorg_block, __MODULE__) end end diff --git a/apps/indexer/lib/indexer/fetcher/scroll/helper.ex b/apps/indexer/lib/indexer/fetcher/scroll/helper.ex index 574ff1aa4634..728027b75104 100644 --- a/apps/indexer/lib/indexer/fetcher/scroll/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/scroll/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Scroll.Helper do @moduledoc """ A module to define common Scroll indexer functions. diff --git a/apps/indexer/lib/indexer/fetcher/scroll/l1_fee_param.ex b/apps/indexer/lib/indexer/fetcher/scroll/l1_fee_param.ex index c0b8d0c23bb7..f4c2d6c36570 100644 --- a/apps/indexer/lib/indexer/fetcher/scroll/l1_fee_param.ex +++ b/apps/indexer/lib/indexer/fetcher/scroll/l1_fee_param.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Scroll.L1FeeParam do @moduledoc """ Fills scroll_l1_fee_params DB table. diff --git a/apps/indexer/lib/indexer/fetcher/shibarium/helper.ex b/apps/indexer/lib/indexer/fetcher/shibarium/helper.ex index 592880d957a1..526b03c93301 100644 --- a/apps/indexer/lib/indexer/fetcher/shibarium/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/shibarium/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Shibarium.Helper do @moduledoc """ Common functions for Indexer.Fetcher.Shibarium.* modules. diff --git a/apps/indexer/lib/indexer/fetcher/shibarium/l1.ex b/apps/indexer/lib/indexer/fetcher/shibarium/l1.ex index f65498e88445..a02c2c409da0 100644 --- a/apps/indexer/lib/indexer/fetcher/shibarium/l1.ex +++ b/apps/indexer/lib/indexer/fetcher/shibarium/l1.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Shibarium.L1 do @moduledoc """ Fills shibarium_bridge DB table. @@ -23,11 +24,11 @@ defmodule Indexer.Fetcher.Shibarium.L1 do import Indexer.Fetcher.Shibarium.Helper, only: [calc_operation_hash: 5, prepare_insert_items: 2, recalculate_cached_count: 0] - alias Explorer.Chain.RollupReorgMonitorQueue - alias Explorer.Chain.Shibarium.Bridge alias Explorer.{Chain, Repo} + alias Explorer.Chain.Shibarium.Bridge alias Indexer.Fetcher.RollupL1ReorgMonitor alias Indexer.Helper + alias Indexer.RollupReorgMonitorQueue alias Indexer.Transform.Addresses @block_check_interval_range_size 100 @@ -135,7 +136,7 @@ defmodule Indexer.Fetcher.Shibarium.L1 do {last_l1_block_number, last_l1_transaction_hash} <- get_last_l1_item(), {:start_block_valid, true} <- {:start_block_valid, start_block <= last_l1_block_number || last_l1_block_number == 0}, - json_rpc_named_arguments = json_rpc_named_arguments(rpc), + json_rpc_named_arguments = Helper.json_rpc_named_arguments(rpc), {:ok, last_l1_transaction} <- Helper.get_transaction_by_hash(last_l1_transaction_hash, json_rpc_named_arguments), {:l1_transaction_not_found, false} <- @@ -281,7 +282,7 @@ defmodule Indexer.Fetcher.Shibarium.L1 do ) end - reorg_block = RollupReorgMonitorQueue.reorg_block_pop(__MODULE__) + reorg_block = RollupReorgMonitorQueue.pop(__MODULE__) if !is_nil(reorg_block) && reorg_block > 0 do reorg_handle(reorg_block) @@ -559,21 +560,6 @@ defmodule Indexer.Fetcher.Shibarium.L1 do ) end - defp json_rpc_named_arguments(rpc_url) do - [ - transport: EthereumJSONRPC.HTTP, - transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, - urls: [rpc_url], - http_options: [ - recv_timeout: :timer.minutes(10), - timeout: :timer.minutes(10), - hackney: [pool: :ethereum_jsonrpc] - ] - ] - ] - end - defp prepare_operations(events, json_rpc_named_arguments) do timestamps = events diff --git a/apps/indexer/lib/indexer/fetcher/shibarium/l2.ex b/apps/indexer/lib/indexer/fetcher/shibarium/l2.ex index 3aa2d42826c6..6872ae5a06d8 100644 --- a/apps/indexer/lib/indexer/fetcher/shibarium/l2.ex +++ b/apps/indexer/lib/indexer/fetcher/shibarium/l2.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Shibarium.L2 do @moduledoc """ Fills shibarium_bridge DB table. diff --git a/apps/indexer/lib/indexer/fetcher/signed_authorization_status.ex b/apps/indexer/lib/indexer/fetcher/signed_authorization_status.ex new file mode 100644 index 000000000000..0af51bc556ee --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/signed_authorization_status.ex @@ -0,0 +1,472 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.SignedAuthorizationStatus do + @moduledoc """ + Fetches `status` `t:Explorer.Chain.SignedAuthorization.t/0`. + """ + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + require Logger + + import Ecto.Query, only: [from: 2] + import EthereumJSONRPC, only: [integer_to_quantity: 1] + + import Explorer.Chain.SignedAuthorization.Reader, + only: [ + stream_blocks_to_refetch_signed_authorizations_statuses: 2, + address_hashes_to_latest_authorizations: 1 + ] + + alias EthereumJSONRPC.Utility.RangesHelper + alias Explorer.{Chain, Repo} + alias Explorer.Chain.{Address, Block, BlockNumberHelper, Hash, SignedAuthorization, Transaction} + alias Explorer.Chain.Cache.Accounts + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation + alias Indexer.{BufferedTask, Tracer} + + @failed_to_import "failed to import signed_authorization status for transactions: " + + @type inner_entry :: + {:transaction, %{authority: Hash.Address.t(), nonce: non_neg_integer()}} + | {:authorization, SignedAuthorization.t()} + + @typedoc """ + Each entry is a list of all transactions and signed authorizations from the same block. + + - `:block_number` - The block number of given batch. + - `:block_hash` - The block hash of given batch. + - `:entries` - The signed authorizations merged with transaction nonces. + """ + @type entry :: %{ + required(:block_number) => Block.block_number(), + required(:block_hash) => Hash.Full.t(), + optional(:entries) => [inner_entry()] + } + + @behaviour BufferedTask + + @default_max_batch_size 10 + @default_max_concurrency 1 + + @doc """ + Enqueues a batch of transactions to fetch and handle signed authorization statuses. + Only works correctly if all transactions and signed authorizations from the particular block + are present in the list at the same time. + """ + @spec async_fetch([Transaction.t()], [SignedAuthorization.t()], boolean(), integer()) :: :ok + def async_fetch( + transactions, + signed_authorizations, + realtime?, + timeout \\ 5000 + ) do + grouped_signed_authorizations = signed_authorizations |> Enum.group_by(& &1.transaction_hash) + + BufferedTask.buffer( + __MODULE__, + transactions + |> RangesHelper.filter_traceable_block_numbers() + |> Enum.map(&Map.put(&1, :signed_authorizations, Map.get(grouped_signed_authorizations, &1.hash, []))) + |> entries_from_transactions(), + realtime?, + timeout + ) + end + + # Chunks a list of transactions into entries, with all transactions from the same block grouped together. + @spec entries_from_transactions([Transaction.t()]) :: [entry()] + defp entries_from_transactions(transactions) do + transactions + |> Enum.group_by(& &1.block_hash) + |> Enum.map(fn {block_hash, block_transactions} -> + %{ + block_number: block_transactions |> List.first() |> Map.get(:block_number), + block_hash: block_hash, + entries: + block_transactions + |> Enum.sort_by(& &1.index) + |> Enum.flat_map(&transaction_to_inner_entries/1) + } + end) + |> Enum.sort_by(& &1.block_number) + end + + # Extract all nonce changes from transaction with signed authorizations. + # + # Any transaction change the nonce of the transaction sender. + # + # Additionally, EIP7702 transactions may change the nonce of successful EIP7702 tuple authorities. + @spec transaction_to_inner_entries(Transaction.t()) :: [inner_entry()] + defp transaction_to_inner_entries(transaction) do + [ + {:transaction, %{authority: transaction.from_address_hash, nonce: transaction.nonce}} + | transaction + |> Map.get(:signed_authorizations, []) + |> Enum.sort_by(& &1.index) + |> Enum.map(&{:authorization, &1}) + ] + end + + @doc false + def child_spec([init_options, gen_server_options]) do + {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) + + if !state do + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> + "to allow for json_rpc calls when running." + end + + merged_init_opts = + defaults() + |> Keyword.merge(mergeable_init_options) + |> Keyword.put(:state, state) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial, reducer, _) do + stream_reducer = RangesHelper.stream_reducer_traceable(reducer) + + # here we stream only block hashes, transactions are preloaded later by `preload_entries/1` + {:ok, final} = + stream_blocks_to_refetch_signed_authorizations_statuses( + initial, + stream_reducer + ) + + final + end + + @doc """ + Processes a batch of entries to fetch and handle signed authorization statuses. + This function is executed as part of the `BufferedTask` behavior. + + ## Parameters + + - `entries`: A list of entries to process. + - `json_rpc_named_arguments`: A list of options for JSON-RPC communication. + + ## Returns + + - `:ok`: Indicates successful processing of the signed authorization statuses. + - `{:retry, any()}`: Returns the entries for retry if an error occurs during + the fetch operation. + """ + @impl BufferedTask + @decorate trace( + name: "fetch", + resource: "Indexer.Fetcher.SignedAuthorizationStatus.run/2", + service: :indexer, + tracer: Tracer + ) + @spec run([entry()], [ + {:throttle_timeout, non_neg_integer()} + | {:transport, atom()} + | {:transport_options, any()} + | {:variant, atom()} + ]) :: :ok | {:retry, any()} + def run(entries, json_rpc_named_arguments) do + Logger.debug("fetching signed authorization statuses") + + # preload transactions for init-generated entries, in case of retry, preloaded transactions are preserved + entries = entries |> Enum.map(&preload_entries/1) + + # compute pairs of block numbers and addresses for which don't know nonce at the start of the block + missing_nonces_for = entries |> Enum.flat_map(&compute_missing_nonces/1) + + with {:fetch, {:ok, nonces_map}} <- {:fetch, fetch_nonces(missing_nonces_for, json_rpc_named_arguments)}, + {new_entries, updated_authorizations} = + entries + |> Enum.map(&compute_statuses(&1, Map.get(nonces_map, &1.block_number, %{}))) + |> Enum.unzip(), + {:import, :ok} <- {:import, import_authorizations(List.flatten(updated_authorizations))} do + entries_to_retry = + new_entries + |> Enum.filter( + &Enum.any?(&1, fn + {:authorization, %{status: nil}} -> true + _ -> false + end) + ) + + if Enum.empty?(entries_to_retry) do + :ok + else + {:retry, entries_to_retry} + end + else + {:fetch, {:error, reason}} -> + Logger.error(fn -> ["failed to fetch address nonces: ", inspect(reason)] end, + error_count: Enum.count(missing_nonces_for) + ) + + {:retry, entries} + + {:import, {:error, reason}} -> + Logger.error(fn -> ["failed to import signed authorizations: ", inspect(reason)] end) + + {:retry, entries} + end + end + + @spec preload_entries(entry()) :: entry() + defp preload_entries(%{entries: entries} = entry) when not is_nil(entries) do + entry + end + + defp preload_entries(%{block_hash: block_hash} = entry) do + block = + block_hash + |> Block.fetch_block_by_hash() + |> Repo.preload([:transactions, [transactions: :signed_authorizations]]) + + entry + |> Map.put( + :entries, + block + |> Map.get(:transactions, []) + |> Enum.sort_by(& &1.index) + |> Enum.flat_map(&transaction_to_inner_entries/1) + ) + end + + # Compute pairs of block numbers and addresses for which don't know nonce at the start of the block. + # + # Checks all authorizations signers and returns those without any prior transactions with known nonce. + @spec compute_missing_nonces(entry()) :: [%{block_number: Block.block_number(), address_hash: Hash.Address.t()}] + defp compute_missing_nonces(%{block_number: block_number, entries: entries}) do + entries + |> Enum.reduce({[], MapSet.new()}, fn element, {missing_nonces, known_nonces} -> + case element do + # once we have seen a transaction in the block, we can be certain in address nonce for later transactions in the same block + {:transaction, %{authority: authority}} -> + {missing_nonces, known_nonces |> MapSet.put(authority)} + + {:authorization, %{authority: authority, status: nil}} when not is_nil(authority) -> + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if MapSet.member?(known_nonces, authority) do + {missing_nonces, known_nonces} + else + {[%{block_number: block_number, address_hash: authority} | missing_nonces], + known_nonces |> MapSet.put(authority)} + end + + _ -> + {missing_nonces, known_nonces} + end + end) + |> elem(0) + end + + # Compute authorization statuses by iteratively going through all transactions and authorizations in the block, + # while keeping track of expected nonces. + @spec compute_statuses(entry(), %{Hash.Address.t() => non_neg_integer()}) :: {entry(), [SignedAuthorization.t()]} + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + defp compute_statuses(entry, known_nonces) do + {new_entries, {updated_authorizations, _}} = + entry.entries + |> Enum.map_reduce({[], known_nonces}, fn + {:transaction, transaction} = entry, {updated_authorizations, known_nonces} -> + {entry, {updated_authorizations, known_nonces |> Map.put(transaction.authority, transaction.nonce + 1)}} + + {:authorization, authorization} = entry, {updated_authorizations, known_nonces} -> + nonce = Decimal.to_integer(authorization.nonce) + status = SignedAuthorization.basic_validate(authorization) + + cond do + # if authorization is valid, update known nonce and proceed + authorization.status == :ok -> + {entry, {updated_authorizations, known_nonces |> Map.put(authorization.authority, nonce + 1)}} + + # if authorization is invalid, nonce is not incremented + authorization.status in [:invalid_signature, :invalid_chain_id, :invalid_nonce] -> + {entry, {updated_authorizations, known_nonces}} + + # remaining cases handle authorization.status == nil + + status in [:invalid_signature, :invalid_chain_id, :invalid_nonce] -> + new_authorization = %{authorization | status: status} + + {{:authorization, new_authorization}, {[new_authorization | updated_authorizations], known_nonces}} + + # we still can't get chain_id from the json rpc, so we can't validate authorization and don't know up-to-date nonce anymore + is_nil(status) -> + {entry, {updated_authorizations, known_nonces |> Map.delete(authorization.authority)}} + + Map.has_key?(known_nonces, authorization.authority) and + nonce != Map.get(known_nonces, authorization.authority) -> + new_authorization = %{authorization | status: :invalid_nonce} + + {{:authorization, new_authorization}, {[new_authorization | updated_authorizations], known_nonces}} + + Map.has_key?(known_nonces, authorization.authority) and + nonce == Map.get(known_nonces, authorization.authority) -> + new_authorization = %{authorization | status: :ok} + + { + {:authorization, new_authorization}, + {[new_authorization | updated_authorizations], + known_nonces |> Map.put(authorization.authority, nonce + 1)} + } + + true -> + # we couldn't validate authorization due to unknown nonce, we don't know up-to-date nonce anymore + {entry, {updated_authorizations, known_nonces |> Map.delete(authorization.authority)}} + end + end) + + {entry |> Map.put(:entries, new_entries), updated_authorizations |> Enum.reverse()} + end + + @spec fetch_nonces([%{block_number: Block.block_number(), address_hash: Hash.Address.t()}], keyword()) :: + {:ok, %{Block.block_number() => %{Hash.Address.t() => non_neg_integer()}}} | {:error, any()} + defp fetch_nonces([], _json_rpc_named_arguments), do: {:ok, %{}} + + defp fetch_nonces(entries, json_rpc_named_arguments) do + # fetch nonces for at the end of the previous block, to know starting nonces for the current block + entries + |> Enum.map( + &%{ + block_quantity: integer_to_quantity(BlockNumberHelper.previous_block_number(&1.block_number)), + address: to_string(&1.address_hash) + } + ) + |> EthereumJSONRPC.fetch_nonces(json_rpc_named_arguments) + |> case do + {:ok, %{params_list: params}} -> + {:ok, nonces_map_from_params(params)} + + error -> + error + end + end + + defp nonces_map_from_params(params) do + Enum.reduce(params, %{}, fn %{address: address, block_number: block_number, nonce: nonce}, acc -> + case Hash.Address.cast(address) do + {:ok, address_hash} -> + acc + |> Map.update( + BlockNumberHelper.next_block_number(block_number), + %{address_hash => nonce}, + &Map.put(&1, address_hash, nonce) + ) + + _ -> + acc + end + end) + end + + # Imports all updated signed authorizations, updates relevant addresses and proxy implementations. + @spec import_authorizations([SignedAuthorization.t()]) :: :ok | {:error, any()} + defp import_authorizations(signed_authorizations) do + address_params = + signed_authorizations + |> Enum.filter(&(&1.status == :ok)) + # keeps only the latest record for each authority address + |> Enum.into(%{}, &{&1.authority, SignedAuthorization.to_address_params(&1)}) + |> Map.values() + + # Fetch latest successful authorizations for each authority address + # and skip importing addresses for which newer authorization already exists. + # Will only work correctly with concurrency of the fetcher set to 1. + # Alternative concurrent approach may be considered in the future by moving + # this check to the DB level inside an "on conflict" clause and introduction + # of the `last_code_change_nonce` column. + latest_authorization_nonces = + address_params + |> Enum.map(& &1.hash) + |> address_hashes_to_latest_authorizations() + |> Enum.into(%{}, &{&1.authority, &1.nonce}) + + addresses = + address_params + |> Enum.filter(fn %{hash: hash, nonce: nonce} -> + Decimal.gt?(nonce, Map.get(latest_authorization_nonces, hash, -1)) + end) + + case Chain.import(%{ + addresses: %{ + params: addresses, + on_conflict: address_on_conflict(), + fields_to_update: [:contract_code, :nonce] + }, + signed_authorizations: %{ + params: signed_authorizations |> Enum.map(&SignedAuthorization.to_map/1), + on_conflict: {:replace, [:status, :updated_at]} + } + }) do + {:ok, %{addresses: addresses}} -> + Accounts.drop(addresses) + + # Update EIP7702 proxy addresses to avoid inconsistencies between addresses and proxy_implementations tables. + {contract_addresses, eoa_addresses} = addresses |> Enum.split_with(&Address.smart_contract?/1) + + if !Enum.empty?(eoa_addresses) do + eoa_addresses + |> Enum.map(& &1.hash) + |> Implementation.delete_implementations() + end + + if !Enum.empty?(contract_addresses) do + contract_addresses + |> Implementation.upsert_eip7702_implementations() + end + + :ok + + {:ok, %{}} -> + :ok + + {:error, step, reason, _changes_so_far} -> + Logger.error( + fn -> + [ + @failed_to_import, + inspect(reason) + ] + end, + step: step + ) + + {:error, reason} + + {:error, reason} -> + Logger.error(fn -> + [ + @failed_to_import, + inspect(reason) + ] + end) + + {:error, reason} + end + end + + defp address_on_conflict do + from(address in Address, + update: [ + set: [ + contract_code: fragment("EXCLUDED.contract_code"), + nonce: fragment("GREATEST(EXCLUDED.nonce, ?)", address.nonce), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", address.updated_at) + ] + ] + ) + end + + defp defaults do + [ + poll: false, + flush_interval: :timer.seconds(3), + max_concurrency: @default_max_concurrency, + max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, + task_supervisor: __MODULE__.TaskSupervisor, + metadata: [fetcher: :signed_authorization_status] + ] + end +end diff --git a/apps/indexer/lib/indexer/fetcher/stability/validator.ex b/apps/indexer/lib/indexer/fetcher/stability/validator.ex index 7339cb9a1d44..f764b931c6e7 100644 --- a/apps/indexer/lib/indexer/fetcher/stability/validator.ex +++ b/apps/indexer/lib/indexer/fetcher/stability/validator.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Stability.Validator do @moduledoc """ GenServer responsible for updating the list of stability validators in the database. @@ -58,7 +59,11 @@ defmodule Indexer.Fetcher.Stability.Validator do |> ValidatorStability.append_timestamps() end) - ValidatorStability.insert_validators(active ++ inactive) + ValidatorStability.insert_validators( + (active ++ inactive) + |> add_blocks_validated(validators_from_db) + ) + ValidatorStability.delete_validators_by_address_hashes(address_hashes_to_drop_from_db) _ -> @@ -68,6 +73,31 @@ defmodule Indexer.Fetcher.Stability.Validator do {:noreply, state} end + defp add_blocks_validated([_ | _] = validators, validators_from_db) do + validators_from_db_map = + Enum.reduce(validators_from_db, %{}, fn validator, map -> Map.put(map, validator.address_hash, true) end) + + address_hashes_to_fetch_blocks_validated = + Enum.flat_map(validators, fn validator -> + if validators_from_db_map[validator.address_hash] do + [] + else + [validator.address_hash] + end + end) + + blocks_validated_map = + address_hashes_to_fetch_blocks_validated + |> ValidatorStability.fetch_blocks_validated() + |> Enum.into(%{}) + + Enum.map(validators, fn validator -> + Map.put(validator, :blocks_validated, blocks_validated_map[validator.address_hash] || 0) + end) + end + + defp add_blocks_validated(validators, _), do: validators + @spec trigger_update_validators_list() :: :ok def trigger_update_validators_list do GenServer.cast(__MODULE__, :update_validators_list) diff --git a/apps/indexer/lib/indexer/fetcher/stats/hot_smart_contracts.ex b/apps/indexer/lib/indexer/fetcher/stats/hot_smart_contracts.ex new file mode 100644 index 000000000000..7a416e2db7c2 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/stats/hot_smart_contracts.ex @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Stats.HotSmartContracts do + @moduledoc """ + This module defines the HotSmartContracts fetcher for indexing hot contracts. + """ + use Indexer.Fetcher, restart: :permanent + + use GenServer + + alias Explorer.Chain + alias Explorer.Chain.Block + alias Explorer.Stats.HotSmartContracts + + require Logger + + @retry_interval 10_000 + @max_days_ago 30 + @min_chain_age_days 30 + + @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, pid()} + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + @impl GenServer + def init(opts) do + {:ok, opts, {:continue, :check_chain_age}} + end + + @impl GenServer + def handle_continue(:check_chain_age, state) do + process_chain_age_check() + {:noreply, state} + end + + @impl GenServer + def handle_info({:fetch_for_date, date, new_day?}, state) do + with {:ok, hot_smart_contracts} <- HotSmartContracts.aggregate_hot_smart_contracts_for_date(date), + {:ok, _} <- Chain.import(%{hot_smart_contracts_daily: %{params: hot_smart_contracts}, timeout: :infinity}) do + if new_day? do + schedule_next_day_fetch() + HotSmartContracts.delete_older_than(Date.add(date, 1 - @max_days_ago)) + end + + Logger.info("Hot contracts fetched for #{date}") + else + {:error, error} -> + Process.send_after(self(), {:fetch_for_date, date, new_day?}, @retry_interval) + Logger.error("Error fetching hot contracts for #{date}: #{inspect(error)}") + end + + {:noreply, state} + end + + @impl GenServer + def handle_info(:check_chain_age, state) do + process_chain_age_check() + {:noreply, state} + end + + @impl GenServer + def handle_info(_, state) do + {:noreply, state} + end + + @impl GenServer + def handle_cast(:check_completeness, state) do + indexed_dates = HotSmartContracts.indexed_dates() + + today = Date.utc_today() + + Enum.each(1..@max_days_ago, fn days_ago -> + date = Date.add(today, -days_ago) + + if date not in indexed_dates do + Process.send_after(self(), {:fetch_for_date, date, false}, @retry_interval) + end + end) + + {:noreply, state} + end + + defp schedule_next_day_fetch do + schedule_message_on_the_next_day({:fetch_for_date, Date.utc_today(), true}) + end + + defp schedule_startup_check do + schedule_message_on_the_next_day(:check_chain_age) + end + + defp schedule_message_on_the_next_day(payload) do + now = DateTime.utc_now() + next_day = Date.utc_today() |> Date.add(1) |> DateTime.new!(~T[00:00:00], "Etc/UTC") + delay = DateTime.diff(next_day, now, :millisecond) + Process.send_after(self(), payload, delay) + end + + defp process_chain_age_check do + case check_chain_age() do + :ok -> + GenServer.cast(__MODULE__, :check_completeness) + schedule_next_day_fetch() + + {:wait, delay_ms} -> + Logger.info( + "Hot contracts module delayed: chain is less than #{@min_chain_age_days} days old. Rescheduling startup check." + ) + + Process.send_after(self(), :check_chain_age, delay_ms) + + {:error, :block_not_found} -> + Logger.info("Hot contracts module delayed: second block not found. Rescheduling startup check for next day.") + + schedule_startup_check() + + {:error, reason} -> + Logger.warning( + "Hot contracts module delayed: error checking chain age (#{inspect(reason)}). Rescheduling startup check for next day." + ) + + schedule_startup_check() + end + end + + defp check_chain_age do + # Get the second block (ordered by number ascending) timestamp + case Block.fetch_second_block_in_database() do + {:ok, block} -> + now = DateTime.utc_now() + age_days = DateTime.diff(now, block.timestamp, :day) + + if age_days >= @min_chain_age_days do + :ok + else + # Calculate delay until chain reaches 30 days old, or next day if that's sooner + target_date = DateTime.add(block.timestamp, @min_chain_age_days, :day) + next_day = Date.utc_today() |> Date.add(1) |> DateTime.new!(~T[00:00:00], "Etc/UTC") + + delay_target = DateTime.diff(target_date, now, :millisecond) + delay_next_day = DateTime.diff(next_day, now, :millisecond) + + # Use the smaller delay (either until 30 days or next day, whichever comes first) + delay_ms = delay_target |> min(delay_next_day) |> max(0) + + {:wait, delay_ms} + end + + {:error, :not_found} -> + {:error, :block_not_found} + end + rescue + e -> + {:error, {:exception, e}} + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token.ex b/apps/indexer/lib/indexer/fetcher/token.ex index ededd909e9f5..e715929ee9ff 100644 --- a/apps/indexer/lib/indexer/fetcher/token.ex +++ b/apps/indexer/lib/indexer/fetcher/token.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Token do @moduledoc """ Fetches information about a token. @@ -9,6 +10,7 @@ defmodule Indexer.Fetcher.Token do alias Explorer.Chain alias Explorer.Chain.Hash.Address alias Explorer.Chain.Token + alias Explorer.MicroserviceInterfaces.MultichainSearch alias Explorer.Token.MetadataRetriever alias Indexer.{BufferedTask, Tracer} @@ -20,7 +22,7 @@ defmodule Indexer.Fetcher.Token do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -66,13 +68,22 @@ defmodule Indexer.Fetcher.Token do end defp catalog_token(token) do - token_params = - token - |> MetadataRetriever.get_functions_of() - |> (&if(&1 == %{}, do: &1, else: Map.put(&1, :cataloged, true))).() + token + |> MetadataRetriever.get_functions_of(set_skip_metadata: true) + |> case do + %{skip_metadata: false} -> + :ok - {:ok, _} = Token.update(token, token_params) - :ok + token_params -> + data_for_multichain = MultichainSearch.prepare_token_metadata_for_queue(token, token_params) + + %{} + |> Map.put(token.contract_address_hash.bytes, data_for_multichain) + |> MultichainSearch.send_token_info_to_queue(:metadata) + + {:ok, _} = Token.update(token, Map.put(token_params, :cataloged, true)) + :ok + end end defp defaults do diff --git a/apps/indexer/lib/indexer/fetcher/token_balance/current.ex b/apps/indexer/lib/indexer/fetcher/token_balance/current.ex new file mode 100644 index 000000000000..228df6bf529a --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/token_balance/current.ex @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenBalance.Current do + @moduledoc """ + Fetches current token balances and sends the ones that were fetched to be imported in `Address.CurrentTokenBalance`. + + The module responsible for fetching current token balances in the Smart Contract is the `Indexer.TokenBalances`. This module + only prepares the params, sends them to `Indexer.TokenBalances` and relies on its return. + + It behaves as a `BufferedTask`, so we can configure the `max_batch_size` and the `max_concurrency` to control how many + token balances will be fetched at the same time. + + Also, this module set a `refetch_after` for each token balance in case of failure to avoid fetching the ones + that always raise errors interacting with the Smart Contract. + """ + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + require Logger + + alias Explorer.Chain + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Events.Publisher + alias Explorer.Chain.Hash + alias Indexer.{BufferedTask, Tracer} + alias Indexer.Fetcher.TokenBalance.Helper + + @behaviour BufferedTask + + @timeout :timer.minutes(10) + + @spec async_fetch( + [ + %{ + token_contract_address_hash: Hash.Address.t(), + address_hash: Hash.Address.t(), + block_number: non_neg_integer(), + token_type: String.t(), + token_id: non_neg_integer() + } + ], + boolean() + ) :: :ok + def async_fetch(current_token_balances, realtime?) do + Helper.async_fetch(__MODULE__, current_token_balances, realtime?) + end + + @doc false + def child_spec(args) do + Helper.child_spec(args, __MODULE__) + end + + @impl BufferedTask + def init(initial, reducer, _) do + Helper.init(reducer, &CurrentTokenBalance.stream_unfetched_current_token_balances(initial, &1, true)) + end + + @doc """ + Fetches the given entries (token_balances) from the Smart Contract and import them in our database. + + It also set the `refetch_after` in case of failure to avoid fetching token balances that always raise errors + when reading their balance in the Smart Contract. + """ + @impl BufferedTask + @decorate trace( + name: "fetch", + resource: "Indexer.Fetcher.CurrentTokenBalance.run/2", + tracer: Tracer, + service: :indexer + ) + def run(entries, _json_rpc_named_arguments) do + entries + |> Helper.fetch_token_balances() + |> import_token_balances() + |> case do + :ok -> :ok + _ -> {:retry, entries} + end + end + + def import_token_balances(ctb_params) do + addresses_params = Helper.format_and_filter_address_params(ctb_params) + address_coin_balances = Helper.format_and_filter_address_coin_balances(ctb_params) + formatted_current_token_balances_params = Helper.format_and_filter_token_balance_params(ctb_params) + + import_params = %{ + addresses: %{params: addresses_params}, + address_coin_balances: %{params: address_coin_balances}, + address_current_token_balances: %{params: formatted_current_token_balances_params}, + timeout: @timeout + } + + case Chain.import(import_params) do + {:ok, %{address_current_token_balances: imported_ctbs}} -> + imported_ctbs + |> Enum.group_by(& &1.address_hash) + |> Enum.each(fn {address_hash, ctbs} -> + Publisher.broadcast( + %{ + address_current_token_balances: %{ + address_hash: to_string(address_hash), + address_current_token_balances: ctbs + } + }, + :realtime + ) + end) + + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.debug(fn -> ["failed to import current token balances: ", inspect(reason)] end, + error_count: Enum.count(ctb_params) + ) + + :error + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_balance.ex b/apps/indexer/lib/indexer/fetcher/token_balance/helper.ex similarity index 50% rename from apps/indexer/lib/indexer/fetcher/token_balance.ex rename to apps/indexer/lib/indexer/fetcher/token_balance/helper.ex index d8589251974e..1773057e7fbf 100644 --- a/apps/indexer/lib/indexer/fetcher/token_balance.ex +++ b/apps/indexer/lib/indexer/fetcher/token_balance/helper.ex @@ -1,102 +1,107 @@ -defmodule Indexer.Fetcher.TokenBalance do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenBalance.Helper do @moduledoc """ - Fetches token balances and sends the ones that were fetched to be imported in `Address.CurrentTokenBalance` and - `Address.TokenBalance`. - - The module responsible for fetching token balances in the Smart Contract is the `Indexer.TokenBalances`. This module - only prepares the params, sends them to `Indexer.TokenBalances` and relies on its return. - - It behaves as a `BufferedTask`, so we can configure the `max_batch_size` and the `max_concurrency` to control how many - token balances will be fetched at the same time. - - Also, this module set a `refetch_after` for each token balance in case of failure to avoid fetching the ones - that always raise errors interacting with the Smart Contract. + Common functions for `Indexer.Fetcher.TokenBalance.Historical` and `Indexer.Fetcher.TokenBalance.Current` modules """ - use Indexer.Fetcher, restart: :permanent - use Spandex.Decorators - require Logger - alias Explorer.Chain + alias EthereumJSONRPC.Utility.RangesHelper alias Explorer.Chain.Address.{CurrentTokenBalance, TokenBalance} - alias Explorer.Chain.Hash + alias Explorer.Chain.{Hash, Token} alias Explorer.Utility.MissingBalanceOfToken - alias Indexer.{BufferedTask, TokenBalances, Tracer} - alias Indexer.Fetcher.TokenBalance.Supervisor, as: TokenBalanceSupervisor - - @behaviour BufferedTask + alias Indexer.{BufferedTask, TokenBalances} @default_max_batch_size 100 @default_max_concurrency 10 - @timeout :timer.minutes(10) - - @spec async_fetch( - [ - %{ - token_contract_address_hash: Hash.Address.t(), - address_hash: Hash.Address.t(), - block_number: non_neg_integer(), - token_type: String.t(), - token_id: non_neg_integer() - } - ], - boolean() - ) :: :ok - def async_fetch(token_balances, realtime?) do - if TokenBalanceSupervisor.disabled?() do + def async_fetch(module, token_balances, realtime?) do + if Module.concat(module, Supervisor).disabled?() do :ok else - formatted_params = Enum.map(token_balances, &entry/1) + filtered_balances = + token_balances + |> RangesHelper.filter_by_block_ranges() + |> RangesHelper.filter_traceable_block_numbers() - BufferedTask.buffer(__MODULE__, formatted_params, realtime?, :infinity) + formatted_params = Enum.map(filtered_balances, &entry/1) + + BufferedTask.buffer(module, formatted_params, realtime?, :infinity) end end @doc false - def child_spec([init_options, gen_server_options]) do + def child_spec([init_options, gen_server_options], module) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, - ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> + ":json_rpc_named_arguments must be provided to `#{module}.child_spec " <> "to allow for json_rpc calls when running." end merged_init_opts = - defaults() + module + |> defaults() |> Keyword.merge(mergeable_init_options) |> Keyword.put(:state, state) - Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + Supervisor.child_spec({BufferedTask, [{module, merged_init_opts}, gen_server_options]}, id: module) end - @impl BufferedTask - def init(initial, reducer, _) do - {:ok, final} = - Chain.stream_unfetched_token_balances( - initial, - fn token_balance, acc -> - token_balance - |> entry() - |> reducer.(acc) - end, - true - ) + def init(reducer, stream_func) do + entry_reducer = fn token_balance, acc -> + token_balance + |> entry() + |> reducer.(acc) + end + + stream_reducer = + entry_reducer + |> RangesHelper.stream_reducer_by_block_ranges() + |> RangesHelper.stream_reducer_traceable() + + {:ok, final} = stream_func.(stream_reducer) final end - @doc """ - Fetches the given entries (token_balances) from the Smart Contract and import them in our database. + def format_and_filter_address_params(token_balances_params) do + token_balances_params + |> Enum.map(fn token_balances_param -> + if token_balances_param.token_contract_address_hash == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" do + %{ + hash: token_balances_param.address_hash, + fetched_coin_balance: token_balances_param.value, + fetched_coin_balance_block_number: token_balances_param.block_number + } + else + %{hash: token_balances_param.address_hash} + end + end) + |> Enum.uniq() + end + + def format_and_filter_address_coin_balances(token_balances_params) do + token_balances_params + |> Enum.filter(&(&1.token_contract_address_hash == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")) + |> Enum.map( + &%{ + address_hash: &1.address_hash, + value: &1.value, + block_number: &1.block_number, + value_fetched_at: &1.value_fetched_at + } + ) + end - It also set the `refetch_after` in case of failure to avoid fetching token balances that always raise errors - when reading their balance in the Smart Contract. - """ - @impl BufferedTask - @decorate trace(name: "fetch", resource: "Indexer.Fetcher.TokenBalance.run/2", tracer: Tracer, service: :indexer) - def run(entries, _json_rpc_named_arguments) do + def format_and_filter_token_balance_params(token_balances_params) do + {params_without_type, params_with_type} = Enum.split_with(token_balances_params, &is_nil(&1.token_type)) + + params_with_type ++ put_token_type_to_balance_objects(params_without_type) + end + + def fetch_token_balances(entries) do params = Enum.map(entries, &format_params/1) missing_balance_of_tokens = @@ -105,20 +110,24 @@ defmodule Indexer.Fetcher.TokenBalance do |> Enum.uniq() |> MissingBalanceOfToken.get_by_hashes() - result = - params - |> MissingBalanceOfToken.filter_token_balances_params(true, missing_balance_of_tokens) - |> fetch_from_blockchain(missing_balance_of_tokens) - |> import_token_balances() + params + |> MissingBalanceOfToken.filter_token_balances_params(true, missing_balance_of_tokens) + |> fetch_from_blockchain(missing_balance_of_tokens) + end - if result == :ok do - :ok - else - {:retry, entries} - end + defp put_token_type_to_balance_objects([]), do: [] + + defp put_token_type_to_balance_objects(token_balances) do + token_types_map = + token_balances + |> Enum.map(& &1.token_contract_address_hash) + |> Token.get_token_types() + |> Map.new() + + Enum.map(token_balances, &Map.put(&1, :token_type, token_types_map[&1.token_contract_address_hash])) end - def fetch_from_blockchain(params_list, missing_balance_of_tokens) do + defp fetch_from_blockchain(params_list, missing_balance_of_tokens) do params_list = Enum.uniq_by(params_list, &Map.take(&1, [:token_contract_address_hash, :token_id, :address_hash, :block_number])) @@ -162,11 +171,7 @@ defmodule Indexer.Fetcher.TokenBalance do defp handle_missing_balance_of_tokens(failed_token_balances) do {missing_balance_of_balances, other_failed_balances} = - Enum.split_with(failed_token_balances, fn - %{error: :unable_to_decode} -> true - %{error: error} when is_binary(error) -> String.match?(error, ~r/execution.*revert/) - _ -> false - end) + Enum.split_with(failed_token_balances, &EthereumJSONRPC.contract_failure?/1) MissingBalanceOfToken.insert_from_params(missing_balance_of_balances) @@ -195,7 +200,7 @@ defmodule Indexer.Fetcher.TokenBalance do end defp define_refetch_after(retries_count) do - config = Application.get_env(:indexer, __MODULE__) + config = Application.get_env(:indexer, Indexer.Fetcher.TokenBalance.Historical) coef = config[:exp_timeout_coeff] max_refetch_interval = config[:max_refetch_interval] @@ -206,81 +211,6 @@ defmodule Indexer.Fetcher.TokenBalance do Timex.shift(Timex.now(), seconds: value) end - def import_token_balances(token_balances_params) do - addresses_params = format_and_filter_address_params(token_balances_params) - address_coin_balances = format_and_filter_address_coin_balances(token_balances_params) - formatted_token_balances_params = format_and_filter_token_balance_params(token_balances_params) - - import_params = %{ - addresses: %{params: addresses_params}, - address_coin_balances: %{params: address_coin_balances}, - address_token_balances: %{params: formatted_token_balances_params}, - address_current_token_balances: %{ - params: TokenBalances.to_address_current_token_balances(formatted_token_balances_params) - }, - timeout: @timeout - } - - case Chain.import(import_params) do - {:ok, _} -> - :ok - - {:error, reason} -> - Logger.debug(fn -> ["failed to import token balances: ", inspect(reason)] end, - error_count: Enum.count(token_balances_params) - ) - - :error - end - end - - defp format_and_filter_address_coin_balances(token_balances_params) do - token_balances_params - |> Enum.filter(&(&1.token_contract_address_hash == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")) - |> Enum.map(&%{ - address_hash: &1.address_hash, - value: &1.value, - block_number: &1.block_number, - value_fetched_at: &1.value_fetched_at - }) - end - - defp format_and_filter_address_params(token_balances_params) do - token_balances_params - |> Enum.map(fn token_balances_param -> - if token_balances_param.token_contract_address_hash == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" do - %{ - hash: token_balances_param.address_hash, - fetched_coin_balance: token_balances_param.value, - fetched_coin_balance_block_number: token_balances_param.block_number - } - else - %{hash: token_balances_param.address_hash} - end - end) - end - - defp format_and_filter_token_balance_params(token_balances_params) do - token_balances_params - |> Enum.map(fn token_balance -> - if token_balance.token_type do - token_balance - else - put_token_type_to_balance_object(token_balance) - end - end) - end - - defp put_token_type_to_balance_object(token_balance) do - token_type = Chain.get_token_type(token_balance.token_contract_address_hash) - - if token_type do - Map.put(token_balance, :token_type, token_type) - else - token_balance - end - end - defp entry( %{ token_contract_address_hash: token_contract_address_hash, @@ -323,12 +253,12 @@ defmodule Indexer.Fetcher.TokenBalance do } end - defp defaults do + defp defaults(module) do [ flush_interval: 300, - max_batch_size: Application.get_env(:indexer, __MODULE__)[:batch_size] || @default_max_batch_size, - max_concurrency: Application.get_env(:indexer, __MODULE__)[:concurrency] || @default_max_concurrency, - task_supervisor: Indexer.Fetcher.TokenBalance.TaskSupervisor + max_batch_size: Application.get_env(:indexer, module)[:batch_size] || @default_max_batch_size, + max_concurrency: Application.get_env(:indexer, module)[:concurrency] || @default_max_concurrency, + task_supervisor: Module.concat(module, TaskSupervisor) ] end end diff --git a/apps/indexer/lib/indexer/fetcher/token_balance/historical.ex b/apps/indexer/lib/indexer/fetcher/token_balance/historical.ex new file mode 100644 index 000000000000..852be894f610 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/token_balance/historical.ex @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenBalance.Historical do + @moduledoc """ + Fetches historical token balances and sends the ones that were fetched to be imported in `Address.TokenBalance`. + + The module responsible for fetching token balances in the Smart Contract is the `Indexer.TokenBalances`. This module + only prepares the params, sends them to `Indexer.TokenBalances` and relies on its return. + + It behaves as a `BufferedTask`, so we can configure the `max_batch_size` and the `max_concurrency` to control how many + token balances will be fetched at the same time. + + Also, this module set a `refetch_after` for each token balance in case of failure to avoid fetching the ones + that always raise errors interacting with the Smart Contract. + """ + + use Indexer.Fetcher, restart: :permanent + use Spandex.Decorators + + require Logger + + alias Explorer.Chain + alias Explorer.Chain.Address.TokenBalance + alias Explorer.Chain.Hash + alias Indexer.{BufferedTask, Tracer} + alias Indexer.Fetcher.TokenBalance.Helper + + @behaviour BufferedTask + + @timeout :timer.minutes(10) + + @spec async_fetch( + [ + %{ + token_contract_address_hash: Hash.Address.t(), + address_hash: Hash.Address.t(), + block_number: non_neg_integer(), + token_type: String.t(), + token_id: non_neg_integer() | nil + } + ], + boolean() + ) :: :ok + def async_fetch(token_balances, realtime?) do + Helper.async_fetch(__MODULE__, token_balances, realtime?) + end + + @doc false + def child_spec(args) do + Helper.child_spec(args, __MODULE__) + end + + @impl BufferedTask + def init(initial, reducer, _) do + Helper.init(reducer, &TokenBalance.stream_unfetched_token_balances(initial, &1, true)) + end + + @doc """ + Fetches the given entries (token balances) from the smart contract and imports them. + + It also sets `refetch_after` on failure to avoid repeated smart contract errors. + + ## Parameters + - `entries`: Token balance entries to fetch. + - `json_rpc_named_arguments`: JSON-RPC configuration (unused). + + ## Returns + - `:ok` on success + - `{:retry, entries}` on failure + """ + @impl BufferedTask + @decorate trace( + name: "fetch", + resource: "Indexer.Fetcher.TokenBalance.Historical.run/2", + tracer: Tracer, + service: :indexer + ) + def run(entries, _json_rpc_named_arguments) do + entries + |> Helper.fetch_token_balances() + |> import_token_balances() + |> case do + :ok -> :ok + _ -> {:retry, entries} + end + end + + def import_token_balances(token_balances_params) do + addresses_params = Helper.format_and_filter_address_params(token_balances_params) + address_coin_balances = Helper.format_and_filter_address_coin_balances(token_balances_params) + formatted_token_balances_params = Helper.format_and_filter_token_balance_params(token_balances_params) + + import_params = %{ + addresses: %{params: addresses_params}, + address_coin_balances: %{params: address_coin_balances}, + address_token_balances: %{params: formatted_token_balances_params}, + timeout: @timeout + } + + case Chain.import(import_params) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.debug(fn -> ["failed to import token balances: ", inspect(reason)] end, + error_count: Enum.count(token_balances_params) + ) + + :error + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex b/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex new file mode 100644 index 000000000000..91960a0cdfa7 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/token_counters_updater.ex @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenCountersUpdater do + @moduledoc """ + Updates counters for cataloged tokens. + """ + use Indexer.Fetcher, restart: :permanent + + require Logger + + alias Explorer.Chain.Token + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Indexer.BufferedTask + alias Timex.Duration + + @behaviour BufferedTask + + @max_batch_size 10 + @max_concurrency 4 + @defaults [ + flush_interval: :timer.seconds(3), + max_concurrency: @max_concurrency, + max_batch_size: @max_batch_size, + task_supervisor: Indexer.Fetcher.TokenCountersUpdater.TaskSupervisor, + metadata: [fetcher: :token_counters_updater] + ] + + @doc false + def child_spec([init_options, gen_server_options]) do + {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) + + if !state do + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> + "to allow for json_rpc calls when running." + end + + merged_init_opts = + @defaults + |> Keyword.merge(mergeable_init_options) + |> Keyword.put(:state, state) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_opts}, gen_server_options]}, id: __MODULE__) + end + + @impl BufferedTask + def init(initial, reducer, _) do + counters_updater_milliseconds_interval = Application.get_env(:indexer, __MODULE__)[:milliseconds_interval] + + interval_in_minutes = + counters_updater_milliseconds_interval + |> Duration.from_milliseconds() + |> Duration.to_minutes() + |> trunc() + + {:ok, tokens} = Token.stream_cataloged_tokens(initial, reducer, interval_in_minutes, true) + + tokens + end + + @impl BufferedTask + def run(entries, _json_rpc_named_arguments) do + Logger.debug("updating token counters") + + entries + |> Enum.reduce(%{}, fn token, acc -> + {transfers_count, holders_count} = Token.fetch_token_counters(token.contract_address_hash, :infinity) + + data_for_multichain = MultichainSearch.prepare_token_counters_for_queue(transfers_count, holders_count) + Map.put(acc, token.contract_address_hash.bytes, data_for_multichain) + end) + |> MultichainSearch.send_token_info_to_queue(:counters) + + :ok + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex b/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex index 74cbc08b7757..0a6497da5dfa 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/helper.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.Helper do @moduledoc """ Common functions for Indexer.Fetcher.TokenInstance fetchers """ alias EthereumJSONRPC.NFT - alias Explorer.Chain + alias Explorer.Chain.Token alias Explorer.Chain.Token.Instance alias Explorer.Token.MetadataRetriever alias Indexer.NFTMediaHandler.Queue @@ -13,8 +14,55 @@ defmodule Indexer.Fetcher.TokenInstance.Helper do @cryptokitties_address_hash "0x06012c8cf97bead5deae237070f9587f8e7a266d" - @spec batch_fetch_instances([%{}]) :: list() + @doc """ + Fetches and upserts a batch of token instances. + + This function takes a list of `token_instances`, prepares the parameters for insertion, + and attempts to upsert them into the database. If an error occurs during the upsert, + it rescues the exception. + + ## Parameters + + - `token_instances`: A list of token instance maps to be processed. + + ## Returns + + - The result of the upsert operation, which may vary depending on the implementation + of `upsert_with_rescue/1`. + """ + @spec batch_fetch_instances([map()]) :: nil | [map()] def batch_fetch_instances(token_instances) do + token_instances + |> batch_prepare_instances_insert_params() + |> upsert_with_rescue() + end + + @doc """ + Prepares a batch of token instance insert parameters. + + This function processes a list of token instances, grouping them by contract address hash, + and handles special logic for CryptoKitties tokens. It fetches token types for non-CryptoKitties + tokens, retrieves metadata, and formats the results for database insertion. + + ## Parameters + + - `token_instances`: A list of maps representing token instances. Each item should + contain at least `:contract_address_hash` and `:token_id`. + + ## Returns + + - A list of insert parameters for each token instance, ready for database insertion. + + ## Special Cases + + - CryptoKitties tokens are identified by a specific contract address hash and are handled + separately with a fixed API endpoint. + + - Errors during metadata retrieval are truncated and included in the result. + + """ + @spec batch_prepare_instances_insert_params([map()]) :: [map()] + def batch_prepare_instances_insert_params(token_instances) do token_instances = Enum.map(token_instances, fn %{contract_address_hash: hash, token_id: token_id} -> {hash, token_id} @@ -39,7 +87,7 @@ defmodule Indexer.Fetcher.TokenInstance.Helper do other |> Enum.map(fn {contract_address_hash, _token_id} -> contract_address_hash end) |> Enum.uniq() - |> Chain.get_token_types() + |> Token.get_token_types() |> Map.new(fn {hash, type} -> {hash.bytes, type} end) other @@ -57,7 +105,6 @@ defmodule Indexer.Fetcher.TokenInstance.Helper do ) end end) - |> upsert_with_rescue() end defp batch_fetch_instances_inner(token_instances, token_types_map, cryptokitties) do diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/realtime.ex b/apps/indexer/lib/indexer/fetcher/token_instance/realtime.ex index e79243cf57ab..ce41dde9545c 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/realtime.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/realtime.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.Realtime do @moduledoc """ Fetches information about a token instance. @@ -58,19 +59,15 @@ defmodule Indexer.Fetcher.TokenInstance.Realtime do def async_fetch(_data, true), do: :ok - def async_fetch(token_transfers, _disabled?) when is_list(token_transfers) do + def async_fetch(token_instances, _disabled?) when is_list(token_instances) do data = - token_transfers - |> Enum.reject(fn token_transfer -> is_nil(token_transfer.token_ids) end) - |> Enum.map(fn token_transfer -> - Enum.map(token_transfer.token_ids, fn token_id -> - %{ - contract_address_hash: token_transfer.token_contract_address_hash, - token_id: token_id - } - end) + token_instances + |> Enum.map(fn token_instance -> + %{ + contract_address_hash: token_instance.token_contract_address_hash, + token_id: token_instance.token_id + } end) - |> List.flatten() |> Enum.uniq() BufferedTask.buffer(__MODULE__, data, true) diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/refetch.ex b/apps/indexer/lib/indexer/fetcher/token_instance/refetch.ex index a23883f63d54..fdf5be69349c 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/refetch.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/refetch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.Refetch do @moduledoc """ Fetches information about a token instance, which is marked to be re-fetched. diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/retry.ex b/apps/indexer/lib/indexer/fetcher/token_instance/retry.ex index 2613b2350fbb..158f123abc1a 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/retry.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/retry.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.Retry do @moduledoc """ Fetches information about a token instance. @@ -10,13 +11,12 @@ defmodule Indexer.Fetcher.TokenInstance.Retry do alias Explorer.Chain.Token.Instance alias Indexer.BufferedTask + alias Indexer.Helper, as: IndexerHelper @behaviour BufferedTask @default_max_batch_size 10 @default_max_concurrency 10 - @max_queue_size 5000 - @busy_waiting_timeout 500 @doc false def child_spec([init_options, gen_server_options]) do @@ -34,25 +34,13 @@ defmodule Indexer.Fetcher.TokenInstance.Retry do Instance.stream_token_instances_with_error( initial_acc, fn data, acc -> - reduce_if_queue_is_not_full(data, acc, reducer) + IndexerHelper.reduce_if_queue_is_not_full(data, acc, reducer, __MODULE__) end ) acc end - defp reduce_if_queue_is_not_full(data, acc, reducer) do - bound_queue = GenServer.call(__MODULE__, :state).bound_queue - - if bound_queue.size >= @max_queue_size or (bound_queue.maximum_size && bound_queue.size >= bound_queue.maximum_size) do - :timer.sleep(@busy_waiting_timeout) - - reduce_if_queue_is_not_full(data, acc, reducer) - else - reducer.(data, acc) - end - end - @impl BufferedTask def run(token_instances, _json_rpc_named_arguments) when is_list(token_instances) do batch_fetch_instances(token_instances) diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/sanitize.ex b/apps/indexer/lib/indexer/fetcher/token_instance/sanitize.ex index ae18d9bdb232..7f650044ce9a 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/sanitize.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/sanitize.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.Sanitize do @moduledoc """ Fetches information about a token instance. diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc1155.ex b/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc1155.ex index 4496d1091009..a175b44079d5 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc1155.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc1155.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.SanitizeERC1155 do @moduledoc """ This fetcher is stands for creating token instances which wasn't inserted yet and index meta for them. @@ -7,9 +8,9 @@ defmodule Indexer.Fetcher.TokenInstance.SanitizeERC1155 do use GenServer, restart: :transient + alias Explorer.{Chain, Repo} alias Explorer.Chain.Token.Instance alias Explorer.Migrator.MigrationStatus - alias Explorer.{Chain, Repo} alias Indexer.Fetcher.TokenInstance.Sanitize @migration_name "backfill_erc1155" @@ -43,7 +44,7 @@ defmodule Indexer.Fetcher.TokenInstance.SanitizeERC1155 do instances_to_fetch = (concurrency * batch_size) |> Instance.not_inserted_erc_1155_token_instances() - |> Repo.all() + |> Repo.all(timeout: :infinity) if Enum.empty?(instances_to_fetch) do MigrationStatus.set_status(@migration_name, "completed") diff --git a/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc721.ex b/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc721.ex index 2f415507426f..823e207a0e07 100644 --- a/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc721.ex +++ b/apps/indexer/lib/indexer/fetcher/token_instance/sanitize_erc721.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.SanitizeERC721 do @moduledoc """ This fetcher is stands for creating token instances which wasn't inserted yet and index meta for them. @@ -54,7 +55,7 @@ defmodule Indexer.Fetcher.TokenInstance.SanitizeERC721 do address_hashes = state[:tokens_queue_size] |> Token.ordered_erc_721_token_address_hashes_list_query(state[:last_token_address_hash]) - |> Repo.all() + |> Repo.all(timeout: :infinity) if Enum.empty?(address_hashes) do MigrationStatus.set_status(@migration_name, "completed") @@ -81,7 +82,7 @@ defmodule Indexer.Fetcher.TokenInstance.SanitizeERC721 do instances_to_fetch = (concurrency * batch_size) |> Instance.not_inserted_token_instances_query_by_token(current_address_hash) - |> Repo.all() + |> Repo.all(timeout: :infinity) if Enum.empty?(instances_to_fetch) do Constants.insert_last_processed_token_address_hash(current_address_hash) diff --git a/apps/indexer/lib/indexer/fetcher/token_instance_importer.ex b/apps/indexer/lib/indexer/fetcher/token_instance_importer.ex new file mode 100644 index 000000000000..b9b0dbddfb13 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/token_instance_importer.ex @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenInstanceImporter do + @moduledoc """ + Periodically updates token instances accumulated from block fetcher + """ + + use GenServer + + require Logger + + alias Explorer.Chain + alias Indexer.Block.Fetcher + alias Indexer.Transform.TokenInstances + + @default_update_interval :timer.minutes(1) + + def child_spec(_) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, []}, + type: :worker, + restart: :permanent, + shutdown: Application.get_env(:indexer, :graceful_shutdown_period) + } + end + + def start_link do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + Process.flag(:trap_exit, true) + schedule_next_update() + + {:ok, %{tokens_map: %{}, token_instances_map: %{}}} + end + + def add(token_params, token_transfers_params) do + GenServer.cast(__MODULE__, {:add, {token_params, token_transfers_params}}) + end + + def handle_cast({:add, {token_params, token_transfers_params}}, state) do + token_instances_map = + %{token_transfers_params: token_transfers_params} + |> TokenInstances.params_set(state.token_instances_map) + |> Map.new(&{{&1.token_contract_address_hash, &1.token_id}, &1}) + + tokens_map = + Enum.reduce(token_params, state.tokens_map, fn %{contract_address_hash: contract_address_hash} = params, acc -> + Map.put(acc, contract_address_hash, Map.merge(acc[contract_address_hash] || %{}, params)) + end) + + {:noreply, %{tokens_map: tokens_map, token_instances_map: token_instances_map}} + end + + def handle_info(:update, state) do + Logger.info( + "TokenInstanceImporter importing #{Enum.count(state.tokens_map)} tokens and #{Enum.count(state.token_instances_map)} token instances" + ) + + result_state = do_update(state) + schedule_next_update() + {:noreply, result_state} + rescue + exception -> + error = Exception.format(:error, exception, __STACKTRACE__) + log_error(error) + schedule_next_update() + + {:noreply, state} + end + + def handle_info({_ref, _result}, state) do + {:noreply, state} + end + + def handle_info({:EXIT, _pid, :normal}, state) do + {:noreply, state} + end + + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + do_update(state) + end + + defp do_update(%{tokens_map: tokens_map, token_instances_map: token_instances_map} = state) + when tokens_map == %{} and token_instances_map == %{}, do: state + + defp do_update(%{tokens_map: tokens_map, token_instances_map: token_instances_map}) do + token_instances_params = Map.values(token_instances_map) + tokens_params = Map.values(tokens_map) + + case Chain.import(%{ + tokens: %{params: tokens_params}, + token_instances: %{params: token_instances_params}, + timeout: :infinity + }) do + {:ok, imported} -> + Fetcher.async_import_token_instances(imported) + + Logger.info( + "TokenInstanceImporter imported #{Enum.count(tokens_params)} tokens and #{Enum.count(token_instances_params)} token instances" + ) + + %{tokens_map: %{}, token_instances_map: %{}} + + error -> + log_error(inspect(error)) + %{tokens_map: tokens_map, token_instances_map: token_instances_map} + end + end + + defp schedule_next_update do + Process.send_after(self(), :update, @default_update_interval) + end + + defp log_error(error) do + Logger.error("Failed to update token instances: #{error}, retrying") + end +end diff --git a/apps/indexer/lib/indexer/fetcher/token_total_supply_updater.ex b/apps/indexer/lib/indexer/fetcher/token_total_supply_updater.ex index f67eec104457..e6738d347441 100644 --- a/apps/indexer/lib/indexer/fetcher/token_total_supply_updater.ex +++ b/apps/indexer/lib/indexer/fetcher/token_total_supply_updater.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenTotalSupplyUpdater do @moduledoc """ Periodically updates tokens total_supply @@ -7,7 +8,8 @@ defmodule Indexer.Fetcher.TokenTotalSupplyUpdater do alias Explorer.{Chain, Repo} alias Explorer.Chain.Cache.Counters.AverageBlockTime - alias Explorer.Chain.Token + alias Explorer.Chain.{Hash, Token} + alias Explorer.MicroserviceInterfaces.MultichainSearch alias Explorer.Token.MetadataRetriever alias Timex.Duration @@ -32,7 +34,17 @@ defmodule Indexer.Fetcher.TokenTotalSupplyUpdater do end def handle_info(:update, contract_address_hashes) do - Enum.each(contract_address_hashes, &update_token/1) + contract_address_hashes + |> Enum.reduce(%{}, fn contract_address_hash, acc -> + with {:ok, address_hash} <- Chain.string_to_address_hash(contract_address_hash), + data_for_multichain = update_token(address_hash), + false <- is_nil(data_for_multichain) do + Map.put(acc, address_hash.bytes, data_for_multichain) + else + _ -> acc + end + end) + |> MultichainSearch.send_token_info_to_queue(:total_supply) schedule_next_update() @@ -49,21 +61,20 @@ defmodule Indexer.Fetcher.TokenTotalSupplyUpdater do Process.send_after(self(), :update, update_interval) end - defp update_token(nil), do: :ok - - defp update_token(address_hash_string) do - {:ok, address_hash} = Chain.string_to_address_hash(address_hash_string) - + defp update_token(address_hash) do token = Repo.get_by(Token, contract_address_hash: address_hash) if token && !token.skip_metadata do - token_params = MetadataRetriever.get_total_supply_of(address_hash_string) + token_params = + address_hash + |> Hash.to_string() + |> MetadataRetriever.get_total_supply_of() if token_params !== %{} do {:ok, _} = Token.update(token, token_params) + + MultichainSearch.prepare_token_total_supply_for_queue(token_params.total_supply) end end - - :ok end end diff --git a/apps/indexer/lib/indexer/fetcher/token_updater.ex b/apps/indexer/lib/indexer/fetcher/token_updater.ex index 3c5cc64bbe40..f9fdfd3e1f2a 100644 --- a/apps/indexer/lib/indexer/fetcher/token_updater.ex +++ b/apps/indexer/lib/indexer/fetcher/token_updater.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenUpdater do @moduledoc """ Updates metadata for cataloged tokens @@ -8,6 +9,7 @@ defmodule Indexer.Fetcher.TokenUpdater do alias Explorer.Chain alias Explorer.Chain.{Hash, Token} + alias Explorer.MicroserviceInterfaces.MultichainSearch alias Explorer.Token.MetadataRetriever alias Indexer.BufferedTask alias Timex.Duration @@ -28,7 +30,7 @@ defmodule Indexer.Fetcher.TokenUpdater do def child_spec([init_options, gen_server_options]) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -78,13 +80,23 @@ defmodule Indexer.Fetcher.TokenUpdater do @doc false def update_metadata(metadata_list) when is_list(metadata_list) do - Enum.each(metadata_list, fn %{contract_address_hash: contract_address_hash} = metadata -> + metadata_list + |> Enum.reduce(%{}, fn %{contract_address_hash: contract_address_hash} = metadata, acc -> {:ok, hash} = Hash.Address.cast(contract_address_hash) - with {:ok, %Token{cataloged: true} = token} <- Chain.token_from_address_hash(hash) do - update_metadata(token, metadata) + case Chain.token_from_address_hash(hash) do + {:ok, %Token{cataloged: true} = token} -> + update_metadata(token, metadata) + data_for_multichain = MultichainSearch.prepare_token_metadata_for_queue(token, metadata) + Map.put(acc, hash.bytes, data_for_multichain) + + _ -> + acc end end) + |> MultichainSearch.send_token_info_to_queue(:metadata) + + :ok end def update_metadata(%Token{} = token, metadata) do diff --git a/apps/indexer/lib/indexer/fetcher/transaction_action.ex b/apps/indexer/lib/indexer/fetcher/transaction_action.ex deleted file mode 100644 index 5d8c9e7652da..000000000000 --- a/apps/indexer/lib/indexer/fetcher/transaction_action.ex +++ /dev/null @@ -1,297 +0,0 @@ -defmodule Indexer.Fetcher.TransactionAction do - @moduledoc """ - Fetches information about transaction actions. - """ - - use GenServer - use Indexer.Fetcher - - require Logger - - import Ecto.Query, - only: [ - from: 2 - ] - - import Explorer.Helper, only: [parse_integer: 1] - - alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Block, BlockNumberHelper, Log, TransactionAction} - alias Explorer.Chain.Cache.Counters.LastFetchedCounter - alias Indexer.Transform.{Addresses, TransactionActions} - - @stage_first_block "transaction_action_first_block" - @stage_next_block "transaction_action_next_block" - @stage_last_block "transaction_action_last_block" - - defstruct first_block: nil, next_block: nil, last_block: nil, protocols: [], task: nil, pid: nil - - def child_spec([init_arguments]) do - child_spec([init_arguments, []]) - end - - def child_spec([_init_arguments, _gen_server_options] = start_link_arguments) do - default = %{ - id: __MODULE__, - start: {__MODULE__, :start_link, start_link_arguments} - } - - Supervisor.child_spec(default, restart: :transient) - end - - def start_link(arguments, gen_server_options \\ []) do - GenServer.start_link(__MODULE__, arguments, gen_server_options) - end - - @impl GenServer - def init(opts) when is_list(opts) do - opts = - Application.get_all_env(:indexer)[__MODULE__] - |> Keyword.merge(opts) - - first_block = Keyword.get(opts, :reindex_first_block) - last_block = Keyword.get(opts, :reindex_last_block) - - cond do - !is_nil(first_block) and !is_nil(last_block) -> - init_fetching(opts, first_block, last_block) - - is_nil(first_block) and !is_nil(last_block) -> - {:stop, "Please, specify the first block of the block range for #{__MODULE__}."} - - !is_nil(first_block) and is_nil(last_block) -> - {:stop, "Please, specify the last block of the block range for #{__MODULE__}."} - - true -> - :ignore - end - end - - @impl true - def handle_continue({opts, first_block, last_block}, _state) do - logger_metadata = Logger.metadata() - Logger.metadata(fetcher: :transaction_action) - - max_block_number = Chain.fetch_max_block_number() - - if last_block > max_block_number do - Logger.warning( - "Note, that the last block number (#{last_block}) provided to #{__MODULE__} exceeds max block number available in DB (#{max_block_number})." - ) - end - - supported_protocols = - TransactionAction.supported_protocols() - |> Enum.map(&Atom.to_string(&1)) - - protocols = - opts - |> Keyword.get(:reindex_protocols, "") - |> String.trim() - |> String.split(",") - |> Enum.map(&String.trim(&1)) - |> Enum.filter(&Enum.member?(supported_protocols, &1)) - - next_block = get_next_block(first_block, last_block, protocols) - - state = - %__MODULE__{ - first_block: first_block, - next_block: next_block, - last_block: last_block, - protocols: protocols - } - |> run_fetch() - - Logger.reset_metadata(logger_metadata) - - {:noreply, state} - end - - @impl GenServer - def handle_info(:fetch, %__MODULE__{} = state) do - task = Task.Supervisor.async_nolink(Indexer.Fetcher.TransactionAction.TaskSupervisor, fn -> task(state) end) - {:noreply, %__MODULE__{state | task: task}} - end - - def handle_info(:stop_server, %__MODULE__{} = state) do - {:stop, :normal, state} - end - - def handle_info({ref, _result}, %__MODULE__{task: %Task{ref: ref}} = state) do - Process.demonitor(ref, [:flush]) - {:noreply, %__MODULE__{state | task: nil}} - end - - def handle_info( - {:DOWN, ref, :process, pid, reason}, - %__MODULE__{task: %Task{pid: pid, ref: ref}} = state - ) do - if reason === :normal do - {:noreply, %__MODULE__{state | task: nil}} - else - logger_metadata = Logger.metadata() - Logger.metadata(fetcher: :transaction_action) - Logger.error(fn -> "Transaction action fetcher task exited due to #{inspect(reason)}. Rerunning..." end) - Logger.reset_metadata(logger_metadata) - {:noreply, run_fetch(%__MODULE__{state | next_block: get_stage_block(@stage_next_block)})} - end - end - - defp run_fetch(state) do - pid = self() - Process.send_after(pid, :fetch, 3000, []) - %__MODULE__{state | task: nil, pid: pid} - end - - defp task( - %__MODULE__{ - first_block: first_block, - next_block: next_block, - last_block: last_block, - protocols: protocols, - pid: pid - } = _state - ) do - logger_metadata = Logger.metadata() - Logger.metadata(fetcher: :transaction_action) - - block_range = Range.new(next_block, first_block, -1) - block_range_init_length = last_block - first_block + 1 - - for block_number <- block_range do - query = - from( - log in Log, - inner_join: b in Block, - on: b.hash == log.block_hash and b.consensus == true, - where: log.block_number == ^block_number, - select: log - ) - - %{transaction_actions: transaction_actions} = - query - |> Repo.all(timeout: :infinity) - |> TransactionActions.parse(protocols) - - addresses = - Addresses.extract_addresses(%{ - transaction_actions: transaction_actions - }) - - transaction_actions_with_data = - Enum.map(transaction_actions, fn action -> - Map.put(action, :data, Map.delete(action.data, :block_number)) - end) - - {:ok, _} = - Chain.import(%{ - addresses: %{params: addresses, on_conflict: :nothing}, - transaction_actions: %{params: transaction_actions_with_data}, - timeout: :infinity - }) - - blocks_processed = last_block - block_number + 1 - - progress_percentage = - blocks_processed - |> Decimal.div(block_range_init_length) - |> Decimal.mult(100) - |> Decimal.round(2) - |> Decimal.to_string() - - next_block_new = BlockNumberHelper.previous_block_number(block_number) - - Logger.info( - "Block #{block_number} handled successfully. Progress: #{progress_percentage}%. Initial block range: #{first_block}..#{last_block}." <> - " Actions found: #{Enum.count(transaction_actions_with_data)}." <> - if(next_block_new >= first_block, do: " Remaining block range: #{first_block}..#{next_block_new}", else: "") - ) - - if block_number == next_block do - {:ok, _} = - LastFetchedCounter.upsert(%{ - counter_type: @stage_first_block, - value: first_block - }) - - {:ok, _} = - LastFetchedCounter.upsert(%{ - counter_type: @stage_last_block, - value: last_block - }) - end - - {:ok, _} = - LastFetchedCounter.upsert(%{ - counter_type: @stage_next_block, - value: next_block_new - }) - end - - Process.send(pid, :stop_server, []) - - Logger.reset_metadata(logger_metadata) - - :ok - end - - defp init_fetching(opts, first_block, last_block) do - first_block = parse_integer(first_block) - last_block = parse_integer(last_block) - - if is_nil(first_block) or is_nil(last_block) or first_block <= 0 or last_block <= 0 or first_block > last_block do - {:stop, "Correct block range must be provided to #{__MODULE__}."} - else - {:ok, %{}, {:continue, {opts, first_block, last_block}}} - end - end - - defp get_next_block(first_block, last_block, protocols) do - first_block_from_stage = get_stage_block(@stage_first_block) - last_block_from_stage = get_stage_block(@stage_last_block) - - {stage_first_block, stage_next_block, stage_last_block} = - if first_block_from_stage == 0 or last_block_from_stage == 0 do - {first_block, last_block, last_block} - else - {first_block_from_stage, get_stage_block(@stage_next_block), last_block_from_stage} - end - - next_block = - if Decimal.eq?(stage_first_block, first_block) and Decimal.eq?(stage_last_block, last_block) do - stage_next_block - else - last_block - end - - if next_block < first_block do - Logger.warning( - "It seems #{__MODULE__} already finished work for the block range #{first_block}..#{last_block} and " <> - if(Enum.empty?(protocols), - do: "all supported protocols.", - else: "the following protocols: #{Enum.join(protocols, ", ")}." - ) <> - " To run it again for a different block range, please change the range through environment variables." - ) - else - Logger.info( - "Running #{__MODULE__} for the block range #{first_block}..#{next_block} and " <> - if(Enum.empty?(protocols), - do: "all supported protocols.", - else: "the following protocols: #{Enum.join(protocols, ", ")}." - ) <> if(next_block < last_block, do: " Initial block range: #{first_block}..#{last_block}.", else: "") - ) - end - - next_block - end - - defp get_stage_block(type) do - type - |> LastFetchedCounter.get() - |> Decimal.to_integer() - rescue - _e in Ecto.NoResultsError -> 0 - end -end diff --git a/apps/indexer/lib/indexer/fetcher/uncle_block.ex b/apps/indexer/lib/indexer/fetcher/uncle_block.ex index 1b65dc2966fb..6ab0712166fc 100644 --- a/apps/indexer/lib/indexer/fetcher/uncle_block.ex +++ b/apps/indexer/lib/indexer/fetcher/uncle_block.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.UncleBlock do @moduledoc """ Fetches `t:Explorer.Chain.Block.t/0` by `hash` and updates `t:Explorer.Chain.Block.SecondDegreeRelation.t/0` @@ -12,6 +13,7 @@ defmodule Indexer.Fetcher.UncleBlock do alias Ecto.Changeset alias EthereumJSONRPC.Blocks alias Explorer.Chain + alias Explorer.Chain.Block, as: ExplorerChainBlock alias Explorer.Chain.Cache.{Accounts, Uncles} alias Explorer.Chain.Hash alias Indexer.{Block, BufferedTask, Tracer} @@ -49,26 +51,26 @@ defmodule Indexer.Fetcher.UncleBlock do @doc false def child_spec([init_options, gen_server_options]) when is_list(init_options) do - {state, mergeable_init_options} = Keyword.pop(init_options, :block_fetcher) - - unless state do - raise ArgumentError, - ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> - "to allow for json_rpc calls when running." + case Keyword.pop(init_options, :block_fetcher) do + {%Block.Fetcher{} = state, mergeable_init_options} -> + merged_init_options = + @defaults + |> Keyword.merge(mergeable_init_options) + |> Keyword.put(:state, %Block.Fetcher{state | broadcast: :uncle, callback_module: __MODULE__}) + + Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_options}, gen_server_options]}, id: __MODULE__) + + _ -> + raise ArgumentError, + ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> + "to allow for json_rpc calls when running." end - - merged_init_options = - @defaults - |> Keyword.merge(mergeable_init_options) - |> Keyword.put(:state, %Block.Fetcher{state | broadcast: :uncle, callback_module: __MODULE__}) - - Supervisor.child_spec({BufferedTask, [{__MODULE__, merged_init_options}, gen_server_options]}, id: __MODULE__) end @impl BufferedTask def init(initial, reducer, _) do {:ok, final} = - Chain.stream_unfetched_uncles( + ExplorerChainBlock.stream_unfetched_uncles( initial, fn uncle, acc -> uncle @@ -226,7 +228,7 @@ defmodule Indexer.Fetcher.UncleBlock do loggable_errors = loggable_errors(errors) loggable_error_count = Enum.count(loggable_errors) - unless loggable_error_count == 0 do + if loggable_error_count != 0 do Logger.error( fn -> [ diff --git a/apps/indexer/lib/indexer/fetcher/withdrawal.ex b/apps/indexer/lib/indexer/fetcher/withdrawal.ex index b8a7707bd93e..058e82cca3bd 100644 --- a/apps/indexer/lib/indexer/fetcher/withdrawal.ex +++ b/apps/indexer/lib/indexer/fetcher/withdrawal.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Withdrawal do @moduledoc """ Reindexes withdrawals from blocks that were indexed before app update. @@ -50,7 +51,7 @@ defmodule Indexer.Fetcher.Withdrawal do # withdrawals from all other blocks will be imported by realtime and catchup indexers json_rpc_named_arguments = opts[:json_rpc_named_arguments] - unless json_rpc_named_arguments do + if !json_rpc_named_arguments do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.init to allow for json_rpc calls when running." end diff --git a/apps/indexer/lib/indexer/fetcher/zilliqa/scilla_smart_contracts.ex b/apps/indexer/lib/indexer/fetcher/zilliqa/scilla_smart_contracts.ex index 2a022db6de8c..d6a2571c0cb2 100644 --- a/apps/indexer/lib/indexer/fetcher/zilliqa/scilla_smart_contracts.ex +++ b/apps/indexer/lib/indexer/fetcher/zilliqa/scilla_smart_contracts.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Zilliqa.ScillaSmartContracts do @moduledoc """ Marks Scilla smart contracts as verified on the Zilliqa blockchain. These diff --git a/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex b/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex new file mode 100644 index 000000000000..09a9361e55f4 --- /dev/null +++ b/apps/indexer/lib/indexer/fetcher/zilliqa/zrc2_tokens.ex @@ -0,0 +1,716 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Zilliqa.Zrc2Tokens do + @moduledoc """ + Fills `zilliqa_zrc2_token_transfers` (or `token_transfers`) and `zilliqa_zrc2_token_adapters` table + (with accompanying tables like `tokens`, `address_token_balances`, etc.). + + The `zilliqa_zrc2_token_transfers` table is used for temporary storing ZRC-2 transfers that have unknown adapter contract address. + Once the token adapter address becomes known, the corresponding transfers are moved from + the `zilliqa_zrc2_token_transfers` to `token_transfers` table (and the adapter address is used for the `token_contract_address_hash` field). + + This fetcher is responsible for: + - handling historic ZRC-2 transfer events (`TransferSuccess`, `TransferFromSuccess`, `Minted`, `Burnt`) and + filling the token transfer and token adapter tables. + - periodically checking and moving ZRC-2 token transfers from the `zilliqa_zrc2_token_transfers` + to the `token_transfers` table. + + Also, this fetcher contains a common handling function which is also called by the realtime and catchup block fetchers + (see `fetch_zrc2_token_transfers_and_adapters` code) to handle the ZRC-2 transfer events in catchup and realtime mode. + + The historic handler goes through the `logs` and `transactions` database tables (in the reverse block number order) + and fills the `zilliqa_zrc2_token_transfers` and `zilliqa_zrc2_token_adapters` tables. If ZRC-2 token adapter is already known + (exists in the `zilliqa_zrc2_token_adapters` table), the fetcher instead writes the corresponding rows directly into the `token_transfers` table + setting `token_contract_address_hash` field to the adapter's address. + + Periodically checking and moving ZRC-2 token transfers from the `zilliqa_zrc2_token_transfers` to the `token_transfers` table + is implemented in the `move_zrc2_token_transfers_to_token_transfers` function. It's called after every + `fetch_zrc2_token_transfers_and_adapters` call. After the historic handler finishes its work (reaches the FIRST_BLOCK), + the `move_zrc2_token_transfers_to_token_transfers` function continues to be called periodically (once per a few seconds) + to check and move token transfers created by the realtime (or catchup) block fetcher. This call isn't invoked in the block + fetcher itself as it can take a lot of time (if there are a lot of rows in the `zilliqa_zrc2_token_transfers` table) and lead to + block fetcher delays. It's called asynchronously by this fetcher instead. + + The handled token transfers are added with "ZRC-2" token type. + """ + + use GenServer + use Indexer.Fetcher + + require Logger + + import Ecto.Query + import Explorer.Helper, only: [decode_data: 2] + + import Indexer.Block.Fetcher, + only: [ + async_import_token_balances: 2, + async_import_current_token_balances: 2, + async_import_tokens: 2 + ] + + alias Explorer.{Chain, Repo} + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.{Data, Hash, Log, SmartContract, TokenTransfer} + alias Explorer.Chain.Zilliqa.Zrc2.TokenAdapter + alias Explorer.Chain.Zilliqa.Zrc2.TokenTransfer, as: Zrc2TokenTransfer + alias Indexer.Block.Fetcher, as: BlockFetcher + alias Indexer.TokenBalances + alias Indexer.Transform.{Addresses, AddressTokenBalances} + + @counter_type "zilliqa_zrc2_tokens_fetcher_last_block_number" + @logging_max_block_range 100 + + @zrc2_transfer_success_event "0xa5901fdb53ef45260c18811f35461e0eda2b6133d807dabbfb65314dd4fc2fac" + @zrc2_transfer_from_success_event "0x96acecb2152edcc0681aa27d354d55d64192e86489ff8d5d903d63ef266755a1" + @zrc2_minted_event "0x845020906442ad0651b44a75d9767153912bfa586416784cf8ead41b37b1dbf5" + @zrc2_burnt_event "0x92b328e20a23b6dc6c50345c8a05b555446cbde2e9e1e2ee91dab47bd5204d07" + + @check_zrc2_token_transfers_interval :timer.seconds(10) + + def child_spec(start_link_arguments) do + spec = %{ + id: __MODULE__, + start: {__MODULE__, :start_link, start_link_arguments}, + restart: :transient, + type: :worker + } + + Supervisor.child_spec(spec, []) + end + + def start_link(args, gen_server_options \\ []) do + GenServer.start_link(__MODULE__, args, Keyword.put_new(gen_server_options, :name, __MODULE__)) + end + + @impl GenServer + def init(_args) do + {:ok, %{}, {:continue, nil}} + end + + @impl GenServer + def handle_continue(_, _state) do + Logger.metadata(fetcher: __MODULE__) + + # two seconds pause needed to avoid exceeding Supervisor restart intensity when DB issues + :timer.sleep(2000) + + # get the starting block number from the cache to start from the same point + # we stopped before the instance was down. If this is the first run, + # get the latest known block number from the `logs` table + block_number_to_analyze = + case LastFetchedCounter.get(@counter_type, nullable: true) do + nil -> Log.last_known_block_number() + block_number -> Decimal.to_integer(block_number) + end + + if is_nil(block_number_to_analyze) do + # this is the first run of the instance from scratch, so the events will be handled by the realtime and catchup indexers + # and we only need to call the `move_zrc2_token_transfers_to_token_transfers` periodically + Logger.info( + "There are no known consensus blocks in the database, so #{__MODULE__} will only periodically check the `zilliqa_zrc2_token_transfers` table." + ) + + LastFetchedCounter.upsert(%{counter_type: @counter_type, value: 0}) + Process.send_after(self(), :continue, @check_zrc2_token_transfers_interval) + + {:noreply, %{block_number_to_analyze: 0, is_initial_block: true, first_block_number: 0}} + else + # we continue handling after instance restart + first_block_number = + if block_number_to_analyze > 0 do + max(Application.get_env(:indexer, :first_block), Log.first_known_block_number()) + else + 0 + end + + Process.send(self(), :continue, []) + + {:noreply, + %{ + block_number_to_analyze: block_number_to_analyze, + is_initial_block: true, + first_block_number: first_block_number + }} + end + end + + @doc """ + Implements the handler loop. + + If we didn't reach the FIRST_BLOCK, get the logs and transactions of the `block_number_to_analyze` block, + call the `fetch_zrc2_token_transfers_and_adapters` function for this block, call the + `move_zrc2_token_transfers_to_token_transfers` function, and go to the next block (in the reverse order). + + If we reached the FIRST_BLOCK, we just call the `move_zrc2_token_transfers_to_token_transfers` function + and go to the next iteration (and so on indefinitely). + + ## Parameters + - `:continue`: The GenServer message. + - `%{:block_number_to_analyze, :is_initial_block, :first_block_number}`: A map with the current module state where: + - `block_number_to_analyze` is the current block number we need to handle. Must be zero if we only need to call + `move_zrc2_token_transfers_to_token_transfers`. + - `is_initial_block` must be `true` for the first iteration (for correct logging). + - `first_block_number` is the block number which should be processed last. + + ## Returns + - `{:noreply, %{:block_number_to_analyze, :is_initial_block, :first_block_number}}` map with the changed state. + """ + @impl GenServer + def handle_info(:continue, %{ + block_number_to_analyze: block_number_to_analyze, + is_initial_block: is_initial_block, + first_block_number: first_block_number + }) do + if block_number_to_analyze > 0 and block_number_to_analyze >= first_block_number do + cond do + is_initial_block -> + Logger.info( + "Handling blocks #{block_number_to_analyze}..#{max(div(block_number_to_analyze, @logging_max_block_range) * @logging_max_block_range + 1, first_block_number)}..." + ) + + rem(block_number_to_analyze, @logging_max_block_range) == 0 -> + Logger.info( + "Handling blocks #{block_number_to_analyze}..#{max(block_number_to_analyze - @logging_max_block_range + 1, first_block_number)}..." + ) + + true -> + :ok + end + + transfer_events = [ + @zrc2_transfer_success_event, + @zrc2_transfer_from_success_event, + @zrc2_minted_event, + @zrc2_burnt_event, + TokenTransfer.constant() + ] + + logs = Zrc2TokenTransfer.read_block_logs(block_number_to_analyze, transfer_events) + transactions = Zrc2TokenTransfer.read_transfer_transactions(logs, @zrc2_transfer_success_event) + + block_numbers_to_analyze = Range.new(block_number_to_analyze, block_number_to_analyze) + + fetch_zrc2_token_transfers_and_adapters(logs, transactions, block_numbers_to_analyze, __MODULE__) + move_zrc2_token_transfers_to_token_transfers() + + LastFetchedCounter.upsert(%{counter_type: @counter_type, value: block_number_to_analyze - 1}) + + # little pause to unload cpu + Process.send_after(self(), :continue, 10) + else + Logger.info("Checking zilliqa_zrc2_token_transfers table and hanging adapters...") + move_zrc2_token_transfers_to_token_transfers() + remove_hanging_adapters() + Process.send_after(self(), :continue, @check_zrc2_token_transfers_interval) + end + + {:noreply, + %{ + block_number_to_analyze: max(block_number_to_analyze - 1, 0), + is_initial_block: false, + first_block_number: first_block_number + }} + end + + @impl GenServer + def handle_info({ref, _result}, state) do + Process.demonitor(ref, [:flush]) + {:noreply, state} + end + + @doc """ + Fetches ZRC-2 token transfers and adapter contract addresses for ZRC-2 tokens + by the given list of logs and transactions from a certain block range. + + ## Parameters + - `logs`: The list of logs to filter them by ZRC-2 events. + - `transactions`: The list of transactions to find ZRC-2 adapter addresses. + - `block_numbers`: The block range for logging purposes. + - `calling_module`: The calling module for logging purposes and for prioritization + of async import of tokens and balances. + + ## Returns + - Nothing. + """ + @spec fetch_zrc2_token_transfers_and_adapters( + [ + %{ + :first_topic => Hash.t(), + :data => Data.t(), + :address_hash => Hash.t(), + :transaction_hash => Hash.t(), + :index => non_neg_integer(), + :block_number => non_neg_integer(), + :block_hash => Hash.t(), + optional(:adapter_address_hash) => Hash.t() | nil + } + ], + [ + %{ + :hash => Hash.t(), + :input => Data.t(), + :to_address_hash => Hash.t() + } + ], + Range.t(), + module() + ) :: no_return() + def fetch_zrc2_token_transfers_and_adapters([], _transactions, _block_numbers, _calling_module), do: nil + + def fetch_zrc2_token_transfers_and_adapters(logs, transactions, block_numbers, calling_module) do + zrc2_logs = filter_zrc2_logs(logs) + + adapter_address_hash_by_zrc2_address_hash = + zrc2_logs + |> Enum.reject(&Map.has_key?(&1, :adapter_address_hash)) + |> Enum.map(& &1.address_hash) + |> Enum.uniq() + |> TokenAdapter.adapter_address_hash_by_zrc2_address_hash() + + {zrc2_token_transfers, token_transfers} = + zrc2_logs + |> Enum.reduce({[], []}, fn log, {zrc2_token_transfers_acc, token_transfers_acc} -> + first_topic = Hash.to_string(log.first_topic) + params = zrc2_event_params(log.data) + + {from_address_hash, to_address_hash, amount} = + try do + cond do + first_topic in [@zrc2_transfer_success_event, @zrc2_transfer_from_success_event] -> + {params.sender, params.recipient, Decimal.new(params.amount)} + + first_topic == @zrc2_minted_event -> + {SmartContract.burn_address_hash_string(), params.recipient, Decimal.new(params.amount)} + + first_topic == @zrc2_burnt_event -> + {params.burn_account, SmartContract.burn_address_hash_string(), Decimal.new(params.amount)} + end + rescue + _ -> + {nil, nil, nil} + end + + if Enum.all?([from_address_hash, to_address_hash, amount], &is_nil(&1)) do + # the event is not supported as has incorrect parameters (doesn't relate to ZRC-2) + {zrc2_token_transfers_acc, token_transfers_acc} + else + adapter_address_hash = zrc2_log_adapter_address_hash(log, adapter_address_hash_by_zrc2_address_hash) + + token_transfer = %{ + transaction_hash: log.transaction_hash, + log_index: log.index, + from_address_hash: from_address_hash, + to_address_hash: to_address_hash, + amount: amount, + block_number: log.block_number, + block_hash: log.block_hash + } + + # credo:disable-for-next-line Credo.Check.Refactor.Nesting + if is_nil(adapter_address_hash) do + # the adapter address is unknown yet, so place the token transfer to the `zilliqa_zrc2_token_transfers` table + {[Map.put(token_transfer, :zrc2_address_hash, Hash.to_string(log.address_hash)) | zrc2_token_transfers_acc], + token_transfers_acc} + else + # the adapter address is already known, so place the token transfer directly to the `token_transfers` table + {zrc2_token_transfers_acc, + [ + token_transfer + |> Map.put(:token_contract_address_hash, Hash.to_string(adapter_address_hash)) + |> Map.put(:token_type, "ZRC-2") + |> Map.put(:block_consensus, true) + |> Map.put(:token_ids, nil) + | token_transfers_acc + ]} + end + end + end) + + if token_transfers != [] do + Logger.info( + "Found #{Enum.count(token_transfers)} ZRC-2 token transfer(s) with known adapter address in the block(s) #{block_range_printable(block_numbers)}.", + fetcher: calling_module + ) + end + + if zrc2_token_transfers != [] do + Logger.info( + "Found #{Enum.count(zrc2_token_transfers)} ZRC-2 token transfer(s) with unknown adapter address in the block(s) #{block_range_printable(block_numbers)}.", + fetcher: calling_module + ) + end + + addresses = + Addresses.extract_addresses(%{ + token_transfers: token_transfers, + zilliqa_zrc2_token_transfers: zrc2_token_transfers + }) + + {tokens, address_token_balances, address_current_token_balances} = + prepare_tokens_and_balances(token_transfers) + + {:ok, imported} = + Chain.import(%{ + addresses: %{params: addresses}, + address_token_balances: %{params: address_token_balances}, + address_current_token_balances: %{params: address_current_token_balances}, + tokens: %{params: tokens}, + token_transfers: %{params: token_transfers}, + zilliqa_zrc2_token_transfers: %{params: zrc2_token_transfers}, + timeout: :infinity + }) + + async_import_tokens(imported, calling_module == Indexer.Block.Realtime.Fetcher) + async_import_token_balances(imported, calling_module == Indexer.Block.Realtime.Fetcher) + async_import_current_token_balances(imported, calling_module == Indexer.Block.Realtime.Fetcher) + + fetch_zrc2_token_adapters( + logs, + transactions, + block_numbers, + adapter_address_hash_by_zrc2_address_hash, + calling_module + ) + end + + # Filters the given logs to get ZRC-2 logs. + # + # ## Parameters + # - `logs`: The unfiltered list of logs. + # + # ## Returns + # - The filtered list of logs. + @spec filter_zrc2_logs(list()) :: list() + defp filter_zrc2_logs(logs) do + logs + |> Enum.filter( + &(!is_nil(&1.first_topic) and + Hash.to_string(&1.first_topic) in [ + @zrc2_transfer_success_event, + @zrc2_transfer_from_success_event, + @zrc2_minted_event, + @zrc2_burnt_event + ]) + ) + end + + # Fetches ZRC-2 token adapter contract addresses for ZRC-2 tokens + # by the given list of logs and transactions from a certain block range. + # + # For `TransferSuccess` event, we look into the corresponding transaction input: if that's the `transfer` method call + # and the transaction doesn't have the corresponding `Transfer` event emitted by the `to` address, the `to` address + # is the adapter address we searching for, and the address emitted the `TransferSuccess` event is the corresponding + # ZRC-2 address. + # + # ## Parameters + # - `logs`: The list of logs to filter them by `TransferSuccess` and `Transfer` events. + # - `transactions`: The list of transactions to find ZRC-2 adapter addresses. + # - `block_numbers`: The block range for logging purposes. + # - `adapter_address_hash_by_zrc2_address_hash`: A `%{zrc2_address_hash => adapter_address_hash}` map with + # the existing relations to avoid searching of already found adapter addresses. + # - `calling_module`: The calling module for logging purposes. + # + # ## Returns + # - Nothing. + @spec fetch_zrc2_token_adapters( + [ + %{ + :first_topic => Hash.t(), + :data => Data.t(), + :address_hash => Hash.t(), + :transaction_hash => Hash.t(), + :index => non_neg_integer(), + :block_number => non_neg_integer(), + :block_hash => Hash.t(), + optional(:adapter_address_hash) => Hash.t() | nil + } + ], + [ + %{ + :hash => Hash.t(), + :input => Data.t(), + :to_address_hash => Hash.t() + } + ], + Range.t(), + %{Hash.t() => Hash.t()}, + module() + ) :: no_return() + defp fetch_zrc2_token_adapters( + logs, + transactions, + block_numbers, + adapter_address_hash_by_zrc2_address_hash, + calling_module + ) do + transaction_by_hash = + transactions + |> Enum.map(&{&1.hash, &1}) + |> Enum.into(%{}) + + zrc2_token_adapters = + logs + |> Enum.filter( + &valid_zrc2_transfer_log?(&1, transaction_by_hash, adapter_address_hash_by_zrc2_address_hash, logs) + ) + |> Enum.reduce([], &build_zrc2_adapters(&1, &2, transaction_by_hash, logs)) + |> Enum.uniq() + + if zrc2_token_adapters != [] do + Logger.info( + "Found #{Enum.count(zrc2_token_adapters)} new ERC-20 adapter(s) for ZRC-2 token(s) in the block(s) #{block_range_printable(block_numbers)}.", + fetcher: calling_module + ) + + {:ok, _} = + Chain.import(%{ + addresses: %{params: Addresses.extract_addresses(%{zilliqa_zrc2_token_adapters: zrc2_token_adapters})}, + zilliqa_zrc2_token_adapters: %{params: zrc2_token_adapters}, + timeout: :infinity + }) + end + end + + # Checks if log is a valid ZRC-2 transfer log with correct event signature and method ID + defp valid_zrc2_transfer_log?(log, transaction_by_hash, adapter_address_hash_by_zrc2_address_hash, _logs) do + with false <- is_nil(log.first_topic), + true <- Hash.to_string(log.first_topic) == @zrc2_transfer_success_event, + # only ZRC-2 is supported + params = zrc2_event_params(log.data), + true <- Map.has_key?(params, :sender) && Map.has_key?(params, :recipient) && Map.has_key?(params, :amount), + true <- is_nil(zrc2_log_adapter_address_hash(log, adapter_address_hash_by_zrc2_address_hash)) do + transaction_input = transaction_by_hash[log.transaction_hash].input.bytes + + method_id = + if byte_size(transaction_input) >= 4 do + <> = transaction_input + "0x" <> Base.encode16(method_id, case: :lower) + else + nil + end + + method_id == TokenTransfer.transfer_function_signature() + else + _ -> false + end + end + + # Builds ZRC-2 token adapter from a log if no ERC-20 transfer was found in the same transaction + defp build_zrc2_adapters(log, acc, transaction_by_hash, logs) do + transaction_hash = log.transaction_hash + to_address_hash = transaction_by_hash[transaction_hash].to_address_hash + + # are there any `Transfer` logs emitted by the `to_address_hash` in this transaction? + erc20_transfer_event_found = + has_erc20_transfer_for_address?(logs, transaction_hash, to_address_hash) + + if erc20_transfer_event_found do + acc + else + # if the `Transfer` log is not found, this is ERC-20 adapter contract address + [ + %{ + adapter_address_hash: Hash.to_string(to_address_hash), + zrc2_address_hash: Hash.to_string(log.address_hash) + } + | acc + ] + end + end + + # Checks if there are `Transfer` logs emitted by the given address in the transaction + defp has_erc20_transfer_for_address?(logs, transaction_hash, to_address_hash) do + logs + |> Enum.filter(&(&1.transaction_hash == transaction_hash)) + |> Enum.any?( + &(!is_nil(&1.first_topic) and Hash.to_string(&1.first_topic) == TokenTransfer.constant() and + &1.address_hash == to_address_hash) + ) + end + + # Scans the `zilliqa_zrc2_token_transfers` table for the rows that have corresponding + # adapter addresses in the `zilliqa_zrc2_token_adapters` table. The found rows are inserted into the `token_transfers` + # table (with the `token_contract_address_hash` == `adapter_address_hash` and `token_type` == "ZRC-2"), and then + # removed from the `zilliqa_zrc2_token_transfers` table. + # + # For the found token transfers, the function prepares and asynchronously imports the corresponding tokens + # and their balances. + # + # ## Returns + # - :ok + @spec move_zrc2_token_transfers_to_token_transfers() :: :ok + defp move_zrc2_token_transfers_to_token_transfers do + zrc2_token_transfers = Zrc2TokenTransfer.zrc2_token_transfers_having_adapter() + + token_transfers = + zrc2_token_transfers + |> Enum.map(fn token_transfer -> + token_transfer + |> Map.put(:from_address_hash, Hash.to_string(token_transfer.from_address_hash)) + |> Map.put(:to_address_hash, Hash.to_string(token_transfer.to_address_hash)) + |> Map.put(:token_contract_address_hash, Hash.to_string(token_transfer.adapter_address_hash)) + |> Map.put(:token_type, "ZRC-2") + |> Map.put(:block_consensus, true) + |> Map.put(:token_ids, nil) + |> Map.delete(:adapter_address_hash) + end) + + if token_transfers != [] do + {tokens, address_token_balances, address_current_token_balances} = + prepare_tokens_and_balances(token_transfers) + + Logger.info("Moving #{Enum.count(token_transfers)} ZRC-2 token transfer(s) to the token_transfers table...") + + {:ok, imported} = + Chain.import(%{ + address_token_balances: %{params: address_token_balances}, + address_current_token_balances: %{params: address_current_token_balances}, + tokens: %{params: tokens}, + token_transfers: %{params: token_transfers}, + timeout: :infinity + }) + + async_import_tokens(imported, false) + async_import_token_balances(imported, false) + async_import_current_token_balances(imported, false) + end + + Enum.each(token_transfers, fn tt -> + Repo.delete_all( + from( + ztt in Zrc2TokenTransfer, + where: + ztt.transaction_hash == ^tt.transaction_hash and ztt.log_index == ^tt.log_index and + ztt.block_hash == ^tt.block_hash + ) + ) + end) + end + + # Checks if there are adapter addresses not bound to any consensus token transfers: if there are, + # they are removed from the `zilliqa_zrc2_token_adapters` table. Also, the corresponding rows are + # removed from the `zilliqa_zrc2_token_transfers` table by the `zrc2_address_hash` address. + # The hanging adapters can appear due to reorgs. + @spec remove_hanging_adapters() :: any() + defp remove_hanging_adapters do + hanging_adapters = + TokenAdapter + |> Repo.all() + |> Enum.reduce([], fn adapter, acc -> + query = + from(tt in TokenTransfer, + where: tt.token_contract_address_hash == ^adapter.adapter_address_hash and tt.block_consensus == true, + limit: 1 + ) + + case Repo.one(query) do + nil -> [adapter | acc] + _ -> acc + end + end) + + if hanging_adapters != [] do + adapter_address_hashes = + hanging_adapters + |> Enum.map(& &1.adapter_address_hash) + + zrc2_address_hashes = + hanging_adapters + |> Enum.map(& &1.zrc2_address_hash) + + Repo.delete_all(from(a in TokenAdapter, where: a.adapter_address_hash in ^adapter_address_hashes)) + Repo.delete_all(from(ztt in Zrc2TokenTransfer, where: ztt.zrc2_address_hash in ^zrc2_address_hashes)) + end + end + + # Prepares tokens, token balances, and current token balances to be imported + # to the database by the given token transfers. + # + # ## Parameters + # - `token_transfers`: The list of token transfers details. + # + # ## Returns + # - `{tokens, address_token_balances, address_current_token_balances}` tuple with the lists + # ready to be imported to the database. + @spec prepare_tokens_and_balances([map()]) :: {list(), list(), list()} + defp prepare_tokens_and_balances(token_transfers) do + tokens = + token_transfers + |> Enum.map(&%{contract_address_hash: &1.token_contract_address_hash, type: &1.token_type}) + |> Enum.uniq() + + address_token_balances = + %{token_transfers_params: BlockFetcher.token_transfers_merge_token(token_transfers, tokens)} + |> AddressTokenBalances.params_set() + |> MapSet.to_list() + + address_current_token_balances = + address_token_balances + |> TokenBalances.to_address_current_token_balances() + + {tokens, address_token_balances, address_current_token_balances} + end + + # Gets ZRC-2 adapter address hash from the corresponding ZRC-2 log + # or (if not found) from the `%{zrc2_address_hash => adapter_address_hash}` map. + # + # ## Parameters + # - `log`: The ZRC-2 log map with or without the `adapter_address_hash` key. + # - `adapter_address_hash_by_zrc2_address_hash`: The `%{zrc2_address_hash => adapter_address_hash}` map + # with already existing relations. + # + # ## Returns + # - The adapter address hash. + # - `nil` if not found. + @spec zrc2_log_adapter_address_hash( + %{optional(:adapter_address_hash) => Hash.t() | nil, :address_hash => Hash.t()}, + %{Hash.t() => Hash.t()} + ) :: Hash.t() | nil + defp zrc2_log_adapter_address_hash(log, adapter_address_hash_by_zrc2_address_hash) do + case Map.fetch(log, :adapter_address_hash) do + {:ok, adapter_address_hash} -> adapter_address_hash + :error -> Map.get(adapter_address_hash_by_zrc2_address_hash, log.address_hash) + end + end + + # Converts ZRC-2 event string parameter into a map. + # + # Example: + # {"address":"0x818ca2e217e060ad17b7bd0124a483a1f66930a9","_eventname":"TransferSuccess","params":[{"vname":"sender","value":"0x90696d5bea3f11feb2e304718d17564d90c3d780","type":"ByStr20"},{"vname":"recipient","value":"0x4afc249b706560766a3c6ab8db2825102aa224fc","type":"ByStr20"},{"vname":"amount","value":"1000000000","type":"Uint128"}]} + # will be converted to + # %{sender: "0x90696d5bea3f11feb2e304718d17564d90c3d780", recipient: "0x4afc249b706560766a3c6ab8db2825102aa224fc", amount: "1000000000"} + # + # ## Parameters + # - `log_data`: The event's data field. + # + # ## Returns + # - A map with the parameters from the event. + @spec zrc2_event_params(Data.t()) :: map() + defp zrc2_event_params(log_data) do + log_data + |> decode_data([:string]) + |> Enum.at(0) + |> Jason.decode!() + |> Map.get("params", nil) + |> Enum.map(fn param -> {String.to_atom(param["vname"]), param["value"]} end) + |> Enum.into(%{}) + end + + # Makes the given range human readable (and prints only one value if the range contains only one item). + # + # ## Parameters + # - `range`: The range to make printable. + # + # ## Returns + # - A string of the range. + @spec block_range_printable(Range.t()) :: String.t() + defp block_range_printable(range) do + first..last//_step = range + + if Range.size(range) == 1 do + to_string(first) + else + "#{first}..#{last}" + end + end +end diff --git a/apps/indexer/lib/indexer/fetcher/zksync/batches_status_tracker.ex b/apps/indexer/lib/indexer/fetcher/zksync/batches_status_tracker.ex index 7c733bba89fc..0d60692d7baa 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/batches_status_tracker.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/batches_status_tracker.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.BatchesStatusTracker do @moduledoc """ Updates batches statuses and imports historical batches to the `zksync_transaction_batches` table. @@ -87,12 +88,12 @@ defmodule Indexer.Fetcher.ZkSync.BatchesStatusTracker do json_l1_rpc_named_arguments: [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, + http: EthereumJSONRPC.HTTP.Tesla, urls: [l1_rpc], http_options: [ recv_timeout: :timer.minutes(10), timeout: :timer.minutes(10), - hackney: [pool: :ethereum_jsonrpc] + pool: :ethereum_jsonrpc ] ] ], diff --git a/apps/indexer/lib/indexer/fetcher/zksync/discovery/batches_data.ex b/apps/indexer/lib/indexer/fetcher/zksync/discovery/batches_data.ex index 36a816674beb..bcfa6bf387f9 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/discovery/batches_data.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/discovery/batches_data.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.Discovery.BatchesData do @moduledoc """ Provides main functionality to extract data for batches and associated with them diff --git a/apps/indexer/lib/indexer/fetcher/zksync/discovery/workers.ex b/apps/indexer/lib/indexer/fetcher/zksync/discovery/workers.ex index bd0bde1ef959..0dc559c8d881 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/discovery/workers.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/discovery/workers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.Discovery.Workers do @moduledoc """ Provides functions to download a set of batches from RPC and import them to DB. diff --git a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/committed.ex b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/committed.ex index 85ed1f9cbd45..b51a64c59a86 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/committed.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/committed.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.StatusTracking.Committed do @moduledoc """ Functionality to discover committed batches diff --git a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/common.ex b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/common.ex index a5133d97bc2f..563476365325 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/common.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/common.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.StatusTracking.CommonUtils do @moduledoc """ Common functions for status changes trackers diff --git a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/executed.ex b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/executed.ex index 03538bd82e9a..2b397318e941 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/executed.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/executed.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.StatusTracking.Executed do @moduledoc """ Functionality to discover executed batches diff --git a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/proven.ex b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/proven.ex index 0fb3e21a8c17..b1e202aef01f 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/proven.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/status_tracking/proven.ex @@ -1,9 +1,9 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.StatusTracking.Proven do @moduledoc """ Functionality to discover proven batches """ - alias ABI.{FunctionSelector, TypeDecoder} alias Indexer.Fetcher.ZkSync.Utils.{Db, Rpc} import Indexer.Fetcher.ZkSync.StatusTracking.CommonUtils, @@ -12,7 +12,7 @@ defmodule Indexer.Fetcher.ZkSync.StatusTracking.Proven do associate_and_import_or_prepare_for_recovery: 4 ] - import Indexer.Fetcher.ZkSync.Utils.Logging, only: [log_error: 1, log_info: 1] + import Indexer.Fetcher.ZkSync.Utils.Logging, only: [log_info: 1] @doc """ Checks if the oldest unproven batch in the database has the associated L1 proving transaction @@ -60,7 +60,7 @@ defmodule Indexer.Fetcher.ZkSync.StatusTracking.Proven do :look_for_batches -> log_info("The batch #{expected_batch_number} looks like proven") prove_transaction = Rpc.fetch_transaction_by_hash(transaction_hash, json_l1_rpc_named_arguments) - batches_numbers_from_rpc = get_proven_batches_from_calldata(prove_transaction["input"]) + batches_numbers_from_rpc = Rpc.get_proven_batches_from_calldata(prove_transaction["input"]) associate_and_import_or_prepare_for_recovery( batches_numbers_from_rpc, @@ -71,119 +71,4 @@ defmodule Indexer.Fetcher.ZkSync.StatusTracking.Proven do end end end - - defp get_proven_batches_from_calldata(calldata) do - # /// @param batchNumber Rollup batch number - # /// @param batchHash Hash of L2 batch - # /// @param indexRepeatedStorageChanges The serial number of the shortcut index that's used as a unique identifier for storage keys that were used twice or more - # /// @param numberOfLayer1Txs Number of priority operations to be processed - # /// @param priorityOperationsHash Hash of all priority operations from this batch - # /// @param l2LogsTreeRoot Root hash of tree that contains L2 -> L1 messages from this batch - # /// @param timestamp Rollup batch timestamp, have the same format as Ethereum batch constant - # /// @param commitment Verified input for the zkSync circuit - # struct StoredBatchInfo { - # uint64 batchNumber; - # bytes32 batchHash; - # uint64 indexRepeatedStorageChanges; - # uint256 numberOfLayer1Txs; - # bytes32 priorityOperationsHash; - # bytes32 l2LogsTreeRoot; - # uint256 timestamp; - # bytes32 commitment; - # } - # /// @notice Recursive proof input data (individual commitments are constructed onchain) - # struct ProofInput { - # uint256[] recursiveAggregationInput; - # uint256[] serializedProof; - # } - proven_batches = - case calldata do - "0x7f61885c" <> encoded_params -> - # proveBatches(StoredBatchInfo calldata _prevBatch, StoredBatchInfo[] calldata _committedBatches, ProofInput calldata _proof) - # IO.inspect(FunctionSelector.decode("proveBatches((uint64,bytes32,uint64,uint256,bytes32,bytes32,uint256,bytes32),(uint64,bytes32,uint64,uint256,bytes32,bytes32,uint256,bytes32)[],(uint256[],uint256[]))")) - [_prev_batch, proven_batches, _proof] = - TypeDecoder.decode( - Base.decode16!(encoded_params, case: :lower), - %FunctionSelector{ - function: "proveBatches", - types: [ - tuple: [ - uint: 64, - bytes: 32, - uint: 64, - uint: 256, - bytes: 32, - bytes: 32, - uint: 256, - bytes: 32 - ], - array: - {:tuple, - [ - uint: 64, - bytes: 32, - uint: 64, - uint: 256, - bytes: 32, - bytes: 32, - uint: 256, - bytes: 32 - ]}, - tuple: [array: {:uint, 256}, array: {:uint, 256}] - ] - } - ) - - proven_batches - - "0xc37533bb" <> encoded_params -> - # proveBatchesSharedBridge(uint256 _chainId, StoredBatchInfo calldata _prevBatch, StoredBatchInfo[] calldata _committedBatches, ProofInput calldata _proof) - # IO.inspect(FunctionSelector.decode("proveBatchesSharedBridge(uint256,(uint64,bytes32,uint64,uint256,bytes32,bytes32,uint256,bytes32),(uint64,bytes32,uint64,uint256,bytes32,bytes32,uint256,bytes32)[],(uint256[],uint256[]))")) - [_chainid, _prev_batch, proven_batches, _proof] = - TypeDecoder.decode( - Base.decode16!(encoded_params, case: :lower), - %FunctionSelector{ - function: "proveBatchesSharedBridge", - types: [ - {:uint, 256}, - tuple: [ - uint: 64, - bytes: 32, - uint: 64, - uint: 256, - bytes: 32, - bytes: 32, - uint: 256, - bytes: 32 - ], - array: - {:tuple, - [ - uint: 64, - bytes: 32, - uint: 64, - uint: 256, - bytes: 32, - bytes: 32, - uint: 256, - bytes: 32 - ]}, - tuple: [array: {:uint, 256}, array: {:uint, 256}] - ] - } - ) - - proven_batches - - _ -> - log_error("Unknown calldata format: #{calldata}") - - [] - end - - log_info("Discovered #{length(proven_batches)} proven batches in the prove transaction") - - proven_batches - |> Enum.map(fn batch_info -> elem(batch_info, 0) end) - end end diff --git a/apps/indexer/lib/indexer/fetcher/zksync/transaction_batch.ex b/apps/indexer/lib/indexer/fetcher/zksync/transaction_batch.ex index dac1b1d84304..4ba2a821e4f9 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/transaction_batch.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/transaction_batch.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.TransactionBatch do @moduledoc """ Discovers new batches and populates the `zksync_transaction_batches` table. diff --git a/apps/indexer/lib/indexer/fetcher/zksync/utils/db.ex b/apps/indexer/lib/indexer/fetcher/zksync/utils/db.ex index eee7f470b442..563e9a7a5428 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/utils/db.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/utils/db.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.Utils.Db do @moduledoc """ Common functions to simplify DB routines for Indexer.Fetcher.ZkSync fetchers diff --git a/apps/indexer/lib/indexer/fetcher/zksync/utils/logging.ex b/apps/indexer/lib/indexer/fetcher/zksync/utils/logging.ex index eb7fe6058797..4ea940713a17 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/utils/logging.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/utils/logging.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.Utils.Logging do @moduledoc """ Common logging functions for Indexer.Fetcher.ZkSync fetchers diff --git a/apps/indexer/lib/indexer/fetcher/zksync/utils/rpc.ex b/apps/indexer/lib/indexer/fetcher/zksync/utils/rpc.ex index 98df6235e38b..9338904fa18f 100644 --- a/apps/indexer/lib/indexer/fetcher/zksync/utils/rpc.ex +++ b/apps/indexer/lib/indexer/fetcher/zksync/utils/rpc.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ZkSync.Utils.Rpc do @moduledoc """ Common functions to handle RPC calls for Indexer.Fetcher.ZkSync fetchers @@ -5,9 +6,13 @@ defmodule Indexer.Fetcher.ZkSync.Utils.Rpc do import EthereumJSONRPC, only: [json_rpc: 2, quantity_to_integer: 1] - alias Explorer.Helper, as: ExplorerHelper + alias ABI.{FunctionSelector, TypeDecoder} + alias EthereumJSONRPC.ZkSync.Constants.Contracts, as: ZkSyncContracts + alias Explorer.Chain.Hash alias Indexer.Helper, as: IndexerHelper + import Indexer.Fetcher.ZkSync.Utils.Logging, only: [log_error: 1, log_info: 1] + @zero_hash "0000000000000000000000000000000000000000000000000000000000000000" @zero_hash_binary <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>> @@ -204,7 +209,7 @@ defmodule Indexer.Fetcher.ZkSync.Utils.Rpc do @spec fetch_transaction_by_hash(binary(), EthereumJSONRPC.json_rpc_named_arguments()) :: map() def fetch_transaction_by_hash(raw_hash, json_rpc_named_arguments) when is_binary(raw_hash) and is_list(json_rpc_named_arguments) do - hash = ExplorerHelper.add_0x_prefix(raw_hash) + {:ok, hash} = Hash.Full.cast(raw_hash) req = EthereumJSONRPC.request(%{ @@ -235,7 +240,7 @@ defmodule Indexer.Fetcher.ZkSync.Utils.Rpc do @spec fetch_transaction_receipt_by_hash(binary(), EthereumJSONRPC.json_rpc_named_arguments()) :: map() def fetch_transaction_receipt_by_hash(raw_hash, json_rpc_named_arguments) when is_binary(raw_hash) and is_list(json_rpc_named_arguments) do - hash = ExplorerHelper.add_0x_prefix(raw_hash) + {:ok, hash} = Hash.Full.cast(raw_hash) req = EthereumJSONRPC.request(%{ @@ -376,4 +381,64 @@ defmodule Indexer.Fetcher.ZkSync.Utils.Rpc do responses end + + @doc """ + Extracts batch numbers from the calldata of a proof transaction. + + ## Parameters + - `calldata`: The calldata from the parent chain transaction + + ## Returns + - A list of batch numbers that were proven by the transaction + """ + @spec get_proven_batches_from_calldata(binary()) :: [non_neg_integer()] + def get_proven_batches_from_calldata(calldata) do + proven_batches = + case calldata do + "0x7f61885c" <> encoded_params -> + [_prev_batch, proven_batches, _proof] = + decode_params(encoded_params, ZkSyncContracts.prove_batches_selector_with_abi()) + + extract_batch_numbers(proven_batches) + + # Pre-v26 proveBatchesSharedBridge + "0xc37533bb" <> encoded_params -> + [_chainid, _prev_batch, proven_batches, _proof] = + decode_params(encoded_params, ZkSyncContracts.prove_batches_shared_bridge_c37533bb_selector_with_abi()) + + extract_batch_numbers(proven_batches) + + # v26+ proveBatchesSharedBridge + "0xe12a6137" <> encoded_params -> + [_chainid, process_from, process_to, _proof_data] = + decode_params(encoded_params, ZkSyncContracts.prove_batches_shared_bridge_e12a6137_selector_with_abi()) + + Enum.to_list(process_from..process_to) + + _ -> + log_error("Unknown calldata format: #{calldata}") + + [] + end + + log_info("Discovered #{length(proven_batches)} proven batches in the prove transaction") + + proven_batches + end + + # Decodes encoded parameters using the provided function selector. + # credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct + @spec decode_params(binary(), %FunctionSelector{}) :: list() + defp decode_params(encoded_params, function_selector) do + encoded_params + |> Base.decode16!(case: :lower) + |> TypeDecoder.decode(function_selector) + end + + # Extracts batch numbers from a list of StoredBatchInfo tuples. + @spec extract_batch_numbers([any()]) :: [non_neg_integer()] + defp extract_batch_numbers(proven_batches) do + proven_batches + |> Enum.map(fn batch_info -> elem(batch_info, 0) end) + end end diff --git a/apps/indexer/lib/indexer/helper.ex b/apps/indexer/lib/indexer/helper.ex index 3ff19561d8d4..4620e72aa6c2 100644 --- a/apps/indexer/lib/indexer/helper.ex +++ b/apps/indexer/lib/indexer/helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Helper do @moduledoc """ Auxiliary common functions for indexers. @@ -30,6 +31,7 @@ defmodule Indexer.Helper do @infinite_retries_number 100_000_000 @block_check_interval_range_size 100 @block_by_number_chunk_size 50 + @http_get_request_max_attempts 10 @beacon_blob_fetcher_reference_slot_eth 8_500_000 @beacon_blob_fetcher_reference_timestamp_eth 1_708_824_023 @@ -115,9 +117,15 @@ defmodule Indexer.Helper do @doc """ Calculates average block time in milliseconds (based on the latest 100 blocks) divided by 2. Sends corresponding requests to the RPC node. - Returns a tuple {:ok, block_check_interval, last_safe_block} - where `last_safe_block` is the number of the recent `safe` or `latest` block (depending on which one is available). - Returns {:error, description} in case of error. + + ## Parameters + - `json_rpc_named_arguments`: Configuration parameters for the JSON RPC connection. + + ## Returns + `{:ok, block_check_interval, last_safe_block}`: A tuple where + - `block_check_interval` is the calculated interval in milliseconds. + - `last_safe_block` is the safe or latest block number. + In case of error returns the `{:error, description}` tuple. """ @spec get_block_check_interval(list()) :: {:ok, non_neg_integer(), non_neg_integer()} | {:error, any()} def get_block_check_interval(json_rpc_named_arguments) do @@ -219,21 +227,31 @@ defmodule Indexer.Helper do Forms JSON RPC named arguments for the given RPC URL. """ @spec json_rpc_named_arguments(binary()) :: list() - def json_rpc_named_arguments(rpc_url) do + def json_rpc_named_arguments(rpc_url) when is_binary(rpc_url) do + normalized_rpc_url = + rpc_url + |> trim_url() + |> validate_rpc_url!() + [ transport: EthereumJSONRPC.HTTP, transport_options: [ - http: EthereumJSONRPC.HTTP.HTTPoison, - urls: [rpc_url], + http: EthereumJSONRPC.HTTP.Tesla, + urls: [normalized_rpc_url], http_options: [ recv_timeout: :timer.minutes(10), timeout: :timer.minutes(10), - hackney: [pool: :ethereum_jsonrpc] + pool: :ethereum_jsonrpc ] ] ] end + def json_rpc_named_arguments(_rpc_url), do: raise(ArgumentError, "RPC URL must be a non-empty string") + + defp validate_rpc_url!(""), do: raise(ArgumentError, "RPC URL must be a non-empty string") + defp validate_rpc_url!(rpc_url), do: rpc_url + @doc """ Splits a given range into chunks of the specified size. @@ -264,6 +282,7 @@ defmodule Indexer.Helper do end) end + # todo: refactor: reuse existing code from EthereumJSONRPC app @doc """ Retrieves event logs from Ethereum-like blockchains within a specified block range for a given address and set of topics using JSON-RPC. @@ -380,6 +399,53 @@ defmodule Indexer.Helper do end end + @doc """ + Processes a large batch of contract calls by splitting them into smaller + chunks for efficiency and resiliency. + + This function takes a potentially large list of contract call requests, + divides them into manageable chunks, applies the provided reader function to + each chunk, and then consolidates the results. + + The chunking approach helps prevent timeouts that can occur when making too + many simultaneous RPC calls. + + ## Parameters + - `requests`: A list of `EthereumJSONRPC.Contract.call()` instances + representing contract calls to execute. + - `chunk_size`: The maximum number of contract calls to include in each chunk. + - `reader`: A function that takes a chunk of contract call requests and + returns `{responses, errors}` tuple. + + ## Returns + - A tuple `{responses, errors}` where: + - `responses`: A concatenated list of all response tuples `{:ok, result}` or + `{:error, reason}` from all chunks, maintaining the original + order. + - `errors`: A deduplicated list of all error messages encountered across all + chunks. + """ + @spec read_contracts_with_retries_by_chunks( + [EthereumJSONRPC.Contract.call()], + integer(), + ([EthereumJSONRPC.Contract.call()] -> + {[{:ok | :error, any()}], list()}) + ) :: + {[{:ok | :error, any()}], list()} + def read_contracts_with_retries_by_chunks(requests, chunk_size, reader) + when is_list(requests) and is_integer(chunk_size) and chunk_size > 0 do + {responses_lists, errors_lists} = + requests + |> Enum.chunk_every(chunk_size) + |> Enum.map(reader) + |> Enum.unzip() + + { + Enum.concat(responses_lists), + errors_lists |> Enum.concat() |> Enum.uniq() + } + end + @doc """ Retrieves decoded results of `eth_call` requests to contracts, with retry logic for handling errors. @@ -415,7 +481,8 @@ defmodule Indexer.Helper do [EthereumJSONRPC.Contract.call()], [map()], EthereumJSONRPC.json_rpc_named_arguments(), - integer() + integer(), + boolean() ) :: {[{:ok | :error, any()}], list()} def read_contracts_with_retries(requests, abi, json_rpc_named_arguments, retries_left, log_error? \\ true) when is_list(requests) and is_list(abi) and is_integer(retries_left) do @@ -724,32 +791,44 @@ defmodule Indexer.Helper do end @doc """ - Sends HTTP GET request to the given URL and returns JSON response. Makes max 10 attempts and then returns an error in case of failure. + Sends HTTP GET request to the given URL and returns a response. Makes max 10 attempts and then returns an error in case of failure. There is a timeout between attempts (increasing from 3 seconds to 20 minutes max as the number of attempts increases). ## Parameters - `url`: The URL which needs to be requested. + - `response_format`: Can be `:json` (by default) or `:raw`. In case of `:json`, the response is decoded with `Jason.decode`. - `attempts_done`: The number of attempts done. Incremented by the function itself. + - `avoid_retry_for_statuses`: The list of http error codes we don't need to re-send the request for. + E.g. for 404 error we don't try to re-send the request. ## Returns - - `{:ok, response}` where `response` is a map decoded from a JSON object. - - `{:error, reason}` in case of failure (after three unsuccessful attempts). + - `{:ok, response}` where `response` is a map decoded from a JSON object, or the raw bytes (depending on the `response_format` parameter). + - `{:error, reason}` in case of failure (after all unsuccessful attempts). """ - @spec http_get_request(String.t(), non_neg_integer()) :: {:ok, map()} | {:error, any()} - def http_get_request(url, attempts_done \\ 0) do - recv_timeout = 5_000 + @spec http_get_request(String.t(), :json | :raw, non_neg_integer(), [non_neg_integer()]) :: + {:ok, map() | binary()} | {:error, any()} + def http_get_request(url, response_format \\ :json, attempts_done \\ 0, avoid_retry_for_statuses \\ [404]) do + recv_timeout = 60_000 connect_timeout = 8_000 client = Tesla.client([{Tesla.Middleware.Timeout, timeout: recv_timeout}], Tesla.Adapter.Mint) case Tesla.get(client, url, opts: [adapter: [timeout: recv_timeout, transport_opts: [timeout: connect_timeout]]]) do {:ok, %{body: body, status: 200}} -> - Jason.decode(body) + if response_format == :json do + Jason.decode(body) + else + {:ok, body} + end - {:ok, %{body: body, status: _}} -> - http_get_request_error(url, body, attempts_done) + {:ok, %{body: body, status: status}} -> + if status in avoid_retry_for_statuses do + http_get_request_error(url, response_format, body, @http_get_request_max_attempts, avoid_retry_for_statuses) + else + http_get_request_error(url, response_format, body, attempts_done, avoid_retry_for_statuses) + end {:error, error} -> - http_get_request_error(url, error, attempts_done) + http_get_request_error(url, response_format, error, attempts_done, avoid_retry_for_statuses) end end @@ -757,14 +836,18 @@ defmodule Indexer.Helper do # # ## Parameters # - `url`: The URL which needs to be requested. + # - `response_format`: Can be `:json` (by default) or `:raw`. In case of `:json`, the response is decoded with `Jason.decode`. # - `error`: The error description for logging purposes. # - `attempts_done`: The number of attempts done. Incremented by the function itself. + # - `avoid_retry_for_statuses`: The list of http error codes we don't need to re-send the request for. + # E.g. for 404 error we don't try to re-send the request. # # ## Returns # - `{:ok, response}` tuple if the re-call was successful. # - `{:error, reason}` if all attempts were failed. - @spec http_get_request_error(String.t(), any(), non_neg_integer()) :: {:ok, map()} | {:error, any()} - defp http_get_request_error(url, error, attempts_done) do + @spec http_get_request_error(String.t(), :json | :raw, any(), non_neg_integer(), [non_neg_integer()]) :: + {:ok, map()} | {:error, any()} + defp http_get_request_error(url, response_format, error, attempts_done, avoid_retry_for_statuses) do old_truncate = Application.get_env(:logger, :truncate) Logger.configure(truncate: :infinity) @@ -780,11 +863,11 @@ defmodule Indexer.Helper do # retry to send the request attempts_done = attempts_done + 1 - if attempts_done < 10 do + if attempts_done < @http_get_request_max_attempts do # wait up to 20 minutes and then retry :timer.sleep(min(3000 * Integer.pow(2, attempts_done - 1), 1_200_000)) Logger.info("Retry to send the request to #{url} ...") - http_get_request(url, attempts_done) + http_get_request(url, response_format, attempts_done, avoid_retry_for_statuses) else {:error, "Error while sending request to #{url}"} end @@ -883,4 +966,48 @@ defmodule Indexer.Helper do |> String.trim() |> String.trim_trailing("/") end + + @max_queue_size 5000 + @enqueue_busy_waiting_timeout 500 + @doc """ + Reduces the given `data` into an accumulator `acc` using the provided `reducer` function, + but only if the queue is not full. This function ensures that the processing respects + the queue's size constraints. + + If the queue is full (i.e., its size is greater than or equal to `@max_queue_size` or + its `maximum_size`), the function will pause for a duration defined by `@busy_waiting_timeout` + and retry until the queue has available space. + + ## Parameters + + - `data`: The data to be processed by the `reducer` function. + - `acc`: The accumulator that will be passed to the `reducer` function. + - `reducer`: A function that takes `data` and `acc` as arguments and returns the updated accumulator. + + ## Returns + + The result of applying the `reducer` function to the `data` and `acc`. + + ## Notes + + This function uses a recursive approach to wait for the queue to have available space. + Ensure that the `@busy_waiting_timeout` is set to an appropriate value to avoid excessive delays. + """ + @spec reduce_if_queue_is_not_full(any(), any(), (any(), any() -> any()), module()) :: any() + def reduce_if_queue_is_not_full(data, acc, reducer, module) do + bound_queue = GenServer.call(module, :state).bound_queue + + max_queue_size = Application.get_env(:indexer, module)[:max_queue_size] || @max_queue_size + + enqueue_busy_waiting_timeout = + Application.get_env(:indexer, module)[:enqueue_busy_waiting_timeout] || @enqueue_busy_waiting_timeout + + if bound_queue.size >= max_queue_size or (bound_queue.maximum_size && bound_queue.size >= bound_queue.maximum_size) do + :timer.sleep(enqueue_busy_waiting_timeout) + + reduce_if_queue_is_not_full(data, acc, reducer, module) + else + reducer.(data, acc) + end + end end diff --git a/apps/indexer/lib/indexer/logger.ex b/apps/indexer/lib/indexer/logger.ex index ee762b1f4598..89ae7254353a 100644 --- a/apps/indexer/lib/indexer/logger.ex +++ b/apps/indexer/lib/indexer/logger.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Logger do @moduledoc """ Helper for `Logger`. diff --git a/apps/indexer/lib/indexer/memory/monitor.ex b/apps/indexer/lib/indexer/memory/monitor.ex index 81f7e6f0567a..d41dafb025be 100644 --- a/apps/indexer/lib/indexer/memory/monitor.ex +++ b/apps/indexer/lib/indexer/memory/monitor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Memory.Monitor do @moduledoc """ Monitors memory usage of Erlang VM. @@ -47,7 +48,7 @@ defmodule Indexer.Memory.Monitor do @impl GenServer def init(options) when is_map(options) do - state = struct!(__MODULE__, Map.put_new(options, :limit, define_memory_limit())) + %__MODULE__{} = state = struct!(__MODULE__, Map.put_new(options, :limit, define_memory_limit())) {:ok, timer_reference} = :timer.send_interval(state.timer_interval, :check) {:ok, %__MODULE__{state | timer_reference: timer_reference}} @@ -105,7 +106,7 @@ defmodule Indexer.Memory.Monitor do default_limit = 1 <<< 30 percentage = - case Application.get_env(:explorer, :mode) do + case Explorer.mode() do :indexer -> 100 _ -> Application.get_env(:indexer, :system_memory_percentage) end diff --git a/apps/indexer/lib/indexer/memory/shrinkable.ex b/apps/indexer/lib/indexer/memory/shrinkable.ex index 274c72ace0db..74ea4d080042 100644 --- a/apps/indexer/lib/indexer/memory/shrinkable.ex +++ b/apps/indexer/lib/indexer/memory/shrinkable.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Memory.Shrinkable do @moduledoc """ A process that can shrink its memory usage when asked by `Indexer.Memory.Monitor`. diff --git a/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex b/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex index de42f171cd3e..01e563bf7ce6 100644 --- a/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex +++ b/apps/indexer/lib/indexer/migrator/recovery_weth_token_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do @moduledoc """ Recovers WETH token transfers that were accidentally deleted from the database by previous version of Explorer.Migrator.SanitizeIncorrectWETHTokenTransfers. @@ -10,7 +11,7 @@ defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do import Ecto.Query alias Explorer.{Chain, Repo} - alias Explorer.Chain.{Log, TokenTransfer} + alias Explorer.Chain.{Block, Log, TokenTransfer} alias Explorer.Migrator.MigrationStatus alias Indexer.Transform.TokenTransfers @@ -32,8 +33,8 @@ defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do {:stop, :normal, state} migration_status -> - max_block_number = Chain.fetch_max_block_number() - min_block_number = Chain.fetch_min_block_number() + max_block_number = Block.fetch_max_block_number() + min_block_number = Block.fetch_min_block_number() state = (migration_status && migration_status.meta) || @@ -151,7 +152,7 @@ defmodule Indexer.Migrator.RecoveryWETHTokenTransfers do |> where([log, tt], is_nil(tt)) |> where(^Log.first_topic_is_deposit_or_withdrawal_signature()) |> Repo.all(timeout: :infinity) - |> Enum.map(fn log -> + |> Enum.map(fn %Log{} = log -> %Log{ log | first_topic: to_string(log.first_topic), diff --git a/apps/indexer/lib/indexer/nft_media_handler/backfiller.ex b/apps/indexer/lib/indexer/nft_media_handler/backfiller.ex index b8ebea6b81df..6fe2963f6104 100644 --- a/apps/indexer/lib/indexer/nft_media_handler/backfiller.ex +++ b/apps/indexer/lib/indexer/nft_media_handler/backfiller.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.NFTMediaHandler.Backfiller do @moduledoc """ Module fetches from DB token instances which wasn't processed via NFTMediaHandler yet. Then put it to the queue. diff --git a/apps/indexer/lib/indexer/nft_media_handler/queue.ex b/apps/indexer/lib/indexer/nft_media_handler/queue.ex index b85846785706..11bd18d27d96 100644 --- a/apps/indexer/lib/indexer/nft_media_handler/queue.ex +++ b/apps/indexer/lib/indexer/nft_media_handler/queue.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.NFTMediaHandler.Queue do @moduledoc """ Queue for fetching media @@ -124,7 +125,7 @@ defmodule Indexer.NFTMediaHandler.Queue do end dets_insert_wrapper(in_progress, instances) - {:reply, urls, {queue, in_progress, continuation}} + {:reply, {urls, Application.get_env(:nft_media_handler, :r2_folder)}, {queue, in_progress, continuation}} end @doc """ diff --git a/apps/indexer/lib/indexer/pending_ops_cleaner.ex b/apps/indexer/lib/indexer/pending_ops_cleaner.ex index 6c12f6810d96..d25aaaf0db0b 100644 --- a/apps/indexer/lib/indexer/pending_ops_cleaner.ex +++ b/apps/indexer/lib/indexer/pending_ops_cleaner.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.PendingOpsCleaner do @moduledoc """ Periodically cleans non-consensus pending ops. @@ -7,7 +8,7 @@ defmodule Indexer.PendingOpsCleaner do require Logger - alias Explorer.Chain + alias Explorer.Chain.Block @interval :timer.minutes(60) @@ -38,7 +39,7 @@ defmodule Indexer.PendingOpsCleaner do end defp clean_nonconsensus_pending_ops do - :ok = Chain.remove_nonconsensus_blocks_from_pending_ops() + :ok = Block.remove_nonconsensus_blocks_from_pending_ops() Logger.debug(fn -> "Non-consensus pending ops are cleaned" end) end diff --git a/apps/indexer/lib/indexer/pending_transactions_sanitizer.ex b/apps/indexer/lib/indexer/pending_transactions_sanitizer.ex index 28f64794f371..319a02fb3bf5 100644 --- a/apps/indexer/lib/indexer/pending_transactions_sanitizer.ex +++ b/apps/indexer/lib/indexer/pending_transactions_sanitizer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.PendingTransactionsSanitizer do @moduledoc """ Periodically checks pending transactions status in order to detect that transaction already included to the block @@ -12,9 +13,8 @@ defmodule Indexer.PendingTransactionsSanitizer do import EthereumJSONRPC.Receipt, only: [to_elixir: 1] alias Ecto.Changeset - alias Explorer.{Chain, Repo} alias Explorer.Chain.{Block, Transaction} - alias Explorer.Helper, as: ExplorerHelper + alias Explorer.Repo defstruct interval: nil, json_rpc_named_arguments: [] @@ -62,9 +62,10 @@ defmodule Indexer.PendingTransactionsSanitizer do {:noreply, state} end - defp sanitize_pending_transactions(json_rpc_named_arguments) do + def sanitize_pending_transactions(json_rpc_named_arguments) do receipts_batch_size = Application.get_env(:indexer, :receipts_batch_size) - pending_transactions_list_from_db = Chain.pending_transactions_list() + window_size = Application.get_env(:indexer, __MODULE__)[:window_size] + pending_transactions_list_from_db = Transaction.pending_transactions_list(window_size) id_to_params = id_to_params(pending_transactions_list_from_db) with {:ok, responses} <- @@ -76,16 +77,7 @@ defmodule Indexer.PendingTransactionsSanitizer do %{id: id, result: result} -> pending_transaction = Map.fetch!(id_to_params, id) - if result do - fetch_block_and_invalidate_wrapper(pending_transaction, to_string(pending_transaction.hash), result) - else - Logger.debug( - "Transaction with hash #{pending_transaction.hash} doesn't exist in the node anymore. We should remove it from Blockscout DB.", - fetcher: :pending_transactions_to_refetch - ) - - fetch_pending_transaction_and_delete(pending_transaction) - end + handle_pending_transaction_result(pending_transaction, result) error -> Logger.error("Error while fetching pending transaction receipt: #{inspect(error)}") @@ -97,6 +89,19 @@ defmodule Indexer.PendingTransactionsSanitizer do ) end + defp handle_pending_transaction_result(pending_transaction, result) do + if result do + fetch_block_and_invalidate_wrapper(pending_transaction, to_string(pending_transaction.hash), result) + else + Logger.debug( + "Transaction with hash #{pending_transaction.hash} doesn't exist in the node anymore. We should remove it from Blockscout DB.", + fetcher: :pending_transactions_to_refetch + ) + + fetch_pending_transaction_and_delete(pending_transaction) + end + end + defp get_transaction_receipt_requests(id_to_params) do Enum.map(id_to_params, fn {id, transaction} -> request(%{id: id, method: "eth_getTransactionReceipt", params: [to_string(transaction.hash)]}) @@ -122,30 +127,33 @@ defmodule Indexer.PendingTransactionsSanitizer do end defp fetch_pending_transaction_and_delete(transaction) do - pending_transaction_hash_string = ExplorerHelper.add_0x_prefix(transaction.hash) - - case transaction - |> Changeset.change() - |> Repo.delete(timeout: :infinity) do - {:ok, _transaction} -> - Logger.debug( - "Transaction with hash #{pending_transaction_hash_string} successfully deleted from Blockscout DB because it doesn't exist in the archive node anymore", - fetcher: :pending_transactions_to_refetch - ) - + with %{block_hash: nil} <- Repo.reload(transaction), + changeset = Changeset.change(transaction), + {:ok, _transaction} <- Repo.delete(changeset, timeout: :infinity) do + Logger.debug( + "Transaction with hash #{transaction.hash} successfully deleted from Blockscout DB because it doesn't exist in the archive node anymore", + fetcher: :pending_transactions_to_refetch + ) + else {:error, changeset} -> Logger.debug( [ - "Deletion of pending transaction with hash #{pending_transaction_hash_string} from Blockscout DB failed", + "Deletion of pending transaction with hash #{transaction.hash} from Blockscout DB failed", inspect(changeset) ], fetcher: :pending_transactions_to_refetch ) + + _transaction -> + Logger.debug( + "Transaction with hash #{transaction.hash} is already included in block, cancel deletion", + fetcher: :pending_transactions_to_refetch + ) end end defp fetch_block_and_invalidate(block_hash, pending_transaction, transaction) do - case Chain.fetch_block_by_hash(block_hash) do + case Block.fetch_block_by_hash(block_hash) do %{number: number, consensus: consensus} = block -> Logger.debug( "Corresponding number of the block with hash #{block_hash} to invalidate is #{number} and consensus #{consensus}", @@ -163,26 +171,35 @@ defmodule Indexer.PendingTransactionsSanitizer do end defp invalidate_block(block, pending_transaction, transaction) do + transaction_info = to_elixir(transaction) + + pending_transaction + |> Transaction.changeset(%{ + gas_price: transaction_info["effectiveGasPrice"] || pending_transaction.gas_price, + created_contract_address_hash: transaction_info["contractAddress"] + }) + |> Changeset.put_change(:cumulative_gas_used, transaction_info["cumulativeGasUsed"]) + |> Changeset.put_change(:gas_used, transaction_info["gasUsed"]) + |> Changeset.put_change(:index, transaction_info["transactionIndex"]) + |> Changeset.put_change(:status, transaction_info["status"]) + |> Changeset.put_change(:block_number, block.number) + |> Changeset.put_change(:block_hash, block.hash) + |> Changeset.put_change(:block_timestamp, block.timestamp) + |> Changeset.put_change(:block_consensus, block.consensus) + |> Repo.update() + |> case do + {:ok, _result} -> + :ok + + {:error, error} -> + Logger.error("Failed to update pending transaction with hash #{pending_transaction.hash}: #{inspect(error)}") + end + if block.consensus do Block.set_refetch_needed(block.number) else - transaction_info = to_elixir(transaction) - - changeset = - pending_transaction - |> Transaction.changeset() - |> Changeset.put_change(:cumulative_gas_used, transaction_info["cumulativeGasUsed"]) - |> Changeset.put_change(:gas_used, transaction_info["gasUsed"]) - |> Changeset.put_change(:index, transaction_info["transactionIndex"]) - |> Changeset.put_change(:block_number, block.number) - |> Changeset.put_change(:block_hash, block.hash) - |> Changeset.put_change(:block_timestamp, block.timestamp) - |> Changeset.put_change(:block_consensus, false) - - Repo.update(changeset) - Logger.debug( - "Pending transaction with hash #{ExplorerHelper.add_0x_prefix(pending_transaction.hash)} assigned to block ##{block.number} with hash #{block.hash}" + "Pending transaction with hash #{pending_transaction.hash} assigned to block ##{block.number} with hash #{block.hash}" ) end end diff --git a/apps/indexer/lib/indexer/prometheus/collector/filecoin_pending_address_operations_collector.ex b/apps/indexer/lib/indexer/prometheus/collector/filecoin_pending_address_operations_collector.ex index 231a8048d6bc..9f402f681052 100644 --- a/apps/indexer/lib/indexer/prometheus/collector/filecoin_pending_address_operations_collector.ex +++ b/apps/indexer/lib/indexer/prometheus/collector/filecoin_pending_address_operations_collector.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Prometheus.Collector.FilecoinPendingAddressOperations do @moduledoc """ Custom collector to count number of records in filecoin_pending_address_operations table. @@ -7,10 +8,9 @@ defmodule Indexer.Prometheus.Collector.FilecoinPendingAddressOperations do if @chain_type == :filecoin do use Prometheus.Collector - # TODO: remove when https://github.com/elixir-lang/elixir/issues/13975 comes to elixir release - alias Explorer.Chain.Filecoin.PendingAddressOperation, warn: false - alias Explorer.Repo, warn: false - alias Prometheus.Model, warn: false + alias Explorer.Chain.Filecoin.PendingAddressOperation + alias Explorer.Repo + alias Prometheus.Model def collect_mf(_registry, callback) do query = PendingAddressOperation.fresh_operations_query() diff --git a/apps/indexer/lib/indexer/prometheus/collector/pending_block_operations_collector.ex b/apps/indexer/lib/indexer/prometheus/collector/pending_block_operations_collector.ex index 56f7f48408de..92008f3b1ed4 100644 --- a/apps/indexer/lib/indexer/prometheus/collector/pending_block_operations_collector.ex +++ b/apps/indexer/lib/indexer/prometheus/collector/pending_block_operations_collector.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Prometheus.Collector.PendingBlockOperations do @moduledoc """ Custom collector to count number of records in pending_block_operations table. diff --git a/apps/indexer/lib/indexer/prometheus/collector/pending_transaction_operations_collector.ex b/apps/indexer/lib/indexer/prometheus/collector/pending_transaction_operations_collector.ex index 38f022186b6d..d50542a437a0 100644 --- a/apps/indexer/lib/indexer/prometheus/collector/pending_transaction_operations_collector.ex +++ b/apps/indexer/lib/indexer/prometheus/collector/pending_transaction_operations_collector.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Prometheus.Collector.PendingTransactionOperations do @moduledoc """ Custom collector to count number of records in pending_transaction_operations table. diff --git a/apps/indexer/lib/indexer/prometheus/instrumenter.ex b/apps/indexer/lib/indexer/prometheus/instrumenter.ex index 47a71417fc6f..ece67e8e45ec 100644 --- a/apps/indexer/lib/indexer/prometheus/instrumenter.ex +++ b/apps/indexer/lib/indexer/prometheus/instrumenter.ex @@ -1,12 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Prometheus.Instrumenter do @moduledoc """ Blockchain data fetch and import metrics for `Prometheus`. """ use Prometheus.Metric - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type] - @rollups [:arbitrum, :zksync, :optimism, :polygon_zkevm, :scroll] + alias EthereumJSONRPC.Utility.RangesHelper + + @rollups [:arbitrum, :zksync, :optimism, :scroll] @histogram [ name: :block_full_processing_duration_microseconds, @@ -32,8 +35,6 @@ defmodule Indexer.Prometheus.Instrumenter do help: "Block fetch batch request processing time" ] - @gauge [name: :missing_block_count, help: "Number of missing blocks in the database"] - @gauge [name: :delay_from_last_node_block, help: "Delay from the last block on the node in seconds"] @counter [name: :import_errors_count, help: "Number of database import errors"] @@ -44,39 +45,116 @@ defmodule Indexer.Prometheus.Instrumenter do @gauge [name: :latest_block_timestamp, help: "Latest block timestamp"] - def block_full_process(time, fetcher) do + # metrics of indexing monitor + @gauge [name: :missing_blocks_count, help: "Number of blocks missing in the chain"] + @gauge [ + name: :missing_internal_transactions_count, + help: "Number of blocks with not yet fetched internal transactions" + ] + @gauge [name: :missing_current_token_balances_count, help: "Number of missing current token balances"] + @gauge [name: :missing_archival_token_balances_count, help: "Number of missing token balances in history"] + @gauge [name: :unfetched_token_instances_count, help: "Number of unfetched token instances"] + @gauge [name: :failed_token_instances_metadata_count, help: "Number of failed token instances metadata"] + @gauge [name: :token_instances_not_uploaded_to_cdn_count, help: "Token instances not uploaded to CDN"] + @gauge [name: :multichain_search_db_main_export_queue_count, help: "Size of the main multichain export queue"] + @gauge [name: :multichain_search_db_export_balances_queue_count, help: "Size of the balances export queue"] + @gauge [name: :multichain_search_db_export_counters_queue_count, help: "Size of the counters export queue"] + @gauge [name: :multichain_search_db_export_token_info_queue_count, help: "Size of the token info export queue"] + @gauge [ + name: :erc20_token_balances_realtime_indexing_delay_seconds, + labels: [:percentile], + help: "Realtime token balances indexing delay in seconds by percentile" + ] + @gauge [ + name: :blocks_realtime_indexing_delay_seconds, + labels: [:percentile], + help: "Realtime blocks indexing delay in seconds by percentile" + ] + + @erc20_token_balance_delay_percentiles ["p20", "p40", "p60", "p80", "p90", "p95", "p99"] + @block_delay_percentiles ["p20", "p30", "p40", "p50", "p60", "p70", "p80", "p90", "p99"] + + @spec setup() :: :ok + def setup do + min_blockchain_block_number = + RangesHelper.get_min_block_number_from_range_string(Application.get_env(:indexer, :block_ranges)) + + set_latest_block_number(min_blockchain_block_number) + set_latest_block_timestamp(0) + + if chain_type() in @rollups do + set_latest_batch_number(0) + set_latest_batch_timestamp(0) + end + + Enum.each(@erc20_token_balance_delay_percentiles, fn p -> + Gauge.set([name: :erc20_token_balances_realtime_indexing_delay_seconds, labels: [p]], 0) + end) + + Enum.each(@block_delay_percentiles, fn p -> + Gauge.set([name: :blocks_realtime_indexing_delay_seconds, labels: [p]], 0) + end) + + :ok + end + + @doc """ + Defines the metric for the full processing time of a block (in microseconds). + """ + @spec set_block_full_process(time :: integer(), fetcher :: atom()) :: :ok + def set_block_full_process(time, fetcher) do Histogram.observe([name: :block_full_processing_duration_microseconds, labels: [fetcher]], time) end - def block_import(time, fetcher) do + @doc """ + Defines the metric for the import time of a block (in microseconds). + """ + @spec set_block_import(time :: float(), fetcher :: atom()) :: :ok + def set_block_import(time, fetcher) do Histogram.observe([name: :block_import_duration_microseconds, labels: [fetcher]], time) end - def block_batch_fetch(time, fetcher) do + @doc """ + Defines the metric for the block batch fetch request time (in microseconds). + """ + @spec set_block_batch_fetch(time :: integer(), fetcher :: atom()) :: :ok + def set_block_batch_fetch(time, fetcher) do Histogram.observe([name: :block_batch_fetch_request_duration_microseconds, labels: [fetcher]], time) end - def missing_blocks(missing_block_count) do - Gauge.set([name: :missing_block_count], missing_block_count) - end - - def node_delay(delay) do + @doc """ + Defines the metric for JSON-RPC node response delay (in seconds) during block import. + """ + @spec set_json_rpc_node_delay(delay :: integer()) :: :ok + def set_json_rpc_node_delay(delay) do Gauge.set([name: :delay_from_last_node_block], delay) end - def import_errors(error_count \\ 1) do + @doc """ + Defines the metric for the number of import errors encountered during block processing. + """ + @spec set_import_errors_count(error_count :: integer()) :: :ok + def set_import_errors_count(error_count \\ 1) do Counter.inc([name: :import_errors_count], error_count) end + @doc """ + Defines the metric for memory consumed by a specific fetcher (in MB). + """ + @spec set_memory_consumed(fetcher :: nil | atom() | String.t(), memory :: float()) :: :ok + def set_memory_consumed(nil, _memory), do: :ok + def set_memory_consumed(fetcher, memory) do Gauge.set([name: :memory_consumed, labels: [fetcher]], memory) end - defp latest_block_number(number) do + @spec set_latest_block_number(number :: integer()) :: :ok + defp set_latest_block_number(number) do Gauge.set([name: :latest_block_number], number) end - defp latest_block_timestamp(timestamp) do + @spec set_latest_block_timestamp(timestamp :: integer()) :: :ok + defp set_latest_block_timestamp(timestamp) do Gauge.set([name: :latest_block_timestamp], timestamp) end @@ -90,48 +168,129 @@ defmodule Indexer.Prometheus.Instrumenter do """ @spec set_latest_block(number :: integer, timestamp :: DateTime.t()) :: :ok def set_latest_block(number, timestamp) do - latest_block_number(number) - latest_block_timestamp(DateTime.to_unix(timestamp)) + set_latest_block_number(number) + set_latest_block_timestamp(DateTime.to_unix(timestamp)) end - if @chain_type in @rollups do - @gauge [name: :latest_batch_number, help: "L2 latest batch number"] + @gauge [name: :latest_batch_number, help: "L2 latest batch number"] - @gauge [name: :latest_batch_timestamp, help: "L2 latest batch timestamp"] + @gauge [name: :latest_batch_timestamp, help: "L2 latest batch timestamp"] - defp latest_batch_number(number) do - Gauge.set([name: :latest_batch_number], number) - end + defp set_latest_batch_number(number) do + Gauge.set([name: :latest_batch_number], number) + end - defp latest_batch_timestamp(timestamp) do - Gauge.set([name: :latest_batch_timestamp], timestamp) - end + defp set_latest_batch_timestamp(timestamp) do + Gauge.set([name: :latest_batch_timestamp], timestamp) + end - @doc """ - Generates the latest batch number and timestamp Prometheus metrics. + @doc """ + Generates the latest batch number and timestamp Prometheus metrics. - ## Parameters + ## Parameters - - `number`: The batch number to set. - - `timestamp`: The timestamp of the batch as a `DateTime` struct. - """ - @spec set_latest_batch(number :: integer, timestamp :: DateTime.t()) :: :ok - def set_latest_batch(number, timestamp) do - latest_batch_number(number) - latest_batch_timestamp(DateTime.to_unix(timestamp)) + - `number`: The batch number to set. + - `timestamp`: The timestamp of the batch as a `DateTime` struct. + """ + @spec set_latest_batch(number :: integer, timestamp :: DateTime.t()) :: :ok + def set_latest_batch(number, timestamp) do + if chain_type() in @rollups do + set_latest_batch_number(number) + set_latest_batch_timestamp(DateTime.to_unix(timestamp)) + else + :ok end - else - @doc """ - Generates the latest batch number and timestamp Prometheus metrics. + end - ## Parameters + @doc """ + Defines the metric for the number of blocks missing in the chain. + """ + @spec missing_blocks_count(integer()) :: :ok + def missing_blocks_count(value), do: Gauge.set([name: :missing_blocks_count], value) - - `number`: The batch number to set. - - `timestamp`: The timestamp of the batch as a `DateTime` struct. - """ - @spec set_latest_batch(number :: integer, timestamp :: DateTime.t()) :: :ok - def set_latest_batch(_number, _timestamp) do - :ok - end + @doc """ + Defines the metric for the number of blocks with not yet fetched internal transactions. + """ + @spec missing_internal_transactions_count(integer()) :: :ok + def missing_internal_transactions_count(value), do: Gauge.set([name: :missing_internal_transactions_count], value) + + @doc """ + Defines the metric for the number of missing current token balances. + """ + @spec missing_current_token_balances_count(integer()) :: :ok + def missing_current_token_balances_count(value), + do: Gauge.set([name: :missing_current_token_balances_count], value) + + @doc """ + Defines the metric for the number of missing token balances in history. + """ + @spec missing_archival_token_balances_count(integer()) :: :ok + def missing_archival_token_balances_count(value), do: Gauge.set([name: :missing_archival_token_balances_count], value) + + @doc """ + Defines the metric for the number of unfetched token instances. + """ + @spec unfetched_token_instances_count(integer()) :: :ok + def unfetched_token_instances_count(value), + do: Gauge.set([name: :unfetched_token_instances_count], value) + + @doc """ + Defines the metric for the number of failed token instances metadata. + """ + @spec failed_token_instances_metadata_count(integer()) :: :ok + def failed_token_instances_metadata_count(value), + do: Gauge.set([name: :failed_token_instances_metadata_count], value) + + @doc """ + Defines the metric for the number of token instances not uploaded to CDN. + """ + @spec token_instances_not_uploaded_to_cdn_count(integer()) :: :ok + def token_instances_not_uploaded_to_cdn_count(value), + do: Gauge.set([name: :token_instances_not_uploaded_to_cdn_count], value) + + @doc """ + Defines the metric for the size of the main multichain export queue. + """ + @spec multichain_search_db_main_export_queue_count(integer()) :: :ok + def multichain_search_db_main_export_queue_count(value), + do: Gauge.set([name: :multichain_search_db_main_export_queue_count], value) + + @doc """ + Defines the metric for the size of the balances export queue. + """ + @spec multichain_search_db_export_balances_queue_count(integer()) :: :ok + def multichain_search_db_export_balances_queue_count(value), + do: Gauge.set([name: :multichain_search_db_export_balances_queue_count], value) + + @doc """ + Defines the metric for the size of the counters export queue. + """ + @spec multichain_search_db_export_counters_queue_count(integer()) :: :ok + def multichain_search_db_export_counters_queue_count(value), + do: Gauge.set([name: :multichain_search_db_export_counters_queue_count], value) + + @doc """ + Defines the metric for the size of the token info export queue. + """ + @spec multichain_search_db_export_token_info_queue_count(integer()) :: :ok + def multichain_search_db_export_token_info_queue_count(value), + do: Gauge.set([name: :multichain_search_db_export_token_info_queue_count], value) + + @spec erc20_token_balances_realtime_indexing_delay_seconds(map()) :: :ok + def erc20_token_balances_realtime_indexing_delay_seconds(percentiles) do + Enum.each(percentiles, fn {percentile, value} -> + unless is_nil(value) do + Gauge.set([name: :erc20_token_balances_realtime_indexing_delay_seconds, labels: [percentile]], value) + end + end) + end + + @spec blocks_realtime_indexing_delay_seconds(map()) :: :ok + def blocks_realtime_indexing_delay_seconds(percentiles) do + Enum.each(percentiles, fn {percentile, value} -> + unless is_nil(value) do + Gauge.set([name: :blocks_realtime_indexing_delay_seconds, labels: [percentile]], value) + end + end) end end diff --git a/apps/indexer/lib/indexer/prometheus/metrics.ex b/apps/indexer/lib/indexer/prometheus/metrics.ex new file mode 100644 index 000000000000..81e38bed9c1c --- /dev/null +++ b/apps/indexer/lib/indexer/prometheus/metrics.ex @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Prometheus.Metrics do + @moduledoc """ + Module responsible for periodically setting indexer metrics. + """ + + use GenServer + + alias Explorer.Chain.Metrics.Queries.IndexerMetrics, as: IndexerMetricsQueries + alias Indexer.Prometheus.Instrumenter + + @interval :timer.hours(1) + @default_metrics_list [ + :missing_blocks_count, + :missing_internal_transactions_count, + :multichain_search_db_main_export_queue_count, + :multichain_search_db_export_balances_queue_count, + :multichain_search_db_export_counters_queue_count, + :multichain_search_db_export_token_info_queue_count + ] + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + if Application.get_env(:indexer, __MODULE__)[:enabled] do + send(self(), :set_metrics) + {:ok, %{}} + else + :ignore + end + end + + def handle_info(:set_metrics, state) do + schedule_next_run() + set_metrics() + + {:noreply, state} + end + + defp metrics_list do + additional_metrics = + Application.get_env(:indexer, __MODULE__)[:specific_metrics_enabled?] + |> Enum.filter(fn {_, enabled?} -> enabled? == true end) + |> Enum.map(fn {metric, _} -> metric end) + + @default_metrics_list ++ additional_metrics + end + + defp set_metrics do + metrics_list() + |> Enum.map(fn metric -> + Task.async(fn -> + set_handler_metric(metric) + end) + end) + |> Task.yield_many(:timer.hours(1)) + |> Enum.map(fn {_task, res} -> + case res do + {:ok, result} -> + result + + {:exit, reason} -> + raise "Query fetching indexer metrics terminated: #{inspect(reason)}" + + nil -> + raise "Query fetching indexer metrics timed out." + end + end) + end + + # sobelow_skip ["DOS.StringToAtom"] + defp set_handler_metric(metric) do + func = String.to_atom(to_string(metric)) + + items_count = + IndexerMetricsQueries + |> apply(func, []) + + apply(Instrumenter, metric, [items_count]) + end + + defp schedule_next_run do + Process.send_after(self(), :set_metrics, @interval) + end +end diff --git a/apps/indexer/lib/indexer/prometheus/realtime_metrics.ex b/apps/indexer/lib/indexer/prometheus/realtime_metrics.ex new file mode 100644 index 000000000000..c53bd4a5b689 --- /dev/null +++ b/apps/indexer/lib/indexer/prometheus/realtime_metrics.ex @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Prometheus.RealtimeMetrics do + @moduledoc """ + Module responsible for periodically setting realtime indexing delay metrics. + """ + + use GenServer + + require Logger + + alias Explorer.Chain.Metrics.Queries.IndexerMetrics, as: IndexerMetricsQueries + alias Indexer.Prometheus.Instrumenter + + @interval :timer.minutes(5) + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + if Application.get_env(:indexer, __MODULE__)[:enabled] do + send(self(), :set_metrics) + {:ok, %{}} + else + :ignore + end + end + + def handle_info(:set_metrics, state) do + schedule_next_run() + set_metrics() + + {:noreply, state} + end + + defp set_metrics do + [ + {:erc20_token_balances_realtime_indexing_delay_percentiles, + :erc20_token_balances_realtime_indexing_delay_seconds}, + {:blocks_realtime_indexing_delay_percentiles, :blocks_realtime_indexing_delay_seconds} + ] + |> Enum.map(fn {query_fn, metric_fn} -> + Task.async(fn -> + percentiles = apply(IndexerMetricsQueries, query_fn, []) + apply(Instrumenter, metric_fn, [percentiles]) + end) + end) + |> Task.yield_many(:timer.minutes(5)) + |> Enum.each(fn {task, res} -> + case res do + {:ok, _} -> + :ok + + {:exit, reason} -> + Logger.error("Query fetching realtime indexer metrics terminated: #{inspect(reason)}") + + nil -> + Task.shutdown(task, :brutal_kill) + Logger.error("Query fetching realtime indexer metrics timed out.") + end + end) + end + + defp schedule_next_run do + Process.send_after(self(), :set_metrics, @interval) + end +end diff --git a/apps/indexer/lib/indexer/rollup_reorg_monitor_queue.ex b/apps/indexer/lib/indexer/rollup_reorg_monitor_queue.ex new file mode 100644 index 000000000000..ba69a4316e22 --- /dev/null +++ b/apps/indexer/lib/indexer/rollup_reorg_monitor_queue.ex @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.RollupReorgMonitorQueue do + @moduledoc """ + Queue registry used by rollup reorg monitoring fetchers. + """ + + use Explorer.ModuleQueueRegistry + + # sobelow_skip ["DOS.BinToAtom"] + @impl true + @spec table_name(module()) :: atom() + def table_name(module) do + :"#{module}#{:_reorgs}" + end +end diff --git a/apps/indexer/lib/indexer/supervisor.ex b/apps/indexer/lib/indexer/supervisor.ex index ab96cb012731..642e15e5998b 100644 --- a/apps/indexer/lib/indexer/supervisor.ex +++ b/apps/indexer/lib/indexer/supervisor.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Supervisor do @moduledoc """ Supervisor of all indexer worker supervision trees @@ -15,7 +16,8 @@ defmodule Indexer.Supervisor do BridgedTokens.SetAmbBridgedMetadataForTokens, BridgedTokens.SetOmniBridgedMetadataForTokens, PendingOpsCleaner, - PendingTransactionsSanitizer + PendingTransactionsSanitizer, + TokenTransferBlockConsensusSanitizer } alias Indexer.Block.Catchup, as: BlockCatchup @@ -23,7 +25,16 @@ defmodule Indexer.Supervisor do alias Indexer.Fetcher.Blackfort.Validator, as: ValidatorBlackfort alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup alias Indexer.Fetcher.CoinBalance.Realtime, as: CoinBalanceRealtime + alias Indexer.Fetcher.InternalTransaction.DeleteQueue, as: InternalTransactionDeleteQueue + alias Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue, as: MultichainSearchDbBalancesExportQueue + alias Indexer.Fetcher.MultichainSearchDb.CountersExportQueue, as: MultichainSearchDbCountersExportQueue + alias Indexer.Fetcher.MultichainSearchDb.CountersFetcher, as: MultichainSearchDbCountersFetcher + alias Indexer.Fetcher.MultichainSearchDb.MainExportQueue, as: MultichainSearchDbMainExportQueue + alias Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue, as: MultichainSearchDbTokenInfoExportQueue alias Indexer.Fetcher.Stability.Validator, as: ValidatorStability + alias Indexer.Fetcher.Stats.HotSmartContracts + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical alias Indexer.Fetcher.TokenInstance.Realtime, as: TokenInstanceRealtime alias Indexer.Fetcher.TokenInstance.Retry, as: TokenInstanceRetry alias Indexer.Fetcher.TokenInstance.Sanitize, as: TokenInstanceSanitize @@ -31,8 +42,11 @@ defmodule Indexer.Supervisor do alias Indexer.Fetcher.TokenInstance.SanitizeERC721, as: TokenInstanceSanitizeERC721 alias Indexer.Fetcher.{ + AddressImporter, + AddressNonceUpdater, BlockReward, ContractCode, + CurrentTokenBalanceImporter, EmptyBlocksSanitizer, InternalTransaction, PendingBlockOperationsSanitizer, @@ -40,10 +54,10 @@ defmodule Indexer.Supervisor do ReplacedTransaction, RootstockData, Token, - TokenBalance, + TokenCountersUpdater, + TokenInstanceImporter, TokenTotalSupplyUpdater, TokenUpdater, - TransactionAction, UncleBlock, Withdrawal } @@ -63,6 +77,8 @@ defmodule Indexer.Supervisor do UnclesWithoutIndex } + alias Indexer.Utils.EventNotificationsCleaner + def child_spec([]) do child_spec([[]]) end @@ -98,6 +114,7 @@ defmodule Indexer.Supervisor do ) |> Enum.into(%{}) |> Map.put(:memory_monitor, memory_monitor) + |> Map.put(:task_supervisor, nil) |> Map.put_new(:realtime_overrides, %{}) %{ @@ -138,14 +155,23 @@ defmodule Indexer.Supervisor do {TokenInstanceSanitize.Supervisor, [[memory_monitor: memory_monitor]]}, configure(TokenInstanceSanitizeERC721, [[memory_monitor: memory_monitor]]), configure(TokenInstanceSanitizeERC1155, [[memory_monitor: memory_monitor]]), - configure(TransactionAction.Supervisor, [[memory_monitor: memory_monitor]]), {ContractCode.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, - {TokenBalance.Supervisor, + {TokenBalanceHistorical.Supervisor, + [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, + {TokenBalanceCurrent.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, {TokenUpdater.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, + {TokenCountersUpdater.Supervisor, + [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, {ReplacedTransaction.Supervisor, [[memory_monitor: memory_monitor]]}, + {InternalTransactionDeleteQueue.Supervisor, [[memory_monitor: memory_monitor]]}, + {MultichainSearchDbMainExportQueue.Supervisor, [[memory_monitor: memory_monitor]]}, + {MultichainSearchDbBalancesExportQueue.Supervisor, [[memory_monitor: memory_monitor]]}, + {MultichainSearchDbTokenInfoExportQueue.Supervisor, [[memory_monitor: memory_monitor]]}, + {MultichainSearchDbCountersExportQueue.Supervisor, [[memory_monitor: memory_monitor]]}, + {MultichainSearchDbCountersFetcher.Supervisor, [[memory_monitor: memory_monitor]]}, {Indexer.Fetcher.RollupL1ReorgMonitor.Supervisor, [[memory_monitor: memory_monitor]]}, configure( Indexer.Fetcher.Optimism.TransactionBatch.Supervisor, @@ -175,14 +201,12 @@ defmodule Indexer.Supervisor do Indexer.Fetcher.Optimism.Interop.MessageQueue.Supervisor, [[memory_monitor: memory_monitor, json_rpc_named_arguments: json_rpc_named_arguments]] }, - configure(Indexer.Fetcher.PolygonEdge.Deposit.Supervisor, [[memory_monitor: memory_monitor]]), - configure(Indexer.Fetcher.PolygonEdge.DepositExecute.Supervisor, [ - [memory_monitor: memory_monitor, json_rpc_named_arguments: json_rpc_named_arguments] - ]), - configure(Indexer.Fetcher.PolygonEdge.Withdrawal.Supervisor, [ - [memory_monitor: memory_monitor, json_rpc_named_arguments: json_rpc_named_arguments] - ]), - configure(Indexer.Fetcher.PolygonEdge.WithdrawalExit.Supervisor, [[memory_monitor: memory_monitor]]), + { + Indexer.Fetcher.Optimism.Interop.MultichainExport.Supervisor, + [[memory_monitor: memory_monitor]] + }, + {Indexer.Fetcher.Optimism.OperatorFee.Supervisor, + [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, configure(Indexer.Fetcher.Shibarium.L2.Supervisor, [ [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] ]), @@ -203,20 +227,12 @@ defmodule Indexer.Supervisor do [ [memory_monitor: memory_monitor] ]}, - configure(Indexer.Fetcher.PolygonZkevm.BridgeL1.Supervisor, [[memory_monitor: memory_monitor]]), - configure(Indexer.Fetcher.PolygonZkevm.BridgeL1Tokens.Supervisor, [[memory_monitor: memory_monitor]]), - configure(Indexer.Fetcher.PolygonZkevm.BridgeL2.Supervisor, [ - [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] - ]), configure(ZkSyncTransactionBatch.Supervisor, [ [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] ]), configure(ZkSyncBatchesStatusTracker.Supervisor, [ [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] ]), - configure(Indexer.Fetcher.PolygonZkevm.TransactionBatch.Supervisor, [ - [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] - ]), configure(ArbitrumTrackingMessagesOnL1.Supervisor, [ [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] ]), @@ -235,6 +251,9 @@ defmodule Indexer.Supervisor do configure(Indexer.Fetcher.Celo.EpochBlockOperations.Supervisor, [ [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] ]), + configure(Indexer.Fetcher.Celo.Legacy.Account.Supervisor, [ + [json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor] + ]), {Indexer.Fetcher.Filecoin.AddressInfo.Supervisor, [ [ @@ -243,12 +262,21 @@ defmodule Indexer.Supervisor do ] ]}, {Indexer.Fetcher.Zilliqa.ScillaSmartContracts.Supervisor, [[memory_monitor: memory_monitor]]}, + {Indexer.Fetcher.Zilliqa.Zrc2Tokens.Supervisor, [[memory_monitor: memory_monitor]]}, {Indexer.Fetcher.Beacon.Blob.Supervisor, [[memory_monitor: memory_monitor]]}, + {Indexer.Fetcher.Beacon.Deposit.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, + {Indexer.Fetcher.Beacon.Deposit.Status.Supervisor, []}, + {Indexer.Fetcher.SignedAuthorizationStatus.Supervisor, + [[json_rpc_named_arguments: json_rpc_named_arguments, memory_monitor: memory_monitor]]}, # Out-of-band fetchers {EmptyBlocksSanitizer.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, {PendingTransactionsSanitizer, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, {TokenTotalSupplyUpdater, [[]]}, + AddressNonceUpdater, + + # Notifications cleaner + configure(EventNotificationsCleaner, [[]]), # Temporary workers {UncatalogedTokenTransfers.Supervisor, [[]]}, @@ -271,12 +299,14 @@ defmodule Indexer.Supervisor do [name: BlockCatchup.Supervisor] ] ), - {Withdrawal.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]} + {Withdrawal.Supervisor, [[json_rpc_named_arguments: json_rpc_named_arguments]]}, + configure(HotSmartContracts.Supervisor, [[memory_monitor: memory_monitor]]) ] |> List.flatten() all_fetchers = basic_fetchers + |> maybe_add_async_importers() |> maybe_add_bridged_tokens_fetchers() |> add_chain_type_dependent_fetchers() |> maybe_add_block_reward_fetcher( @@ -290,6 +320,14 @@ defmodule Indexer.Supervisor do ) end + defp maybe_add_async_importers(basic_fetchers) do + if Application.get_env(:indexer, :enable_partial_async_import?) do + [AddressImporter, TokenInstanceImporter, CurrentTokenBalanceImporter | basic_fetchers] + else + basic_fetchers + end + end + defp maybe_add_bridged_tokens_fetchers(basic_fetchers) do extended_fetchers = if BridgedToken.enabled?() && BridgedToken.necessary_envs_passed?() do @@ -335,6 +373,9 @@ defmodule Indexer.Supervisor do :blackfort -> [{ValidatorBlackfort, []} | fetchers] + :rsk -> + [TokenTransferBlockConsensusSanitizer | fetchers] + _ -> fetchers end diff --git a/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex b/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex index 53664810aa77..a6cca831def5 100644 --- a/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex +++ b/apps/indexer/lib/indexer/temporary/uncataloged_token_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Temporary.UncatalogedTokenTransfers do @moduledoc """ Catalogs token transfer logs missing an accompanying token transfer record. @@ -13,7 +14,7 @@ defmodule Indexer.Temporary.UncatalogedTokenTransfers do require Logger alias Explorer.Chain.TokenTransfer - alias Explorer.Utility.MissingRangesManipulator + alias Explorer.Utility.MissingBlockRange alias Indexer.Temporary.UncatalogedTokenTransfers def child_spec([init_arguments]) do @@ -96,7 +97,7 @@ defmodule Indexer.Temporary.UncatalogedTokenTransfers do defp async_push_front(block_numbers) do Task.Supervisor.async_nolink( UncatalogedTokenTransfers.TaskSupervisor, - MissingRangesManipulator, + MissingBlockRange, :add_ranges_by_block_numbers, [block_numbers] ) diff --git a/apps/indexer/lib/indexer/temporary/uncles_without_index.ex b/apps/indexer/lib/indexer/temporary/uncles_without_index.ex index 79abdc18147a..937f1b5efc0d 100644 --- a/apps/indexer/lib/indexer/temporary/uncles_without_index.ex +++ b/apps/indexer/lib/indexer/temporary/uncles_without_index.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Temporary.UnclesWithoutIndex do @moduledoc """ Fetches `index`es for unfetched `t:Explorer.Chain.Block.SecondDegreeRelation.t/0`. @@ -33,7 +34,7 @@ defmodule Indexer.Temporary.UnclesWithoutIndex do def child_spec([init_options, gen_server_options]) when is_list(init_options) do {state, mergeable_init_options} = Keyword.pop(init_options, :json_rpc_named_arguments) - unless state do + if !state do raise ArgumentError, ":json_rpc_named_arguments must be provided to `#{__MODULE__}.child_spec " <> "to allow for json_rpc calls when running." @@ -123,7 +124,7 @@ defmodule Indexer.Temporary.UnclesWithoutIndex do loggable_errors = loggable_errors(errors) loggable_error_count = Enum.count(loggable_errors) - unless loggable_error_count == 0 do + if loggable_error_count != 0 do Logger.error( fn -> [ diff --git a/apps/indexer/lib/indexer/token_balances.ex b/apps/indexer/lib/indexer/token_balances.ex index 78180c997864..970537ce262a 100644 --- a/apps/indexer/lib/indexer/token_balances.ex +++ b/apps/indexer/lib/indexer/token_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.TokenBalances do @moduledoc """ Reads Token's balances using Smart Contract functions from the blockchain. @@ -49,7 +50,9 @@ defmodule Indexer.TokenBalances do token_balances |> Enum.filter(fn token_balance -> if Map.has_key?(token_balance, :token_type) do - token_balance.token_type !== "ERC-1155" && !(token_balance.token_type == "ERC-404" && token_balance.token_id) + token_balance.token_type !== "ERC-1155" && + token_balance.token_type !== "ERC-7984" && + !(token_balance.token_type == "ERC-404" && token_balance.token_id) else true end diff --git a/apps/indexer/lib/indexer/token_transfer_block_consensus_sanitizer.ex b/apps/indexer/lib/indexer/token_transfer_block_consensus_sanitizer.ex new file mode 100644 index 000000000000..f3cb4466bc2d --- /dev/null +++ b/apps/indexer/lib/indexer/token_transfer_block_consensus_sanitizer.ex @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.TokenTransferBlockConsensusSanitizer do + @moduledoc """ + Periodically find token transfers with incorrect block_consensus and set refetch_needed for their blocks. + """ + + use GenServer + + require Logger + + import Ecto.Query + + alias Explorer.Chain.{Block, TokenTransfer} + alias Explorer.Repo + + def child_spec(_) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, []}, + type: :worker, + restart: :permanent, + shutdown: Application.get_env(:indexer, :graceful_shutdown_period) + } + end + + def start_link do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_) do + schedule_sanitize() + + {:ok, %{}} + end + + def handle_info(:sanitize, state) do + block_numbers = + TokenTransfer + |> join(:inner, [tt], b in assoc(tt, :block)) + |> where([tt, b], tt.block_consensus != b.consensus) + |> select([tt], tt.block_number) + |> distinct(true) + |> Repo.all(timeout: :infinity) + + case block_numbers do + [] -> + Logger.debug("[TokenTransferBlockConsensusSanitizer] No inconsistent token transfer block consensus found") + + numbers -> + Logger.info("[TokenTransferBlockConsensusSanitizer] Marking #{length(numbers)} blocks for refetch") + Block.set_refetch_needed(numbers) + end + + schedule_sanitize() + + {:noreply, state} + end + + defp schedule_sanitize do + Process.send_after(self(), :sanitize, Application.get_env(:indexer, __MODULE__)[:interval]) + end +end diff --git a/apps/indexer/lib/indexer/tracer.ex b/apps/indexer/lib/indexer/tracer.ex index 7e123b99cdcf..f1b87701c5ed 100644 --- a/apps/indexer/lib/indexer/tracer.ex +++ b/apps/indexer/lib/indexer/tracer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Tracer do @moduledoc false diff --git a/apps/indexer/lib/indexer/transform/address_coin_balances.ex b/apps/indexer/lib/indexer/transform/address_coin_balances.ex index 3787261d552f..50175ec99dd7 100644 --- a/apps/indexer/lib/indexer/transform/address_coin_balances.ex +++ b/apps/indexer/lib/indexer/transform/address_coin_balances.ex @@ -1,8 +1,19 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressCoinBalances do @moduledoc """ Extracts `Explorer.Chain.Address.CoinBalance` params from other schema's params. """ - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + + use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + arc_native_token_system_address: [:indexer, [:arc, :arc_native_token_system_address]] + + import Explorer.Helper, only: [truncate_address_hash: 1] + import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] + + @burn_address_hash_string burn_address_hash_string() alias Explorer.Chain.TokenTransfer @@ -28,22 +39,32 @@ defmodule Indexer.Transform.AddressCoinBalances do defp reducer({:logs_params, logs_params}, acc) when is_list(logs_params) do # a log MUST have address_hash and block_number - logs_params - |> Enum.reject( - &(&1.first_topic == TokenTransfer.constant() or - &1.first_topic == TokenTransfer.erc1155_single_transfer_signature() or - &1.first_topic == TokenTransfer.erc1155_batch_transfer_signature()) - ) - |> Enum.into(acc, fn - %{address_hash: address_hash, block_number: block_number} - when is_binary(address_hash) and is_integer(block_number) -> + filtered_logs = + logs_params + |> Enum.reject( + &(&1.first_topic == TokenTransfer.constant() or + &1.first_topic == TokenTransfer.erc1155_single_transfer_signature() or + &1.first_topic == TokenTransfer.erc1155_batch_transfer_signature()) + ) + |> Enum.into(acc, fn %{address_hash: address_hash, block_number: block_number} - - %{type: "pending"} -> - nil - end) - |> Enum.reject(fn val -> is_nil(val) end) - |> MapSet.new() + when is_binary(address_hash) and is_integer(block_number) -> + %{address_hash: address_hash, block_number: block_number} + + %{type: "pending"} -> + nil + end) + |> Enum.reject(fn val -> is_nil(val) end) + + # for :arc chain type we also need to parse the `NativeCoinTransferred`, `NativeCoinMinted`, `NativeCoinBurned`, EIP-7708 events + filtered_arc_logs = + if chain_type() == :arc do + handle_arc_transfer_logs(logs_params) + else + [] + end + + MapSet.new(filtered_logs ++ filtered_arc_logs) end defp reducer({:transactions_params, transactions_params}, initial) when is_list(transactions_params) do @@ -61,6 +82,88 @@ defmodule Indexer.Transform.AddressCoinBalances do end) end + # Handles Arc chain type logs. + # + # ## Parameters + # - `logs_params`: The given list of logs. + # + # ## Returns + # - The list of `address_hash, block_number` pairs. + @spec handle_arc_transfer_logs([ + %{ + :first_topic => String.t(), + :second_topic => String.t(), + :third_topic => String.t() | nil, + :address_hash => String.t(), + :block_number => non_neg_integer(), + optional(:type) => String.t() | nil + } + ]) :: [%{:address_hash => String.t(), :block_number => non_neg_integer()}] + defp handle_arc_transfer_logs(logs_params) do + arc_native_coin_transferred_event = TokenTransfer.arc_native_coin_transferred_event() + arc_native_coin_minted_event = TokenTransfer.arc_native_coin_minted_event() + arc_native_coin_burned_event = TokenTransfer.arc_native_coin_burned_event() + arc_native_token_system_address = arc_native_token_system_address() + eip7708_transfer_topic = TokenTransfer.constant() + eip7708_system_address = TokenTransfer.eip7708_system_address() + + logs_params + |> Enum.flat_map(fn + %{ + type: "pending" + } -> + [] + + %{ + first_topic: ^eip7708_transfer_topic, + second_topic: second_topic, + third_topic: third_topic, + address_hash: ^eip7708_system_address, + block_number: block_number + } + when is_integer(block_number) and is_binary(second_topic) and is_binary(third_topic) -> + [ + %{address_hash: truncate_address_hash(second_topic), block_number: block_number}, + %{address_hash: truncate_address_hash(third_topic), block_number: block_number} + ] + |> Enum.filter(fn %{address_hash: address_hash} -> address_hash != @burn_address_hash_string end) + + %{ + first_topic: ^arc_native_coin_transferred_event, + second_topic: second_topic, + third_topic: third_topic, + address_hash: ^arc_native_token_system_address, + block_number: block_number + } + when is_integer(block_number) -> + [ + %{address_hash: truncate_address_hash(second_topic), block_number: block_number}, + %{address_hash: truncate_address_hash(third_topic), block_number: block_number} + ] + + %{ + first_topic: ^arc_native_coin_minted_event, + second_topic: second_topic, + address_hash: ^arc_native_token_system_address, + block_number: block_number + } + when is_integer(block_number) -> + [%{address_hash: truncate_address_hash(second_topic), block_number: block_number}] + + %{ + first_topic: ^arc_native_coin_burned_event, + second_topic: second_topic, + address_hash: ^arc_native_token_system_address, + block_number: block_number + } + when is_integer(block_number) -> + [%{address_hash: truncate_address_hash(second_topic), block_number: block_number}] + + _ -> + [] + end) + end + defp blocks_params_reducer(%{miner_hash: address_hash, number: block_number}, acc) when is_binary(address_hash) and is_integer(block_number) do MapSet.put(acc, %{address_hash: address_hash, block_number: block_number}) @@ -71,16 +174,18 @@ defmodule Indexer.Transform.AddressCoinBalances do defp internal_transactions_params_reducer(%{block_number: block_number} = internal_transaction_params, acc) when is_integer(block_number) do case internal_transaction_params do - %{type: "call"} = params -> + %{error: _} -> + acc + + %{type: "call", call_type: call_type, value: value} = params when call_type in ~w(call invalid) and value > 0 -> acc |> process_internal_transaction_field(params, :from_address_hash, block_number) |> process_internal_transaction_field(params, :to_address_hash, block_number) - %{type: "create", error: _} -> + %{type: type} = params when type in ~w(create create2) -> acc - - %{type: "create"} = params -> - process_internal_transaction_field(acc, params, :created_contract_address_hash, block_number) + |> process_internal_transaction_field(params, :from_address_hash, block_number) + |> process_internal_transaction_field(params, :created_contract_address_hash, block_number) %{type: "selfdestruct", from_address_hash: from_address_hash, to_address_hash: to_address_hash} when is_binary(from_address_hash) and is_binary(to_address_hash) -> @@ -118,11 +223,7 @@ defmodule Indexer.Transform.AddressCoinBalances do |> (&transactions_params_chain_type_fields_reducer(transaction_params, &1)).() end - if @chain_type == :celo do - import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - - @burn_address_hash_string burn_address_hash_string() - + if @chain_identity == {:optimism, :celo} do # todo: subject for deprecation, since celo transactions with # gatewayFeeRecipient are deprecated defp transactions_params_chain_type_fields_reducer( diff --git a/apps/indexer/lib/indexer/transform/address_coin_balances_daily.ex b/apps/indexer/lib/indexer/transform/address_coin_balances_daily.ex index 408c782b08b9..f0dbce17481f 100644 --- a/apps/indexer/lib/indexer/transform/address_coin_balances_daily.ex +++ b/apps/indexer/lib/indexer/transform/address_coin_balances_daily.ex @@ -1,9 +1,12 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressCoinBalancesDaily do @moduledoc """ Extracts `Explorer.Chain.Address.CoinBalanceDaily` params from other schema's params. """ - import EthereumJSONRPC, only: [integer_to_quantity: 1, json_rpc: 2, quantity_to_integer: 1, request: 1] + import EthereumJSONRPC, only: [json_rpc: 2, quantity_to_integer: 1] + + alias EthereumJSONRPC.Block.ByNumber def params_set(%{coin_balances_params: coin_balances_params_set, blocks: blocks}) do coin_balances_params = @@ -21,20 +24,7 @@ defmodule Indexer.Transform.AddressCoinBalancesDaily do block.number == block_number end) - day = - if block do - DateTime.to_date(block.timestamp) - else - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - with {:ok, %{"timestamp" => timestamp_raw}} <- - %{id: 1, method: "eth_getBlockByNumber", params: [integer_to_quantity(block_number), false]} - |> request() - |> json_rpc(json_rpc_named_arguments) do - timestamp = quantity_to_integer(timestamp_raw) - DateTime.from_unix!(timestamp) - end - end + day = resolve_day(block, block_number) [%{address_hash: address_hash, day: day} | acc] end) @@ -47,7 +37,9 @@ defmodule Indexer.Transform.AddressCoinBalancesDaily do coin_balances_daily_params_set end - def params_set(%{address_coin_balances_params_with_block_timestamp: address_coin_balances_params_with_block_timestamp}) do + def params_set(%{ + address_coin_balances_params_with_block_timestamp: address_coin_balances_params_with_block_timestamp + }) do coin_balances_params = address_coin_balances_params_with_block_timestamp |> MapSet.to_list() @@ -69,4 +61,20 @@ defmodule Indexer.Transform.AddressCoinBalancesDaily do coin_balances_daily_params_set end + + defp resolve_day(block, _block_number) when not is_nil(block), do: DateTime.to_date(block.timestamp) + + defp resolve_day(_block, block_number) do + json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + with {:ok, %{"timestamp" => timestamp_raw}} <- + %{id: 1, number: block_number} + |> ByNumber.request(false) + |> json_rpc(json_rpc_named_arguments) do + timestamp_raw + |> quantity_to_integer() + |> DateTime.from_unix!() + |> DateTime.to_date() + end + end end diff --git a/apps/indexer/lib/indexer/transform/address_token_balances.ex b/apps/indexer/lib/indexer/transform/address_token_balances.ex index 288757d313c4..cccbf52a5d76 100644 --- a/apps/indexer/lib/indexer/transform/address_token_balances.ex +++ b/apps/indexer/lib/indexer/transform/address_token_balances.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressTokenBalances do @moduledoc """ Extracts `Explorer.Address.TokenBalance` params from other schema's params. diff --git a/apps/indexer/lib/indexer/transform/addresses.ex b/apps/indexer/lib/indexer/transform/addresses.ex index ca4e4939ebff..29ba4f3af06a 100644 --- a/apps/indexer/lib/indexer/transform/addresses.ex +++ b/apps/indexer/lib/indexer/transform/addresses.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Addresses do @moduledoc """ Extract Addresses from data fetched from the Blockchain and structured as Blocks, InternalTransactions, @@ -49,8 +50,6 @@ defmodule Indexer.Transform.Addresses do """ use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - alias Indexer.Helper - @entity_to_address_map %{ address_coin_balances: [ [ @@ -137,6 +136,28 @@ defmodule Indexer.Transform.Addresses do %{from: :token_contract_address_hash, to: :hash} ] ], + zilliqa_zrc2_token_transfers: [ + [ + %{from: :block_number, to: :fetched_coin_balance_block_number}, + %{from: :from_address_hash, to: :hash} + ], + [ + %{from: :block_number, to: :fetched_coin_balance_block_number}, + %{from: :to_address_hash, to: :hash} + ], + [ + %{from: :block_number, to: :fetched_coin_balance_block_number}, + %{from: :zrc2_address_hash, to: :hash} + ] + ], + zilliqa_zrc2_token_adapters: [ + [ + %{from: :zrc2_address_hash, to: :hash} + ], + [ + %{from: :adapter_address_hash, to: :hash} + ] + ], mint_transfers: [ [ %{from: :block_number, to: :fetched_coin_balance_block_number}, @@ -159,11 +180,6 @@ defmodule Indexer.Transform.Addresses do %{from: :address_hash, to: :hash} ] ], - polygon_zkevm_bridge_operations: [ - [ - %{from: :l2_token_address, to: :hash} - ] - ], celo_election_rewards: [ [ %{from: :account_address_hash, to: :hash} @@ -176,6 +192,26 @@ defmodule Indexer.Transform.Addresses do [ %{from: :group_address_hash, to: :hash} ] + ], + celo_accounts: [ + [ + %{from: :address_hash, to: :hash} + ], + [ + %{from: :vote_signer_address_hash, to: :hash} + ], + [ + %{from: :validator_signer_address_hash, to: :hash} + ], + [ + %{from: :attestation_signer_address_hash, to: :hash} + ] + ], + celo_pending_account_operations: [ + [ + %{from: :block_number, to: :fetched_coin_balance_block_number}, + %{from: :address_hash, to: :hash} + ] ] } @@ -460,9 +496,18 @@ defmodule Indexer.Transform.Addresses do required(:block_number) => non_neg_integer() } ], - optional(:transaction_actions) => [ + optional(:zilliqa_zrc2_token_transfers) => [ %{ - required(:data) => map() + required(:from_address_hash) => String.t(), + required(:to_address_hash) => String.t(), + required(:zrc2_address_hash) => String.t(), + required(:block_number) => non_neg_integer() + } + ], + optional(:zilliqa_zrc2_token_adapters) => [ + %{ + required(:zrc2_address_hash) => String.t(), + required(:adapter_address_hash) => String.t() } ], optional(:mint_transfers) => [ @@ -484,11 +529,6 @@ defmodule Indexer.Transform.Addresses do required(:block_number) => non_neg_integer() } ], - optional(:polygon_zkevm_bridge_operations) => [ - %{ - optional(:l2_token_address) => String.t() - } - ], optional(:celo_election_rewards) => [ %{ required(:account_address_hash) => String.t() @@ -499,6 +539,19 @@ defmodule Indexer.Transform.Addresses do required(:account_address_hash) => String.t(), required(:group_address_hash) => String.t() } + ], + optional(:celo_accounts) => [ + %{ + optional(:address_hash) => String.t() | nil, + optional(:vote_signer_address_hash) => String.t() | nil, + optional(:validator_signer_address_hash) => String.t() | nil, + optional(:attestation_signer_address_hash) => String.t() | nil + } + ], + optional(:celo_pending_account_operations) => [ + %{ + required(:address_hash) => String.t() + } ] }) :: [params] def extract_addresses(fetched_data, options \\ []) when is_map(fetched_data) and is_list(options) do @@ -509,18 +562,7 @@ defmodule Indexer.Transform.Addresses do (entity_items = Map.get(fetched_data, entity_key)) != nil, do: extract_addresses_from_collection(entity_items, entity_fields, state) - transaction_actions_addresses = - fetched_data - |> Map.get(:transaction_actions, []) - |> Enum.map(fn transaction_action -> - transaction_action.data - |> Map.get(:block_number) - |> find_transaction_action_addresses(transaction_action.data) - end) - |> List.flatten() - addresses - |> Enum.concat(transaction_actions_addresses) |> List.flatten() |> merge_addresses() end @@ -530,25 +572,6 @@ defmodule Indexer.Transform.Addresses do def extract_addresses_from_item(item, fields, state), do: Enum.flat_map(fields, &extract_fields(&1, item, state)) - defp find_transaction_action_addresses(block_number, data, accumulator \\ []) - - defp find_transaction_action_addresses(block_number, data, accumulator) when is_map(data) or is_list(data) do - Enum.reduce(data, accumulator, fn - {_, value}, acc -> find_transaction_action_addresses(block_number, value, acc) - value, acc -> find_transaction_action_addresses(block_number, value, acc) - end) - end - - defp find_transaction_action_addresses(block_number, value, accumulator) when is_binary(value) do - if Helper.address_correct?(value) do - [%{:fetched_coin_balance_block_number => block_number, :hash => value} | accumulator] - else - accumulator - end - end - - defp find_transaction_action_addresses(_block_number, _value, accumulator), do: accumulator - def merge_addresses(addresses) when is_list(addresses) do addresses |> Enum.group_by(fn address -> address.hash end) diff --git a/apps/indexer/lib/indexer/transform/arbitrum/messaging.ex b/apps/indexer/lib/indexer/transform/arbitrum/messaging.ex index e8c328f5bc2d..e4fd6003a902 100644 --- a/apps/indexer/lib/indexer/transform/arbitrum/messaging.ex +++ b/apps/indexer/lib/indexer/transform/arbitrum/messaging.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Arbitrum.Messaging do @moduledoc """ Helper functions for transforming data for Arbitrum cross-chain messages. diff --git a/apps/indexer/lib/indexer/transform/blocks.ex b/apps/indexer/lib/indexer/transform/blocks.ex index 195e3642765e..dccee1ad6202 100644 --- a/apps/indexer/lib/indexer/transform/blocks.ex +++ b/apps/indexer/lib/indexer/transform/blocks.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Blocks do @moduledoc """ Protocol for transforming blocks. diff --git a/apps/indexer/lib/indexer/transform/blocks/base.ex b/apps/indexer/lib/indexer/transform/blocks/base.ex index 815a9fe177df..cd68ff5f3652 100644 --- a/apps/indexer/lib/indexer/transform/blocks/base.ex +++ b/apps/indexer/lib/indexer/transform/blocks/base.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Blocks.Base do @moduledoc """ Default block transformer to be used. diff --git a/apps/indexer/lib/indexer/transform/blocks/clique.ex b/apps/indexer/lib/indexer/transform/blocks/clique.ex index 664d49f8c9c4..5805889038b4 100644 --- a/apps/indexer/lib/indexer/transform/blocks/clique.ex +++ b/apps/indexer/lib/indexer/transform/blocks/clique.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Blocks.Clique do @moduledoc """ Handles block transforms for Clique chain. diff --git a/apps/indexer/lib/indexer/transform/celo/l1_epochs.ex b/apps/indexer/lib/indexer/transform/celo/l1_epochs.ex new file mode 100644 index 000000000000..426a9c9ea4f8 --- /dev/null +++ b/apps/indexer/lib/indexer/transform/celo/l1_epochs.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Transform.Celo.L1Epochs do + @moduledoc """ + Transformer for Celo L1 epoch data from blockchain blocks. + + This module processes blocks from the Celo blockchain to extract epoch + information for pre-migration blocks (L1 epochs). It identifies epoch blocks + based on the mathematical formula that governed Celo's epoch structure before + the L2 migration. + + This information is essential for tracking Celo's epoch structure during the + L1 era, which followed a deterministic mathematical formula. + """ + + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + + alias Explorer.Chain.Celo.Helper + + @spec parse([EthereumJSONRPC.Block.params()]) :: [map()] + def parse(blocks) do + if chain_identity() == {:optimism, :celo} do + do_parse(blocks) + else + [] + end + end + + defp do_parse(blocks) do + blocks + |> Enum.filter(fn %{number: number} -> + Helper.pre_migration_block_number?(number) and + Helper.epoch_block_number?(number) + end) + |> Enum.map(fn block -> + epoch_number = + Helper.block_number_to_epoch_number(block.number) + + {start_block_number, end_block_number} = + Helper.epoch_number_to_block_range(epoch_number) + + %{ + number: epoch_number, + start_block_number: start_block_number, + end_block_number: end_block_number, + start_processing_block_hash: block.hash, + end_processing_block_hash: block.hash + } + end) + end +end diff --git a/apps/indexer/lib/indexer/transform/celo/l2_epochs.ex b/apps/indexer/lib/indexer/transform/celo/l2_epochs.ex new file mode 100644 index 000000000000..7112765ec2af --- /dev/null +++ b/apps/indexer/lib/indexer/transform/celo/l2_epochs.ex @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Transform.Celo.L2Epochs do + @moduledoc """ + Transformer for Celo L2 epoch data from blockchain logs. + + This module processes logs from the Celo EpochManager contract to extract + epoch information for post-migration blocks (L2 epochs). It identifies epoch + processing start and end events by filtering logs based on their topics and + the contract address. + + This information is essential for tracking Celo's epoch structure after the L2 + migration, which no longer follows a simple deterministic formula. + """ + + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity], + epoch_manager_contract_address: [ + :explorer, + [:celo, :epoch_manager_contract_address] + ] + + import Explorer.Helper, only: [decode_data: 2] + + alias Explorer.Chain.Celo.Helper + + # Events from the EpochManager contract + @epoch_processing_started_topic "0xae58a33f8b8d696bcbaca9fa29d9fdc336c140e982196c2580db3d46f3e6d4b6" + @epoch_processing_ended_topic "0xc8e58d8e6979dd5e68bad79d4a4368a1091f6feb2323e612539b1b84e0663a8f" + + @spec parse([map()]) :: [map()] + def parse(logs) do + if chain_identity() == {:optimism, :celo} do + do_parse(logs) + else + [] + end + end + + defp do_parse(logs) do + logs + |> Enum.filter( + &(not Helper.pre_migration_block_number?(&1.block_number) and + &1.address_hash == epoch_manager_contract_address() |> String.downcase() and + &1.first_topic in [ + @epoch_processing_started_topic, + @epoch_processing_ended_topic + ]) + ) + |> Enum.reduce(%{}, fn log, epochs_acc -> + # Extract epoch number from the log + [epoch_number] = decode_data(log.second_topic, [{:uint, 256}]) + + current_epoch = Map.get(epochs_acc, epoch_number, %{number: epoch_number}) + + updated_epoch = + case log.first_topic do + @epoch_processing_started_topic -> + Map.put(current_epoch, :start_processing_block_hash, log.block_hash) + + @epoch_processing_ended_topic -> + Map.put(current_epoch, :end_processing_block_hash, log.block_hash) + end + + Map.put(epochs_acc, epoch_number, updated_epoch) + end) + |> Map.values() + end +end diff --git a/apps/indexer/lib/indexer/transform/celo/transaction_gas_tokens.ex b/apps/indexer/lib/indexer/transform/celo/transaction_gas_tokens.ex index 7857a932157d..408c9d36ce2e 100644 --- a/apps/indexer/lib/indexer/transform/celo/transaction_gas_tokens.ex +++ b/apps/indexer/lib/indexer/transform/celo/transaction_gas_tokens.ex @@ -1,7 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Celo.TransactionGasTokens do @moduledoc """ Helper functions for extracting tokens specified as gas fee currency. """ + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] alias Explorer.Chain.Hash @@ -19,7 +22,7 @@ defmodule Indexer.Transform.Celo.TransactionGasTokens do } ] def parse(transactions) do - if Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() == {:optimism, :celo} do transactions |> Enum.reduce( MapSet.new(), diff --git a/apps/indexer/lib/indexer/transform/celo/transaction_token_transfers.ex b/apps/indexer/lib/indexer/transform/celo/transaction_token_transfers.ex index 659a9c8143f2..6a3ae014e6cd 100644 --- a/apps/indexer/lib/indexer/transform/celo/transaction_token_transfers.ex +++ b/apps/indexer/lib/indexer/transform/celo/transaction_token_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Celo.TransactionTokenTransfers do @moduledoc """ Helper functions for generating ERC20 token transfers from native Celo coin @@ -11,13 +12,16 @@ defmodule Indexer.Transform.Celo.TransactionTokenTransfers do """ require Logger + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import Indexer.Transform.TokenTransfers, only: [ filter_tokens_for_supply_update: 1 ] alias Explorer.Chain.Cache.CeloCoreContracts - alias Explorer.Chain.Hash + alias Explorer.Chain.{Hash, InternalTransaction} alias Indexer.Fetcher.TokenTotalSupplyUpdater @token_type "ERC-20" @transaction_buffer_size 20_000 @@ -47,7 +51,7 @@ defmodule Indexer.Transform.Celo.TransactionTokenTransfers do } def parse_transactions(transactions) do token_transfers = - if Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() == {:optimism, :celo} do transactions |> Enum.filter(fn transaction -> transaction.value > 0 end) |> Enum.map(fn transaction -> @@ -89,11 +93,12 @@ defmodule Indexer.Transform.Celo.TransactionTokenTransfers do token_transfers = internal_transactions |> Enum.filter(fn internal_transaction -> - internal_transaction.value > 0 && + not is_nil(internal_transaction.value) && internal_transaction.value > 0 && internal_transaction.index > 0 && not Map.has_key?(internal_transaction, :error) && (not Map.has_key?(internal_transaction, :call_type) || internal_transaction.call_type != "delegatecall") end) + |> InternalTransaction.preload_transaction() |> Enum.map(fn internal_transaction -> to_address_hash = Map.get(internal_transaction, :to_address_hash) || @@ -114,7 +119,7 @@ defmodule Indexer.Transform.Celo.TransactionTokenTransfers do token_contract_address_hash: celo_token_address, token_ids: nil, token_type: @token_type, - transaction_hash: internal_transaction.transaction_hash + transaction_hash: internal_transaction.transaction.hash } end) diff --git a/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex b/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex index 2e83684ffd1b..ff92d64ee5fd 100644 --- a/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex +++ b/apps/indexer/lib/indexer/transform/celo/validator_epoch_payment_distributions.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Celo.ValidatorEpochPaymentDistributions do @moduledoc """ Extracts data from `ValidatorEpochPaymentDistributed` event logs of the @@ -7,7 +8,6 @@ defmodule Indexer.Transform.Celo.ValidatorEpochPaymentDistributions do alias Explorer.Chain.Cache.CeloCoreContracts alias Explorer.Chain.{Hash, Log} - alias Explorer.Helper, as: ExplorerHelper require Logger @@ -56,16 +56,19 @@ defmodule Indexer.Transform.Celo.ValidatorEpochPaymentDistributions do |> Enum.map(fn log -> {:ok, %FunctionSelector{}, [ - {"validator", "address", true, validator_address}, + {"validator", "address", true, validator_address_bytes}, {"validatorPayment", "uint256", false, validator_payment}, - {"group", "address", true, group_address}, + {"group", "address", true, group_address_bytes}, {"groupPayment", "uint256", false, group_payment} ]} = Log.find_and_decode(@event_abi, log, log.block_hash) + {:ok, validator_address} = Hash.Address.cast(validator_address_bytes) + {:ok, group_address} = Hash.Address.cast(group_address_bytes) + %{ - validator_address: ExplorerHelper.add_0x_prefix(validator_address), + validator_address: validator_address, validator_payment: validator_payment, - group_address: ExplorerHelper.add_0x_prefix(group_address), + group_address: group_address, group_payment: group_payment } end) diff --git a/apps/indexer/lib/indexer/transform/fhe_operations.ex b/apps/indexer/lib/indexer/transform/fhe_operations.ex new file mode 100644 index 000000000000..8be2e0e98332 --- /dev/null +++ b/apps/indexer/lib/indexer/transform/fhe_operations.ex @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Transform.FheOperations do + @moduledoc """ + Parses FHE (Fully Homomorphic Encryption) operations from transaction logs during indexing. + + This module extracts FHE operations from logs, calculates HCU metrics, and prepares + data for database insertion. + """ + + alias Explorer.Chain.Fhe.Parser + + @doc """ + Parses FHE operations from a list of logs. + + Returns a map with :fhe_operations key containing a list of params ready for database insertion. + """ + @spec parse(list()) :: %{fhe_operations: list()} + def parse(logs) do + if Application.get_env(:indexer, __MODULE__)[:enabled] do + filtered_logs = filter_fhe_logs(logs) + + fhe_operations = + filtered_logs + |> group_by_transaction() + |> parse_all_transactions() + |> List.flatten() + + %{fhe_operations: fhe_operations} + else + %{fhe_operations: []} + end + end + + defp filter_fhe_logs(logs) do + all_events = Parser.all_fhe_events() + + Enum.filter(logs, fn log -> + case log.first_topic do + nil -> + false + + "" -> + false + + topic -> + normalized = sanitize_first_topic(topic) + normalized in all_events + end + end) + end + + defp group_by_transaction(logs) do + Enum.group_by(logs, & &1.transaction_hash) + end + + defp parse_all_transactions(grouped_logs) do + Enum.map(grouped_logs, fn {_tx_hash, tx_logs} -> + parse_transaction_logs(tx_logs) + end) + end + + defp parse_transaction_logs(tx_logs) when is_list(tx_logs) and tx_logs != [] do + # Get first log to extract common fields + first_log = hd(tx_logs) + transaction_hash = first_log.transaction_hash + block_hash = first_log.block_hash + block_number = first_log.block_number + + # Sort logs by index to ensure correct HCU depth calculation order + sorted_logs = Enum.sort_by(tx_logs, & &1.index) + + # Parse operations using shared Parser logic + operations = Enum.map(sorted_logs, &parse_single_log/1) + + # Build HCU depth map + hcu_depth_map = Parser.build_hcu_depth_map(operations) + + # Convert to database params + Enum.map(operations, fn op -> + result_handle_key = if is_binary(op.result), do: Base.encode16(op.result, case: :lower), else: "unknown" + + %{ + transaction_hash: transaction_hash, + block_hash: block_hash, + log_index: op.log_index, + block_number: block_number, + operation: op.operation, + operation_type: op.type, + fhe_type: op.fhe_type, + is_scalar: op.is_scalar, + hcu_cost: op.hcu_cost, + hcu_depth: Map.get(hcu_depth_map, result_handle_key, op.hcu_cost), + caller: op.caller, + result_handle: op.result, + input_handles: op.inputs + } + end) + end + + defp parse_transaction_logs(_), do: [] + + defp parse_single_log(log) do + event_name = Parser.get_event_name(log.first_topic) + caller = extract_caller_binary(log.second_topic) + operation_data = Parser.decode_event_data(log, event_name) + + fhe_type = Parser.extract_fhe_type(operation_data, event_name) + is_scalar = Map.get(operation_data, :scalar_byte) == <<0x01>> + hcu_cost = Parser.calculate_hcu_cost(event_name, fhe_type, is_scalar) + + %{ + log_index: log.index, + operation: event_name, + type: Parser.get_operation_type(event_name), + fhe_type: fhe_type, + is_scalar: is_scalar, + hcu_cost: hcu_cost, + caller: caller, + inputs: Parser.extract_inputs(operation_data, event_name), + result: operation_data[:result] || <<0::256>> + } + end + + # Helper functions + + defp sanitize_first_topic(%Explorer.Chain.Data{bytes: bytes}), do: "0x" <> Base.encode16(bytes, case: :lower) + defp sanitize_first_topic(topic) when is_binary(topic), do: String.downcase(topic) + defp sanitize_first_topic(_), do: "" + + # We need specific binary extraction for caller here because Indexer might deal with raw binaries differently than Explorer + defp extract_caller_binary(nil), do: nil + + defp extract_caller_binary("0x" <> hex_data) when byte_size(hex_data) == 64 do + case Base.decode16(hex_data, case: :mixed) do + {:ok, bytes} -> extract_caller_binary(bytes) + _ -> nil + end + end + + defp extract_caller_binary(topic) when is_binary(topic) and byte_size(topic) == 32 do + <<_::binary-size(12), address::binary-size(20)>> = topic + address + end + + defp extract_caller_binary(%Explorer.Chain.Hash{bytes: bytes}) when byte_size(bytes) >= 32 do + <<_::binary-size(12), address::binary-size(20)>> = bytes + address + end + + defp extract_caller_binary(%Explorer.Chain.Data{bytes: bytes}) when byte_size(bytes) >= 32 do + <<_::binary-size(12), address::binary-size(20)>> = bytes + address + end + + defp extract_caller_binary(_), do: nil +end diff --git a/apps/indexer/lib/indexer/transform/mint_transfers.ex b/apps/indexer/lib/indexer/transform/mint_transfers.ex index c53dde7a5387..bac796b78d6a 100644 --- a/apps/indexer/lib/indexer/transform/mint_transfers.ex +++ b/apps/indexer/lib/indexer/transform/mint_transfers.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.MintTransfers do @moduledoc """ Helper functions to parse addresses from mint transfers. diff --git a/apps/indexer/lib/indexer/transform/optimism/withdrawals.ex b/apps/indexer/lib/indexer/transform/optimism/withdrawals.ex index eca8d19e7a87..82c8a7df9672 100644 --- a/apps/indexer/lib/indexer/transform/optimism/withdrawals.ex +++ b/apps/indexer/lib/indexer/transform/optimism/withdrawals.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Optimism.Withdrawals do @moduledoc """ Helper functions for transforming data for Optimism withdrawals. @@ -5,12 +6,10 @@ defmodule Indexer.Transform.Optimism.Withdrawals do require Logger + alias Explorer.Chain.Optimism.Withdrawal alias Indexer.Fetcher.Optimism.Withdrawal, as: OptimismWithdrawal alias Indexer.Helper - # 32-byte signature of the event MessagePassed(uint256 indexed nonce, address indexed sender, address indexed target, uint256 value, uint256 gasLimit, bytes data, bytes32 withdrawalHash) - @message_passed_event "0x02a52367d10742d8032712c1bb8e0144ff1ec5ffda1ed7d70bb05a2744955054" - @doc """ Returns a list of withdrawals given a list of logs. """ @@ -23,10 +22,11 @@ defmodule Indexer.Transform.Optimism.Withdrawals do message_passer = Application.get_env(:indexer, OptimismWithdrawal)[:message_passer], true <- Helper.address_correct?(message_passer) do message_passer = String.downcase(message_passer) + message_passed_event = Withdrawal.message_passed_event() logs |> Enum.filter(fn log -> - !is_nil(log.first_topic) && String.downcase(log.first_topic) == @message_passed_event && + !is_nil(log.first_topic) && String.downcase(log.first_topic) == message_passed_event && String.downcase(Helper.address_hash_to_string(log.address_hash)) == message_passer end) |> Enum.map(fn log -> diff --git a/apps/indexer/lib/indexer/transform/polygon_edge/deposit_executes.ex b/apps/indexer/lib/indexer/transform/polygon_edge/deposit_executes.ex deleted file mode 100644 index 0e3ddeae5520..000000000000 --- a/apps/indexer/lib/indexer/transform/polygon_edge/deposit_executes.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule Indexer.Transform.PolygonEdge.DepositExecutes do - @moduledoc """ - Helper functions for transforming data for Polygon Edge deposit executes. - """ - - require Logger - - alias Indexer.Fetcher.PolygonEdge.DepositExecute - alias Indexer.Helper - - @doc """ - Returns a list of deposit executes given a list of logs. - """ - @spec parse(list()) :: list() - def parse(logs) do - prev_metadata = Logger.metadata() - Logger.metadata(fetcher: :polygon_edge_deposit_executes_realtime) - - items = - with false <- - is_nil(Application.get_env(:indexer, DepositExecute)[:start_block_l2]), - state_receiver = Application.get_env(:indexer, DepositExecute)[:state_receiver], - true <- Helper.address_correct?(state_receiver) do - state_receiver = String.downcase(state_receiver) - state_sync_result_event_signature = DepositExecute.state_sync_result_event_signature() - - logs - |> Enum.filter(fn log -> - !is_nil(log.first_topic) && String.downcase(log.first_topic) == state_sync_result_event_signature && - String.downcase(Helper.address_hash_to_string(log.address_hash)) == state_receiver - end) - |> Enum.map(fn log -> - Logger.info("Deposit Execute (StateSyncResult) message found, id: #{log.second_topic}.") - - DepositExecute.event_to_deposit_execute( - log.second_topic, - log.third_topic, - log.transaction_hash, - log.block_number - ) - end) - else - true -> - [] - - false -> - Logger.error("StateReceiver contract address is incorrect. Cannot use #{__MODULE__} for parsing logs.") - [] - end - - Logger.reset_metadata(prev_metadata) - - items - end -end diff --git a/apps/indexer/lib/indexer/transform/polygon_edge/withdrawals.ex b/apps/indexer/lib/indexer/transform/polygon_edge/withdrawals.ex deleted file mode 100644 index 02ffcbaaa71e..000000000000 --- a/apps/indexer/lib/indexer/transform/polygon_edge/withdrawals.ex +++ /dev/null @@ -1,54 +0,0 @@ -defmodule Indexer.Transform.PolygonEdge.Withdrawals do - @moduledoc """ - Helper functions for transforming data for Polygon Edge withdrawals. - """ - - require Logger - - alias Indexer.Fetcher.PolygonEdge.Withdrawal - alias Indexer.Helper - - @doc """ - Returns a list of withdrawals given a list of logs. - """ - @spec parse(list()) :: list() - def parse(logs) do - prev_metadata = Logger.metadata() - Logger.metadata(fetcher: :polygon_edge_withdrawals_realtime) - - items = - with false <- is_nil(Application.get_env(:indexer, Withdrawal)[:start_block_l2]), - state_sender = Application.get_env(:indexer, Withdrawal)[:state_sender], - true <- Helper.address_correct?(state_sender) do - state_sender = String.downcase(state_sender) - l2_state_synced_event_signature = Withdrawal.l2_state_synced_event_signature() - - logs - |> Enum.filter(fn log -> - !is_nil(log.first_topic) && String.downcase(log.first_topic) == l2_state_synced_event_signature && - String.downcase(Helper.address_hash_to_string(log.address_hash)) == state_sender - end) - |> Enum.map(fn log -> - Logger.info("Withdrawal message found, id: #{log.second_topic}.") - - Withdrawal.event_to_withdrawal( - log.second_topic, - log.data, - log.transaction_hash, - log.block_number - ) - end) - else - true -> - [] - - false -> - Logger.error("L2StateSender contract address is incorrect. Cannot use #{__MODULE__} for parsing logs.") - [] - end - - Logger.reset_metadata(prev_metadata) - - items - end -end diff --git a/apps/indexer/lib/indexer/transform/polygon_zkevm/bridge.ex b/apps/indexer/lib/indexer/transform/polygon_zkevm/bridge.ex deleted file mode 100644 index ba14abac1031..000000000000 --- a/apps/indexer/lib/indexer/transform/polygon_zkevm/bridge.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule Indexer.Transform.PolygonZkevm.Bridge do - @moduledoc """ - Helper functions for transforming data for Polygon zkEVM Bridge operations. - """ - - require Logger - - import Indexer.Fetcher.PolygonZkevm.Bridge, - only: [filter_bridge_events: 2, prepare_operations: 8] - - alias Indexer.Fetcher.PolygonZkevm.{BridgeL1, BridgeL2} - alias Indexer.Helper - - @doc """ - Returns a list of operations given a list of blocks and logs. - """ - @spec parse(list(), list()) :: list() - def parse(blocks, logs) do - prev_metadata = Logger.metadata() - Logger.metadata(fetcher: :polygon_zkevm_bridge_l2_realtime) - - items = - with false <- is_nil(Application.get_env(:indexer, BridgeL2)[:start_block]), - false <- Application.get_env(:explorer, :chain_type) != :polygon_zkevm, - rpc_l1 = Application.get_all_env(:indexer)[BridgeL1][:rpc], - {:rpc_l1_undefined, false} <- {:rpc_l1_undefined, is_nil(rpc_l1)}, - rollup_network_id_l1 = Application.get_all_env(:indexer)[BridgeL1][:rollup_network_id_l1], - rollup_network_id_l2 = Application.get_all_env(:indexer)[BridgeL2][:rollup_network_id_l2], - rollup_index_l1 = Application.get_all_env(:indexer)[BridgeL1][:rollup_index_l1], - rollup_index_l2 = Application.get_all_env(:indexer)[BridgeL2][:rollup_index_l2], - {:rollup_network_id_l1_is_valid, true} <- - {:rollup_network_id_l1_is_valid, !is_nil(rollup_network_id_l1) and rollup_network_id_l1 >= 0}, - {:rollup_network_id_l2_is_valid, true} <- - {:rollup_network_id_l2_is_valid, !is_nil(rollup_network_id_l2) and rollup_network_id_l2 > 0}, - {:rollup_index_l2_is_valid, true} <- {:rollup_index_l2_is_valid, !is_nil(rollup_index_l2)}, - bridge_contract = Application.get_env(:indexer, BridgeL2)[:bridge_contract], - {:bridge_contract_address_is_valid, true} <- - {:bridge_contract_address_is_valid, Helper.address_correct?(bridge_contract)} do - bridge_contract = String.downcase(bridge_contract) - - block_numbers = Enum.map(blocks, fn block -> block.number end) - start_block = Enum.min(block_numbers) - end_block = Enum.max(block_numbers) - - Helper.log_blocks_chunk_handling(start_block, end_block, start_block, end_block, nil, :L2) - - json_rpc_named_arguments_l1 = Helper.json_rpc_named_arguments(rpc_l1) - - block_to_timestamp = Enum.reduce(blocks, %{}, fn block, acc -> Map.put(acc, block.number, block.timestamp) end) - - items = - logs - |> filter_bridge_events(bridge_contract) - |> prepare_operations( - rollup_network_id_l1, - rollup_network_id_l2, - rollup_index_l1, - rollup_index_l2, - nil, - json_rpc_named_arguments_l1, - block_to_timestamp - ) - - Helper.log_blocks_chunk_handling( - start_block, - end_block, - start_block, - end_block, - "#{Enum.count(items)} L2 operation(s)", - :L2 - ) - - items - else - true -> - [] - - {:rpc_l1_undefined, true} -> - Logger.error("L1 RPC URL is not defined. Cannot use #{__MODULE__} for parsing logs.") - [] - - {:rollup_network_id_l1_is_valid, false} -> - Logger.error( - "Invalid network ID for L1. Please, check INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NETWORK_ID env variable." - ) - - [] - - {:rollup_network_id_l2_is_valid, false} -> - Logger.error( - "Invalid network ID for L2. Please, check INDEXER_POLYGON_ZKEVM_L2_BRIDGE_NETWORK_ID env variable." - ) - - [] - - {:rollup_index_l2_is_valid, false} -> - Logger.error( - "Rollup index is undefined for L2. Please, check INDEXER_POLYGON_ZKEVM_L2_BRIDGE_ROLLUP_INDEX env variable." - ) - - [] - - {:bridge_contract_address_is_valid, false} -> - Logger.error( - "PolygonZkEVMBridge contract address is invalid or not defined. Cannot use #{__MODULE__} for parsing logs." - ) - - [] - end - - Logger.reset_metadata(prev_metadata) - - items - end -end diff --git a/apps/indexer/lib/indexer/transform/scroll/l1_fee_params.ex b/apps/indexer/lib/indexer/transform/scroll/l1_fee_params.ex index 4615266a7519..cb0010f38fd6 100644 --- a/apps/indexer/lib/indexer/transform/scroll/l1_fee_params.ex +++ b/apps/indexer/lib/indexer/transform/scroll/l1_fee_params.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Scroll.L1FeeParams do @moduledoc """ Helper functions for transforming data for Scroll L1 fee parameters diff --git a/apps/indexer/lib/indexer/transform/shibarium/bridge.ex b/apps/indexer/lib/indexer/transform/shibarium/bridge.ex index 6d867c5f67b5..cb8922e29987 100644 --- a/apps/indexer/lib/indexer/transform/shibarium/bridge.ex +++ b/apps/indexer/lib/indexer/transform/shibarium/bridge.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Shibarium.Bridge do @moduledoc """ Helper functions for transforming data for Shibarium Bridge operations. diff --git a/apps/indexer/lib/indexer/transform/signed_authorizations.ex b/apps/indexer/lib/indexer/transform/signed_authorizations.ex index b7e5f8e50980..0ab0f4dbe9f8 100644 --- a/apps/indexer/lib/indexer/transform/signed_authorizations.ex +++ b/apps/indexer/lib/indexer/transform/signed_authorizations.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.SignedAuthorizations do @moduledoc """ Helper functions for extracting signed authorizations from EIP-7702 transactions. @@ -30,12 +31,24 @@ defmodule Indexer.Transform.SignedAuthorizations do &(&1.authorization_list |> Enum.with_index() |> Enum.map(fn {authorization, index} -> - authorization - |> Map.merge(%{ - transaction_hash: &1.hash, - index: index, - authority: recover_authority(authorization) - }) + new_authorization = + authorization + |> Map.merge(%{ + transaction_hash: &1.hash, + index: index, + authority: recover_authority(authorization) + }) + + # we can immediately do some basic validation that doesn't require any extra JSON-RPC requests + # full validation for :invalid_nonce is deferred to async fetcher (so :ok is replaced with nil) + status = + case SignedAuthorization.basic_validate(new_authorization) do + :ok -> nil + status -> status + end + + new_authorization + |> Map.put(:status, status) end)) ) end diff --git a/apps/indexer/lib/indexer/transform/stability/validators.ex b/apps/indexer/lib/indexer/transform/stability/validators.ex new file mode 100644 index 000000000000..cda15a066d59 --- /dev/null +++ b/apps/indexer/lib/indexer/transform/stability/validators.ex @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Transform.Stability.Validators do + @moduledoc """ + Helper functions for transforming blocks into stability validator counter updates. + """ + + require Logger + + @doc """ + Returns a list of validator counter updates given a list of blocks. + Only processes blocks for stability chain type. + """ + def parse(blocks) do + chain_type = Application.get_env(:explorer, :chain_type) + + if chain_type == :stability do + do_parse(blocks) + else + [] + end + end + + defp do_parse(blocks) when is_list(blocks) do + blocks + |> Enum.filter(&(&1[:miner_hash] != nil)) + |> Enum.group_by(& &1[:miner_hash]) + |> Enum.map(fn {miner_hash, validator_blocks} -> + %{ + address_hash: miner_hash, + blocks_validated: length(validator_blocks) + } + end) + end + + defp do_parse(_), do: [] +end diff --git a/apps/indexer/lib/indexer/transform/token_instances.ex b/apps/indexer/lib/indexer/transform/token_instances.ex index 1b9374318b7d..745cb91d3d54 100644 --- a/apps/indexer/lib/indexer/transform/token_instances.ex +++ b/apps/indexer/lib/indexer/transform/token_instances.ex @@ -1,10 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.TokenInstances do @moduledoc """ Module extracts token instances from token transfers """ - def params_set(%{} = import_options) do - Enum.reduce(import_options, %{}, &reducer/2) + def params_set(%{} = import_options, initial \\ %{}) do + Enum.reduce(import_options, initial, &reducer/2) end defp reducer({:token_transfers_params, token_transfers_params}, initial) when is_list(token_transfers_params) do diff --git a/apps/indexer/lib/indexer/transform/token_transfers.ex b/apps/indexer/lib/indexer/transform/token_transfers.ex index 03e894d9a323..a269a42439c1 100644 --- a/apps/indexer/lib/indexer/transform/token_transfers.ex +++ b/apps/indexer/lib/indexer/transform/token_transfers.ex @@ -1,15 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.TokenTransfers do @moduledoc """ - Helper functions for transforming data for known token standards (ERC-20, ERC-721, ERC-1155, ERC-404) transfers. + Helper functions for transforming data for known token standards (ERC-20, ERC-721, ERC-1155, ERC-404, ERC-7984) transfers. """ require Logger + use Utils.RuntimeEnvHelper, + chain_type: [:explorer, :chain_type], + arc_native_token_address: [:indexer, [:arc, :arc_native_token_address]], + arc_native_token_system_address: [:indexer, [:arc, :arc_native_token_system_address]], + arc_native_token_decimals: [:indexer, [:arc, :arc_native_token_decimals]] + import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] import Explorer.Helper, only: [decode_data: 2, truncate_address_hash: 1] - alias Explorer.Repo alias Explorer.Chain.{Hash, Token, TokenTransfer} + alias Explorer.Repo alias Indexer.Fetcher.TokenTotalSupplyUpdater @doc """ @@ -18,9 +25,23 @@ defmodule Indexer.Transform.TokenTransfers do def parse(logs, skip_additional_fetchers? \\ false) do initial_acc = %{tokens: [], token_transfers: []} + erc20_and_erc721_token_transfers_filtered = + if chain_type() == :arc do + allowed_erc20_erc721_token_transfer_events = + [ + TokenTransfer.constant(), + TokenTransfer.arc_native_coin_transferred_event(), + TokenTransfer.arc_native_coin_minted_event(), + TokenTransfer.arc_native_coin_burned_event() + ] + + Enum.filter(logs, &(&1.first_topic in allowed_erc20_erc721_token_transfer_events)) + else + Enum.filter(logs, &(&1.first_topic == unquote(TokenTransfer.constant()))) + end + erc20_and_erc721_token_transfers = - logs - |> Enum.filter(&(&1.first_topic == unquote(TokenTransfer.constant()))) + erc20_and_erc721_token_transfers_filtered |> Enum.reduce(initial_acc, &do_parse/2) weth_transfers = @@ -49,27 +70,46 @@ defmodule Indexer.Transform.TokenTransfers do end) |> Enum.reduce(initial_acc, &do_parse(&1, &2, :erc404)) + erc7984_token_transfers = + logs + |> Enum.filter(fn log -> + log.first_topic == TokenTransfer.erc7984_transfer_event() + end) + |> Enum.reduce(initial_acc, &do_parse(&1, &2, :erc7984)) + rough_tokens = - erc404_token_transfers.tokens ++ + erc7984_token_transfers.tokens ++ + erc404_token_transfers.tokens ++ erc1155_token_transfers.tokens ++ erc20_and_erc721_token_transfers.tokens ++ weth_transfers.tokens + rough_tokens_uniq = + rough_tokens + |> Enum.reduce({%{}, []}, fn %{contract_address_hash: addr} = token, {seen, acc} -> + if Map.has_key?(seen, addr) do + {seen, acc} + else + {Map.put(seen, addr, true), [token | acc]} + end + end) + |> elem(1) + |> Enum.reverse() + rough_token_transfers = - erc404_token_transfers.token_transfers ++ + erc7984_token_transfers.token_transfers ++ + erc404_token_transfers.token_transfers ++ erc1155_token_transfers.token_transfers ++ erc20_and_erc721_token_transfers.token_transfers ++ weth_transfers.token_transfers - tokens = sanitize_token_types(rough_tokens, rough_token_transfers) - token_transfers = sanitize_weth_transfers(tokens, rough_token_transfers, weth_transfers.token_transfers) + tokens_uniq = sanitize_token_types(rough_tokens_uniq, rough_token_transfers) + token_transfers = sanitize_weth_transfers(tokens_uniq, rough_token_transfers, weth_transfers.token_transfers) - unless skip_additional_fetchers? do + if !skip_additional_fetchers? do token_transfers |> filter_tokens_for_supply_update() |> TokenTotalSupplyUpdater.add_tokens() end - tokens_uniq = tokens |> Enum.uniq() - token_transfers_from_logs_uniq = %{ tokens: tokens_uniq, token_transfers: token_transfers @@ -176,7 +216,7 @@ defmodule Indexer.Transform.TokenTransfers do defp token_type_priority(nil), do: -1 - @token_types_priority_order ["ERC-20", "ERC-721", "ERC-1155", "ERC-404"] + @token_types_priority_order ["ERC-20", "ERC-721", "ERC-1155", "ERC-404", "ERC-7984"] defp token_type_priority(token_type) do Enum.find_index(@token_types_priority_order, &(&1 == token_type)) end @@ -186,6 +226,7 @@ defmodule Indexer.Transform.TokenTransfers do case type do :erc1155 -> parse_erc1155_params(log) :erc404 -> parse_erc404_params(log) + :erc7984 -> parse_erc7984_params(log) _ -> parse_params(log) end @@ -211,59 +252,90 @@ defmodule Indexer.Transform.TokenTransfers do # ERC-20 token transfer defp parse_params(%{second_topic: second_topic, third_topic: third_topic, fourth_topic: nil} = log) when not is_nil(second_topic) and not is_nil(third_topic) do - [amount] = decode_data(log.data, [{:uint, 256}]) - - from_address_hash = truncate_address_hash(log.second_topic) - to_address_hash = truncate_address_hash(log.third_topic) + if arc_native_token_transfer_event?(log) do + # for :arc chain type we need to ignore ERC-20 Transfer events from the native token as there are + # NativeCoinTransferred, NativeCoinMinted, NativeCoinBurned, EIP-7708 events instead + nil + else + # handle the transfer for other cases + [decoded_amount] = decode_data(log.data, [{:uint, 256}]) + decimal_amount = Decimal.new(decoded_amount || 0) + + {token_contract_address_hash, amount} = + if arc_native_coin_transferred_event?(log) or arc_eip7708_transfer_log?(log) do + # NativeCoinTransferred and EIP-7708 protocol Transfer use 18-decimal amounts on-chain; + # normalize to configured native token decimals + {arc_native_token_address(), amount_18_decimals_to_n_decimals(decimal_amount, arc_native_token_decimals())} + else + {log.address_hash, decimal_amount} + end - token_transfer = %{ - amount: Decimal.new(amount || 0), - block_number: log.block_number, - block_hash: log.block_hash, - log_index: log.index, - from_address_hash: from_address_hash, - to_address_hash: to_address_hash, - token_contract_address_hash: log.address_hash, - transaction_hash: log.transaction_hash, - token_ids: nil, - token_type: "ERC-20" - } + token_transfer = %{ + amount: amount, + block_number: log.block_number, + block_hash: log.block_hash, + log_index: log.index, + from_address_hash: truncate_address_hash(log.second_topic), + to_address_hash: truncate_address_hash(log.third_topic), + token_contract_address_hash: token_contract_address_hash, + transaction_hash: log.transaction_hash, + token_ids: nil, + token_type: "ERC-20" + } - token = %{ - contract_address_hash: log.address_hash, - type: "ERC-20" - } + token = %{ + contract_address_hash: token_contract_address_hash, + type: "ERC-20" + } - {token, token_transfer} + {token, token_transfer} + end end - # ERC-20 token transfer for WETH + # ERC-20 token transfer for WETH or Arc native token mint/burn defp parse_params(%{second_topic: second_topic, third_topic: nil, fourth_topic: nil} = log) when not is_nil(second_topic) do - [amount] = decode_data(log.data, [{:uint, 256}]) + [decoded_amount] = decode_data(log.data, [{:uint, 256}]) + decimal_amount = Decimal.new(decoded_amount || 0) - {from_address_hash, to_address_hash} = - if log.first_topic == TokenTransfer.weth_deposit_signature() do - {burn_address_hash_string(), truncate_address_hash(log.second_topic)} - else - {truncate_address_hash(log.second_topic), burn_address_hash_string()} + {from_address_hash, to_address_hash, token_contract_address_hash, amount} = + cond do + log.first_topic == TokenTransfer.weth_deposit_signature() -> + {burn_address_hash_string(), truncate_address_hash(log.second_topic), log.address_hash, decimal_amount} + + arc_native_coin_minted_event?(log) -> + # there are 18 decimals for the native token, so we need to adjust the amount with the token decimals + normalized_amount = amount_18_decimals_to_n_decimals(decimal_amount, arc_native_token_decimals()) + + {burn_address_hash_string(), truncate_address_hash(log.second_topic), arc_native_token_address(), + normalized_amount} + + arc_native_coin_burned_event?(log) -> + # there are 18 decimals for the native token, so we need to adjust the amount with the token decimals + normalized_amount = amount_18_decimals_to_n_decimals(decimal_amount, arc_native_token_decimals()) + + {truncate_address_hash(log.second_topic), burn_address_hash_string(), arc_native_token_address(), + normalized_amount} + + true -> + {truncate_address_hash(log.second_topic), burn_address_hash_string(), log.address_hash, decimal_amount} end token_transfer = %{ - amount: Decimal.new(amount || 0), + amount: amount, block_number: log.block_number, block_hash: log.block_hash, log_index: log.index, from_address_hash: from_address_hash, to_address_hash: to_address_hash, - token_contract_address_hash: log.address_hash, + token_contract_address_hash: token_contract_address_hash, transaction_hash: log.transaction_hash, token_ids: nil, token_type: "ERC-20" } token = %{ - contract_address_hash: log.address_hash, + contract_address_hash: token_contract_address_hash, type: "ERC-20" } @@ -479,6 +551,139 @@ defmodule Indexer.Transform.TokenTransfers do end end + # Converts from 18-decimal amount to n-decimal amount. + # + # ## Parameters + # - `amount`: The given 18-decimal amount to convert from. + # - `new_decimals`: The new number of decimals. + # + # ## Returns + # - The converted amount. If the source amount has more than `new_decimals` decimals, the new amount will be truncated. + # Example: If we have 18-decimal amount 25148712000000000, that will be 25148 for 6-decimal representation. + @spec amount_18_decimals_to_n_decimals(Decimal.t(), non_neg_integer()) :: Decimal.t() + defp amount_18_decimals_to_n_decimals(amount, new_decimals) do + amount + |> Decimal.mult(Integer.pow(10, new_decimals)) + |> Decimal.div_int(Integer.pow(10, 18)) + end + + # EIP-7708 protocol `Transfer` log (emitter is SYSTEM_ADDRESS, not the synthetic ERC-20). + @spec arc_eip7708_transfer_log?(%{ + :first_topic => String.t(), + :address_hash => String.t(), + optional(any()) => any() + }) :: boolean() + defp arc_eip7708_transfer_log?(log) do + chain_type() == :arc and log.first_topic == TokenTransfer.constant() and + log.address_hash == TokenTransfer.eip7708_system_address() + end + + # Determines if the given log is the NativeCoinTransferred event emitted by the native token system address on Arc chain. + # + # ## Parameters + # - `log`: The log to check. + # + # ## Returns + # - `true` if this is the NativeCoinTransferred event from the native token system address on Arc chain, `false` otherwise. + @spec arc_native_coin_transferred_event?(%{ + :first_topic => String.t(), + :address_hash => String.t(), + optional(any()) => any() + }) :: boolean() + defp arc_native_coin_transferred_event?(log) do + chain_type() == :arc and log.first_topic == TokenTransfer.arc_native_coin_transferred_event() and + log.address_hash == arc_native_token_system_address() + end + + # Determines if the given log is the NativeCoinMinted event emitted by the native token system address on Arc chain. + # + # ## Parameters + # - `log`: The log to check. + # + # ## Returns + # - `true` if this is the NativeCoinMinted event from the native token system address on Arc chain, `false` otherwise. + @spec arc_native_coin_minted_event?(%{ + :first_topic => String.t(), + :address_hash => String.t(), + optional(any()) => any() + }) :: boolean() + defp arc_native_coin_minted_event?(log) do + chain_type() == :arc and log.first_topic == TokenTransfer.arc_native_coin_minted_event() and + log.address_hash == arc_native_token_system_address() + end + + # Determines if the given log is the NativeCoinBurned event emitted by the native token system address on Arc chain. + # + # ## Parameters + # - `log`: The log to check. + # + # ## Returns + # - `true` if this is the NativeCoinBurned event from the native token system address on Arc chain, `false` otherwise. + @spec arc_native_coin_burned_event?(%{ + :first_topic => String.t(), + :address_hash => String.t(), + optional(any()) => any() + }) :: boolean() + defp arc_native_coin_burned_event?(log) do + chain_type() == :arc and log.first_topic == TokenTransfer.arc_native_coin_burned_event() and + log.address_hash == arc_native_token_system_address() + end + + # Determines if the given log is the Transfer event emitted by the native token contract on Arc chain. + # + # ## Parameters + # - `log`: The log to check. + # + # ## Returns + # - `true` if this is the Transfer event from the native token contract on Arc chain, `false` otherwise. + @spec arc_native_token_transfer_event?(%{ + :first_topic => String.t(), + :address_hash => String.t(), + optional(any()) => any() + }) :: boolean() + defp arc_native_token_transfer_event?(log) do + chain_type() == :arc and log.first_topic == TokenTransfer.constant() and + log.address_hash == arc_native_token_address() + end + + @spec parse_erc7984_params(map()) :: + nil + | {%{ + contract_address_hash: Hash.Address.t(), + type: String.t() + }, map()} + defp parse_erc7984_params( + %{ + second_topic: second_topic, + third_topic: third_topic, + fourth_topic: fourth_topic + } = log + ) + when not is_nil(second_topic) and not is_nil(third_topic) and not is_nil(fourth_topic) do + from_address_hash = truncate_address_hash(second_topic) + to_address_hash = truncate_address_hash(third_topic) + + token_transfer = %{ + block_number: log.block_number, + block_hash: log.block_hash, + log_index: log.index, + from_address_hash: from_address_hash, + to_address_hash: to_address_hash, + token_contract_address_hash: log.address_hash, + transaction_hash: log.transaction_hash, + token_type: "ERC-7984", + token_ids: nil, + amount: nil + } + + token = %{ + contract_address_hash: log.address_hash, + type: "ERC-7984" + } + + {token, token_transfer} + end + def filter_tokens_for_supply_update(token_transfers) do token_transfers |> Enum.filter(fn token_transfer -> diff --git a/apps/indexer/lib/indexer/transform/transaction_actions.ex b/apps/indexer/lib/indexer/transform/transaction_actions.ex deleted file mode 100644 index 14805430f26f..000000000000 --- a/apps/indexer/lib/indexer/transform/transaction_actions.ex +++ /dev/null @@ -1,992 +0,0 @@ -defmodule Indexer.Transform.TransactionActions do - @moduledoc """ - Helper functions for transforming data for transaction actions. - """ - - require Logger - - import Ecto.Query, only: [from: 2] - import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] - import Explorer.Helper, only: [decode_data: 2] - - alias Explorer.Chain.Cache.{ChainId, TransactionActionTokensData, TransactionActionUniswapPools} - alias Explorer.Chain.{Address, Hash, Token, TransactionAction} - alias Explorer.Repo - alias Indexer.Helper, as: IndexerHelper - - @mainnet 1 - @goerli 5 - @optimism 10 - @polygon 137 - @base_mainnet 8453 - @base_goerli 84531 - # @gnosis 100 - - @uniswap_v3_factory_abi [ - %{ - "inputs" => [ - %{"internalType" => "address", "name" => "", "type" => "address"}, - %{"internalType" => "address", "name" => "", "type" => "address"}, - %{"internalType" => "uint24", "name" => "", "type" => "uint24"} - ], - "name" => "getPool", - "outputs" => [%{"internalType" => "address", "name" => "", "type" => "address"}], - "stateMutability" => "view", - "type" => "function" - } - ] - @uniswap_v3_pool_abi [ - %{ - "inputs" => [], - "name" => "fee", - "outputs" => [%{"internalType" => "uint24", "name" => "", "type" => "uint24"}], - "stateMutability" => "view", - "type" => "function" - }, - %{ - "inputs" => [], - "name" => "token0", - "outputs" => [%{"internalType" => "address", "name" => "", "type" => "address"}], - "stateMutability" => "view", - "type" => "function" - }, - %{ - "inputs" => [], - "name" => "token1", - "outputs" => [%{"internalType" => "address", "name" => "", "type" => "address"}], - "stateMutability" => "view", - "type" => "function" - } - ] - @erc20_abi [ - %{ - "constant" => true, - "inputs" => [], - "name" => "symbol", - "outputs" => [%{"name" => "", "type" => "string"}], - "payable" => false, - "stateMutability" => "view", - "type" => "function" - }, - %{ - "constant" => true, - "inputs" => [], - "name" => "decimals", - "outputs" => [%{"name" => "", "type" => "uint8"}], - "payable" => false, - "stateMutability" => "view", - "type" => "function" - } - ] - - # 32-byte signature of the event Borrow(address indexed reserve, address user, address indexed onBehalfOf, uint256 amount, uint8 interestRateMode, uint256 borrowRate, uint16 indexed referralCode) - @aave_v3_borrow_event "0xb3d084820fb1a9decffb176436bd02558d15fac9b0ddfed8c465bc7359d7dce0" - - # 32-byte signature of the event Supply(address indexed reserve, address user, address indexed onBehalfOf, uint256 amount, uint16 indexed referralCode) - @aave_v3_supply_event "0x2b627736bca15cd5381dcf80b0bf11fd197d01a037c52b927a881a10fb73ba61" - - # 32-byte signature of the event Withdraw(address indexed reserve, address indexed user, address indexed to, uint256 amount) - @aave_v3_withdraw_event "0x3115d1449a7b732c986cba18244e897a450f61e1bb8d589cd2e69e6c8924f9f7" - - # 32-byte signature of the event Repay(address indexed reserve, address indexed user, address indexed repayer, uint256 amount, bool useATokens) - @aave_v3_repay_event "0xa534c8dbe71f871f9f3530e97a74601fea17b426cae02e1c5aee42c96c784051" - - # 32-byte signature of the event FlashLoan(address indexed target, address initiator, address indexed asset, uint256 amount, uint8 interestRateMode, uint256 premium, uint16 indexed referralCode) - @aave_v3_flash_loan_event "0xefefaba5e921573100900a3ad9cf29f222d995fb3b6045797eaea7521bd8d6f0" - - # 32-byte signature of the event ReserveUsedAsCollateralEnabled(address indexed reserve, address indexed user) - @aave_v3_enable_collateral_event "0x00058a56ea94653cdf4f152d227ace22d4c00ad99e2a43f58cb7d9e3feb295f2" - - # 32-byte signature of the event ReserveUsedAsCollateralDisabled(address indexed reserve, address indexed user) - @aave_v3_disable_collateral_event "0x44c58d81365b66dd4b1a7f36c25aa97b8c71c361ee4937adc1a00000227db5dd" - - # 32-byte signature of the event LiquidationCall(address indexed collateralAsset, address indexed debtAsset, address indexed user, uint256 debtToCover, uint256 liquidatedCollateralAmount, address liquidator, bool receiveAToken) - @aave_v3_liquidation_call_event "0xe413a321e8681d831f4dbccbca790d2952b56f977908e45be37335533e005286" - - # 32-byte signature of the event Transfer(address indexed from, address indexed to, uint256 indexed tokenId) - @uniswap_v3_transfer_nft_event "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" - - # 32-byte signature of the event Mint(address sender, address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1) - @uniswap_v3_mint_event "0x7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde" - - # 32-byte signature of the event Burn(address indexed owner, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount, uint256 amount0, uint256 amount1) - @uniswap_v3_burn_event "0x0c396cd989a39f4459b5fa1aed6a9a8dcdbc45908acfd67e028cd568da98982c" - - # 32-byte signature of the event Collect(address indexed owner, address recipient, int24 indexed tickLower, int24 indexed tickUpper, uint128 amount0, uint128 amount1) - @uniswap_v3_collect_event "0x70935338e69775456a85ddef226c395fb668b63fa0115f5f20610b388e6ca9c0" - - # 32-byte signature of the event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick); - @uniswap_v3_swap_event "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67" - - # max number of token decimals - @decimals_max 0xFF - - @doc """ - Returns a list of transaction actions given a list of logs. - """ - def parse(logs, protocols_to_rewrite \\ nil) do - if Application.get_env(:indexer, Indexer.Fetcher.TransactionAction.Supervisor)[:enabled] do - actions = [] - - chain_id = ChainId.get_id() - - if not is_nil(protocols_to_rewrite) do - logs - |> logs_group_by_transactions() - |> clear_actions(protocols_to_rewrite) - end - - # create tokens cache if not exists - TransactionActionTokensData.create_cache_table() - - actions = parse_aave_v3(logs, actions, protocols_to_rewrite, chain_id) - actions = parse_uniswap_v3(logs, actions, protocols_to_rewrite, chain_id) - - %{transaction_actions: actions} - else - %{transaction_actions: []} - end - end - - defp parse_aave_v3(logs, actions, protocols_to_rewrite, chain_id) do - aave_v3_pool = Application.get_all_env(:indexer)[Indexer.Fetcher.TransactionAction][:aave_v3_pool] - - if not is_nil(aave_v3_pool) and - (is_nil(protocols_to_rewrite) or Enum.empty?(protocols_to_rewrite) or - Enum.member?(protocols_to_rewrite, "aave_v3")) do - logs - |> aave_filter_logs(String.downcase(aave_v3_pool)) - |> logs_group_by_transactions() - |> aave(actions, chain_id) - else - actions - end - end - - defp parse_uniswap_v3(logs, actions, protocols_to_rewrite, chain_id) do - if Enum.member?([@mainnet, @goerli, @optimism, @polygon, @base_mainnet, @base_goerli], chain_id) and - (is_nil(protocols_to_rewrite) or Enum.empty?(protocols_to_rewrite) or - Enum.member?(protocols_to_rewrite, "uniswap_v3")) do - uniswap_v3_positions_nft = - String.downcase( - Application.get_all_env(:indexer)[Indexer.Fetcher.TransactionAction][:uniswap_v3_nft_position_manager] - ) - - logs - |> uniswap_filter_logs(uniswap_v3_positions_nft) - |> logs_group_by_transactions() - |> uniswap(actions, chain_id, uniswap_v3_positions_nft) - else - actions - end - end - - defp aave_filter_logs(logs, pool_address) do - logs - |> Enum.filter(fn log -> - Enum.member?( - [ - @aave_v3_borrow_event, - @aave_v3_supply_event, - @aave_v3_withdraw_event, - @aave_v3_repay_event, - @aave_v3_flash_loan_event, - @aave_v3_enable_collateral_event, - @aave_v3_disable_collateral_event, - @aave_v3_liquidation_call_event - ], - sanitize_first_topic(log.first_topic) - ) && IndexerHelper.address_hash_to_string(log.address_hash, true) == pool_address - end) - end - - defp aave(logs_grouped, actions, chain_id) do - # iterate for each transaction - Enum.reduce(logs_grouped, actions, fn {_transaction_hash, transaction_logs}, actions_acc -> - # go through actions - Enum.reduce(transaction_logs, actions_acc, fn log, acc -> - acc ++ aave_handle_action(log, chain_id) - end) - end) - end - - # credo:disable-for-next-line /Complexity/ - defp aave_handle_action(log, chain_id) do - case sanitize_first_topic(log.first_topic) do - @aave_v3_borrow_event -> - # this is Borrow event - aave_handle_borrow_event(log, chain_id) - - @aave_v3_supply_event -> - # this is Supply event - aave_handle_supply_event(log, chain_id) - - @aave_v3_withdraw_event -> - # this is Withdraw event - aave_handle_withdraw_event(log, chain_id) - - @aave_v3_repay_event -> - # this is Repay event - aave_handle_repay_event(log, chain_id) - - @aave_v3_flash_loan_event -> - # this is FlashLoan event - aave_handle_flash_loan_event(log, chain_id) - - @aave_v3_enable_collateral_event -> - # this is ReserveUsedAsCollateralEnabled event - aave_handle_event("enable_collateral", log, log.second_topic, chain_id) - - @aave_v3_disable_collateral_event -> - # this is ReserveUsedAsCollateralDisabled event - aave_handle_event("disable_collateral", log, log.second_topic, chain_id) - - @aave_v3_liquidation_call_event -> - # this is LiquidationCall event - aave_handle_liquidation_call_event(log, chain_id) - - _ -> - [] - end - end - - defp aave_handle_borrow_event(log, chain_id) do - [_user, amount, _interest_rate_mode, _borrow_rate] = - decode_data(log.data, [:address, {:uint, 256}, {:uint, 8}, {:uint, 256}]) - - aave_handle_event("borrow", amount, log, log.second_topic, chain_id) - end - - defp aave_handle_supply_event(log, chain_id) do - [_user, amount] = decode_data(log.data, [:address, {:uint, 256}]) - - aave_handle_event("supply", amount, log, log.second_topic, chain_id) - end - - defp aave_handle_withdraw_event(log, chain_id) do - [amount] = decode_data(log.data, [{:uint, 256}]) - - aave_handle_event("withdraw", amount, log, log.second_topic, chain_id) - end - - defp aave_handle_repay_event(log, chain_id) do - [amount, _use_a_tokens] = decode_data(log.data, [{:uint, 256}, :bool]) - - aave_handle_event("repay", amount, log, log.second_topic, chain_id) - end - - defp aave_handle_flash_loan_event(log, chain_id) do - [_initiator, amount, _interest_rate_mode, _premium] = - decode_data(log.data, [:address, {:uint, 256}, {:uint, 8}, {:uint, 256}]) - - aave_handle_event("flash_loan", amount, log, log.third_topic, chain_id) - end - - defp aave_handle_liquidation_call_event(log, chain_id) do - [debt_amount, collateral_amount, _liquidator, _receive_a_token] = - decode_data(log.data, [{:uint, 256}, {:uint, 256}, :address, :bool]) - - debt_address = - log.third_topic - |> IndexerHelper.log_topic_to_string() - |> truncate_address_hash() - - collateral_address = - log.second_topic - |> IndexerHelper.log_topic_to_string() - |> truncate_address_hash() - - case get_token_data([debt_address, collateral_address]) do - false -> - [] - - token_data -> - debt_decimals = token_data[debt_address].decimals - collateral_decimals = token_data[collateral_address].decimals - - [ - %{ - hash: log.transaction_hash, - protocol: "aave_v3", - data: %{ - debt_amount: fractional(Decimal.new(debt_amount), Decimal.new(debt_decimals)), - debt_symbol: clarify_token_symbol(token_data[debt_address].symbol, chain_id), - debt_address: Address.checksum(debt_address), - collateral_amount: fractional(Decimal.new(collateral_amount), Decimal.new(collateral_decimals)), - collateral_symbol: clarify_token_symbol(token_data[collateral_address].symbol, chain_id), - collateral_address: Address.checksum(collateral_address), - block_number: log.block_number - }, - type: "liquidation_call", - log_index: log.index - } - ] - end - end - - defp aave_handle_event(type, amount, log, address_topic, chain_id) - when type in ["borrow", "supply", "withdraw", "repay", "flash_loan"] do - address = - address_topic - |> IndexerHelper.log_topic_to_string() - |> truncate_address_hash() - - case get_token_data([address]) do - false -> - [] - - token_data -> - decimals = token_data[address].decimals - - [ - %{ - hash: log.transaction_hash, - protocol: "aave_v3", - data: %{ - amount: fractional(Decimal.new(amount), Decimal.new(decimals)), - symbol: clarify_token_symbol(token_data[address].symbol, chain_id), - address: Address.checksum(address), - block_number: log.block_number - }, - type: type, - log_index: log.index - } - ] - end - end - - defp aave_handle_event(type, log, address_topic, chain_id) when type in ["enable_collateral", "disable_collateral"] do - address = - address_topic - |> IndexerHelper.log_topic_to_string() - |> truncate_address_hash() - - case get_token_data([address]) do - false -> - [] - - token_data -> - [ - %{ - hash: log.transaction_hash, - protocol: "aave_v3", - data: %{ - symbol: clarify_token_symbol(token_data[address].symbol, chain_id), - address: Address.checksum(address), - block_number: log.block_number - }, - type: type, - log_index: log.index - } - ] - end - end - - defp uniswap(logs_grouped, actions, chain_id, uniswap_v3_positions_nft) do - # create a list of UniswapV3Pool legitimate contracts - legitimate = uniswap_legitimate_pools(logs_grouped) - - # iterate for each transaction - Enum.reduce(logs_grouped, actions, fn {transaction_hash, transaction_logs}, actions_acc -> - # trying to find `mint_nft` actions - actions_acc = - uniswap_handle_mint_nft_actions(transaction_hash, transaction_logs, actions_acc, uniswap_v3_positions_nft) - - # go through other actions - Enum.reduce(transaction_logs, actions_acc, fn log, acc -> - acc ++ uniswap_handle_action(log, legitimate, chain_id) - end) - end) - end - - defp uniswap_filter_logs(logs, uniswap_v3_positions_nft) do - logs - |> Enum.filter(fn log -> - first_topic = sanitize_first_topic(log.first_topic) - - Enum.member?( - [ - @uniswap_v3_mint_event, - @uniswap_v3_burn_event, - @uniswap_v3_collect_event, - @uniswap_v3_swap_event - ], - first_topic - ) || - (first_topic == @uniswap_v3_transfer_nft_event && - IndexerHelper.address_hash_to_string(log.address_hash, true) == uniswap_v3_positions_nft) - end) - end - - defp uniswap_handle_action(log, legitimate, chain_id) do - first_topic = sanitize_first_topic(log.first_topic) - - with false <- first_topic == @uniswap_v3_transfer_nft_event, - # check UniswapV3Pool contract is legitimate - pool_address <- IndexerHelper.address_hash_to_string(log.address_hash, true), - false <- is_nil(legitimate[pool_address]), - false <- Enum.empty?(legitimate[pool_address]), - # this is legitimate uniswap pool, so handle this event - token_address <- legitimate[pool_address], - token_data <- get_token_data(token_address), - false <- token_data === false do - case first_topic do - @uniswap_v3_mint_event -> - # this is Mint event - uniswap_handle_mint_event(log, token_address, token_data, chain_id) - - @uniswap_v3_burn_event -> - # this is Burn event - uniswap_handle_burn_event(log, token_address, token_data, chain_id) - - @uniswap_v3_collect_event -> - # this is Collect event - uniswap_handle_collect_event(log, token_address, token_data, chain_id) - - @uniswap_v3_swap_event -> - # this is Swap event - uniswap_handle_swap_event(log, token_address, token_data, chain_id) - - _ -> - [] - end - else - _ -> [] - end - end - - defp uniswap_handle_mint_nft_actions(transaction_hash, transaction_logs, actions_acc, uniswap_v3_positions_nft) do - first_log = Enum.at(transaction_logs, 0) - - local_acc = - transaction_logs - |> Enum.reduce(%{}, fn log, acc -> - if sanitize_first_topic(log.first_topic) == @uniswap_v3_transfer_nft_event do - # This is Transfer event for NFT - from = - log.second_topic - |> IndexerHelper.log_topic_to_string() - |> truncate_address_hash() - - # credo:disable-for-next-line - if from == burn_address_hash_string() do - to = - log.third_topic - |> IndexerHelper.log_topic_to_string() - |> truncate_address_hash() - - [token_id] = - log.fourth_topic - |> IndexerHelper.log_topic_to_string() - |> decode_data([{:uint, 256}]) - - mint_nft_ids = Map.put_new(acc, to, %{ids: [], log_index: log.index}) - - Map.put(mint_nft_ids, to, %{ - ids: Enum.reverse([to_string(token_id) | Enum.reverse(mint_nft_ids[to].ids)]), - log_index: mint_nft_ids[to].log_index - }) - else - acc - end - else - acc - end - end) - |> Enum.reduce([], fn {to, %{ids: ids, log_index: log_index}}, acc -> - action = %{ - hash: transaction_hash, - protocol: "uniswap_v3", - data: %{ - name: "Uniswap V3: Positions NFT", - symbol: "UNI-V3-POS", - address: uniswap_v3_positions_nft, - to: Address.checksum(to), - ids: ids, - block_number: first_log.block_number - }, - type: "mint_nft", - log_index: log_index - } - - [action | acc] - end) - |> Enum.reverse() - - actions_acc ++ local_acc - end - - defp uniswap_handle_burn_event(log, token_address, token_data, chain_id) do - [_amount, amount0, amount1] = decode_data(log.data, [{:uint, 128}, {:uint, 256}, {:uint, 256}]) - - uniswap_handle_event("burn", amount0, amount1, log, token_address, token_data, chain_id) - end - - defp uniswap_handle_collect_event(log, token_address, token_data, chain_id) do - [_recipient, amount0, amount1] = decode_data(log.data, [:address, {:uint, 128}, {:uint, 128}]) - - uniswap_handle_event("collect", amount0, amount1, log, token_address, token_data, chain_id) - end - - defp uniswap_handle_mint_event(log, token_address, token_data, chain_id) do - [_sender, _amount, amount0, amount1] = decode_data(log.data, [:address, {:uint, 128}, {:uint, 256}, {:uint, 256}]) - - uniswap_handle_event("mint", amount0, amount1, log, token_address, token_data, chain_id) - end - - defp uniswap_handle_swap_event(log, token_address, token_data, chain_id) do - [amount0, amount1, _sqrt_price_x96, _liquidity, _tick] = - decode_data(log.data, [{:int, 256}, {:int, 256}, {:uint, 160}, {:uint, 128}, {:int, 24}]) - - uniswap_handle_event("swap", amount0, amount1, log, token_address, token_data, chain_id) - end - - defp uniswap_handle_swap_amounts(log, amount0, amount1, symbol0, symbol1, address0, address1) do - cond do - String.first(amount0) === "-" and String.first(amount1) !== "-" -> - {amount1, symbol1, address1, String.slice(amount0, 1..-1//1), symbol0, address0, false} - - String.first(amount1) === "-" and String.first(amount0) !== "-" -> - {amount0, symbol0, address0, String.slice(amount1, 1..-1//1), symbol1, address1, false} - - amount1 === "0" and String.first(amount0) !== "-" -> - {amount0, symbol0, address0, amount1, symbol1, address1, false} - - true -> - Logger.error( - "TransactionActions: Invalid Swap event in transaction #{log.transaction_hash}. Log index: #{log.index}. amount0 = #{amount0}, amount1 = #{amount1}" - ) - - {amount0, symbol0, address0, amount1, symbol1, address1, true} - end - end - - defp uniswap_handle_event(type, amount0, amount1, log, token_address, token_data, chain_id) do - address0 = Enum.at(token_address, 0) - decimals0 = token_data[address0].decimals - symbol0 = clarify_token_symbol(token_data[address0].symbol, chain_id) - address1 = Enum.at(token_address, 1) - decimals1 = token_data[address1].decimals - symbol1 = clarify_token_symbol(token_data[address1].symbol, chain_id) - - amount0 = fractional(Decimal.new(amount0), Decimal.new(decimals0)) - amount1 = fractional(Decimal.new(amount1), Decimal.new(decimals1)) - - {new_amount0, new_symbol0, new_address0, new_amount1, new_symbol1, new_address1, is_error} = - if type == "swap" do - uniswap_handle_swap_amounts(log, amount0, amount1, symbol0, symbol1, address0, address1) - else - {amount0, symbol0, address0, amount1, symbol1, address1, false} - end - - if is_error do - [] - else - [ - %{ - hash: log.transaction_hash, - protocol: "uniswap_v3", - data: %{ - amount0: new_amount0, - symbol0: new_symbol0, - address0: Address.checksum(new_address0), - amount1: new_amount1, - symbol1: new_symbol1, - address1: Address.checksum(new_address1), - block_number: log.block_number - }, - type: type, - log_index: log.index - } - ] - end - end - - defp uniswap_legitimate_pools(logs_grouped) do - TransactionActionUniswapPools.create_cache_table() - - {pools_to_request, pools_cached} = - logs_grouped - |> Enum.reduce(%{}, fn {_transaction_hash, transaction_logs}, addresses_acc -> - transaction_logs - |> Enum.filter(fn log -> - sanitize_first_topic(log.first_topic) != @uniswap_v3_transfer_nft_event - end) - |> Enum.reduce(addresses_acc, fn log, acc -> - pool_address = IndexerHelper.address_hash_to_string(log.address_hash, true) - Map.put(acc, pool_address, true) - end) - end) - |> Enum.reduce({[], %{}}, fn {pool_address, _}, {to_request, cached} -> - value_from_cache = TransactionActionUniswapPools.fetch_from_cache(pool_address) - - if is_nil(value_from_cache) do - {[pool_address | to_request], cached} - else - {to_request, Map.put(cached, pool_address, value_from_cache)} - end - end) - - req_resp = uniswap_request_tokens_and_fees(pools_to_request) - - case uniswap_request_get_pools(req_resp) do - {requests_get_pool, responses_get_pool} -> - requests_get_pool - |> Enum.zip(responses_get_pool) - |> Enum.reduce(%{}, fn {request, {_status, response} = _resp}, acc -> - value = uniswap_pool_is_legitimate(request, response) - TransactionActionUniswapPools.put_to_cache(request.pool_address, value) - Map.put(acc, request.pool_address, value) - end) - |> Map.merge(pools_cached) - - _ -> - pools_cached - end - end - - defp uniswap_pool_is_legitimate(request, response) do - response = - case response do - [item] -> item - items -> items - end - - if request.pool_address == String.downcase(response) do - [token0, token1, _] = request.args - [token0, token1] - else - [] - end - end - - defp uniswap_request_get_pools({requests_tokens_and_fees, responses_tokens_and_fees}) do - uniswap_v3_factory = Application.get_all_env(:indexer)[Indexer.Fetcher.TransactionAction][:uniswap_v3_factory] - - requests_get_pool = - requests_tokens_and_fees - |> Enum.zip(responses_tokens_and_fees) - |> Enum.reduce(%{}, fn {request, {status, response} = _resp}, acc -> - if status == :ok do - response = parse_response(response) - - acc = Map.put_new(acc, request.contract_address, %{token0: "", token1: "", fee: ""}) - item = Map.put(acc[request.contract_address], atomized_key(request.method_id), response) - Map.put(acc, request.contract_address, item) - else - acc - end - end) - |> Enum.map(fn {pool_address, pool} -> - token0 = - if IndexerHelper.address_correct?(pool.token0), - do: String.downcase(pool.token0), - else: burn_address_hash_string() - - token1 = - if IndexerHelper.address_correct?(pool.token1), - do: String.downcase(pool.token1), - else: burn_address_hash_string() - - fee = if pool.fee == "", do: 0, else: pool.fee - - # we will call getPool(token0, token1, fee) public getter - %{ - pool_address: pool_address, - contract_address: uniswap_v3_factory, - method_id: "1698ee82", - args: [token0, token1, fee] - } - end) - - {responses_get_pool, error_messages} = read_contracts(requests_get_pool, @uniswap_v3_factory_abi) - - if not Enum.empty?(error_messages) or Enum.count(requests_get_pool) != Enum.count(responses_get_pool) do - Logger.error( - "TransactionActions: Cannot read Uniswap V3 Factory contract getPool public getter. Error messages: #{Enum.join(error_messages, ", ")}. Requests: #{inspect(requests_get_pool)}" - ) - - false - else - {requests_get_pool, responses_get_pool} - end - end - - defp uniswap_request_tokens_and_fees(pools) do - requests = - pools - |> Enum.map(fn pool_address -> - # we will call token0(), token1(), fee() public getters - Enum.map(["0dfe1681", "d21220a7", "ddca3f43"], fn method_id -> - %{ - contract_address: pool_address, - method_id: method_id, - args: [] - } - end) - end) - |> List.flatten() - - {responses, error_messages} = read_contracts(requests, @uniswap_v3_pool_abi) - - if not Enum.empty?(error_messages) do - incorrect_pools = uniswap_get_incorrect_pools(requests, responses) - - Logger.warning( - "TransactionActions: Cannot read Uniswap V3 Pool contract public getters for some pools: token0(), token1(), fee(). Error messages: #{Enum.join(error_messages, ", ")}. Incorrect pools: #{Enum.join(incorrect_pools, ", ")} - they will be marked as not legitimate." - ) - end - - {requests, responses} - end - - defp uniswap_get_incorrect_pools(requests, responses) do - responses - |> Enum.with_index() - |> Enum.reduce([], fn {{status, _}, i}, acc -> - if status == :error do - pool_address = Enum.at(requests, i)[:contract_address] - TransactionActionUniswapPools.put_to_cache(pool_address, []) - [pool_address | acc] - else - acc - end - end) - |> Enum.reverse() - end - - defp atomized_key("token0"), do: :token0 - defp atomized_key("token1"), do: :token1 - defp atomized_key("fee"), do: :fee - defp atomized_key("getPool"), do: :getPool - defp atomized_key("symbol"), do: :symbol - defp atomized_key("decimals"), do: :decimals - defp atomized_key("0dfe1681"), do: :token0 - defp atomized_key("d21220a7"), do: :token1 - defp atomized_key("ddca3f43"), do: :fee - defp atomized_key("1698ee82"), do: :getPool - defp atomized_key("95d89b41"), do: :symbol - defp atomized_key("313ce567"), do: :decimals - - defp clarify_token_symbol(symbol, chain_id) do - if symbol == "WETH" && Enum.member?([@mainnet, @goerli, @optimism], chain_id) do - "Ether" - else - symbol - end - end - - defp clear_actions(logs_grouped, protocols_to_clear) do - logs_grouped - |> Enum.each(fn {transaction_hash, _} -> - query = - if Enum.empty?(protocols_to_clear) do - from(ta in TransactionAction, where: ta.hash == ^transaction_hash) - else - from(ta in TransactionAction, where: ta.hash == ^transaction_hash and ta.protocol in ^protocols_to_clear) - end - - Repo.delete_all(query) - end) - end - - defp fractional(%Decimal{} = amount, %Decimal{} = decimals) do - amount.sign - |> Decimal.new(amount.coef, amount.exp - Decimal.to_integer(decimals)) - |> Decimal.normalize() - |> Decimal.to_string(:normal) - end - - defp get_token_data(token_addresses) do - # first, we're trying to read token data from the cache. - # if the cache is empty, we read that from DB. - # if tokens are not in the cache, nor in the DB, read them through RPC. - token_data = - token_addresses - |> get_token_data_from_cache() - |> get_token_data_from_db() - |> get_token_data_from_rpc() - - if Enum.any?(token_data, fn {_, token} -> - Map.get(token, :symbol, "") == "" or Map.get(token, :decimals) > @decimals_max - end) do - false - else - token_data - end - end - - defp get_token_data_from_cache(token_addresses) do - token_addresses - |> Enum.reduce(%{}, fn address, acc -> - Map.put( - acc, - address, - TransactionActionTokensData.fetch_from_cache(address) - ) - end) - end - - defp get_token_data_from_db(token_data_from_cache) do - # a list of token addresses which we should select from the database - select_tokens_from_db = - token_data_from_cache - |> Enum.reduce([], fn {address, data}, acc -> - if is_nil(data.symbol) or is_nil(data.decimals) do - [address | acc] - else - acc - end - end) - |> Enum.reverse() - - if Enum.empty?(select_tokens_from_db) do - # we don't need to read data from db, so will use the cache - token_data_from_cache - else - # try to read token symbols and decimals from the database and then save to the cache - query = - from( - t in Token, - where: t.contract_address_hash in ^select_tokens_from_db, - select: {t.symbol, t.decimals, t.contract_address_hash} - ) - - query - |> Repo.all() - |> Enum.reduce(token_data_from_cache, fn {symbol, decimals, contract_address_hash}, token_data_acc -> - contract_address_hash = String.downcase(Hash.to_string(contract_address_hash)) - - symbol = parse_symbol(symbol, contract_address_hash, token_data_acc) - - decimals = parse_decimals(decimals, contract_address_hash, token_data_acc) - - new_data = %{symbol: symbol, decimals: decimals} - - put_to_cache(contract_address_hash, new_data) - - Map.put(token_data_acc, contract_address_hash, new_data) - end) - end - end - - defp parse_symbol(symbol, contract_address_hash, token_data_acc) do - if is_nil(symbol) or symbol == "" do - # if db field is empty, take it from the cache - token_data_acc[contract_address_hash].symbol - else - symbol - end - end - - defp parse_decimals(decimals, contract_address_hash, token_data_acc) do - if is_nil(decimals) do - # if db field is empty, take it from the cache - token_data_acc[contract_address_hash].decimals - else - decimals - end - end - - defp put_to_cache(contract_address_hash, new_data) do - if Map.get(new_data, :decimals, 0) <= @decimals_max do - TransactionActionTokensData.put_to_cache(contract_address_hash, new_data) - end - end - - defp get_token_data_from_rpc(token_data) do - token_addresses = - token_data - |> Enum.reduce([], fn {address, data}, acc -> - if is_nil(data.symbol) or data.symbol == "" or is_nil(data.decimals) do - [address | acc] - else - acc - end - end) - |> Enum.reverse() - - {requests, responses} = get_token_data_request_symbol_decimals(token_addresses) - - requests - |> Enum.zip(responses) - |> Enum.reduce(token_data, fn {request, {status, response} = _resp}, token_data_acc -> - if status == :ok do - response = parse_response(response) - - data = token_data_acc[request.contract_address] - - new_data = get_new_data(data, request, response) - - put_to_cache(request.contract_address, new_data) - - Map.put(token_data_acc, request.contract_address, new_data) - else - token_data_acc - end - end) - end - - defp parse_response(response) do - case response do - [item] -> item - items -> items - end - end - - defp get_new_data(data, request, response) do - if atomized_key(request.method_id) == :symbol do - %{data | symbol: response} - else - %{data | decimals: response} - end - end - - defp get_token_data_request_symbol_decimals(token_addresses) do - requests = - token_addresses - |> Enum.map(fn address -> - # we will call symbol() and decimals() public getters - Enum.map(["95d89b41", "313ce567"], fn method_id -> - %{ - contract_address: address, - method_id: method_id, - args: [] - } - end) - end) - |> List.flatten() - - {responses, error_messages} = read_contracts(requests, @erc20_abi) - - if not Enum.empty?(error_messages) or Enum.count(requests) != Enum.count(responses) do - Logger.warning( - "TransactionActions: Cannot read symbol and decimals of an ERC-20 token contract. Error messages: #{Enum.join(error_messages, ", ")}. Addresses: #{Enum.join(token_addresses, ", ")}" - ) - end - - {requests, responses} - end - - defp logs_group_by_transactions(logs) do - logs - |> Enum.group_by(& &1.transaction_hash) - end - - defp read_contracts(requests, abi) do - max_retries = Application.get_env(:explorer, :token_functions_reader_max_retries) - json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) - - IndexerHelper.read_contracts_with_retries(requests, abi, json_rpc_named_arguments, max_retries) - end - - defp sanitize_first_topic(first_topic) do - if is_nil(first_topic), do: "", else: String.downcase(IndexerHelper.log_topic_to_string(first_topic)) - end - - defp truncate_address_hash(nil), do: burn_address_hash_string() - - defp truncate_address_hash("0x000000000000000000000000" <> truncated_hash) do - "0x#{truncated_hash}" - end -end diff --git a/apps/indexer/lib/indexer/utils/event_notifications_cleaner.ex b/apps/indexer/lib/indexer/utils/event_notifications_cleaner.ex new file mode 100644 index 000000000000..f697e1ed6752 --- /dev/null +++ b/apps/indexer/lib/indexer/utils/event_notifications_cleaner.ex @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Utils.EventNotificationsCleaner do + @moduledoc """ + Module is responsible for cleaning up event notifications from the database. + """ + + alias Explorer.Repo.EventNotifications, as: EventNotificationsRepo + alias Explorer.Utility.EventNotification + + import Ecto.Query + + use GenServer + + require Logger + + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + def init(args) do + Process.send(self(), :clean_up_event_notifications, []) + {:ok, args} + end + + def handle_info(:clean_up_event_notifications, state) do + clean_up_event_notifications() + Process.send_after(self(), :clean_up_event_notifications, interval()) + {:noreply, state} + end + + defp clean_up_event_notifications do + {count, _} = + EventNotification + |> where([en], en.inserted_at < ago(^max_age(), "millisecond")) + |> EventNotificationsRepo.delete_all() + + Logger.info("Deleted #{count} event notifications") + end + + defp max_age do + config()[:max_age] + end + + defp interval do + config()[:interval] + end + + defp config do + Application.get_env(:indexer, __MODULE__) + end +end diff --git a/apps/indexer/mix.exs b/apps/indexer/mix.exs index fcf29990ad9a..346f94cbbe8f 100644 --- a/apps/indexer/mix.exs +++ b/apps/indexer/mix.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.MixProject do use Mix.Project @@ -10,11 +11,11 @@ defmodule Indexer.MixProject do deps: deps(), deps_path: "../../deps", description: "Fetches blockchain data from on-chain node for later reading with Explorer.", - elixir: "~> 1.17", + elixir: "~> 1.19", elixirc_paths: elixirc_paths(Mix.env()), lockfile: "../../mix.lock", start_permanent: Mix.env() == :prod, - version: "8.0.2", + version: "11.2.2", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, @@ -51,6 +52,7 @@ defmodule Indexer.MixProject do {:decorator, "~> 1.4"}, # JSONRPC access to Nethermind for `Explorer.Indexer` {:ethereum_jsonrpc, in_umbrella: true}, + {:ex_eth_bls, "~> 0.1.0"}, # Brotli compression/decompression {:ex_brotli, "~> 0.5.0"}, {:ex_keccak, "~> 0.7.5"}, @@ -59,17 +61,17 @@ defmodule Indexer.MixProject do # Importing to database {:explorer, in_umbrella: true}, # ex_secp256k1 crypto functions - {:ex_secp256k1, "~> 0.7.0"}, + {:ex_secp256k1, "~> 0.8.0"}, # Log errors and application output to separate files {:logger_file_backend, "~> 0.0.10"}, + {:logger_json, "~> 7.0"}, # Mocking `EthereumJSONRPC.Transport`, so we avoid hitting real chains for local testing - {:mox, "~> 1.0"}, - {:prometheus_ex, git: "https://github.com/lanodan/prometheus.ex", branch: "fix/elixir-1.14", override: true}, + {:mox, "~> 1.1.0"}, + {:prometheus_ex, "~> 5.1.0", override: true}, # Tracing {:spandex, "~> 3.0"}, # `:spandex` integration with Datadog {:spandex_datadog, "~> 1.0"}, - {:logger_json, "~> 5.1"}, {:varint, "~> 1.4"}, {:utils, in_umbrella: true}, {:cachex, "~> 4.0"} diff --git a/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs b/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs index fc81b000867e..d194381317ba 100644 --- a/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs +++ b/apps/indexer/test/indexer/block/catchup/bound_interval_supervisor_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do # `async: false` due to use of named GenServer use EthereumJSONRPC.Case, async: false @@ -12,13 +13,14 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do alias Indexer.Block.Catchup alias Indexer.Block.Catchup.MissingRangesCollector alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical alias Indexer.Fetcher.{ ContractCode, InternalTransaction, ReplacedTransaction, Token, - TokenBalance, UncleBlock } @@ -234,15 +236,22 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ReplacedTransaction.Supervisor.Case.start_supervised!() UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Indexer.Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } ) Catchup.Supervisor.Case.start_supervised!(%{ - block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Indexer.Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } }) first_catchup_block_number = latest_block_number - 1 @@ -434,13 +443,17 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do insert(:block, number: 0) insert(:block, number: 1) + stub(EthereumJSONRPC.Mox, :json_rpc, fn _, _ -> + {:ok, []} + end) + MissingRangesCollector.start_link([]) - start_supervised!({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ReplacedTransaction.Supervisor.Case.start_supervised!() # from `setup :state` @@ -527,16 +540,19 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do Application.put_env(:indexer, :block_ranges, "0..0") MissingRangesCollector.start_link([]) - start_supervised({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ReplacedTransaction.Supervisor.Case.start_supervised!() UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Indexer.Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } ) # from `setup :state` @@ -578,9 +594,14 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do end defp state(%{json_rpc_named_arguments: json_rpc_named_arguments}) do + start_supervised!({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) + {:ok, state} = Catchup.BoundIntervalSupervisor.init(%{ - block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Indexer.Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } }) %{state: state} @@ -592,7 +613,14 @@ defmodule Indexer.Block.Catchup.BoundIntervalSupervisorTest do pid = start_supervised!( {Catchup.BoundIntervalSupervisor, - [%{block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments}}]} + [ + %{ + block_fetcher: %Indexer.Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } + } + ]} ) {:ok, %{pid: pid}} diff --git a/apps/indexer/test/indexer/block/catchup/fetcher_test.exs b/apps/indexer/test/indexer/block/catchup/fetcher_test.exs index 99b014a4788c..983f8a29875b 100644 --- a/apps/indexer/test/indexer/block/catchup/fetcher_test.exs +++ b/apps/indexer/test/indexer/block/catchup/fetcher_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.FetcherTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase @@ -8,14 +9,15 @@ defmodule Indexer.Block.Catchup.FetcherTest do alias Explorer.Chain alias Explorer.Chain.Block.Reward alias Explorer.Chain.Hash - alias Explorer.Utility.MissingRangesManipulator alias Explorer.Utility.MissingBlockRange alias Indexer.Block alias Indexer.Block.Catchup.Fetcher alias Indexer.Block.Catchup.MissingRangesCollector alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup - alias Indexer.Fetcher.{BlockReward, InternalTransaction, Token, TokenBalance, UncleBlock} + alias Indexer.Fetcher.{BlockReward, InternalTransaction, Token, UncleBlock} alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical @moduletag capture_log: true @@ -54,14 +56,14 @@ defmodule Indexer.Block.Catchup.FetcherTest do CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case.start_supervised!( json_rpc_named_arguments: json_rpc_named_arguments ) MissingRangesCollector.start_link([]) - MissingRangesManipulator.start_link([]) parent = self() @@ -85,62 +87,65 @@ defmodule Indexer.Block.Catchup.FetcherTest do block_number = 0 assert {:ok, _} = - Fetcher.import(%Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments}, %{ - addresses: %{ - params: [ - %{hash: miner_hash} - ] - }, - address_hash_to_fetched_balance_block_number: %{miner_hash => block_number}, - address_coin_balances: %{ - params: [ - %{ - address_hash: miner_hash, - block_number: block_number - } - ] - }, - blocks: %{ - params: [ - %{ - difficulty: 0, - gas_limit: 21000, - gas_used: 21000, - miner_hash: miner_hash, - nonce: 0, - number: block_number, - parent_hash: - block_hash() - |> to_string(), - size: 0, - timestamp: DateTime.utc_now(), - total_difficulty: 0, - hash: nephew_hash - } - ] - }, - block_rewards: %{errors: [], params: []}, - block_second_degree_relations: %{ - params: [ - %{ - nephew_hash: nephew_hash, - uncle_hash: uncle_hash, - index: nephew_index - } - ] - }, - tokens: %{ - params: [], - on_conflict: :nothing - }, - address_token_balances: %{ - params: [] - }, - transactions: %{ - params: [], - on_conflict: :nothing + Fetcher.import( + %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments, task_supervisor: nil}, + %{ + addresses: %{ + params: [ + %{hash: miner_hash} + ] + }, + address_hash_to_fetched_balance_block_number: %{miner_hash => block_number}, + address_coin_balances: %{ + params: [ + %{ + address_hash: miner_hash, + block_number: block_number + } + ] + }, + blocks: %{ + params: [ + %{ + difficulty: 0, + gas_limit: 21000, + gas_used: 21000, + miner_hash: miner_hash, + nonce: 0, + number: block_number, + parent_hash: + block_hash() + |> to_string(), + size: 0, + timestamp: DateTime.utc_now(), + total_difficulty: 0, + hash: nephew_hash + } + ] + }, + block_rewards: %{errors: [], params: []}, + block_second_degree_relations: %{ + params: [ + %{ + nephew_hash: nephew_hash, + uncle_hash: uncle_hash, + index: nephew_index + } + ] + }, + tokens: %{ + params: [], + on_conflict: :nothing + }, + address_token_balances: %{ + params: [] + }, + transactions: %{ + params: [], + on_conflict: :nothing + } } - }) + ) assert_receive {:uncles, [{^nephew_hash_bytes, ^nephew_index}]} end @@ -184,9 +189,9 @@ defmodule Indexer.Block.Catchup.FetcherTest do CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) MissingRangesCollector.start_link([]) - MissingRangesManipulator.start_link([]) latest_block_number = 2 latest_block_quantity = integer_to_quantity(latest_block_number) @@ -325,7 +330,8 @@ defmodule Indexer.Block.Catchup.FetcherTest do Fetcher.task(%Fetcher{ block_fetcher: %Block.Fetcher{ callback_module: Fetcher, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor } }) @@ -344,9 +350,9 @@ defmodule Indexer.Block.Catchup.FetcherTest do CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) MissingRangesCollector.start_link([]) - MissingRangesManipulator.start_link([]) latest_block_number = 2 latest_block_quantity = integer_to_quantity(latest_block_number) @@ -480,7 +486,8 @@ defmodule Indexer.Block.Catchup.FetcherTest do Fetcher.task(%Fetcher{ block_fetcher: %Block.Fetcher{ callback_module: Fetcher, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor } }) @@ -501,9 +508,9 @@ defmodule Indexer.Block.Catchup.FetcherTest do CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) MissingRangesCollector.start_link([]) - MissingRangesManipulator.start_link([]) latest_block_number = 2 latest_block_quantity = integer_to_quantity(latest_block_number) @@ -630,7 +637,8 @@ defmodule Indexer.Block.Catchup.FetcherTest do Fetcher.task(%Fetcher{ block_fetcher: %Block.Fetcher{ callback_module: Fetcher, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor } }) @@ -646,7 +654,6 @@ defmodule Indexer.Block.Catchup.FetcherTest do Application.put_env(:indexer, :block_ranges, "0..1") start_supervised!({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) MissingRangesCollector.start_link([]) - MissingRangesManipulator.start_link([]) EthereumJSONRPC.Mox |> expect(:json_rpc, 1, fn @@ -686,7 +693,8 @@ defmodule Indexer.Block.Catchup.FetcherTest do Fetcher.task(%Fetcher{ block_fetcher: %Block.Fetcher{ callback_module: Fetcher, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor } }) @@ -694,6 +702,168 @@ defmodule Indexer.Block.Catchup.FetcherTest do assert %{from_number: 1, to_number: 0} = Repo.one(MissingBlockRange) end + + if Application.compile_env(:explorer, :chain_type) == :stability do + test "update stability validator counter", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + Application.put_env(:indexer, Indexer.Block.Catchup.Fetcher, batch_size: 1, concurrency: 10) + Application.put_env(:indexer, :block_ranges, "1..2") + start_supervised!({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) + CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + MissingRangesCollector.start_link([]) + + latest_block_number = 3 + latest_block_quantity = integer_to_quantity(latest_block_number) + + block_number = latest_block_number - 1 + block_hash = block_hash() + block_hash_1 = block_hash() + block_quantity = integer_to_quantity(block_number) + block_quantity_1 = integer_to_quantity(block_number - 1) + + validator = insert(:validator_stability) + miner_hash_data = to_string(validator.address_hash) + miner_hash_0 = address_hash() + miner_hash_0_data = to_string(miner_hash_0) + + new_block_hash = block_hash() + + refute block_hash == new_block_hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 4, fn + %{method: "eth_getBlockByNumber", params: ["latest", false]}, _options -> + {:ok, %{"number" => latest_block_quantity}} + + [ + %{ + id: id, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: [^block_quantity, true] + } + ], + _options -> + {:ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: %{ + "hash" => to_string(block_hash), + "number" => block_quantity, + "difficulty" => "0x0", + "gasLimit" => "0x0", + "gasUsed" => "0x0", + "extraData" => "0x0", + "logsBloom" => "0x0", + "miner" => miner_hash_data, + "parentHash" => + block_hash() + |> to_string(), + "receiptsRoot" => "0x0", + "size" => "0x0", + "sha3Uncles" => "0x0", + "stateRoot" => "0x0", + "timestamp" => "0x0", + "totalDifficulty" => "0x0", + "transactions" => [], + "transactionsRoot" => "0x0", + "uncles" => [] + } + } + ]} + + [%{id: id, jsonrpc: "2.0", method: "trace_block", params: [^block_quantity]}], _options -> + { + :ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: [] + } + ] + } + + [%{id: id, jsonrpc: "2.0", method: "trace_block", params: [^block_quantity_1]}], _options -> + { + :ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: [] + } + ] + } + + [ + %{ + id: id, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: [^block_quantity_1, true] + } + ], + _options -> + {:ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: %{ + "hash" => to_string(block_hash_1), + "number" => block_quantity_1, + "difficulty" => "0x0", + "gasLimit" => "0x0", + "gasUsed" => "0x0", + "extraData" => "0x0", + "logsBloom" => "0x0", + "miner" => miner_hash_data, + "parentHash" => + block_hash() + |> to_string(), + "receiptsRoot" => "0x0", + "size" => "0x0", + "sha3Uncles" => "0x0", + "stateRoot" => "0x0", + "timestamp" => "0x0", + "totalDifficulty" => "0x0", + "transactions" => [], + "transactionsRoot" => "0x0", + "uncles" => [] + } + } + ]} + end) + + assert count(Chain.Block) == 0 + + Process.sleep(50) + + assert %{first_block_number: ^block_number, last_block_number: 1, missing_block_count: 2, shrunk: false} = + Fetcher.task(%Fetcher{ + block_fetcher: %Block.Fetcher{ + callback_module: Fetcher, + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.Block.Catchup.TaskSupervisor + } + }) + + Process.sleep(3000) + + assert count(Chain.Block) == 2 + + validator_from_db = Repo.get!(Explorer.Chain.Stability.Validator, validator.address_hash) + assert validator_from_db.blocks_validated == validator.blocks_validated + 2 + end + end end defp count(schema) do diff --git a/apps/indexer/test/indexer/block/catchup/massive_blocks_fetcher_test.exs b/apps/indexer/test/indexer/block/catchup/massive_blocks_fetcher_test.exs new file mode 100644 index 000000000000..41f926074969 --- /dev/null +++ b/apps/indexer/test/indexer/block/catchup/massive_blocks_fetcher_test.exs @@ -0,0 +1,281 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Block.Catchup.MassiveBlocksFetcherTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import EthereumJSONRPC, only: [integer_to_quantity: 1] + import Mox + + alias Indexer.Block.Catchup.MassiveBlocksFetcher + alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical + alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand + + alias Indexer.Fetcher.{ + ContractCode, + InternalTransaction, + ReplacedTransaction, + Token + } + + alias Explorer.Chain.Block + alias Explorer.Utility.{MassiveBlock, MissingBlockRange} + + setup :set_mox_global + + setup :verify_on_exit! + + setup do + old_celo_env = Application.get_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, []) + + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, + contracts: %{ + "addresses" => %{ + "Accounts" => [], + "Election" => [], + "EpochRewards" => [], + "FeeHandler" => [], + "GasPriceMinimum" => [], + "GoldToken" => [], + "Governance" => [], + "LockedGold" => [], + "Reserve" => [], + "StableToken" => [], + "Validators" => [] + } + } + ) + + on_exit(fn -> + Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, old_celo_env) + end) + end + + test "successfully imports block", %{json_rpc_named_arguments: json_rpc_named_arguments} do + %{number: block_number} = insert(:massive_block) + block_quantity = integer_to_quantity(block_number) + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + case Keyword.fetch!(json_rpc_named_arguments, :variant) do + EthereumJSONRPC.Nethermind -> + EthereumJSONRPC.Mox + |> stub(:json_rpc, fn + [%{id: id, method: "eth_getBlockByNumber", params: ["latest", false]}], _options -> + {:ok, + [ + %{ + id: id, + result: %{ + "author" => "0xe2ac1c6843a33f81ae4935e5ef1277a392990381", + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0xd583010a068650617269747986312e32362e32826c69", + "gasLimit" => "0x7a1200", + "gasUsed" => "0x0", + "hash" => "0x627baabf5a17c0cfc547b6903ac5e19eaa91f30d9141be1034e3768f6adbc94e", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0xe2ac1c6843a33f81ae4935e5ef1277a392990381", + "number" => block_quantity, + "parentHash" => "0x006edcaa1e6fde822908783bc4ef1ad3675532d542fce53537557391cfe34c3c", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sealFields" => [ + "0x841240b30d", + "0xb84158bc4fa5891934bc94c5dca0301867ce4f35925ef46ea187496162668210bba61b4cda09d7e0dca2f1dd041fad498ced6697aeef72656927f52c55b630f2591c01" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "58bc4fa5891934bc94c5dca0301867ce4f35925ef46ea187496162668210bba61b4cda09d7e0dca2f1dd041fad498ced6697aeef72656927f52c55b630f2591c01", + "size" => "0x243", + "stateRoot" => "0x9a8111062667f7b162851a1cbbe8aece5ff12e761b3dcee93b787fcc12548cf7", + "step" => "306230029", + "timestamp" => "0x5b437f41", + "totalDifficulty" => "0x342337ffffffffffffffffffffffffed8d29bb", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + + %{method: "eth_getBlockByNumber", params: ["latest", false]}, _options -> + {:ok, + [ + %{ + "difficulty" => "0xc2550dc5bfc5d", + "extraData" => "0x65746865726d696e652d657538", + "gasLimit" => "0x7a121d", + "gasUsed" => "0x6cc04b", + "hash" => "0x71f484056fec687fd469989426c94c469ff08a28eae9a1865359d64557bb99f6", + "logsBloom" => + "0x900840000041000850020000002800020800840900200210041006005028810880231200c1a0800001003a00011813005102000020800207080210000020014c00888640001040300c180008000084001000010018010040001118181400a06000280428024010081100015008080814141000644404040a8021101010040001001022000000000880420004008000180004000a01002080890010000a0601001a0000410244421002c0000100920100020004000020c10402004080008000203001000200c4001a000002000c0000000100200410090bc52e080900108230000110010082120200000004e01002000500001009e14001002051000040830080", + "miner" => "0xea674fdde714fd979de3edf0f56aa9716b898ec8", + "mixHash" => "0x555275cd0ab4c3b2fe3936843ee25bb67da05ef7dcf17216bc0e382d21d139a0", + "nonce" => "0xa49e42a024600113", + "number" => block_quantity, + "parentHash" => "0xb4357733c59cc6f785542d072a205f4e195f7198f544ea5e01c1b90ef0f914a5", + "receiptsRoot" => "0x17baf8de366fecc1be494bff245be6357ac60a5fe786099dba89983778c8421e", + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "size" => "0x6c7b", + "stateRoot" => "0x79345c692a0bf363e95c37750336c534309b3f3fe8b59712ac1527118070f488", + "timestamp" => "0x5b475377", + "totalDifficulty" => "0x120258e22c69502fc88", + "transactions" => ["0xa4b58d1d1473f4891d9ff91f624dba73611bf1f6e9a60d3ca2dcfc75d2ab185c"], + "transactionsRoot" => "0x5972b7988f667d7e86679322641117e503ea2c1bc5a27822a8a8120fe53f2c8b", + "uncles" => [] + } + ]} + + [%{id: id, method: "eth_getBlockByNumber", params: [^block_quantity, true]}], _options -> + {:ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: %{ + "author" => "0xe2ac1c6843a33f81ae4935e5ef1277a392990381", + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0xd583010a068650617269747986312e32362e32826c69", + "gasLimit" => "0x7a1200", + "gasUsed" => "0x0", + "hash" => "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0xe2ac1c6843a33f81ae4935e5ef1277a392990381", + "number" => block_quantity, + "parentHash" => "0x006edcaa1e6fde822908783bc4ef1ad3675532d542fce53537557391cfe34c3c", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sealFields" => [ + "0x841240b30d", + "0xb84158bc4fa5891934bc94c5dca0301867ce4f35925ef46ea187496162668210bba61b4cda09d7e0dca2f1dd041fad498ced6697aeef72656927f52c55b630f2591c01" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "58bc4fa5891934bc94c5dca0301867ce4f35925ef46ea187496162668210bba61b4cda09d7e0dca2f1dd041fad498ced6697aeef72656927f52c55b630f2591c01", + "size" => "0x243", + "stateRoot" => "0x9a8111062667f7b162851a1cbbe8aece5ff12e761b3dcee93b787fcc12548cf7", + "step" => "306230029", + "timestamp" => "0x5b437f41", + "totalDifficulty" => "0x342337ffffffffffffffffffffffffed8d29bb", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + end) + + EthereumJSONRPC.Geth -> + EthereumJSONRPC.Mox + |> stub(:json_rpc, fn + %{method: "eth_getBlockByNumber", params: ["latest", false]}, _options -> + {:ok, + %{ + "difficulty" => "0xc2550dc5bfc5d", + "extraData" => "0x65746865726d696e652d657538", + "gasLimit" => "0x7a121d", + "gasUsed" => "0x6cc04b", + "hash" => "0x71f484056fec687fd469989426c94c469ff08a28eae9a1865359d64557bb99f6", + "logsBloom" => + "0x900840000041000850020000002800020800840900200210041006005028810880231200c1a0800001003a00011813005102000020800207080210000020014c00888640001040300c180008000084001000010018010040001118181400a06000280428024010081100015008080814141000644404040a8021101010040001001022000000000880420004008000180004000a01002080890010000a0601001a0000410244421002c0000100920100020004000020c10402004080008000203001000200c4001a000002000c0000000100200410090bc52e080900108230000110010082120200000004e01002000500001009e14001002051000040830080", + "miner" => "0xea674fdde714fd979de3edf0f56aa9716b898ec8", + "mixHash" => "0x555275cd0ab4c3b2fe3936843ee25bb67da05ef7dcf17216bc0e382d21d139a0", + "nonce" => "0xa49e42a024600113", + "number" => block_quantity, + "parentHash" => "0xb4357733c59cc6f785542d072a205f4e195f7198f544ea5e01c1b90ef0f914a5", + "receiptsRoot" => "0x17baf8de366fecc1be494bff245be6357ac60a5fe786099dba89983778c8421e", + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "size" => "0x6c7b", + "stateRoot" => "0x79345c692a0bf363e95c37750336c534309b3f3fe8b59712ac1527118070f488", + "timestamp" => "0x5b475377", + "totalDifficulty" => "0x120258e22c69502fc88", + "transactions" => ["0xa4b58d1d1473f4891d9ff91f624dba73611bf1f6e9a60d3ca2dcfc75d2ab185c"], + "transactionsRoot" => "0x5972b7988f667d7e86679322641117e503ea2c1bc5a27822a8a8120fe53f2c8b", + "uncles" => [] + }} + + [%{id: id, method: "eth_getBlockByNumber", params: [^block_quantity, true]}], _options -> + {:ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: %{ + "difficulty" => "0xc22479024e55f", + "extraData" => "0x73656f3130", + "gasLimit" => "0x7a121d", + "gasUsed" => "0x7a0527", + "hash" => "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + "logsBloom" => + "0x006a044c050a6759208088200009808898246808402123144ac15801c09a2672990130000042500000cc6090b063f195352095a88018194112101a02640000a0109c03c40568440b853a800a60044408604bb49d1d604c802008000884520208496608a520992e0f4b41a94188088920c1995107db4696c03839a911500084001009884100605084c4542953b08101103080254c34c802a00042a62f811340400d22080d000c0e39927ca481800c8024048425462000150850500205a224810041904023a80c00dc01040203000086020111210403081096822008c12500a2060a54834800400851210122c481a04a24b5284e9900a08110c180011001c03100", + "miner" => "0xb2930b35844a230f00e51431acae96fe543a0347", + "mixHash" => "0x5e07a58028d2cee7ddbefe245e6d7b5232d997b66cc906b18ad9ad51535ced24", + "nonce" => "0x3d88ebe8031aadf6", + "number" => block_quantity, + "parentHash" => "0x006edcaa1e6fde822908783bc4ef1ad3675532d542fce53537557391cfe34c3c", + "receiptsRoot" => "0x5294a8b56be40c0c198aa443664e801bb926d49878f96151849f3ddd0cb5e76d", + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "size" => "0x4796", + "stateRoot" => "0x3755d4b5c9ae3cd58d7a856a46fbe8fb69f0ba93d81e831cd68feb8b61bc3009", + "timestamp" => "0x5b475393", + "totalDifficulty" => "0x120259a450e2527e1e7", + "transactions" => [], + "transactionsRoot" => "0xa71969ed649cd1f21846ab7b4029e79662941cc34cd473aa4590e666920ad2f4", + "uncles" => [] + } + } + ]} + end) + + variant_name -> + raise ArgumentError, "Unsupported variant name (#{variant_name})" + end + end + + start_supervised!({Task.Supervisor, name: Indexer.Block.Catchup.TaskSupervisor}) + ContractCreatorOnDemand.start_link([[], []]) + CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + ReplacedTransaction.Supervisor.Case.start_supervised!() + + Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case.start_supervised!( + json_rpc_named_arguments: json_rpc_named_arguments + ) + + MassiveBlocksFetcher.start_link(%{task_supervisor: Indexer.Block.Catchup.TaskSupervisor}) + + wait_until(:timer.seconds(10), fn -> + match?([%{number: ^block_number}], Repo.all(Block)) and + Repo.all(MassiveBlock) == [] and + Repo.all(MissingBlockRange) == [] + end) + + assert [%{number: ^block_number}] = Repo.all(Block) + assert [] = Repo.all(MassiveBlock) + assert [] = Repo.all(MissingBlockRange) + end + + defp wait_until(timeout, producer) do + parent = self() + ref = make_ref() + + spawn(fn -> do_wait_until(parent, ref, producer) end) + + receive do + {^ref, :ok} -> :ok + after + timeout -> exit(:timeout) + end + end + + defp do_wait_until(parent, ref, producer) do + if producer.() do + send(parent, {ref, :ok}) + else + :timer.sleep(100) + do_wait_until(parent, ref, producer) + end + end +end diff --git a/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs b/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs index dea7a91791ec..fd86c68826ce 100644 --- a/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs +++ b/apps/indexer/test/indexer/block/catchup/missing_ranges_collector_test.exs @@ -1,9 +1,14 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do use Explorer.DataCase, async: false + import Mox + alias Explorer.Utility.MissingBlockRange alias Indexer.Block.Catchup.MissingRangesCollector + setup :set_mox_global + describe "default_init" do setup do initial_env = Application.get_all_env(:indexer) @@ -11,6 +16,11 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do end test "empty envs" do + stub(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, method: "eth_getBlockByNumber", params: ["latest", false]}], + _options -> + block_response(id, 1_000_000) + end) + insert(:block, number: 1_000_000) insert(:block, number: 500_123) MissingRangesCollector.start_link([]) @@ -23,6 +33,11 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do assert [999_799..999_700//-1] = batch = MissingBlockRange.get_latest_batch(100) MissingBlockRange.clear_batch(batch) + stub(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, method: "eth_getBlockByNumber", params: ["latest", false]}], + _options -> + block_response(id, 1_000_200) + end) + insert(:block, number: 1_000_200) Process.sleep(1000) @@ -41,6 +56,11 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do end test "infinite range" do + stub(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, method: "eth_getBlockByNumber", params: ["latest", false]}], + _options -> + block_response(id, 200) + end) + Application.put_env(:indexer, :block_ranges, "1..5,3..5,2qw1..12,10..11a,,asd..qwe,10..latest") insert(:block, number: 200) @@ -96,4 +116,33 @@ defmodule Indexer.Block.Catchup.MissingRangesCollectorTest do assert MissingRangesCollector.parse_block_ranges("10..20,5..15,18..25,35..40,30..50,150..200") == {:finite_ranges, [5..25, 30..50, 150..200]} end + + defp block_response(id, block_number) do + {:ok, + [ + %{ + id: id, + result: %{ + "difficulty" => "0x0", + "gasLimit" => "0x0", + "gasUsed" => "0x0", + "hash" => "0x29c850324e357f3c0c836d79860c5af55f7b651e5d7ee253c1af1b14908af49c", + "extraData" => "0x0", + "logsBloom" => "0x0", + "miner" => "0x0", + "number" => block_number, + "parentHash" => "0x0", + "receiptsRoot" => "0x0", + "size" => "0x0", + "sha3Uncles" => "0x0", + "stateRoot" => "0x0", + "timestamp" => "0x0", + "totalDifficulty" => "0x0", + "transactions" => [], + "transactionsRoot" => "0x0", + "uncles" => [] + } + } + ]} + end end diff --git a/apps/indexer/test/indexer/block/fetcher/receipts_test.exs b/apps/indexer/test/indexer/block/fetcher/receipts_test.exs index a7c45106551e..9bc1f4adc384 100644 --- a/apps/indexer/test/indexer/block/fetcher/receipts_test.exs +++ b/apps/indexer/test/indexer/block/fetcher/receipts_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Fetcher.ReceiptsTest do use EthereumJSONRPC.Case, async: false @@ -14,9 +15,12 @@ defmodule Indexer.Block.Fetcher.ReceiptsTest do describe "fetch/2" do setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + %{ block_fetcher: %Fetcher{ json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.TaskSupervisor, receipts_concurrency: 10 } } diff --git a/apps/indexer/test/indexer/block/fetcher_test.exs b/apps/indexer/test/indexer/block/fetcher_test.exs index 49d0b0745268..948172f107f9 100644 --- a/apps/indexer/test/indexer/block/fetcher_test.exs +++ b/apps/indexer/test/indexer/block/fetcher_test.exs @@ -1,24 +1,30 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.FetcherTest do # `async: false` due to use of named GenServer use EthereumJSONRPC.Case, async: false use Explorer.DataCase + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import Mox import EthereumJSONRPC, only: [integer_to_quantity: 1] alias Explorer.Chain alias Explorer.Chain.{Address, Log, Transaction, Wei} + # alias Explorer.TestHelper alias Indexer.Block.Fetcher alias Indexer.BufferedTask alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical alias Indexer.Fetcher.{ ContractCode, InternalTransaction, ReplacedTransaction, Token, - TokenBalance, UncleBlock } @@ -65,12 +71,18 @@ defmodule Indexer.Block.FetcherTest do ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ReplacedTransaction.Supervisor.Case.start_supervised!() {:ok, _pid} = ContractCreatorOnDemand.start_link([[], []]) + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.TaskSupervisor + } ) Application.put_env(:indexer, Indexer.Fetcher.Celo.EpochBlockOperations.Supervisor, disabled?: true) @@ -81,7 +93,8 @@ defmodule Indexer.Block.FetcherTest do block_fetcher: %Fetcher{ broadcast: false, callback_module: Indexer.Block.Catchup.Fetcher, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.TaskSupervisor } } end @@ -96,7 +109,7 @@ defmodule Indexer.Block.FetcherTest do # block_quantity = integer_to_quantity(block_number) # miner_hash = "0x0000000000000000000000000000000000000000" - # res = eth_block_number_fake_response(block_quantity) + # res = TestHelper.eth_block_number_fake_response(block_quantity) # case Keyword.fetch!(json_rpc_named_arguments, :variant) do # EthereumJSONRPC.Nethermind -> @@ -286,11 +299,20 @@ defmodule Indexer.Block.FetcherTest do test "can import range with all synchronous imported schemas", %{ block_fetcher: %Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} = block_fetcher } do + initial_env = Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction, initial_env) + end) + + Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction, disabled?: false) block_number = @first_full_block_number - Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case.start_supervised!( - json_rpc_named_arguments: json_rpc_named_arguments - ) + if Application.get_env(:explorer, :chain_type) == :filecoin do + Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case.start_supervised!( + json_rpc_named_arguments: json_rpc_named_arguments + ) + end if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do case Keyword.fetch!(json_rpc_named_arguments, :variant) do @@ -748,9 +770,6 @@ defmodule Indexer.Block.FetcherTest do } %{id: id, method: "trace_block"} -> - block_quantity = integer_to_quantity(block_number) - _res = eth_block_number_fake_response(block_quantity) - %{ id: id, result: [ @@ -803,18 +822,27 @@ defmodule Indexer.Block.FetcherTest do end end - if Application.compile_env(:explorer, :chain_type) == :celo do + if @chain_identity == {:optimism, :celo} do + alias Explorer.Chain.Celo.Legacy.Events + alias Explorer.Chain.Celo.PendingAccountOperation + describe "import_range/2 celo" do setup %{json_rpc_named_arguments: json_rpc_named_arguments} do CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) ReplacedTransaction.Supervisor.Case.start_supervised!() + {:ok, _pid} = ContractCreatorOnDemand.start_link([[], []]) + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.TaskSupervisor + } ) Application.put_env(:indexer, Indexer.Fetcher.Celo.EpochBlockOperations.Supervisor, disabled?: true) @@ -825,7 +853,8 @@ defmodule Indexer.Block.FetcherTest do block_fetcher: %Fetcher{ broadcast: false, callback_module: Indexer.Block.Catchup.Fetcher, - json_rpc_named_arguments: json_rpc_named_arguments + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: Indexer.TaskSupervisor } } end @@ -834,6 +863,7 @@ defmodule Indexer.Block.FetcherTest do block_fetcher: %Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} = block_fetcher } do block_number = @first_full_block_number + accounts_contract_address = "0x000000000000000000000000000000000000ce10" if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do case Keyword.fetch!(json_rpc_named_arguments, :variant) do @@ -844,6 +874,23 @@ defmodule Indexer.Block.FetcherTest do transaction_hash = "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5" gas_token_contract_address_hash = "0x471ece3750da237f93b8e339c536989b8978a438" + account_event_topic = hd(Events.account_events()) + pending_address = to_address_hash + pending_suffix = String.slice(pending_address, 2, 40) + pending_topic = "0x000000000000000000000000" <> pending_suffix + + account_event_log = %{ + "address" => accounts_contract_address, + "blockHash" => "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", + "blockNumber" => "0x25", + "data" => "0x", + "logIndex" => "0x1", + "topics" => [account_event_topic, pending_topic], + "transactionHash" => transaction_hash, + "transactionIndex" => "0x0", + "transactionLogIndex" => "0x1" + } + transaction = %{ "blockHash" => "0xf6b4b8c88df3ebd252ec476328334dc026cf66606a84fb769b3d3cbccc8471bd", "blockNumber" => "0x25", @@ -946,7 +993,8 @@ defmodule Indexer.Block.FetcherTest do "transactionHash" => "0x53bd884872de3e488692881baeec262e7b95234d3965248c39fe992fffd433e5", "transactionIndex" => "0x0", "transactionLogIndex" => "0x0" - } + }, + account_event_log ], "logsBloom" => "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000200000000000000000000020000000000000000200000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", @@ -963,7 +1011,7 @@ defmodule Indexer.Block.FetcherTest do end) # async requests need to be grouped in one expect because the order is non-deterministic while multiple expect # calls on the same name/arity are used in order - |> expect(:json_rpc, 5, fn json, _options -> + |> expect(:json_rpc, 7, fn json, _options -> case json do [ %{ @@ -1016,6 +1064,12 @@ defmodule Indexer.Block.FetcherTest do [%{id: id, method: "eth_getBalance", params: [^from_address_hash, ^block_quantity]}] -> {:ok, [%{id: id, jsonrpc: "2.0", result: "0xd0d4a965ab52d8cd740000"}]} + [%{id: id, method: "eth_getBalance", params: [^accounts_contract_address, ^block_quantity]}] -> + {:ok, [%{id: id, jsonrpc: "2.0", result: "0xd0d4a965ab52d8cd740000"}]} + + [%{id: id, method: "eth_getBalance", params: [_, ^block_quantity]}] -> + {:ok, [%{id: id, jsonrpc: "2.0", result: "0x0"}]} + [%{id: id, method: "trace_replayBlockTransactions", params: [^block_quantity, ["trace"]]}] -> {:ok, [ @@ -1087,14 +1141,34 @@ defmodule Indexer.Block.FetcherTest do end end + account_supervisor_configuration = + Application.get_env(:indexer, Indexer.Fetcher.Celo.Legacy.Account.Supervisor) + + Application.put_env(:indexer, Indexer.Fetcher.Celo.Legacy.Account.Supervisor, disabled?: true) + + on_exit(fn -> + if account_supervisor_configuration do + Application.put_env( + :indexer, + Indexer.Fetcher.Celo.Legacy.Account.Supervisor, + account_supervisor_configuration + ) + else + Application.delete_env(:indexer, Indexer.Fetcher.Celo.Legacy.Account.Supervisor) + end + end) + case Keyword.fetch!(json_rpc_named_arguments, :variant) do EthereumJSONRPC.Nethermind -> gateway_fee_value = Decimal.new(0) + {:ok, accounts_contract_address} = Explorer.Chain.Hash.Address.cast(accounts_contract_address) + assert {:ok, %{ inserted: %{ addresses: [ + %Address{hash: ^accounts_contract_address}, %Address{ hash: %Explorer.Chain.Hash{ @@ -1133,6 +1207,15 @@ defmodule Indexer.Block.FetcherTest do <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> } + }, + %Log{ + index: 1, + transaction_hash: %Explorer.Chain.Hash{ + byte_count: 32, + bytes: + <<83, 189, 136, 72, 114, 222, 62, 72, 134, 146, 136, 27, 174, 236, 38, 46, 123, 149, 35, + 77, 57, 101, 36, 140, 57, 254, 153, 47, 255, 212, 51, 229>> + } } ], transactions: [ @@ -1154,6 +1237,16 @@ defmodule Indexer.Block.FetcherTest do gas_fee_recipient_address_hash: nil, gateway_fee: %Explorer.Chain.Wei{value: ^gateway_fee_value} } + ], + celo_pending_account_operations: [ + %PendingAccountOperation{ + address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: + <<139, 243, 141, 71, 100, 146, 144, 100, 242, 212, 211, 165, 101, 32, 167, 106, 179, + 223, 65, 91>> + } + } ] }, errors: [] @@ -1169,9 +1262,10 @@ defmodule Indexer.Block.FetcherTest do wait_for_tasks(CoinBalanceCatchup) assert Repo.aggregate(Chain.Block, :count, :hash) == 1 - assert Repo.aggregate(Address, :count, :hash) == 2 - assert Repo.aggregate(Log, :count) == 1 + assert Repo.aggregate(Address, :count, :hash) == 3 + assert Repo.aggregate(Log, :count) == 2 assert Repo.aggregate(Transaction, :count, :hash) == 1 + assert Repo.aggregate(PendingAccountOperation, :count, :address_hash) == 1 first_address = Repo.get!(Address, first_address_hash) @@ -1219,42 +1313,6 @@ defmodule Indexer.Block.FetcherTest do end) end - defp eth_block_number_fake_response(block_quantity) do - %{ - id: 0, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] - } - } - end - def maybe_set_celo_core_contracts_env do Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, contracts: %{ diff --git a/apps/indexer/test/indexer/block/realtime/fetcher_test.exs b/apps/indexer/test/indexer/block/realtime/fetcher_test.exs index 4cf0493336bf..44ad6c2d0fc9 100644 --- a/apps/indexer/test/indexer/block/realtime/fetcher_test.exs +++ b/apps/indexer/test/indexer/block/realtime/fetcher_test.exs @@ -1,7 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Realtime.FetcherTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase + use Utils.RuntimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import Mox alias Explorer.{Chain, Factory} @@ -14,11 +18,12 @@ defmodule Indexer.Block.Realtime.FetcherTest do InternalTransaction, ReplacedTransaction, Token, - TokenBalance, UncleBlock } alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical @moduletag capture_log: true @@ -39,13 +44,17 @@ defmodule Indexer.Block.Realtime.FetcherTest do trace_block: "http://54.144.107.14:8545" ) + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + block_fetcher = %Indexer.Block.Fetcher{ broadcast: false, callback_module: Realtime.Fetcher, - json_rpc_named_arguments: core_json_rpc_named_arguments + json_rpc_named_arguments: core_json_rpc_named_arguments, + task_supervisor: Indexer.TaskSupervisor } - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) CoinBalanceRealtime.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) {:ok, _pid} = ContractCreatorOnDemand.start_link([[], []]) @@ -88,9 +97,7 @@ defmodule Indexer.Block.Realtime.FetcherTest do InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} - ) + UncleBlock.Supervisor.Case.start_supervised!(block_fetcher: block_fetcher) ReplacedTransaction.Supervisor.Case.start_supervised!() @@ -568,7 +575,7 @@ defmodule Indexer.Block.Realtime.FetcherTest do errors: [] }} = Indexer.Block.Fetcher.fetch_and_import_range(block_fetcher, 3_946_079..3_946_080) - unless Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() != {:optimism, :celo} do assert [ %Address{hash: ^first_address_hash}, %Address{hash: ^second_address_hash}, @@ -599,9 +606,7 @@ defmodule Indexer.Block.Realtime.FetcherTest do InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Indexer.Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} - ) + UncleBlock.Supervisor.Case.start_supervised!(block_fetcher: block_fetcher) Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case.start_supervised!( json_rpc_named_arguments: json_rpc_named_arguments @@ -828,7 +833,7 @@ defmodule Indexer.Block.Realtime.FetcherTest do errors: [] }} = Indexer.Block.Fetcher.fetch_and_import_range(block_fetcher, 3_946_079..3_946_080) - unless Application.get_env(:explorer, :chain_type) == :celo do + if chain_identity() != {:optimism, :celo} do assert [ %Address{hash: ^first_address_hash}, %Address{hash: ^second_address_hash}, @@ -847,6 +852,295 @@ defmodule Indexer.Block.Realtime.FetcherTest do Application.put_env(:indexer, :fetch_rewards_way, nil) end + + if Application.compile_env(:explorer, :chain_type) == :stability do + @tag :no_geth + test "update stability validator counter", %{ + block_fetcher: %Indexer.Block.Fetcher{} = block_fetcher, + json_rpc_named_arguments: json_rpc_named_arguments + } do + Token.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + ContractCode.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + InternalTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + UncleBlock.Supervisor.Case.start_supervised!(block_fetcher: block_fetcher) + + ReplacedTransaction.Supervisor.Case.start_supervised!() + + Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case.start_supervised!( + json_rpc_named_arguments: json_rpc_named_arguments + ) + + # In CELO network, there is a token duality feature where CELO can be used + # as both a native chain currency and as an ERC-20 token (GoldToken). + # Transactions that transfer CELO are also counted as token transfers, and + # the TokenInstance fetcher is called. However, for simplicity, we disable + # it in this test. + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Realtime.Supervisor, disabled?: true) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.Realtime.Supervisor, disabled?: false) + end) + + validator_1 = insert(:validator_stability) + validator_2 = insert(:validator_stability) + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: 0, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["0x3C365F", true] + }, + %{ + id: 1, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["0x3C3660", true] + } + ], + _ -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: %{ + "author" => "0x5ee341ac44d344ade1ca3a771c59b98eb2a77df2", + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0xd583010b088650617269747986312e32372e32826c69", + "gasLimit" => "0x7a1200", + "gasUsed" => "0x2886e", + "hash" => "0xa4ec735cabe1510b5ae081b30f17222580b4588dbec52830529753a688b046cc", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => to_string(validator_1.address_hash), + "number" => "0x3c365f", + "parentHash" => "0x57f6d66e07488defccd5216c4d2968dd6afd3bd32415e284de3b02af6535e8dc", + "receiptsRoot" => "0x111be72e682cea9c93e02f1ef503fb64aa821b2ef510fd9177c49b37d0af98b5", + "sealFields" => [ + "0x841246c63f", + "0xb841ba3d11db672fd7893d1b7906275fa7c4c7f4fbcc8fa29eab0331480332361516545ef10a36d800ad2be2b449dde8d5703125156a9cf8a035f5a8623463e051b700" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "ba3d11db672fd7893d1b7906275fa7c4c7f4fbcc8fa29eab0331480332361516545ef10a36d800ad2be2b449dde8d5703125156a9cf8a035f5a8623463e051b700", + "size" => "0x33e", + "stateRoot" => "0x7f73f5fb9f891213b671356126c31e9795d038844392c7aa8800ed4f52307209", + "step" => "306628159", + "timestamp" => "0x5b61df3b", + "totalDifficulty" => "0x3c365effffffffffffffffffffffffed7f0362", + "transactions" => [], + "transactionsRoot" => "0xd7c39a93eafe0bdcbd1324c13dcd674bed8c9fa8adbf8f95bf6a59788985da6f", + "uncles" => ["0xa4ec735cabe1510b5ae081b30f17222580b4588dbec52830529753a688b046cd"] + } + }, + %{ + id: 1, + jsonrpc: "2.0", + result: %{ + "author" => "0x66c9343c7e8ca673a1fedf9dbf2cd7936dbbf7e3", + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0xd583010a068650617269747986312e32362e32826c69", + "gasLimit" => "0x7a1200", + "gasUsed" => "0x0", + "hash" => "0xfb483e511d316fa4072694da3f7abc94b06286406af45061e5e681395bdc6815", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => to_string(validator_2.address_hash), + "number" => "0x3c3660", + "parentHash" => "0xa4ec735cabe1510b5ae081b30f17222580b4588dbec52830529753a688b046cc", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sealFields" => [ + "0x841246c640", + "0xb84114db3fd7526b7ea3635f5c85c30dd8a645453aa2f8afe5fd33fe0ec663c9c7b653b0fb5d8dc7d0b809674fa9dca9887d1636a586bf62191da22255eb068bf20800" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "14db3fd7526b7ea3635f5c85c30dd8a645453aa2f8afe5fd33fe0ec663c9c7b653b0fb5d8dc7d0b809674fa9dca9887d1636a586bf62191da22255eb068bf20800", + "size" => "0x243", + "stateRoot" => "0x3174c461989e9f99e08fa9b4ffb8bce8d9a281c8fc9f80694bb9d3acd4f15559", + "step" => "306628160", + "timestamp" => "0x5b61df40", + "totalDifficulty" => "0x3c365fffffffffffffffffffffffffed7f0360", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + end) + |> expect(:json_rpc, 1, fn + [ + %{id: 0, jsonrpc: "2.0", method: "trace_block", params: ["0x3C365F"]}, + %{id: 1, jsonrpc: "2.0", method: "trace_block", params: ["0x3C3660"]} + ], + _ -> + {:ok, + [ + %{id: 0, jsonrpc: "2.0", result: []}, + %{id: 1, jsonrpc: "2.0", result: []} + ]} + + [ + %{ + id: 0, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["0x3C365F", true] + } + ], + _ -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: %{ + "author" => "0x5ee341ac44d344ade1ca3a771c59b98eb2a77df2", + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0xd583010b088650617269747986312e32372e32826c69", + "gasLimit" => "0x7a1200", + "gasUsed" => "0x2886e", + "hash" => "0xa4ec735cabe1510b5ae081b30f17222580b4588dbec52830529753a688b046cc", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0x5ee341ac44d344ade1ca3a771c59b98eb2a77df2", + "number" => "0x3c365f", + "parentHash" => "0x57f6d66e07488defccd5216c4d2968dd6afd3bd32415e284de3b02af6535e8dc", + "receiptsRoot" => "0x111be72e682cea9c93e02f1ef503fb64aa821b2ef510fd9177c49b37d0af98b5", + "sealFields" => [ + "0x841246c63f", + "0xb841ba3d11db672fd7893d1b7906275fa7c4c7f4fbcc8fa29eab0331480332361516545ef10a36d800ad2be2b449dde8d5703125156a9cf8a035f5a8623463e051b700" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "ba3d11db672fd7893d1b7906275fa7c4c7f4fbcc8fa29eab0331480332361516545ef10a36d800ad2be2b449dde8d5703125156a9cf8a035f5a8623463e051b700", + "size" => "0x33e", + "stateRoot" => "0x7f73f5fb9f891213b671356126c31e9795d038844392c7aa8800ed4f52307209", + "step" => "306628159", + "timestamp" => "0x5b61df3b", + "totalDifficulty" => "0x3c365effffffffffffffffffffffffed7f0362", + "transactions" => [], + "transactionsRoot" => "0xd7c39a93eafe0bdcbd1324c13dcd674bed8c9fa8adbf8f95bf6a59788985da6f", + "uncles" => ["0xa4ec735cabe1510b5ae081b30f17222580b4588dbec52830529753a688b046cd"] + } + } + ]} + + [ + %{ + id: 0, + jsonrpc: "2.0", + method: "eth_getBlockByNumber", + params: ["0x3C3660", true] + } + ], + _ -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: %{ + "author" => "0x66c9343c7e8ca673a1fedf9dbf2cd7936dbbf7e3", + "difficulty" => "0xfffffffffffffffffffffffffffffffe", + "extraData" => "0xd583010a068650617269747986312e32362e32826c69", + "gasLimit" => "0x7a1200", + "gasUsed" => "0x0", + "hash" => "0xfb483e511d316fa4072694da3f7abc94b06286406af45061e5e681395bdc6815", + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0x66c9343c7e8ca673a1fedf9dbf2cd7936dbbf7e3", + "number" => "0x3c3660", + "parentHash" => "0xa4ec735cabe1510b5ae081b30f17222580b4588dbec52830529753a688b046cc", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sealFields" => [ + "0x841246c640", + "0xb84114db3fd7526b7ea3635f5c85c30dd8a645453aa2f8afe5fd33fe0ec663c9c7b653b0fb5d8dc7d0b809674fa9dca9887d1636a586bf62191da22255eb068bf20800" + ], + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "signature" => + "14db3fd7526b7ea3635f5c85c30dd8a645453aa2f8afe5fd33fe0ec663c9c7b653b0fb5d8dc7d0b809674fa9dca9887d1636a586bf62191da22255eb068bf20800", + "size" => "0x243", + "stateRoot" => "0x3174c461989e9f99e08fa9b4ffb8bce8d9a281c8fc9f80694bb9d3acd4f15559", + "step" => "306628160", + "timestamp" => "0x5b61df40", + "totalDifficulty" => "0x3c365fffffffffffffffffffffffffed7f0360", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + + [ + %{ + id: 0, + jsonrpc: "2.0", + method: "trace_replayBlockTransactions", + params: [ + "0x3C3660", + ["trace"] + ] + }, + %{ + id: 1, + jsonrpc: "2.0", + method: "trace_replayBlockTransactions", + params: [ + "0x3C365F", + ["trace"] + ] + } + ], + _ -> + {:ok, + [ + %{id: 0, jsonrpc: "2.0", result: []}, + %{ + id: 1, + jsonrpc: "2.0", + result: [] + } + ]} + end) + end + + validator_1_address_hash = validator_1.address_hash + validator_2_address_hash = validator_2.address_hash + + assert {:ok, + %{ + inserted: %{ + blocks: [%Chain.Block{number: 3_946_079}, %Chain.Block{number: 3_946_080}], + stability_validators: [ + %Explorer.Chain.Stability.Validator{ + address_hash: ^validator_1_address_hash, + blocks_validated: blocks_validated_1 + }, + %Explorer.Chain.Stability.Validator{ + address_hash: ^validator_2_address_hash, + blocks_validated: blocks_validated_2 + } + ] + }, + errors: [] + }} = Indexer.Block.Fetcher.fetch_and_import_range(block_fetcher, 3_946_079..3_946_080) + + validator_from_db = Repo.get!(Explorer.Chain.Stability.Validator, validator_1.address_hash) + assert validator_from_db.blocks_validated == blocks_validated_1 + assert validator_from_db.blocks_validated == validator_1.blocks_validated + 1 + + validator_from_db = Repo.get!(Explorer.Chain.Stability.Validator, validator_2.address_hash) + assert validator_from_db.blocks_validated == blocks_validated_2 + assert validator_from_db.blocks_validated == validator_2.blocks_validated + 1 + end + end end describe "start_fetch_and_import" do diff --git a/apps/indexer/test/indexer/buffered_task_test.exs b/apps/indexer/test/indexer/buffered_task_test.exs index 22722ccea8bf..f1613cd0831f 100644 --- a/apps/indexer/test/indexer/buffered_task_test.exs +++ b/apps/indexer/test/indexer/buffered_task_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.BufferedTaskTest do use ExUnit.Case @@ -270,11 +271,6 @@ defmodule Indexer.BufferedTaskTest do start_supervised!({Task.Supervisor, name: BufferedTaskSup}) - ShrinkableTask - |> expect(:init, fn initial, reducer, _ -> - Enum.reduce([2, 3, 4], initial, reducer) - end) - assert {:noreply, %BufferedTask{flush_timer: flush_timer}} = BufferedTask.handle_info(:flush, %BufferedTask{ callback_module: ShrinkableTask, @@ -289,14 +285,7 @@ defmodule Indexer.BufferedTaskTest do refute flush_timer == nil - assert_receive {:"$gen_call", from1, {:push_back, [2, 3]}}, @assert_receive_timeout - - GenServer.reply(from1, :ok) - - assert_receive {:"$gen_call", from2, {:push_back, [4]}}, @assert_receive_timeout - - GenServer.reply(from2, :ok) - + assert_receive :initial_stream, @assert_receive_timeout assert_receive :flush, @assert_receive_timeout end end diff --git a/apps/indexer/test/indexer/fetcher/beacon/blob_test.exs b/apps/indexer/test/indexer/fetcher/beacon/blob_test.exs index c0816d017831..6496c3a3e082 100644 --- a/apps/indexer/test/indexer/fetcher/beacon/blob_test.exs +++ b/apps/indexer/test/indexer/fetcher/beacon/blob_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Beacon.BlobTest do use Explorer.DataCase, async: false @@ -45,8 +46,6 @@ defmodule Indexer.Fetcher.Beacon.BlobTest do assert {:error, :not_found} = Reader.blob(blob_c.hash, true) assert {:ok, _} = Reader.blob(blob_d.hash, true) - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - result_ab = """ { "data": [ @@ -79,16 +78,18 @@ defmodule Indexer.Fetcher.Beacon.BlobTest do } """ - Explorer.Mox.HTTPoison - |> expect(:get, 2, fn url -> - case url do - "http://localhost:5052/eth/v1/beacon/blob_sidecars/8269188" -> - {:ok, %HTTPoison.Response{status_code: 200, body: result_c}} + Tesla.Test.expect_tesla_call( + times: 2, + returns: fn %{url: url}, _opts -> + case url do + "http://localhost:5052/eth/v1/beacon/blob_sidecars/8269188" -> + {:ok, %Tesla.Env{status: 200, body: result_c}} - "http://localhost:5052/eth/v1/beacon/blob_sidecars/8269198" -> - {:ok, %HTTPoison.Response{status_code: 200, body: result_ab}} + "http://localhost:5052/eth/v1/beacon/blob_sidecars/8269198" -> + {:ok, %Tesla.Env{status: 200, body: result_ab}} + end end - end) + ) BlobSupervisor.Case.start_supervised!() @@ -100,8 +101,6 @@ defmodule Indexer.Fetcher.Beacon.BlobTest do assert {:ok, _} = Reader.blob(blob_b.hash, true) assert {:ok, _} = Reader.blob(blob_c.hash, true) assert {:ok, _} = Reader.blob(blob_d.hash, true) - - Application.put_env(:explorer, :http_adapter, HTTPoison) end end @@ -116,8 +115,6 @@ defmodule Indexer.Fetcher.Beacon.BlobTest do end test "fetches blobs for block timestamp" do - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - {:ok, now, _} = DateTime.from_iso8601("2024-01-24 00:00:00Z") block_a = insert(:block, timestamp: now) @@ -141,10 +138,16 @@ defmodule Indexer.Fetcher.Beacon.BlobTest do } """ - Explorer.Mox.HTTPoison - |> expect(:get, fn "http://localhost:5052/eth/v1/beacon/blob_sidecars/8269198" -> - {:ok, %HTTPoison.Response{status_code: 200, body: result_a}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:5052/eth/v1/beacon/blob_sidecars/8269198"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: result_a + }} + end + ) BlobSupervisor.Case.start_supervised!() @@ -162,8 +165,6 @@ defmodule Indexer.Fetcher.Beacon.BlobTest do kzg_commitment: ^kzg_commitment_a, kzg_proof: ^kzg_proof_a } = blob - - Application.put_env(:explorer, :http_adapter, HTTPoison) end end end diff --git a/apps/indexer/test/indexer/fetcher/beacon/deposit/status_test.exs b/apps/indexer/test/indexer/fetcher/beacon/deposit/status_test.exs new file mode 100644 index 000000000000..7ad7909b8c44 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/beacon/deposit/status_test.exs @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Beacon.Deposit.StatusTest do + use Explorer.DataCase, async: false + + import Mox + import Ecto.Query + + alias Explorer.Chain.Beacon.Deposit + alias Explorer.Chain.Wei + alias Indexer.Fetcher.Beacon.Deposit.Status, as: StatusFetcher + alias Indexer.Fetcher.Beacon.Deposit.Status.Supervisor, as: StatusSupervisor + + setup :verify_on_exit! + setup :set_mox_global + + if Application.compile_env(:explorer, :chain_type) == :ethereum do + @epoch_duration 384 + + setup do + initial_supervisor_env = Application.get_env(:indexer, StatusSupervisor) + initial_fetcher_env = Application.get_env(:indexer, StatusFetcher) + + Application.put_env(:indexer, StatusSupervisor, initial_supervisor_env |> Keyword.put(:disabled?, false)) + + Application.put_env( + :indexer, + StatusFetcher, + initial_fetcher_env |> Keyword.merge(epoch_duration: @epoch_duration, reference_timestamp: 1_722_024_023) + ) + + on_exit(fn -> + Application.put_env(:indexer, StatusSupervisor, initial_supervisor_env) + Application.put_env(:indexer, StatusFetcher, initial_fetcher_env) + end) + end + + describe "handle_info(:fetch_queued_deposits, _state)" do + test "marks deposits as completed" do + # 12405630 + deposit_a = + insert(:beacon_deposit, + status: :invalid, + amount: 1 |> Decimal.new() |> Wei.from(:gwei), + block_timestamp: DateTime.from_unix!(1_755_691_583) + ) + + # 12405631 + _deposit_b = + insert(:beacon_deposit, + status: :pending, + amount: 2 |> Decimal.new() |> Wei.from(:gwei), + block_timestamp: DateTime.from_unix!(1_755_691_595) + ) + + # 12405633 + deposit_c = + insert(:beacon_deposit, + status: :pending, + amount: 3 |> Decimal.new() |> Wei.from(:gwei), + block_timestamp: DateTime.from_unix!(1_755_691_619) + ) + + # 12405634 + deposit_d = + insert(:beacon_deposit, + status: :pending, + amount: 4 |> Decimal.new() |> Wei.from(:gwei), + block_timestamp: DateTime.from_unix!(1_755_691_631) + ) + + # 12405635 + _deposit_e = + insert(:beacon_deposit, + status: :pending, + amount: 5 |> Decimal.new() |> Wei.from(:gwei), + block_timestamp: DateTime.from_unix!(1_755_691_643) + ) + + pending_deposits_result = + """ + { + "execution_optimistic": false, + "finalized": false, + "data": [ + { + "pubkey": "0xb257656f0a024a5c3be175a3bafd96cfcc452544b0fc6a23bbc39381028a28c10e8bafe6119c34771b5d86c9cae559e5", + "withdrawal_credentials": "0x01000000000000000000000082ce3e15a02e6a2e5a677d9700fe1390efead8eb", + "amount": "32000000000", + "signature": "0xb12eb9bddaf201aac73fb5ba9972ed06093c95a8baa1b5adcf1936ae7f33b0033c34d9e4756567c0424529e2de6774230274b015dfbe848e24a962a8748ea6229a8a87825f979e17d5e66f0246fb5eeb180e318c6574c22339f1cbcf5408a8dd", + "slot": "12405629" + }, + { + "pubkey": "#{deposit_a.pubkey}", + "withdrawal_credentials": "#{deposit_a.withdrawal_credentials}", + "amount": "#{deposit_a.amount |> Wei.to(:gwei)}", + "signature": "#{deposit_a.signature}", + "slot": "12405630" + }, + { + "pubkey": "#{deposit_c.pubkey}", + "withdrawal_credentials": "#{deposit_c.withdrawal_credentials}", + "amount": "#{deposit_c.amount |> Wei.to(:gwei)}", + "signature": "#{deposit_c.signature}", + "slot": "12405633" + }, + { + "pubkey": "#{deposit_d.pubkey}", + "withdrawal_credentials": "#{deposit_d.withdrawal_credentials}", + "amount": "#{deposit_d.amount |> Wei.to(:gwei)}", + "signature": "#{deposit_d.signature}", + "slot": "12405634" + } + ] + } + """ + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:5052/eth/v1/beacon/states/head/pending_deposits"}, _opts -> + {:ok, %Tesla.Env{status: 200, body: pending_deposits_result}} + end + ) + + {:noreply, timer} = StatusFetcher.handle_info(:fetch_queued_deposits, nil) + + # if next scheduled call is later than epoch duration something is wrong + assert Process.read_timer(timer) / 1000 <= @epoch_duration + 1 + + assert [ + %Deposit{status: :invalid}, + %Deposit{status: :completed}, + %Deposit{status: :pending}, + %Deposit{status: :pending}, + # deposit that appeared after the oldest one in pending deposits + # response is not yet reflected on the beacon node and should + # not be marked as completed + %Deposit{status: :pending} + ] = Repo.all(from(d in Deposit, order_by: [asc: d.index])) + end + + test "handles huge amount of data" do + Repo.query!("SET LOCAL max_stack_depth = '128kB'") + + pending_deposits_response = File.read!("./test/support/fixture/pending_beacon_deposits_response.json") + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:5052/eth/v1/beacon/states/head/pending_deposits"}, _opts -> + {:ok, %Tesla.Env{status: 200, body: pending_deposits_response}} + end + ) + + {:noreply, _timer} = StatusFetcher.handle_info(:fetch_queued_deposits, nil) + end + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs b/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs new file mode 100644 index 000000000000..75ee23b37534 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/beacon/deposit_test.exs @@ -0,0 +1,911 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Beacon.DepositTest do + use EthereumJSONRPC.Case + use Explorer.DataCase, async: false + + import ExUnit.CaptureLog, only: [capture_log: 1] + import Mox + import Ecto.Query + + alias Explorer.Chain.Beacon.Deposit + alias Indexer.Fetcher.Beacon.Deposit.Supervisor, as: DepositSupervisor + alias Indexer.Fetcher.Beacon.Deposit, as: DepositFetcher + + setup :verify_on_exit! + setup :set_mox_global + + if Application.compile_env(:explorer, :chain_type) == :ethereum do + setup do + initial_supervisor_env = Application.get_env(:indexer, DepositSupervisor) + initial_chain_id = Application.get_env(:indexer, :chain_id) + initial_fetcher_env = Application.get_env(:indexer, DepositFetcher) + + Application.put_env(:indexer, DepositSupervisor, initial_supervisor_env |> Keyword.put(:disabled?, false)) + Application.put_env(:indexer, :chain_id, "1") + Application.put_env(:indexer, DepositFetcher, initial_fetcher_env |> Keyword.merge(interval: 1, batch_size: 1)) + + on_exit(fn -> + Application.put_env(:indexer, DepositSupervisor, initial_supervisor_env) + Application.put_env(:indexer, :chain_id, initial_chain_id) + Application.put_env(:indexer, DepositFetcher, initial_fetcher_env) + end) + end + + @spec_result """ + { + "data": { + "CONFIG_NAME": "mainnet", + "PRESET_BASE": "mainnet", + "TERMINAL_TOTAL_DIFFICULTY": "58750000000000000000000", + "TERMINAL_BLOCK_HASH": "0x0000000000000000000000000000000000000000000000000000000000000000", + "TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH": "18446744073709551615", + "MIN_GENESIS_ACTIVE_VALIDATOR_COUNT": "16384", + "MIN_GENESIS_TIME": "1606824000", + "GENESIS_FORK_VERSION": "0x00000000", + "GENESIS_DELAY": "604800", + "ALTAIR_FORK_VERSION": "0x01000000", + "ALTAIR_FORK_EPOCH": "74240", + "BELLATRIX_FORK_VERSION": "0x02000000", + "BELLATRIX_FORK_EPOCH": "144896", + "CAPELLA_FORK_VERSION": "0x03000000", + "CAPELLA_FORK_EPOCH": "194048", + "DENEB_FORK_VERSION": "0x04000000", + "DENEB_FORK_EPOCH": "269568", + "ELECTRA_FORK_VERSION": "0x05000000", + "ELECTRA_FORK_EPOCH": "364032", + "FULU_FORK_VERSION": "0x06000000", + "FULU_FORK_EPOCH": "18446744073709551615", + "SECONDS_PER_SLOT": "12", + "SECONDS_PER_ETH1_BLOCK": "14", + "MIN_VALIDATOR_WITHDRAWABILITY_DELAY": "256", + "SHARD_COMMITTEE_PERIOD": "256", + "ETH1_FOLLOW_DISTANCE": "2048", + "SUBNETS_PER_NODE": "2", + "INACTIVITY_SCORE_BIAS": "4", + "INACTIVITY_SCORE_RECOVERY_RATE": "16", + "EJECTION_BALANCE": "16000000000", + "MIN_PER_EPOCH_CHURN_LIMIT": "4", + "MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT": "8", + "CHURN_LIMIT_QUOTIENT": "65536", + "PROPOSER_SCORE_BOOST": "40", + "DEPOSIT_CHAIN_ID": "1", + "DEPOSIT_NETWORK_ID": "1", + "DEPOSIT_CONTRACT_ADDRESS": "0x00000000219ab540356cbb839cbe05303d7705fa", + "GAS_LIMIT_ADJUSTMENT_FACTOR": "1024", + "MAX_PAYLOAD_SIZE": "10485760", + "MAX_REQUEST_BLOCKS": "1024", + "MIN_EPOCHS_FOR_BLOCK_REQUESTS": "33024", + "TTFB_TIMEOUT": "5", + "RESP_TIMEOUT": "10", + "ATTESTATION_PROPAGATION_SLOT_RANGE": "32", + "MAXIMUM_GOSSIP_CLOCK_DISPARITY_MILLIS": "500", + "MESSAGE_DOMAIN_INVALID_SNAPPY": "0x00000000", + "MESSAGE_DOMAIN_VALID_SNAPPY": "0x01000000", + "ATTESTATION_SUBNET_PREFIX_BITS": "6", + "MAX_REQUEST_BLOCKS_DENEB": "128", + "MAX_REQUEST_BLOB_SIDECARS": "768", + "MAX_REQUEST_DATA_COLUMN_SIDECARS": "16384", + "MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS": "4096", + "BLOB_SIDECAR_SUBNET_COUNT": "6", + "MAX_BLOBS_PER_BLOCK": "6", + "MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA": "128000000000", + "MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT": "256000000000", + "MAX_BLOBS_PER_BLOCK_ELECTRA": "9", + "BLOB_SIDECAR_SUBNET_COUNT_ELECTRA": "9", + "MAX_REQUEST_BLOB_SIDECARS_ELECTRA": "1152", + "NUMBER_OF_COLUMNS": "128", + "NUMBER_OF_CUSTODY_GROUPS": "128", + "DATA_COLUMN_SIDECAR_SUBNET_COUNT": "128", + "SAMPLES_PER_SLOT": "8", + "CUSTODY_REQUIREMENT": "4", + "MAX_COMMITTEES_PER_SLOT": "64", + "TARGET_COMMITTEE_SIZE": "128", + "MAX_VALIDATORS_PER_COMMITTEE": "2048", + "SHUFFLE_ROUND_COUNT": "90", + "HYSTERESIS_QUOTIENT": "4", + "HYSTERESIS_DOWNWARD_MULTIPLIER": "1", + "HYSTERESIS_UPWARD_MULTIPLIER": "5", + "MIN_DEPOSIT_AMOUNT": "1000000000", + "MAX_EFFECTIVE_BALANCE": "32000000000", + "EFFECTIVE_BALANCE_INCREMENT": "1000000000", + "MIN_ATTESTATION_INCLUSION_DELAY": "1", + "SLOTS_PER_EPOCH": "32", + "MIN_SEED_LOOKAHEAD": "1", + "MAX_SEED_LOOKAHEAD": "4", + "EPOCHS_PER_ETH1_VOTING_PERIOD": "64", + "SLOTS_PER_HISTORICAL_ROOT": "8192", + "MIN_EPOCHS_TO_INACTIVITY_PENALTY": "4", + "EPOCHS_PER_HISTORICAL_VECTOR": "65536", + "EPOCHS_PER_SLASHINGS_VECTOR": "8192", + "HISTORICAL_ROOTS_LIMIT": "16777216", + "VALIDATOR_REGISTRY_LIMIT": "1099511627776", + "BASE_REWARD_FACTOR": "64", + "WHISTLEBLOWER_REWARD_QUOTIENT": "512", + "PROPOSER_REWARD_QUOTIENT": "8", + "INACTIVITY_PENALTY_QUOTIENT": "67108864", + "MIN_SLASHING_PENALTY_QUOTIENT": "128", + "PROPORTIONAL_SLASHING_MULTIPLIER": "1", + "MAX_PROPOSER_SLASHINGS": "16", + "MAX_ATTESTER_SLASHINGS": "2", + "MAX_ATTESTATIONS": "128", + "MAX_DEPOSITS": "16", + "MAX_VOLUNTARY_EXITS": "16", + "INACTIVITY_PENALTY_QUOTIENT_ALTAIR": "50331648", + "MIN_SLASHING_PENALTY_QUOTIENT_ALTAIR": "64", + "PROPORTIONAL_SLASHING_MULTIPLIER_ALTAIR": "2", + "SYNC_COMMITTEE_SIZE": "512", + "EPOCHS_PER_SYNC_COMMITTEE_PERIOD": "256", + "MIN_SYNC_COMMITTEE_PARTICIPANTS": "1", + "INACTIVITY_PENALTY_QUOTIENT_BELLATRIX": "16777216", + "MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX": "32", + "PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX": "3", + "MAX_BYTES_PER_TRANSACTION": "1073741824", + "MAX_TRANSACTIONS_PER_PAYLOAD": "1048576", + "BYTES_PER_LOGS_BLOOM": "256", + "MAX_EXTRA_DATA_BYTES": "32", + "MAX_BLS_TO_EXECUTION_CHANGES": "16", + "MAX_WITHDRAWALS_PER_PAYLOAD": "16", + "MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP": "16384", + "MAX_BLOB_COMMITMENTS_PER_BLOCK": "4096", + "FIELD_ELEMENTS_PER_BLOB": "4096", + "MIN_ACTIVATION_BALANCE": "32000000000", + "MAX_EFFECTIVE_BALANCE_ELECTRA": "2048000000000", + "MIN_SLASHING_PENALTY_QUOTIENT_ELECTRA": "4096", + "WHISTLEBLOWER_REWARD_QUOTIENT_ELECTRA": "4096", + "PENDING_DEPOSITS_LIMIT": "134217728", + "PENDING_PARTIAL_WITHDRAWALS_LIMIT": "134217728", + "PENDING_CONSOLIDATIONS_LIMIT": "262144", + "MAX_ATTESTER_SLASHINGS_ELECTRA": "1", + "MAX_ATTESTATIONS_ELECTRA": "8", + "MAX_DEPOSIT_REQUESTS_PER_PAYLOAD": "8192", + "MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD": "16", + "MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD": "2", + "MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP": "8", + "MAX_PENDING_DEPOSITS_PER_EPOCH": "16", + "FIELD_ELEMENTS_PER_CELL": "64", + "FIELD_ELEMENTS_PER_EXT_BLOB": "8192", + "KZG_COMMITMENTS_INCLUSION_PROOF_DEPTH": "4", + "DOMAIN_RANDAO": "0x02000000", + "DOMAIN_DEPOSIT": "0x03000000", + "FULL_EXIT_REQUEST_AMOUNT": "0", + "COMPOUNDING_WITHDRAWAL_PREFIX": "0x02", + "DOMAIN_SELECTION_PROOF": "0x05000000", + "SYNC_COMMITTEE_SUBNET_COUNT": "4", + "UNSET_DEPOSIT_REQUESTS_START_INDEX": "18446744073709551615", + "DOMAIN_BEACON_PROPOSER": "0x00000000", + "DOMAIN_VOLUNTARY_EXIT": "0x04000000", + "VERSIONED_HASH_VERSION_KZG": "1", + "DOMAIN_BEACON_ATTESTER": "0x01000000", + "TARGET_AGGREGATORS_PER_SYNC_SUBCOMMITTEE": "16", + "DOMAIN_APPLICATION_MASK": "0x00000001", + "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000", + "ETH1_ADDRESS_WITHDRAWAL_PREFIX": "0x01", + "TARGET_AGGREGATORS_PER_COMMITTEE": "16", + "DOMAIN_SYNC_COMMITTEE": "0x07000000", + "BLS_WITHDRAWAL_PREFIX": "0x00", + "DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF": "0x08000000", + "DOMAIN_CONTRIBUTION_AND_PROOF": "0x09000000" + } + } + """ + + describe "init/1" do + test "fetches config and initializes state without deposits in the database", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:5052/eth/v1/config/spec"}, _opts -> + {:ok, %Tesla.Env{status: 200, body: @spec_result}} + end + ) + + DepositSupervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + {_, pid, _, _} = + Supervisor.which_children(DepositSupervisor) |> Enum.find(fn {name, _, _, _} -> name == DepositFetcher end) + + assert :sys.get_state(pid) == %DepositFetcher{ + interval: 1, + batch_size: 1, + deposit_contract_address_hash: "0x00000000219ab540356cbb839cbe05303d7705fa", + domain_deposit: <<3, 0, 0, 0>>, + genesis_fork_version: <<0, 0, 0, 0>>, + deposit_index: -1, + last_processed_log_block_number: -1, + last_processed_log_index: -1, + json_rpc_named_arguments: json_rpc_named_arguments + } + end + + test "fetches config and initializes state with deposits in the database", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:5052/eth/v1/config/spec"}, _opts -> + {:ok, %Tesla.Env{status: 200, body: @spec_result}} + end + ) + + deposit = insert(:beacon_deposit) + + DepositSupervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + {_, pid, _, _} = + Supervisor.which_children(DepositSupervisor) |> Enum.find(fn {name, _, _, _} -> name == DepositFetcher end) + + assert :sys.get_state(pid) == %DepositFetcher{ + interval: 1, + batch_size: 1, + deposit_contract_address_hash: "0x00000000219ab540356cbb839cbe05303d7705fa", + domain_deposit: <<3, 0, 0, 0>>, + genesis_fork_version: <<0, 0, 0, 0>>, + deposit_index: deposit.index, + last_processed_log_block_number: deposit.block_number, + last_processed_log_index: deposit.log_index, + json_rpc_named_arguments: json_rpc_named_arguments + } + end + end + + describe "handle_info(:process_logs, state)" do + @state %DepositFetcher{ + interval: 1, + batch_size: 1, + deposit_contract_address_hash: "0x00000000219ab540356cbb839cbe05303d7705fa", + domain_deposit: <<3, 0, 0, 0>>, + genesis_fork_version: <<0, 0, 0, 0>>, + deposit_index: -1, + last_processed_log_block_number: -1, + last_processed_log_index: -1 + } + + test "processes logs (batch 1)" do + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + other_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fb") + + insert(:log) + transaction_a = insert(:transaction) |> with_block() + + log_a = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 0, + transaction: transaction_a, + block: transaction_a.block + ) + + log_a_transaction_hash = log_a.transaction_hash + transaction_b = insert(:transaction) |> with_block() + + log_b = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1, + transaction: transaction_b, + block: transaction_b.block + ) + + log_b_transaction_hash = log_b.transaction_hash + # ensure that logs from other contract or with other signature are ignored + _log_to_be_ignored_b = insert(:beacon_deposit_log, address: other_contract_address, deposit_index: 2) + + {:noreply, new_state} = DepositFetcher.handle_info(:process_logs, @state) + DepositFetcher.handle_info(:process_logs, new_state) + + assert [ + %Deposit{transaction_hash: ^log_a_transaction_hash}, + %Deposit{transaction_hash: ^log_b_transaction_hash} + ] = + Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + + test "processes logs (batch 5)" do + state = Map.put(@state, :batch_size, 5) + + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + other_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fb") + + insert(:log) + transaction_a = insert(:transaction) |> with_block() + + log_a = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 0, + transaction: transaction_a, + block: transaction_a.block + ) + + log_a_transaction_hash = log_a.transaction_hash + + transaction_b = insert(:transaction) |> with_block() + + log_b = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1, + transaction: transaction_b, + block: transaction_b.block + ) + + log_b_transaction_hash = log_b.transaction_hash + # ensure that logs from other contract or with other signature are ignored + _log_to_be_ignored_b = insert(:beacon_deposit_log, address: other_contract_address, deposit_index: 2) + + DepositFetcher.handle_info(:process_logs, state) + + assert [ + %Deposit{transaction_hash: ^log_a_transaction_hash}, + %Deposit{transaction_hash: ^log_b_transaction_hash} + ] = + Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + + test "fallbacks to fetch data from the node when non-sequential logs are detected (logs starts not from 0, between batches)", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + + log_from_node = + build(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 0 + ) + + deposit_signature = log_from_node.first_topic + log_from_node_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_from_node.block_number) + log_from_node_transaction_hash = log_from_node.transaction.hash + + transaction_a = insert(:transaction) |> with_block() + + log_a = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1, + transaction: transaction_a, + block: transaction_a.block + ) + + log_a_transaction_hash = log_a.transaction_hash + log_a_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_a.block_number) + + transaction_b = insert(:transaction) |> with_block() + + log_b = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 2, + transaction: transaction_b, + block: transaction_b.block + ) + + log_b_transaction_hash = log_b.transaction_hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 2, fn + %{ + id: _id, + method: "eth_getLogs", + params: [ + %{ + address: "0x00000000219ab540356cbb839cbe05303d7705fa", + topics: [^deposit_signature], + fromBlock: "0x0", + toBlock: ^log_a_block_number_quantity + } + ] + }, + _options -> + {:ok, + [ + %{ + "address" => "0x7f02c3e3c98b133055b8b348b2ac625669ed295d", + "blockHash" => to_string(log_from_node.block.hash), + "blockNumber" => log_from_node_block_number_quantity, + "data" => to_string(log_from_node.data), + "logIndex" => "0x1", + "removed" => false, + "topics" => [ + "0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5" + ], + "transactionHash" => to_string(log_from_node_transaction_hash), + "transactionIndex" => "0x4" + } + ]} + + [ + %{ + id: id, + method: "eth_getBlockByNumber", + params: [^log_from_node_block_number_quantity, false] + } + ], + _options -> + {:ok, + [ + %{ + id: id, + result: %{ + "difficulty" => "0xa3ff9e", + "extraData" => "0x", + "gasLimit" => "0x1c9c380", + "gasUsed" => "0x0", + "hash" => to_string(log_from_node.block.hash), + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0x2f14582947e292a2ecd20c430b46f2d27cfe213c", + "mixHash" => "0xa85d26c4efa1d9fdbb095935b49c93a1ddcc967634d899826168abe486f095d8", + "nonce" => "0xc7faaf72b6690188", + "number" => log_from_node_block_number_quantity, + "parentHash" => "0x8ac578a998c3af00a0eab91f7fa209aeed0251c61c37f3b1d43d4253a5e2fa7a", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "size" => "0x203", + "stateRoot" => "0xd432683a9704dcabff1daacf8c40742301248faf3d9f05ea738daa2dffc19043", + "totalDifficulty" => "0x5113296ac", + "timestamp" => "0x617383e7", + "baseFeePerGas" => "0x7", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + end) + + log = + capture_log(fn -> + DepositFetcher.handle_info( + :process_logs, + Map.put(@state, :json_rpc_named_arguments, json_rpc_named_arguments) + ) + |> then(fn {:noreply, new_state} -> + DepositFetcher.handle_info( + :process_logs, + new_state + ) + end) + |> then(fn {:noreply, new_state} -> + DepositFetcher.handle_info( + :process_logs, + new_state + ) + end) + end) + + assert log =~ "Non-sequential deposits detected" + + assert [ + %Deposit{transaction_hash: ^log_from_node_transaction_hash, index: 0}, + %Deposit{transaction_hash: ^log_a_transaction_hash, index: 1}, + %Deposit{transaction_hash: ^log_b_transaction_hash, index: 2} + ] = + Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + + test "fallbacks to fetch data from the node when non-sequential logs are detected (non-sequential between, between batches)", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + + transaction_a = insert(:transaction) |> with_block() + + log_a = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 0, + transaction: transaction_a, + block: transaction_a.block + ) + + log_a_transaction_hash = log_a.transaction_hash + log_a_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_a.block_number) + + transaction_with_missing_logs = insert(:transaction) |> with_block() + + log_from_node = + build(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1, + transaction: transaction_with_missing_logs, + block: transaction_with_missing_logs.block + ) + + deposit_signature = log_from_node.first_topic + log_from_node_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_from_node.block_number) + log_from_node_transaction_hash = log_from_node.transaction.hash + + transaction_b = insert(:transaction) |> with_block() + + log_b = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 2, + transaction: transaction_b, + block: transaction_b.block + ) + + log_b_transaction_hash = log_b.transaction_hash + log_b_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_b.block_number) + + transaction_c = insert(:transaction) |> with_block() + + _log_c = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 3, + transaction: transaction_c, + block: transaction_c.block + ) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 1, fn + %{ + id: _id, + method: "eth_getLogs", + params: [ + %{ + address: "0x00000000219ab540356cbb839cbe05303d7705fa", + topics: [^deposit_signature], + fromBlock: ^log_a_block_number_quantity, + toBlock: ^log_b_block_number_quantity + } + ] + }, + _options -> + {:ok, + [ + %{ + "address" => "0x7f02c3e3c98b133055b8b348b2ac625669ed295d", + "blockHash" => to_string(log_from_node.block.hash), + "blockNumber" => log_from_node_block_number_quantity, + "data" => to_string(log_from_node.data), + "logIndex" => "0x1", + "removed" => false, + "topics" => [ + "0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5" + ], + "transactionHash" => to_string(log_from_node_transaction_hash), + "transactionIndex" => "0x4" + } + ]} + end) + + {:noreply, new_state} = + DepositFetcher.handle_info( + :process_logs, + Map.put(@state, :json_rpc_named_arguments, json_rpc_named_arguments) + ) + + log = capture_log(fn -> DepositFetcher.handle_info(:process_logs, new_state) end) + + assert log =~ "Non-sequential deposits detected" + + assert [ + %Deposit{transaction_hash: ^log_a_transaction_hash}, + %Deposit{transaction_hash: ^log_from_node_transaction_hash}, + %Deposit{transaction_hash: ^log_b_transaction_hash} + ] = Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + + test "fallbacks to fetch data from the node when non-sequential logs are detected (logs starts not from 0, inside batch)", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + state = @state |> Map.put(:batch_size, 5) |> Map.put(:json_rpc_named_arguments, json_rpc_named_arguments) + + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + + log_from_node = + build(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 0 + ) + + deposit_signature = log_from_node.first_topic + log_from_node_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_from_node.block_number) + log_from_node_transaction_hash = log_from_node.transaction.hash + + transaction_a = insert(:transaction) |> with_block() + + log_a = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1, + transaction: transaction_a, + block: transaction_a.block + ) + + log_a_transaction_hash = log_a.transaction_hash + log_a_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_a.block_number) + + transaction_b = insert(:transaction) |> with_block() + + log_b = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 2, + transaction: transaction_b, + block: transaction_b.block + ) + + log_b_transaction_hash = log_b.transaction_hash + + transaction_c = insert(:transaction) |> with_block() + + log_c = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 3, + transaction: transaction_c, + block: transaction_c.block + ) + + log_c_transaction_hash = log_c.transaction_hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 2, fn + %{ + id: _id, + method: "eth_getLogs", + params: [ + %{ + address: "0x00000000219ab540356cbb839cbe05303d7705fa", + topics: [^deposit_signature], + fromBlock: "0x0", + toBlock: ^log_a_block_number_quantity + } + ] + }, + _options -> + {:ok, + [ + %{ + "address" => "0x7f02c3e3c98b133055b8b348b2ac625669ed295d", + "blockHash" => to_string(log_from_node.block.hash), + "blockNumber" => log_from_node_block_number_quantity, + "data" => to_string(log_from_node.data), + "logIndex" => "0x1", + "removed" => false, + "topics" => [ + "0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5" + ], + "transactionHash" => to_string(log_from_node_transaction_hash), + "transactionIndex" => "0x4" + } + ]} + + [ + %{ + id: id, + method: "eth_getBlockByNumber", + params: [^log_from_node_block_number_quantity, false] + } + ], + _options -> + {:ok, + [ + %{ + id: id, + result: %{ + "difficulty" => "0xa3ff9e", + "extraData" => "0x", + "gasLimit" => "0x1c9c380", + "gasUsed" => "0x0", + "hash" => to_string(log_from_node.block.hash), + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0x2f14582947e292a2ecd20c430b46f2d27cfe213c", + "mixHash" => "0xa85d26c4efa1d9fdbb095935b49c93a1ddcc967634d899826168abe486f095d8", + "nonce" => "0xc7faaf72b6690188", + "number" => log_from_node_block_number_quantity, + "parentHash" => "0x8ac578a998c3af00a0eab91f7fa209aeed0251c61c37f3b1d43d4253a5e2fa7a", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "size" => "0x203", + "stateRoot" => "0xd432683a9704dcabff1daacf8c40742301248faf3d9f05ea738daa2dffc19043", + "totalDifficulty" => "0x5113296ac", + "timestamp" => "0x617383e7", + "baseFeePerGas" => "0x7", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + end) + + log = capture_log(fn -> DepositFetcher.handle_info(:process_logs, state) end) + + assert log =~ "Non-sequential deposits detected" + + assert [ + %Deposit{transaction_hash: ^log_from_node_transaction_hash}, + %Deposit{transaction_hash: ^log_a_transaction_hash}, + %Deposit{transaction_hash: ^log_b_transaction_hash}, + %Deposit{transaction_hash: ^log_c_transaction_hash} + ] = Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + + test "fallbacks to fetch data from the node when non-sequential logs are detected (non-sequential between, inside batch)", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + state = @state |> Map.put(:batch_size, 5) |> Map.put(:json_rpc_named_arguments, json_rpc_named_arguments) + + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + + transaction_a = insert(:transaction) |> with_block() + + log_a = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 0, + transaction: transaction_a, + block: transaction_a.block + ) + + log_a_transaction_hash = log_a.transaction_hash + log_a_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_a.block_number) + + log_from_node = + build(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1 + ) + + deposit_signature = log_from_node.first_topic + log_from_node_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_from_node.block_number) + log_from_node_transaction_hash = log_from_node.transaction.hash + + transaction_b = insert(:transaction) |> with_block() + + log_b = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 2, + transaction: transaction_b, + block: transaction_b.block + ) + + log_b_transaction_hash = log_b.transaction_hash + log_b_block_number_quantity = EthereumJSONRPC.integer_to_quantity(log_b.block_number) + + transaction_c = insert(:transaction) |> with_block() + + log_c = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 3, + transaction: transaction_c, + block: transaction_c.block + ) + + log_c_transaction_hash = log_c.transaction_hash + + EthereumJSONRPC.Mox + |> expect(:json_rpc, 2, fn + %{ + id: _id, + method: "eth_getLogs", + params: [ + %{ + address: "0x00000000219ab540356cbb839cbe05303d7705fa", + topics: [^deposit_signature], + fromBlock: ^log_a_block_number_quantity, + toBlock: ^log_b_block_number_quantity + } + ] + }, + _options -> + {:ok, + [ + %{ + "address" => "0x7f02c3e3c98b133055b8b348b2ac625669ed295d", + "blockHash" => to_string(log_from_node.block.hash), + "blockNumber" => log_from_node_block_number_quantity, + "data" => to_string(log_from_node.data), + "logIndex" => "0x1", + "removed" => false, + "topics" => [ + "0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5" + ], + "transactionHash" => to_string(log_from_node_transaction_hash), + "transactionIndex" => "0x4" + } + ]} + + [ + %{ + id: id, + method: "eth_getBlockByNumber", + params: [^log_from_node_block_number_quantity, false] + } + ], + _options -> + {:ok, + [ + %{ + id: id, + result: %{ + "difficulty" => "0xa3ff9e", + "extraData" => "0x", + "gasLimit" => "0x1c9c380", + "gasUsed" => "0x0", + "hash" => to_string(log_from_node.block.hash), + "logsBloom" => + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "miner" => "0x2f14582947e292a2ecd20c430b46f2d27cfe213c", + "mixHash" => "0xa85d26c4efa1d9fdbb095935b49c93a1ddcc967634d899826168abe486f095d8", + "nonce" => "0xc7faaf72b6690188", + "number" => log_from_node_block_number_quantity, + "parentHash" => "0x8ac578a998c3af00a0eab91f7fa209aeed0251c61c37f3b1d43d4253a5e2fa7a", + "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "size" => "0x203", + "stateRoot" => "0xd432683a9704dcabff1daacf8c40742301248faf3d9f05ea738daa2dffc19043", + "totalDifficulty" => "0x5113296ac", + "timestamp" => "0x617383e7", + "baseFeePerGas" => "0x7", + "transactions" => [], + "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "uncles" => [] + } + } + ]} + end) + + log = capture_log(fn -> DepositFetcher.handle_info(:process_logs, state) end) + + assert log =~ "Non-sequential deposits detected" + + assert [ + %Deposit{transaction_hash: ^log_a_transaction_hash}, + %Deposit{transaction_hash: ^log_from_node_transaction_hash}, + %Deposit{transaction_hash: ^log_b_transaction_hash}, + %Deposit{transaction_hash: ^log_c_transaction_hash} + ] = Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + + test "signature verification" do + state = Map.put(@state, :batch_size, 5) + + deposit_contract_address = insert(:address, hash: "0x00000000219ab540356cbb839cbe05303d7705fa") + + transaction_a = insert(:transaction) |> with_block() + + _valid_log = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_pubkey: + Base.decode16!( + "b325d901d41957b6746088a61f5a1562f09ccb184e1628fa63b6bdfe9a724645d536484e4d26e8c9b653aaf0501cefe1", + case: :mixed + ), + deposit_withdrawal_credentials: + Base.decode16!("0100000000000000000000001ed8b3e4278184675fefa6981dea36f4535df417", case: :mixed), + deposit_amount: 32_000_000_000, + deposit_signature: + Base.decode16!( + "a519a7ff525a6831a6099399033bb5a0b959ec7af022ad7f37aa869927bbb59e1271079cbdff416e7f8f6f0f8ea7173304f4abdabfa65923a6b0304d49c97cef0690d0017b39518e7b19848657e2a9f73601d5037c217c5252558be1a8176e3d", + case: :mixed + ), + deposit_index: 0, + transaction: transaction_a, + block: transaction_a.block + ) + + transaction_b = insert(:transaction) |> with_block() + + _invalid_log = + insert(:beacon_deposit_log, + address: deposit_contract_address, + deposit_index: 1, + transaction: transaction_b, + block: transaction_b.block + ) + + DepositFetcher.handle_info(:process_logs, state) + + assert [%Deposit{status: :pending, index: 0}, %Deposit{status: :invalid, index: 1}] = + Repo.all(from(d in Deposit, order_by: [asc: :index])) + end + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/block_reward_test.exs b/apps/indexer/test/indexer/fetcher/block_reward_test.exs index 10b8f250dbde..a058989815e5 100644 --- a/apps/indexer/test/indexer/fetcher/block_reward_test.exs +++ b/apps/indexer/test/indexer/fetcher/block_reward_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.BlockRewardTest do # MUST be `async: false` so that {:shared, pid} is set for connection to allow CoinBalanceFetcher's self-send to have # connection allowed immediately. @@ -41,13 +42,13 @@ defmodule Indexer.Fetcher.BlockRewardTest do end test "with consensus block without reward" do - %Block{number: block_number} = insert(:block) + %Block{number: block_number} = insert(:block, number: 1) assert [^block_number] = BlockReward.init([], &[&1 | &2], nil) end test "with consensus block with reward" do - block = insert(:block) + block = insert(:block, number: 1) insert(:reward, address_hash: block.miner_hash, block_hash: block.hash) assert [] = BlockReward.init([], &[&1 | &2], nil) @@ -64,7 +65,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do setup %{json_rpc_named_arguments: json_rpc_named_arguments} do BlockReward.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - block = insert(:block) + block = insert(:block, number: 1) %{block: block} end @@ -79,45 +80,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do } do block_quantity = integer_to_quantity(block_number) - expect(EthereumJSONRPC.Mox, :json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "trace_block", - params: [^block_quantity] - } - ], - _ -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: [ - %{ - "action" => %{ - "author" => to_string(miner_hash), - "rewardType" => "external", - "value" => "0x0" - }, - # ... but, switches to non-consensus by the time `trace_block` is called - "blockHash" => to_string(block_hash), - "blockNumber" => block_number, - "result" => nil, - "subtraces" => 0, - "traceAddress" => [], - "transactionHash" => nil, - "transactionPosition" => nil, - "type" => "reward" - } - ] - } - ] - } - end) - - _res = eth_block_number_fake_response(block_quantity) + trace_block_expectation(block_quantity, miner_hash, block_hash, block_number, "0x0") assert count(Chain.Block.Reward) == 0 @@ -154,45 +117,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do block_quantity = integer_to_quantity(block_number) - expect(EthereumJSONRPC.Mox, :json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "trace_block", - params: [^block_quantity] - } - ], - _ -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: [ - %{ - "action" => %{ - "author" => to_string(miner_hash), - "rewardType" => "external", - "value" => "0x0" - }, - # ... but, switches to non-consensus by the time `trace_block` is called - "blockHash" => to_string(block_hash), - "blockNumber" => block_number, - "result" => nil, - "subtraces" => 0, - "traceAddress" => [], - "transactionHash" => nil, - "transactionPosition" => nil, - "type" => "reward" - } - ] - } - ] - } - end) - - _res = eth_block_number_fake_response(block_quantity) + trace_block_expectation(block_quantity, miner_hash, block_hash, block_number, "0x0") parent = self() @@ -216,49 +141,15 @@ defmodule Indexer.Fetcher.BlockRewardTest do end test "with consensus block does not import if fetch beneficiaries returns a different block hash for block number", - %{block: %Block{hash: block_hash, number: block_number, consensus: true, miner_hash: miner_hash}} do + %{ + block: %Block{hash: block_hash, number: block_number, consensus: true, miner_hash: miner_hash} + } do block_quantity = integer_to_quantity(block_number) new_block_hash = block_hash() refute block_hash == new_block_hash - expect(EthereumJSONRPC.Mox, :json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "trace_block", - params: [^block_quantity] - } - ], - _ -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: [ - %{ - "action" => %{ - "author" => to_string(miner_hash), - "rewardType" => "external", - "value" => "0x0" - }, - # ... but, switches to non-consensus by the time `trace_block` is called - "blockHash" => to_string(new_block_hash), - "blockNumber" => block_number, - "result" => nil, - "subtraces" => 0, - "traceAddress" => [], - "transactionHash" => nil, - "transactionPosition" => nil, - "type" => "reward" - } - ] - } - ] - } - end) + trace_block_expectation(block_quantity, miner_hash, new_block_hash, block_number, "0x0") assert :ok = BlockReward.async_fetch([block_number], false) @@ -270,7 +161,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do describe "run/2" do setup do - block = insert(:block) + block = insert(:block, number: 1) %{block: block} end @@ -286,45 +177,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do } do block_quantity = integer_to_quantity(block_number) - expect(EthereumJSONRPC.Mox, :json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "trace_block", - params: [^block_quantity] - } - ], - _ -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: [ - %{ - "action" => %{ - "author" => to_string(miner_hash), - "rewardType" => "external", - "value" => "0x0" - }, - # ... but, switches to non-consensus by the time `trace_block` is called - "blockHash" => to_string(block_hash), - "blockNumber" => block_number, - "result" => nil, - "subtraces" => 0, - "traceAddress" => [], - "transactionHash" => nil, - "transactionPosition" => nil, - "type" => "reward" - } - ] - } - ] - } - end) - - _res = eth_block_number_fake_response(block_quantity) + trace_block_expectation(block_quantity, miner_hash, block_hash, block_number, "0x0") assert count(Chain.Block.Reward) == 0 assert count(Chain.Address.CoinBalance) == 0 @@ -414,8 +267,6 @@ defmodule Indexer.Fetcher.BlockRewardTest do } end) - _res = eth_block_number_fake_response(block_quantity) - assert count(Chain.Block.Reward) == 0 assert count(Chain.Address.CoinBalance) == 0 @@ -456,45 +307,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do block_quantity = integer_to_quantity(block_number) - expect(EthereumJSONRPC.Mox, :json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "trace_block", - params: [^block_quantity] - } - ], - _ -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: [ - %{ - "action" => %{ - "author" => to_string(miner_hash), - "rewardType" => "external", - "value" => "0x1" - }, - # ... but, switches to non-consensus by the time `trace_block` is called - "blockHash" => to_string(block_hash), - "blockNumber" => block_number, - "result" => nil, - "subtraces" => 0, - "traceAddress" => [], - "transactionHash" => nil, - "transactionPosition" => nil, - "type" => "reward" - } - ] - } - ] - } - end) - - _res = eth_block_number_fake_response(block_quantity) + trace_block_expectation(block_quantity, miner_hash, block_hash, block_number, "0x1") assert count(Chain.Block.Reward) == 1 assert count(Chain.Address.CoinBalance) == 1 @@ -537,43 +350,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do refute block_hash == new_block_hash - expect(EthereumJSONRPC.Mox, :json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "trace_block", - params: [^block_quantity] - } - ], - _ -> - { - :ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: [ - %{ - "action" => %{ - "author" => to_string(miner_hash), - "rewardType" => "external", - "value" => "0x0" - }, - # ... but, switches to non-consensus by the time `trace_block` is called - "blockHash" => to_string(new_block_hash), - "blockNumber" => block_number, - "result" => nil, - "subtraces" => 0, - "traceAddress" => [], - "transactionHash" => nil, - "transactionPosition" => nil, - "type" => "reward" - } - ] - } - ] - } - end) + trace_block_expectation(block_quantity, miner_hash, new_block_hash, block_number, "0x0") assert :ok = BlockReward.run([block_number], json_rpc_named_arguments) @@ -591,7 +368,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do json_rpc_named_arguments: json_rpc_named_arguments } do block_quantity = integer_to_quantity(block_number) - %Block{number: error_block_number} = insert(:block) + %Block{number: error_block_number} = insert(:block, number: 2) error_block_quantity = integer_to_quantity(error_block_number) @@ -635,8 +412,6 @@ defmodule Indexer.Fetcher.BlockRewardTest do } end) - _res = eth_block_number_fake_response(block_quantity) - assert count(Chain.Block.Reward) == 0 assert count(Chain.Address.CoinBalance) == 0 @@ -670,7 +445,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do defp wait_for_tasks(buffered_task) do wait_until(:timer.seconds(10), fn -> - counts = BufferedTask.debug_count(buffered_task) + counts = BufferedTask.debug_count(buffered_task, false) counts.buffer == 0 and counts.tasks == 0 end) end @@ -684,7 +459,7 @@ defmodule Indexer.Fetcher.BlockRewardTest do receive do {^ref, :ok} -> :ok after - timeout -> exit(:timeout) + timeout -> :ok end end @@ -697,39 +472,43 @@ defmodule Indexer.Fetcher.BlockRewardTest do end end - defp eth_block_number_fake_response(block_quantity) do - %{ - id: 0, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] + defp trace_block_expectation(block_quantity, miner_hash, block_hash, block_number, value) do + expect(EthereumJSONRPC.Mox, :json_rpc, fn [ + %{ + id: id, + jsonrpc: "2.0", + method: "trace_block", + params: [^block_quantity] + } + ], + _ -> + { + :ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: [ + %{ + "action" => %{ + "author" => to_string(miner_hash), + "rewardType" => "external", + "value" => value + }, + # ... but, switches to non-consensus by the time `trace_block` is called + "blockHash" => to_string(block_hash), + "blockNumber" => block_number, + "result" => nil, + "subtraces" => 0, + "traceAddress" => [], + "transactionHash" => nil, + "transactionPosition" => nil, + "type" => "reward" + } + ] + } + ] } - } + end) end end diff --git a/apps/indexer/test/indexer/fetcher/celo/epoch_block_operations_test.exs b/apps/indexer/test/indexer/fetcher/celo/epoch_block_operations_test.exs deleted file mode 100644 index 02130aee01af..000000000000 --- a/apps/indexer/test/indexer/fetcher/celo/epoch_block_operations_test.exs +++ /dev/null @@ -1,64 +0,0 @@ -defmodule Indexer.Fetcher.Celo.EpochBlockOperationsTest do - use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] - use EthereumJSONRPC.Case - use Explorer.DataCase - - import Explorer.Chain.Celo.Helper, only: [blocks_per_epoch: 0] - import Mox - - alias Indexer.Fetcher.Celo.EpochBlockOperations - - # MUST use global mode because we aren't guaranteed to get - # `start_supervised`'s pid back fast enough to `allow` it to use - # expectations and stubs from test's pid. - setup :set_mox_global - - setup :verify_on_exit! - - if @chain_type == :celo do - describe "init/3" do - test "buffers blocks with pending epoch operation", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - unfetched = insert(:block, number: 1 * blocks_per_epoch()) - insert(:celo_pending_epoch_block_operation, block: unfetched) - - assert [ - %{ - block_number: unfetched.number, - block_hash: unfetched.hash - } - ] == - EpochBlockOperations.init( - [], - fn block_number, acc -> [block_number | acc] end, - json_rpc_named_arguments - ) - end - end - - test "correctly buffers blocks when :l2_migration_block is set", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - last_l1_epoch_block_number = 1 * blocks_per_epoch() - l1_block = insert(:block, number: last_l1_epoch_block_number) - l2_block = insert(:block, number: 2 * blocks_per_epoch()) - insert(:celo_pending_epoch_block_operation, block: l1_block) - insert(:celo_pending_epoch_block_operation, block: l2_block) - - Application.put_env(:explorer, :celo, l2_migration_block: last_l1_epoch_block_number) - - assert [ - %{ - block_number: l1_block.number, - block_hash: l1_block.hash - } - ] == - EpochBlockOperations.init( - [], - fn block_number, acc -> [block_number | acc] end, - json_rpc_named_arguments - ) - end - end -end diff --git a/apps/indexer/test/indexer/fetcher/coin_balance/catchup_test.exs b/apps/indexer/test/indexer/fetcher/coin_balance/catchup_test.exs index b330dccef57d..1bab44862090 100644 --- a/apps/indexer/test/indexer/fetcher/coin_balance/catchup_test.exs +++ b/apps/indexer/test/indexer/fetcher/coin_balance/catchup_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.CoinBalance.CatchupTest do # MUST be `async: false` so that {:shared, pid} is set for connection to allow CoinBalanceFetcher's self-send to have # connection allowed immediately. @@ -9,6 +10,7 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do alias Explorer.Chain.{Address, Hash, Wei} alias Explorer.Chain.Cache.BlockNumber + alias Explorer.TestHelper alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup @moduletag :capture_log @@ -60,27 +62,9 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do block_quantity = integer_to_quantity(block_number) + TestHelper.eth_get_balance_expectation(miner_hash_data, block_quantity, integer_to_quantity(fetched_balance)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [%{id: id, method: "eth_getBalance", params: [^miner_hash_data, ^block_quantity]}], - _options -> - {:ok, [%{id: id, result: integer_to_quantity(fetched_balance)}]} - end) - - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [^block_quantity, true] - } - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(block_number) end {:ok, miner_hash} = Hash.Address.cast(miner_hash_data) @@ -132,26 +116,9 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do block_quantity = integer_to_quantity(block_number) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [%{id: id, method: "eth_getBalance", params: [^miner_hash_data, ^block_quantity]}], - _options -> - {:ok, [%{id: id, result: integer_to_quantity(fetched_balance)}]} - end) - - res = eth_block_number_fake_response(block_quantity) + TestHelper.eth_get_balance_expectation(miner_hash_data, block_quantity, integer_to_quantity(fetched_balance)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [^block_quantity, true] - } - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(block_number) end {:ok, miner_hash} = Hash.Address.cast(miner_hash_data) @@ -211,26 +178,9 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do block_quantity = integer_to_quantity(block_number) hash_data = to_string(hash) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [%{id: id, method: "eth_getBalance", params: [^hash_data, ^block_quantity]}], - _options -> - {:ok, [%{id: id, result: integer_to_quantity(fetched_balance)}]} - end) + TestHelper.eth_get_balance_expectation(hash_data, block_quantity, integer_to_quantity(fetched_balance)) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [^block_quantity, true] - } - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(block_number) end BlockNumber.set_max(block_number) @@ -309,24 +259,8 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do {:ok, %Hash{bytes: address_hash_bytes}} = Hash.Address.cast(hash_data) entries = Enum.map(block_quantities, &{address_hash_bytes, quantity_to_integer(&1)}) - res1 = eth_block_number_fake_response("0x1") - res2 = eth_block_number_fake_response("0x2") - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x1", true]} - ], - _ -> - {:ok, [res1]} - end) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{id: 0, jsonrpc: "2.0", method: "eth_getBlockByNumber", params: ["0x2", true]} - ], - _ -> - {:ok, [res2]} - end) + TestHelper.eth_get_block_by_number_expectation(1) + TestHelper.eth_get_block_by_number_expectation(2) BlockNumber.set_max(2) @@ -399,21 +333,7 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do {:ok, [%{id: id, result: "0x1"}]} end) - block_quantity = integer_to_quantity(block_number) - res = eth_block_number_fake_response(block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [^block_quantity, true] - } - ], - _ -> - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(block_number) BlockNumber.set_max(block_number) @@ -456,21 +376,7 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do {:ok, responses} end) - good_block_quantity = integer_to_quantity(good_block_number) - res_good = eth_block_number_fake_response(good_block_quantity) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [^good_block_quantity, true] - } - ], - [] -> - {:ok, [res_good]} - end) + TestHelper.eth_get_block_by_number_expectation(good_block_number) BlockNumber.set_max(good_block_number) @@ -489,40 +395,4 @@ defmodule Indexer.Fetcher.CoinBalance.CatchupTest do Process.sleep(100) wait(producer) end - - defp eth_block_number_fake_response(block_quantity) do - %{ - id: 0, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] - } - } - end end diff --git a/apps/indexer/test/indexer/fetcher/contract_code_test.exs b/apps/indexer/test/indexer/fetcher/contract_code_test.exs index e671eed641a6..9dd0f9dbbec3 100644 --- a/apps/indexer/test/indexer/fetcher/contract_code_test.exs +++ b/apps/indexer/test/indexer/fetcher/contract_code_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ContractCodeTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase @@ -6,7 +7,7 @@ defmodule Indexer.Fetcher.ContractCodeTest do import Mox - alias Explorer.Chain.{Address, Transaction} + alias Explorer.Chain.{Address, Data, Transaction} alias Indexer.Fetcher.ContractCode @moduletag :capture_log @@ -146,24 +147,48 @@ defmodule Indexer.Fetcher.ContractCodeTest do false ) - # Wait a bit to ensure any potential processing is done - Process.sleep(100) + updated_address = + wait(fn -> + Repo.one!( + from(address in Address, where: address.hash == ^address.hash and not is_nil(address.contract_code)) + ) + end) + + assert Data.to_string(updated_address.contract_code) == "0x" - # Verify that the contract code was set to "0x" - updated_address = Repo.get!(Address, address.hash) - assert to_string(updated_address.contract_code) == "0x" + updated_transaction = + wait(fn -> + Repo.one!( + from(transaction in Transaction, + where: transaction.hash == ^transaction.hash and not is_nil(transaction.created_contract_code_indexed_at) + ) + ) + end) - # Verify that the transaction's created_contract_code_indexed_at remains nil - updated_transaction = Repo.get!(Transaction, transaction.hash) assert updated_transaction.created_contract_code_indexed_at end end defp wait(producer) do + wait(producer, 10_000) + end + + defp wait(producer, timeout) when is_integer(timeout) and timeout > 0 do + deadline = System.monotonic_time(:millisecond) + timeout + + wait(producer, timeout, deadline) + end + + defp wait(producer, timeout, deadline) do producer.() rescue Ecto.NoResultsError -> - Process.sleep(100) - wait(producer) + if System.monotonic_time(:millisecond) > deadline do + raise RuntimeError, + "wait/1 timed out after #{timeout}ms while waiting for producer #{inspect(producer)}" + else + Process.sleep(100) + wait(producer, timeout, deadline) + end end end diff --git a/apps/indexer/test/indexer/fetcher/empty_blocks_sanitizer_test.exs b/apps/indexer/test/indexer/fetcher/empty_blocks_sanitizer_test.exs new file mode 100644 index 000000000000..31c39adbdec2 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/empty_blocks_sanitizer_test.exs @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.EmptyBlocksSanitizerTest do + # `async: false` due to use of named GenServer + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase, async: false + + import Mox + + alias Indexer.Fetcher.EmptyBlocksSanitizer + alias Explorer.Chain.Block + + @head_offset 1 + + # We decrease the number of blocks required to be inserted for the test + # in order to make it faster and to prevent filling the database with lots of trash. + # Otherwise, with default offset of `1000`, the tests start to flake periodically. + setup_all do + opts = Application.get_env(:indexer, EmptyBlocksSanitizer) + new_opts = Keyword.put(opts, :head_offset, @head_offset) + Application.put_env(:indexer, EmptyBlocksSanitizer, new_opts) + :ok + end + + setup :set_mox_global + setup :verify_on_exit! + + # Uncomment if you need to see what queries are sent to the Postgres + # (check the database logs) + # + # setup do + # Repo.query("load 'auto_explain';") + # Repo.query("SET auto_explain.log_min_duration = 0;") + # Repo.query("SET auto_explain.log_analyze = true;") + # :ok + # end + + @moduletag [capture_log: true, no_geth: true] + + test "process db-non-empty blocks", %{json_rpc_named_arguments: json_rpc_named_arguments} do + # Setup + block_to_process = insert(:block, is_empty: nil) + _transaction = insert(:transaction) |> with_block(block_to_process) + populate_database_with_dummy_blocks() + assert Repo.get!(Block, block_to_process.hash).is_empty == nil, "precondition to check setup correctness" + + EmptyBlocksSanitizer.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + processed_block = + wait_for_results(fn -> + Repo.one!( + from(block in Block, + where: block.hash == ^block_to_process.hash and block.updated_at != ^block_to_process.updated_at + ) + ) + end) + + assert processed_block.is_empty == false, "invalid `is_empty` value set for processed block" + assert processed_block.refetch_needed == false, "invalid `refetch_needed` value set for processed block" + end + + test "process db-empty blocks without transactions", %{json_rpc_named_arguments: json_rpc_named_arguments} do + # Setup + block_to_process = insert(:block, is_empty: nil) + populate_database_with_dummy_blocks() + assert Repo.get!(Block, block_to_process.hash).is_empty == nil, "precondition to check setup correctness" + + # Setup jsonrpc client + encoded_expected_block_number = "0x" <> Integer.to_string(block_to_process.number, 16) + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn [ + %{ + id: id, + method: "eth_getBlockByNumber", + params: [^encoded_expected_block_number, false] + } + ], + _options -> + eth_get_block_by_number_response(id, block_to_process.number, block_to_process.hash, []) + end + ) + end + + EmptyBlocksSanitizer.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + processed_block = + wait_for_results(fn -> + Repo.one!( + from(block in Block, + where: block.hash == ^block_to_process.hash and block.updated_at != ^block_to_process.updated_at + ) + ) + end) + + assert processed_block.is_empty == true, "invalid `is_empty` value set for processed block" + assert processed_block.refetch_needed == false, "invalid `refetch_needed` value set for processed block" + end + + test "process db-empty blocks with transactions", %{json_rpc_named_arguments: json_rpc_named_arguments} do + # Setup + block_to_process = insert(:block, is_empty: nil) + populate_database_with_dummy_blocks() + assert Repo.get!(Block, block_to_process.hash).is_empty == nil, "precondition to check setup correctness" + + # Setup jsonrpc client + encoded_expected_block_number = "0x" <> Integer.to_string(block_to_process.number, 16) + transaction_hash = "0x0000000000000000000000000000000000000000000000000000000000000001" + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn [ + %{ + id: id, + method: "eth_getBlockByNumber", + params: [^encoded_expected_block_number, false] + } + ], + _options -> + eth_get_block_by_number_response(id, encoded_expected_block_number, block_to_process.hash, [transaction_hash]) + end + ) + end + + EmptyBlocksSanitizer.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + processed_block = + wait_for_results(fn -> + Repo.one!( + from(block in Block, + where: block.hash == ^block_to_process.hash and block.updated_at != ^block_to_process.updated_at + ) + ) + end) + + assert processed_block.is_empty == nil, "invalid `is_empty` value set for processed block" + assert processed_block.refetch_needed == true, "invalid `refetch_needed` value set for processed block" + end + + test "only old enough blocks are sanitized", %{json_rpc_named_arguments: json_rpc_named_arguments} do + # Setup + block_to_process = insert(:block, is_empty: nil) + insert(:transaction) |> with_block(block_to_process) + + Enum.each(1..@head_offset, fn _index -> + insert(:block, is_empty: nil) + end) + + assert Repo.one!(from(b in Block, select: count("*"), where: is_nil(b.is_empty))) == @head_offset + 1 + + EmptyBlocksSanitizer.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + wait_for_results(fn -> + Repo.one!( + from(block in Block, + where: block.hash == ^block_to_process.hash and block.updated_at != ^block_to_process.updated_at + ) + ) + end) + + assert Repo.one!(from(b in Block, select: count("*"), where: is_nil(b.is_empty))) == @head_offset + end + + defp populate_database_with_dummy_blocks() do + Enum.each(1..@head_offset, fn _index -> + insert(:block, is_empty: true) + end) + end + + defp eth_get_block_by_number_response(id, encoded_block_number, block_hash, transaction_hashes) do + {:ok, + [ + %{ + id: id, + result: %{ + "difficulty" => "0x0", + "gasLimit" => "0x0", + "gasUsed" => "0x0", + "hash" => block_hash, + "extraData" => "0x0", + "logsBloom" => "0x0", + "miner" => "0x0", + "number" => encoded_block_number, + "parentHash" => "0x0", + "receiptsRoot" => "0x0", + "size" => "0x0", + "sha3Uncles" => "0x0", + "stateRoot" => "0x0", + "timestamp" => "0x0", + "totalDifficulty" => "0x0", + "transactions" => transaction_hashes, + "transactionsRoot" => "0x0", + "uncles" => [] + } + } + ]} + end +end diff --git a/apps/indexer/test/indexer/fetcher/internal_transaction/delete_queue_test.exs b/apps/indexer/test/indexer/fetcher/internal_transaction/delete_queue_test.exs new file mode 100644 index 000000000000..0d0fbc816c9b --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/internal_transaction/delete_queue_test.exs @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.InternalTransaction.DeleteQueueTest do + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.{InternalTransaction, PendingBlockOperation} + alias Explorer.Chain.InternalTransaction.DeleteQueue + alias Indexer.Fetcher.InternalTransaction.DeleteQueue, as: DeleteQueueFetcher + + setup :verify_on_exit! + setup :set_mox_global + + setup do + geth_config = Application.get_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth) + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, Keyword.put(geth_config, :block_traceable?, true)) + + fetcher_config = Application.get_env(:indexer, DeleteQueueFetcher) + Application.put_env(:indexer, DeleteQueueFetcher, Keyword.put(fetcher_config, :threshold, :timer.minutes(10))) + + on_exit(fn -> + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, geth_config) + Application.put_env(:indexer, DeleteQueueFetcher, fetcher_config) + end) + end + + test "deletes internal transactions and inserts pending operations" do + transaction_1 = + :transaction + |> insert() + |> with_block() + + transaction_2 = + :transaction + |> insert() + |> with_block() + + %{block_number: fresh_block_number} = + insert(:internal_transaction_delete_queue, block_number: transaction_1.block_number, updated_at: Timex.now()) + + %{block_number: expired_block_number} = + insert(:internal_transaction_delete_queue, + block_number: transaction_2.block_number, + updated_at: Timex.shift(Timex.now(), minutes: -20) + ) + + insert(:internal_transaction, + transaction: transaction_1, + index: 0, + block_number: fresh_block_number, + transaction_index: transaction_1.index + ) + + insert(:internal_transaction, + transaction: transaction_2, + index: 0, + block_number: expired_block_number, + transaction_index: transaction_2.index + ) + + pid = DeleteQueueFetcher.Supervisor.Case.start_supervised!() + + wait_for_results(fn -> + PendingBlockOperation + |> limit(1) + |> Repo.one!() + end) + + assert [%{block_number: ^fresh_block_number}] = Repo.all(DeleteQueue) + assert [%{block_number: ^fresh_block_number}] = Repo.all(InternalTransaction) + assert [%{block_number: ^expired_block_number}] = Repo.all(PendingBlockOperation) + + GenServer.stop(pid) + end +end diff --git a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs index 8d864bb2c347..00846fdb95ed 100644 --- a/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/internal_transaction_test.exs @@ -1,7 +1,11 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.InternalTransactionTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity] + import ExUnit.CaptureLog import Mox @@ -10,7 +14,9 @@ defmodule Indexer.Fetcher.InternalTransactionTest do alias Explorer.Chain.{Block, PendingBlockOperation, PendingTransactionOperation} alias Explorer.Chain.Import.Runner.Blocks alias Indexer.Fetcher.CoinBalance.Catchup, as: CoinBalanceCatchup - alias Indexer.Fetcher.{InternalTransaction, PendingTransaction, TokenBalance} + alias Indexer.Fetcher.{InternalTransaction, PendingTransaction} + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical # MUST use global mode because we aren't guaranteed to get PendingTransactionFetcher's pid back fast enough to `allow` # it to use expectations and stubs from test's pid. @@ -77,9 +83,12 @@ defmodule Indexer.Fetcher.InternalTransactionTest do PendingTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) start_token_balance_fetcher(json_rpc_named_arguments) - wait_for_results(fn -> - Repo.one!(from(transaction in Explorer.Chain.Transaction, where: is_nil(transaction.block_hash), limit: 1)) - end) + wait_for_results( + fn -> + Repo.one!(from(transaction in Explorer.Chain.Transaction, where: is_nil(transaction.block_hash), limit: 1)) + end, + 60 + ) hash_strings = InternalTransaction.init([], fn hash_string, acc -> [hash_string | acc] end, json_rpc_named_arguments) @@ -126,9 +135,18 @@ defmodule Indexer.Fetcher.InternalTransactionTest do end describe "init/2" do + setup do + initial_env = Application.get_env(:indexer, Indexer.Fetcher.InternalTransaction) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction, initial_env) + end) + end + test "buffers blocks with unfetched internal transactions", %{ json_rpc_named_arguments: json_rpc_named_arguments } do + Application.put_env(:indexer, Indexer.Fetcher.InternalTransaction, disabled?: false) block = insert(:block) insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) @@ -199,7 +217,8 @@ defmodule Indexer.Fetcher.InternalTransactionTest do block = insert(:block) transaction = insert(:transaction) |> with_block(block) block_hash = block.hash - insert(:pending_block_operation, block_hash: block_hash, block_number: block.number) + block_number = block.number + insert(:pending_block_operation, block_hash: block_hash, block_number: block_number) if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do case Keyword.fetch!(json_rpc_named_arguments, :variant) do @@ -298,7 +317,7 @@ defmodule Indexer.Fetcher.InternalTransactionTest do assert nil == Repo.get(PendingBlockOperation, block_hash) - assert Repo.exists?(from(i in Chain.InternalTransaction, where: i.block_hash == ^block_hash)) + assert Repo.exists?(from(i in Chain.InternalTransaction, where: i.block_number == ^block_number)) end test "handles failure by retrying only unique numbers", %{ @@ -321,201 +340,6 @@ defmodule Indexer.Fetcher.InternalTransactionTest do assert %{block_hash: ^block_hash} = Repo.get(PendingBlockOperation, block_hash) end - - test "set block refetch_needed=true on foreign_key_violation", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - block = insert(:block) - transaction = :transaction |> insert() |> with_block(block) - block_number = block.number - insert(:pending_block_operation, block_hash: block.hash, block_number: block.number) - - if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do - case Keyword.fetch!(json_rpc_named_arguments, :variant) do - EthereumJSONRPC.Nethermind -> - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [%{id: id, method: "trace_replayBlockTransactions"}], _options -> - {:ok, - [ - %{ - id: id, - result: [ - %{ - "output" => "0x", - "stateDiff" => nil, - "trace" => [ - %{ - "action" => %{ - "callType" => "call", - "from" => "0xa931c862e662134b85e4dc4baf5c70cc9ba74db4", - "gas" => "0x8600", - "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", - "to" => "0x1469b17ebf82fedf56f04109e5207bdc4554288c", - "value" => "0x174876e800" - }, - "result" => %{"gasUsed" => "0x7d37", "output" => "0x"}, - "subtraces" => 1, - "traceAddress" => [], - "type" => "call" - }, - %{ - "action" => %{ - "callType" => "call", - "from" => "0xb37b428a7ddee91f39b26d79d23dc1c89e3e12a7", - "gas" => "0x32dcf", - "input" => "0x42dad49e", - "to" => "0xee4019030fb5c2b68c42105552c6268d56c6cbfe", - "value" => "0x0" - }, - "result" => %{ - "gasUsed" => "0xb08", - "output" => "0x" - }, - "subtraces" => 0, - "traceAddress" => [0], - "type" => "call" - } - ], - "transactionHash" => transaction.hash, - "vmTrace" => nil - }, - %{ - "output" => "0x", - "stateDiff" => nil, - "trace" => [ - %{ - "action" => %{ - "callType" => "call", - "from" => "0xa931c862e662134b85e4dc4baf5c70cc9ba74db4", - "gas" => "0x8600", - "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", - "to" => "0x1469b17ebf82fedf56f04109e5207bdc4554288c", - "value" => "0x174876e800" - }, - "result" => %{"gasUsed" => "0x7d37", "output" => "0x"}, - "subtraces" => 1, - "traceAddress" => [], - "type" => "call" - }, - %{ - "action" => %{ - "callType" => "call", - "from" => "0xb37b428a7ddee91f39b26d79d23dc1c89e3e12a7", - "gas" => "0x32dcf", - "input" => "0x42dad49e", - "to" => "0xee4019030fb5c2b68c42105552c6268d56c6cbfe", - "value" => "0x0" - }, - "result" => %{ - "gasUsed" => "0xb08", - "output" => "0x" - }, - "subtraces" => 0, - "traceAddress" => [0], - "type" => "call" - } - ], - "transactionHash" => transaction_hash(), - "vmTrace" => nil - } - ] - } - ]} - end) - - EthereumJSONRPC.Geth -> - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [%{id: id, method: "debug_traceTransaction"}], _options -> - {:ok, - [ - %{ - id: id, - result: [ - %{ - "blockNumber" => block.number, - "transactionIndex" => 0, - "transactionHash" => transaction.hash, - "index" => 0, - "traceAddress" => [], - "type" => "call", - "callType" => "call", - "from" => "0xa931c862e662134b85e4dc4baf5c70cc9ba74db4", - "to" => "0x1469b17ebf82fedf56f04109e5207bdc4554288c", - "gas" => "0x8600", - "gasUsed" => "0x7d37", - "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", - "output" => "0x", - "value" => "0x174876e800" - }, - %{ - "blockNumber" => block.number, - "transactionIndex" => 0, - "transactionHash" => transaction_hash(), - "index" => 0, - "traceAddress" => [], - "type" => "call", - "callType" => "call", - "from" => "0xa931c862e662134b85e4dc4baf5c70cc9ba74db4", - "to" => "0x1469b17ebf82fedf56f04109e5207bdc4554288c", - "gas" => "0x8600", - "gasUsed" => "0x7d37", - "input" => "0xb118e2db0000000000000000000000000000000000000000000000000000000000000008", - "output" => "0x", - "value" => "0x174876e800" - } - ] - } - ]} - end) - - variant_name -> - raise ArgumentError, "Unsupported variant name (#{variant_name})" - end - end - - logs = - capture_log(fn -> - assert {:retry, [^block_number]} = InternalTransaction.run([block_number], json_rpc_named_arguments) - end) - - assert %{consensus: true, refetch_needed: true} = Repo.reload(block) - assert logs =~ "foreign_key_violation on internal transactions import, foreign transactions hashes:" - end - end - - test "doesn't delete pending block operations after block import if no async process was requested", %{ - json_rpc_named_arguments: json_rpc_named_arguments - } do - fetcher_options = - Keyword.merge([poll: true, json_rpc_named_arguments: json_rpc_named_arguments], InternalTransaction.defaults()) - - if fetcher_options[:poll] do - expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id}], _options -> - {:ok, [%{id: id, result: []}]} - end) - end - - InternalTransaction.Supervisor.Case.start_supervised!(fetcher_options) - - %Ecto.Changeset{valid?: true, changes: block_changes} = - Block.changeset(%Block{}, params_for(:block, miner_hash: insert(:address).hash, number: 1)) - - changes_list = [block_changes] - timestamp = DateTime.utc_now() - options = %{timestamps: %{inserted_at: timestamp, updated_at: timestamp}} - - assert [] = Repo.all(PendingBlockOperation) - - {:ok, %{blocks: [%{number: block_number, hash: block_hash}]}} = - Multi.new() - |> Blocks.run(changes_list, options) - |> Repo.transaction() - - assert %{block_number: ^block_number, block_hash: ^block_hash} = Repo.one(PendingBlockOperation) - - Process.sleep(4000) - - assert %{block_number: ^block_number, block_hash: ^block_hash} = Repo.one(PendingBlockOperation) end if Application.compile_env(:explorer, :chain_type) == :arbitrum do @@ -532,10 +356,9 @@ defmodule Indexer.Fetcher.InternalTransactionTest do |> Enum.concat([{:transport_options, [http_options: []]}]) block = insert(:block, number: 1) - _transaction = :transaction |> insert() |> with_block(block) + transaction = :transaction |> insert() |> with_block(block) block_number = block.number - block_hash = block.hash - insert(:pending_block_operation, block_hash: block_hash, block_number: block_number) + insert(:pending_transaction_operation, transaction_hash: transaction.hash) EthereumJSONRPC.Mox |> expect(:json_rpc, fn [%{id: id, method: "debug_traceTransaction"}], _options -> @@ -608,29 +431,34 @@ defmodule Indexer.Fetcher.InternalTransactionTest do CoinBalanceCatchup.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - assert %{block_hash: block_hash} = Repo.get(PendingBlockOperation, block_hash) + assert %{} = Repo.get(PendingTransactionOperation, transaction.hash) - assert :ok == InternalTransaction.run([block_number], json_rpc_named_arguments) + assert :ok == + InternalTransaction.run( + [%{block_number: transaction.block_number, hash: transaction.hash, index: transaction.index}], + json_rpc_named_arguments + ) - assert nil == Repo.get(PendingBlockOperation, block_hash) + assert nil == Repo.get(PendingTransactionOperation, transaction.hash) - internal_transactions = Repo.all(from(i in Chain.InternalTransaction, where: i.block_hash == ^block_hash)) + internal_transactions = Repo.all(from(i in Chain.InternalTransaction, where: i.block_number == ^block_number)) assert Enum.count(internal_transactions) > 0 last_internal_transaction = List.last(internal_transactions) assert last_internal_transaction.type == :call - assert last_internal_transaction.call_type == :invalid + assert last_internal_transaction.call_type_enum == :invalid end end # Due to token-duality feature in Celo network (native coin transfers are # treated as token transfers), we need to fetch updated token balances after # parsing the internal transactions - if Application.compile_env(:explorer, :chain_type) == :celo do + if @chain_identity == {:optimism, :celo} do defp start_token_balance_fetcher(json_rpc_named_arguments) do - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) end else defp start_token_balance_fetcher(_json_rpc_named_arguments), do: :ok diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs new file mode 100644 index 000000000000..13c993a0dfc2 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/balances_export_queue_test.exs @@ -0,0 +1,364 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.BalancesExportQueueTest do + use ExUnit.Case + use Explorer.DataCase + + import ExUnit.CaptureLog, only: [capture_log: 1] + + alias Explorer.Chain + alias Explorer.Chain.Wei + alias Explorer.Chain.MultichainSearchDb.BalancesExportQueue + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.TestHelper + + alias Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue, + as: MultichainSearchDbExportBalancesExportQueue + + alias Plug.Conn + + @moduletag :capture_log + + setup do + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + Application.put_env(:indexer, MultichainSearchDbExportBalancesExportQueue.Supervisor, disabled?: false) + + on_exit(fn -> + Application.put_env(:indexer, MultichainSearchDbExportBalancesExportQueue.Supervisor, disabled?: true) + end) + + :ok + end + + describe "init/3" do + setup do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + end) + end + + test "initializes with data from the retry queue" do + {:ok, address_hash} = + Chain.string_to_address_hash("0x66A9B160F6a06f53f23785F069882Ee7337180E8") + + erc_20_contract_address_hash_bytes = "A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" |> Base.decode16!(case: :mixed) + nft_contract_address_hash_bytes = "B01b6a7EF8560017AA59A990e39f26b8df29F80f" |> Base.decode16!(case: :mixed) + + insert(:multichain_search_db_export_balances_queue, %{ + address_hash: address_hash, + token_contract_address_hash_or_native: "native", + value: 100, + token_id: nil + }) + + insert(:multichain_search_db_export_balances_queue, %{ + address_hash: address_hash, + token_contract_address_hash_or_native: erc_20_contract_address_hash_bytes, + value: 200, + token_id: nil + }) + + insert(:multichain_search_db_export_balances_queue, %{ + address_hash: address_hash, + token_contract_address_hash_or_native: nft_contract_address_hash_bytes, + value: nil, + token_id: 12345 + }) + + reducer = fn data, acc -> [data | acc] end + + pid = + [] + |> MultichainSearchDbExportBalancesExportQueue.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + + results = MultichainSearchDbExportBalancesExportQueue.init([], reducer, nil) + + assert Enum.count(results) == 3 + + assert Enum.member?(results, %{ + address_hash: address_hash, + token_contract_address_hash_or_native: "native", + value: %Wei{value: Decimal.new(100)}, + token_id: nil + }) + + assert Enum.member?(results, %{ + address_hash: address_hash, + token_contract_address_hash_or_native: erc_20_contract_address_hash_bytes, + value: %Wei{value: Decimal.new(200)}, + token_id: nil + }) + + assert Enum.member?(results, %{ + address_hash: address_hash, + token_contract_address_hash_or_native: nft_contract_address_hash_bytes, + value: nil, + token_id: Decimal.new(12345) + }) + + :timer.sleep(10) + GenServer.stop(pid) + end + end + + describe "run/2" do + setup do + bypass = Bypass.open() + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end) + + {:ok, bypass: bypass} + end + + test "successfully processes multichain search db export retry queue data", %{bypass: bypass} do + {:ok, address_hash} = + Chain.string_to_address_hash("0x66A9B160F6a06f53f23785F069882Ee7337180E8") + + {:ok, erc_20_contract_address_hash} = + Chain.string_to_address_hash("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + + {:ok, nft_contract_address_hash} = + Chain.string_to_address_hash("0xB01b6a7EF8560017AA59A990e39f26b8df29F80f") + + export_data = [ + %{ + address_hash: address_hash, + token_contract_address_hash_or_native: "native", + value: %Wei{value: Decimal.new(100)} + }, + %{ + address_hash: erc_20_contract_address_hash, + token_contract_address_hash_or_native: erc_20_contract_address_hash.bytes, + value: %Wei{value: Decimal.new(200)}, + token_id: nil + }, + %{ + address_hash: nft_contract_address_hash, + token_contract_address_hash_or_native: nft_contract_address_hash.bytes, + value: nil, + token_id: Decimal.new(12345) + } + ] + + TestHelper.get_chain_id_mock() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{"status" => "ok"}) + ) + end) + + assert :ok = MultichainSearchDbExportBalancesExportQueue.run(export_data, nil) + end + + test "returns {:retry, failed_data} on error where failed_data is only chunks that failed to export", %{ + bypass: _bypass + } do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + addresses_chunk_size: 1 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + end) + + address_1 = insert(:address) + address_1_hash = address_1.hash + address_1_hash_string = to_string(address_1_hash) + address_2 = insert(:address) + address_2_hash = address_2.hash + address_2_hash_string = to_string(address_2_hash) + address_3 = insert(:address) + address_3_hash = address_3.hash + address_3_hash_string = to_string(address_3_hash) + address_4 = insert(:address) + address_5 = insert(:address) + address_5_hash = address_5.hash + address_5_hash_string = to_string(address_5_hash) + token_address_1 = insert(:address) + token_address_1_hash_string = to_string(token_address_1) |> String.downcase() + token_address_2 = insert(:address) + token_address_2_hash_string = to_string(token_address_2) |> String.downcase() + + export_data = [ + %{ + address_hash: address_1.hash, + token_contract_address_hash_or_native: "native", + value: %Wei{value: Decimal.new(100)} + }, + %{ + address_hash: address_2.hash, + token_contract_address_hash_or_native: token_address_1.hash.bytes, + value: %Wei{value: Decimal.new(200)}, + token_id: nil + }, + %{ + address_hash: address_3.hash, + token_contract_address_hash_or_native: token_address_2.hash.bytes, + value: %Wei{value: Decimal.new(300)}, + token_id: nil + }, + %{ + address_hash: address_4.hash, + token_contract_address_hash_or_native: "native", + value: %Wei{value: Decimal.new(400)} + }, + %{ + address_hash: address_5.hash, + token_contract_address_hash_or_native: token_address_1.hash.bytes, + value: %Wei{value: Decimal.new(500)}, + token_id: nil + } + ] + + TestHelper.get_chain_id_mock() + + tesla_expectations(address_1_hash_string) + + val0 = Decimal.new(100) |> Wei.cast() |> elem(1) + val1 = Decimal.new(200) |> Wei.cast() |> elem(1) + val2 = Decimal.new(300) |> Wei.cast() |> elem(1) + val4 = Decimal.new(500) |> Wei.cast() |> elem(1) + + log = + capture_log(fn -> + assert {:retry, + %{ + address_coin_balances: [ + %{ + address_hash: ^address_1_hash_string, + token_contract_address_hash_or_native: "native", + value: ^val0 + } + ], + address_token_balances: [ + %{ + address_hash: ^address_5_hash_string, + token_address_hash: ^token_address_1_hash_string, + value: ^val4, + token_id: nil + }, + %{ + address_hash: ^address_3_hash_string, + token_address_hash: ^token_address_2_hash_string, + value: ^val2, + token_id: nil + }, + %{ + address_hash: ^address_2_hash_string, + token_address_hash: ^token_address_1_hash_string, + value: ^val1, + token_id: nil + } + ] + }} = MultichainSearchDbExportBalancesExportQueue.run(export_data, nil) + end) + + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 4 + results = Repo.all(BalancesExportQueue) + assert Enum.all?(results, &(&1.retries_number == nil)) + assert log =~ "Batch balances export retry to the Multichain Search DB failed" + + TestHelper.get_chain_id_mock() + + tesla_expectations(address_1_hash_string) + + MultichainSearchDbExportBalancesExportQueue.run(export_data, nil) + + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 4 + results = Repo.all(BalancesExportQueue) + assert Enum.all?(results, &(&1.retries_number == 1)) + + # Check, that `retries_number` is incrementing + + TestHelper.get_chain_id_mock() + + tesla_expectations(address_1_hash_string) + + MultichainSearchDbExportBalancesExportQueue.run(export_data, nil) + + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 4 + results = Repo.all(BalancesExportQueue) + assert Enum.all?(results, &(&1.retries_number == 2)) + + export_data_2 = [ + %{ + address_hash: address_2.hash, + token_contract_address_hash_or_native: token_address_1.hash.bytes, + value: %Wei{value: Decimal.new(200)}, + token_id: nil + }, + %{ + address_hash: address_3.hash, + token_contract_address_hash_or_native: token_address_2.hash.bytes, + value: %Wei{value: Decimal.new(300)}, + token_id: nil + }, + %{ + address_hash: address_1.hash, + token_contract_address_hash_or_native: "native", + value: %Wei{value: Decimal.new(100)} + }, + %{ + address_hash: address_5.hash, + token_contract_address_hash_or_native: token_address_1.hash.bytes, + value: %Wei{value: Decimal.new(500)}, + token_id: nil + } + ] + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + + MultichainSearchDbExportBalancesExportQueue.run(export_data_2, nil) + + assert Repo.aggregate(BalancesExportQueue, :count, :id) == 0 + + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + end + end + + defp tesla_expectations(address_hash_string) do + Tesla.Test.expect_tesla_call( + times: 2, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + {:ok, %{"address_coin_balances" => [%{"address_hash" => ^address_hash_string}]}} -> + {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + end +end diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs new file mode 100644 index 000000000000..9b5ed4e50216 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_export_queue_test.exs @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.CountersExportQueueTest do + use ExUnit.Case + use Explorer.DataCase, async: false + + import ExUnit.CaptureLog, only: [capture_log: 1] + + alias Explorer.Chain.MultichainSearchDb.CountersExportQueue + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.TestHelper + alias Explorer.Repo + alias Indexer.Fetcher.MultichainSearchDb.CountersExportQueue, as: MultichainSearchDbCountersExportQueue + alias Plug.Conn + + @moduletag :capture_log + + setup do + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + + previous_supervisor_config = + Application.get_env(:indexer, MultichainSearchDbCountersExportQueue.Supervisor) + + Application.put_env(:indexer, MultichainSearchDbCountersExportQueue.Supervisor, disabled?: false) + + on_exit(fn -> + Application.put_env( + :indexer, + MultichainSearchDbCountersExportQueue.Supervisor, + previous_supervisor_config + ) + end) + + :ok + end + + describe "init/3" do + setup do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + counters_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, counters_chunk_size: 1000) + end) + end + + test "initializes with data from the retry queue" do + counter_item_1 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(1), data: counter_data("10")) + + counter_item_2 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(2), data: counter_data("20")) + + counter_item_3 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(3), data: counter_data("30")) + + reducer = fn data, acc -> [data | acc] end + + pid = + [] + |> MultichainSearchDbCountersExportQueue.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + + results = MultichainSearchDbCountersExportQueue.init([], reducer, nil) + + assert Enum.count(results) == 3 + + assert Enum.member?(results, %{ + timestamp: counter_item_1.timestamp, + counter_type: counter_item_1.counter_type, + data: counter_item_1.data + }) + + assert Enum.member?(results, %{ + timestamp: counter_item_2.timestamp, + counter_type: counter_item_2.counter_type, + data: counter_item_2.data + }) + + assert Enum.member?(results, %{ + timestamp: counter_item_3.timestamp, + counter_type: counter_item_3.counter_type, + data: counter_item_3.data + }) + + :timer.sleep(100) + GenServer.stop(pid) + end + end + + describe "run/2" do + setup do + bypass = Bypass.open() + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + counters_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, counters_chunk_size: 1000) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end) + + {:ok, bypass: bypass} + end + + test "successfully processes multichain search db export counters retry queue data", %{bypass: bypass} do + export_data = [ + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(1), data: counter_data("10")), + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(2), data: counter_data("20")), + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(3), data: counter_data("30")) + ] + + TestHelper.get_chain_id_mock() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{"status" => "ok"}) + ) + end) + + assert :ok = MultichainSearchDbCountersExportQueue.run(export_data, nil) + assert Repo.aggregate(CountersExportQueue, :count) == 0 + end + + test "returns {:retry, failed_data} on error where failed_data is only chunks that failed to export" do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + counters_chunk_size: 1 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, counters_chunk_size: 1000) + end) + + counter_item_1 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(1), data: counter_data("10")) + + counter_item_2 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(2), data: counter_data("20")) + + counter_item_3 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(3), data: counter_data("30")) + + counter_item_4 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(4), data: counter_data("40")) + + counter_item_5 = + insert(:multichain_search_db_export_counters_queue, timestamp: counter_timestamp(5), data: counter_data("50")) + + export_data = [counter_item_1, counter_item_2, counter_item_3, counter_item_4, counter_item_5] + failed_timestamp_string = Integer.to_string(DateTime.to_unix(counter_item_4.timestamp)) + + TestHelper.get_chain_id_mock() + + tesla_expectations(failed_timestamp_string, 5) + + log = + capture_log(fn -> + assert {:retry, [item_to_retry]} = MultichainSearchDbCountersExportQueue.run(export_data, nil) + + assert item_to_retry.timestamp == counter_item_4.timestamp + assert item_to_retry.counter_type == counter_item_4.counter_type + assert item_to_retry.data == counter_item_4.data + end) + + assert Repo.aggregate(CountersExportQueue, :count) == 1 + assert [remaining_item] = Repo.all(CountersExportQueue) + assert remaining_item.retries_number == 1 + assert log =~ "Batch counters export attempt to the Multichain Search DB failed" + + TestHelper.get_chain_id_mock() + + tesla_expectations(failed_timestamp_string, 1) + + MultichainSearchDbCountersExportQueue.run([counter_item_4], nil) + + assert Repo.aggregate(CountersExportQueue, :count) == 1 + assert [remaining_item] = Repo.all(CountersExportQueue) + assert remaining_item.retries_number == 2 + + TestHelper.get_chain_id_mock() + + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch"}, _opts -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + ) + + assert :ok = MultichainSearchDbCountersExportQueue.run([counter_item_4], nil) + assert Repo.aggregate(CountersExportQueue, :count) == 0 + end + end + + defp tesla_expectations(failed_timestamp_string, times) do + Tesla.Test.expect_tesla_call( + times: times, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + {:ok, %{"counters" => [%{"timestamp" => ^failed_timestamp_string}]}} -> + {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + end + + defp counter_timestamp(offset_in_seconds) do + DateTime.from_unix!(1_700_000_000 + offset_in_seconds) + end + + defp counter_data(value) do + %{ + "daily_transactions_number" => value, + "total_transactions_number" => Integer.to_string(String.to_integer(value) * 10), + "total_addresses_number" => Integer.to_string(String.to_integer(value) * 100) + } + end +end diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_fetcher_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_fetcher_test.exs new file mode 100644 index 000000000000..7eabfedf1337 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/counters_fetcher_test.exs @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.CountersFetcherTest do + use ExUnit.Case + use Explorer.DataCase, async: false + + import ExUnit.CaptureLog, only: [capture_log: 1] + + alias Explorer.Chain.MultichainSearchDb.CountersExportQueue + alias Explorer.Chain.Cache.Counters.LastFetchedCounter + alias Explorer.Chain.Transaction.History.{Historian, TransactionStats} + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.Repo + alias Indexer.Fetcher.MultichainSearchDb.CountersFetcher + + @moduletag :capture_log + + setup do + previous_multichain_config = Application.get_env(:explorer, MultichainSearch) + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + counters_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, previous_multichain_config) + end) + + :ok + end + + describe "handle_info/2" do + test "waits until transaction stats are collected for today" do + today = Date.utc_today() + yesterday = Date.add(today, -1) + + :ok = put_last_save_records_date(yesterday) + + log = + capture_log(fn -> + assert {:noreply, %{}} = CountersFetcher.handle_info(:try_to_fetch_yesterday_counters, %{}) + end) + + assert Repo.aggregate(CountersExportQueue, :count) == 0 + refute_received :fetch_yesterday_counters + assert log =~ "Waiting for transaction stats to be collected for #{yesterday}" + end + + test "queues yesterday counters once transaction stats are ready" do + today = Date.utc_today() + yesterday = Date.add(today, -1) + yesterday_dt = DateTime.new!(yesterday, Time.new!(23, 59, 59, 0)) + + :ok = put_last_save_records_date(today) + + Repo.insert_all(TransactionStats, [ + %{date: yesterday, number_of_transactions: 7} + ]) + + insert(:address, inserted_at: DateTime.add(yesterday_dt, -3600, :second)) + insert(:address, inserted_at: DateTime.add(yesterday_dt, -1800, :second)) + insert(:address, inserted_at: DateTime.add(yesterday_dt, 2, :second)) + + past_block = insert(:block, timestamp: DateTime.add(yesterday_dt, -900, :second), consensus: true) + future_block = insert(:block, timestamp: DateTime.add(yesterday_dt, 2, :second), consensus: true) + non_consensus_block = insert(:block, timestamp: DateTime.add(yesterday_dt, -600, :second), consensus: false) + + insert(:transaction) + |> with_block(past_block, block_timestamp: past_block.timestamp, block_consensus: true, status: :ok) + + insert(:transaction) + |> with_block(past_block, block_timestamp: past_block.timestamp, block_consensus: true, status: :ok) + + insert(:transaction) + |> with_block(future_block, block_timestamp: future_block.timestamp, block_consensus: true, status: :ok) + + insert(:transaction, + block_hash: non_consensus_block.hash, + block_number: non_consensus_block.number, + block_timestamp: non_consensus_block.timestamp, + block_consensus: false, + cumulative_gas_used: 21_000, + gas_used: 21_000, + index: 0, + status: :ok + ) + + log = + capture_log(fn -> + assert {:noreply, %{number_of_transactions: 7, yesterday: ^yesterday}} = + CountersFetcher.handle_info(:try_to_fetch_yesterday_counters, %{}) + + assert_receive :fetch_yesterday_counters + + assert {:noreply, %{}} = + CountersFetcher.handle_info(:fetch_yesterday_counters, %{ + number_of_transactions: 7, + yesterday: yesterday + }) + end) + + assert [queued_counter] = Repo.all(CountersExportQueue) + + assert queued_counter.timestamp == yesterday_dt + assert queued_counter.counter_type == :global + + assert queued_counter.data == %{ + "daily_transactions_number" => "7", + "total_transactions_number" => "2", + "total_addresses_number" => "2" + } + + assert log =~ "Transaction stats is now available for #{yesterday}" + assert log =~ "daily_transactions_number = 7" + assert log =~ "total_transactions_number = 2" + assert log =~ "total_addresses_number = 2" + end + end + + defp put_last_save_records_date(date) do + date + |> DateTime.new!(Time.new!(0, 0, 0, 0)) + |> DateTime.to_unix() + |> then(fn unix_timestamp -> + LastFetchedCounter.upsert(%{ + counter_type: Historian.transaction_stats_last_save_records_timestamp(), + value: unix_timestamp + }) + end) + |> case do + {:ok, _counter} -> :ok + {:error, error} -> raise inspect(error) + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs new file mode 100644 index 000000000000..7702ddedbfc2 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/main_export_queue_test.exs @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.MainExportQueueTest do + use ExUnit.Case + use Explorer.DataCase + + import ExUnit.CaptureLog, only: [capture_log: 1] + + alias Explorer.Chain.MultichainSearchDb.MainExportQueue + alias Explorer.Chain.Block.Range + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.TestHelper + alias Indexer.Fetcher.MultichainSearchDb.MainExportQueue, as: MultichainSearchDbMainExportQueue + alias Plug.Conn + + @moduletag :capture_log + + setup do + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + Application.put_env(:indexer, MultichainSearchDbMainExportQueue.Supervisor, disabled?: false) + + on_exit(fn -> + Application.put_env(:indexer, MultichainSearchDbMainExportQueue.Supervisor, disabled?: true) + end) + + :ok + end + + describe "init/3" do + setup do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + end) + end + + test "initializes with data from the retry queue" do + address_hash_bytes = "66A9B160F6a06f53f23785F069882Ee7337180E8" |> Base.decode16!(case: :mixed) + + block_hash_bytes = + "bba915260f4859d2c908d31296e125368c01e05ee59e2e691ea8d69cceac6e1b" |> Base.decode16!(case: :mixed) + + transaction_hash_bytes = + "aba197aa8a13871bdd53861f7b5108394000fc0f72893661ae39610e9cd94019" |> Base.decode16!(case: :mixed) + + insert(:multichain_search_db_main_export_queue, %{hash: address_hash_bytes, hash_type: :address}) + insert(:multichain_search_db_main_export_queue, %{hash: block_hash_bytes, hash_type: :block}) + insert(:multichain_search_db_main_export_queue, %{hash: transaction_hash_bytes, hash_type: :transaction}) + + reducer = fn data, acc -> [data | acc] end + + pid = + [] + |> MultichainSearchDbMainExportQueue.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + + results = MultichainSearchDbMainExportQueue.init([], reducer, nil) + + assert Enum.count(results) == 3 + assert Enum.member?(results, %{hash: address_hash_bytes, hash_type: :address, block_range: nil}) + assert Enum.member?(results, %{hash: block_hash_bytes, hash_type: :block, block_range: nil}) + assert Enum.member?(results, %{hash: transaction_hash_bytes, hash_type: :transaction, block_range: nil}) + :timer.sleep(10) + GenServer.stop(pid) + end + end + + describe "run/2" do + setup do + bypass = Bypass.open() + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + addresses_chunk_size: 7000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end) + + {:ok, bypass: bypass} + end + + test "successfully processes multichain search db export retry queue data", %{bypass: bypass} do + address_hash_bytes = "66A9B160F6a06f53f23785F069882Ee7337180E8" |> Base.decode16!(case: :mixed) + + block_hash_bytes = + "bba915260f4859d2c908d31296e125368c01e05ee59e2e691ea8d69cceac6e1b" |> Base.decode16!(case: :mixed) + + transaction_hash_bytes = + "aba197aa8a13871bdd53861f7b5108394000fc0f72893661ae39610e9cd94019" |> Base.decode16!(case: :mixed) + + export_data = [ + %{hash: address_hash_bytes, hash_type: :address, block_range: nil}, + %{hash: block_hash_bytes, hash_type: :block, block_range: nil}, + %{hash: transaction_hash_bytes, hash_type: :transaction, block_range: nil} + ] + + TestHelper.get_chain_id_mock() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{"status" => "ok"}) + ) + end) + + assert :ok = MultichainSearchDbMainExportQueue.run(export_data, nil) + end + + test "returns {:retry, failed_data} on error where failed_data is only chunks that failed to export", %{ + bypass: _bypass + } do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + addresses_chunk_size: 1 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, addresses_chunk_size: 7000) + end) + + address_1 = insert(:address) + address_1_hash_string = to_string(address_1) |> String.downcase() + address_2 = insert(:address) + address_2_hash_string = to_string(address_2) |> String.downcase() + address_3 = insert(:address) + block = insert(:block, number: 1) + block_number_string = to_string(block.number) + block_hash_string = to_string(block.hash) + transaction = insert(:transaction) |> with_block(block) + transaction_hash_string = to_string(transaction.hash) + + export_data = [ + %{hash: address_1.hash.bytes, hash_type: :address, block_range: %Range{from: block.number, to: block.number}}, + %{hash: address_2.hash.bytes, hash_type: :address, block_range: %Range{from: block.number, to: block.number}}, + %{hash: address_3.hash.bytes, hash_type: :address, block_range: %Range{from: block.number, to: block.number}}, + %{hash: block.hash.bytes, hash_type: :block, block_range: %Range{from: block.number, to: block.number}}, + %{ + hash: transaction.hash.bytes, + hash_type: :transaction, + block_range: %Range{from: block.number, to: block.number} + } + ] + + TestHelper.get_chain_id_mock() + + Tesla.Test.expect_tesla_call( + times: 3, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + {:ok, %{"block_ranges" => [%{"max_block_number" => _, "min_block_number" => _}]}} -> + {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + + {:ok, %{"addresses" => [%{"hash" => ^address_2_hash_string}]}} -> + {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + + log = + capture_log(fn -> + assert {:retry, + %{ + addresses: [ + %{ + hash: ^address_2_hash_string, + is_contract: false, + contract_name: nil, + is_verified_contract: false + }, + %{ + hash: ^address_1_hash_string, + is_contract: false, + contract_name: nil, + is_verified_contract: false + } + ], + block_ranges: [%{max_block_number: ^block_number_string, min_block_number: ^block_number_string}], + hashes: [ + %{hash: ^block_hash_string, hash_type: "BLOCK"}, + %{ + hash: ^transaction_hash_string, + hash_type: "TRANSACTION" + } + ] + }} = MultichainSearchDbMainExportQueue.run(export_data, nil) + end) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 4 + results = Repo.all(MainExportQueue) + assert Enum.all?(results, &(&1.retries_number == nil)) + + assert log =~ "Batch main export retry to the Multichain Search DB failed" + + export_data_2 = [ + %{hash: address_2.hash.bytes, hash_type: :address, block_range: %Range{from: block.number, to: block.number}}, + %{hash: address_1.hash.bytes, hash_type: :address, block_range: %Range{from: block.number, to: block.number}}, + %{hash: block.hash.bytes, hash_type: :block, block_range: %Range{from: block.number, to: block.number}}, + %{ + hash: transaction.hash.bytes, + hash_type: :transaction, + block_range: %Range{from: block.number, to: block.number} + } + ] + + TestHelper.get_chain_id_mock() + + tesla_expectations() + + MultichainSearchDbMainExportQueue.run(export_data_2, nil) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 3 + results = Repo.all(MainExportQueue) + assert Enum.all?(results, &(&1.retries_number == 1)) + + # Check, that `retries_number` is incrementing + + TestHelper.get_chain_id_mock() + + tesla_expectations() + + MultichainSearchDbMainExportQueue.run(export_data_2, nil) + + assert Repo.aggregate(MainExportQueue, :count, :hash) == 3 + results = Repo.all(MainExportQueue) + assert Enum.all?(results, &(&1.retries_number == 2)) + end + end + + defp tesla_expectations() do + Tesla.Test.expect_tesla_call( + times: 2, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + {:ok, %{"block_ranges" => [%{"max_block_number" => _, "min_block_number" => _}]}} -> + {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + end +end diff --git a/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs b/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs new file mode 100644 index 000000000000..629689cd2fe8 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/multichain_search_db/token_info_export_queue_test.exs @@ -0,0 +1,225 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueueTest do + use ExUnit.Case + use Explorer.DataCase + + import ExUnit.CaptureLog, only: [capture_log: 1] + + alias Explorer.Chain.Hash + alias Explorer.Chain.MultichainSearchDb.TokenInfoExportQueue + alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.TestHelper + + alias Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue, + as: MultichainSearchDbTokenInfoExportQueue + + alias Plug.Conn + + @moduletag :capture_log + + setup do + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + Application.put_env(:indexer, MultichainSearchDbTokenInfoExportQueue.Supervisor, disabled?: false) + + on_exit(fn -> + Application.put_env(:indexer, MultichainSearchDbTokenInfoExportQueue.Supervisor, disabled?: true) + end) + + :ok + end + + describe "init/3" do + setup do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + end + + test "initializes with data from the retry queue" do + token_info_item_1 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_2 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_3 = insert(:multichain_search_db_export_token_info_queue) + + {:ok, token_info_item_1_data} = Jason.decode(Jason.encode!(token_info_item_1.data)) + {:ok, token_info_item_2_data} = Jason.decode(Jason.encode!(token_info_item_2.data)) + {:ok, token_info_item_3_data} = Jason.decode(Jason.encode!(token_info_item_3.data)) + + reducer = fn data, acc -> [data | acc] end + + pid = + [] + |> MultichainSearchDbTokenInfoExportQueue.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + + results = MultichainSearchDbTokenInfoExportQueue.init([], reducer, nil) + + assert Enum.count(results) == 3 + + assert Enum.member?(results, %{ + address_hash: token_info_item_1.address_hash, + data_type: token_info_item_1.data_type, + data: token_info_item_1_data + }) + + assert Enum.member?(results, %{ + address_hash: token_info_item_2.address_hash, + data_type: token_info_item_2.data_type, + data: token_info_item_2_data + }) + + assert Enum.member?(results, %{ + address_hash: token_info_item_3.address_hash, + data_type: token_info_item_3.data_type, + data: token_info_item_3_data + }) + + :timer.sleep(100) + GenServer.stop(pid) + end + end + + describe "run/2" do + setup do + bypass = Bypass.open() + + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:#{bypass.port}", + api_key: "12345", + token_info_chunk_size: 1000 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + Bypass.down(bypass) + end) + + {:ok, bypass: bypass} + end + + test "successfully processes multichain search db export retry queue data", %{bypass: bypass} do + export_data = [ + insert(:multichain_search_db_export_token_info_queue), + insert(:multichain_search_db_export_token_info_queue), + insert(:multichain_search_db_export_token_info_queue) + ] + + TestHelper.get_chain_id_mock() + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + + Bypass.expect_once(bypass, "POST", "/api/v1/import:batch", fn conn -> + Conn.resp( + conn, + 200, + Jason.encode!(%{"status" => "ok"}) + ) + end) + + assert :ok = MultichainSearchDbTokenInfoExportQueue.run(export_data, nil) + end + + test "returns {:retry, failed_data} on error where failed_data is only chunks that failed to export", %{ + bypass: _bypass + } do + Application.put_env(:explorer, MultichainSearch, + service_url: "http://localhost:1234", + api_key: "12345", + token_info_chunk_size: 1 + ) + + on_exit(fn -> + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end) + + token_info_item_1 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_2 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_3 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_4 = insert(:multichain_search_db_export_token_info_queue) + token_info_item_4_address_hash_string = Hash.to_string(token_info_item_4.address_hash) + token_info_item_5 = insert(:multichain_search_db_export_token_info_queue) + + export_data = [ + token_info_item_1, + token_info_item_2, + token_info_item_3, + token_info_item_4, + token_info_item_5 + ] + + TestHelper.get_chain_id_mock() + + tesla_expectations(token_info_item_4_address_hash_string) + + log = + capture_log(fn -> + assert {:retry, [item_to_retry]} = MultichainSearchDbTokenInfoExportQueue.run(export_data, nil) + + assert item_to_retry.address_hash == token_info_item_4.address_hash and + item_to_retry.data_type == token_info_item_4.data_type and + item_to_retry.data == token_info_item_4.data + end) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 1 + results = Repo.all(TokenInfoExportQueue) + assert Enum.all?(results, &(&1.retries_number == 1)) + assert log =~ "Batch token info export attempt to the Multichain Search DB failed" + + # Check that `retries_number` is incrementing + + TestHelper.get_chain_id_mock() + + tesla_expectations(token_info_item_4_address_hash_string) + + MultichainSearchDbTokenInfoExportQueue.run(export_data, nil) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 1 + results = Repo.all(TokenInfoExportQueue) + assert Enum.all?(results, &(&1.retries_number == 2)) + + export_data_2 = [ + token_info_item_2, + token_info_item_3, + token_info_item_4, + token_info_item_5 + ] + + Tesla.Test.expect_tesla_call( + times: 4, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + + MultichainSearchDbTokenInfoExportQueue.run(export_data_2, nil) + + assert Repo.aggregate(TokenInfoExportQueue, :count) == 0 + + Application.put_env(:explorer, MultichainSearch, service_url: nil, api_key: nil, token_info_chunk_size: 1000) + end + end + + defp tesla_expectations(address_4_hash_string) do + Tesla.Test.expect_tesla_call( + times: 5, + returns: fn %{url: "http://localhost:1234/api/v1/import:batch", body: body}, _opts -> + case Jason.decode(body) do + {:ok, %{"tokens" => [%{"address_hash" => ^address_4_hash_string}]}} -> + {:ok, %Tesla.Env{status: 500, body: Jason.encode!(%{"code" => 0, "message" => "Error"})}} + + _ -> + {:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{"status" => "ok"})}} + end + end + ) + end +end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/coin_balance_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/coin_balance_test.exs index 378d19bb312b..5514b6bbae89 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/coin_balance_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/coin_balance_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do # MUST be `async: false` so that {:shared, pid} is set for connection to allow CoinBalanceFetcher's self-send to have # connection allowed immediately. @@ -7,10 +8,11 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do import Mox import EthereumJSONRPC, only: [integer_to_quantity: 1] - alias Explorer.Chain + alias Explorer.Chain.Address.CoinBalance alias Explorer.Chain.Events.Subscriber alias Explorer.Chain.Wei alias Explorer.Chain.Cache.Counters.AverageBlockTime + alias Explorer.TestHelper alias Indexer.Fetcher.OnDemand.CoinBalance, as: CoinBalanceOnDemand @moduletag :capture_log @@ -116,32 +118,11 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do block_number = block.number string_block_number = integer_to_quantity(block_number) balance = 42 - assert nil == Chain.get_coin_balance(address.hash, block_number) - - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - method: "eth_getBalance", - params: [^string_address_hash, ^string_block_number] - } - ], - _options -> - {:ok, [%{id: id, jsonrpc: "2.0", result: integer_to_quantity(balance)}]} - end) + assert nil == CoinBalance.get_coin_balance(address.hash, block_number) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: [^string_block_number, true] - } - ], - _ -> - {:ok, [eth_block_number_fake_response(string_block_number, id)]} - end) + TestHelper.eth_get_balance_expectation(string_address_hash, string_block_number, integer_to_quantity(balance)) + + TestHelper.eth_get_block_by_number_expectation(block_number) {:ok, expected_wei} = Wei.cast(balance) @@ -149,7 +130,7 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do :timer.sleep(1000) - assert %{value: ^expected_wei} = Chain.get_coin_balance(address.hash, block_number) + assert %{value: ^expected_wei} = CoinBalance.get_coin_balance(address.hash, block_number) end end @@ -177,16 +158,7 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do address_hash = address.hash string_address_hash = to_string(address.hash) - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ - %{ - id: id, - method: "eth_getBalance", - params: [^string_address_hash, "0x66"] - } - ], - _options -> - {:ok, [%{id: id, jsonrpc: "2.0", result: "0x02"}]} - end) + TestHelper.eth_get_balance_expectation(string_address_hash, "0x66", "0x02") assert CoinBalanceOnDemand.trigger_fetch(address) == {:stale, 102} @@ -194,7 +166,8 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do assert_receive( {:chain_event, :addresses, :on_demand, - [%{hash: ^address_hash, fetched_coin_balance: ^expected_wei, fetched_coin_balance_block_number: 102}]} + [%{hash: ^address_hash, fetched_coin_balance: ^expected_wei, fetched_coin_balance_block_number: 102}]}, + 1_000 ) end @@ -204,30 +177,9 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do address_hash = address.hash string_address_hash = to_string(address.hash) - expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ - %{ - id: id, - method: "eth_getBalance", - params: [^string_address_hash, "0x67"] - } - ], - _options -> - {:ok, [%{id: id, jsonrpc: "2.0", result: "0x02"}]} - end) + TestHelper.eth_get_balance_expectation(string_address_hash, "0x67", "0x02") - EthereumJSONRPC.Mox - |> expect(:json_rpc, 1, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_getBlockByNumber", - params: ["0x67", true] - } - ], - _ -> - res = eth_block_number_fake_response("0x67") - {:ok, [res]} - end) + TestHelper.eth_get_block_by_number_expectation(103) assert CoinBalanceOnDemand.trigger_fetch(address) == {:pending, 103} @@ -235,44 +187,9 @@ defmodule Indexer.Fetcher.OnDemand.CoinBalanceTest do assert_receive( {:chain_event, :addresses, :on_demand, - [%{hash: ^address_hash, fetched_coin_balance: ^expected_wei, fetched_coin_balance_block_number: 103}]} + [%{hash: ^address_hash, fetched_coin_balance: ^expected_wei, fetched_coin_balance_block_number: 103}]}, + 1_000 ) end end - - defp eth_block_number_fake_response(block_quantity, id \\ 0) do - %{ - id: id, - jsonrpc: "2.0", - result: %{ - "author" => "0x0000000000000000000000000000000000000000", - "difficulty" => "0x20000", - "extraData" => "0x", - "gasLimit" => "0x663be0", - "gasUsed" => "0x0", - "hash" => "0x5b28c1bfd3a15230c9a46b399cd0f9a6920d432e85381cc6a140b06e8410112f", - "logsBloom" => - "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "miner" => "0x0000000000000000000000000000000000000000", - "number" => block_quantity, - "parentHash" => "0x0000000000000000000000000000000000000000000000000000000000000000", - "receiptsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "sealFields" => [ - "0x80", - "0xb8410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ], - "sha3Uncles" => "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "signature" => - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "size" => "0x215", - "stateRoot" => "0xfad4af258fd11939fae0c6c6eec9d340b1caac0b0196fd9a1bc3f489c5bf00b3", - "step" => "0", - "timestamp" => "0x0", - "totalDifficulty" => "0x20000", - "transactions" => [], - "transactionsRoot" => "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "uncles" => [] - } - } - end end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/contract_code_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/contract_code_test.exs index 010aa14faf9a..6a1de39ee4dc 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/contract_code_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/contract_code_test.exs @@ -1,14 +1,17 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase import Mox - alias Explorer.Chain.Address + alias Explorer.Chain.{Address, Data, Hash} + alias Explorer.Chain.SmartContract.Proxy.EIP7702 + alias Explorer.Chain.SmartContract.Proxy.Models.Implementation alias Explorer.Chain.Events.Subscriber alias Explorer.Utility.AddressContractCodeFetchAttempt alias Indexer.Fetcher.OnDemand.ContractCode, as: ContractCodeOnDemand - + alias Indexer.Fetcher.OnDemand.ContractCreator, as: ContractCreatorOnDemand @moduletag :capture_log setup :set_mox_global @@ -20,6 +23,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) start_supervised!({ContractCodeOnDemand, [mocked_json_rpc_named_arguments, [name: ContractCodeOnDemand]]}) + start_supervised!({ContractCreatorOnDemand, name: ContractCreatorOnDemand}) %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} end @@ -38,18 +42,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do contract_code = "0x6080" - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_getCode", - params: [^string_address_hash, "latest"] - } - ], - _ -> - {:ok, [%{id: id, result: contract_code}]} - end) + eth_get_code_expectation(string_address_hash, contract_code) assert ContractCodeOnDemand.trigger_fetch(address) == :ok @@ -85,18 +78,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do address_hash = address.hash string_address_hash = to_string(address.hash) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_getCode", - params: [^string_address_hash, "latest"] - } - ], - _ -> - {:ok, [%{id: id, result: "0x"}]} - end) + eth_get_code_expectation(string_address_hash, "0x") assert ContractCodeOnDemand.trigger_fetch(address) == :ok @@ -105,13 +87,22 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do address = assert(Repo.get(Address, address_hash)) assert is_nil(address.contract_code) - attempts = Repo.get(AddressContractCodeFetchAttempt, address_hash) + attempts = + wait_for_results(fn -> + Repo.one!( + from(attempt in AddressContractCodeFetchAttempt, + where: attempt.address_hash == ^address_hash and attempt.retries_number == 1 + ) + ) + end) + assert attempts.retries_number == 1 refute_receive({:chain_event, :fetched_bytecode, :on_demand, [^address_hash, "0x"]}) end test "updates contract_code after 2nd attempt" do + threshold_configuration = Application.get_env(:indexer, ContractCodeOnDemand)[:threshold] threshold = parse_time_env_var("CONTRACT_CODE_ON_DEMAND_FETCHER_THRESHOLD", "500ms") Application.put_env(:indexer, ContractCodeOnDemand, threshold: threshold) @@ -119,27 +110,24 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do address_hash = address.hash string_address_hash = to_string(address.hash) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_getCode", - params: [^string_address_hash, "latest"] - } - ], - _ -> - {:ok, [%{id: id, result: "0x"}]} - end) + eth_get_code_expectation(string_address_hash, "0x") assert ContractCodeOnDemand.trigger_fetch(address) == :ok - :timer.sleep(100) + :timer.sleep(200) address = assert(Repo.get(Address, address_hash)) assert is_nil(address.contract_code) - attempts = Repo.get(AddressContractCodeFetchAttempt, address_hash) + attempts = + wait_for_results(fn -> + Repo.one!( + from(attempt in AddressContractCodeFetchAttempt, + where: attempt.address_hash == ^address_hash and attempt.retries_number == 1 + ) + ) + end) + assert attempts.retries_number == 1 refute_receive({:chain_event, :fetched_bytecode, :on_demand, [^address_hash, "0x"]}) @@ -151,34 +139,32 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do assert ContractCodeOnDemand.trigger_fetch(address) == :ok - :timer.sleep(50) + :timer.sleep(200) address = assert(Repo.get(Address, address_hash)) assert is_nil(address.contract_code) - refute is_nil(Repo.get(AddressContractCodeFetchAttempt, address_hash)) + attempts = + wait_for_results(fn -> + Repo.one!( + from(attempt in AddressContractCodeFetchAttempt, + where: attempt.address_hash == ^address_hash and attempt.retries_number == 1 + ) + ) + end) + + assert attempts.retries_number == 1 refute_receive({:chain_event, :fetched_bytecode, :on_demand, [^address_hash, ^contract_code]}) # trying 3d time after update threshold reached: update is expected. :timer.sleep(1000) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_getCode", - params: [^string_address_hash, "latest"] - } - ], - _ -> - {:ok, [%{id: id, result: contract_code}]} - end) + eth_get_code_expectation(string_address_hash, contract_code) assert ContractCodeOnDemand.trigger_fetch(address) == :ok - :timer.sleep(50) + :timer.sleep(200) address = assert(Repo.get(Address, address_hash)) refute is_nil(address.contract_code) @@ -187,12 +173,80 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do assert_receive({:chain_event, :fetched_bytecode, :on_demand, [^address_hash, ^contract_code]}) - default_threshold = parse_time_env_var("CONTRACT_CODE_ON_DEMAND_FETCHER_THRESHOLD", "5s") - Application.put_env(:indexer, ContractCodeOnDemand, threshold: default_threshold) + on_exit(fn -> + Application.put_env(:indexer, ContractCodeOnDemand, threshold: threshold_configuration) + end) + end + + test "updates code for eip7702 address" do + threshold_configuration = Application.get_env(:indexer, ContractCodeOnDemand)[:threshold] + threshold = parse_time_env_var("CONTRACT_CODE_ON_DEMAND_FETCHER_THRESHOLD", "1ms") + Application.put_env(:indexer, ContractCodeOnDemand, threshold: threshold) + + address = insert(:address) + address_hash = address.hash + string_address_hash = to_string(address.hash) + + test_cases = [ + {"0x", 1}, + {"0x", 2}, + {"0xef0100aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 0}, + {"0xef0100aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 1}, + {"0xef0100bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 0}, + {"0xef0100bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 1}, + {"0xef0100bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 2}, + {"0x", 0}, + {"0x", 1} + ] + + test_cases + |> Enum.map(fn {code, attempts_number} -> + eth_get_code_expectation(string_address_hash, code) + + code = code |> Data.cast() |> elem(1) + + address = assert(Repo.get(Address, address_hash)) + assert ContractCodeOnDemand.trigger_fetch(address) == :ok + + :timer.sleep(300) + + address = assert(Repo.get(Address, address_hash)) + + if Data.empty?(code) do + assert is_nil(address.contract_code) + else + assert address.contract_code == code + end + + attempts = Repo.get(AddressContractCodeFetchAttempt, address_hash) + + if attempts_number == 0 do + assert is_nil(attempts) + else + assert attempts.retries_number == attempts_number + end + + proxy_implementations = Implementation.get_proxy_implementations(address_hash) + + if Data.empty?(code) do + assert is_nil(proxy_implementations) + else + {:ok, address_hashes} = EIP7702.quick_resolve_implementations(address) + + assert proxy_implementations.proxy_address_hash == address_hash + assert proxy_implementations.proxy_type == :eip7702 + assert proxy_implementations.address_hashes == address_hashes + assert proxy_implementations.names == [nil] + end + end) + + on_exit(fn -> + Application.put_env(:indexer, ContractCodeOnDemand, threshold: threshold_configuration) + end) end defp parse_time_env_var(env_var, default_value) do - case env_var |> safe_get_env(default_value) |> String.downcase() |> Integer.parse() do + case env_var |> Utils.ConfigHelper.safe_get_env(default_value) |> String.downcase() |> Integer.parse() do {milliseconds, "ms"} -> milliseconds {hours, "h"} -> :timer.hours(hours) {minutes, "m"} -> :timer.minutes(minutes) @@ -200,15 +254,82 @@ defmodule Indexer.Fetcher.OnDemand.ContractCodeTest do _ -> 0 end end + end - defp safe_get_env(env_var, default_value) do - env_var - |> System.get_env(default_value) - |> case do - "" -> default_value - value -> value - end - |> to_string() + describe "get_or_fetch_bytecode/1" do + test "returns code from DB without RPC when present" do + code = Data.cast("0x6080") |> elem(1) + address = insert(:address, contract_code: code) + + # No RPC expectations set: will fail if RPC is called. + assert {:ok, ^code} = ContractCodeOnDemand.get_or_fetch_bytecode(address.hash) + + # Ensure fetch attempts not created + assert is_nil(Repo.get(AddressContractCodeFetchAttempt, address.hash)) + + # Ensure code unchanged in DB + assert Repo.get(Address, address.hash).contract_code == code end + + test "fetches from RPC when code not in DB and returns it, broadcasting event" do + Subscriber.to(:fetched_bytecode, :on_demand) + + address = insert(:address) + address_hash = address.hash + string_address_hash = to_string(address.hash) + + contract_code_hex = "0x6080" + + eth_get_code_expectation(string_address_hash, contract_code_hex) + + code = Data.cast(contract_code_hex) |> elem(1) + + assert {:ok, ^code} = ContractCodeOnDemand.get_or_fetch_bytecode(address_hash) + + # DB updated + assert Repo.get(Address, address_hash).contract_code == code + + # No attempts record left + assert is_nil(Repo.get(AddressContractCodeFetchAttempt, address_hash)) + + # Broadcast happened + assert_receive({:chain_event, :fetched_bytecode, :on_demand, [^address_hash, ^contract_code_hex]}) + end + + test "returns :error and increments attempts when RPC returns empty code" do + address = insert(:address) + address_hash = address.hash + string_address_hash = to_string(address.hash) + + eth_get_code_expectation(string_address_hash, "0x") + + assert :error = ContractCodeOnDemand.get_or_fetch_bytecode(address_hash) + + # DB not updated, attempts incremented + assert is_nil(Repo.get(Address, address_hash).contract_code) + attempts = Repo.get(AddressContractCodeFetchAttempt, address_hash) + assert attempts.retries_number == 1 + end + + test "returns :error when address is not found" do + # Build a random address hash that is not in DB + {:ok, random_hash} = Hash.Address.cast(<<1::160>>) + assert :error = ContractCodeOnDemand.get_or_fetch_bytecode(random_hash) + end + end + + defp eth_get_code_expectation(string_address_hash, contract_code) do + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: id, + jsonrpc: "2.0", + method: "eth_getCode", + params: [^string_address_hash, "latest"] + } + ], + _ -> + {:ok, [%{id: id, result: contract_code}]} + end) end end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs index eea538e39961..2b0f35e17e43 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/contract_creator_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase @@ -56,19 +57,17 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do insert( :internal_transaction_create, transaction: transaction, - index: 0, + index: 1, created_contract_address: contract_address, created_contract_code: "0x1234", block_number: transaction.block_number, - block_hash: transaction.block_hash, - block_index: 0, transaction_index: transaction.index ) assert :ignore = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_internal_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) end @@ -81,7 +80,8 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ignore = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_transaction, :contract_creation_internal_transaction]) + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) end @@ -94,7 +94,8 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ignore = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_transaction, :contract_creation_internal_transaction]) + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) end @@ -112,7 +113,22 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ignore = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_transaction, :contract_creation_internal_transaction]) + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() + ) + end + + test "does not crash when ETS table is unavailable" do + contract_address = + insert(:address, contract_code: "0x1234") + + :ets.delete(:contract_creator_lookup) + + assert :ignore = + ContractCreatorOnDemand.trigger_fetch( + contract_address + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) end @@ -123,7 +139,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do now = Timex.now() Enum.each(0..4, fn i -> - insert(:block, number: i, timestamp: Timex.shift(now, minutes: -i)) + insert(:block, number: i, refetch_needed: i == 3, timestamp: Timex.shift(now, minutes: -i)) end) Explorer.Chain.Cache.BlockNumber.get_max() @@ -137,14 +153,11 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ok = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_transaction, :contract_creation_internal_transaction]) + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) - :timer.sleep(100) - - assert :ets.lookup(:contract_creator_lookup, contract_address_hash) == [{contract_address_hash, :in_progress}] - - :timer.sleep(200) + :timer.sleep(300) assert [%{from_number: 3, to_number: 3, priority: 1}] = Repo.all(MissingBlockRange) @@ -152,6 +165,14 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do {"pending_blocks", [%{block_number: 3, address_hash_string: contract_address_hash}]} ] end + + test "returns :ignore when ETS table does not exist (GenServer not started)" do + :ets.delete(:contract_creator_lookup) + + address = insert(:address, contract_code: "0x1234") + + assert :ignore = ContractCreatorOnDemand.trigger_fetch(address) + end end test "initiates fetch if address has contract code but no creator hash (target block is in the middle)" do @@ -161,7 +182,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do now = Timex.now() Enum.each(0..4, fn i -> - insert(:block, number: i, timestamp: Timex.shift(now, minutes: -i)) + insert(:block, number: i, refetch_needed: i == 2, timestamp: Timex.shift(now, minutes: -i)) end) Explorer.Chain.Cache.BlockNumber.get_max() @@ -175,14 +196,11 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ok = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_transaction, :contract_creation_internal_transaction]) + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) - :timer.sleep(100) - - assert :ets.lookup(:contract_creator_lookup, contract_address_hash) == [{contract_address_hash, :in_progress}] - - :timer.sleep(200) + :timer.sleep(300) assert [%{from_number: 2, to_number: 2, priority: 1}] = Repo.all(MissingBlockRange) @@ -198,7 +216,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do now = Timex.now() Enum.each(0..4, fn i -> - insert(:block, number: i, timestamp: Timex.shift(now, minutes: -i)) + insert(:block, number: i, refetch_needed: i == 1, timestamp: Timex.shift(now, minutes: -i)) end) Explorer.Chain.Cache.BlockNumber.get_max() @@ -213,14 +231,11 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do assert :ok = ContractCreatorOnDemand.trigger_fetch( contract_address - |> Repo.preload([:contract_creation_transaction, :contract_creation_internal_transaction]) + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() ) - :timer.sleep(100) - - assert :ets.lookup(:contract_creator_lookup, contract_address_hash) == [{contract_address_hash, :in_progress}] - - :timer.sleep(200) + :timer.sleep(300) assert [%{from_number: 1, to_number: 1, priority: 1}] = Repo.all(MissingBlockRange) @@ -229,6 +244,155 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do ] end + test "retries when eth_getTransactionCount returns an error tuple" do + contract_address = + insert(:address, contract_code: "0x1234") + + now = Timex.now() + + Enum.each(0..4, fn i -> + insert(:block, number: i, refetch_needed: i == 3, timestamp: Timex.shift(now, minutes: -i)) + end) + + Explorer.Chain.Cache.BlockNumber.get_max() + + contract_address_hash = to_string(contract_address.hash) + + EthereumJSONRPC.Mox + |> eth_get_transaction_count_error_mock(contract_address_hash, "0x2") + |> eth_get_transaction_count_mock(contract_address_hash, "0x2", "0x0") + |> eth_get_transaction_count_mock(contract_address_hash, "0x3", "0x1") + + pid = Process.whereis(ContractCreatorOnDemand) + + assert :ok = + ContractCreatorOnDemand.trigger_fetch( + contract_address + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() + ) + + :timer.sleep(1200) + + assert Process.whereis(ContractCreatorOnDemand) == pid + assert [%{from_number: 3, to_number: 3, priority: 1}] = Repo.all(MissingBlockRange) + end + + test "stops retrying after 5 JSON RPC errors" do + contract_address = + insert(:address, contract_code: "0x1234") + + now = Timex.now() + + Enum.each(0..4, fn i -> + insert(:block, number: i, refetch_needed: i == 3, timestamp: Timex.shift(now, minutes: -i)) + end) + + Explorer.Chain.Cache.BlockNumber.get_max() + + contract_address_hash = to_string(contract_address.hash) + + EthereumJSONRPC.Mox + |> eth_get_transaction_count_error_mock_times(contract_address_hash, "0x2", 6) + + pid = Process.whereis(ContractCreatorOnDemand) + + assert :ok = + ContractCreatorOnDemand.trigger_fetch( + contract_address + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() + ) + + :timer.sleep(5400) + + assert Process.whereis(ContractCreatorOnDemand) == pid + assert [] == Repo.all(MissingBlockRange) + assert [] == :ets.lookup(:contract_creator_lookup, contract_address_hash) + end + + # Regression: per-address ETS entry must be updated to the resolved integer block number + # after a successful fetch. Previously it was left as :in_progress, which caused + # trigger_fetch/1 to keep returning :ignore and never re-trigger, masking stalls, or to + # re-dispatch indefinitely when the guard was removed. + test "stamps the resolved block number in the per-address ETS entry after a successful fetch" do + contract_address = + insert(:address, contract_code: "0x1234") + + now = Timex.now() + + Enum.each(0..4, fn i -> + insert(:block, number: i, refetch_needed: i == 3, timestamp: Timex.shift(now, minutes: -i)) + end) + + Explorer.Chain.Cache.BlockNumber.get_max() + + contract_address_hash = to_string(contract_address.hash) + + EthereumJSONRPC.Mox + |> eth_get_transaction_count_mock(contract_address_hash, "0x2", "0x0") + |> eth_get_transaction_count_mock(contract_address_hash, "0x3", "0x1") + + assert :ok = + ContractCreatorOnDemand.trigger_fetch( + contract_address + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() + ) + + :timer.sleep(300) + + # Must be the resolved integer, not the :in_progress atom that was written at fetch start + assert :ets.lookup(:contract_creator_lookup, contract_address_hash) == [ + {contract_address_hash, 3} + ] + end + + # Regression: Enum.member?(pending_blocks, block_number) compared an integer against a + # list of maps and was always false, allowing the same address to accumulate duplicate + # entries in pending_blocks. The fix uses Enum.reject/2 on address_hash_string so a + # stale entry is replaced rather than duplicated. + test "replaces a stale pending_blocks entry for the same address instead of duplicating it" do + contract_address = + insert(:address, contract_code: "0x1234") + + now = Timex.now() + + Enum.each(0..4, fn i -> + insert(:block, number: i, refetch_needed: i == 3, timestamp: Timex.shift(now, minutes: -i)) + end) + + Explorer.Chain.Cache.BlockNumber.get_max() + + contract_address_hash = to_string(contract_address.hash) + + # Pre-seed a stale pending_blocks entry for the same address at a different block (999). + # Without the fix, Enum.member?(pending_blocks, 3) would be false (integer vs map), + # the new entry would be prepended, and the list would grow to two entries. + :ets.insert( + :contract_creator_lookup, + {"pending_blocks", [%{block_number: 999, address_hash_string: contract_address_hash}]} + ) + + EthereumJSONRPC.Mox + |> eth_get_transaction_count_mock(contract_address_hash, "0x2", "0x0") + |> eth_get_transaction_count_mock(contract_address_hash, "0x3", "0x1") + + assert :ok = + ContractCreatorOnDemand.trigger_fetch( + contract_address + |> Repo.preload([:contract_creation_transaction]) + |> Address.maybe_preload_contract_creation_internal_transaction() + ) + + :timer.sleep(300) + + # Exactly one entry for this address — the stale block 999 entry was replaced + assert :ets.lookup(:contract_creator_lookup, "pending_blocks") == [ + {"pending_blocks", [%{block_number: 3, address_hash_string: contract_address_hash}]} + ] + end + defp eth_get_transaction_count_mock(mox, contract_address_hash, block_number, nonce) do mox |> expect(:json_rpc, fn %{ @@ -241,4 +405,27 @@ defmodule Indexer.Fetcher.OnDemand.ContractCreatorTest do {:ok, nonce} end) end + + defp eth_get_transaction_count_error_mock(mox, contract_address_hash, block_number) do + mox + |> expect(:json_rpc, fn %{ + id: _id, + jsonrpc: "2.0", + method: "eth_getTransactionCount", + params: [^contract_address_hash, ^block_number] + }, + _ -> + {:error, + %{ + code: -32000, + message: "missing trie node 0000000000000000000000000000000000000000000000000000000000000000 (path )" + }} + end) + end + + defp eth_get_transaction_count_error_mock_times(mox, contract_address_hash, block_number, times) do + Enum.reduce(1..times, mox, fn _, acc -> + eth_get_transaction_count_error_mock(acc, contract_address_hash, block_number) + end) + end end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs new file mode 100644 index 000000000000..856ee8c762b4 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/on_demand/internal_transaction_test.exs @@ -0,0 +1,486 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.OnDemand.InternalTransactionTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.InternalTransaction + alias Explorer.PagingOptions + alias Indexer.Fetcher.OnDemand.InternalTransaction, as: InternalTransactionOnDemand + + setup :set_mox_global + + setup :verify_on_exit! + + setup do + snapshot_env_restore() + + %{json_rpc_named_arguments: json_rpc_named_arguments} = EthereumJSONRPC.Case.Geth.Mox.setup() + Application.put_env(:explorer, :json_rpc_named_arguments, json_rpc_named_arguments) + end + + test "returns empty results when internal transactions fetcher is disabled" do + Application.put_env(:indexer, Indexer.Fetcher.OnDemand.InternalTransaction, disabled?: true) + + transaction = :transaction |> insert() |> with_block() + block = transaction.block + address = insert(:address) + opts = [paging_options: %PagingOptions{page_size: 5}] + + assert [] = InternalTransactionOnDemand.fetch_latest(opts) + assert [] = InternalTransactionOnDemand.fetch_by_transaction(transaction, opts) + assert [] = InternalTransactionOnDemand.fetch_by_block(block, opts) + assert [] = InternalTransactionOnDemand.fetch_by_address(address.hash, opts) + end + + test "should_fetch?/2 returns false when internal transactions fetcher is disabled" do + Application.put_env(:indexer, Indexer.Fetcher.OnDemand.InternalTransaction, disabled?: true) + + address = insert(:address) + id_to_hash = insert(:address_id_to_address_hash, address: address) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 1, + count_tos: 1, + count_froms: 1 + ) + + refute InternalTransactionOnDemand.should_fetch?([], 10) + end + + defp snapshot_env_restore do + initial_ethereum_jsonrpc_env = Application.get_all_env(:ethereum_jsonrpc) + initial_json_rpc_named_arguments = Application.get_env(:explorer, :json_rpc_named_arguments) + + initial_on_demand_internal_transaction_config = + Application.get_env(:indexer, Indexer.Fetcher.OnDemand.InternalTransaction) + + on_exit(fn -> + Application.put_all_env([{:ethereum_jsonrpc, initial_ethereum_jsonrpc_env}]) + Application.put_env(:explorer, :json_rpc_named_arguments, initial_json_rpc_named_arguments) + + if is_nil(initial_on_demand_internal_transaction_config) do + Application.delete_env(:indexer, Indexer.Fetcher.OnDemand.InternalTransaction) + else + Application.put_env( + :indexer, + Indexer.Fetcher.OnDemand.InternalTransaction, + initial_on_demand_internal_transaction_config + ) + end + end) + end + + test "fetch_by_transaction/2" do + transaction = :transaction |> insert() |> with_block() + block_hash = transaction.block_hash + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn + [%{id: id, params: _}], _ -> + {:ok, + [ + %{ + id: id, + result: %{ + "type" => "create", + "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", + "to" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", + "value" => "0x0", + "gas" => "0x106f5", + "gasUsed" => "0x106f5", + "input" => + "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033", + "output" => + "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033" + } + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, tracer: "call_tracer", debug_trace_timeout: "5s") + + assert [ + %InternalTransaction{ + created_contract_address_hash: created_contract_address_hash, + from_address_hash: from_address_hash, + transaction: %{block_hash: ^block_hash} + } + ] = InternalTransactionOnDemand.fetch_by_transaction(transaction) + + assert to_string(created_contract_address_hash) == "0x205a6b72ce16736c9d87172568a9c0cb9304de0d" + assert to_string(from_address_hash) == "0x117b358218da5a4f647072ddb50ded038ed63d17" + end + + test "fetch_by_block/2" do + block = build(:block) + block_quantity = EthereumJSONRPC.integer_to_quantity(block.number) + block_number = block.number + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, params: [^block_quantity, _]}], _ -> + {:ok, + [ + %{ + id: id, + result: [ + %{ + "result" => %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => "0x6df83a19647a398d48e77a6835f4a28eb7e2f7c0", + "type" => "DELEGATECALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => "0x4200000000000000000000000000000000000015", + "type" => "CALL", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5c" + } + ] + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, + tracer: "call_tracer", + debug_trace_timeout: "5s", + block_traceable?: true + ) + + assert [%InternalTransaction{block_number: ^block_number, index: 1}] = + InternalTransactionOnDemand.fetch_by_block(block, []) + end + + test "fetch_by_block/2 (block_traceable?: false)" do + transaction = :transaction |> insert() |> with_block() + transaction_hash_str = to_string(transaction.hash) + block_number = transaction.block_number + + expect(EthereumJSONRPC.Mox, :json_rpc, fn [%{id: id, params: [^transaction_hash_str, _]}], _ -> + {:ok, + [ + %{ + id: id, + result: %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => "0x6df83a19647a398d48e77a6835f4a28eb7e2f7c0", + "type" => "DELEGATECALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => "0x4200000000000000000000000000000000000015", + "type" => "CALL", + "value" => "0x0" + } + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, + tracer: "call_tracer", + debug_trace_timeout: "5s", + block_traceable?: false + ) + + assert [%InternalTransaction{block_number: ^block_number, index: 1}] = + InternalTransactionOnDemand.fetch_by_block(transaction.block, []) + end + + test "fetch_by_address/2" do + address = insert(:address) + address_hash_str = to_string(address.hash) + id_to_hash = insert(:address_id_to_address_hash, address: address) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 1, + count_tos: 1, + count_froms: 1 + ) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 2, + count_tos: 2, + count_froms: 2 + ) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 3, + count_tos: 3, + count_froms: 3 + ) + + expect(EthereumJSONRPC.Mox, :json_rpc, fn + [ + %{id: id_1, params: ["0x3", _]}, + %{id: id_2, params: ["0x2", _]} + ], + _ -> + {:ok, + [ + %{ + id: id_1, + result: [ + %{ + "result" => %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5c" + }, + %{ + "result" => %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5d" + }, + %{ + "result" => %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5e" + } + ] + }, + %{ + id: id_2, + result: [ + %{ + "result" => %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5f" + }, + %{ + "result" => %{ + "calls" => [ + %{ + "from" => "0x4200000000000000000000000000000000000015", + "gas" => "0xe9a3c", + "gasUsed" => "0x4a28", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + } + ], + "from" => "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gas" => "0xf4240", + "gasUsed" => "0xb6f9", + "input" => + "0x015d8eb900000000000000000000000000000000000000000000000000000000009cb0d80000000000000000000000000000000000000000000000000000000065898738000000000000000000000000000000000000000000000000000000000000001b65f7961a6893850c1f001edeaa0aa4f1fb36b67eee61a8623f8f4da81be25c0000000000000000000000000000000000000000000000000000000000000000050000000000000000000000007431310e026b69bfc676c0013e12a1a11411eec9000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240", + "to" => address_hash_str, + "type" => "CALL", + "value" => "0x0" + }, + "txHash" => "0x32b17f27ddb546eab3c4c33f31eb22c1cb992d4ccc50dae26922805b717efe5b" + } + ] + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, + tracer: "call_tracer", + debug_trace_timeout: "5s", + block_traceable?: true + ) + + opts = [ + direction: :to_address_hash, + paging_options: %PagingOptions{page_size: 4} + ] + + assert [%InternalTransaction{}, %InternalTransaction{}, %InternalTransaction{}, %InternalTransaction{}] = + result = InternalTransactionOnDemand.fetch_by_address(address.hash, opts) + + assert result |> Enum.filter(&(&1.block_number == 3)) |> Enum.count() == 3 + assert result |> Enum.filter(&(&1.block_number == 2)) |> Enum.count() == 1 + end + + test "fetch_by_address/2 (no suitable placeholders)" do + address = insert(:address) + address_hash_str = to_string(address.hash) + id_to_hash = insert(:address_id_to_address_hash, address: address) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 1, + count_tos: 0, + count_froms: 1 + ) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 2, + count_tos: 0, + count_froms: 2 + ) + + insert(:deleted_internal_transactions_address_placeholder, + address_id: id_to_hash.address_id, + block_number: 3, + count_tos: 0, + count_froms: 3 + ) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, + tracer: "call_tracer", + debug_trace_timeout: "5s", + block_traceable?: true + ) + + opts = [ + direction: :to_address_hash, + paging_options: %PagingOptions{page_size: 4} + ] + + assert [] = InternalTransactionOnDemand.fetch_by_address(address.hash, opts) + end + + test "etherscan_fetch_by_transaction/2" do + transaction = :transaction |> insert() |> with_block() + block_timestamp = transaction.block.timestamp + transaction_hash = transaction.hash + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn + [%{id: id, params: _}], _ -> + {:ok, + [ + %{ + id: id, + result: %{ + "type" => "create", + "from" => "0x117b358218da5a4f647072ddb50ded038ed63d17", + "to" => "0x205a6b72ce16736c9d87172568a9c0cb9304de0d", + "value" => "0x0", + "gas" => "0x106f5", + "gasUsed" => "0x106f5", + "input" => + "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033", + "output" => + "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea26469706673582212209a159a4f3847890f10bfb87871a61eba91c5dbf5ee3cf6398207e292eee22a1664736f6c63430008070033" + } + } + ]} + end) + + Application.put_env(:ethereum_jsonrpc, EthereumJSONRPC.Geth, tracer: "call_tracer", debug_trace_timeout: "5s") + + assert [ + %{ + created_contract_address_hash: created_contract_address_hash, + from_address_hash: from_address_hash, + to_address_hash: nil, + block_timestamp: ^block_timestamp, + transaction_hash: ^transaction_hash, + error: nil + } + ] = InternalTransactionOnDemand.etherscan_fetch_by_transaction(transaction, %{}) + + assert to_string(created_contract_address_hash) == "0x205a6b72ce16736c9d87172568a9c0cb9304de0d" + assert to_string(from_address_hash) == "0x117b358218da5a4f647072ddb50ded038ed63d17" + end +end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/token_balance_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/token_balance_test.exs new file mode 100644 index 000000000000..ecbc0e5cd01d --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/on_demand/token_balance_test.exs @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.OnDemand.TokenBalanceTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.Address.CurrentTokenBalance + alias Explorer.Chain.Address.TokenBalance + alias Explorer.Chain.Events.Subscriber + alias Explorer.Chain.Cache.Counters.AverageBlockTime + alias Indexer.Fetcher.OnDemand.TokenBalance, as: TokenBalanceOnDemand + + setup :set_mox_global + setup :verify_on_exit! + + setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) + + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + start_supervised!(AverageBlockTime) + + configuration = Application.get_env(:indexer, TokenBalanceOnDemand.Supervisor) + Application.put_env(:indexer, TokenBalanceOnDemand.Supervisor, disabled?: false) + + Application.put_env(:explorer, AverageBlockTime, enabled: true, cache_period: 1_800_000) + + TokenBalanceOnDemand.Supervisor.Case.start_supervised!(json_rpc_named_arguments: mocked_json_rpc_named_arguments) + + on_exit(fn -> + Application.put_env(:indexer, TokenBalanceOnDemand.Supervisor, configuration) + Application.put_env(:explorer, AverageBlockTime, enabled: false, cache_period: 1_800_000) + end) + + %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} + end + + describe "update behaviour" do + setup do + Subscriber.to(:address_current_token_balances, :on_demand) + Subscriber.to(:address_token_balances, :on_demand) + + now = Timex.now() + + Enum.each(0..101, fn i -> + insert(:block, number: i, timestamp: Timex.shift(now, hours: -(102 - i) * 50)) + end) + + insert(:block, number: 102, timestamp: now) + AverageBlockTime.refresh() + + :ok + end + + test "current token balances are imported and broadcasted for a stale address" do + %{address: address, token_contract_address_hash: token_contract_address_hash} = + insert(:address_current_token_balance, + value_fetched_at: nil, + value: nil, + token_type: "ERC-20", + block_number: 101 + ) + + success_eth_call_expectation("0x00000000000000000000000000000000000000000000d3c21bcecceda1000000") + + TokenBalanceOnDemand.trigger_fetch(address.hash) + + updated_ctb = + wait_for_results(fn -> + Repo.one!( + from( + ctb in CurrentTokenBalance, + where: + ctb.address_hash == ^address.hash and + ctb.token_contract_address_hash == ^token_contract_address_hash and + not is_nil(ctb.value) + ) + ) + end) + + updated_value = updated_ctb.value + + assert updated_value == Decimal.new(1_000_000_000_000_000_000_000_000) + refute is_nil(updated_ctb.value_fetched_at) + + address_hash = to_string(address.hash) + + assert_receive( + {:chain_event, :address_current_token_balances, :on_demand, + %{ + address_hash: ^address_hash, + address_current_token_balances: [ + %{value: ^updated_value, token_contract_address_hash: ^token_contract_address_hash} + ] + }} + ) + end + + test "historic balances are imported and broadcasted" do + token_balance = insert(:token_balance, value_fetched_at: nil, value: nil, token_type: "ERC-20", block_number: 101) + + success_eth_call_expectation("0x00000000000000000000000000000000000000000000d3c21bcecceda1000000") + + TokenBalanceOnDemand.trigger_historic_fetch( + token_balance.address_hash, + token_balance.token_contract_address_hash, + token_balance.token_type, + token_balance.token_id, + token_balance.block_number + ) + + updated_tb = + wait_for_results(fn -> + Repo.one!( + from( + tb in TokenBalance, + where: + tb.address_hash == ^token_balance.address_hash and + tb.token_contract_address_hash == ^token_balance.token_contract_address_hash and + tb.block_number == ^token_balance.block_number and + not is_nil(tb.value) + ) + ) + end) + + updated_value = updated_tb.value + + assert updated_value == Decimal.new(1_000_000_000_000_000_000_000_000) + refute is_nil(updated_tb.value_fetched_at) + + address_hash = token_balance.address_hash + token_contract_address_hash = token_balance.token_contract_address_hash + + assert_receive( + {:chain_event, :address_token_balances, :on_demand, + [ + %{ + address_hash: ^address_hash, + token_contract_address_hash: ^token_contract_address_hash, + value: ^updated_value + } + ]} + ) + end + end + + describe "run/2" do + setup do + now = Timex.now() + + Enum.each(0..101, fn i -> + insert(:block, number: i, timestamp: Timex.shift(now, hours: -(102 - i) * 50)) + end) + + insert(:block, number: 102, timestamp: now) + AverageBlockTime.refresh() + + :ok + end + + test "fetches token balance for an address" do + address = insert(:address, hash: "0x3078000000000000000000000000000000000001") + token_contract_address = insert(:address, hash: "0x3078000000000000000000000000000000000002") + + token = + insert(:token, + contract_address_hash: token_contract_address.hash, + contract_address: token_contract_address + ) + + insert(:address_current_token_balance, + address_hash: address.hash, + address: address, + token_contract_address_hash: token_contract_address.hash, + token: token, + token_type: "ERC-20", + value_fetched_at: nil, + value: nil + ) + + insert_list(2, :block) + + success_eth_call_expectation("0x00000000000000000000000000000000000000000000d3c21bcecceda1000000") + + TokenBalanceOnDemand.trigger_fetch(address.hash) + + updated_ctb = + wait_for_results(fn -> + Repo.one!( + from( + ctb in CurrentTokenBalance, + where: + ctb.address_hash == ^address.hash and + ctb.token_contract_address_hash == ^token_contract_address.hash and + not is_nil(ctb.value) + ) + ) + end) + + assert updated_ctb.value == Decimal.new(1_000_000_000_000_000_000_000_000) + assert updated_ctb.value_fetched_at != nil + end + end + + defp success_eth_call_expectation(result) do + expect( + EthereumJSONRPC.Mox, + :json_rpc, + fn [%{id: id, method: "eth_call", params: [%{data: _, to: _}, _]}], _options -> + {:ok, + [ + %{ + id: id, + jsonrpc: "2.0", + result: result + } + ]} + end + ) + end +end diff --git a/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs b/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs index 79d6f527b8bc..9573d40ddf06 100644 --- a/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs +++ b/apps/indexer/test/indexer/fetcher/on_demand/token_instance_metadata_refetch_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do use EthereumJSONRPC.Case, async: false use Explorer.DataCase @@ -32,12 +33,7 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do describe "refetch token instance metadata behaviour" do setup do Subscriber.to(:fetched_token_instance_metadata, :on_demand) - - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - - on_exit(fn -> - Application.put_env(:explorer, :http_adapter, HTTPoison) - end) + Subscriber.to(:not_fetched_token_instance_metadata, :on_demand) :ok end @@ -60,17 +56,30 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(metadata) + }} + end + ) assert TokenInstanceMetadataRefetchOnDemand.trigger_refetch(token_instance) == :ok - :timer.sleep(100) - token_instance_from_db = - Repo.get_by(TokenInstance, token_id: token_id, token_contract_address_hash: token.contract_address_hash) + wait_for_results(fn -> + Repo.one!( + from(ti in TokenInstance, + where: + ti.token_id == ^token_id and + ti.token_contract_address_hash == ^token.contract_address_hash and + ti.metadata == ^metadata + ) + ) + end) assert(token_instance_from_db) refute is_nil(token_instance_from_db.metadata) @@ -107,17 +116,30 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: Jason.encode!(metadata)}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: Jason.encode!(metadata) + }} + end + ) assert TokenInstanceMetadataRefetchOnDemand.trigger_refetch(token_instance) == :ok - :timer.sleep(100) - token_instance_from_db = - Repo.get_by(TokenInstance, token_id: token_id, token_contract_address_hash: token.contract_address_hash) + wait_for_results(fn -> + Repo.one!( + from(ti in TokenInstance, + where: + ti.token_id == ^token_id and + ti.token_contract_address_hash == ^token.contract_address_hash and + ti.metadata == ^metadata + ) + ) + end) assert(token_instance_from_db) assert token_instance_from_db.metadata == metadata @@ -153,10 +175,16 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do TestHelper.fetch_token_uri_mock(url, token_contract_address_hash_string) - Explorer.Mox.HTTPoison - |> expect(:get, fn ^url, _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: nil}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: ^url}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: nil + }} + end + ) assert TokenInstanceMetadataRefetchOnDemand.trigger_refetch(token_instance) == :ok @@ -182,6 +210,11 @@ defmodule Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetchTest do {:chain_event, :fetched_token_instance_metadata, :on_demand, [^token_contract_address_hash_string, ^token_id, %{metadata: ^metadata}]} ) + + assert_receive( + {:chain_event, :not_fetched_token_instance_metadata, :on_demand, + [^token_contract_address_hash_string, ^token_id, "error"]} + ) end end end diff --git a/apps/indexer/test/indexer/fetcher/optimism/dispute_game_test.exs b/apps/indexer/test/indexer/fetcher/optimism/dispute_game_test.exs new file mode 100644 index 000000000000..0751aa271468 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/optimism/dispute_game_test.exs @@ -0,0 +1,199 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +if Application.get_env(:explorer, :chain_type) == :optimism do + defmodule Indexer.Fetcher.Optimism.DisputeGameTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.Data + alias Indexer.Fetcher.Optimism.DisputeGame + + setup :verify_on_exit! + + setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) + + %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} + end + + describe "handle_info/2" do + defp mox_handle_continue_calls(%{ + id: id, + method: "eth_call", + params: [ + %{data: data, to: _}, + _ + ] + }) + when data in ~w(0x3c9f397c 0x19effeb4 0x200d2ed2), + do: %{id: id, jsonrpc: "2.0", result: "0x0"} + + defp mox_handle_continue_calls(%{ + id: id, + method: "eth_call", + params: [ + %{ + data: %Data{bytes: <<187, 138, 161, 252, index::integer-256>>}, + to: "0x3078000000000000000000000000000000000001" + }, + _ + ] + }) do + %{ + id: id, + jsonrpc: "2.0", + result: + "0x" <> + (ABI.encode("(uint32,uint64,address)", [ + {1, index + 1_740_000_000, 0x3078A00000000000000000000000000000000000 + index} + ]) + |> Base.encode16(case: :lower)) + } + end + + defp mox_handle_continue_calls(%{ + id: id, + method: "eth_call", + params: [ + %{data: "0x609d3334", to: "0x3078a0000000000000000000000000000000000" <> index}, + _ + ] + }) do + {index, ""} = Integer.parse(index) + + %{ + id: id, + jsonrpc: "2.0", + result: + "0x" <> + (ABI.TypeEncoder.encode([4, <<46 + index, 120, 32, 32 + index>>], [:bytes]) + |> Base.encode16(case: :lower)) + } + end + + defp mox_handle_continue_calls(%{ + id: id, + method: "eth_call", + params: [ + %{ + data: "0x609d3334", + to: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<48, 120, 160, 0::128, index>> + } + }, + _ + ] + }) do + %{ + id: id, + jsonrpc: "2.0", + result: + "0x" <> + (ABI.TypeEncoder.encode([<<46 + index, 120, 32, 32 + index>>], [:bytes]) |> Base.encode16(case: :lower)) + } + end + + defp mox_handle_continue_calls(%{ + id: id, + method: "eth_call", + params: [ + %{data: "0x4d1975b4", to: _}, + _ + ] + }) do + %{id: id, jsonrpc: "2.0", result: "0x5"} + end + + test "handles :continue", %{json_rpc_named_arguments: json_rpc_named_arguments} do + old_env = Application.get_env(:indexer, DisputeGame, []) + + Application.put_env( + :indexer, + DisputeGame, + Keyword.merge(old_env, json_rpc_named_arguments: json_rpc_named_arguments) + ) + + on_exit(fn -> + Application.put_env(:indexer, DisputeGame, old_env) + end) + + expect( + EthereumJSONRPC.Mox, + :json_rpc, + 8, + fn + requests, _options when is_list(requests) -> + {:ok, Enum.map(requests, &mox_handle_continue_calls/1)} + + request, _options -> + {:ok, mox_handle_continue_calls(request).result} + end + ) + + assert {:noreply, + %{ + optimism_portal: "0x3078000000000000000000000000000000000002", + dispute_game_factory: "0x3078000000000000000000000000000000000001", + end_index: 4, + start_index: 4 + }} = + DisputeGame.handle_info(:continue, %{ + dispute_game_factory: "0x3078000000000000000000000000000000000001", + optimism_portal: "0x3078000000000000000000000000000000000002", + start_index: 0, + end_index: 3, + json_rpc_named_arguments: json_rpc_named_arguments + }) + + assert [ + %Explorer.Chain.Optimism.DisputeGame{ + index: 0, + game_type: 1, + address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<48, 120, 160, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>> + }, + extra_data: %Explorer.Chain.Data{bytes: ".x "}, + resolved_at: nil, + status: 0 + }, + %Explorer.Chain.Optimism.DisputeGame{ + index: 1, + game_type: 1, + address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<48, 120, 160, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>> + }, + extra_data: %Explorer.Chain.Data{bytes: "/x !"}, + resolved_at: nil, + status: 0 + }, + %Explorer.Chain.Optimism.DisputeGame{ + index: 2, + game_type: 1, + address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<48, 120, 160, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2>> + }, + extra_data: %Explorer.Chain.Data{bytes: "0x \""}, + resolved_at: nil, + status: 0 + }, + %Explorer.Chain.Optimism.DisputeGame{ + index: 3, + game_type: 1, + address_hash: %Explorer.Chain.Hash{ + byte_count: 20, + bytes: <<48, 120, 160, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3>> + }, + extra_data: %Explorer.Chain.Data{bytes: "1x #"}, + resolved_at: nil, + status: 0 + } + ] = Repo.all(Explorer.Chain.Optimism.DisputeGame) + end + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/optimism/interop/message_queue_test.exs b/apps/indexer/test/indexer/fetcher/optimism/interop/message_queue_test.exs new file mode 100644 index 000000000000..fab687e41332 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/optimism/interop/message_queue_test.exs @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Optimism.Interop.MessageQueueTest do + use EthereumJSONRPC.Case + use Explorer.DataCase + + alias Indexer.Fetcher.Optimism.Interop.MessageQueue + + describe "set_post_data_signature/3" do + test "handles signature starting with 0x properly" do + message_nonce = 1 + init_chain_id = 2 + relay_chain_id = 3 + relay_transaction_hash = "0xf463ce43ac52251f60f437a0346b7168874e6eb13a9689d7efde4ce907826897" + message_failed = false + + data_to_sign = + Integer.to_string(message_nonce) <> + Integer.to_string(init_chain_id) <> + Integer.to_string(relay_chain_id) <> relay_transaction_hash <> to_string(message_failed) + + private_key = + <<51, 169, 186, 160, 251, 109, 12, 35, 225, 13, 110, 62, 216, 253, 27, 181, 187, 222, 222, 75, 79, 84, 185, 24, + 245, 213, 28, 21, 76, 179, 162, 16>> + + {:ok, {signature, _}} = + data_to_sign + |> ExKeccak.hash_256() + |> ExSecp256k1.sign_compact(private_key) + + assert {^init_chain_id, + %{ + signature: + "0x3078a19168c4ab6d5aebcb8556d4c6bdf2df53e8b4562f6974ccfaea60639ae13437ee19c4bd7328a5d8451e1c11075d1581e0abf4e470c97d7a89b20e4c68d9" + }} = MessageQueue.set_post_data_signature(init_chain_id, %{signature: nil}, signature) + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/optimism/transaction_batch_test.exs b/apps/indexer/test/indexer/fetcher/optimism/transaction_batch_test.exs new file mode 100644 index 000000000000..3733d20624ad --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/optimism/transaction_batch_test.exs @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +if Application.get_env(:explorer, :chain_type) == :optimism do + defmodule Indexer.Fetcher.Optimism.TransactionBatchTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import Mox + + alias Indexer.Fetcher.Optimism.TransactionBatch + + setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) + + %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} + end + + describe "get_block_numbers_by_hashes/2" do + test "processes empty list" do + assert TransactionBatch.get_block_numbers_by_hashes([], %{}) == %{} + end + + test "processes list of hashes", %{json_rpc_named_arguments: json_rpc_named_arguments} do + hashA = <<1::256>> + + hashB = + <<48, 120, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32>> + + hashes = [ + hashA, + hashB + ] + + expect(EthereumJSONRPC.Mox, :json_rpc, 1, fn [ + %{ + id: id1, + method: "eth_getBlockByHash", + params: [ + %Explorer.Chain.Hash{byte_count: 32, bytes: hashA}, + false + ] + }, + %{ + id: id2, + method: "eth_getBlockByHash", + params: [ + %Explorer.Chain.Hash{byte_count: 32, bytes: hashB}, + false + ] + } + ], + _options -> + {:ok, + [ + %{ + id: id1, + jsonrpc: "2.0", + result: %{"number" => 1, "hash" => "0x0000000000000000000000000000000000000000000000000000000000000001"} + }, + %{ + id: id2, + jsonrpc: "2.0", + result: %{"number" => 2, "hash" => "0x3078202020202020202020202020202020202020202020202020202020202020"} + } + ]} + end) + + assert %{hashA => 1, hashB => 2} == + TransactionBatch.get_block_numbers_by_hashes(hashes, json_rpc_named_arguments) + end + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/pending_block_operations_sanitizer_test.exs b/apps/indexer/test/indexer/fetcher/pending_block_operations_sanitizer_test.exs index a3d9e41fdb3b..c08c490ff220 100644 --- a/apps/indexer/test/indexer/fetcher/pending_block_operations_sanitizer_test.exs +++ b/apps/indexer/test/indexer/fetcher/pending_block_operations_sanitizer_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.PendingBlockOperationsSanitizerTest do use Explorer.DataCase, async: false diff --git a/apps/indexer/test/indexer/fetcher/pending_transaction_test.exs b/apps/indexer/test/indexer/fetcher/pending_transaction_test.exs index 7d4b1c7ea746..6076bce9a8dc 100644 --- a/apps/indexer/test/indexer/fetcher/pending_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/pending_transaction_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.PendingTransactionTest do # `async: false` due to use of named GenServer use EthereumJSONRPC.Case, async: false @@ -60,9 +61,19 @@ defmodule Indexer.Fetcher.PendingTransactionTest do PendingTransaction.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) - wait_for_results(fn -> - Repo.one!(from(transaction in Transaction, where: is_nil(transaction.block_hash), limit: 1)) - end) + pending_hash = "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6" + + wait_for_results( + fn -> + Repo.one!( + from(transaction in Transaction, + where: is_nil(transaction.block_hash) and transaction.hash == ^pending_hash, + limit: 1 + ) + ) + end, + 60 + ) assert Repo.aggregate(Transaction, :count, :hash) >= 1 end diff --git a/apps/indexer/test/indexer/fetcher/replaced_transaction_test.exs b/apps/indexer/test/indexer/fetcher/replaced_transaction_test.exs index ea4ac2d2194c..7486a6bd228c 100644 --- a/apps/indexer/test/indexer/fetcher/replaced_transaction_test.exs +++ b/apps/indexer/test/indexer/fetcher/replaced_transaction_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ReplacedTransactionTest do use Explorer.DataCase diff --git a/apps/indexer/test/indexer/fetcher/rootstock_data_test.exs b/apps/indexer/test/indexer/fetcher/rootstock_data_test.exs index 7ab9b9162dbc..53712efa210c 100644 --- a/apps/indexer/test/indexer/fetcher/rootstock_data_test.exs +++ b/apps/indexer/test/indexer/fetcher/rootstock_data_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout if Application.compile_env(:explorer, :chain_type) == :rsk do defmodule Indexer.Fetcher.RootstockDataTest do use EthereumJSONRPC.Case diff --git a/apps/indexer/test/indexer/fetcher/signed_authorization_status_test.exs b/apps/indexer/test/indexer/fetcher/signed_authorization_status_test.exs new file mode 100644 index 000000000000..0821b69bec07 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/signed_authorization_status_test.exs @@ -0,0 +1,314 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.SignedAuthorizationStatusTest do + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import EthereumJSONRPC, only: [integer_to_quantity: 1] + + import Mox + + alias Explorer.Chain.Cache.ChainId + alias Explorer.Chain.{Data, SignedAuthorization, SmartContract.Proxy.Models.Implementation} + alias Explorer.TestHelper + alias Indexer.BufferedTask + alias Indexer.Fetcher.SignedAuthorizationStatus + + @moduletag :capture_log + + # MUST use global mode because we aren't guaranteed to get `start_supervised`'s pid back fast enough to `allow` it to + # use expectations and stubs from test's pid. + setup :set_mox_global + + setup :verify_on_exit! + + setup do + start_supervised!({Task.Supervisor, name: Indexer.TaskSupervisor}) + + Supervisor.terminate_child(Explorer.Supervisor, ChainId.child_id()) + Supervisor.restart_child(Explorer.Supervisor, ChainId.child_id()) + + :ok + end + + describe "async_fetch/1" do + test "fetched authorization statuses, proxy implementations and nonces", %{ + json_rpc_named_arguments: json_rpc_named_arguments + } do + SignedAuthorizationStatus.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + + block1 = insert(:block, number: 1010) + block2 = insert(:block, number: 1020) + + delegate1 = insert(:address) + delegate2 = insert(:address) + + address1 = insert(:address) + address2 = insert(:address) + address3 = insert(:address) + address4 = insert(:address) + address5 = insert(:address) + + transaction11 = insert(:transaction, from_address: address1, nonce: 100, type: 2) |> with_block(block1) + transaction12 = insert(:transaction, from_address: address1, nonce: 101, type: 2) |> with_block(block1) + transaction13 = insert(:transaction, from_address: address2, nonce: 200, type: 4) |> with_block(block1) + transaction14 = insert(:transaction, from_address: address1, nonce: 103, type: 4) |> with_block(block1) + transaction15 = insert(:transaction, from_address: address5, nonce: 500, type: 4) |> with_block(block1) + + transaction21 = insert(:transaction, from_address: address3, nonce: 300, type: 4) |> with_block(block2) + transaction22 = insert(:transaction, from_address: address1, nonce: 105, type: 2) |> with_block(block2) + transaction23 = insert(:transaction, from_address: address5, nonce: 502, type: 4) |> with_block(block2) + + # invalid signature + auth1 = + insert(:signed_authorization, + transaction: transaction13, + index: 0, + address: delegate1.hash, + authority: nil, + nonce: 102 + ) + + # invalid chain id + auth2 = + insert(:signed_authorization, + transaction: transaction13, + index: 1, + address: delegate1.hash, + authority: address1.hash, + nonce: 102, + chain_id: 123 + ) + + # known nonce, invalid nonce + auth3 = + insert(:signed_authorization, + transaction: transaction13, + index: 2, + address: delegate1.hash, + authority: address1.hash, + nonce: 77 + ) + + # known nonce, all ok + auth4 = + insert(:signed_authorization, + transaction: transaction13, + index: 3, + address: delegate1.hash, + authority: address1.hash, + nonce: 102 + ) + + # known nonce, all ok + auth5 = + insert(:signed_authorization, + transaction: transaction13, + index: 4, + address: delegate1.hash, + authority: address2.hash, + nonce: 201 + ) + + # known nonce, all ok + auth6 = + insert(:signed_authorization, + transaction: transaction13, + index: 5, + address: delegate2.hash, + authority: address2.hash, + nonce: 202 + ) + + # unknown nonce, invalid nonce + auth7 = + insert(:signed_authorization, + transaction: transaction13, + index: 6, + address: delegate1.hash, + authority: address3.hash, + nonce: 77 + ) + + # unknown nonce, all ok + auth8 = + insert(:signed_authorization, + transaction: transaction13, + index: 7, + address: delegate1.hash, + authority: address4.hash, + nonce: 400 + ) + + # unknown nonce, all ok + auth9 = + insert(:signed_authorization, + transaction: transaction14, + index: 0, + address: delegate2.hash, + authority: address4.hash, + nonce: 401 + ) + + # known nonce, all ok + auth10 = + insert(:signed_authorization, + transaction: transaction15, + index: 0, + address: delegate1.hash, + authority: address5.hash, + nonce: 501 + ) + + # unknown nonce, all ok + auth11 = + insert(:signed_authorization, + transaction: transaction21, + index: 0, + address: delegate2.hash, + authority: address1.hash, + nonce: 104 + ) + + # known nonce, all ok + auth12 = + insert(:signed_authorization, + transaction: transaction23, + index: 0, + address: "0x0000000000000000000000000000000000000000", + authority: address5.hash, + nonce: 503 + ) + + if json_rpc_named_arguments[:transport] == EthereumJSONRPC.Mox do + block1_quantity = integer_to_quantity(block1.number - 1) + block2_quantity = integer_to_quantity(block2.number - 1) + address1_string = to_string(address1.hash) + address3_string = to_string(address3.hash) + address4_string = to_string(address4.hash) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: id2, + method: "eth_getTransactionCount", + params: [^address4_string, ^block1_quantity] + }, + %{ + id: id1, + method: "eth_getTransactionCount", + params: [^address3_string, ^block1_quantity] + }, + %{ + id: id3, + method: "eth_getTransactionCount", + params: [^address1_string, ^block2_quantity] + } + ], + _options -> + {:ok, + [ + %{id: id1, result: integer_to_quantity(300)}, + %{id: id2, result: integer_to_quantity(400)}, + %{id: id3, result: integer_to_quantity(104)} + ]} + end) + + TestHelper.get_chain_id_mock() + end + + addresses = [address1, address2, address3, address4, address5] + + transactions = [ + transaction11, + transaction12, + transaction13, + transaction14, + transaction15, + transaction21, + transaction22, + transaction23 + ] + + auths = [auth1, auth2, auth3, auth4, auth5, auth6, auth7, auth8, auth9, auth10, auth11, auth12] + + assert :ok = SignedAuthorizationStatus.async_fetch(transactions, auths, false) + + wait_for_tasks(SignedAuthorizationStatus) + + auths = from(auth in SignedAuthorization, order_by: [asc: :transaction_hash, asc: :index]) |> Repo.all() + + assert auths |> Enum.map(& &1.status) == [ + :invalid_signature, + :invalid_chain_id, + :invalid_nonce, + :ok, + :ok, + :ok, + :invalid_nonce, + :ok, + :ok, + :ok, + :ok, + :ok + ] + + addresses = + addresses + |> Repo.reload() + |> Repo.preload(Implementation.proxy_implementations_association()) + + assert addresses |> Enum.map(& &1.nonce) == [ + 104, + 202, + nil, + 401, + 503 + ] + + assert addresses |> Enum.map(& &1.contract_code) == [ + %Data{bytes: <<239, 1, 0>> <> delegate2.hash.bytes}, + %Data{bytes: <<239, 1, 0>> <> delegate2.hash.bytes}, + nil, + %Data{bytes: <<239, 1, 0>> <> delegate2.hash.bytes}, + nil + ] + + assert addresses |> Enum.map(&((&1.proxy_implementations || %{}) |> Map.get(:address_hashes))) == [ + [delegate2.hash], + [delegate2.hash], + nil, + [delegate2.hash], + nil + ] + end + end + + defp wait_for_tasks(buffered_task) do + wait_until(:timer.seconds(10), fn -> + counts = BufferedTask.debug_count(buffered_task) + counts.buffer == 0 and counts.tasks == 0 + end) + end + + defp wait_until(timeout, producer) do + parent = self() + ref = make_ref() + + spawn(fn -> do_wait_until(parent, ref, producer) end) + + receive do + {^ref, :ok} -> :ok + after + timeout -> exit(:timeout) + end + end + + defp do_wait_until(parent, ref, producer) do + if producer.() do + send(parent, {ref, :ok}) + else + :timer.sleep(100) + do_wait_until(parent, ref, producer) + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/stability/validator_test.exs b/apps/indexer/test/indexer/fetcher/stability/validator_test.exs index accbebac2e7e..b1c9240fbfe5 100644 --- a/apps/indexer/test/indexer/fetcher/stability/validator_test.exs +++ b/apps/indexer/test/indexer/fetcher/stability/validator_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Stability.ValidatorTest do use EthereumJSONRPC.Case use Explorer.DataCase @@ -265,6 +266,206 @@ defmodule Indexer.Fetcher.Stability.ValidatorTest do assert %ValidatorStability{state: :probation} = map[validator_inactive1.address_hash.bytes] assert %ValidatorStability{state: :active} = map[validator_probation1.address_hash.bytes] end + + test "handles blocks_validated for new and existing validators" do + # Create existing validators with known blocks_validated values + existing_validator_1 = insert(:validator_stability, state: :active, blocks_validated: 100) + existing_validator_2 = insert(:validator_stability, state: :inactive, blocks_validated: 50) + + # Create some blocks for the new validators that will be added + new_validator_address_1 = insert(:address) + new_validator_address_2 = insert(:address) + + # Insert blocks to test blocks_validated functionality + insert(:block, miner: new_validator_address_1) + insert(:block, miner: new_validator_address_1) + insert(:block, miner: new_validator_address_1) + insert(:block, miner: new_validator_address_2) + insert(:block, miner: new_validator_address_2) + + start_supervised!({Indexer.Fetcher.Stability.Validator, name: Indexer.Fetcher.Stability.Validator}) + + # Mock the first call to get validator lists (existing + new validators in the network) + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn + [ + %{ + id: id_1, + jsonrpc: "2.0", + method: "eth_call", + params: [%{data: "0xa5aa7380", to: "0x0000000000000000000000000000000000000805"}, "latest"] + }, + %{ + id: id_2, + jsonrpc: "2.0", + method: "eth_call", + params: [%{data: "0xe35c0f7d", to: "0x0000000000000000000000000000000000000805"}, "latest"] + } + ], + _ -> + # Return all validators (existing + new) + <<"0x", _method_id::binary-size(8), result_all::binary>> = + [@accepts_list_of_addresses] + |> ABI.parse_specification() + |> Enum.at(0) + |> Encoder.encode_function_call([ + [ + existing_validator_1.address_hash.bytes, + existing_validator_2.address_hash.bytes, + new_validator_address_1.hash.bytes, + new_validator_address_2.hash.bytes + ] + ]) + + # Return active validators (subset of all) + <<"0x", _method_id::binary-size(8), result_active::binary>> = + [@accepts_list_of_addresses] + |> ABI.parse_specification() + |> Enum.at(0) + |> Encoder.encode_function_call([ + [ + existing_validator_1.address_hash.bytes, + new_validator_address_1.hash.bytes, + new_validator_address_2.hash.bytes + ] + ]) + + {:ok, + [ + %{ + id: id_1, + jsonrpc: "2.0", + result: "0x" <> result_active + }, + %{ + id: id_2, + jsonrpc: "2.0", + result: "0x" <> result_all + } + ]} + end) + + # Mock the second call to get missing blocks for active validators + "0x" <> existing_address_1 = to_string(existing_validator_1.address_hash) + "0x" <> new_address_1 = to_string(new_validator_address_1.hash) + "0x" <> new_address_2 = to_string(new_validator_address_2.hash) + + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn + [ + %{ + id: id_1, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: "0x41ee9a53000000000000000000000000" <> ^existing_address_1, + to: "0x0000000000000000000000000000000000000805" + }, + "latest" + ] + }, + %{ + id: id_2, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: "0x41ee9a53000000000000000000000000" <> ^new_address_1, + to: "0x0000000000000000000000000000000000000805" + }, + "latest" + ] + }, + %{ + id: id_3, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: "0x41ee9a53000000000000000000000000" <> ^new_address_2, + to: "0x0000000000000000000000000000000000000805" + }, + "latest" + ] + } + ], + _ -> + # Return missing blocks for each validator + <<"0x", _method_id::binary-size(8), result_1::binary>> = + [@accepts_integer] + |> ABI.parse_specification() + |> Enum.at(0) + # existing validator - still active + |> Encoder.encode_function_call([0]) + + <<"0x", _method_id::binary-size(8), result_2::binary>> = + [@accepts_integer] + |> ABI.parse_specification() + |> Enum.at(0) + # new validator 1 - active + |> Encoder.encode_function_call([0]) + + <<"0x", _method_id::binary-size(8), result_3::binary>> = + [@accepts_integer] + |> ABI.parse_specification() + |> Enum.at(0) + # new validator 2 - active + |> Encoder.encode_function_call([0]) + + {:ok, + [ + %{ + id: id_1, + jsonrpc: "2.0", + result: "0x" <> result_1 + }, + %{ + id: id_2, + jsonrpc: "2.0", + result: "0x" <> result_2 + }, + %{ + id: id_3, + jsonrpc: "2.0", + result: "0x" <> result_3 + } + ]} + end) + + # Wait for async processing + :timer.sleep(100) + + validators = ValidatorStability.get_all_validators() + assert Enum.count(validators) == 4 + + validator_map = + Enum.reduce(validators, %{}, fn validator, map -> + Map.put(map, validator.address_hash.bytes, validator) + end) + + # Verify existing validators keep their original blocks_validated values + existing_val_1 = validator_map[existing_validator_1.address_hash.bytes] + existing_val_2 = validator_map[existing_validator_2.address_hash.bytes] + + # Original value preserved + assert existing_val_1.blocks_validated == 100 + # Original value preserved + assert existing_val_2.blocks_validated == 50 + assert existing_val_1.state == :active + assert existing_val_2.state == :inactive + + # Verify new validators get blocks_validated populated from database + new_val_1 = validator_map[new_validator_address_1.hash.bytes] + new_val_2 = validator_map[new_validator_address_2.hash.bytes] + + # Should match actual blocks count + assert new_val_1.blocks_validated == 3 + # Should match actual blocks count + assert new_val_2.blocks_validated == 2 + assert new_val_1.state == :active + assert new_val_2.state == :active + end end end end diff --git a/apps/indexer/test/indexer/fetcher/stats/hot_smart_contracts_test.exs b/apps/indexer/test/indexer/fetcher/stats/hot_smart_contracts_test.exs new file mode 100644 index 000000000000..2afecaa73379 --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/stats/hot_smart_contracts_test.exs @@ -0,0 +1,563 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Stats.HotSmartContractsTest do + # MUST be `async: false` due to use of named GenServer + use EthereumJSONRPC.Case, async: false + use Explorer.DataCase + + import ExUnit.CaptureLog + import Mox + + alias Explorer.Chain.Block + alias Indexer.Fetcher.Stats.HotSmartContracts + + @moduletag :capture_log + + # MUST use global mode because we aren't guaranteed to get `start_supervised`'s pid back fast enough to `allow` it to + # use expectations and stubs from test's pid. + setup :set_mox_global + setup :verify_on_exit! + + setup do + # Clean up any existing named process before each test + if Process.whereis(HotSmartContracts) do + try do + GenServer.stop(HotSmartContracts, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + + Process.sleep(50) + end + + on_exit(fn -> + if Process.whereis(HotSmartContracts) do + try do + GenServer.stop(HotSmartContracts, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end) + + :ok + end + + describe "check_chain_age/0" do + test "returns :ok when chain is older than 30 days" do + # Create genesis block (first block) + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + + # Create second block that is 31 days old + second_block_timestamp = ~U[2024-01-01 00:00:00Z] |> DateTime.add(-31, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + # Start the GenServer + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait a bit for handle_continue to process + Process.sleep(200) + + # Verify that check_completeness was called (indirectly by checking if process is still running) + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + # Should not log delay message when chain is old enough + refute log =~ "Hot contracts module delayed" + end + + test "returns {:wait, delay_ms} when chain is less than 30 days old" do + # Create genesis block + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + + # Create second block that is 10 days old + now = DateTime.utc_now() + second_block_timestamp = DateTime.add(now, -10, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for handle_continue to process + Process.sleep(200) + + # Process should still be alive and waiting + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: chain is less than 30 days old" + assert log =~ "Rescheduling startup check" + end + + test "returns {:error, :block_not_found} when second block does not exist" do + # Only create genesis block, no second block + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for handle_continue to process + Process.sleep(200) + + # Process should still be alive + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: second block not found" + assert log =~ "Rescheduling startup check for next day" + end + + test "returns {:error, :block_not_found} when no blocks exist" do + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for handle_continue to process + Process.sleep(200) + + # Process should still be alive + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: second block not found" + assert log =~ "Rescheduling startup check for next day" + end + + test "correctly calculates delay when chain is close to 30 days old" do + # Create genesis block + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + + # Create second block that is 25 days old (5 days away from 30) + now = DateTime.utc_now() + second_block_timestamp = DateTime.add(now, -25, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for handle_continue to process + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: chain is less than 30 days old" + assert log =~ "Rescheduling startup check" + end + + test "uses next day delay when it's sooner than 30-day target" do + # Create genesis block + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + + # Create second block that is 29.9 days old + # This means we need to wait ~0.1 days to reach 30, but next day might be sooner + now = DateTime.utc_now() + second_block_timestamp = DateTime.add(now, -29, :day) |> DateTime.add(-21, :hour) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for handle_continue to process + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: chain is less than 30 days old" + assert log =~ "Rescheduling startup check" + end + end + + describe "process_chain_age_check/0" do + test "starts normal operation when chain age check returns :ok" do + # Create blocks older than 30 days + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + second_block_timestamp = DateTime.utc_now() |> DateTime.add(-31, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for initialization + Process.sleep(200) + + # Process should be running and should have scheduled next day fetch + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + # Should not log delay message when chain is old enough + refute log =~ "Hot contracts module delayed" + end + + test "schedules retry when chain is too young" do + # Create blocks younger than 30 days + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + second_block_timestamp = DateTime.utc_now() |> DateTime.add(-10, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for initialization + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: chain is less than 30 days old" + assert log =~ "Rescheduling startup check" + end + + test "schedules startup check for next day when block not found" do + # Only genesis block exists + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for initialization + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: second block not found" + assert log =~ "Rescheduling startup check for next day" + end + end + + describe "handle_continue/2" do + test "processes chain age check on startup" do + # Create blocks older than 30 days + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + second_block_timestamp = DateTime.utc_now() |> DateTime.add(-31, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for handle_continue to execute + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end + end + + describe "handle_info/2 for :check_chain_age" do + test "processes chain age check when message is received" do + # Create blocks older than 30 days + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + second_block_timestamp = DateTime.utc_now() |> DateTime.add(-31, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + # Wait for initial processing + Process.sleep(100) + + # Send the check_chain_age message + send(pid, :check_chain_age) + + # Wait for processing + Process.sleep(100) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end + end + + describe "fetch_second_block_in_database integration" do + test "finds second block when multiple blocks exist" do + # Create multiple blocks + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: true) + insert(:block, number: 1, timestamp: ~U[2024-01-02 00:00:00Z], consensus: true) + insert(:block, number: 2, timestamp: ~U[2024-01-03 00:00:00Z], consensus: true) + + assert {:ok, %Block{number: 1}} = Block.fetch_second_block_in_database() + end + + test "returns error when only one consensus block exists #1" do + # Create non-consensus first block and consensus second block + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: false) + insert(:block, number: 1, timestamp: ~U[2024-01-02 00:00:00Z], consensus: true) + + assert {:error, :not_found} = Block.fetch_second_block_in_database() + end + + test "returns error when only one consensus block exists #2" do + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: true) + + assert {:error, :not_found} = Block.fetch_second_block_in_database() + end + + test "returns error when no consensus blocks exist" do + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: false) + insert(:block, number: 1, timestamp: ~U[2024-01-02 00:00:00Z], consensus: false) + + assert {:error, :not_found} = Block.fetch_second_block_in_database() + end + + test "finds second block when blocks are not sequential" do + # Create blocks with gaps in numbers + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z], consensus: true) + insert(:block, number: 5, timestamp: ~U[2024-01-02 00:00:00Z], consensus: true) + insert(:block, number: 10, timestamp: ~U[2024-01-03 00:00:00Z], consensus: true) + + # Should find block number 5 as the second block (ordered by number ascending) + assert {:ok, %Block{number: 5}} = Block.fetch_second_block_in_database() + end + end + + describe "edge cases" do + test "handles chain exactly 30 days old" do + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + now = DateTime.utc_now() + second_block_timestamp = DateTime.add(now, -30, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + # Should not log delay message when chain is exactly 30 days old + refute log =~ "Hot contracts module delayed" + end + + test "handles chain just under 30 days old" do + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + now = DateTime.utc_now() + # 29 days, 23 hours, 59 minutes old + second_block_timestamp = DateTime.add(now, -29, :day) |> DateTime.add(-23, :hour) |> DateTime.add(-59, :minute) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: chain is less than 30 days old" + assert log =~ "Rescheduling startup check" + end + + test "handles very new chain (1 day old)" do + insert(:block, number: 0, timestamp: ~U[2024-01-01 00:00:00Z]) + now = DateTime.utc_now() + second_block_timestamp = DateTime.add(now, -1, :day) + insert(:block, number: 1, timestamp: second_block_timestamp, consensus: true) + + log = + capture_log(fn -> + {:ok, pid} = HotSmartContracts.start_link([]) + + try do + Process.sleep(200) + + assert Process.alive?(pid) + after + # Clean up + if Process.alive?(pid) do + try do + GenServer.stop(pid, :normal, 1000) + rescue + _ -> :ok + catch + :exit, _ -> :ok + end + end + end + end) + + assert log =~ "Hot contracts module delayed: chain is less than 30 days old" + assert log =~ "Rescheduling startup check" + end + end +end diff --git a/apps/indexer/test/indexer/fetcher/token_balance_test.exs b/apps/indexer/test/indexer/fetcher/token_balance/historical_test.exs similarity index 88% rename from apps/indexer/test/indexer/fetcher/token_balance_test.exs rename to apps/indexer/test/indexer/fetcher/token_balance/historical_test.exs index 0c4fb0be44f8..0b4152e90ff0 100644 --- a/apps/indexer/test/indexer/fetcher/token_balance_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_balance/historical_test.exs @@ -1,13 +1,15 @@ -defmodule Indexer.Fetcher.TokenBalanceTest do +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenBalance.HistoricalTest do use EthereumJSONRPC.Case use Explorer.DataCase import Mox alias Explorer.Chain.{Address, Hash} + alias Explorer.Chain.Events.Subscriber alias Explorer.Repo alias Explorer.Utility.MissingBalanceOfToken - alias Indexer.Fetcher.TokenBalance + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical @moduletag :capture_log @@ -24,7 +26,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do insert(:token_balance, value_fetched_at: DateTime.utc_now()) - assert TokenBalance.init([], &[&1 | &2], nil) == [ + assert TokenBalanceHistorical.init([], &[&1 | &2], nil) == [ {address_hash_bytes, token_contract_address_hash_bytes, 1000, "ERC-20", nil, 0} ] end @@ -40,7 +42,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do insert(:token_balance, refetch_after: Timex.shift(Timex.now(), hours: 1)) - assert TokenBalance.init([], &[&1 | &2], nil) == [ + assert TokenBalanceHistorical.init([], &[&1 | &2], nil) == [ {address_hash_bytes, token_contract_address_hash_bytes, block_number, "ERC-20", nil, 0} ] end @@ -48,15 +50,17 @@ defmodule Indexer.Fetcher.TokenBalanceTest do describe "run/3" do setup %{json_rpc_named_arguments: json_rpc_named_arguments} do - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) :ok end test "imports the given token balances" do + Subscriber.to(:address_current_token_balances, :realtime) + %Address.TokenBalance{ address_hash: %Hash{bytes: address_hash_bytes} = address_hash, - token_contract_address_hash: %Hash{bytes: token_contract_address_hash_bytes}, + token_contract_address_hash: %Hash{bytes: token_contract_address_hash_bytes} = token_contract_address_hash, block_number: block_number } = insert(:token_balance, value_fetched_at: nil, value: nil) @@ -75,14 +79,15 @@ defmodule Indexer.Fetcher.TokenBalanceTest do end ) - assert TokenBalance.run( + assert TokenBalanceHistorical.run( [{address_hash_bytes, token_contract_address_hash_bytes, block_number, "ERC-20", nil, 0}], nil ) == :ok token_balance_updated = Repo.get_by(Address.TokenBalance, address_hash: address_hash) - assert token_balance_updated.value == Decimal.new(1_000_000_000_000_000_000_000_000) + expected_value = Decimal.new(1_000_000_000_000_000_000_000_000) + assert token_balance_updated.value == expected_value assert token_balance_updated.value_fetched_at != nil end @@ -108,7 +113,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do end ) - assert TokenBalance.run( + assert TokenBalanceHistorical.run( [ {address_hash_bytes, token_contract_address_hash_bytes, block_number, "ERC-20", nil, 0}, {address_hash_bytes, token_contract_address_hash_bytes, block_number, "ERC-20", nil, 0} @@ -125,7 +130,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do address = insert(:address) missing_balance_of_token = insert(:missing_balance_of_token, currently_implemented: true) - assert TokenBalance.run( + assert TokenBalanceHistorical.run( [ {address.hash.bytes, missing_balance_of_token.token_contract_address_hash.bytes, missing_balance_of_token.block_number, "ERC-20", nil, 0} @@ -158,7 +163,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do refute missing_balance_of_token.currently_implemented - assert TokenBalance.run( + assert TokenBalanceHistorical.run( [ {address.hash.bytes, missing_balance_of_token.token_contract_address_hash.bytes, missing_balance_of_token.block_number + window_size + 1, "ERC-20", nil, 0} @@ -196,7 +201,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do end ) - assert TokenBalance.run( + assert TokenBalanceHistorical.run( [ {address.hash.bytes, token_contract_address_hash.bytes, 1, "ERC-20", nil, 0} ], @@ -238,7 +243,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do end ) - assert TokenBalance.run( + assert TokenBalanceHistorical.run( [ {address.hash.bytes, token_contract_address_hash.bytes, 1, "ERC-20", nil, 0} ], @@ -252,7 +257,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do describe "import_token_balances/1" do test "ignores when it receives a empty list" do - assert TokenBalance.import_token_balances([]) == :ok + assert TokenBalanceHistorical.import_token_balances([]) == :ok end test "returns :error when the token balances has invalid data" do @@ -270,7 +275,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do } ] - assert TokenBalance.import_token_balances(token_balances_params) == :error + assert TokenBalanceHistorical.import_token_balances(token_balances_params) == :error end test "insert the missing address, import the token balances and return :ok when the address does not exist yet" do @@ -288,7 +293,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do ] {:ok, address_hash} = Explorer.Chain.string_to_address_hash("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF") - assert TokenBalance.import_token_balances(token_balances_params) == :ok + assert TokenBalanceHistorical.import_token_balances(token_balances_params) == :ok assert {:ok, _} = Explorer.Chain.hash_to_address(address_hash) end @@ -314,7 +319,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do } ] - assert TokenBalance.import_token_balances(token_balances_params) == :ok + assert TokenBalanceHistorical.import_token_balances(token_balances_params) == :ok end test "import the token balances and return :ok when there are multiple balances for the same address on the batch (ERC-1155)" do @@ -340,7 +345,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do } ] - assert TokenBalance.import_token_balances(token_balances_params) == :ok + assert TokenBalanceHistorical.import_token_balances(token_balances_params) == :ok end test "import ERC-404 token balances and return :ok" do @@ -365,7 +370,7 @@ defmodule Indexer.Fetcher.TokenBalanceTest do } ] - assert TokenBalance.import_token_balances(token_balances_params) == :ok + assert TokenBalanceHistorical.import_token_balances(token_balances_params) == :ok end end end diff --git a/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs b/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs index a8a34d34362c..277dfecbf1a1 100644 --- a/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_instance/helper_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.HelperTest do use EthereumJSONRPC.Case use Explorer.DataCase, async: false @@ -15,22 +16,29 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do setup do bypass = Bypass.open() - on_exit(fn -> Bypass.down(bypass) end) + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) + end) {:ok, bypass: bypass} end describe "fetch instance tests" do test "fetches json metadata for kitties" do - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) - result = "{\"id\":100500,\"name\":\"KittyBlue_2_Lemonade\",\"generation\":20,\"genes\":\"623509754227292470437941473598751240781530569131665917719736997423495595\",\"created_at\":\"2017-12-06T01:56:27.000Z\",\"birthday\":\"2017-12-06T00:00:00.000Z\",\"image_url\":\"https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/100500.svg\",\"image_url_cdn\":\"https://img.cn.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/100500.svg\",\"color\":\"strawberry\",\"background_color\":\"#ffe0e5\",\"bio\":\"Shalom! I'm KittyBlue_2_Lemonade. I'm a professional Foreign Film Director and I love cantaloupe. I'm convinced that the world is flat. One day I'll prove it. It's pawesome to meet you!\",\"kitty_type\":null,\"is_fancy\":false,\"is_exclusive\":false,\"is_special_edition\":false,\"fancy_type\":null,\"language\":\"en\",\"is_prestige\":false,\"prestige_type\":null,\"prestige_ranking\":null,\"prestige_time_limit\":null,\"status\":{\"is_ready\":true,\"is_gestating\":false,\"cooldown\":1410310201506,\"dynamic_cooldown\":1475064986478,\"cooldown_index\":10,\"cooldown_end_block\":0,\"pending_transaction_type\":null,\"pending_tx_since\":null},\"purrs\":{\"count\":1,\"is_purred\":false},\"watchlist\":{\"count\":0,\"is_watchlisted\":false},\"hatcher\":{\"address\":\"0x7b9ea9ac69b8fde875554321472c732eeff06ca0\",\"image\":\"14\",\"nickname\":\"KittyBlu\",\"hasDapper\":false,\"twitter_id\":null,\"twitter_image_url\":null,\"twitter_handle\":null},\"auction\":{},\"offer\":{},\"owner\":{\"address\":\"0x7b9ea9ac69b8fde875554321472c732eeff06ca0\",\"hasDapper\":false,\"twitter_id\":null,\"twitter_image_url\":null,\"twitter_handle\":null,\"image\":\"14\",\"nickname\":\"KittyBlu\"},\"matron\":{\"id\":46234,\"name\":\"KittyBlue_1_Limegreen\",\"generation\":10,\"enhanced_cattributes\":[{\"type\":\"body\",\"kittyId\":19631,\"position\":105,\"description\":\"cymric\"},{\"type\":\"coloreyes\",\"kittyId\":40356,\"position\":263,\"description\":\"limegreen\"},{\"type\":\"eyes\",\"kittyId\":3185,\"position\":16,\"description\":\"raisedbrow\"},{\"type\":\"pattern\",\"kittyId\":46234,\"position\":-1,\"description\":\"totesbasic\"},{\"type\":\"mouth\",\"kittyId\":46234,\"position\":-1,\"description\":\"happygokitty\"},{\"type\":\"colorprimary\",\"kittyId\":46234,\"position\":-1,\"description\":\"greymatter\"},{\"type\":\"colorsecondary\",\"kittyId\":46234,\"position\":-1,\"description\":\"lemonade\"},{\"type\":\"colortertiary\",\"kittyId\":46234,\"position\":-1,\"description\":\"granitegrey\"}],\"owner_wallet_address\":\"0x7b9ea9ac69b8fde875554321472c732eeff06ca0\",\"owner\":{\"address\":\"0x7b9ea9ac69b8fde875554321472c732eeff06ca0\"},\"created_at\":\"2017-12-03T21:29:17.000Z\",\"image_url\":\"https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/46234.svg\",\"image_url_cdn\":\"https://img.cn.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/46234.svg\",\"color\":\"limegreen\",\"is_fancy\":false,\"kitty_type\":null,\"is_exclusive\":false,\"is_special_edition\":false,\"fancy_type\":null,\"status\":{\"is_ready\":true,\"is_gestating\":false,\"cooldown\":1486487069384},\"hatched\":true,\"wrapped\":false,\"image_url_png\":\"https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/46234.png\"},\"sire\":{\"id\":82090,\"name\":null,\"generation\":19,\"enhanced_cattributes\":[{\"type\":\"body\",\"kittyId\":82090,\"position\":-1,\"description\":\"himalayan\"},{\"type\":\"coloreyes\",\"kittyId\":82090,\"position\":-1,\"description\":\"strawberry\"},{\"type\":\"eyes\",\"kittyId\":82090,\"position\":-1,\"description\":\"thicccbrowz\"},{\"type\":\"pattern\",\"kittyId\":82090,\"position\":-1,\"description\":\"totesbasic\"},{\"type\":\"mouth\",\"kittyId\":82090,\"position\":-1,\"description\":\"pouty\"},{\"type\":\"colorprimary\",\"kittyId\":82090,\"position\":-1,\"description\":\"aquamarine\"},{\"type\":\"colorsecondary\",\"kittyId\":82090,\"position\":-1,\"description\":\"chocolate\"},{\"type\":\"colortertiary\",\"kittyId\":82090,\"position\":-1,\"description\":\"granitegrey\"}],\"owner_wallet_address\":\"0x798fdad0cedc4b298fc7d53a982fa0c5f447eaa5\",\"owner\":{\"address\":\"0x798fdad0cedc4b298fc7d53a982fa0c5f447eaa5\"},\"created_at\":\"2017-12-05T06:30:05.000Z\",\"image_url\":\"https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/82090.svg\",\"image_url_cdn\":\"https://img.cn.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/82090.svg\",\"color\":\"strawberry\",\"is_fancy\":false,\"is_exclusive\":false,\"is_special_edition\":false,\"fancy_type\":null,\"status\":{\"is_ready\":true,\"is_gestating\":false,\"cooldown\":1486619010030},\"kitty_type\":null,\"hatched\":true,\"wrapped\":false,\"image_url_png\":\"https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/82090.png\"},\"children\":[],\"hatched\":true,\"wrapped\":false,\"enhanced_cattributes\":[{\"type\":\"colorprimary\",\"description\":\"greymatter\",\"position\":null,\"kittyId\":100500},{\"type\":\"coloreyes\",\"description\":\"strawberry\",\"position\":null,\"kittyId\":100500},{\"type\":\"body\",\"description\":\"himalayan\",\"position\":null,\"kittyId\":100500},{\"type\":\"colorsecondary\",\"description\":\"lemonade\",\"position\":null,\"kittyId\":100500},{\"type\":\"mouth\",\"description\":\"pouty\",\"position\":null,\"kittyId\":100500},{\"type\":\"pattern\",\"description\":\"totesbasic\",\"position\":null,\"kittyId\":100500},{\"type\":\"eyes\",\"description\":\"thicccbrowz\",\"position\":null,\"kittyId\":100500},{\"type\":\"colortertiary\",\"description\":\"kittencream\",\"position\":null,\"kittyId\":100500},{\"type\":\"secret\",\"description\":\"se5\",\"position\":-1,\"kittyId\":100500},{\"type\":\"purrstige\",\"description\":\"pu20\",\"position\":-1,\"kittyId\":100500}],\"variation\":null,\"variation_ranking\":null,\"image_url_png\":\"https://img.cryptokitties.co/0x06012c8cf97bead5deae237070f9587f8e7a266d/100500.png\",\"items\":[]}" - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://api.cryptokitties.co/kitties/100500", _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: result}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://api.cryptokitties.co/kitties/100500"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: result + }} + end + ) token = insert(:token, @@ -42,8 +50,6 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do Helper.batch_fetch_instances([{token.contract_address_hash, 100_500}]) assert Map.get(metadata, "name") == "KittyBlue_2_Lemonade" - - Application.put_env(:explorer, :http_adapter, HTTPoison) end test "replace {id} with actual token_id", %{bypass: bypass} do @@ -63,39 +69,16 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do }) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: - "0x0e89341c0000000000000000000000000000000000000000000000000000000000000309", - to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: encoded_url - } - ]} - end) + success_eth_call_expectation( + "0x0e89341c0000000000000000000000000000000000000000000000000000000000000309", + "0x5caebd3b32e210e85ce3e9d51638b9c445481567", + encoded_url + ) - Bypass.expect( - bypass, - "GET", - "/api/card/0000000000000000000000000000000000000000000000000000000000000309", - fn conn -> - Conn.resp(conn, 200, json) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: _url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: json}} end ) @@ -117,40 +100,23 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do test "fetch ipfs of ipfs/{id} format" do address_hash_string = String.downcase("0x7e01CC81fCfdf6a71323900288A69e234C464f63") - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: - "0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000", - to: ^address_hash_string - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: - "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000033697066732f516d6439707654684577676a544262456b4e6d6d47466263704a4b773137666e524241543454643472636f67323200000000000000000000000000" - } - ]} - end) - - Application.put_env(:explorer, :http_adapter, Explorer.Mox.HTTPoison) + success_eth_call_expectation( + "0xc87b56dd0000000000000000000000000000000000000000000000000000000000000000", + address_hash_string, + "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000033697066732f516d6439707654684577676a544262456b4e6d6d47466263704a4b773137666e524241543454643472636f67323200000000000000000000000000" + ) - Explorer.Mox.HTTPoison - |> expect(:get, fn "https://ipfs.io/ipfs/Qmd9pvThEwgjTBbEkNmmGFbcpJKw17fnRBAT4Td4rcog22", _headers, _options -> - {:ok, %HTTPoison.Response{status_code: 200, body: "123", headers: [{"Content-Type", "image/jpg"}]}} - end) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: "https://ipfs.io/ipfs/Qmd9pvThEwgjTBbEkNmmGFbcpJKw17fnRBAT4Td4rcog22"}, _opts -> + {:ok, + %Tesla.Env{ + status: 200, + body: "123", + headers: [{"Content-Type", "image/jpg"}] + }} + end + ) token = insert(:token, @@ -165,8 +131,6 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do } } ] = Helper.batch_fetch_instances([{token.contract_address_hash, 0}]) - - Application.put_env(:explorer, :http_adapter, HTTPoison) end test "re-fetch metadata from baseURI", %{bypass: bypass} do @@ -176,32 +140,10 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do } """ - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: - "0xc87b56dd0000000000000000000000000000000000000000000000004f3f5ce294ff3d36", - to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - error: %{code: -32015, data: "Reverted 0x", message: "execution reverted"}, - id: id, - jsonrpc: "2.0" - } - ]} - end) + error_eth_call_expectation( + "0xc87b56dd0000000000000000000000000000000000000000000000004f3f5ce294ff3d36", + "0x5caebd3b32e210e85ce3e9d51638b9c445481567" + ) encoded_url = "0x" <> @@ -213,31 +155,9 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do }) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: "0x6c0360eb", - to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: id, - jsonrpc: "2.0", - result: encoded_url - } - ]} - end) + success_eth_call_expectation("0x6c0360eb", "0x5caebd3b32e210e85ce3e9d51638b9c445481567", encoded_url) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect( bypass, @@ -270,33 +190,11 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do # https://github.com/blockscout/blockscout/issues/9696 test "fetch json in utf8 format" do - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: - "0xc87b56dd000000000000000000000000000000000000000000000000042a0d58bfd13000", - to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: - "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000115646174613a6170706c69636174696f6e2f6a736f6e3b757466382c7b226e616d65223a20224f4d4e493430342023333030303637303030303030303030303030222c226465736372697074696f6e223a225468652066726f6e74696572206f66207065726d697373696f6e6c657373206173736574732e222c2265787465726e616c5f75726c223a2268747470733a2f2f747769747465722e636f6d2f6f6d6e69636861696e343034222c22696d616765223a2268747470733a2f2f697066732e696f2f697066732f516d55364447586369535a5854483166554b6b45716a3734503846655850524b7853546a675273564b55516139352f626173652f3330303036373030303030303030303030302e4a5047227d0000000000000000000000" - } - ]} - end) + success_eth_call_expectation( + "0xc87b56dd000000000000000000000000000000000000000000000000042a0d58bfd13000", + "0x5caebd3b32e210e85ce3e9d51638b9c445481567", + "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000115646174613a6170706c69636174696f6e2f6a736f6e3b757466382c7b226e616d65223a20224f4d4e493430342023333030303637303030303030303030303030222c226465736372697074696f6e223a225468652066726f6e74696572206f66207065726d697373696f6e6c657373206173736574732e222c2265787465726e616c5f75726c223a2268747470733a2f2f747769747465722e636f6d2f6f6d6e69636861696e343034222c22696d616765223a2268747470733a2f2f697066732e696f2f697066732f516d55364447586369535a5854483166554b6b45716a3734503846655850524b7853546a675273564b55516139352f626173652f3330303036373030303030303030303030302e4a5047227d0000000000000000000000" + ) token = insert(:token, @@ -335,39 +233,16 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do }) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: - "0x0e89341c0000000000000000000000000000000000000000000000000000000000000309", - to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: encoded_url - } - ]} - end) + success_eth_call_expectation( + "0x0e89341c0000000000000000000000000000000000000000000000000000000000000309", + "0x5caebd3b32e210e85ce3e9d51638b9c445481567", + encoded_url + ) - Bypass.expect( - bypass, - "GET", - "/api/card/0000000000000000000000000000000000000000000000000000000000000309", - fn conn -> - Conn.resp(conn, 200, json) + Tesla.Test.expect_tesla_call( + times: 1, + returns: fn %{url: _url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: json}} end ) @@ -414,32 +289,13 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do }) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: - "0x0e89341c0000000000000000000000000000000000000000000000000000000000000309", - to: "0x5caebd3b32e210e85ce3e9d51638b9c445481567" - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: encoded_url - } - ]} - end) + success_eth_call_expectation( + "0x0e89341c0000000000000000000000000000000000000000000000000000000000000309", + "0x5caebd3b32e210e85ce3e9d51638b9c445481567", + encoded_url + ) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect( bypass, @@ -553,10 +409,22 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do token_instance = build(:token_instance, token_contract_address_hash: erc_721_token.contract_address_hash) + token_address = to_string(erc_721_token.contract_address_hash) + + data = + "0xc87b56dd" <> + (ABI.TypeEncoder.encode([token_instance.token_id], [{:uint, 256}]) |> Base.encode16(case: :lower)) + + # First call - should fail + error_eth_call_expectation(data, token_address) + Helper.batch_fetch_instances([ %{contract_address_hash: token_instance.token_contract_address_hash, token_id: token_instance.token_id} ]) + # Second call - should also fail + error_eth_call_expectation(data, token_address) + Helper.batch_fetch_instances([ %{contract_address_hash: token_instance.token_contract_address_hash, token_id: token_instance.token_id} ]) @@ -578,32 +446,11 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do "0xc87b56dd" <> (ABI.TypeEncoder.encode([token_instance.token_id], [{:uint, 256}]) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: ^data, - to: ^token_address - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: - "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000115646174613a6170706c69636174696f6e2f6a736f6e3b757466382c7b226e616d65223a20224f4d4e493430342023333030303637303030303030303030303030222c226465736372697074696f6e223a225468652066726f6e74696572206f66207065726d697373696f6e6c657373206173736574732e222c2265787465726e616c5f75726c223a2268747470733a2f2f747769747465722e636f6d2f6f6d6e69636861696e343034222c22696d616765223a2268747470733a2f2f697066732e696f2f697066732f516d55364447586369535a5854483166554b6b45716a3734503846655850524b7853546a675273564b55516139352f626173652f3330303036373030303030303030303030302e4a5047227d0000000000000000000000" - } - ]} - end) + success_eth_call_expectation( + data, + token_address, + "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000115646174613a6170706c69636174696f6e2f6a736f6e3b757466382c7b226e616d65223a20224f4d4e493430342023333030303637303030303030303030303030222c226465736372697074696f6e223a225468652066726f6e74696572206f66207065726d697373696f6e6c657373206173736574732e222c2265787465726e616c5f75726c223a2268747470733a2f2f747769747465722e636f6d2f6f6d6e69636861696e343034222c22696d616765223a2268747470733a2f2f697066732e696f2f697066732f516d55364447586369535a5854483166554b6b45716a3734503846655850524b7853546a675273564b55516139352f626173652f3330303036373030303030303030303030302e4a5047227d0000000000000000000000" + ) Helper.batch_fetch_instances([ %{contract_address_hash: token_instance.token_contract_address_hash, token_id: token_instance.token_id} @@ -670,31 +517,7 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do (ABI.TypeEncoder.encode([Decimal.to_integer(token_instance.token_id)], [{:uint, 256}]) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: ^data, - to: ^token_address - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - error: %{code: -32015, data: "Reverted 0x", message: "execution reverted"}, - id: id, - jsonrpc: "2.0" - } - ]} - end) + error_eth_call_expectation(data, token_address) Helper.batch_fetch_instances([ %{contract_address_hash: token_instance.token_contract_address_hash, token_id: token_instance.token_id} @@ -723,31 +546,7 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do (ABI.TypeEncoder.encode([Decimal.to_integer(token_instance.token_id)], [{:uint, 256}]) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: id, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: ^data, - to: ^token_address - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - error: %{code: -32015, data: "Reverted 0x", message: "execution reverted"}, - id: id, - jsonrpc: "2.0" - } - ]} - end) + error_eth_call_expectation(data, token_address) Helper.batch_fetch_instances([ %{contract_address_hash: token_instance.token_contract_address_hash, token_id: token_instance.token_id} @@ -786,31 +585,9 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do }) |> Base.encode16(case: :lower)) - EthereumJSONRPC.Mox - |> expect(:json_rpc, fn [ - %{ - id: 0, - jsonrpc: "2.0", - method: "eth_call", - params: [ - %{ - data: ^data, - to: ^token_address - }, - "latest" - ] - } - ], - _options -> - {:ok, - [ - %{ - id: 0, - jsonrpc: "2.0", - result: encoded_url - } - ]} - end) + success_eth_call_expectation(data, token_address, encoded_url) + + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) Bypass.expect( bypass, @@ -841,4 +618,60 @@ defmodule Indexer.Fetcher.TokenInstance.HelperTest do assert not instance.is_banned end end + + defp success_eth_call_expectation(data, to, result) do + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: 0, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: ^data, + to: ^to + }, + "latest" + ] + } + ], + _options -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: result + } + ]} + end) + end + + defp error_eth_call_expectation(data, to) do + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn [ + %{ + id: id, + jsonrpc: "2.0", + method: "eth_call", + params: [ + %{ + data: ^data, + to: ^to + }, + "latest" + ] + } + ], + _options -> + {:ok, + [ + %{ + error: %{code: -32015, data: "Reverted 0x", message: "execution reverted"}, + id: id, + jsonrpc: "2.0" + } + ]} + end) + end end diff --git a/apps/indexer/test/indexer/fetcher/token_instance/realtime_test.exs b/apps/indexer/test/indexer/fetcher/token_instance/realtime_test.exs index 542b2954ad6d..70443a275eac 100644 --- a/apps/indexer/test/indexer/fetcher/token_instance/realtime_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_instance/realtime_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.RealtimeTest do use EthereumJSONRPC.Case use Explorer.DataCase @@ -29,6 +30,8 @@ defmodule Indexer.Fetcher.TokenInstance.RealtimeTest do test "retry once after timeout" do bypass = Bypass.open() + Application.put_env(:tesla, :adapter, Tesla.Adapter.Mint) + [] |> TokenInstanceRealtime.Supervisor.child_spec() |> ExUnit.Callbacks.start_supervised!() @@ -100,23 +103,37 @@ defmodule Indexer.Fetcher.TokenInstance.RealtimeTest do type: "ERC-1155" ) - insert(:token_instance, - token_id: 777, - token_contract_address_hash: token.contract_address_hash, - metadata: nil, - error: nil - ) + inserted_instance = + insert(:token_instance, + token_id: 777, + token_contract_address_hash: token.contract_address_hash, + metadata: nil, + error: nil + ) TokenInstanceRealtime.async_fetch([ - %{token_contract_address_hash: token.contract_address_hash, token_ids: [Decimal.new(777)]} + %{token_contract_address_hash: token.contract_address_hash, token_id: Decimal.new(777)} ]) - :timer.sleep(150) + instance = + Enum.reduce_while(1..30, nil, fn _, _ -> + :timer.sleep(100) + + case Repo.get_by(Instance, + token_id: inserted_instance.token_id, + token_contract_address_hash: inserted_instance.token_contract_address_hash + ) do + %{metadata: metadata} = inst when not is_nil(metadata) -> {:halt, inst} + _ -> {:cont, nil} + end + end) - [instance] = Repo.all(Instance) + assert instance != nil, + "Timed out waiting for token instance #{inserted_instance.token_id} at #{inserted_instance.token_contract_address_hash} metadata to be populated" assert is_nil(instance.error) assert instance.metadata == %{"name" => "name"} + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) Bypass.down(bypass) end end diff --git a/apps/indexer/test/indexer/fetcher/token_instance/refetch_test.exs b/apps/indexer/test/indexer/fetcher/token_instance/refetch_test.exs index 0ca957570d22..c24c72556bcf 100644 --- a/apps/indexer/test/indexer/fetcher/token_instance/refetch_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_instance/refetch_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.RefetchTest do use Explorer.DataCase use EthereumJSONRPC.Case, async: false @@ -7,7 +8,6 @@ defmodule Indexer.Fetcher.TokenInstance.RefetchTest do alias Explorer.Chain.Token.Instance alias Indexer.Fetcher.TokenInstance.Refetch alias Indexer.Fetcher.OnDemand.NFTCollectionMetadataRefetch, as: NFTCollectionMetadataRefetchOnDemand - alias Plug.Conn describe "child_spec/1" do test "merges default options with provided options" do @@ -76,6 +76,8 @@ defmodule Indexer.Fetcher.TokenInstance.RefetchTest do test "filters and fetches token instances marked to refetch" do bypass = Bypass.open() + # Mock HTTP responses via Tesla.Test instead of real adapter + json = """ { "name": "nice nft" @@ -155,21 +157,10 @@ defmodule Indexer.Fetcher.TokenInstance.RefetchTest do end ) - Bypass.expect_once( - bypass, - "GET", - "/api/card/0000000000000000000000000000000000000000000000000000000000000001", - fn conn -> - Conn.resp(conn, 200, json) - end - ) - - Bypass.expect_once( - bypass, - "GET", - "/api/card/0000000000000000000000000000000000000000000000000000000000000002", - fn conn -> - Conn.resp(conn, 200, json) + Tesla.Test.expect_tesla_call( + times: 2, + returns: fn %{url: _url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: json, headers: [{"content-type", "application/json"}]}} end ) @@ -205,6 +196,7 @@ defmodule Indexer.Fetcher.TokenInstance.RefetchTest do assert updated_token_instance.error == nil end + Application.put_env(:tesla, :adapter, Explorer.Mock.TeslaAdapter) Bypass.down(bypass) end end diff --git a/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc1155_test.exs b/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc1155_test.exs index bb8f45e9f9c3..77ffc38eb663 100644 --- a/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc1155_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc1155_test.exs @@ -1,9 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.SanitizeERC1155Test do use Explorer.DataCase + import Mox + alias Explorer.Repo alias Explorer.Chain.Token.Instance + setup :verify_on_exit! + setup :set_mox_global + describe "sanitizer test" do test "imports token instances" do for i <- 0..3 do @@ -17,6 +23,18 @@ defmodule Indexer.Fetcher.TokenInstance.SanitizeERC1155Test do ) end + # Mock the ERC-1155 uri() calls to return errors so instances get error field populated + EthereumJSONRPC.Mox + |> expect(:json_rpc, fn _requests, _options -> + {:ok, + [ + %{id: 0, error: %{code: -32015, message: "VM execution error"}}, + %{id: 1, error: %{code: -32015, message: "VM execution error"}}, + %{id: 2, error: %{code: -32015, message: "VM execution error"}}, + %{id: 3, error: %{code: -32015, message: "VM execution error"}} + ]} + end) + assert [] = Repo.all(Instance) start_supervised!({Indexer.Fetcher.TokenInstance.SanitizeERC1155, []}) diff --git a/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc721_test.exs b/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc721_test.exs index 51db3b70d478..7a91b43a09a6 100644 --- a/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc721_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_instance/sanitize_erc721_test.exs @@ -1,11 +1,39 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenInstance.SanitizeERC721Test do use Explorer.DataCase + import Mox + + setup :verify_on_exit! + setup :set_mox_global + alias Explorer.Repo alias Explorer.Chain.Token.Instance + alias Explorer.Application.Constants + alias Explorer.Migrator.MigrationStatus describe "sanitizer test" do + setup do + initial_env = Application.get_env(:indexer, Indexer.Fetcher.TokenInstance.SanitizeERC721) + + on_exit(fn -> + Application.put_env(:indexer, Indexer.Fetcher.TokenInstance.SanitizeERC721, initial_env) + end) + + {:ok, initial_env: initial_env} + end + test "imports token instances" do + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _options -> + {:ok, + Enum.map(requests, fn %{id: id} -> + %{ + id: id, + error: %{code: -32000, message: "execution reverted"} + } + end)} + end) + for x <- 0..3 do erc_721_token = insert(:token, type: "ERC-721") @@ -34,6 +62,162 @@ defmodule Indexer.Fetcher.TokenInstance.SanitizeERC721Test do assert Enum.count(instances) == 4 assert Enum.all?(instances, fn instance -> !is_nil(instance.error) and is_nil(instance.metadata) end) + assert MigrationStatus.get_status("backfill_erc721") == "completed" + end + + test "imports token instances with low tokens queue size", %{initial_env: initial_env} do + stub(EthereumJSONRPC.Mox, :json_rpc, fn requests, _options -> + {:ok, + Enum.map(requests, fn %{id: id} -> + %{ + id: id, + error: %{code: -32000, message: "execution reverted"} + } + end)} + end) + + tokens = + for x <- 0..5 do + erc_721_token = insert(:token, type: "ERC-721") + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + address = insert(:address) + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_contract_address: erc_721_token.contract_address, + token_ids: [x] + ) + + erc_721_token + end + + assert [] = Repo.all(Instance) + + Application.put_env( + :indexer, + Indexer.Fetcher.TokenInstance.SanitizeERC721, + Keyword.put(initial_env, :tokens_queue_size, 1) + ) + + start_supervised!({Indexer.Fetcher.TokenInstance.SanitizeERC721, []}) + start_supervised!({Indexer.Fetcher.TokenInstance.Sanitize.Supervisor, [[flush_interval: 1]]}) + + :timer.sleep(500) + + instances = Repo.all(Instance) + + assert Enum.count(instances) == 6 + assert Enum.all?(instances, fn instance -> !is_nil(instance.error) and is_nil(instance.metadata) end) + + assert MigrationStatus.get_status("backfill_erc721") == "completed" + assert List.last(tokens).contract_address_hash == Constants.get_last_processed_token_address_hash() + end + + test "don't start if completed" do + MigrationStatus.set_status("backfill_erc721", "completed") + + for x <- 0..5 do + erc_721_token = insert(:token, type: "ERC-721") + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + address = insert(:address) + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_contract_address: erc_721_token.contract_address, + token_ids: [x] + ) + end + + assert [] = Repo.all(Instance) + + start_supervised!({Indexer.Fetcher.TokenInstance.SanitizeERC721, []}) + start_supervised!({Indexer.Fetcher.TokenInstance.Sanitize.Supervisor, [[flush_interval: 1]]}) + + :timer.sleep(500) + + instances = Repo.all(Instance) + + assert Enum.count(instances) == 0 + + assert MigrationStatus.get_status("backfill_erc721") == "completed" + end + + test "takes into account the last processed token address hash" do + tokens = + for x <- 0..5 do + erc_721_token = insert(:token, type: "ERC-721") + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + address = insert(:address) + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_contract_address: erc_721_token.contract_address, + token_ids: [x] + ) + + erc_721_token + end + + assert [] = Repo.all(Instance) + + pid_sanitize_erc721 = start_supervised!({Indexer.Fetcher.TokenInstance.SanitizeERC721, []}) + start_supervised!({Indexer.Fetcher.TokenInstance.Sanitize.Supervisor, [[flush_interval: 1]]}) + + :timer.sleep(500) + + instances = Repo.all(Instance) + + assert Enum.count(instances) == 6 + last_token = List.last(tokens) + assert MigrationStatus.get_status("backfill_erc721") == "completed" + assert last_token.contract_address_hash == Constants.get_last_processed_token_address_hash() + refute Process.alive?(pid_sanitize_erc721) + + first_token = List.first(tokens) + + transaction = insert(:transaction, input: "0xabcd010203040506") |> with_block() + + address = insert(:address) + + insert(:token_transfer, + transaction: transaction, + block: transaction.block, + block_number: transaction.block_number, + from_address: address, + token_contract_address: first_token.contract_address, + token_ids: [6] + ) + + MigrationStatus.set_status("backfill_erc721", "started") + + {:ok, supervisor} = ExUnit.fetch_test_supervisor() + + {:ok, _pid_sanitize_erc721} = + Supervisor.restart_child(supervisor, Indexer.Fetcher.TokenInstance.SanitizeERC721) + + :timer.sleep(500) + + assert MigrationStatus.get_status("backfill_erc721") == "completed" + assert last_token.contract_address_hash == Constants.get_last_processed_token_address_hash() + + instances = Repo.all(Instance) + + assert Enum.count(instances) == 6 end end end diff --git a/apps/indexer/test/indexer/fetcher/token_test.exs b/apps/indexer/test/indexer/fetcher/token_test.exs index d9494d097fd9..a9de82b02bcf 100644 --- a/apps/indexer/test/indexer/fetcher/token_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenTest do use EthereumJSONRPC.Case use Explorer.DataCase diff --git a/apps/indexer/test/indexer/fetcher/token_updater_test.exs b/apps/indexer/test/indexer/fetcher/token_updater_test.exs index 28826604e1f0..8943f33e6a89 100644 --- a/apps/indexer/test/indexer/fetcher/token_updater_test.exs +++ b/apps/indexer/test/indexer/fetcher/token_updater_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenUpdaterTest do use Explorer.DataCase diff --git a/apps/indexer/test/indexer/fetcher/uncle_block_test.exs b/apps/indexer/test/indexer/fetcher/uncle_block_test.exs index 052b4b75f492..f1fdf16f8b99 100644 --- a/apps/indexer/test/indexer/fetcher/uncle_block_test.exs +++ b/apps/indexer/test/indexer/fetcher/uncle_block_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.UncleBlockTest do # MUST be `async: false` so that {:shared, pid} is set for connection to allow CoinBalanceFetcher's self-send to have # connection allowed immediately. @@ -61,6 +62,7 @@ defmodule Indexer.Fetcher.UncleBlockTest do uncle_hash_data = to_string(uncle_hash) uncle_uncle_hash_data = to_string(block_hash()) index_data = integer_to_quantity(index) + transaction_hash = "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6" EthereumJSONRPC.Mox |> expect(:json_rpc, fn [ @@ -104,7 +106,7 @@ defmodule Indexer.Fetcher.UncleBlockTest do "from" => "0xe8ddc5c7a2d2f0d7a9798459c0104fdf5e987aca", "gas" => "0x47b760", "gasPrice" => "0x174876e800", - "hash" => "0x3a3eb134e6792ce9403ea4188e5e79693de9e4c94e499db132be086400da79e6", + "hash" => transaction_hash, "input" => "0x", "nonce" => "0x0", "r" => "0xad3733df250c87556335ffe46c23e34dbaffde93097ef92f52c88632a40f0c75", @@ -124,7 +126,7 @@ defmodule Indexer.Fetcher.UncleBlockTest do end) UncleBlock.Supervisor.Case.start_supervised!( - block_fetcher: %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments} + block_fetcher: %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments, task_supervisor: nil} ) wait(fn -> @@ -135,6 +137,15 @@ defmodule Indexer.Fetcher.UncleBlockTest do ) end) + wait_for_results(fn -> + Repo.one!( + from(tf in Chain.Transaction.Fork, + where: tf.hash == ^transaction_hash, + select: tf.hash + ) + ) + end) + refute is_nil(Repo.get(Chain.Block, uncle_hash)) assert Repo.aggregate(Chain.Transaction.Fork, :count, :hash) == 1 end @@ -167,7 +178,10 @@ defmodule Indexer.Fetcher.UncleBlockTest do end) assert {:retry, ^entries} = - UncleBlock.run(entries, %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments}) + UncleBlock.run(entries, %Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: nil + }) end test "retries only unique uncles on failed request", %{json_rpc_named_arguments: json_rpc_named_arguments} do @@ -197,7 +211,10 @@ defmodule Indexer.Fetcher.UncleBlockTest do end) assert {:retry, [^entry]} = - UncleBlock.run(entries, %Block.Fetcher{json_rpc_named_arguments: json_rpc_named_arguments}) + UncleBlock.run(entries, %Block.Fetcher{ + json_rpc_named_arguments: json_rpc_named_arguments, + task_supervisor: nil + }) end end @@ -229,7 +246,8 @@ defmodule Indexer.Fetcher.UncleBlockTest do params, %Block.Fetcher{ json_rpc_named_arguments: json_rpc_named_arguments, - callback_module: Indexer.Block.Realtime.Fetcher + callback_module: Indexer.Block.Realtime.Fetcher, + task_supervisor: nil }, [] ) diff --git a/apps/indexer/test/indexer/fetcher/withdrawal_test.exs b/apps/indexer/test/indexer/fetcher/withdrawal_test.exs index f3b13f783af8..bee0dd08bc4b 100644 --- a/apps/indexer/test/indexer/fetcher/withdrawal_test.exs +++ b/apps/indexer/test/indexer/fetcher/withdrawal_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.WithdrawalTest do use EthereumJSONRPC.Case use Explorer.DataCase diff --git a/apps/indexer/test/indexer/fetcher/zksync/utils/rpc_test.exs b/apps/indexer/test/indexer/fetcher/zksync/utils/rpc_test.exs new file mode 100644 index 000000000000..d79eb1ed708e --- /dev/null +++ b/apps/indexer/test/indexer/fetcher/zksync/utils/rpc_test.exs @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.ZkSync.Utils.RpcTest do + use EthereumJSONRPC.Case + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.Hash + alias Indexer.Fetcher.ZkSync.Utils.Rpc, as: ZksyncRpc + + setup :set_mox_global + setup :verify_on_exit! + + setup %{json_rpc_named_arguments: json_rpc_named_arguments} do + mocked_json_rpc_named_arguments = Keyword.put(json_rpc_named_arguments, :transport, EthereumJSONRPC.Mox) + + %{json_rpc_named_arguments: mocked_json_rpc_named_arguments} + end + + describe "fetch_transaction_by_hash/2" do + test "returns transaction data for a valid transaction hash", %{json_rpc_named_arguments: json_rpc_named_arguments} do + transaction_hash = "0x3078313131313131313131313131313131313131313131313131313131313131" + raw_transaction_hash = "3078313131313131313131313131313131313131313131313131313131313131" |> Base.decode16!() + + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn + %{id: _id, method: "eth_getTransactionByHash", params: [^transaction_hash]}, _options -> + {:ok, + %{ + "hash" => transaction_hash + }} + + %{id: _id, method: "eth_getTransactionByHash", params: [%Hash{bytes: ^raw_transaction_hash}]}, _options -> + {:ok, + %{ + "hash" => transaction_hash + }} + end + ) + + assert %{"hash" => ^transaction_hash} = + ZksyncRpc.fetch_transaction_by_hash(raw_transaction_hash, json_rpc_named_arguments) + end + end + + describe "fetch_transaction_receipt_by_hash/2" do + test "returns transaction receipt data for a valid transaction hash", + %{json_rpc_named_arguments: json_rpc_named_arguments} do + transaction_hash = "0x3078313131313131313131313131313131313131313131313131313131313131" + raw_transaction_hash = "3078313131313131313131313131313131313131313131313131313131313131" |> Base.decode16!() + + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn + %{id: _id, method: "eth_getTransactionReceipt", params: [^transaction_hash]}, _options -> + {:ok, + %{ + "transactionHash" => transaction_hash + }} + + %{id: _id, method: "eth_getTransactionReceipt", params: [%Hash{bytes: ^raw_transaction_hash}]}, _options -> + {:ok, + %{ + "transactionHash" => transaction_hash + }} + end + ) + + assert %{"transactionHash" => ^transaction_hash} = + ZksyncRpc.fetch_transaction_receipt_by_hash(raw_transaction_hash, json_rpc_named_arguments) + end + end +end diff --git a/apps/indexer/test/indexer/helper_test.exs b/apps/indexer/test/indexer/helper_test.exs new file mode 100644 index 000000000000..298cc5f7a7ed --- /dev/null +++ b/apps/indexer/test/indexer/helper_test.exs @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.HelperTest do + use ExUnit.Case, async: true + + alias Indexer.Helper + + describe "json_rpc_named_arguments/1" do + test "builds expected JSON-RPC named arguments from RPC URL" do + rpc_url = "https://rpc.example" + timeout = :timer.minutes(10) + + assert Helper.json_rpc_named_arguments(rpc_url) == + [ + transport: EthereumJSONRPC.HTTP, + transport_options: [ + http: EthereumJSONRPC.HTTP.Tesla, + urls: [rpc_url], + http_options: [ + recv_timeout: timeout, + timeout: timeout, + pool: :ethereum_jsonrpc + ] + ] + ] + end + + test "handles nil RPC URL" do + assert_raise ArgumentError, "RPC URL must be a non-empty string", fn -> + Helper.json_rpc_named_arguments(nil) + end + end + + test "handles empty string RPC URL" do + assert_raise ArgumentError, "RPC URL must be a non-empty string", fn -> + Helper.json_rpc_named_arguments("") + end + end + + test "normalizes trailing slash in RPC URL" do + timeout = :timer.minutes(10) + + assert Helper.json_rpc_named_arguments("https://rpc.example/") == + [ + transport: EthereumJSONRPC.HTTP, + transport_options: [ + http: EthereumJSONRPC.HTTP.Tesla, + urls: ["https://rpc.example"], + http_options: [ + recv_timeout: timeout, + timeout: timeout, + pool: :ethereum_jsonrpc + ] + ] + ] + end + end +end diff --git a/apps/indexer/test/indexer/migrator/recovery_weth_token_transfers_test.exs b/apps/indexer/test/indexer/migrator/recovery_weth_token_transfers_test.exs index f77a51d81f9e..f5edb0f649c8 100644 --- a/apps/indexer/test/indexer/migrator/recovery_weth_token_transfers_test.exs +++ b/apps/indexer/test/indexer/migrator/recovery_weth_token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Migrator.RecoveryWETHTokenTransfersTest do use Explorer.DataCase, async: false diff --git a/apps/indexer/test/indexer/pending_ops_cleaner_test.exs b/apps/indexer/test/indexer/pending_ops_cleaner_test.exs index 51c251b3f62f..4756c4f3d41e 100644 --- a/apps/indexer/test/indexer/pending_ops_cleaner_test.exs +++ b/apps/indexer/test/indexer/pending_ops_cleaner_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.PendingOpsCleanerTest do use Explorer.DataCase diff --git a/apps/indexer/test/indexer/pending_transactions_sanitizer_test.exs b/apps/indexer/test/indexer/pending_transactions_sanitizer_test.exs new file mode 100644 index 000000000000..b4a0f59ddcfe --- /dev/null +++ b/apps/indexer/test/indexer/pending_transactions_sanitizer_test.exs @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.PendingTransactionsSanitizerTest do + use EthereumJSONRPC.Case + use Explorer.DataCase + + import Mox + + alias Explorer.Chain.{Transaction, Wei} + alias Explorer.Repo + alias Indexer.PendingTransactionsSanitizer + + describe "sanitize_pending_transactions/1" do + test "with included transaction", %{json_rpc_named_arguments: json_rpc_named_arguments} do + pending_transaction = insert(:transaction, inserted_at: Timex.shift(Timex.now(), days: -2)) + block = insert(:block, consensus: true, refetch_needed: false) + + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn _json, _options -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: %{ + "transactionHash" => to_string(pending_transaction.hash), + "blockHash" => to_string(block.hash), + "cumulativeGasUsed" => "0x5208", + "gasUsed" => "0x5208", + "status" => "0x1", + "transactionIndex" => "0x0" + } + } + ]} + end + ) + + PendingTransactionsSanitizer.sanitize_pending_transactions(json_rpc_named_arguments) + + updated_block = Repo.reload(block) + assert updated_block.refetch_needed == true + + assert [transaction] = Repo.all(Transaction) + + assert transaction.cumulative_gas_used == Decimal.new("21000") + assert transaction.gas_used == Decimal.new("21000") + assert transaction.block_hash == block.hash + assert transaction.status == :ok + assert transaction.index == 0 + end + + test "with empty result", %{json_rpc_named_arguments: json_rpc_named_arguments} do + insert(:transaction, inserted_at: Timex.shift(Timex.now(), days: -2)) + block = insert(:block, consensus: true, refetch_needed: false) + + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn _json, _options -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: nil + } + ]} + end + ) + + PendingTransactionsSanitizer.sanitize_pending_transactions(json_rpc_named_arguments) + + updated_block = Repo.reload(block) + assert updated_block.refetch_needed == false + + assert [] = Repo.all(Transaction) + end + + test "with non-consensus block", %{json_rpc_named_arguments: json_rpc_named_arguments} do + pending_transaction = insert(:transaction, inserted_at: Timex.shift(Timex.now(), days: -2)) + block = insert(:block, consensus: false, refetch_needed: false) + + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn _json, _options -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: %{ + "transactionHash" => to_string(pending_transaction.hash), + "blockHash" => to_string(block.hash), + "cumulativeGasUsed" => "0x5208", + "gasUsed" => "0x5208", + "status" => "0x1", + "transactionIndex" => "0x0" + } + } + ]} + end + ) + + PendingTransactionsSanitizer.sanitize_pending_transactions(json_rpc_named_arguments) + + updated_block = Repo.reload(block) + assert updated_block.refetch_needed == false + + assert [transaction] = Repo.all(Transaction) + + assert transaction.cumulative_gas_used == Decimal.new("21000") + assert transaction.gas_used == Decimal.new("21000") + assert transaction.block_hash == block.hash + assert transaction.status == :ok + assert transaction.index == 0 + end + + test "with gas price and contract address", %{json_rpc_named_arguments: json_rpc_named_arguments} do + pending_transaction = insert(:transaction, inserted_at: Timex.shift(Timex.now(), days: -2), gas_price: nil) + block = insert(:block, consensus: true, refetch_needed: false) + contract_address = insert(:address) + + EthereumJSONRPC.Mox + |> expect( + :json_rpc, + fn _json, _options -> + {:ok, + [ + %{ + id: 0, + jsonrpc: "2.0", + result: %{ + "transactionHash" => to_string(pending_transaction.hash), + "blockHash" => to_string(block.hash), + "cumulativeGasUsed" => "0x5208", + "gasUsed" => "0x5208", + "effectiveGasPrice" => "0x76be5e6c00", + "contractAddress" => to_string(contract_address.hash), + "status" => "0x1", + "transactionIndex" => "0x0" + } + } + ]} + end + ) + + PendingTransactionsSanitizer.sanitize_pending_transactions(json_rpc_named_arguments) + + updated_block = Repo.reload(block) + assert updated_block.refetch_needed == true + + assert [transaction] = Repo.all(Transaction) + + assert transaction.cumulative_gas_used == Decimal.new("21000") + assert transaction.gas_used == Decimal.new("21000") + assert Wei.to(transaction.gas_price, :wei) == Decimal.new("510000000000") + assert transaction.block_hash == block.hash + assert transaction.created_contract_address_hash == contract_address.hash + assert transaction.status == :ok + assert transaction.index == 0 + end + end +end diff --git a/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs b/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs index 9e1468916ecf..830196857a61 100644 --- a/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs +++ b/apps/indexer/test/indexer/temporary/uncataloged_token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Temporary.UncatalogedTokenTransfersTest do use Explorer.DataCase @@ -46,6 +47,7 @@ defmodule Indexer.Temporary.UncatalogedTokenTransfersTest do insert(:token_transfer_log, transaction: transaction, address_hash: address.hash, + address: address, block: block ) diff --git a/apps/indexer/test/indexer/token_balances_test.exs b/apps/indexer/test/indexer/token_balances_test.exs index 92265544486c..6a9a0e887723 100644 --- a/apps/indexer/test/indexer/token_balances_test.exs +++ b/apps/indexer/test/indexer/token_balances_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.TokenBalancesTest do use EthereumJSONRPC.Case use Explorer.DataCase @@ -5,7 +6,8 @@ defmodule Indexer.TokenBalancesTest do doctest Indexer.TokenBalances alias Indexer.TokenBalances - alias Indexer.Fetcher.TokenBalance + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical alias Explorer.Chain.Hash import Mox @@ -16,7 +18,8 @@ defmodule Indexer.TokenBalancesTest do describe "fetch_token_balances_from_blockchain/2" do setup %{json_rpc_named_arguments: json_rpc_named_arguments} do - TokenBalance.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceHistorical.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) + TokenBalanceCurrent.Supervisor.Case.start_supervised!(json_rpc_named_arguments: json_rpc_named_arguments) :ok end diff --git a/apps/indexer/test/indexer/transform/address_coin_balances_test.exs b/apps/indexer/test/indexer/transform/address_coin_balances_test.exs index a230b43e5d5d..836789bc5652 100644 --- a/apps/indexer/test/indexer/transform/address_coin_balances_test.exs +++ b/apps/indexer/test/indexer/transform/address_coin_balances_test.exs @@ -1,5 +1,6 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressCoinBalancesTest do - use ExUnit.Case, async: true + use Explorer.DataCase, async: true alias Explorer.Factory alias Indexer.Transform.AddressCoinBalances @@ -36,6 +37,7 @@ defmodule Indexer.Transform.AddressCoinBalancesTest do :internal_transaction |> Factory.params_for() |> Map.put(:type, "call") + |> Map.put(:call_type, "call") |> Map.put(:block_number, block_number) |> Map.put(:from_address_hash, from_address_hash) |> Map.put(:to_address_hash, to_address_hash) @@ -76,10 +78,56 @@ defmodule Indexer.Transform.AddressCoinBalancesTest do params_set = AddressCoinBalances.params_set(%{internal_transactions_params: [internal_transaction_params]}) - assert MapSet.size(params_set) == 1 + assert MapSet.size(params_set) == 2 assert MapSet.member?(params_set, %{address_hash: created_contract_address_hash, block_number: block_number}) end + test "with create2 internal transaction without error extracts created_contract_address_hash and from_address_hash" do + block_number = 1 + + created_contract_address_hash = + Factory.address_hash() + |> to_string() + + from_address_hash = + Factory.address_hash() + |> to_string() + + internal_transaction_params = + :internal_transaction_create + |> Factory.params_for() + |> Map.put(:type, "create2") + |> Map.put(:block_number, block_number) + |> Map.put(:created_contract_address_hash, created_contract_address_hash) + |> Map.put(:from_address_hash, from_address_hash) + + params_set = AddressCoinBalances.params_set(%{internal_transactions_params: [internal_transaction_params]}) + + assert MapSet.size(params_set) == 2 + assert MapSet.member?(params_set, %{address_hash: created_contract_address_hash, block_number: block_number}) + assert MapSet.member?(params_set, %{address_hash: from_address_hash, block_number: block_number}) + end + + test "ignores call internal transaction with call type that does not change balances" do + block_number = 1 + + from_address_hash = + Factory.address_hash() + |> to_string() + + internal_transaction_params = + :internal_transaction + |> Factory.params_for() + |> Map.put(:type, "call") + |> Map.put(:call_type, "staticcall") + |> Map.put(:block_number, block_number) + |> Map.put(:from_address_hash, from_address_hash) + + params_set = AddressCoinBalances.params_set(%{internal_transactions_params: [internal_transaction_params]}) + + assert MapSet.size(params_set) == 0 + end + test "with self-destruct internal transaction extracts from_address_hash and to_address_hash" do block_number = 1 @@ -198,4 +246,45 @@ defmodule Indexer.Transform.AddressCoinBalancesTest do assert MapSet.member?(params_set, %{address_hash: to_address_hash, block_number: block_number}) end end + + if Application.compile_env(:explorer, :chain_type) == :arc do + describe "params_set/1 logs_params on Arc chain" do + test "with EIP-7708 Transfer queues non-burn from_address and to_address for coin balances" do + from_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + to_address = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + logs_params = [ + %{ + block_number: 100, + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + first_topic: Explorer.Chain.TokenTransfer.constant(), + second_topic: "0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + third_topic: "0x000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + block_number: 101, + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + first_topic: Explorer.Chain.TokenTransfer.constant(), + second_topic: "0x0000000000000000000000000000000000000000000000000000000000000000", + third_topic: "0x000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + block_number: 102, + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + first_topic: Explorer.Chain.TokenTransfer.constant(), + second_topic: "0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + third_topic: "0x0000000000000000000000000000000000000000000000000000000000000000" + } + ] + + params_set = AddressCoinBalances.params_set(%{logs_params: logs_params}) + + assert MapSet.member?(params_set, %{address_hash: from_address, block_number: 100}) + assert MapSet.member?(params_set, %{address_hash: to_address, block_number: 100}) + assert MapSet.member?(params_set, %{address_hash: to_address, block_number: 101}) + assert MapSet.member?(params_set, %{address_hash: from_address, block_number: 102}) + assert MapSet.size(params_set) == 4 + end + end + end end diff --git a/apps/indexer/test/indexer/transform/address_token_balances_test.exs b/apps/indexer/test/indexer/transform/address_token_balances_test.exs index 28b67ac2ffa7..235b6d7d8937 100644 --- a/apps/indexer/test/indexer/transform/address_token_balances_test.exs +++ b/apps/indexer/test/indexer/transform/address_token_balances_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressTokenBalancesTest do use ExUnit.Case, async: true diff --git a/apps/indexer/test/indexer/transform/addresses.exs b/apps/indexer/test/indexer/transform/addresses.exs index d1f21d74553d..adad66f8b6f7 100644 --- a/apps/indexer/test/indexer/transform/addresses.exs +++ b/apps/indexer/test/indexer/transform/addresses.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.AddressesTest do use Explorer.DataCase, async: true diff --git a/apps/indexer/test/indexer/transform/blocks/base_test.exs b/apps/indexer/test/indexer/transform/blocks/base_test.exs index c58800f8a443..431a727724a0 100644 --- a/apps/indexer/test/indexer/transform/blocks/base_test.exs +++ b/apps/indexer/test/indexer/transform/blocks/base_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Blocks.BaseTest do use ExUnit.Case diff --git a/apps/indexer/test/indexer/transform/blocks/clique_test.exs b/apps/indexer/test/indexer/transform/blocks/clique_test.exs index 60d0824584b4..a947ca6375f1 100644 --- a/apps/indexer/test/indexer/transform/blocks/clique_test.exs +++ b/apps/indexer/test/indexer/transform/blocks/clique_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.Blocks.CliqueTest do use ExUnit.Case diff --git a/apps/indexer/test/indexer/transform/blocks_test.exs b/apps/indexer/test/indexer/transform/blocks_test.exs index 8f0e7cb44b72..0e8507fe08fa 100644 --- a/apps/indexer/test/indexer/transform/blocks_test.exs +++ b/apps/indexer/test/indexer/transform/blocks_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.BlocksTest do use ExUnit.Case diff --git a/apps/indexer/test/indexer/transform/celo/validator_epoch_payment_distributions_test.exs b/apps/indexer/test/indexer/transform/celo/validator_epoch_payment_distributions_test.exs new file mode 100644 index 000000000000..076a9200ccb3 --- /dev/null +++ b/apps/indexer/test/indexer/transform/celo/validator_epoch_payment_distributions_test.exs @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Transform.Celo.ValidatorEpochPaymentDistributionsTest do + use EthereumJSONRPC.Case + use Explorer.DataCase + + alias Explorer.Chain.Hash + alias Indexer.Transform.Celo.ValidatorEpochPaymentDistributions + + describe "parse/1" do + setup do + old_env = Application.get_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, []) + + validator = insert(:address) + + Application.put_env( + :explorer, + Explorer.Chain.Cache.CeloCoreContracts, + Keyword.merge(old_env, + contracts: %{ + "addresses" => %{ + "Validators" => [ + %{"address" => to_string(validator.hash), "updated_at_block_number" => 0} + ] + } + } + ) + ) + + on_exit(fn -> Application.put_env(:explorer, Explorer.Chain.Cache.CeloCoreContracts, old_env) end) + + %{validator: validator} + end + + test "parses log", %{validator: validator} do + transaction = + :transaction + |> insert() + |> with_block() + + log = + insert(:log, + transaction_hash: transaction.hash, + transaction: transaction, + address_hash: validator.hash, + address: validator, + first_topic: ValidatorEpochPaymentDistributions.signature(), + second_topic: "0x0000000000000000000000003078323232323232323232323232323232323232", + third_topic: "0x0000000000000000000000003078333333333333333333333333333333333333", + data: "0x" <> Base.encode16(ABI.encode("(uint256,uint256)", [{123, 456}]), case: :lower) + ) + + logs = [log] + + assert [ + %{ + group_address: %Hash{ + byte_count: 20, + bytes: "3078333333333333333333333333333333333333" |> Base.decode16!(case: :lower) + }, + group_payment: 456, + validator_address: %Hash{ + byte_count: 20, + bytes: "3078323232323232323232323232323232323232" |> Base.decode16!(case: :lower) + }, + validator_payment: 123 + } + ] == ValidatorEpochPaymentDistributions.parse(logs) + end + end +end diff --git a/apps/indexer/test/indexer/transform/mint_transfers_test.exs b/apps/indexer/test/indexer/transform/mint_transfers_test.exs index 4670e983a272..e58ffa98e643 100644 --- a/apps/indexer/test/indexer/transform/mint_transfers_test.exs +++ b/apps/indexer/test/indexer/transform/mint_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.MintTransfersTest do use ExUnit.Case, async: true diff --git a/apps/indexer/test/indexer/transform/stability/validators_test.exs b/apps/indexer/test/indexer/transform/stability/validators_test.exs new file mode 100644 index 000000000000..e78068d3a231 --- /dev/null +++ b/apps/indexer/test/indexer/transform/stability/validators_test.exs @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +if Application.compile_env(:explorer, :chain_type) == :stability do + defmodule Indexer.Transform.Stability.ValidatorsTest do + use ExUnit.Case, async: true + + alias Indexer.Transform.Stability.Validators + + describe "parse/1" do + setup do + # Save original chain type and restore after each test + original_chain_type = Application.get_env(:explorer, :chain_type) + + on_exit(fn -> + Application.put_env(:explorer, :chain_type, original_chain_type) + end) + + :ok + end + + test "parses blocks for stability chain type and returns validator counter updates" do + Application.put_env(:explorer, :chain_type, :stability) + + blocks = [ + %{ + number: 100, + hash: "0xabc123", + miner_hash: "0x1234567890abcdef1234567890abcdef12345678" + }, + %{ + number: 101, + hash: "0xdef456", + miner_hash: "0x1234567890abcdef1234567890abcdef12345678" + }, + %{ + number: 102, + hash: "0x789012", + miner_hash: "0xabcdef1234567890abcdef1234567890abcdef12" + } + ] + + result = Validators.parse(blocks) + + expected = [ + %{ + address_hash: "0x1234567890abcdef1234567890abcdef12345678", + blocks_validated: 2 + }, + %{ + address_hash: "0xabcdef1234567890abcdef1234567890abcdef12", + blocks_validated: 1 + } + ] + + # Sort both lists by address_hash for comparison + sorted_result = Enum.sort_by(result, & &1.address_hash) + sorted_expected = Enum.sort_by(expected, & &1.address_hash) + + assert sorted_result == sorted_expected + end + + test "filters out blocks with nil miner_hash" do + Application.put_env(:explorer, :chain_type, :stability) + + blocks = [ + %{ + number: 100, + hash: "0xabc123", + miner_hash: "0x1234567890abcdef1234567890abcdef12345678" + }, + %{ + number: 101, + hash: "0xdef456", + miner_hash: nil + }, + %{ + number: 102, + hash: "0x789012" + # no miner_hash field + } + ] + + result = Validators.parse(blocks) + + expected = [ + %{ + address_hash: "0x1234567890abcdef1234567890abcdef12345678", + blocks_validated: 1 + } + ] + + assert result == expected + end + + test "returns empty list for non-stability chain type" do + Application.put_env(:explorer, :chain_type, :ethereum) + + blocks = [ + %{ + number: 100, + hash: "0xabc123", + miner_hash: "0x1234567890abcdef1234567890abcdef12345678" + }, + %{ + number: 101, + hash: "0xdef456", + miner_hash: "0xabcdef1234567890abcdef1234567890abcdef12" + } + ] + + result = Validators.parse(blocks) + + assert result == [] + end + + test "returns empty list for empty blocks list" do + Application.put_env(:explorer, :chain_type, :stability) + + result = Validators.parse([]) + + assert result == [] + end + + test "returns empty list for nil input" do + Application.put_env(:explorer, :chain_type, :stability) + + result = Validators.parse(nil) + + assert result == [] + end + + test "groups multiple blocks by same validator correctly" do + Application.put_env(:explorer, :chain_type, :stability) + + blocks = [ + %{number: 100, hash: "0x1", miner_hash: "0x1111"}, + %{number: 101, hash: "0x2", miner_hash: "0x1111"}, + %{number: 102, hash: "0x3", miner_hash: "0x1111"}, + %{number: 103, hash: "0x4", miner_hash: "0x2222"}, + %{number: 104, hash: "0x5", miner_hash: "0x1111"} + ] + + result = Validators.parse(blocks) + + expected = [ + %{ + address_hash: "0x1111", + blocks_validated: 4 + }, + %{ + address_hash: "0x2222", + blocks_validated: 1 + } + ] + + # Sort both lists by address_hash for comparison + sorted_result = Enum.sort_by(result, & &1.address_hash) + sorted_expected = Enum.sort_by(expected, & &1.address_hash) + + assert sorted_result == sorted_expected + end + end + end +end diff --git a/apps/indexer/test/indexer/transform/token_transfers_test.exs b/apps/indexer/test/indexer/transform/token_transfers_test.exs index f25c207681bc..2d70864cdf4e 100644 --- a/apps/indexer/test/indexer/transform/token_transfers_test.exs +++ b/apps/indexer/test/indexer/transform/token_transfers_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Transform.TokenTransfersTest do use Explorer.DataCase @@ -152,6 +153,52 @@ defmodule Indexer.Transform.TokenTransfersTest do Application.put_env(:explorer, Explorer.Chain.TokenTransfer, env) end + test "deduplicates duplicate contract tokens by contract_address_hash for Celo" do + original_chain_type = Application.get_env(:explorer, :chain_type) + Application.put_env(:explorer, :chain_type, :celo) + + on_exit(fn -> Application.put_env(:explorer, :chain_type, original_chain_type) end) + + contract_address_hash = "0x1234567890abcdef1234567890abcdef12345678" + + logs = [ + %{ + address_hash: contract_address_hash, + block_number: 1_000_001, + block_hash: "0x1111111111111111111111111111111111111111111111111111111111111111", + data: "0x000000000000000000000000000000000000000000000000000000000000000a", + first_topic: "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + fourth_topic: nil, + index: 0, + second_topic: "0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + third_topic: "0x000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + transaction_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + %{ + address_hash: contract_address_hash, + block_number: 1_000_002, + block_hash: "0x2222222222222222222222222222222222222222222222222222222222222222", + data: + "0x0000000000000000000000000000000000000000000000000000000000000001" <> + "000000000000000000000000000000000000000000000000000000000000000a", + first_topic: "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62", + second_topic: "0x000000000000000000000000cccccccccccccccccccccccccccccccccccccccc", + third_topic: "0x000000000000000000000000dddddddddddddddddddddddddddddddddddddddd", + fourth_topic: "0x000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + index: 1, + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{ + tokens: [%{contract_address_hash: ^contract_address_hash, type: "ERC-1155"}], + token_transfers: [ + %{token_contract_address_hash: ^contract_address_hash, token_type: "ERC-1155"}, + %{token_contract_address_hash: ^contract_address_hash, token_type: "ERC-20"} + ] + } = TokenTransfers.parse(logs) + end + test "parses ERC-721 transfer with addresses in data field" do log = %{ address_hash: "0x58Ab73CB79c8275628E0213742a85B163fE0A9Fb", @@ -448,6 +495,44 @@ defmodule Indexer.Transform.TokenTransfersTest do } end + test "parses erc7984 confidential token transfer" do + log = %{ + address_hash: "0x1234567890123456789012345678901234567890", + block_number: 12_345_678, + data: "0x", + first_topic: "0x67500e8d0ed826d2194f514dd0d8124f35648ab6e3fb5e6ed867134cffe661e9", + second_topic: "0x000000000000000000000000a1b2c3d4e5f6789012345678901234567890abcd", + third_topic: "0x000000000000000000000000b2c3d4e5f67890123456789012345678901abcde", + fourth_topic: "0x123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", + index: 10, + transaction_hash: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + block_hash: "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" + } + + assert TokenTransfers.parse([log]) == %{ + token_transfers: [ + %{ + block_hash: "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", + block_number: 12_345_678, + from_address_hash: "0xa1b2c3d4e5f6789012345678901234567890abcd", + log_index: 10, + to_address_hash: "0xb2c3d4e5f67890123456789012345678901abcde", + token_contract_address_hash: "0x1234567890123456789012345678901234567890", + amount: nil, + token_ids: nil, + token_type: "ERC-7984", + transaction_hash: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + } + ], + tokens: [ + %{ + contract_address_hash: "0x1234567890123456789012345678901234567890", + type: "ERC-7984" + } + ] + } + end + test "Filters WETH transfers from not whitelisted tokens" do logs = [ %{ @@ -478,7 +563,19 @@ defmodule Indexer.Transform.TokenTransfersTest do expected = %{token_transfers: [], tokens: []} + env = Application.get_env(:explorer, Explorer.Chain.TokenTransfer) + + Application.put_env( + :explorer, + Explorer.Chain.TokenTransfer, + env + |> Keyword.put(:weth_token_transfers_filtering_enabled, true) + |> Keyword.put(:whitelisted_weth_contracts, []) + ) + assert TokenTransfers.parse(logs) == expected + + Application.put_env(:explorer, Explorer.Chain.TokenTransfer, env) end test "Filters duplicates WETH transfers" do @@ -590,6 +687,316 @@ defmodule Indexer.Transform.TokenTransfersTest do end end + if Application.compile_env(:explorer, :chain_type) == :arc do + describe "parse/1 on Arc chain" do + @arc_native_token "0x3600000000000000000000000000000000000000" + @arc_system "0x1800000000000000000000000000000000000000" + @wei_1e18 "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" + @wei_1e6 "0x00000000000000000000000000000000000000000000000000000000000f4240" + + setup do + original_indexer_arc = Application.get_env(:indexer, :arc) + + on_exit(fn -> + Application.put_env(:indexer, :arc, original_indexer_arc) + end) + + Application.put_env(:indexer, :arc, + arc_native_token_decimals: 6, + arc_native_token_address: @arc_native_token, + arc_native_token_system_address: @arc_system + ) + + :ok + end + + test "ignores ERC-20 Transfer from synthetic native token contract" do + log = %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + + assert %{tokens: [], token_transfers: []} = TokenTransfers.parse([log]) + end + + test "maps native EIP-7708 Transfer to synthetic native token with decimal normalization" do + log = %{ + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse([log]) + assert token == %{contract_address_hash: @arc_native_token, type: "ERC-20"} + assert tt.from_address_hash == "0x1000000000000000000000000000000000000001" + assert tt.to_address_hash == "0x2000000000000000000000000000000000000002" + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps EIP-7708 + ERC-20 Transfer to synthetic native token with decimal normalization" do + logs = [ + %{ + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 1, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse(logs) + assert token == %{contract_address_hash: @arc_native_token, type: "ERC-20"} + assert tt.from_address_hash == "0x1000000000000000000000000000000000000001" + assert tt.to_address_hash == "0x2000000000000000000000000000000000000002" + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps native legacy NativeCoinTransferred to synthetic native token with decimal normalization" do + log = %{ + address_hash: @arc_system, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.arc_native_coin_transferred_event(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse([log]) + assert token.contract_address_hash == @arc_native_token + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps legacy NativeCoinTransferred + ERC-20 Transfer to synthetic native token with decimal normalization" do + logs = [ + %{ + address_hash: @arc_system, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.arc_native_coin_transferred_event(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 1, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x2000000000000000000000000000000000000002"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse(logs) + assert token.contract_address_hash == @arc_native_token + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps EIP-7708 + ERC-20 Mint to synthetic native token with decimal normalization" do + logs = [ + %{ + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x0000000000000000000000000000000000000000"), + third_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 1, + second_topic: topic_padded_address("0x0000000000000000000000000000000000000000"), + third_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse(logs) + assert token == %{contract_address_hash: @arc_native_token, type: "ERC-20"} + assert tt.from_address_hash == "0x0000000000000000000000000000000000000000" + assert tt.to_address_hash == "0x1000000000000000000000000000000000000001" + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps legacy NativeCoinMinted + ERC-20 Transfer to synthetic native token with decimal normalization" do + logs = [ + %{ + address_hash: @arc_system, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.arc_native_coin_minted_event(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: nil, + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 1, + second_topic: topic_padded_address("0x0000000000000000000000000000000000000000"), + third_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse(logs) + assert token == %{contract_address_hash: @arc_native_token, type: "ERC-20"} + assert tt.from_address_hash == "0x0000000000000000000000000000000000000000" + assert tt.to_address_hash == "0x1000000000000000000000000000000000000001" + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps EIP-7708 + ERC-20 Burn to synthetic native token with decimal normalization" do + logs = [ + %{ + address_hash: Explorer.Chain.TokenTransfer.eip7708_system_address(), + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x0000000000000000000000000000000000000000"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 1, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x0000000000000000000000000000000000000000"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse(logs) + assert token == %{contract_address_hash: @arc_native_token, type: "ERC-20"} + assert tt.from_address_hash == "0x1000000000000000000000000000000000000001" + assert tt.to_address_hash == "0x0000000000000000000000000000000000000000" + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + + test "maps legacy NativeCoinBurned + ERC-20 Transfer to synthetic native token with decimal normalization" do + logs = [ + %{ + address_hash: @arc_system, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e18, + first_topic: Explorer.Chain.TokenTransfer.arc_native_coin_burned_event(), + fourth_topic: nil, + index: 0, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: nil, + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + %{ + address_hash: @arc_native_token, + block_number: 1, + block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + data: @wei_1e6, + first_topic: Explorer.Chain.TokenTransfer.constant(), + fourth_topic: nil, + index: 1, + second_topic: topic_padded_address("0x1000000000000000000000000000000000000001"), + third_topic: topic_padded_address("0x0000000000000000000000000000000000000000"), + transaction_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ] + + assert %{tokens: [token], token_transfers: [tt]} = TokenTransfers.parse(logs) + assert token == %{contract_address_hash: @arc_native_token, type: "ERC-20"} + assert tt.from_address_hash == "0x1000000000000000000000000000000000000001" + assert tt.to_address_hash == "0x0000000000000000000000000000000000000000" + assert tt.token_contract_address_hash == @arc_native_token + assert tt.log_index == 0 + assert tt.amount == Decimal.new(1_000_000) + end + end + + defp topic_padded_address("0x" <> addr_hex) when byte_size(addr_hex) == 40 do + "0x000000000000000000000000#{addr_hex}" + end + end + defp truncated_hash("0x000000000000000000000000" <> rest) do "0x" <> rest end diff --git a/apps/indexer/test/indexer/utils/event_notifications_cleaner_test.exs b/apps/indexer/test/indexer/utils/event_notifications_cleaner_test.exs new file mode 100644 index 000000000000..12e9b5cc39ce --- /dev/null +++ b/apps/indexer/test/indexer/utils/event_notifications_cleaner_test.exs @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Utils.EventNotificationsCleanerTest do + use Explorer.DataCase + + alias Explorer.Repo.EventNotifications, as: EventNotificationsRepo + alias Explorer.Utility.EventNotification + alias Indexer.Utils.EventNotificationsCleaner + + import Ecto.Query + + setup do + # Store original config + original_config = Application.get_env(:indexer, EventNotificationsCleaner) + + # Restore original config after each test + on_exit(fn -> + Application.put_env(:indexer, EventNotificationsCleaner, original_config) + end) + + :ok + end + + describe "start_link/1" do + test "starts the GenServer with the given name" do + assert {:ok, pid} = EventNotificationsCleaner.start_link([]) + assert Process.alive?(pid) + Process.exit(pid, :normal) + end + end + + describe "clean_up_event_notifications/0" do + test "deletes notifications older than max_age" do + # Create notifications with different timestamps + old_time = DateTime.utc_now() |> DateTime.add(-2000, :millisecond) + new_time = DateTime.utc_now() |> DateTime.add(-500, :millisecond) + + build(:event_notification, data: "old_data", inserted_at: old_time) |> EventNotificationsRepo.insert() + build(:event_notification, data: "new_data", inserted_at: new_time) |> EventNotificationsRepo.insert() + + # Verify both notifications exist + assert EventNotificationsRepo.aggregate(EventNotification, :count) == 2 + + # Set configuration for max_age of 1000ms + config = Application.get_env(:indexer, EventNotificationsCleaner) + Application.put_env(:indexer, EventNotificationsCleaner, Keyword.put(config, :max_age, 1000)) + + assert {:ok, _pid} = EventNotificationsCleaner.start_link([]) + Process.sleep(500) + + # Verify only the old notification was deleted + assert EventNotificationsRepo.aggregate(EventNotification, :count) == 1 + remaining = EventNotificationsRepo.one(from(n in EventNotification, select: n.data)) + assert remaining == "new_data" + end + + test "deletes multiple old notifications" do + old_time = DateTime.utc_now() |> DateTime.add(-2000, :millisecond) + + # Insert multiple old notifications + build_list(3, :event_notification, inserted_at: old_time) |> Enum.map(&EventNotificationsRepo.insert(&1)) + + # Insert one new notification + build(:event_notification, data: "new_data") |> EventNotificationsRepo.insert() + + # Verify all notifications exist + assert EventNotificationsRepo.aggregate(EventNotification, :count) == 4 + + # Set configuration + config = Application.get_env(:indexer, EventNotificationsCleaner) + Application.put_env(:indexer, EventNotificationsCleaner, Keyword.put(config, :max_age, 1000)) + + assert {:ok, _pid} = EventNotificationsCleaner.start_link([]) + Process.sleep(500) + + # Verify only the new notification remains + assert EventNotificationsRepo.aggregate(EventNotification, :count) == 1 + remaining = EventNotificationsRepo.one(from(n in EventNotification, select: n.data)) + assert remaining == "new_data" + end + + test "does not delete notifications when none are old enough" do + # Insert only new notifications + build_list(3, :event_notification) |> Enum.map(&EventNotificationsRepo.insert(&1)) + + # Verify notifications exist + assert EventNotificationsRepo.aggregate(EventNotification, :count) == 3 + + # Set configuration + config = Application.get_env(:indexer, EventNotificationsCleaner) + Application.put_env(:indexer, EventNotificationsCleaner, Keyword.put(config, :max_age, 1000)) + + assert {:ok, _pid} = EventNotificationsCleaner.start_link([]) + Process.sleep(500) + + # Verify all notifications still exist + assert EventNotificationsRepo.aggregate(EventNotification, :count) == 3 + end + end +end diff --git a/apps/indexer/test/indexer_test.exs b/apps/indexer/test/indexer_test.exs index 78a92fdea849..6109a26caa1e 100644 --- a/apps/indexer/test/indexer_test.exs +++ b/apps/indexer/test/indexer_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule IndexerTest do use Explorer.DataCase, async: true diff --git a/apps/indexer/test/support/fixture/pending_beacon_deposits_response.json b/apps/indexer/test/support/fixture/pending_beacon_deposits_response.json new file mode 100644 index 000000000000..84871c152476 --- /dev/null +++ b/apps/indexer/test/support/fixture/pending_beacon_deposits_response.json @@ -0,0 +1,4816 @@ +{ + "version": "electra", + "execution_optimistic": false, + "finalized": false, + "data": [ + { + "pubkey": "0xad000ed585fcad3cf95888f6c22319c5422cdff058ef8ddc1562cef075b270f5036ccd9fd2f6124421e67f104acb42cb", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb39ed0adb094d7ac3fa561c954eead6cdc1fab89aa3eb010e9d0325425ec8b5d3d8577d6a26a37f2cd0421d467ff62fa0f533493f929e36304c9675f264db9678c2292cf29c488357f96cca0b76cf211d319c2b6a8ce839e591feeceb80b0e3e", + "slot": "12856210" + }, + { + "pubkey": "0xa5235a4c09d3ca42e202f3dc5ecba4004311844f392eadd1669a76c47f5974eacd65267ae71e703733d9be63d5804a90", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x85c3697b258ec79b535564c48a09b4f72dad773ed9dd148b116fb50014a9be8ee59c2296f93119a16c76555dfa8f8b1203aa03f9d4413026e7528ceb2f4b500af4e249e30ddf20ee29484ef9dc3b8744bb497543d7aa62440980b0ad5f8f9991", + "slot": "12856210" + }, + { + "pubkey": "0x963f658cedb572cbcf8f1ff28184a80336538146ab87aa70e5093b1c57cdd83a1fb167fce83c85d0c27e9054b3b775b9", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8897f23c44c7b3664437277676ad9973b94709aaf2542b7f25198f0731e9588c023b0e98b0c095b36b38e66316a5038e0f003d986a87088797ccd870322a9975da9a9a7c163bfa3ec0d841fa0e3f10bab13d5f7efc7aba57f17dc998962ff203", + "slot": "12856210" + }, + { + "pubkey": "0xae6ee3dda3f767786329163d4c41bb5467b6f55d7b5dce238309a3b346984f6014a9ca82a2255896b02ddca53c81e029", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xaa30e77781cd4a5b29f7f3bc537d210b45f740d25e7969bc2ccda311f4fa35afac3bc6c0f7fd0ba381f982222e0ceb2a188f81b23193ea7882a4998a97a61056854df9808ec04be239be8720f70d15c902f45babe484aba5275eedd6a7f7020f", + "slot": "12856210" + }, + { + "pubkey": "0xad84264c144c6dcf43d380ec18b84d390e8311c3ea530b80a7cfbc2d8f7a19730ddf5745c5b505765dbdb34746a9571c", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8e499de53c29f671375d25f94108b22e1ba9fb3b13014d9787c7d1b3e6eba2aa5e8594f8bbdc76f42299825247420f6710a86b84bedc80ff89abbb930042085c5cedb394f0be3a21ec80f25a059d7661b22f55338f170f759f70692ad28112f4", + "slot": "12856210" + }, + { + "pubkey": "0xa1d880a979b98fe28bb16312f433c36c065552d1dd44b37e49b64cc8af85d7eb523227f05b9c0dcb2e71eec28265bbf1", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xaaeaf10bb0ac85c658ec247b4a82af9d679033a63dab2c58e467329f86251f275563f5430b0770e393373bff5cb2e5fd030c6f57a8a3c4dfcbfb147aa9f20e2543aca3cf0a2a97a9f4e659fd67b389e764c4cf47c0927f88181125dd7f0720c3", + "slot": "12856210" + }, + { + "pubkey": "0xa4c056ba3bd791bdcd0e029d99701cf0d30c0da495ba6a258378efdfbe5e719501aecd8f55b348c02278b20b66e78760", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x80c63e12246ee6d61dd716d429ef95c86f83c3ed5158dad89c5c5843fdad24f04b536cf47496580d8c0dc589f93a5299024b37e346119a1d3e22fc6f314d0c4f227a22cb82d0b914bac2496ab6129451412322e6fab8baf4c74482f40d248b23", + "slot": "12856210" + }, + { + "pubkey": "0xa98befdb066a07bb867684bbd5f0bb3dcb20554c115667ce376155dcf4f1bb05b95c5c62e144ff4bd4e13753e46b60d7", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x86bdd0121fa0ae43f46da332491d0db528490f1240a09175cede0436dd261c1b6fdc5a67b35e5608c294f066101ef86f0e917e0f59601e72f07a5d3396581b13d5e5061e9d48cae9f946bc31b8428cb896bd72b11eb0a3663edb264cf6694ac2", + "slot": "12856210" + }, + { + "pubkey": "0x87e69a330548b427ae5e03676e5b580a2cb1032d179d8dc895752f992c23fb544c0ac46dabe3c6bf77568edf9a1bbd5b", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa3603bff791b8a11b1eef2ad8143490e2386139a1d7d6cf518cfc0f30d9dd67649fc365a0f0bf83f1821ac0cc7802e1a084467b38d396483e6d3cd94b3fe49cd5da39e0c14e9bbce9e0c2f4f1fd6e50bd8ac7a054e797d99a72f5b1295235c17", + "slot": "12856210" + }, + { + "pubkey": "0xafd0a2e561e287e391f83eba5e89a76bdb0f59559fe0c938195f34810f495b910885222cd467780032b0dc07ff86be11", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x80e31ad4f1d755a9261a68588d2e85d654d3768e5739112feae7ea1b8fd73310f09e84f5e1c588cda7aca7acdab91c440081d68fcf1667d4b95d838d3ea21f20fbd7cbf562285decf76269421f8f9f13aadf0c85646c879daf8f39cac0dc2841", + "slot": "12856210" + }, + { + "pubkey": "0xa7b4cf31911b0a8ff97906b80949346ce75b667ae8ab6d08ed21ef3cf7532c5a346086152c9b4c266eac983a7580d807", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x84d8d988d0090353c52a48a0cb520945ac5246947adb87693506e42ed05a298bac674286d2f6affa4114bc03536b10fe10e1a16bdbc52b5d3344bb7fb428430c5b2f71e9ca80e0d0e8a7868491b308ec32b6119cb463ca035dcfb37945385fc0", + "slot": "12856210" + }, + { + "pubkey": "0xa16da7696868eef5ed4238f88d38744fbc6002184d3191247f59dec9fe5657f28250a90bab46553aca0dcef4eb49d37b", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8fcbdb0e48c56183b16dbb58be90bbab2bf0cf37a0c46344c4793cf1a5765f8c6440a3255355a3c763678565c51e61b806ac084c20f68a09cc3f8812f18ef1be5f340f9906f92771fe0e8cd56e09af175b388cf19186e34c6c0ffbc2435064ec", + "slot": "12856210" + }, + { + "pubkey": "0x95478c14d3e838f4c5823288ca7b34776b93de2b7754eeb41a2f418834bd1213b56d4fbf3f69edb088c7af03fd3af788", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xae92240a2c3d9ea8e246ce3c40430d14afdeb9d496fbcf8be5d5786537db850bfe36504cf1d51c4e5e3f8d59f330c2460a1abdc76d3c52982322f38fbe472f7ef284cb0c04d1c9261dce432844f03b53d6f487d4d8b5b876436f4307842ec7a1", + "slot": "12856210" + }, + { + "pubkey": "0xa364bc99934e2f07f1be3ef13c9f2109816fcd1c05b76287e4888237312f5c97794d6b75045bc5532e02b64287925ed0", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x99e0f7b0b5ad661ae464c8bd1dd3d5f850518df3ae573e67b980053c27d6d2eb42ce2bd22d612fd6f9d9451b3d34ffba0e3e02021a080d2579160ca7d857f1e6d0935e3ca7760c55db092970e27bbbd1eb1ed672c33ca6ce3f5ede0566e3635d", + "slot": "12856210" + }, + { + "pubkey": "0x8a41740c4402c34511d3ed2cbefb6723985e77eec988f919d356acffefc7a664227e712f09f0d82941fee114aed370f3", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa0c83955fac2ffcd1f9ce20fcdd1361484ded55dba23b59d63427ea4396fc5acc0a87dbd72743b1ac3de40a861e3841c0431f2e295c33dd6dfc5f37838fef591d7877efdf7a0ff95882bc9a06ec046e1d510746cbe32fff9368d952c40e03fe3", + "slot": "12856210" + }, + { + "pubkey": "0xb51839b108e2d87ba7dd537d79264323249804c39fcbc289a6a308a74d1b8af5282eb8cb0926fcc35fd8ba2d96ecfc58", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8d7728cb878d6fd69bbeb0a25809cb70e6b5c4a24ce274cabfd8e57bc50a5ea1dc91a8928c1b84b9e5d3d948b1e1f50a0ab50fdc5e31cf2d04f5b02d1413dccfb54c59a8f43d63efed2dd07137e3670900be2f566458ab34fb480c0ff8d97b34", + "slot": "12856210" + }, + { + "pubkey": "0xa9e8e72e8e1ac8c041bfac604944622359d250f6ff095238f10fc1571950346938acb0a238e30dc47101e620ab7f5b3a", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb8ea7ccd0c923ea5bc1cd29a9dd32a32da5a307b256b8c6e6282adc149eeb8c249d803b82aa3774ea1fe19d8c18595f10f7727d29e8651520f0f6e9f854ee22f7ea75c4b257f8e157b8ce0aab4f702dd756a1c585d4b7c6c933339ca7d631989", + "slot": "12856210" + }, + { + "pubkey": "0xb83ccfe1a9a3727beb1f7fcd1fd6cc26b07abac3250ce91675419d232f59b7bde772136de6c447e0bb6ecd7d1745fda3", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x9217fd5d47401d8f6cc6177bba238c575c97264de03d589aec79ee2664a2cf7cc5dfef222c4d000baa519bf8e598115911fd8df9103fba9045a73ae6345c71d5fa5373882edfd3c41281e79f64f36f791af353eb72727055273116d06c047c7c", + "slot": "12856210" + }, + { + "pubkey": "0x96747ade9e515d2eadc53cdac41aa09c6913ea4e73a40b3746239672fa48811eb015e1a194e322d0389152c7ce3efc17", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8a2a3fd52de49f373dc136ea5959384117992a4f9fdbcfbd8f9dddea20195ad55b9ee1649aeaa75425e293704f54309512d862bc2f991e1dbef68edc3444e8ae24e5a9556790c540a35b20e6431812e38f31a2c25be50befee9a6cb949d21838", + "slot": "12856210" + }, + { + "pubkey": "0x97a6f2bba1687013d2f559dd451903d194601233dea377852266f6d8df1e675e8f68f83b48d60107d38cea1d31a2dbf0", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8c52ca4f82b05b5a68cd7d2b27153d8764db11a51d35f09121bb1eb2b1cae428d504b82520ba49196fe159a19722a949199787ee356466fc4d5af58338fdd031681c4c3f4e375f7fc2855dfcf08d4fcd61e6cf8281e0839261f6cf7b2a0ae7fc", + "slot": "12856210" + }, + { + "pubkey": "0xb3cef4ce62f34a40f48e678e995908eacc69da79ee7f88b506ac4a2b099f9756cde9f8532e1f0bdbe1b97b7fac052c03", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x86cf9d6a07bf1b3b699d7cb8bb8b8782ec1aa43d4a3a05d3949c0f34af4cb12aba7b73495d01951152ee529de2bec33a011457a4097524f9ccfbec3fabf1448caaf2c522d6ea1cdd339bdb370c815fb14aa72cb1265b82526df821b16993a172", + "slot": "12856210" + }, + { + "pubkey": "0xb8e8eb3914202a62197eaea71cc35aa6772225cf72b93ced7070a769a340e37a19a0f992a11b593b4d16b311cd2025b1", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x98fd97694df8f2a03ddabb9818e43942167638cefb3b61ea7d23f46363cc8dd4e6a9708dd8c0b0b0e4d118f9ae47cb190112bbdefc17584301bb902d6500dd5be3b578e2df7400ba33a573ae0206f6f8653c8e4f3e4cb3b9e01be75cebbc2589", + "slot": "12856210" + }, + { + "pubkey": "0xb738eeeabdb13c1c4fd30ca0ac288de9759eadbc50c9f54dff2a805b277cbd362079b5c535eb52b67575eb5b5b70e9c5", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa71e68f4f2dcd0edd261ae45b284d316f05047138e8a4a3485d4d61edb9230035ae8d8428da8a07d334592589b3987420b95757ac994625b0dc55c349a3a51522b707a7039c66d257202d3737461e2f99228ffce56689f4aeccefaadb82d158f", + "slot": "12856210" + }, + { + "pubkey": "0x80372df8d8dd4d0c2466d400854c421f26fe042966ef1d04540f97fc4b2c9d746f79a19cdd1b0eb2fa07944a8463ae44", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8fa7bcc95728e9ae45a3ac40a1329f2796281cc37b737e5af4100f1e65662876f304a35e6de3c5266e2f41123f424dbe021304837db5452d3e750167bae8fc11d9a730330f3fd3b9fc947ada477b8da0dbd8e22a182b7e0cc4ba5e95bb655750", + "slot": "12856210" + }, + { + "pubkey": "0x936e623d0dbf060d79f88e3632a82e280ccfef1329ebb9274e234293c6bff45b60fb6a3151c22d14c720a3dda9185e14", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x994f6c9f486544c5c12dc7389e98a24031c1b84c488e2777d9a4dc13bf79ec7cd5ae4c71eda964ae351b1e21b7c474b402da4b6e2dc6a00dbbaa4c9421e6b41466954746dec171e4d07509796c194f2ed7f8840b98ae243a71c1be01317b5d17", + "slot": "12856210" + }, + { + "pubkey": "0xab1a007b4bbed2babda52939de00e1e080bb75fab73ff49c3963b4391935d7914557c4362f39e688c4621f2ccbdeb020", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8f1aa1b654be3efd211faf72d8193d816c0222a1516d2ddd57cca011e51c9e15d65022e98ed1ede466e5579f4f2c834a133e1e2fbbbee1e6c81d99b886870e21d0beac069b7e040a8547098e599f5e2de7069f6c16aa681d154dace29abb971e", + "slot": "12856210" + }, + { + "pubkey": "0xacda7e7ed5bfb9f8dd5498bb0e1523bef26cad8a4b882a0bd64373de3b7b8d4a401db1a6b84e1a3b35e76f1a7d62e167", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb1959f5676d4e49dae92647eca7f274d4e5f6fcb1f83c125848d240aad86e27560f1ee5eb7eba331b6d7f109c9a4906b04644804b5dac5cc4b1a80cdaf5b56cc1dea5ef4bbaaf448cda39e00284b2d3f9d04bd23b7c718b9fec49c807a8d15f8", + "slot": "12856210" + }, + { + "pubkey": "0x8a122aa33472ce43b567b65db1df34f9096b78afae5c78674aa340ab7523900170b30d5fd46eb18a90e48c53e224dded", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb7835ef10b974cc043c5fea2bdf16d9dae7e4c260f1fc0f9ef6005691d6c668771c7b7553cbb1d670a5d53f63d473a2a16b0c9bdfb4ba46e863581b52d622b73934b8360e8562c70b743764d9949e7aa521eee6c86f3b87a631227e37860ef30", + "slot": "12856210" + }, + { + "pubkey": "0x93cf06514a01fd7958463594a660e9e8661a97bc7f5f32823351e128b7ff8cf4eccc2ab05a3b9c7d758e369e68a0c999", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8e3ea36be8eb58920207400f79ec3e3551a1a93da7bc3df423362f310063811fee98570e23f2157b750cae3dde67298f0aee19a34549636cd50093e296b6b416445124e55c51a65a7d42cd0e0c6611311c5c37c59f10d6083354609cecbb75bc", + "slot": "12856210" + }, + { + "pubkey": "0x9486505a2df9004c0b620e36f1cc77dac0cd2da175b65f1c581236ea80a1ea9623e8d17810031ddbedb36f662c10cafe", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x97c80a89025a94751a41ea84c7baf162e75d843f4ccd6214a421198b9a2859899d573cae76a5f291dadbeb7e1dbb34280884ee9b287599e7743c509b02d700fa6b85f042f96a5809d85506a54a6fcecf4f5eaef99d6166f1662b933ac323f1fe", + "slot": "12856210" + }, + { + "pubkey": "0x8036c19343b2dd718ae349c85d4a4099250c039e0b85817a02f130c34eb7e7f286d87d6f7a7cf3c032aecb2414f50471", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x99e7bec0d70fbdd3b7a110c9b59bf826dbe6524b18d775e37fc7103fba5769b2b699a4129930e41d48530d0a3c555dd209569c748f12a8b14b56a2e46b277613058e1e6df9450c8a6c37807b16a91ae7d94057a29758e1261f5c9e06b8fcdea1", + "slot": "12856210" + }, + { + "pubkey": "0x8645bb9fa48297c639f38e0bb962b56de597ec74b0f6d795bce001315bd45c26977ad7ba39a6ebac372755a0bffb7ba0", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x99b50c54ad04f1f5eec9de52b6f95451ab4a68e4400c9fe3563c6fe5de8e0f06741be0daa7c7819e690a32b98871391a0f2865e40604f8e356686a58d5546c42a1e89b6d2d36c3bb50f8fe73d342de3c48baf7c7400751701b2eaf503322d5f6", + "slot": "12856210" + }, + { + "pubkey": "0xa5847437aa4627437f71288afa75c1520126d3f7c726898355bc4596b216ca4f4a68e71dba5dd7b672c0467e91eaad33", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8e59b5d86b65dc0c80a83f33ca9d6e15b771538d691c98a2dcb02e4c1adc183449d9b5b9e8fe7cc83007c458cf1a13230b6d133c6d31d204f66a971785856fdc611e2e69ab7e499557fab13b3f7fc5dae83f52860ccdafeb4825df6773c9a23c", + "slot": "12856210" + }, + { + "pubkey": "0x85e485a742dd50ad3a04f1eee5f688cd5a80cc8b5415add20e32404e3e325e7d5ba9dd887b475a407572a493aeb7cd19", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb3e0cdc104438650ef9a38b4b329dd90d0eda69ccb9ba2504aef7a98b112f5ef98458aa526e163fafc717efa5ab2fca4154760432db371d305952b688821f9891a95d89bf1b98170c836e0bccb81ad547ad449b597cccb7229c73164fb01a25c", + "slot": "12856210" + }, + { + "pubkey": "0xa7d5acedb0f088041dadb1a811a9d77a4c0c14b5944887ee22b41107df8c3f3b0aa56a1035d0587b1506e42558b459ca", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb69836a695ac2001d3cd24aae2550b785ac869600c8bccfb860aff7b7884819ce177053bdc055a4dbb41fb8ffa344178170d7d54f477e039432e2783a7910f9b1b11053548c2499c9001949ac201d47bfcf16d8150f297b2f01d7a27f6260519", + "slot": "12856210" + }, + { + "pubkey": "0x930242a18d1d9ad02a3c1b099287152e18372fa9c9c101d19f35390c169c3788c9e2c60ded8ecccaeceab807bf56e822", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xabd115c7f9cde18ca4477ac82e9ec409e37015ceb042875a5e4d10e27d4f8b993a19a7d9aac319166dade05349c1d22f14f74099d9235ed652f01d1bbabfede7d1837249881b830969bbca7b1ec7f605aa5af6b069ea5302241a37418999844c", + "slot": "12856210" + }, + { + "pubkey": "0xb8efee1271569e89962e653e003746c2e3f7dfa236f35cd2c840f0f260d7b986cd01345c90b280551919b56a789b4016", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb3dc3f435830d3f14ddd8ec082d07737c05c7b73a5280cc8c14ba8feb4947c35ad55510496071f880b7d4549d7438acd0110d618eeb45cb094aac582ea16840c0aed08b5f227a6092ec30542976ad6f165d3f3c5b825f2a95f98fc75b90d9d8d", + "slot": "12856210" + }, + { + "pubkey": "0x87bf6cb13e9c43f5520ba8317dbe60b5b2f7b38ee026b4cfe9c411b1e8e93c3d78697291559293c445dea403e3b75fb5", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa3a0cfc64e56299b8efa1b3feedf06b458cdb1b84048dcf7b35761cf280fcbb0ea6fefded9d2535227106860aebf6b971716fb544945a398a4ef4d2acdfa6b3f136e968be6d16a8549ec0ed744cd7d052c9036b9113f0d759314307863556cf0", + "slot": "12856215" + }, + { + "pubkey": "0xb9a1c45f2ce5a8d994373b106f4c4a208c3a14ddfe1fe1f421c15c1bb3bc02c82c685093cba848d80ce302a4496d2103", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x93224d27e9a4f02c95826f73b40c8273595b786b52bef91e9ae5a2186a2fe7f2a739eb8bd5d07355d2c5a5ba3c5abf98000b358d5eb58f8a0b14195be576d0dc1e08344f967d8f3bcba75b0eb81ab7970a18103b6ffb184a130780ff84b18006", + "slot": "12856215" + }, + { + "pubkey": "0xb07f002690d88901475eac9da96321a2d8be0800d8e96ee299c1e109409fa224dd43cf0f50565ea1c498c0767953974c", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xaa4c6bcab4d480e4ff4efaaa7c464f8ef70e0f271c9ca31cf54000a46b30d36a6f6d35c8e2d00d2d30ab99f47c3a46100e39670e1e2e88729a3ddea944021381fc5dde56bc8d8176d237d8c9d7f725170d61b1f6110ebe425a92d56506790c5f", + "slot": "12856215" + }, + { + "pubkey": "0xb7615a6ec593353a58b8835aa1c4162fbca57301d109d428f51a07b5d57f55072923bc87f6bf59df92a5115f76275e28", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8c5ff41a7c6f5d2287851782459b99131d93cea80e13cf7c8605d581180a1d331e70ec74431dd7f7496c32e9e256098c14565958541d23fcc34a8e8b2885997edb4745a745cd6e4ded5985bc06852e27d2a8a206d743114caff86a230744771b", + "slot": "12856215" + }, + { + "pubkey": "0x8257833ebee325e77edbc9c4f395958ea85bee155f173b3190511e4d00cd9216e7b45005025eed2443da6c3890674710", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb585b93b001434b531d3d549964b4a08db9957a77d81549ef6885edf8fe6c73568cc8d4dbcbb96cc5d514eacb6e588e105baeea342af0084c1c8753d6266ac4356eaaa3318f21e0f84d1b64c8f8b65e70f04c699339dd1c7a4135f51e37d25de", + "slot": "12856215" + }, + { + "pubkey": "0x82261f6b4cb421539499ccc5d5549ccf33cdcd9a965cecc301b55f88a865c7c2c581e3c8263b12a1711de9998f497c92", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb74d8f34f30508e66032a403e2d15c874c2e9279a2a6366c9ba1a152f646bdd12cf54c9c9f193cd4c459f7945b7e62ae0cc4005fdbff00501681d26f54e450a2f92c099897bdf212313fca916401559908a845bea8ad1e8a9281e906956449a9", + "slot": "12856215" + }, + { + "pubkey": "0x809add83998be0abe104b0c28d1dd987864321c81004c1c60b91f1961e29fb3f3a3ecd11d7cf594f037cda5ba709196d", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8d1f16145a8d12951866273a8174e09ef14daa80972799b1fb84e3242fd64dd29f1fa885de063a74ace02d50665c559c063f07d15461be1762a331df5624534ffd02a6f2575a187ca9ed0a6a8d6e1a168886f3835823f76a8c3daf2f258fc476", + "slot": "12856215" + }, + { + "pubkey": "0xb0ce556bef2e74033273dc03cd1ea2547bc2c7faf3560601581f990a8743af38784bd7d3257ae0d0cdd2a42d4e8b215f", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x91129d101601f3a1dff99ac35f9888bb37813d1a43108262f55d25566e2352ce65341b0cad71536ee95eac5203ff2b4f0dbd0d76681ddf1da913112dc461a08950d284ef82f20d3ba15f3a26d22cee12054c3f1c73036dc605f04c989e385387", + "slot": "12856215" + }, + { + "pubkey": "0xb36a37f07baec803c9b5719d92b98f989d23eb15772fa7b2347dd5208fe472c2b053b71c6a7cc2f9c89969edd1784425", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x85cc75e70094608ea557fe265ca0e6658e8d392238fef1fa43062e63caf595769d12bef8b6c6cd0ca8200ea2d4f7fbe70d2b810a66fb322af7a5e1caf6f3da4166adc8d50a6f628aedd45b7fc5e5314f65d745d48727c2a54116fb7b8a314b4a", + "slot": "12856215" + }, + { + "pubkey": "0x8c4169e4d3979555f30f803d57f62770c95745fe41e581ced32e205a0707a4eb987d827f24370461e2eee10be1edd364", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa3f73bc17b192ef8904c8b9b81de9ebcfb186c3929dfb389bdcf8b9c2c1602e530c97d3bb521714ece2994de922b4e95122767d216c5e5400cf44c2e91ce0a65a5691fc3a13603509756f081acc482a7d379311cdc644f78b6a1e38c32fd3487", + "slot": "12856215" + }, + { + "pubkey": "0xb832f785db4646e637478c2b5e62fb4148d8aa91ac9084ec81184b9a255e2bca7ada8a27f6bf6ee48088a97cef620520", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa44a1c4521e85b689a950ba2c4bd82110703bc788bd087ccf4b3f53d0b04a64d437a5fce56a2a1d6ba42f30dc6548e0f0303f5fca2eb2223d3147203d8aff05f2fbcb377a283f60a097bfcc94c76a1f780e7ba098730119fe14d4ea2ce84b857", + "slot": "12856215" + }, + { + "pubkey": "0x975bd491cd25bd383c89c518d89485c9f8fdcf9c909770c183ba3dccce3f7e887a67b2efbd648fc11854625938eac7e4", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xa05dd53b8b0eb5452dcd30a25b8be703293e1146f7fb54f60747f814cd34f91fd8479cbeced29dfdaebb56fcfd52f13b0893f308bd4b0aef84c10c13b1eb7eda3c242521d956b577613d39636c9fc54ead50e1c8993eedce7e517acbe1460051", + "slot": "12856215" + }, + { + "pubkey": "0x95c27383e5e6fcffa31bca8e6046ae5d78a8c45ce792d2031728d07ca7d9a59c1d25e9d610b1a0d22f8ad0bbaca25360", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8613b76d17184e15dccec5e85ff731d96bceb05445a5605140fa3fd1878c47f4fd15a1b990d34abf777329ee269a3e2318a5752e933cb3493f0631793dbdeaedefc8727b08d0c07be348b0fd9599f6508f128789497815338ee039f93650a796", + "slot": "12856215" + }, + { + "pubkey": "0xadce47d94ec02e9b794acd00f8e0b7afe53174fd06918837e6a2f91ef306bb92222f30e3b583407e8c556279c094ba40", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x8fc701bbc42e4f306a5fa0a4f03e9d7ccd2c403d953ba6d23224ff7f1e12e67607641fdf90f99a1e0b4a52779478830f18122546f2dc56f30805fdcfdc83bc181eb4908cab212519d2ed5f5d5884ece10dd8c74a9b7f1e151e8a0d3d6f2d1518", + "slot": "12856215" + }, + { + "pubkey": "0xa51e1683d2163b2cce0e51d1f41c3e64f1a948aa0dbc0133dbd602a538bcd8354c2a3f39af5f75931966b393976236c1", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0x90d4ef01b07eeef1cae472b07222e1d9da53cf91e3534caba90c51b9240c77d7866913825f124230c5364d3ebbd03d330b8579030a27e6a4760c19d1de6afbae053a6ac2aebca9e50d4c129679987ff6d07b53a75f6ecbdb34aae8e39a9f2461", + "slot": "12856215" + }, + { + "pubkey": "0x849e5c60a1688cd42758ea9119c108e4c9e3063d43e8107206b347ee13bdf87c4f13c1604044a9ffed2110a68f6f28b8", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xab7d3209425afcfc7baafea06916f293d06a41884cee14e26595fd1bb84f814f617b2fa0b34cdfb990b64412f89f5cfc19ea51cdf267e88b500c45636603d5efeb29a697094ddcec84165dec4fa8457a3c4706c061e629a68cb2cb5c91b7c8b9", + "slot": "12856215" + }, + { + "pubkey": "0xb1e461e448c80cd4fd8647cb6e2e54c1350841dfa41a758b71a9da48b3c115a660707ac1fadb5ee262fee6ca95295a33", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xaccc3135d025d7bf7cb101f6323281b44dbd0271845b18642bb1941f544c42d62c78d7a4bfd34f97b36b3c6f8308aa4406538f3c890eb56e72232a47c5f667f0e753e5ea36417de510f4392ef60cb9aaf368b75b3b231dd633938b3e479c7eb4", + "slot": "12856215" + }, + { + "pubkey": "0xa54e6cd6b246be9563cc2bb934825ca6e63974c53e9eb2104d79c3829c52126272da15edbb4dff8c7f2f6cbe16cc4f67", + "withdrawal_credentials": "0x01000000000000000000000093dbf616676d22ebd0db51c5ebb6f69ea1f9bc8b", + "amount": "32000000000", + "signature": "0xb859e3a6785da7cec6f7a82e1efac7d754ec831ec1d3c5845bd7c9be5432544a2f02eec9eaa8220c5c85704ef45e33a50d3fee094007189e4d74f1bbba5584dbeb97c09bcba624e63a1a43818f16cdd3f6fb4647197cfdf57f76490c3cf36ff5", + "slot": "12856215" + }, + { + "pubkey": "0xb4850d68f469a941ae66ec2561bbe03d6cd659c7c2f1f2fb09bc6ae6976bf88a9bccf24ff297b07080c362c09a609420", + "withdrawal_credentials": "0x0100000000000000000000001e2ff9ae8b6a09c0f5c225a77df6c89dc1329ea9", + "amount": "32000000000", + "signature": "0x8420c2bce8de737f0bd5dfa2578fef822fdebffe0d1f5976aec07d26a4063d89858e79299c57b543317e2e509d8e58a716d084f33c935cf5b16ed0f83e64dd20855fd0f481d650282180ad460986939596dd949b34cb8ee0bfc6e4a8f30fc493", + "slot": "12856290" + }, + { + "pubkey": "0xafc52dadc0b1d7b1cfcf0da1a23a29ce963b61ef92d4c743e2c2118e56dbfc440a79eb743cf9daad9f7a4f6b102c75f0", + "withdrawal_credentials": "0x010000000000000000000000ce0ac4810b3f33598e51526d4c0e4cf5d05a3c7e", + "amount": "32000000000", + "signature": "0x97b76fe5871fe6789e9f1296af5e96812d1fd4414630be3fb3b357a86b5581ac983578ab8b54c0d5aacca14e46044eb21441ada661fead336f64ce060b73c756a6ee863f35f29d2b479ce4b6b42beb9cd102679e99fa363a337446cff20e386f", + "slot": "12856401" + }, + { + "pubkey": "0x82ca83229708e52fc3629246cf8e568f2ddb48be796d9207a3c229b35c5d7480bae2eee4ede61ee0198a8072d6915df9", + "withdrawal_credentials": "0x0100000000000000000000007a9875844666f18110880b67175d301b93ab8c14", + "amount": "32000000000", + "signature": "0x80835e6bcaf5f4a62c6411e02cbe99a3bd23a1ed0bcaaef49ad2f1b7744a77163104200880c4cf2cbc7aa4ffaca607c3169b57b78c0ff6a4df90d6b66f0099907d460cb7b54a6181da3f14389787d857023e4c2e7e405c65ac54ce79aa469661", + "slot": "12856429" + }, + { + "pubkey": "0x915e0746140031d412ca24ac081c067e940b4409986233c44b5550474eb594268e5c7b7d8f186aa66d75de49b67b3ac9", + "withdrawal_credentials": "0x0100000000000000000000007a9875844666f18110880b67175d301b93ab8c14", + "amount": "32000000000", + "signature": "0x94533611375705834518e64cd602ad116a64f1b5fe0c87eb83ede1323b436acf522a99e572ca40e01b693deb3f85e4f808f1da9eda4bc3f731db12714326538627c169a675ccef134818e60f2c935a6625d01ba05d5132d1fed8c873bd1d3454", + "slot": "12856429" + }, + { + "pubkey": "0xb539939eef426ff30675421e20be69bbb6dde2720e540f57208d2ae2e248bd7e584cc49b623f186f0ebdff9856be75bc", + "withdrawal_credentials": "0x0100000000000000000000007a9875844666f18110880b67175d301b93ab8c14", + "amount": "32000000000", + "signature": "0xa009e95ee7afd88f26a954a7f32a7aede9bd26035b0a83f1203a737e762b7a5d57b7d14f8fa4b8b5b13bb5cc590e0df215490f8e4e47d8ce9419866311288578396377f0803f9b75af574b2a6720f346f16e99d59bf80c08c3381822bfa11e5c", + "slot": "12856429" + }, + { + "pubkey": "0xb547fcd772a7010143d7e1febe6924de32972bd23bfe3ab0bc0f0230d865887c283641477c686925904de219f7076814", + "withdrawal_credentials": "0x0100000000000000000000007e2a2fa2a064f693f0a55c5639476d913ff12d05", + "amount": "32000000000", + "signature": "0xa74b1dc311264f4f5448a1a24f069632cdc2d9a8972564d3b1361a5fba2a2fd857176542493b3d138c2a57c1695aaa5919a070c208ac02da6e71db92a5200fd1090f75a2761df85a605f95f315a916301cbaa598272a96a8c92fb8f4e9141d5d", + "slot": "12856578" + }, + { + "pubkey": "0xa07ec9f54e3e9a88bf867c9e99dab32445018ec59aa5c3d381ee78d0dad122b0ee459316e02fc4d853be7e90d171966d", + "withdrawal_credentials": "0x010000000000000000000000f7c846a539c47fd36ee892fbf965c7951a80db88", + "amount": "32000000000", + "signature": "0xad65702fdd1cc9114dfc6ecabb307b5a1c24e1ed91203a4b7b099e529e5cea2771fcb7128d8f82639494b33daa0e45440b5bc85f115feef01b7106da4fe09a21cad7aa309dad0c4b728f09a6809441f5a7f9032126121fa5fdcbcd6148624394", + "slot": "12856742" + }, + { + "pubkey": "0x90147d9b0b8bf6f9258d45ba26e0a786abd2d1d402d8d27a0c60a1574dfef2e9f87a63027ecfe49f3b595014f98fb8d9", + "withdrawal_credentials": "0x010000000000000000000000f7c846a539c47fd36ee892fbf965c7951a80db88", + "amount": "32000000000", + "signature": "0xac5a59b91df6b9b07c4c9156e8dfb086070852f85cab5db5742648ce92e023cd03d2bc06349a0c775e44c3ffec180080090c43ae2d4435b1ff48710ba2c20b12c3e47cd4dd8e99424ed9b1e4dcb2c5ba4c3675e908a6d8d94178ad37cc5fdb38", + "slot": "12856742" + }, + { + "pubkey": "0x8cfa4211afa2cad3796c58c6964ad53562c70266070dd01663a52d5bcb3e843e677816411c1602e4b577e5237c001e2a", + "withdrawal_credentials": "0x010000000000000000000000f7c846a539c47fd36ee892fbf965c7951a80db88", + "amount": "32000000000", + "signature": "0xa5f5a14351fdcd689164f7935c209b67ff8dee377ecec816455dfe066b2039d224434305ceebde85d2126ef3b06ea2680592bb3a729b14e729281773bf089eb988c0978ae3536bb8f47742d18306f035be94ca6eec28bf9dd6bb131485bc0762", + "slot": "12856742" + }, + { + "pubkey": "0xa270a6b708515461e0ca0382823c9abc74e21ca68ab07d582e89787b5b47e1ef7e9f4cc27730646b4f035c7f07a336b1", + "withdrawal_credentials": "0x020000000000000000000000d5f8c864f21824daf3a4b22d9c33693642ed7634", + "amount": "790000000000", + "signature": "0x8e849744cab60ade3af57080f41c844e45a095328f90acb9a7cda25f352c28dede1adacb5726e116c87edee394f8b998145b4e934714e9d654e1ff624862d37d7d45d957ba3aa39aa5e5c4a0f889b96700f5d7d141ab85e8bc492ef6c4a253e7", + "slot": "12856760" + }, + { + "pubkey": "0xb2757152e50adc9ed7e462b2ff8b6035b313d8bea5f2397071f2eb19a0203e4babfcdfac4633f60b787579201568ce52", + "withdrawal_credentials": "0x0100000000000000000000003859a77228139a72d0a088f5d39144d558b01fd4", + "amount": "32000000000", + "signature": "0xb769df3e42227ae1610dfb78728051cfb90aaf6e13db33c4970f3b501459da64854cbc1eea2e63ac44cf5d5d9eeb8d0a07bccff98c4c05ae712e153fb5fa2e4a3622072e3eb99183541103ae6b03bacc39b49e41175017952bbe40fd0c2138fa", + "slot": "12857130" + }, + { + "pubkey": "0x82fd4c363fcdfd25fe6ab0e940877869787431898ee33155f8efac26a0ed987c2d9f82e8746a93b99532b9f44b1918c8", + "withdrawal_credentials": "0x0100000000000000000000003859a77228139a72d0a088f5d39144d558b01fd4", + "amount": "32000000000", + "signature": "0x8550790c526b4c35ed83692e0b22d1187ba07f761955181a96adb8f556239126d8a0b1a3a400a1e6f35899288b999e9a00454becbbe24e4b88c10439b60c28dc6d80fddc105fc6d27f0bd854e287c4ff88b8b18367b1f8c339975f7e062a2767", + "slot": "12857130" + }, + { + "pubkey": "0x9696cff857379b6def54041a0413a04dd1454560250ccf489c0b52d47474bd5f3b420240b7af010ef39b57c85c5f1eb9", + "withdrawal_credentials": "0x0100000000000000000000007e2a2fa2a064f693f0a55c5639476d913ff12d05", + "amount": "32000000000", + "signature": "0xb994e2029bab315350af2e4f2a257bb585f9c5b763a2f03e911d900ea6c92d670e7a0cb31654f21e649c907f1ce4719b0fa8bfbe3496782bae9bc6138cde6d09569e90a3af65da688642a26b2800179c48cf95ad38e93ebde30f0568ad59d581", + "slot": "12857178" + }, + { + "pubkey": "0xb4a21842ab89fd5c0a6000ec67c7388a438d3ab05c86963c383ad77c53771cf0ca9b103f37e94737594970564207ca72", + "withdrawal_credentials": "0x0100000000000000000000007e2a2fa2a064f693f0a55c5639476d913ff12d05", + "amount": "32000000000", + "signature": "0xa7039f6fe93242f425c5e386a858cd6f853e0cb41d1e314a5b614545f8a19a8cb495cb7856a4ff55af5f66062dd56c6800c623b3d0d8763bb335c620f8a7f715b020e3fca8f6f7f3892f3765405abff262ff336cd3dacc5c4ff21afaf55b04dd", + "slot": "12857180" + }, + { + "pubkey": "0x8404bb5e17cddcb283188a6ab8840a24fea1196464a8f27db1e48528de3d1a238fb39f723fbbb12135f44206c58775f3", + "withdrawal_credentials": "0x010000000000000000000000ae3fadd231c8fb2b2c756987ecb582963405d018", + "amount": "32000000000", + "signature": "0xb010c8177e75809ae5f965720673ad58d500500472d5cc6f6e1188c975ec35d48cb0d2da5bf2c6f621576e42075d978e0e3dfcc8eb4d00dc3d7d5bbe8d013659d1a157f70ca9af5ccc5d21a435d080ec6c86fa3f6a633aa7bb4c684c7d3af4fe", + "slot": "12857180" + }, + { + "pubkey": "0x8ce9f67daae228dd52084c91b9c23e29be0c1591315591e269fba4a54560c1d0ba1188f8d9ae47216e38694e9270c6a3", + "withdrawal_credentials": "0x010000000000000000000000ae3fadd231c8fb2b2c756987ecb582963405d018", + "amount": "32000000000", + "signature": "0x906f5f47bcf45d46d07d50a67fb7181471cbb3ccccd0b1974c67966bfbbf535fdc3b0c7a6003682dcff085b420a5f2590e82297046e442eae46646c049c67c3d4f14c9bd2df687ce37ec6cbfdaa1af81e604c85333468794a466753f7a8a0f6f", + "slot": "12857180" + }, + { + "pubkey": "0xb7ace6ee6ac3ae62a38a638fbb989027f7c6cb8c952ce2ad6e19a22ad40f1c1cdea9acc102bfaa7118f971aa508d0682", + "withdrawal_credentials": "0x0100000000000000000000007e2a2fa2a064f693f0a55c5639476d913ff12d05", + "amount": "32000000000", + "signature": "0xa894149a6d8ab56a76de998c28c6292af70826ad5e61d352e57556b5771bddc350050e647c9d2a8127ea090841a31a350b1577f8a21d977bbed024956d0ccdcea5546b685c9602041cad2a9755bef3eaa07a02c31ca51b7a650eebf31d69ecf8", + "slot": "12857181" + }, + { + "pubkey": "0xb9909f3a4ffbf21a2ff8306464087a330156669f25190c0428f60d1e3513fd8aad7dd4ed87d212c224123e5536a067b3", + "withdrawal_credentials": "0x01000000000000000000000008ec37e2eb451ab6fb29fc14d215b0aeef170040", + "amount": "32000000000", + "signature": "0xb200f186a4246299359e754d3ddbf006b37475f33af6174e8361dbac315e435926a298c322e82d4a05c9c0b4880ade8c0087fa69063371acfd5d0b2c87f89776246f36bc664c06cd7afdf6d32ca05300bd98a3517f5f215d7c29fa156a0644ea", + "slot": "12857459" + }, + { + "pubkey": "0xa2d13b6b7d531180d57a00d2028811a4900f84f929cdbf9b2393054d84714d3d0c67d0e6fa3d2045d76bfaf330927772", + "withdrawal_credentials": "0x010000000000000000000000973846d98a952745f2e2113a76c5d62ae04e1eaf", + "amount": "32000000000", + "signature": "0xac317b7ffcfcdd2c47ea66ab5ed6949bec8d581482428577956367e907e22a4c2ed842801fd52a682c0a701a6b9554110d52e016d909672770fb48146d0d853c4e48e625c3b3f90e4da0ea70e45dd18f5d7a86a381f6e4c44dcba952495ae10a", + "slot": "12857604" + }, + { + "pubkey": "0x97b8f02d9201fe34b3dc50f37ebdfe9fb7101c4228db92ee85e11d149b2098bd5916ff37e3432d68872dd79f34879745", + "withdrawal_credentials": "0x010000000000000000000000fdc6a627675a079f2ad446213d896ea6e60192a5", + "amount": "32000000000", + "signature": "0x8664ceb5d063a6410537fc861b32df0614163fb37b81b8ebe1d6b09b92f608068bf1140808d77807ca4f80d4aa2d12a1003867ae3c39b1288b30c67a3ae94ca7d3e307ad46d3d05c353a51c95b8fcadc480b5d51ea2ddb860b3fe6ba64a10dcd", + "slot": "12857730" + }, + { + "pubkey": "0xb4a5bb2ad13a539900708c4387cda96820acdcea1bd30cfe6364d77f48c556d4fe75b9f77376792a2ba42e3d42f3d2b1", + "withdrawal_credentials": "0x0100000000000000000000007e2a2fa2a064f693f0a55c5639476d913ff12d05", + "amount": "32000000000", + "signature": "0x83ff63e3c08c36c6b71b84b74e86fe95881a31d57e06daa314f6d832a4bb60e44125a46b8b02faf02249e4b397e2e97a09cd6ce8ff549b32270e715491a854e709fdaf2150f50d0c1f8f90e22e1373f29a16b30bee9111316739a387576df146", + "slot": "12857778" + }, + { + "pubkey": "0xa5d6e506ba2fbe1f8928f2348008bb50f6151c42d28ef079ad3f3a551c3b7ece2b28def0675855fd3123d7bd8da51604", + "withdrawal_credentials": "0x0200000000000000000000005625e24bad577664619eb2a4e06fb41c9594d181", + "amount": "33500000000", + "signature": "0xa12a97288e38f4f4b30decbfb6b1da043d924c28e80f8e4c48d4010c508d9ab39ee8e5fa051db72102f709a4f9b1c7ca087851cfcfe278a80b78f8a3753d3d9f72fc39020dc288774582df6ba3f609cecd6ac4f44ef48da3464f01497a39ca61", + "slot": "12857887" + }, + { + "pubkey": "0xa50741c4c79246391747f30a7676625576813604ad55d376439efc3da3b1b0e4591fc16edb5e11053dd65e2e937120d9", + "withdrawal_credentials": "0x020000000000000000000000a7b47c53a52b3bb7cf1cca8c07d715ed8de68744", + "amount": "1000000000000", + "signature": "0xad794d0286bebb7e38d04af38afadb0707c0b8ef4064e7ebc6f5391bef18f3dfb41440e9f5790ab61bb8b4e17b49303e1147574c3e101c6d7f5cdaaeabb06904098bfe141219c4818d7154bdee3ca45e3dd4dcc13bba9809de69ff45fc2a50e7", + "slot": "12858026" + }, + { + "pubkey": "0xb3e43495e7a5e2245cde62c1ab3e64e2f19d82f484ad015ad2737544fb34625582960159673d4f7a3a936711ecd7cb40", + "withdrawal_credentials": "0x020000000000000000000000a7b47c53a52b3bb7cf1cca8c07d715ed8de68744", + "amount": "1000000000000", + "signature": "0x822b9c15a0e343aadf2a4e22034a338282703791b6934e206ed5db3df234844dff305fe0b520d762302f6c2425cc823b199ea00091609ec3d4f8cb07564b48c6ebc822fa884cb2b2801f7086aeb7c40d8c028e5b3fe8b9e228af9cd17f844f54", + "slot": "12858042" + }, + { + "pubkey": "0x852220b5225f7b29115f7e4565cac1842a2a981d3d050f7e48a1e10d3286e4f868cb064403e328be9e94bc14e9c25bd1", + "withdrawal_credentials": "0x020000000000000000000000a7b47c53a52b3bb7cf1cca8c07d715ed8de68744", + "amount": "1000000000000", + "signature": "0xb0eb5c234a0274f9855c7ff7b6c319a11b2bbb9798d2b96c6f7ae01c3cb5338d4e2031359af4d8b0b4771009848d52710b4334a3b6c94278e320c8557a66da19d2b3662300016bfddb898803a55fabf235d01363faead05d0efa991092c0331a", + "slot": "12858062" + }, + { + "pubkey": "0x8fbb1e02ef434ee62613cb49307c7f3e529e4fa714d15e8035676d7e7e906b4096d29b259ee7223987dadfc930f8e3c6", + "withdrawal_credentials": "0x020000000000000000000000d19104593d0395080d901cc74822807c9108aafb", + "amount": "100000000000", + "signature": "0xa554398aadf91a18ace8f6744c349d778e2ca14f61b0b9058848bf0abc99ac20b852d7c90e7b422674f4fe88576ecc430ff6be9dbbe97537c57920a5541a0b41277f3a394f325347feb484f2c92176f464b343e48bb498a5743176a123ad56b0", + "slot": "12858080" + }, + { + "pubkey": "0xa7cb6e9281d3c6d10df7afdba97c2bb5be5570a5e27e4aabaf379a378202c863668976994b66643b57342fc3d4d9f1ef", + "withdrawal_credentials": "0x020000000000000000000000d19104593d0395080d901cc74822807c9108aafb", + "amount": "1000000000000", + "signature": "0xb12807ad567f27751af9af57d8d9051d603eab6f537381fe451860d054b9c08b5d3228e4cc52ed75744bc3d21b9f2a8608f00fc2da134afdd2a445c4dba178e217b7c2817017a6303eed76eae0889a73fc241e6c6ab693e9c1067bc0398ac32e", + "slot": "12858088" + }, + { + "pubkey": "0x95325a9148b32f6865d8978487017c56877add04978377c8c5031de18de7e3ec431ce65fa21c2f13fb613b7c906db917", + "withdrawal_credentials": "0x0100000000000000000000002b78035514401ed1592eb691b8673a93edf97470", + "amount": "32000000000", + "signature": "0x8864a477ddc9fc9f3a973b5b4a0924f767f34dc071de8b62b16e061d01a41948b6c58a871674c5592e77a3bd1cceba07115cde2fe0781506a32d194c72e3ecbe9e77d5349b2cbcdc8c0520645138c32eb3adc04f0325c00a22e08cdbd1ff44e1", + "slot": "12858114" + }, + { + "pubkey": "0xa96978e7e6d31f7f6d1ea665219180cd690c10701845867cd078ba936eb6e7a0a00ed6c23ee3ca79858332f97ac4c052", + "withdrawal_credentials": "0x0100000000000000000000003add198d8df24c62e8aad760d51a020bc2d6dca5", + "amount": "32000000000", + "signature": "0x92a06005a12adb2b8d368847ecd7e143d3dffd30096c797ce131ac5363d2f372ca0a49fabaac9ca437064f8258769b270b21b7d48d1b91865530298a0e67e7371557ce7c9229b85409d01f8ffeb380ab46a892a740737a78f4644ac831c913b1", + "slot": "12858154" + }, + { + "pubkey": "0xb108e36675d1a5f2ed68eeedb27c8f5a3cc472981c3fa4e943cf2a4c74e7eb2e323a8f81f384e39ba65e2cb9b24dffd2", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xb406d5a487bd0d9cf41e0cef1e1107b3e3fca65e1e84a33790e13214e6e85c4ca901447a6d11716a8456b0ade9b69852081356d6ce46e99dc23dc04b7e3750a74dc127e8778c761fdc9af97a2e739a64045db0cbf7465e8c79875d945c12ac7b", + "slot": "12858172" + }, + { + "pubkey": "0x8716e7c4a87bdd3616305b6d022983faa4817134f0df1cdbf240e162f1a3c92459f3cb9a23e443f0b6bd3a4df2b03ab0", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x9692e57f1b04e2690f6a32a051aad2b7c813c770d2322b5b4608ecb7ff236f25ef282ea19554349738a829e826af3e8919ded48d3777f55ac0e6219ca8c73a9e712c2addbb3a223e14312bdc6ef5ec803b63b94bf8a03200ba89146a820787eb", + "slot": "12858172" + }, + { + "pubkey": "0xa1f7e8bdc323d0a692d3396a0243987fa12a56e8031df3775be2451f1409cb720c16e4151dfa3a3d7f7dac3227bc62fe", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xa0109be9b40835ea2b24e712b8a97f1bede3630b5757a966e93854d4e2f922f411f111565a474c80dd6b9c1a0e6aaf561869096bc68f1c81a5bf995606634531afd419b33e5e70fba6dae9d4c442beeb5ae11ed33beff84cbe8459191296186f", + "slot": "12858172" + }, + { + "pubkey": "0xb2e222888b54cac44f513e5ea3a2779a85990dfdbbab004bef0bd629f7e10c571ee6d676786c33fb19abcea58657639b", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xb55ab1c02a910ff479c69d2cee6e583cb70cb6636d5baeda24cd3facdf4cc318abd524d7009f889d5cd2a137e79c2d9604ed3f8900a8af1b811363a66c508a08d3a78b599a1dead2bffd159d66958183ec75dc7e009372faeaca91c465a75e05", + "slot": "12858172" + }, + { + "pubkey": "0xb57e4eb39f878e1c9ea29652a1c389b7b946e7921feb6cd40eff709b54baf4853185f2c0f8f7d962c685e46a7aae2580", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x970d7a6cfe2066e6f84b660f316accd03e762f4dc64746f4e7176121259f2799626dfc023aaaa03861004c55feedf0cb044d5c632938ed5d512ec7b67533f09e5a9507669d279d287ba9d04df899d200708183beb6cd2c87ca3f18a4d0c5fb71", + "slot": "12858172" + }, + { + "pubkey": "0x87a41076c865bb81f890208d5dbdf074c32bf549879b83286a55ec2ca491a87ce264eae990e1a6342b9dfdf1ae5796df", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x8c584aab6ac750554d0966e0e299d3071c54f53b2a149f2140197126218d1b228743014b16a1a9da772a4415f370e65308a67e16a18fd8c088d0570ebc81e48d17463d9164f463693e1cf1c2a04c4f0c83dc8e71874e527b9a40ce9d467d177e", + "slot": "12858172" + }, + { + "pubkey": "0x90bbe3f76d51ff3ffb6bf0047b4405b448e907e5d5929f753a8985208fda5a931e18e6e95f6d6073c3c3baf7bcb245e2", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x86a0dd7a582f8eac9a9f92260800b075620ab187357291b82b3fce8d998ed20999345baa9a11ebecba991778d91eea7910df6e59593ea159575b267259edb5921d19365aa7f17bcee8d08006ab4eab9ec25e336d3d04b49f72d63c095d068726", + "slot": "12858172" + }, + { + "pubkey": "0xb505fb7f810e1659c324c0d62c59da91b08c4e6611aa89ce0ed5d90481b7e303ab17ea224aa2f002c0db56931257f03c", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x86c1086d1147060d549b9914fa56abb167ea5c32623a730809d8fb48492652fe2d3259662907821c2404f9ae4253b2cf08f7069571120659f883b756dea9279b58367362ab501bd6b0390139618ca4486b68c0340cad263a8288e355d4fa2676", + "slot": "12858172" + }, + { + "pubkey": "0x890c9e7a547c4b6daca756426b83dd9d37ae8f2c6b37ac348cd535949c60e9f94e2b2512756e516d24533cc2d9659faf", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x868a1d8561f5cc92d87f9db16a912252fc2eb4de78c1a697e17994d555620cf1d49474a8ee967015687dfbe2369176230b0107fb992004cb08c546b644d9c1cc010cba59f2b89bc81c5bd7056c320d3d1739372aa2d69798323fd5f59f374905", + "slot": "12858172" + }, + { + "pubkey": "0xb3879c49c3817dd2f286ddd3f7e65719db81e14055f04e64867971ca3d4e0d8ba57796234eda74b3d71b04542ba32695", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x88ce91f50a52a623efbc33fd570c5ba3738d6f6077d18b0eaea7810419c657053ffe69550cfaec92a65840801c6855890dfd1090b3446b0327ea8b99b201cc73592cf1a00151163969de1fd8cbc037c540f6fa29f8afdf3a4d204a1528990473", + "slot": "12858172" + }, + { + "pubkey": "0xae1890d23fb14d15d77146de9d1b516f2df407e679508fe1c4a64d4c600dba9074b1d6879d1f4859516fc6c8fb672b3b", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x8da9515b06ce6795c22479a94beba346d0436fb4d90c37f72022af06cb2b618aba72deb90ed6a9d98d3bdc7e282a8f4415eb46a0a39da1b23eaea225bfa94db83451c909beae3dc67418619c0d643c7a473220227ea9316c7a41a27b9d16f7bb", + "slot": "12858172" + }, + { + "pubkey": "0x94bd21e50afc6d7f31c97f7a4469ef3b9d000f036fbdb37f09d93b71c2fb74e222a8103fc32a4cd25e65d7deb6de5769", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xb50341c492d700eb6d92dd8dfaa31988ceea3df6dd3ef37e10e1c5baec9d09d334205d07125461be2fa762c81ae81a020b369d09dfc276368db1bd70cdb1d11a789bb4c49ce778b18db648396638ebcdefb642ffacef12f983f5db99fad5296a", + "slot": "12858172" + }, + { + "pubkey": "0xa4a8b99c338d9f40e9d1f87c68ff32c7801260a7e5fe8095a8f2e13a6053b529659cfe8f7d127901b3ee217b4474e6da", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x90b22512eae7fb7cb5991d89f692aaaec8b4e2f466a8fa1193dc1462d0e29250d2f149cd8f343a907c90b9f5ee89d4fe1567a0ff194b6618af3dc29a6078b5e7c1aadc2617701038f6e6b70403ab8d7fb747a9d2d0ecf7d230cbc5b0de26bc2c", + "slot": "12858172" + }, + { + "pubkey": "0x99cffd44d56e15284577955fa22fdb2ab1122ea689a9935e4ca8f2f0cf18c0bca6d112fa40ba263b727d4c5a11721caf", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x86db09d98491de9344b362a8bbd26fb150f075941b758a792bd61ad7bf4d56d3d835777f01971d602ba363254961293e0c5a788eb908a708560810081f843c72431a20ed48aacdbfcc93771d0402abee54878adee8b2e405c1a161b44dca1353", + "slot": "12858172" + }, + { + "pubkey": "0x959cb37826a69e9be39d0888bcbd71ca55763190b7372a86c1528f60987066f051989858d13bd7c6a8d37170c537b11c", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xa0878d5a4e64fcedb8227ca87fef0417f74ad4cb440f6c4e9739fb6963981451e8f474fe9eb0359630f1b05623ca98630aa17492b4206786d07a77df233037023a11b1b7a36b2353cedda14937d587c79d839ec830f78d2db17f79c172f6f0ce", + "slot": "12858172" + }, + { + "pubkey": "0x9831d17c40c8a8750ce7d8434ecdc57c986d538ffeb7434b2501c23b012add50cbd35f1b6c2002ddb051095e1737449b", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x832329e0a5838164b04c91333f04c501149edcbf9911f6de88b7995c1a5897f94ecb6b6aa52768e6a20ee234d23996f5050cc470e6dc4df475013c6db9627f1edba1efd1cbfd5aab83ca3716d0f292a8166de2e1c3eefcb239a7fe606a3ff7bc", + "slot": "12858172" + }, + { + "pubkey": "0x912dd026e00b7772025defa424619e9f10ca93476ee0b606aa04bc7dba33216f5f08ee4721383539cd37bf459a604f87", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x858109c1f7307be5d8d61749f28954dffb1a7834d9d20223dc735142f1c2fd62b328fc9eb87a6438952330943b541cee15bfc131f621eb71fca4310cb21e6a1cd6b145f46646c7f675c8018654a6103553bea23e98dc814823e54d97826f3f73", + "slot": "12858172" + }, + { + "pubkey": "0xa0e7aa1f91ea63b71a8aee17d214f972b6bcfdea481e9d7031bae1bc1e7ec254a1cb63912d430d25bdb680d9758c2a9d", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x92cf3c4d1741ffa45e01514a88d216b3f643f2223f678bc3bc2f0856a64d0174a89b09865c9cc1c8e2c0aa73c3c995e60c4bed42adb28dbdfd822ac2ac505f547d8c2786926e739bc807ad2f1b3bafd41f782d5b3f7607bdd1fef81cdce600f4", + "slot": "12858172" + }, + { + "pubkey": "0xa45f2ca6be75817ce01ba33ffdada3683e5e124d9008e3ab23ebaaddef471f7903054797d52c60979568cc2e63b36dac", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xb3444fa381009ec2f115925577f634ef3e7d5f84bd0948a033bada98bbd7f3a3ad9e0787d86d82c736a97de00115f08f16a63f52c6153170e57335f75315da5eb227cc713e0d7dd43894585e4117604b7c9713625c432c214eb69cf6ab98a57b", + "slot": "12858172" + }, + { + "pubkey": "0x992b4524d572de687a90d9436439367b3ba13c525874e0abd1dac2aacdbb5a8f6bc89a3a1388cdb79158309841860862", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x970e29d44b351ad684a8987d85ef168f77b20a46a1bb864df486d483cf268cd636ede0cbaf80db6e6c8af9a2d635f4f700d240ee107625864fe0090f7f1e296a4ddb84befc532d16bb59598b6399808a78d82ed6e7f8ed982ac957ac8d8d2581", + "slot": "12858172" + }, + { + "pubkey": "0x94ee7be220d1afe707276c0f7234a9b07e60e07471d16858b27c0d96b2160a85470d17cc7a020db7226a75f0dba48e5e", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x892e9d015476f268005916321dfcb24d72d70825b208b4b7d184d05fcc8d64fbd78aebd63724550dd97bf000e9e808800a0623f5d70080a175d16fcf19d58ee2ad20f7469e7867df8ebe1ca5004409bcb52bb105c724b0f2297e646e1aa079d7", + "slot": "12858172" + }, + { + "pubkey": "0xa17729622a23ca8106f45ee50ba0bd1b6b53c2647f0e93311cfaac9f83084ce50e53cf363d9832afcd40efcb54f4c72c", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0xa9acbca0cc001a85cba8503782b82a19a220e5ebeb36992ab54b3534083bb057514056df441c1ec4664fb7597a3f4ec702f33ed25d0d742b24d7fd5eba8f9f0e024d47a616b148c4a35a52dda2753633b0ceb6baaed79507349998b9a3306b36", + "slot": "12858172" + }, + { + "pubkey": "0xb29ef61909a1a202819eac204eee1c5a0fc8f8c7c96b87f989ba36ee62c89ffffccf553fba9c7e9346b3930345958700", + "withdrawal_credentials": "0x0100000000000000000000005396a49ce15a1b1a281816b577a58fe4f87f1868", + "amount": "32000000000", + "signature": "0x8738843a869c771d0275ee5c860d29e809ceffc3f62733ee8a3769d91548f4704ffc4b30ec0fd479a9c64299aeca753b129228aa43d2aba154873579595ae62d5c53e0fcbad2b042f3f81c8d6b1a4f87705571faed5d0108fd672434e9752f1e", + "slot": "12858172" + }, + { + "pubkey": "0x86f168e0af480db07a9227d815f4b674e3adf8b76c68f978a672289de973de726c23875adb3856edac6744c2ae1a60bb", + "withdrawal_credentials": "0x0100000000000000000000003add198d8df24c62e8aad760d51a020bc2d6dca5", + "amount": "32000000000", + "signature": "0xa9c9c27520087a8e033ca797b05715a3272189fdd3e31cd91d507a0e7387a974f5392e97d7ea1c872d16da481b588f1c1696e81928145ba67f3c08703a98f89db48d72fbb04428486982c6c516faa8bf4670275d12fdf866729d5db7c8ae787c", + "slot": "12858256" + }, + { + "pubkey": "0x92e19c36be292f5f4a9c27080bdf31d0d7d796eb55e191d2d7194bf4e236923797efc8335bb770aedb73bdc6dda2a22e", + "withdrawal_credentials": "0x010000000000000000000000f9ec7bba855536be67366119742d6f3501b0db54", + "amount": "32000000000", + "signature": "0x85563e71f0b677ec48fad155aecc31dce713837b8dd8c0a5cdcfe76f62001eaa19049e92503ea2b6c220e15ec0da66eb04ef91f0ed08b78f0a5a09711aafb2190000915dfb4682b6b75f27ae112f2a81acabf5c6c09d49174d9ef972725e325f", + "slot": "12858386" + }, + { + "pubkey": "0xac17f24fe06909206015be5203268101f1e9b20ffcf793b84fe859a594d430b443608918d4c4a8ef2bbd4cfe5a6745fe", + "withdrawal_credentials": "0x010000000000000000000000f9ec7bba855536be67366119742d6f3501b0db54", + "amount": "32000000000", + "signature": "0xb9f9bd0a1875e335f5f59dcca6d994b5e4a4802d6e2c2d08f71019714f58f04ade1860b9ff5ae853f2cf3845cc605eb510316f1640eae218777b8549d4c1276ae557328f10af19cba29ef568c88d29402db032763308984aa40833f8b2e46e35", + "slot": "12858386" + }, + { + "pubkey": "0xa6b52a2c3fd063f398a9de6da1ff8d3587967c3f5f4ff30c2f53d5418cbeb087f7674b3be38109713ec31a766a476b1b", + "withdrawal_credentials": "0x010000000000000000000000f9ec7bba855536be67366119742d6f3501b0db54", + "amount": "32000000000", + "signature": "0x8a1bc453623a83e9e5c3bd6b6aafeefb65603a173accc3e8319a8a5a70a141a862ff9afcce21b27dc5327ec7bd7176ea0501e3d6351e7321b1fb0873a161a02a65898d813f7dc7126f9c707630c186724a006e4f4c4626858e97a3b04f34f1ed", + "slot": "12858386" + }, + { + "pubkey": "0xb2e9f287d9987bdc6e0a267292e1059986ca5a92c31dd1d5f4351972c95eb9390dcb7de0cf0d0c3b25bb85e588b78054", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xb2e10390ac35ecef4abbb263b25516ece7c16c4d1d39ed1d398a5f21b7e2492c9fa7c9e74bd7114c9a061442f4d52b05097bb9f9fdff5e5d1565e2e118610393c723014b4b1eb4bb17f3921bb1b26317f2715ac2757c708e98faa504aa6b9d33", + "slot": "12858393" + }, + { + "pubkey": "0xac0f0e5b12b8089b0ce44c7d76feb3c6facfea43a9faa46ddcc8b871f16f789a98dc13eb63ff3a609873d9da8cabcc10", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x95c00a812d005b34e2616214676335163c955910cdd7c81f42d8ead8c38d153f8ee813af540c3ec302dc9dad68184cbd09df9915e0f7e83f2e416b99d660aae9f72c295c48f72121f492c870e2516446640ad7849442ea37fa84c9913ee883dd", + "slot": "12858393" + }, + { + "pubkey": "0x9797b72229b0fc2cebe76e20358bf72f942dbddf2ba0ca3d969b1c44d83c4533560cab785273fda80a457e68184c2f3e", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa443c8a272cc23a05fb11c01033173543ad4ea870740f2593a5c0bd61f7ecdb1353c1f1471cbabcc8c88f0e430d5b44f07e9739fa2a71237c05455db11fcffe6457a721c037d921b02bb8486a737195d9a9b717b1cc03e0fc7337302e3bf9970", + "slot": "12858393" + }, + { + "pubkey": "0xaa08741403cc2a39d082d7036751954c38bafa049871d10678fddaf0536cef4f07dece6abf8c053a3fee4e73782a5562", + "withdrawal_credentials": "0x010000000000000000000000a658a44cc2592410b58b80cb4259a898a891304b", + "amount": "32000000000", + "signature": "0xb5a5794e1168e70db5fd9aa18ddb5c81867898b91bb5f95415be7b3074856cb1e8bebd0deefd6924ef0ff903cc6fa97b1902ccd846341d562e126cf44a7c544d010c5265ebd86ec4fe7cfeba35ee702570505c30b766e56955cf0dd1d41c539e", + "slot": "12858440" + }, + { + "pubkey": "0xb8552395e8395353c39119209f813f2d860e2d0cd5402fc72b67efb1c918ff4446fc6fe5cfe83c2ebfa7c015db6f2ebe", + "withdrawal_credentials": "0x010000000000000000000000a658a44cc2592410b58b80cb4259a898a891304b", + "amount": "32000000000", + "signature": "0xa051a4c5dc9f83291d0aa55af0cf8a2464cbdc33c6895367e3000ab0eceaac6a4012e6830aaff64e7eab3919d92a9ef303095a5f2dc9aeb0908de1bd616ebe39695a008311215ee811e0f80eedbeb271016d0a76fbcf5fcff561180ec600b26b", + "slot": "12858586" + }, + { + "pubkey": "0xb5f838bc430eb9322832a2f6c834438d4911ef71b638adfa209ef90aee090bd23ace62c39f2cc633e806027b0799e448", + "withdrawal_credentials": "0x010000000000000000000000a658a44cc2592410b58b80cb4259a898a891304b", + "amount": "32000000000", + "signature": "0xb364359758f33edd2016d64f657ff06d8b5ec49db63c1d50d77e636353da5d44d7533c4d1a6a9cc8e4fd089b75f2e83b00ce9e8d77795178a49726b5c4c9e9712dd9e18ac9fe2ce4851407d988b0f68c7f3ef1d1fed1ddab1d7159b18a0d5b0e", + "slot": "12858586" + }, + { + "pubkey": "0xa16e25a1ae74402aa34851d73bab3c2998b485808afb38351cdd9b4e804153c35c17a8448500fe356b08cc67c33db9fc", + "withdrawal_credentials": "0x010000000000000000000000a658a44cc2592410b58b80cb4259a898a891304b", + "amount": "32000000000", + "signature": "0xaada985e4131629b4c49447112755c95368f63ab1d6634dd61f7d7bcd8be6e18b6bcd694f9a583b1dd278e87f3a5be6901296831e13a52debd9863fc3d7c6bb25211f74162a3773abf488a7eddf40cbe4a7ea92e4e711f60d589aa8f0cbd5493", + "slot": "12858586" + }, + { + "pubkey": "0xafd74b8ebd8c7c4a2c5bd5866602135ab42cb5df3971403a51a37197e77f9b15244ecc195bf4987752396312043d14f6", + "withdrawal_credentials": "0x020000000000000000000000c0c9af953317cac889bb312c04b8357232f0a46e", + "amount": "32000000000", + "signature": "0xab6045a263a11c9194194e386a398b9fec2e92d66518859742bf23b94f1733f831e1800c2ba586a1bcc06baa14cb7342135b0c26c330cfbee58a0e3b5d8da3726abb6b0654a877826075a9593c3f5d5064a5b64a42fbee8d80054bb8a585fcfb", + "slot": "12858825" + }, + { + "pubkey": "0xafd74b8ebd8c7c4a2c5bd5866602135ab42cb5df3971403a51a37197e77f9b15244ecc195bf4987752396312043d14f6", + "withdrawal_credentials": "0x020000000000000000000000c0c9af953317cac889bb312c04b8357232f0a46e", + "amount": "1856000000000", + "signature": "0xae60f56dd5f79efbe36cc183661b83f3918f1627d57a97c89792e5e687eea6e83c2666566adba6171d65b04868867465148da1773734541c93342211cf54d9c9e999ec70b933a59420efabc1ea80f27b0ec51c838b16cdce9932f1f2d815843f", + "slot": "12858966" + }, + { + "pubkey": "0x9482ac00cd61eb0504efe8e964059ca4993058f4781f3ddeceb8262c9f1bb62c6d470cee4b855ab8c497e7c44e0d0ffb", + "withdrawal_credentials": "0x020000000000000000000000c0c9af953317cac889bb312c04b8357232f0a46e", + "amount": "244000000000", + "signature": "0x8ed1a50999c6cbfc3a82f4eff46cf2cbf0686975ed946669b7871f5319b89f6693e4195d33f5a789fec40f699607b167147d322f1d04f1fc52639eecf3621f0ae86855623d72d366e0811a16cbd027f826d9f631e025afbe00aff6dc8e653c79", + "slot": "12858966" + }, + { + "pubkey": "0xb690fd0ecede3a22b424cc31d1a5b25276706ea9d9b9cc4b322ee733bd3e5fc5ed5b572b2e903cabaafdd2ed9184affe", + "withdrawal_credentials": "0x0100000000000000000000002b78035514401ed1592eb691b8673a93edf97470", + "amount": "32000000000", + "signature": "0x9299ba8aef61160d0a1590383af685aba54dfefa593a3db8a094adc19e92da243526aefb02c156e595a5cf65fcdafd30074940cd37a8b4fd01986221572fa5f78360262bc2dfb1dfcdcdd62c51b76aa25b9e3ac6a07bb6bc2f52dcecca5a9c90", + "slot": "12858976" + }, + { + "pubkey": "0x9482ac00cd61eb0504efe8e964059ca4993058f4781f3ddeceb8262c9f1bb62c6d470cee4b855ab8c497e7c44e0d0ffb", + "withdrawal_credentials": "0x020000000000000000000000c0c9af953317cac889bb312c04b8357232f0a46e", + "amount": "897000000000", + "signature": "0xb3cfdf464d58c082e17a652ce59718d042ba3affc67c830f60ea90c3cdf943aee11267539494d9bd95fd58971d4c215f0a96cac43ac6d261e05db71a0a954c7b6b542c2cddefa25168c739dce02191ad0d63f7b961021fb26daa202202143816", + "slot": "12858986" + }, + { + "pubkey": "0x823fcc043dde3a00cebf050d029844d5df5f30544e7f707eeb837b2bd6615ef5ec3cbc41f89bdd7f6e8b88ca660dafb3", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x848818e861147d615133c471e0022e4d9e133852b23df88558b201b6ce70e72ea20f2377550591822010b956fab71b5d11eda8ef51e84d7bed44528b1bd418c2c525ad6c04cfe0c5505bf550646874d6ad62a8ae07a5c1fe571110e57426f777", + "slot": "12858996" + }, + { + "pubkey": "0x8b2cbfb115eccf502c362f6295b11cb809d17981edd47f7359ed69c7195a5d2a67522ea5b49b6c4600881183644ae0a7", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x8857b0ec5cf2656daf11f29183126fe64f3c9fc4b063091b4b9d1634d7033b50a15cdebc5a38a8be009623890d7a989f17fb52a003044b29b9fcd18be2e466062c993beeb354856c60605332612fe1e1eb8c54a156890ab796505cccbb1f17b3", + "slot": "12858996" + }, + { + "pubkey": "0xaf1b8dff0988d924d90af2256f91cc832bcb1055f0deca2d14cf12e291981a75a5f9856de4489195e1b6ad33a3c81404", + "withdrawal_credentials": "0x020000000000000000000000c0c9af953317cac889bb312c04b8357232f0a46e", + "amount": "51000000000", + "signature": "0xa6b9cf42f41f00065be67f6a4e5b23aeaf0c0b15418ef71af862b11af556d06d7007e1d43294914754fec62cd92cc9f417a42b9415cec7f241b112576d4f2a5f7747891ebc6cd1b3ab92b8526c0693b0fd64cc6c85a660ac281c00ddedd50bc5", + "slot": "12859135" + }, + { + "pubkey": "0x8e744c35e2a12586e4c81fe5c814da2acdf81fe1a33bafc28653f7f3aaab51a107c0c792f6244bbad64472ae4d97a7ad", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb77b19cc65cb38ec49113f4b02642a1f079e1cd83366e7b54eeb862c3e01921db9ff739a638f07c23151b756ece015cb02215f1684162d19c64ea3a9c19b9d0f3b66a1dc804d46e2a4890de15d40640007fec8d9998bcb706be0dfdd0b2ec76b", + "slot": "12859142" + }, + { + "pubkey": "0xaf2b1175e3c158ae5d145286f757aedb741bc608d40772821e2b300a574a6a7f436799238d1b3d96dc4327745312ab51", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x85fda2459bb7f9116507fc8fa90b04ee8427070f1db97bb273f2e152e3d8fc64e643541ea357fc3f6e6fe20c085d57de0646293785193106051d8c2c545bfdb8156ae20c799d3310ee77738b911b8475afd46b44869aeb108d942b9124d44b3d", + "slot": "12859142" + }, + { + "pubkey": "0xa1eab6284aedd058077de08467dcb307fa52070c09a026721265df986094e2122b218caa12264a2b43265909cc2306e2", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb4e02b5afbcc5bd67401be342831a4e3c4d5fc3ba8e5d197c519c2b293326e325dcbc3cdae911df4f5cd5611007561f906f77b38e669c65d94739946fc26d0c35fd187f9bea3d4d39961f4b6cfc71e3d30a29dc9866117839068381ed0864b58", + "slot": "12859142" + }, + { + "pubkey": "0x87b023047f52087e2d801d3d23fd246887e960ac3f48ffc0c93de01f97c8aeca544541120dbb66fb85e43371abb3ba42", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa1c03fc7aedfc4788f75d677a2654178fabc3fa5f2e1273607877262ef9a44e31a56d906cc450f65d992e64241d30a0710d22027cf98998039af2e5607dce81fd28c3c3b115deae8226a238eb8bd8ac32a2115cccfa4ea66d654bb5380a0a2c5", + "slot": "12859142" + }, + { + "pubkey": "0x901d08b53928e7f18f4eb6d7bdb864cf4e469d1ad0f28d8b9a7d09a97c8244695c2b18bfc27533bced7196132202e601", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x83348bc91d2a73d1ed6d0e04f2ce939f05438e4fff8c597a302e68b9590dfd8f77056cdd0f3003e86f6f5b9991a5b5630bd20c25c0833c6264aa958cbf36fe1ef6694d6e5fde90af2ece8e79fe67a4277f41252d18ceeeacfb89eff1b14981b2", + "slot": "12859142" + }, + { + "pubkey": "0xa7b5cdb76575fb16b5bd37b3d3f3f7803bf46d795fc15aab44c512d0c91419d31a79a0c7f5ab0cb11bb56b006176de36", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb39aeef1c09085fb680630c0521c654c2afc67221b8bd46f05f57a695ae9800df2208553485687d2265f5879ceca06350d283a95d577ea2c705c04357a56b7a7e35ffc9dc7893d4070935303965b98e4cd7604e1ae19dfb2bbcb5a109ef16b94", + "slot": "12859142" + }, + { + "pubkey": "0xb26af9cb7d76181dde1f63a03a6c8ebd7a64b5d2bfb6499a6e7292eb1c7287fb631d6d8990929fb5daa8f4a4c6cd3b76", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x81045f04cbba62153187d628b6c6c62ef5c7e19a3dfc9eb0cfd5e307d515b762cd9434313a5cd53c02507b0a3a5ef0ad19110d5ae5cd2845fc1eade3a89d7c3e5d43a006c8880e187b6d907a91366a0b5c74f334b51b675074edf1f9bd6ae1ba", + "slot": "12859142" + }, + { + "pubkey": "0xa354c3d924eea692fe90048b2863076e636909304f1cc05e2212f5f9ad18043f126d25e98a7057da580eca3168ba90aa", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb5a88f2f8d6b247354bdc9a33dda0611a154d1e78fd22ba218bd7e878e411387bc87a838e9d4a8df6a3425e899eb67f8165072e8e2e89dcaa3ad656006c32ca7de1bb9fd06cdbe56343575e356559ecc4b9d474dd350d03d2a407dd962f6a613", + "slot": "12859142" + }, + { + "pubkey": "0xa98d3741e3261536402f1f23064d6622507da1afd50b74c9ae12c171a760104eac607fb6cab945d3c29c15c7a3037529", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb820a6d9d149b6dad4e3c418f4658bf7d6b48a2ebfb5ead59488f83fd835b66405eda2365305f2a4769099e432ca51d80d72202eb16140ff5b2a4201a36445de649788920e402d3dd0081d33772206d8ad3ee2429bd394a81c70566a96f29de2", + "slot": "12859142" + }, + { + "pubkey": "0xb173ef6572fa9010224cfb0964617b44e4fedb1bcf5fafdd6e792d699026f099c6c6c40babfb27b9eceae06af213e4a0", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb54b527becf7c95c7394ce179001da8fb95e627387ef529b523d21eb49fb537824f7667d2979ad1613f3d3683d5a4241170d3337b6c160474df392aba4348c9a79843f7f9133cfb545cf882f576df3455d1167d21ea87f2cdb77bd1c6603fd96", + "slot": "12859142" + }, + { + "pubkey": "0x8add52730b108494df2cab3debe8e55ea734c1ba7e0de8c1c77bc162d1311d9f43db19944a10af9968260f8b48ef4b13", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xae9ab489a7e5d85b39d04d67c0d905e9a341493a64a1175c3d4ebf10fb553b15e1f5a1478e5f4119a975eaa1846e2b0300f9afe152c562a857f9c8f95508b5e8205583fd17b8e18f4c24d9ce6cc034c090c94470789400977cd9cba1b94d4516", + "slot": "12859142" + }, + { + "pubkey": "0xb2bca709126c6c0408cfe57018ee927db5378f1d55f5e8418c5f320388086e3f8a54946ade9e484c6ff8ab6f4f8c9d43", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x872dc6299331d650672d1bdf3ac62cb152844f5ea21598925bbeb70156a35793314714b10491f83b1ea7859e056cdd0d0a9930968159244f6b112bf0961f576e54564ed8040015999daafcd3c8748a511bac423ae95ab28731df3ba44d90a2c8", + "slot": "12859142" + }, + { + "pubkey": "0xa49e6b2269b800f04ca607ed67ce8e81fb6e9e97d4d487cc8ae1247129d094276d60be5bce3334083becf1c83a6640bf", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x926ed5e0e9575bc3431dd1fa86d6766601e493e09a473e376a8429ace4f33902a8d5289f61ac34777e775db3a12b22161615345c56ee459add698fbff8804dd1dfe409a34c05ef4f6614ddc91df32c958bff21f067be89cebb797655d1f9f8b1", + "slot": "12859142" + }, + { + "pubkey": "0xa1bfb7074918525639f85673d816efbbc6d41bdba52dc4134bbb49495221551001b1ed1d66b80aa5b2d97409a77ec164", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8226399a5d1adfec5a08b0503ec763006a2f129bc7a8ff1963b6708eaf240e08d8c2f7a5591ae8f3c3ba290542ba5321093f39831d53c2ef3bb019ab024b4230ad3dce7e04b1a2a4202942e845b5e79772991c6ffa27312f9cfeb9039a1ded67", + "slot": "12859142" + }, + { + "pubkey": "0x93c25ce7df51c0937efc7cc906a3a12bc732c4827751797d84ad400bab91b3304c5d2883aa8cf1e4606e1fd4fecd964d", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb28994d712563dcfed0204fa95cd4ce8b5baa7fc9d2f9d70ca466211c66bfbeec69d7dae44dc719ac6d53c0f58419a4602792ab7c848820393aaa61ebc474d527a76d39398f24a4a1706152a799ec2fed3d3c0fcf8aa8613686df4b5c8a0ceb3", + "slot": "12859142" + }, + { + "pubkey": "0xadcd062f11d0ed61d581986b1ef25144b033994b8b29e6b49dd866a19efc8967291e2a96b76bb09555834ec5afe9f999", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x97ee6a527d15a224442b4612f77f2d49f2e158e8469daa3bc4f0dbf0d4339bac9c8d90bf55f6a8683df7c286d7b8c62a013a9b8a4c69a96525cfdc629959c07158a9ec4b1365adc42597fcae0d86577eeddb7670d0f9c2a3858120a0cc3269f8", + "slot": "12859142" + }, + { + "pubkey": "0x8c9fa18a04d008f742a4664f1ad5624bf39b18ea0546f41a6cec33167c156c939ac3b95bb6c09f52ab36e15636dc648c", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8b643cc7d00de6ecdf7311e1e89259928147878faa634f1b96bb02d637ec3e001e9ef7a5d4041a284d2692943120e0aa11758b788890500dbf7392a8582a47c0124f4d201b49e78754dab4b598f22c74880b6c9e2159913f60e4823a53611b7f", + "slot": "12859142" + }, + { + "pubkey": "0x870c5047692b82dba73314dbf1187b99dc34220629298d8cc175eaeeb50ace1edea41004a9231c157f9cef9d6046bd5b", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x988503af3f042af29eeae630588e17317985d7369c0a0d9acbeb83c233b432f6c46fa440b31ff151af4ea15295ff2b9d04cd42dbdd0d358f24434a4948b1d64a9b51c74459f12bf3f36984d2140bdce1f22363d50abbc93e0529ecdcc4a1d623", + "slot": "12859142" + }, + { + "pubkey": "0xa482ab057c79f3dd3f18e01318afc97b7e586f892393b93df2bab596c97192065196fb86bce1a4c9dd251fc03a84ecd0", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xaaced8fae9d4c300e8460942b97697276366e8b666bded67614077af7f99a8f0ab031f48d5d69ebc09774c4f595073490225c03caa3821815f70d942888ed1a639efdaf2a1802aad0105cac68abb263c063c3ef08a8713f50b6e5d7a68dcd887", + "slot": "12859142" + }, + { + "pubkey": "0xa87eea8eb3f10ae7794cefc3836b3bb165a591317e88ec1d338cc1930d20a6115c423fdde659acdcfa0bd8062389613f", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa1d11a6c77b672d3da4af09724659e4e646c6e927a8860f741a4d22df603b91c3b30729e399d5b5b667f197565a13f1009a45ee98e39cfcfbfd9b7af6fd5bcd81c33aafb3fd7453d797fe67c107f07a4e0256b67a6ce09eb95f8dfbfca5264f8", + "slot": "12859142" + }, + { + "pubkey": "0x927ebd483fe333b7e47e42b7624522567240e2ffd6d2809843ce353927b4ada547b980d2804775bcd295695333ac18ab", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x95644fde7ca4c7b42af9880becffed907cd937eb18f4bbec1c27ac9f2f42778318df401cba8058070daf6dbb2afa8cb7125b06118e81e762698a4c192febe0c2d97bd37fc592926ae5e4f7b8ece54165826224f6b347e76ad551b7e062458aba", + "slot": "12859142" + }, + { + "pubkey": "0x902ad5b6b584c53bd673f3228dee59df5c1f67e3fe6f55a7d1c640390e894f9bdf6b583a1395ddfc7926d9148d33e9bf", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x81b7e8fa41ebf4d8e4ca6978d146cc4c4d68853a13e3fd0b9c36477f8b2f6a99a6499bc9c1849d60bcf2114ad927cabc020bac3f2879c17ad11a68ce236f3f6c5666643c705790a0190d42a80bb0d29d71a83bfe4304bc919f64b092355e7db8", + "slot": "12859142" + }, + { + "pubkey": "0xb18b938d36ac564d11a4913127e8af4423ba5deca15eb82e2571c2ebf589192327c47855534ab7d073b4473cd67e51a9", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8b4889873104cb031580dbdfa5d76e6954c298bd1dd26248a3d2a52243c422aa1e35e41e8986a891a145b87a07fc91b0055cd3119f00d22763589c1fdac112fa5ea2815a411cd01277859a024bea66a4e1fc13eb54251e339ab4cd3b0e04457e", + "slot": "12859142" + }, + { + "pubkey": "0x8a5a2bfdd510fcda9dd1d16fd9628f4f96b84c3705dd5e1732845690e5a9344cdfc4dee28a17426ee5ecef136a7acc43", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x83d9c8221ebfad458cac180f21e9052cf8a8ae48dea431a60806e7d4cae684805d7f3f8311c055856b71c79db0691c69165ed2fdb28f81e77e73c35c178e6bc1a2084a1c245097ca87deab8faca1a2559ee4cd193af9cdc409e4551394d8357c", + "slot": "12859142" + }, + { + "pubkey": "0xb800e34eb2aff3f00de5ca3fe56a19a697cf61a3dd99247fd49dc962460d728a7def82e3a567c9c4e539d76b33746c1a", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8687dc47f12ea7a803303cdd4a8a3fb28ab9bc44844a4ae42ab5bcffb8886a4a1dcac6aca551488d967007813c3b8f7816077b5730e1cc2fb2f3801422f4c4c76f50b88688b67a8b26014d3c77353c154d7b7fdc991635857ca32e0aa1a71360", + "slot": "12859142" + }, + { + "pubkey": "0xa2278fc40bb513b93503182281fcd99f0d12ec308b489b3d36df8b2c5d8d36f4baf5d460ee4318ac7e0f70a74c76c0f5", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x95b7947e138b5222c7d0de7ad398e06bf09c4d7ac90fd370353d70d1e585b75c221f3487237a8fa94b974f1e8f3247d4022d1678c6268fa48e3ad4956571027980d781f778203fd8346dcb6d1889e8aa02acecc27835f2eb671c2824f1522393", + "slot": "12859142" + }, + { + "pubkey": "0x99af060fef8196827aaf8f84467c5b6ce5957204b9abd4b04d788f5431179ed81bced3f6ba542b83c67530361445d3ec", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xab9ac3eaf3f61dc6a199d7ad71a813e454eefd89dd599dbd3dfb6112c6865ded83ab5b401e3a7dae345d566e31d0961f1000b0ffeb85181f8148291e3bf68240f47cde653583aa8a43466d612b23eab811c2c411c4529bacec63e7064d8e7fdd", + "slot": "12859142" + }, + { + "pubkey": "0x849d3545c7a146bbbcaab44b7a2810b6489afcb4e9430aaa95fca4fbce22f35b914e8151a78c14269d9a9f4594e52d4e", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb337a7e727917fe530be370cfc2cabf1f4ae2a77ecece7b17df9c3396f0e3b132f5506128d16dcdb522a600fcaea4f1014818d5c80d924af9080696122774ae88ce903e29914990a399cdb1b7255f6e503f64841a26106fd3583b79fc17c4828", + "slot": "12859142" + }, + { + "pubkey": "0x87031a607039652e771be62fccbdae8959982f3717fe03cf382ae64231a4f8c507a8d31a7649c98c7bb65f82a64fa149", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x93cdfd13f22edd45f0ccc052f35883c5f174d98b72b931320e9e184d4327c395e2b84eacfef7bd09898308be5657060d134eeba2544e22aac36b778339273c24e87ce2c828c78cbe884b83eec09de88a7fd840214ca619c5c6fabd383ac467c8", + "slot": "12859142" + }, + { + "pubkey": "0x9440cdb251668faf324ab8f9a60b8a67c2a25d1b575138f88e5bb6c24e9743f8512b1fb3e88562d21cb3a7ebc57c4535", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa41bd60720921be82464603daf8560f7c0a65bbb7451ef06e529bf92a2eac94af4d2c6c7a1e9e399f46dfc143f0f5b021815e3b4475b5a01873ce4344be33f31645fed9a1251c8df65265c1cfcc5f13748d1b750ef6d7490d2da2f8893113b55", + "slot": "12859142" + }, + { + "pubkey": "0xaa92a56dbbf5e49e040b77d963313fb6b63972cb1c0a48b2c175a912e1d3d2d80e1b30a04d2a25b8cea41b731fa82a22", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x9666df4bd9421481c82266f6ec301c69f26d420c8e1e129a414c5962e4661f348dede0878287c9c81c5bc17726b7d6cb1257dde4f766b18e042b759b9e8f52d9463385ab6b9aa16de495ef9e6ad337f63e1caa05df52ab1c8bb5c525061982d4", + "slot": "12859142" + }, + { + "pubkey": "0x95d7ca6945ab63cd6d3a7311dfc55cb588c70204653aecec115a4ea0e4105cf77da2e3bffe817761245e054fc7db45e0", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8d2d81b56e2e73372169d6159b602b97602902ff7aa3025a038a902415f04b429b6dc15b973123251927a01e8836b05608fd7f17ea15677a4b532587c466d6e73a9f4308b1032e912433a4418bc714e13860e0584835efae97f5cc60d1663445", + "slot": "12859142" + }, + { + "pubkey": "0xa997123532c25af80152568c91a18623f1bfc2578813623f642b6a2768e30a24883eb16b51278071f6a775a36269e4f1", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8b06b7ddd66a36de8de1b395fc8ad43e7a5933a478ecc3c38397e3705fea5852ab1c3ea66a9a557ed96a5559d672948112347211988e257d35d1b81ca42f440015b8ff8c0dd9dc2682928b6bab5ed5ea6e156bc5aa4f614e5d02a90d31c8d36d", + "slot": "12859142" + }, + { + "pubkey": "0xa0e2cbf994f6573bc032c00144fc9da66dfb676ab7bde4029dd9c22b6cf274e1c50a90324bc47ca23e79af2f926c90a0", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xab057ce488e94630577819708f9343a991205ac161dd96d72b335097b2d68bc4fe3b699b8d67f826dd3d84d345206bac07744c264dc96f2499f163e2825e5b548efdddaef49e36d318c6f3d77f7b0a9e2728514bfa99aeccd64d4db7f9b22c41", + "slot": "12859142" + }, + { + "pubkey": "0xb9d3c1aaecb0ec8329ee61e7ab425e5821b4a884c3f06229172904861835dda3f3930f45290cb6abd077b7291c6caf1e", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x89df34394921136d9495784171e00fb5884f900ae6b80a05c6c991c9195b34c0f7850084199abb95bcbc9a6f305661ea0239c131092ec1e56a038cac1c50332be421d679f9f7007f5b214370b29b331abccee801431e3639bc8cb677301e9b8e", + "slot": "12859142" + }, + { + "pubkey": "0xb16d1b58f16ab1835c74836bd6821a59bca85f8990d931f56af152f564ec123bb4b9f18f9c85a3b5856131b0ed9e382f", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa19ced7b5018abf7269422738ce6fd911abea3333bf84092a79e352cdad04e6f9af3698008f447fd693c1f7dc90723d20885b5e60b415d702544ff5faabaebd9b41c863fe1d8651d67e9398f12bcc998c2211a7a9dec887826195b2e28951e24", + "slot": "12859142" + }, + { + "pubkey": "0xad0854e224272f6c982fc1a205d242295ab89c966b8cbc0cfa2b5d4667163b4535f951a010193e2a87f3538244799a2c", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x99edcac42e57959ef76ac61e2a52e7a902f81bc74493435a0982af62c8abbe08bd563c7a3cf87135078e75a3d5ced39e12c9cd43e0a4e8422d7b36115bcc5caa9f466b033c4af214dfa9b30d206f76a6fee5249428238861b8d11cd40f35f6bc", + "slot": "12859142" + }, + { + "pubkey": "0x8e5331dd3e99b11b779965c500a6f0f7174cf34641701fe6c51fdeb5a6a82dd3e0da53f6dd311980752bff4e8c9a9d09", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8d79bca9c59413193b67ab932c5cc3534bc9dd012a495f0b3ab702d51bb1f49817859052b47100ae9e8ab06471eac65a05e5e6f7eb0a8b820dee60873cf22b01eeea71e7b6884794a22d1e9278f202641bfa0846f0e2218906b92f840a94c76c", + "slot": "12859142" + }, + { + "pubkey": "0x8d5ab2fcf1fb3ae49f0dbdb6047095dde4b152a283fa07fdb1bdc56abaf031c7bad47848deb2e1da1b3bdc869df59364", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8c50460607ed8978dbeeab010a7a3b269eed83f4e20348f38254749478941d1b08b62303abf1c90a3f77dc55171178ac0deaa193eb1b7e9987753e2e0a030a2826454203ab2aa73469aaedd6279b577f0b557c4ce04a84f0dd83652ea2098651", + "slot": "12859142" + }, + { + "pubkey": "0xadcd8deb171c9f16cffb33659796fca7370e2bcdd993aa64a21f913eabd57ecaac7307ae30a639dd20c0e07097819373", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xaff00c032f50963075361b647afe6b4c2bc5e04bc8f9c43de9854331ed4315b25af2e7775f22d4165a6c684d7e80579506b0ee36e9ed279d6d47d91eabacdeab12ce282f160ca3a1576a130985fb98d941073a4c713244b1b1fbaf927f3d542a", + "slot": "12859142" + }, + { + "pubkey": "0xaf63dfe6b000bc94d1c63d18039bed28a3fd5f582dbafeb6ccd9a1a8ae885d99133395be7484d2dbef9410f425dba0c0", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb8f3964a48034e406e05a8fe4f9e58568dbe5ff1a0bfd8cedc97ef28efa2faa1337745781053e03b1ba88f942461d67b0be51d0f21a12c4c686f6cb6e505709bc1e64512840ece1683a0e7183002d7c59cb58b95f23444031424872a2659f5ba", + "slot": "12859142" + }, + { + "pubkey": "0x8749dec521df6679aa8156d9b49c733f4f99794f5dce1cdc6e1c92716c3357f34a05afc05c2eb884adaacbcb7f06d433", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa0e57812db3ddc264cf2c34bb2e8d8f8a3e02d3e076bf229bc92ca0952e5c8c98f7216af9ae6742f21ff6ad017d8477f07293a50963103dda204ff62cd9c91120e287afaa064a67ba81325bca47a87e12def69879ff694fe734124fb30c2019b", + "slot": "12859142" + }, + { + "pubkey": "0xa7ac93187e33702dcc13f61f426e012a08129d78a13a59b946cd0a6ddf294e43b8b55774e5cdd92d6368bc45fea1b168", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x869a0d384f37f18ee33b2438f4ffcf12c78f51eec4e5d643ca40cce2f51c782b88cc29e44e795fd9bf548a3a169317750c012dac55b1ddc6be8f0ab161a9d28f41d0e13d60a7afc64d9bef820914439bad3f2302077e3ede1dd13df88a30cbd0", + "slot": "12859142" + }, + { + "pubkey": "0x8e5c53d830bc792223ece963ba2afab459c6cc2d98731151a1a83ea754aa10464c31d4182a8c2154a0b16b5e34af87fb", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb868520c304d8e9f8f0a8ec46f42998cd26d361c633ee83cb36c8aca4db480d88a33fe5089317a6c26dd491eabf0db3205f3d5375698a8462b8b79d42e22c9b3eb5f352ac69d9dbe77fe803e39f87d4935e5f5f60efcda497c076241e76bcae1", + "slot": "12859142" + }, + { + "pubkey": "0x9902924b8afcae7e6674f46677611a5477f0255030299da3b6a7d8d23eab346bb1f34bf5ffcecae7ecd4392ea15eb019", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb452ca306722ce938aaf354af790e00a404aad8910b16a071b75a77bcbf94faaf18e4af214ca2b9506df046df29560c7139a614c67e5e7d65b6d9ece60d03b37d883abaa46b7fade38fc4a02e3a048525cff0cbbd9215766a86665465fc50a4d", + "slot": "12859142" + }, + { + "pubkey": "0xac9474c6193b6a6cfabe49f555dcc19fa427cd69ee615ac4d87ffc36102a41adaff3c190396dfddaaeea9dddc5662745", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa165e45f7e58b3065da8422d91c58052b3f055eec34cf43c0dee5353b24960d432025e638351eca0333524e0cb367b0b053235a22f9f83c8af24fd93e69b8bc7f87c08cc7ab21a5a72e6877b058feb5f9fc007d0c3ebb438039a4aab2eee6605", + "slot": "12859142" + }, + { + "pubkey": "0x8588716a1387dc1b81dd82c4875efe977459dc68eeb6ad0e9d63bb3a98dcd8e193e30ba036e30e08759d1505960a562f", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8255f54f15c05e25cfe79da0c253d8a537f9f409af922a20624c6e60165e2ea7d8a3a9bbdd8af5ef70cb1b5506b4d88d0bc69ec4827f309b171254cb0f899a20dbc2e55d86ad9647dcddeade4b11ad7eadbe174312fdb7b4bc48843a459a1cf5", + "slot": "12859142" + }, + { + "pubkey": "0xaa161f1435145c710f83bf9517c41517c5a9067dc9dccad51ce948e8a69762c395a2e3fa6696f01290e5e1e8f9ec14f3", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8f901d961ff569db5467eb0defccca5fa8b8628020479d5e52109cd3c944bf32ef580215e950d6b4dcfe50ed5b98f90a143a64c78ee1eb8455104965865d7f1647d782cf224fccd53d432d2f12030f94947f0c61bca355cd9bf7df411c331ea5", + "slot": "12859142" + }, + { + "pubkey": "0xb79c9e8aaed9fd5f93b0ebb0361bb3d47cb31737666b5454b23800cad292eed64c4174d6afcbad3ed8a7b13474974eb6", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x909d7d9c8929cef17188fd1336265830fdc9a3f47502a062107968b1149b31afc8c3d2b84ae261d3daf96b4ffb3517cb12abc6f1329197dc2108765fbc27dde9635c955d48223941645fb8f40237666d82622ba2b29356fdc86718705cdd695a", + "slot": "12859142" + }, + { + "pubkey": "0xadda56aa26c846ea68fa91b9bb96cfde475b9926a6635eeade4067e7e3a4d19dedd28b0ceaf3f664cab282908dd6a30e", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x88db86e51bbdb63d19f7e75629e202b6eea51a1118b5e0ee785c327ff2bf931d3a3b0b6e5a084d8dfcb2dfbf9b84bf7c0e6f3be32f7c6f646304affcf52520d2a96b280e88bd55aac82fe939af9cf4988e7be2943f0848344f7d8caf2077bb7c", + "slot": "12859142" + }, + { + "pubkey": "0x801fa0ec179d74c558dfc9025eb541129637f503ccbde1f74ada179abc40b0886bccbc0da1433c17c3eb499ac8d3ab0c", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xaaa16498bf3a5c7bdce646632f0fa8f00d2c3f5088f41ac161f76bc1bea6d5f7dbf84044fad567572ae15c483c8ae96a0351143dc6a7b61cdff3142e29e208c7b2e337f835b06c51adcde4f8f89edcf4f8f198abaf531ffea0f23ce5d9fbadb2", + "slot": "12859142" + }, + { + "pubkey": "0x8ecadc7ff0f0339d9e8e42c7ec4258b438a2c45d8b387d80136bb1c120b4929c7f732500ec8d3b3391defa5bc3ac5a9a", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa5ef5e92f51c590b61470e8e93a4c31296ee6d5c793b7be49071c01cad139294338b17f005f1f67423eafd39e4ea5f3516f6d0f34dbf8a422e36255439d0a9ea2d5ac50013186aabb03b09530c3efc9a2a362a948162a011901b1bb67608838d", + "slot": "12859142" + }, + { + "pubkey": "0x93b641c87d9171b376dd38787f0c9534ef2f01dbed5d057d189f942ea9e190003e440a663db23922c136f19729a744a9", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x930d549079ca55877cb2dc10cca308a983615c95a45bdeedde1015421ddf7a60f1d88f90b407331ce2457fad089dd23316a198f2a0c7020680582198d9b95b47afc40ab7dc098e594d2fbe43dec6a81b78408955b7d8f6ecdcbf6cfb223bcafb", + "slot": "12859142" + }, + { + "pubkey": "0xb55bfacd7c4635176ee3f2c45e617c7a68b33b738b96a24f49538179db5529596556ac5a60b0765d7206e423c807c985", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb26b7cce680ba2d447a07a30df40ea81f5348d4b8a6c010ce97c611409682a38a2855e3f14d877b7a6d1723866c975ee084db3f9073445514070da1563aa682d2d540be08a7ca51beeb45d6ebe65aefbea29b462d7e6a7c70d9cac381ee482eb", + "slot": "12859142" + }, + { + "pubkey": "0x8ea72cf22d102d1ec349c96b631d5f03955d798bc02af6c33af83a60457e867b8fdde376c65be14d0587cecfe2945d65", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x9651f0f97b212b528680981d15219f8afddb0d266e780fa4cd94667fe0290ecdfe9a7ea0464dc83108132ebb86809a3112e61f019d840454712a80f36d0d3936f1d8ba551b958b14232f195e61156c6c2284e9013f325c1405e97459e80fc9b7", + "slot": "12859142" + }, + { + "pubkey": "0xa7682154150b7f08e8d1a9af7f58bf1ea1290adf5a7553840859a1a1aac838b524d7d4a8a61a8332cb86b716b043ffa2", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa21234ff04942ecac837856a129d2b205c402eb8cb4b5cd57d8a03f4d5c86747e5eb01b8a492ea2ea0b94590abdda51c13ea3d136fbc5b9ad5b077eb5cfe0a703d22981c272dbfc8e10c3e7dffb2be416e97fb395ba47785b57e1264478b244f", + "slot": "12859142" + }, + { + "pubkey": "0x92eac287f1ca0a6ae379850d93dc8f87b4e840a77649f262d0bc3b3a5100a197300c7fec7f243ba3ba5b951d9469f2e9", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8a219de55ce6fc90bec3054fe4e3cbe0097d197d463139535760871fbc853925608b07da72af4c51c86c29b9670bcb4412a678c3264a328fe90e9f81c24adac7714c8f8926b21e39c44b21d19b2c017b049f957ab1920862ede42379bba5fbd7", + "slot": "12859142" + }, + { + "pubkey": "0x92ca85dc16bc1deabea2923b3f7bd42b133c5a155805df33403f0de5ed003bc2d18b3ca1fb0734be6aa2101918028e74", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x94e45d19664a5007256508fd5cda1f0b9e1bcf57a7dc4f62e979a4bd9bc7356048922b2313285d8d29dd6a5de9292960099cb594d56a3770a260e2b5bf5d9a509a70a2efe5e99b1ddec49f060cc40b0b09572dbab92a635994bdc28346e3c34d", + "slot": "12859142" + }, + { + "pubkey": "0x8da5a682b494c4d2f80fc1addfad1acba37185197d5a9d078d4d526928589f7d97a3cec2f3a342c13e787af656775faf", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb019955aa646e9191feb3f2912cbcce294396445c59ad8e12b74b244fd7865ae337e66c860cae00874e0d7f1d13ba6500378a7deba02b79e0e7477e83eef815f0687ef8b9feb87535402a62cafdb506b172471deed50972058cf16f0dec9ba73", + "slot": "12859142" + }, + { + "pubkey": "0xacf79ffc4271f8aefc6ea3ee5bde405b28e03a75c3f1b3e703e81454fdf4444843db5da6182a6c9f933f43f2eff1d9e1", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xadb8421efc39040263a1863d35ca7da1a2c97f6a756865c0fd46d7b06057472e5dcd84fec90c23f32c732a37fbe267651320e32052f0d97b914f65b182b6e2ad0d17ed3f7858ab9a2ab0bcab35cad057ba05a73d43fdf41002d6fa9f01b0904d", + "slot": "12859142" + }, + { + "pubkey": "0xb5ae38ffd1a588c35ed4f9796a64230caf552afc0583fcbd1d8d369817eb768be60bc6ce0bb9c690f062c6c4dd052fbd", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa1cff366c78c475e0d623af1865617ef202a979ec4ed71971d3fce891294aad54e1588c449c9ccee4c2e64aed3fb003c0b2c55951577c15465b322c1a47e8164e389494d677807cfa22a9095fe39e06ac28f39339ab52c611aa09ebf94b04c9c", + "slot": "12859142" + }, + { + "pubkey": "0xb5a0d603b9c2a992cfaf233796f9443c469031463bfd10ecc9f17cfe703ee237ea9ed28b1cb158c9314862b5c9a46ba0", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb013298faa78cfb2f4d026f3acf6897922b307d7128f598b5e9b22b8973e518d33a324635df4cde623592a0ed14786270dddb1e62ef4d8d80717de2ec26dd3b349c93e34c8c6dd5b4252d9b52fa1e0f095adce883294567ded209107891726bc", + "slot": "12859142" + }, + { + "pubkey": "0xb607789ecb07d68fff98a7a34cdcb870908163b450829952a96403726edb67089571e933e9f06590f91cf47e8db7fe49", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb55a83975db711760a7abafbd5b96fe6e511ce86be03bcc3a6cfd9dea71348604459d46208ef32b9db305341442cc49c09b7e5fef652a9bc59982ecd553a3f2c78a9efa7a0d959aafc24227449f891d62d65cab4d57e7a8c1b376b523d1df80a", + "slot": "12859142" + }, + { + "pubkey": "0x917d896de71a2f2f685818345cb7b3c157f7df6c3da14b8865b31fcc9f4738e49a3ec32caed77df458e675287816fe30", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x85cfea3a9ead20d25a10f847f30429156a2aeb04e874971a8c19743badebc9757493ec59d415c4dad24aae84fe6c936c10098253ea8f6dc55ba75b81569072cc49e63d4b0610ba73e5250439868f3f36b0105d9fed185bfc19cda159b66ee063", + "slot": "12859142" + }, + { + "pubkey": "0x921d0476c9b4427955ae346929334eb0e10fb047d56b2a50a43a286977a81fd897d0449873ce0ecb5145ce54cf137d71", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x989fdd83a1aa9b45bcfc48333df3493972f572a63ba8887bd4fecdf056168ef0f38606ab83384cc6e60b854de7631fda012292dc337c9afc63eeb53900f0af463cd0a6fa23c9fbffec3d955f41262c5f7877fc8c3541a6b4a8c22eef25d0145c", + "slot": "12859142" + }, + { + "pubkey": "0xa451df13194ac4b77675fb2ac2a2a3e717e3740fef9e7e76d1cce8d7954a1812831e4232f9e76026beb1aeefd5629f7e", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xae6a9e969d37dc4df5e7f69851c8a32a050963e250e7d15364f5e5645bf01bb77a137c53a96e42e7c8535ffe518598101154a1c4a3a2cbd91caa80d8dd85574c91bcde591e9cddf0beafe942ee93c6c4878698989af1c5771a9a4628f4fe053f", + "slot": "12859142" + }, + { + "pubkey": "0xb068d7b31728a8773ff6c16ab2ebb3ea30c2da98c6dd2ea4d675acd2c598e95c42c606d1e0b24fdcaba7b06f78950c2c", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xab68ff243eb8478a9b060231c529312fec777b5214ac647f29f8764e9456f55ee813a1ee8589bc0ed8f6ec22533df6c40902189491f5e3ecac7578dccc38e6206dfb9cbadef53a2e86e20aa9cd5ffa5eab1361abee02e9d3116a586e701f93d5", + "slot": "12859142" + }, + { + "pubkey": "0xb2dc98cee6251d9b351e3c6d5212f1c4bc2599d18c0d860f652907d5f71bfb6b04b85d37f90bedb7b0d6793ac22f6d3e", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8ccf0965072a70296c1c4b61d4a546dbdbd2d29c3e5083ffc2d70408f8e1c1f98335df4d9370807391dde9615bab79b7083a8fe1ce27b3960b0fb2798783c6b548551fc39825d40c5e3843398bc99380874662a874f3b0d3f6da948da01c54e3", + "slot": "12859142" + }, + { + "pubkey": "0x8e4e39140673da501bd6ef2a584c1cb04c07de89e1944f2f4113a86ca9afc1fb1b704a529bf0de857c15eac1a146c47c", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8def4bd1c4b8367cd5d22fcc45dcb920fef896c9162a3a71a65af0774f7e027fee5f9c13f37e6eea2c4e0c9a72d2a8cd0641931e6aa090e87c78fd536ba9da0d36764f4bca8c6265ac28e65d6e2183b4cd27b573841f9ffab1a7a6b76e9e3c63", + "slot": "12859142" + }, + { + "pubkey": "0x96c3598c08fae29983a0d50661265862978eb12fb213f4419fd460e6a911870b3e2af582bf871a341d4bb56a1bc5a38d", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xb168a6b50528b5f230aed4490963c2d216116072e8bec03c9efe2f80cff2bd6c79a629a1a4ced464fc0d8fe596e7e80518f415bd9847cfb344a81304b4071a18738c663c14dcd3791d0c2251152f5f8e58cd88ddda81d00551ddd9e54df0d8cc", + "slot": "12859142" + }, + { + "pubkey": "0xad5b21e88923607c08f4a5e2c26c5448ae30adb39159dd673d412d732034b76529b9d4bd13549bc79014f8b5b20d9627", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x83a028d1618357e086f149b5208c4a572440a66422b8b41d774165c3d67476d7d9ffc19da2a453a8871ad3798bf94306111b092601c1aabe47b6d059d55013e487d5d8bc8a1ef8a71deb1724928ac18c8a91bb6d5558e4bd7a6c227adc899af0", + "slot": "12859142" + }, + { + "pubkey": "0xb38dc305d5442072ba94bea97d3cd323c458e74fc9d9962089bbfb3f5ef64b21ac4b4422b80c29c1ff47470082a8586a", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x9110b2d7ffa0b0ae5344f8503091dc3a144b33f095ba9b59670b9da9b501cce679b2d3a4f5a6fb9210ad154f3d82c42d0d12f5fa5f121cc5c06c0e5f7a99bf30a2a1e223fd9410e7d09e6fd61dcc3a803b4d725a6c23a552e9b45914a0d03e39", + "slot": "12859142" + }, + { + "pubkey": "0x969ac7469d75c114c9620d29958b25476e3fbbeb7bc726f5ae7cae99dae36fd90f93f52b04520ad6b226a5b925e7d182", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa62196f6f71d6a56371204552b94dbaeeff35791617a65ecf35e56adb54561f7bec68e4ced730ab352ae75274b2f8dea1090393b37c5d56eaa569410997720bed58b31723d5c010287d5973f63afe9b85b15e115aede012bf99a66ad4425aba1", + "slot": "12859142" + }, + { + "pubkey": "0xb04b0ab2eae0ee43c5ca89a4d323a935ab9c80a0f0ad040a5e3250bdc84cf4f2fa549411e6666aba814dd4a5d77a9528", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x88c0d5450a37d84a868065e513d9df10c8d8e90a7df0edf53b7cfdec19d8010332f10c43043d23e65a9cba60255a20fa00a938ab50cb2a4450776837be82fd45cf13093fa486155e648d061ce7e6ac459d2b1a45b607b1fa9d64c2b21b0af73c", + "slot": "12859142" + }, + { + "pubkey": "0xb7a47f6c30346a3bdcfbcb6641e138438d4dc114fc71c39a04f1366a2106acbecc208d7ebc0cb511297c1990eae68731", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x824cb869dd22774dbe255421af08a1759d1f6474bf235795efd649de758f1ac94bdfd9dbe9eb5d861fb7cfb15b539fa80a3ee55996107fa53c3ec5b16582aceb310604c7887d092e8aec1534d4bad0a37ddc36dc428ec7b921cedd9eb15956cd", + "slot": "12859142" + }, + { + "pubkey": "0x90bacb33c7ba093b823d4290b6719d47c2851d19a86e478e4f32fccf75d388eafa56e30af317e1e845c0874365366b8f", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x8f2f396dc74ab02810a686d60c5370ff79aaf404479a3d188919fd6ac73b495758465b19051bef4708713231b1a69e84130b2b7b6f5d6c4532695049ca8bac4050be9e8704f4c6480c708056f0f9f4ef807ad446abe152d581b0cf8421a1a443", + "slot": "12859142" + }, + { + "pubkey": "0xa44f6144cd80f7bbd83945ebe54485dc61528471b4735fe30f0bf3cd9fa20317e75f33db525dce5ae7e3e2e0c2d4e0c9", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa62f0f1c3790ce98cd0fe4c27894ff09d9fbc095b8120e883c5cf8d03426c4197be18264eb4798cd3cd8531a7bc1f5ab1312d2fdfec84a4a2e5f24eda76fb6fef59d83d52665013c62baeb5ed416644d7da1e39f6dcdbc29e05203b8dead4253", + "slot": "12859142" + }, + { + "pubkey": "0x86784213af78815998069fb4403109dae479f6c4964b68a9f0198de1b314508a642069417a777fed48c55261c475e4a9", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0x81b07ff18a89d4d7e20f44cb64a66ed6e4524c745c3efb8ec30ecead3ee264cc5daee1a4f709cb5f7b755f673f25e50903fcb65e04c6cd02ff0d4c368fee46af4ca15e55d93d82382c50d43c71e8b97263e8d9ed7f90ce533fb07a35e819870f", + "slot": "12859142" + }, + { + "pubkey": "0xa1ee685bcd2516e194755d66e5599590c658d5f9961a7445e1b19e14d1b2c7fea1aa004edb3c452d6ab7b58e0b8f7879", + "withdrawal_credentials": "0x010000000000000000000000e1f4acc0affb36a805474e3b6ab786738c6900a2", + "amount": "32000000000", + "signature": "0xa6208f24fe76e1ae5ccd77f60e10808e41210eddfa629a28823ab4361757be58b7b4d2b3a23d9e5d0744d915093917c114af673af07fcbcb118da292926bd703ca100971612756192de96e06e98c1aeebbb4db1742b1fa420c7643f8c88014bf", + "slot": "12859142" + }, + { + "pubkey": "0xb4382274ebb76c55cac692092fba2af087f3a64f53cfee1614e450e25b119023c50749d4cb75c73d274626c5e00c1f3a", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x828c454eab5dacfd95a65f9c7b210f4a66df979181a6d6523d5e556e5beeced960416a99ef5b334a42d112406124a0570d52ef5bd984a528f54a0c5e9d582f697916800effc2f757f6cfc4899c553ef287ed09b15b3e4930d12ce35959e03c2f", + "slot": "12859147" + }, + { + "pubkey": "0xaeee8407e1f2127bfb1a776debef0fc50894c228d2d92257ee7c0b137bf68496fd7fd9f49669f9f01eb5d1bbcf743c79", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb0e2094abd165ff2251d9bd741ba728447e7291a54d006410583674b817fc95deae9e018d8ad4c933b667cb46139e70e1768152e654b1e48bab937e0746dbacaab1fc9f71e75349fc3169b7fe181bd67e0bc5df1ebb9dd7d4b21b9ce390b4b94", + "slot": "12859148" + }, + { + "pubkey": "0xb62e53e42e20078e5387ca6995fb14aa28a0bd72c9bb3f4b264a31a61ae4cd478d74707ddd9e21b6c5642863434f4280", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xa36635c348dff65466bf860bf5c1e7ad01a5ab79607c298c99763a933e37111eea11d222ef44d48d1c4c5e7eb6b1f206153cdf61bb0f60d48f0ce5883edf6da44d0efba8bc8e0ca2de389c6577fbca5c73323fb6f8ba89c48937646265352482", + "slot": "12859213" + }, + { + "pubkey": "0x80df62e16ad2a7af78722afc2fe2ee84bd5f3a6ad4b3d2d966bfc5007d9f3f6da6a57da835f02cad0c2c7244f4f250ae", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xaba8bb2ce86ef7d2579ae4d0a6d5b7ced83cf6b8c09d9e98236ff82e86881f4202a5b2d2404a3746e70badd633da32670d944d5de78ab8c4849396f7bb5154eae86193af023a9a112d180d5cb87cd9bd3fcf1c8c31a567db74b0f6288416f340", + "slot": "12859213" + }, + { + "pubkey": "0xad0de72360a14546f438d896d1518d5f781619fb750c462f2a267e239df24e55d0e98715c7338bda44cf1f4211afdf99", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x81888b541b6134be4a3a8b4c4d1e51e907159139f53a784dc4cf04b5b82f20074176cf616602a5da6f9bd1460e08f4640e8d3eb8efc0fb3a1de6190a9f007d1460c467b9198d91a6850e659168118b57cecd7697c3ea0ab2e779486c84c78714", + "slot": "12859213" + }, + { + "pubkey": "0xaa51227cfcb89beabb30d3705a48351be25ab00a9fdd5e5aab467406329cfcff0bf1f0c693c34a7b09fda29072ac08b2", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x966f17711354f449b417f5bbe4d996e3b48c8ec93eac1c3440e37fa29ec85a348185d077e456d6ff2de108d94b9df411067adbbebc7d182d27709d6f22d5f67fbcf3132ca7370c8b5aded9222e4985b5472c5f70515b8d37f826fe910f359c2d", + "slot": "12859213" + }, + { + "pubkey": "0xadeb30f5535f0105bdf6e9751251c504388dce74dcd568314609fce1a6c15114384f1b26567ddf01d9f8f0c615e6e262", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xb47d3850a10fbbd2e35790c8b27302f67bdee554ad1d976a346595f945414f2dae9a1f4a80aa9150fe9af8661e0fc78817957f97e57ca7a336b308559ab82020f67691ea54f6550f8d34682c34dd0811f2b3e40d69b47a9fc431334aabb9ec6c", + "slot": "12859213" + }, + { + "pubkey": "0x845dfd1e49b99a9f223cced61ec4588b97b8d2151795ee35d9dc9e1e67caaa89c7ba8bfd8461bd027ec1d728bde3ee9f", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xa6c07474c2432afaeca716b6a77cf2879b501d074e89023d8270086b7202fc72a4abbdc8e67f44cdf9ddeb59b4e6c91e0898c0885600f3b9656c64fa7b70a35ff485701812b986af0881e502e28dc5b1189715df756fcd200a59807fd7a83b1b", + "slot": "12859213" + }, + { + "pubkey": "0x8b60bb2b6d5ecd525ada8e22133b0af4e8871a3274d8abbca83e5d129cb1781e7e64f47e6d7d01b91fdd190ef761740b", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xafdf7a37e0f13fa77e55c8a17a5436d1874b7dd48c3fcf7e1bd0c07ccf712736a4b1da7de3bb736b0900c2f118c1dab0180ac13f67da9513d7bdaa906d0603f1155ac6af12f86affd3d8adc27fe905e840d6b645a74e90ad4a727213f17f5dba", + "slot": "12859213" + }, + { + "pubkey": "0xb20aa4e6d94e08ee06814bfe3e58bbdc41e2ce50d1ea7948b0ccf1cb9542f5c8a0e216f72559bf6eae3136cdb20ce236", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x93851d92691e061d5fdcb85fee2fb43a7f45c2bf8ee625d0ddcb4498fa51020a3c19eb6dad2113d3fbdde09447b78b73054988a31b782cc69091ae3de93c7d02f4b934a6fe57088a7611db8dc61ff45b0358d8eab9bab348f1ed2f3721760a7f", + "slot": "12859213" + }, + { + "pubkey": "0x93488959ae27cc39429f508df0f040849214b1262a6cc45aa9e5eb973b1ce11a3ec6d18c85e8dfe2a3de37034b7302bb", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xb0e8d61598ab056ac732b67fe25112d7cd092169ddf5a09235aaecc61c8ef777e3b5d937c4784ae208553894b04f795c18a007111e1f224df24016444963cf3a8bdc80ed5d99535a5237c13f08c9fc154313f3c4da7cc1b60dddcce678e7da47", + "slot": "12859213" + }, + { + "pubkey": "0xa9f23dbc6c9852578a65643fab5b0998c84f879992f29e8cf45ea62d60e81c7e8bc4886f0a5bb572e059344e52445025", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xb05a2d9099f62d3faee6a9ff8e6545bd42f9375557f8d8a08cab0713c3c760b4f21d15ab4727e9ceaa3cd08e1987b97b0df74411ecaee697880f12db55c534baae9200be90fbacb075f732d07101652c812b14db4bc7b2e5c1602d512e9f74d5", + "slot": "12859213" + }, + { + "pubkey": "0xb7b0bb466811880fecae1cec484d900c4aef9fea5de2ab0cc27010bf8c9fc1e8b9869f739678a546b53fd4512c77e151", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xb0691b5f1206e240a263130025b8666b1ea108a35232aa5618cf8b4c3bf127a149f0dda5931d6a8cc7d061c5c41f5f601431fd3a41822aebcd005c8b8588d98311800f20866cab0fe9c2fca31145beefc1dd0fa5d057347488994d32e98525e6", + "slot": "12859213" + }, + { + "pubkey": "0xa8a05498cc67411cbc50357e2339fcd9af957fece470ae47f582f12931db7f2ee0adbb7a07329027fc64f55c91ddb8c7", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x94536a7c671e8748d5bbbe3a8b9cb118811618e35911d0247f64f0dbf69b23e4a8eeed8035d6fbe660637d0d55f90b8e0dfcb22f0c7f6b08a1c4095b745067a821109a729912f56fb5de35f0f9ab8397fe7e500e8a3ba68b115ad05707a37c6c", + "slot": "12859213" + }, + { + "pubkey": "0xae1397b6409ba9736fa7f8e8b9cc985d51fa2f5729f76868d5748edefeaa5f58cfd8c3ae18fbfcbdf483993ce52a1671", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xa0ee87601541b052464cbca57d705c6b45e9f9f23bc27d495200b09389ddd34a7582eb507512579c7b359e98831568ba0240979e89445071905ee58cf067967407a0d32d1b021cd0be54c62aee09fb63fcebe33f39a02172aa2ca70b5d63aa1d", + "slot": "12859213" + }, + { + "pubkey": "0xac4b91deece91c41fa5d1b277f4a05356b3a663f0182599c68acffedc74d2660684a15a9a06af5db28080350033ca443", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0xb3f0ba5f37de3d7bfc135ddc78c3acd3236fe3f5434a29bfa90da051e7c1cfb9bbf403729ebd2ee410e7c08132c5a9140a8c28460a9114a359964de0df15157a1e78a1b5b29970c12bd1df2daa601ffcda35775c1edef1fd3961acfdfbd72b1b", + "slot": "12859213" + }, + { + "pubkey": "0x818ff5dd8ad0c6e6819f756b7c7a96a3a347125684b32869c59624085d4417a10e1d7c270551ecc73015bf80bf7a6b04", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x8e46a3f048aa48791fb5cf52880aec5a16cbba18ba1b333700f406b6e66481e5116f501d84f20c48a72647d4ea45e9cc04cbb84836cc31f8d0c70ad22550bcbff1a0685d4821966047fb75a60ec7bf74b2db6721e880f503def37e44fb6c5b87", + "slot": "12859213" + }, + { + "pubkey": "0x8e2a4eb81bff9d92e55c12f3767accd6df562c2b9caed946a29b85ad359fab8c5e2fac2395804c627a628e2f3cfd8fdb", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x862a1679b4a3b886e77ab1c6253eae188e15381d508190430a0c99466b4717968150b8a48f7529cc27e5ad5d7f5c23a9152c7f5ea6d9bc571470db30e589302d2820b7c94de3bc9d5780c09f2275af0ad6ec106dd31c252b7abcd1b94fd76892", + "slot": "12859213" + }, + { + "pubkey": "0x9007dc535c3a2b745a957612891ebb6bd56726d69acd373897fb85c35dc42fd9acb52c5046ba5cd4f589ec167f5b3d6d", + "withdrawal_credentials": "0x0100000000000000000000009cc337313a93e7029b7304f6f74ad99fd132a7ee", + "amount": "32000000000", + "signature": "0x87fd67797cd3308d92b94922eaa250ec36627b3800ec5129ace7f933de56dec5df2abf0a80c2520fc48d29037686e5e2177d01df1374d386b69327fe56221972d78f994b3aebed50701819a0714275dc58db37e81f6e4131ca5dedc8960c78dc", + "slot": "12859213" + }, + { + "pubkey": "0x8bcd955f5c0ff8327abdb23452a2796b92e9f5ebbfa2f28165d6e7473f2c4121e5780631824ad025d96136ad5905dbde", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xac349ee761f26f97c05652d08a99b4a846e46db782e261a86e8b0d120b4625b6c60b3469564976371edae59c8040aae004ad5ca0fcbdc25a2562d9e310d0936281c18058e821467e25c99e05514e055ecc4a3816000b34763ca344c4c67939bb", + "slot": "12859296" + }, + { + "pubkey": "0xb688fd0f48f1d034fdd535c5307512183d923d6def5daa0bf23edb1daa5f352ebfe207f8febb3850488bb4293976f073", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0xaff0c8b8ba702c23dc3c39abb7f62d516af7ac9af892458cf9bfaeb4ee970954a73fc6f767f57ff56a4154127f922e2b1462b81aff13eb91db12a506ca8981883fadac85eab529a9bf39c56b1c6a540a3cfa7a7b0735d0e5939798c2d51d7008", + "slot": "12859296" + }, + { + "pubkey": "0x95f78c87107cdaed890d260916083c8fd5b1f29599de98b4a4c64d28981c024eeaf5c7991faed22781cc4bb7bce2f735", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x82026ee886dbc9f5cd79246167e668ff6b6967f5b52c62bf74c8c31b7fb726fd8404d36f6c5ee73a2c60e21faadf7b331153dd4af455e527f3a0dbbda9a1af59b86d41d6b4950c14d8c0ef47f84ebdbc328521a95584449cba1e611562d0534a", + "slot": "12859305" + }, + { + "pubkey": "0xa819477698ca1a73022756ec39e4f45bebfef4b8349a67546be7c5e556b1834a87e6de9d5ad6c95088fdd49022f2ae0f", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x952d99f233cfc31850152a30b9fcaea65174183409f791cd8b1f0d54d104fc878fdbccb08fc8beec068041ec5b8a3bef16b7f70e8892fc24c375d89c66d8b29a983fb456b502ccfbfbcbf32ac78e980f3d789eddd528775912dec05f2699d5bd", + "slot": "12859308" + }, + { + "pubkey": "0x8bbe12beca5067a786549e072afcbc80183d3f50f214b6e551cb5b1d1529c4f11d8ea5aba7a3a4deb4bd8aa2e8741573", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x819c7bb72696f177e1a94ddd21980a1a45b92990a28660f1610a2c234e76e168cef4ecc6a15bd60342e72d3b56e0af1215aa30f07379fede12352480576a2e94edd46e113e15830c8f95ad7bbe4a22541377b917cddcf8c8c205b24bece33ae8", + "slot": "12859313" + }, + { + "pubkey": "0x90654dfd2b89b0d87ce21d07ac22698722a3fd8af880ca06ca83fd4b2b2c5b7844d040967cd0deb832bdef0985f77302", + "withdrawal_credentials": "0x010000000000000000000000b36e9d2786005235ba9036585ecd4c40174555f3", + "amount": "32000000000", + "signature": "0x95c882e8d4d95626379f4a4c5e8f6c41cb9a8abfb1de51277d07ebd483f84ad26663ec04234b6bed657e22a3b5bd6cd4182003879b851e2dbddcb87fb3244a97ca2a394251a7fe212d33e2dae3284419459aa49af589edf7d4d90c1482aef046", + "slot": "12859314" + }, + { + "pubkey": "0x9179546a8fee354d94c0ae7ecd51d89ffb76f16135fd65f2d24cc11ed7ebe94dc8546bc199d55abbd328410bb4bfb5c8", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb4f5fba2305a944dcea3ec91ffd114200e4ced7e0e34705f93f29bd6b8cd7a0d71b53332a4f7aa06d97847477df4d3c6017640196420a49cca561aa8cde307413d0ff433ddc511f9c05f8b87184b09266f2da5eceeac1ad7558b992efad4c40c", + "slot": "12859320" + }, + { + "pubkey": "0xb38fb13c1da3517bd8fc10cf70b1be924bb3f95b02317f7bc7658f8f97c6a2d13a88e25a5229ac7fd6421ffde4f0f8b5", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x87b7e14e96434bd43c0cffced9098726fe6c7dc6f3d618212f5742428c9aa01f9bba5701c9eefa399949a8cfb643027c0b9d536913d6b79e2031a587d6f045ea20fae6aa53a4a84be3d7e44444adbdc8228f1e4a471348cdfad2f7bd2851bd06", + "slot": "12859322" + }, + { + "pubkey": "0x89da0b4edf2e06c05345e021fb7cbebd747517428fdc67a5f668d40016594dce2f53a329d494c9402721fa8ce3d232cd", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x9978671939bcd6f268c614d6d4d57956954691242d93e990cfe1196a599af710ba43491c660fe3c21221e9ca6da2de9706d7bb919413a14fad2c04bf647e44ba29637e0985a3e02e1ed4e20f367c2a8ddeb8bec82de3b8a04ccf00d0d8ee1e7e", + "slot": "12859328" + }, + { + "pubkey": "0xa1d2370ff590644b7541c1a2346405a6dd20f602f2bf512ef727bb923f550e49b69ab4cf208b81abbe1329fc2b179fab", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x9276993e79b6858d8024dccba2b9c6610540b998db85a9ab09d9a75fa5f0da1c3dfeab4760d72670d914d9647bd1fa72014a632cfd1ef99f8659e29aa7e2854bc599f76fd7e1b35b48f8389dd4747e85091e7f165e419a863527f5768a0c7fe3", + "slot": "12859330" + }, + { + "pubkey": "0x8a39a3e4afd2c7693d61600f6ee7cb09767c7fe4ec63dfabdfde9a4d04033d57aa056c98fda79259562e96191ae97c50", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x91509b4a2d42d04a7811a272cce3e53058a95991b50bfabb38c7df39d00731723b949f7037962ca12bd361b5b21c2678050a8f47f4cd4680f052393bb17c0b0e9844013964f93be711fe79305d751e524df7ceb54bedadd41598edecbb93c816", + "slot": "12859337" + }, + { + "pubkey": "0xb39d01a32c442869730fb056e6c34d76252bcb4ad67d6e334396a00a724e915c21992446a96db694289173a3c5cbe589", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x802ce58896950a8c5f7e4890ce9205517518ac568b1ae68d80ec5f8201c72941fd141f0f7247f4c411fd6d7b8a898acf1405a70ef6ac5b8bfa326b86defae08c69c22eed5e6c04d3a70d30f59f141fe04192c2b5e57f98a73e086d4408b5ebb0", + "slot": "12859341" + }, + { + "pubkey": "0x9387f0d888d0b85da725829851b6627a0e4e8f79a9f4cb2e54e7e1e41986c81c854157c0107d1ae5c2329d83fc66c214", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0xa44db45e2036ed5ad8bd2192fda21b5c4c57a108152c4edf5540cb857414daa87855a78bcc90d8677a76253c775d071512fa25b70264f60b3c1ad2ad749cd3ec2e49d584efaf3e0bb51127b042b409a409e40ef1ada8e230e52ff901f72cabf0", + "slot": "12859345" + }, + { + "pubkey": "0x97a32fc7ee0c89ae1062b3cfcac89e6713bf3dea3a1cf0c702f004c5d9a3bb40ee4f9c6655653eab182d7e7b59cf6fd6", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x97c1e2f6aa6c4d157a169bfa404a0f5ea1a3a1cdd1ea320b23d09774103e1da4a0a3ad7e6a57e0129a3f8b4581e6390202d79f25fe2d1509dcd075c0dc7bf5f10d639296a6059e80e64d1adfe4f0a39e6c5adc1849d008e3a14d1eb2f8ddb241", + "slot": "12859352" + }, + { + "pubkey": "0x8c8498a55f3086cbc150d2643077050c1bdeec51a0d8ff7b9a6e430ba135b8675fc26babefbeed2d8dc575edf25b42b0", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x8cc73f7ba79d50f639be5341443f937cd3f1c61ae92b4d3c90e1210747b0ded334873c35ce395a51d842be63ef337d461051779f4d64f605202e66ab57c04cee47e9bfb5b291ad478315008e094d04eda947af581745bd03bbb127ff3ca0d1cd", + "slot": "12859354" + }, + { + "pubkey": "0x8788d4369b70d2b066ffaa54a8f830659010234a58076ccf95ae1bcf5a9e8787b85439061707fa49d93c91a894d9e33a", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x84b404d71d6b6c31a694dd3ca9d50cd07c6a4ae5c62c7576702244010379cc5802f1478820d3f847c1a2d2690d3493790a69ce38bc2ea39786157c9d93cd661fe5efd513b5b02db93822c14b47e342c5d63fa62a7d5a5dd425a145799b59445d", + "slot": "12859362" + }, + { + "pubkey": "0x99486b3009a73797c602ffb66b9466bc778d72c7c1c4086ba7278b398c88f441efddde9f2c64d11ab0b55fca216edabe", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xa7038dfd5569cad0c4f7e4944f7fc6fe371a7feb3d55e92e5d287a63a70e3bbd8645cf0ffbaa1184b11bb989fc3e5dc6100a3226b9825eb6cba88fe19cebfa28c22bb1eb694f1bbb59c668ea301058568f18a1b2ff649394945a8f282504c6e5", + "slot": "12859363" + }, + { + "pubkey": "0xaad8bd62f4cbd02cc19a72ff5db7b6df570f404f7d06c626c04e12e3ae293104bc60db55863e53b204a634529f20d0e1", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xad30026f4c27076b71fa721e937e7a6dbc704a4a7e6a98baa5ef9c0cb88cbf987df8e2194b727dc983fdfcc21525d5c113e1fc0b3a730690dffe3a28a74283a12f0089d6d49c1accf5403c206d933ab62d979b6721c0932ffeb8be2e7e6b9232", + "slot": "12859377" + }, + { + "pubkey": "0xa85ca5de77ff442c3fc6daec2ecedfa97092951cb6b8a9020e988494a3edbf9091a2b5edd9e8f7bd2ecafe03588c776d", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x84acd7040d299c1c682493f622c4332bfdff0dd6bf5f79f61d7c5ce5cb8be4d824c234d1315a62d1f72ce264411d2de10cc9d1c21df0e633a3212e3caa0e6ea44049a031467d0fa9d156d3bd203bf5e5be94550f943db7b0a9b9132f2bd12e49", + "slot": "12859388" + }, + { + "pubkey": "0xb9d44704956eec471555d827d9391f1bdc8813a2a9746dcb41349fc3b0b28373d983f43306756e13cbc928d05697843d", + "withdrawal_credentials": "0x010000000000000000000000973846d98a952745f2e2113a76c5d62ae04e1eaf", + "amount": "32000000000", + "signature": "0x8ead0899eb504211e8ab980fac6c464fab12529d5caade19bedc4495941acd500cbd77190c8e81adc4714d17e31544ed0f65b2a4f036f3e9a3da63faa3b5db3081753f4f8a749715fca5d660aeb71f239fa62504a6a6d4fc8cde2ac431daf024", + "slot": "12859404" + }, + { + "pubkey": "0x8ef3468f924a665540a2d731d7b68a6c55ea0c883542f8bf73cbae28fb67352b434f35f03e8bae26abfbc63a6b408e0d", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x8116d293dcc4f00dbe8b05151d14e314a786dc33a722f7dc27333d3a8e60bc0803827b324ca92db7bfd132865d4101a204740b72002bc159756c4abd37798bb220991dce4464384e45cb6c3b8fcad1caa7473dc0d1d6d0d6b758bb58698ee771", + "slot": "12859415" + }, + { + "pubkey": "0xac4a06350eb9c7a7561e3cb415e7515e3757b2070ac30f807ae3fdf03d670cf0ea1c3b76419252c81ecb0f4791db5401", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0xac9d1b284f1226b627aae6bc1a77ace2fafe616f1ecda6474f94c5ef4167d63702de7df62e582c9cb56251ddd5afc1f613a8ef5bf1e0923ab61346cd1ed1a8450bde898ace3352b49a5985bf7540d91ba75ecfbe09c8da1c531100ccbb15e920", + "slot": "12859415" + }, + { + "pubkey": "0xb7f66c8d461cb3a3c5fe63a8aae8ab59129851d5426aadc3e7915e91fb0d627dc386f1e4b3fff67ab71c15168e73a442", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x980c922329dd6f0d84428aa967c27beef5f27b57976c1a021aae20e162b8af714f542c13e77f48a2afb2cdb7107b3a080aa606832479c12ef4dd14df5b89797c49bf592fa933463a8f1a26451b98bedf3112fe3d416aa560f594baab67cf90ef", + "slot": "12859423" + }, + { + "pubkey": "0xa516b6ade3aff764c0a53197ce33c94af8bc6d36d220cec67af343db04485477a1e2f798c9e2165cb0d1bb76296c90f2", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb22086e0549b41119db8ea4535e0aa0633066d3f81823a9efd343688d443a6af8407536b41d6bf19840f7d6e29c14fb1133351cdf2c04c6187a157b7c39967ef88f43be0004ae6445192e012b36ac50051af8c4e94aa23159aea097d1c6849de", + "slot": "12859426" + }, + { + "pubkey": "0xb6e7ade2d43ccaf85080d5f0eba7c717067489fd880de533346db96e1894c6f3a12f5cc94f809bcb1af30e2cd16e8608", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x90e40ce7b60840067217a948657af7a9346ef527859a42fbf925251546fd726d2c479dee2f2d02a85a0b528ea52e488c04e3c0c6c256a4d90677210246444e4f505325aba79678a35a8b0f1c1d29facd9bc1bc7264a485ee523aab98ebc69cdf", + "slot": "12859431" + }, + { + "pubkey": "0x90728c5cfde54dbdfdf40c12442b142c5d4bcef91dfd323eea791908d0380137164a8b41ed5fdceb92eceb03e55f5737", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0x9600303097675cc0b0919a00d6c4c457b8d03657a23afc569e360cc97d6d109af8096e2c68e8b0a6e21dbd225b7d5f911343f1110d220c7be5254a2be758bb448a53e8bdc95a5be137c5362c8f40b2816230064c01361eafe09b5318eb87857e", + "slot": "12859432" + }, + { + "pubkey": "0x83e4f9b56221ff426616a38de24ccf0b63b4832881d2b45022d491e39e503332cdf151ca8bf7d45126b384568fe454c0", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0x97b32033b067c524c24f6f52dbee905658e12277a8c760964461d5c697aa7e4431ccc0a2cd7281ad7f968eb46383cd7913d6c496735c2744fc6e880619e8f0feb6b6dcae576bcf853d9df132b6170c3afb9e688cd669d99f28498566e3167c45", + "slot": "12859432" + }, + { + "pubkey": "0x80a531cddc88a206224065c87cdd9fe0b44297e9beaeaf9f448ec8e814d9c9bc7f0eac7c66fa102232a68a7ea6605d41", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0xb223d73d4c3c4a828742f8ba8eee7b7d5d8982b6e88ea3f09571d01e81338763e30c104e730fc3f1b70a29b7296b9de906db37af3113ba0bcf27e60497b5171022370c8a23626035112e4b50176611513da49e9b89d98e93704dc4e0d4c24fd2", + "slot": "12859432" + }, + { + "pubkey": "0x91caf45343d743d24beed50629d462dabe7ad7030b58bcc2944e3563ee037f4cd1011c3142d0dddd9264344046d0efe6", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0xb0c0a04dd40ffb00b353fe8af194fee91ea5fd3df13aa1cf06871ff98e9969a2ceb18b7a392b18492da027ec8fd0c97305a0c28f29c680c4b778c7a2a28234b960bf5a2b40ff10c6e6c023ae0758d86c565fe5d2a72c01b3b54c76e05beb192b", + "slot": "12859432" + }, + { + "pubkey": "0xb3ab537c6518cad80b594b8b5c702807b1301b6b0836422db3efbce8372cb70544b251b2040fc8ef18e1c7eb69663b83", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0x91cf634534c160460ca14a8e014e7f1ecb7f53f5eea33efa0cbd84f7cf4145b439b7259cbf1602b2280edf9cd89238fb090e605a339b30b57d1de0962b8d2c45dc075a8696ffc8b0679058a9294516aa64ffdaf9ee50ec9833c699caea3fc46c", + "slot": "12859432" + }, + { + "pubkey": "0xa861bf8363ccbc8faab40b5dc0d498804a85ca3701682ce9ad426ad1dde20bbe979eca9751270934276fd65cc28f0bf9", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0xaf921745f819704f4b2851e0336f5f807ab5eca80d538ab7748c9fdd1548ffdb9b4355ba4e0756d927c41db5200874c0121ff35e66d9e253409864ed7ffa11db471e2527e66a270c06b7bfb6505ec49427b024f2f99374c9eca148936639195d", + "slot": "12859432" + }, + { + "pubkey": "0x858f6dfae346bc4ad2f93192a4bfad8969129190ae6202c2fc83af0cc7ed3a44d8680f6697b1796ca042d6534ed762b2", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0x86c9208a310efdae0ec073028274e2f9d68a95a6022731d8c74b2bb095c206d4178fd51696d466ef7f80e774d8ca1bb6103afee019dc60c26e650ca89a7f46fa0e4c8360d8f64ebd56bfbd1592b18aee349b30e34e1238d0882e1ec7147328ed", + "slot": "12859432" + }, + { + "pubkey": "0x910b8302cc580eb669cdff26a7cedf239f8285cbae647830df364c26ffddabc20d9cb8df291fed86f5217732ad6b8e5e", + "withdrawal_credentials": "0x010000000000000000000000116ba62f273a0859b9b29bbe4adc310c749a5e9f", + "amount": "32000000000", + "signature": "0x8d6a14c1f7dc826c8a0382fdbc51c5d4710e1e075324e9ded1ba1ed3316dfe1795d4ee70ed91d8f307506265ac6bf5e0003427a3d36a279e15c619f8288e5abebec1d1193525ba1263b2e921d9782c335b4f5677b8d8fa37e6eafe91c93ed279", + "slot": "12859432" + }, + { + "pubkey": "0xb8217615f438bdcf7af2462eab5c38d76b5f7dca669ce4685eea96b903276df858b255340e8869e358d8e146720aab52", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x8eba1bf91ff1eae1de30cdaf2527f11edd55f266e37728bb963f2321ad8a44a5a2190a4db8411f562cb07d0bcf448b620350626202c18ac14c7bb01371526ceb59ca09e32ac01096f2230cc89612f8d877ba96b23647c83ab37b16e242c5de08", + "slot": "12859438" + }, + { + "pubkey": "0x9837a8c64934c7b42ca283f32a42eaef5b01e37d918d098dd949fb6eac237a8780d4d48bf65d222dc574edf48c928abe", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x9437d5c4a1f501ca33b53f1801c2b4b163a90fcc4aa1b6bd029a59337e65dd68a935faaf4751309295facf9754e24aa7197e8e809072c5f9ec88342cce52f34392fd29d63f2a58598dc77bff3fdd0f2602d15baaa8bb7f92cddf711b4ab6d795", + "slot": "12859439" + }, + { + "pubkey": "0xa050a289b0faaf5f0a786093f8ecd26e77f631673124bd6725596635b34e06c873020ccc2cc011d538346fca77169430", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0xa0786c07c19021a71dfd2135a612dedccff0b7b09a6f36eb30c23eae31bdd65c666d5e452ee316048f3de2adc8680f1511e9056209eab34cb77331d3fcb79b4e34f25ea684f971779b7d01943fc2b131557165f3ed30969b46ceb84581187f2b", + "slot": "12859446" + }, + { + "pubkey": "0xa616877c608d76ca346249065e12a89446380044ad4081b1496b7c1a6a7cc5a6e4c96d47e6deaa56f0cff5eb096515a3", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb5ea4835067f1848ef319044eab17d29fcf5546a87544b8528a3cf01e7c69b6b343a501be7ee66d12c2c4a0073fb41ba0d04e4131dd97f4c3144bb583d5a3fee81c2e3feb18c086da05e4ce08a97af0ed7101a1dc258705035a2150f26158c9b", + "slot": "12859450" + }, + { + "pubkey": "0x98fd00d99dc57ad3685627254f80fdac412f3bc36f6b30502643ab7b9e628631d867f3c9ee8e5d214c5506f07786b6ce", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x9866cdb3afa4d30047107bf68e593e09e540dcb8b4352712da34dbd9407bcbe9bcb69cd32b3b5fa47e7ba2b0330bdd7f085d14382db45a6b09cd24412682ddc95388ea9f3b9ef295f70ee831c0073df3f49751646b2cfef1182b17540fe1aef9", + "slot": "12859454" + }, + { + "pubkey": "0xa1644186479be5638da7e3c11a9da31adbd6a52070f4b02847ce44207d9a9869066e7806691b9920491f4b9c3ca10f85", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb2d100d5bd2a2d41599f00db0e3c78066722af77661d8a50292e8c9a3c1cb1a742263cb97ab9f5da2eed42cd413edb370593d71e131ca8163853ec8f77f53d46e4f233a17ec65ff7a1099919893ddaf62de571fcb3c0856c52232891a9df3d92", + "slot": "12859461" + }, + { + "pubkey": "0x87b1cc1997e2c3891fbbf1d30ccc1688ca112850c05345b76acf2fffba7be49ee9164fb42f8cc084fcacce073422fdc9", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0x9823f0c10b703b50b12395dabf53488bf4e1d4f379f4b32863ffc0d20ab8f139e9be691b2a7344f390f1281f44a618d3155740fba3d555f479f8a86dd9954ed388cad907f0ef730ea4b5fe89291dfc4962441eef03494c3557b5a6a3d165b8c8", + "slot": "12859462" + }, + { + "pubkey": "0x89310f3e288c40a949d42e3180b8efe47536c8408029f83557a53228089159a0e377ed9a38ee9d3c41fa3770be9dc332", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa1895cb4a0a4c51144b7d212fb1278d7cf27f3678fee6297843e951d5d0b283d07c11ba390ce07b64024b16af8cfe964181f41f48d058aa6dd9553b2ecd02a9b2bb8c6fe823dd0c7a6cc627ee6f21b3e7be718dd072c7c8c6588dc1b1f1bc2af", + "slot": "12859466" + }, + { + "pubkey": "0x90c804362d8ec6642c1030d2b7255e25867267cb595adc683913013be1e9d285bf3b80a5450c2a2a6a0143dd66aad37c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xac30a43d4fece022d79d6c4b8bf71ae506d2396993c3ca08a23721bdc24fc5fb1b9195e738e3c386b9d86ac071f69dd1046ce4df3e043587aa02d683ce48e40d9b4a23c8a940c71398de6d28344dfc602d12885bf1d2c98b09ac2aedd1168c27", + "slot": "12859466" + }, + { + "pubkey": "0xa0f93bb32f9ea67c1a0b917612548c7600df26625599eb0cd36a7f5eb7440bb0197093227628180e92bc55b9a2e645df", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa49c8382721dc11392295a6a5cb06671ffcd2f29ea47ec796601aa52f41f810a09f63e44755e2e937c902dcd23eabfcd0d7aecb2461b0fca769351d830fd37915b675fab2ed3e3d008ebe4ca2a44616ec3d464bc77d9440612d2cdda0c2b3c5d", + "slot": "12859466" + }, + { + "pubkey": "0xb291078b98132bc7e9be52157545e03c05aca19fc3a75ba640063c53f24f9b61026d1deef3e3e225998d6e77da67918a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xad9942c2bdff6bc0a68eeac3343150a7d4031a51ff8e1e899790d418c79d2d41a98b87649038501fc004b761afe53f6c0b29be13f5c7d1bd1fbf46eaf95180b21a334249c4c5d306775bfc341eafc82fffbcbe1bf78474bbf40892009ddbc425", + "slot": "12859466" + }, + { + "pubkey": "0xa57cdac4445c6f64cf1bb677c30e32189b3c7de2fe86a91abcc99741a9742e51ee2232f2ee6b3c5fb4029a92743b8367", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaba0e13b0ad2ea50d3aac07c2228349e7f6b833d9b3af095c7989a99ad97cc6e2be7920d2ef42e7960714431433bc135054e0b996227076544fced70f13944ffb3ca8b67a77128b7ad4c24f9b2292d7f4b642536d8b451c64d355264fc2411c3", + "slot": "12859466" + }, + { + "pubkey": "0x8aeb966e235566ce541f8ea5dd756e9752d2586e08f8c3cd42e988aaadf854ede2b8d56810ecd9fba210368dea5c3de4", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xab1af44d6fabc9089d97323e6cb03d3a79f0623e6e91efecbf029909e2d7c2a112897069edb97b1fa83459c4cb5bba7506d8c67899b591a59dd6197037a8d6373e00a324114ddcafc69020286c275fec4ac773900114728d377c4397b49ba7a0", + "slot": "12859466" + }, + { + "pubkey": "0xa9bae29d0a0212bb6fb8fff6091a70dd588d8390574b607a4d4963e74123904d9cfa58c8f92826bb53aed4acddc4168a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb5bc73a8adc05449f25ae3b1abbd7a322d02487a68a03e30789d687915ed43e79a0083bca8580af5ac508d2c561f9820081f8b7b05c14c14e506b529d18c3ed3aa196e27119fcd683800d4e16e2cd30963c6c7d852a039a1fddf7b40325f69c4", + "slot": "12859466" + }, + { + "pubkey": "0x96f88159e68a329760c1fc6499df024f28f7cd38816704f185be46f5e67be0cd3fc86c1913c3437b0d7c7e81262bad2c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x939592fc8c63de55b1a12f0fd5b30fac261d747a6bcead6b8e5c38345915e6657cde7d53d64e41be18b7837ad743c46701218d56a93f4e2d2e11a353bd70346d09e07a8a1fb4da8e67c26b34f3c2d3e95aa236c10857bc5e801d779ad13b0df7", + "slot": "12859466" + }, + { + "pubkey": "0xadcc640d89b68abf573f94a656e8bc1319bee03c7b3e2b26a5ad02a4db0e561ab5c5142e95d3505e3a393deefce86d2a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x94d20a793fafea3cab009102361f2837ff25a7b67db3e58ced3dbbe2ad982bfb9ee8cd322c1343d07fe4c6476ebf5da30a8840f8b5e433c3a46443a190242f557be02f7117f34f80d9e18cbe53a647141648fa0a885006c656692bf0dccafc79", + "slot": "12859466" + }, + { + "pubkey": "0x977bee7fab42e8a969513768e0ea1298b7c48a38f9a56996547deb5df4ec7eca102fd076b406c7b82aeb8e87d83e64a9", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x80d9438fa8cae66f3c4847201efa49668118d17ee855befa02c25fe7be174ae786db4a0541fe84e85c1ff27ff80224760636a87e47341634cabc547b9288059ba84844da167ee88e73cb964edc6cdf22f95b17a667a079b1142105ee17bb39de", + "slot": "12859466" + }, + { + "pubkey": "0x87f095f0797d2f47587ddaf340f124c99a6642dca9e15d27b5a7befb21b3feaf19f4a29adddde8af5d3a89db0d236b34", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8081320d99655ed79712af93cd59510fd8c737e1acc65dcde006018d79ad2d54b353cb4440f924ccb050f37371b6df2e19dcdff95ec4643bf1c9b4ee40b8b40f7480eb1a1fdd116e1c825dad02a9a31a6c1347766e7c19fdbdfe4fe12092ddc9", + "slot": "12859466" + }, + { + "pubkey": "0xb23be80803d66f4a26a6da2215fde1be659e27e2a5a755b4b2f60a93d8e969635faaacdc02fd1751c8a1fee73c7e8b90", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb01539e9225cc1ef0dd612bc44e770cb4be792be7f827c943497632a5840915d2f8bead4aa9dc5340a2fa94163bd2d7e194b3a8dd8d8dd95aacfbc5df1eb44191d54e9abd7fdc2bba71caafb0ce5c9a8afcfefaebdfc4edaf781e8ddf4173ba9", + "slot": "12859466" + }, + { + "pubkey": "0x96f48b024aaa2f0b1d28d6c8c32e0e65ba357777d18993cf58a6cb36923b82b72b8a9556bc3d13076c867344d9573eb7", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa74e381994e6c007e8218e31d312fe9abf3552f04217e1ef7f3ffd68d72b48f4a4bf1385f6aa826ba5592420d750732a0e247f31c975322ef1d1410765067ee5b4dbe770bdca387153d7ec8ed41a2f876e2a3773327adaa65710f87c27419ea9", + "slot": "12859466" + }, + { + "pubkey": "0xa546d455037f2348cf2f7bf28d30afc3cc15bdf22c5e20956feda60124387c2b7a3e0ce4f89dc2d0fc588d5f89549f34", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb8c47a55211dd2f7dc86f7c0fad1d3cdd4c450014663c1baa28195abdd0828e4a8a3f04cb960a1f8cb511d872066a2ff0b7fa537ba9b45ad4ff0f12d9bc1f835a6b5b12ec289487fc67e4ae1e3a52c38c9cffa70934caac9b5a4575caf901cf8", + "slot": "12859466" + }, + { + "pubkey": "0x88e8dc29f30180ea4daeaafc48d1685dafbc6f933bf594363309ed790f4ea5a0166dc66dc701ccbd936e6b4eb886ff62", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x95f009bf76c79c655f9172235613c53c4c427ebf115c790cedfd900f7eb28389c1c433000a73d1146e9c677c0d6ff10d0f66ba1f0b63cdda773542f6ca9d27de2bf6a3aaefe866f5fd18077f435b6a8a815560747003278c638da04b7cf73d40", + "slot": "12859466" + }, + { + "pubkey": "0x88a24dd41b56cd01b813d006b38330d44cf512b458bc1b05a306cbefa4ecd13506297302cd7a0fcfd7569cc42ea2ffcf", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8a9792595066e89dd11908d215cf273141aa3234b33f2413493e9379e7187ddb73a1c21f43ca1b1efbc3d870a47a0f020f26fda82e0800c33d774afaeec991ce4e1100da8c951ad6d1efa08df12a9c5bcc3710dc64f2c8faa9c87f4c846b1b52", + "slot": "12859466" + }, + { + "pubkey": "0x89bc9e4f77d94236f565b90c5a7d8cca2fe060ebe8a36f05549623cd5e73b84ed21bdec17f6252b54c28c0e83a015d6d", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xad5f831bd6c1af16fc4955051bfda0afb186746742a9c4235cf8446e7d4e0efd8c951f367d6a102ced3953fe9799ca7701a5635a91f4389a2fb27e4b2c70972d9b865d437ec0f7c23e8d21a1697ff2f851125a9383983f3ccd76500ff7716899", + "slot": "12859466" + }, + { + "pubkey": "0x87a626cc3092200f8d1db14a0b66b0a1a8f1bfe8f8d10660908accfc769a8f7b522e06472e2a4e4787e004a4bf905470", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaef85c95bbbc17a906c45ce32a97d4e6d762123bde686f29344f63ee065745afcb5d02ca6ed53d1b9fc0a036e01263e61251294c6e53b2fc797ddd9694269b65f9e29d5cdb6790fd57a7d9e8cf27910f1910f015ca1d9dbf35cb79ee75de9e29", + "slot": "12859466" + }, + { + "pubkey": "0xa58c6ac2bd9ed0fe351ba6d489f451ddf8098b201dbdbe3164314a2f5b37c3e5e812efa990f57864bbccff2881d47446", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb6a5789043fdd5b358a66843be5e7e58070c089e0a2840eb7c3ca8dc3c59096267f50fe2ba2c72c7dee56d293ba69a44016e9ad5f145e37be5b8bed1794949cfe95a0718b9d3af13054f391482eb78a10794ba18a670a8c97d6a87ad45fd0bab", + "slot": "12859466" + }, + { + "pubkey": "0xaf5766b8279073887268148a764badb75aaee3f4a8be97dade1ceba463c4d27fc9972734873b693f7eb35ce324cca1de", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x801c4465488b071471e1dc7997fe4712d63d4bbc5d6033abd290fe55846ce2c79cd5c8a2c142c3f3579fcba14e922ea90e1a3c66c97d2fe8f1ae3a98c27ea9bfd943062e64452ae2463af1e6f86ed2b91708918ee2f3f32683b662a1ebcc2953", + "slot": "12859466" + }, + { + "pubkey": "0x88e19e7976ad91382e757263284ed4ada65506d7db9528b2dd22fef82c1b8dd18497b56a2bf6be7ab07cd858e9b435ac", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8bbcf9807928b2e34687ccc041112e93d2e28d7b962fd5c321a396d74aeaea2ac1273cb8d455e242ebd981e275dbb114170371f33d77629330b47b142d656c33f8d5e139ce403355ed188ebcdc06229bbe577685e09edc0590f99c433f3013a1", + "slot": "12859466" + }, + { + "pubkey": "0xb4cf3462b02f30e5f1773d74b8cef5471597fce31237d4320a4f062348546a5659fe6be22b356255b476638d723a3825", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa595aa39b8890db89ae38fd66703c8f7793403309e2b573e044d1837a00c9f2616f9a28114425cc26a6db34e16c043e608e3661a2d116f7e810641d2c1d2ed605f599cc8488d314fd1eb0a5cc1363f7a3d0013d4dc9c0f38809a0937ff9576f7", + "slot": "12859466" + }, + { + "pubkey": "0xb82bcaeaa5c35714e47a5188ed0a17ba5d09a53b01568def0322a14a5a2634881d1470ff9ee4ae626a45f961b65e764e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb9d8537482dabb08472538e1030e7ad5d7db3b52a2b002f90974d62c1158d6b4d196e01d7ca52dadc7bcefb0a587d16d1892fabb5defe39985c41ab6688a8b581a2bf5bf626de3952f3e6fa19f77680dfc85da0d37e153f07b1035e96342b508", + "slot": "12859466" + }, + { + "pubkey": "0x82e234816553e7b306023047929fe261ba7df1b48a262c96ec07f1d4d28eaecc559b4de0b60be6069503b994022a7802", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x86622a7eb516fcc5e50e4c922622f02c891f69da0badd01129a589e8a964f4317d9783d2ba6515f954074aeed99ca78213051a3b0a388ed0787fc8a492f053a80e53c457a2c7385fb0419b63e4b2c56d0bff92e7d9761ffabbc358fd27f42cb0", + "slot": "12859466" + }, + { + "pubkey": "0xb49f75ac6215ac5d5df747301e8e5b3b3263670bdee18bc2143d05c46bac7a87f856147d23b04cd348b949b36c7e0a65", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa66cd0fbbd028dde44f96d5359d0fe80da0d64f05fb36be329f1c80c4ff4c20d3ce2b056aef426353376504914e5d78606dc304b0245cc5fc40f9a260e4708b5592f63f6e4769188aa4a819f79f29015bf4f3b860f12a908825636a9a7e325ac", + "slot": "12859466" + }, + { + "pubkey": "0x810b848de96454ddc71d4e7fab91f64b8446fa089e6e563fb321961088d42cd53c516e68be42d62dc93b498fabea80f0", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa919311d4edc3121c18723ec02e8be2cd7e39498b5fd968c30950cded2af916c71ec36cc98c4ed9b0d23662418d5622d0a674a79ba92dccc21a029bbc4b9f6e2cd19d63b919abc4f122c28efaee1eb63c91a086d186e0accbdbc5ac653787aec", + "slot": "12859466" + }, + { + "pubkey": "0xa887d4111e214a32dacbe33a88c66b8504e711d8fe05c89330fc7f2badb9064761f2210a31f3ddcb6d9ba0cdf9f56a6f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x9714ccfbba269d7f948665ea1096b7b68b7e626d7c2085ad58f5db46c0a83f57eee86e4571359c1392c4f8fb6f2e5b100fd3eb1396841f1f8531a14ede161fb79d8e4e26875e5dee36806aa2c812c26f2d1188470f2e122dda97fac761822770", + "slot": "12859466" + }, + { + "pubkey": "0xad22f46a9879370be49e2346183ef7333b28d37ce48bb5b6e03bf3354cdbef6eaefd63073c1616c6b8b515ea02ea52f4", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8c0fca0e3fa3ec2c9dd43a12fb14478cba13934547fc1afcb705818e7073fad1a8b6409173f82e6aa6e2886da65bfa3e07e6a832cd74e14b0385e57be8c088c410af04afeae61455d4e3a5552f4759fb576592193eecb47d4846d426201de475", + "slot": "12859466" + }, + { + "pubkey": "0xad82af4c9b772022ac3e1897741f45afd4b0e479be5701e0262fed714308af9f78437b0ba4e45612e3564275269962bd", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x897591214dac2649e434cd7c686580f2c385269694247455c585f02c7934b13fd004c30d0df86766eed56685fe70ea6c150ae0811730a441936177a1b31d29f5ddce999e9910ea89ca5bbd9ccb2332d1cf313f2b95e28e2c583a7a460abb2b03", + "slot": "12859466" + }, + { + "pubkey": "0x85bd72e371314464145af02adad33f294c3268e67e0a80e0d3cd58220708c08d17fbd699fcc2eaeb628853c64af529aa", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa5b346fe3e8685981baf1ca04def353741dbd7983f5be27a0eeb6b0a4a652c3f3350e32e649e7b8e2503348f5084c1230114c52c00256a9ebfe95632b9ad3e6cd6907f9a172520bb8453e97a2219747d0120503d3c3d805b80d3b36efc16f838", + "slot": "12859466" + }, + { + "pubkey": "0x8b6aa1163ebde0c98970212e3b06dd640e077fc62e3b4689d27b5e70a8a2be4660d4891a5e28e66664a63389e53e6560", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa2986653b01380248c48f1f50a3726916aae880950121fa7a852b09235de80f5324bead2c72533de6880686e0099adcf03abf8d5587fd4dbc1267c06e4d5547f525da2f5e2bb714cfebcb35b994bad90be8b4c05670ef2090a00b7a35fbfd36c", + "slot": "12859466" + }, + { + "pubkey": "0xac1500a9322d747d42b85c9b50e3db1389c9404bac7e912e732542cfb0621d5a8da0388c682909adc58a44e47546608e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8a6b505289f44ac58a05bf8a6c13342db5822c82e4e20c0890336f100a5e0cb0988cf596a94334a28f068a6dd5ecd83a139983da023a236eeeebdd0131588441f687bcbd01881765a6f1e7ccc0cd22089003504eb91e2cbbc9a34e3465966305", + "slot": "12859466" + }, + { + "pubkey": "0x929acbba9c524876808d1ad7275d87a85a49bceea599de8b799dc7fb854b481258d78ac804f514fda41f8aa3c2c992f8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa18c848732498d1153a1a9d82f20f88138c8760a0b4c326abb74afea326e8d69b27f672ef5719b308cef8e0c78b5d31d0796ae378145ab38f253dfda161226a72209fd7e1f9a7e7cf384131cdecb81c1e8cff241de7d46bdd6514433066f2d3e", + "slot": "12859466" + }, + { + "pubkey": "0xa19c99344c3855dcb448e43328ae06a40849cc2f914f9c29a7800dbab4880a419421da90ac0495f5f6d89a2bf8b62900", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x847790abaf6703f9790610c15027879a8b397c848f58993e2dc44cf8a7ddfc08ab210bda4a6dbc6f524fe4e7b351eb24179e2a7d079e2a56316447c15abacc62327b76e211c50911732164e8eb17c2d83f50c2b3cbba3a746d713efd33e6fcc3", + "slot": "12859466" + }, + { + "pubkey": "0xb9eed8949f83b79017011e3624ff975dff6d427644483560860c5d0d78ad267e0c4ff09d96245e7fb753e9e4280b9cb4", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa1c6c0444fca10d0f694b728e8ade542788bac47e389bbd5392cff67d467eff4d1cae760bee940c8a481d904c51ababb066138de7e75b79de3bb79c57d64b668bfd12ca8b91c80d37c8b2b0641e35ef5e686177cfeb1bb951aadd5a441bb3d84", + "slot": "12859466" + }, + { + "pubkey": "0xb6248c5ef9bc2bf727241452166bfa76583877f629c4e10be1096bb3da2b2b7e71f41454d7f723a54445ddaf001f3e38", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x93358544b4806331df90cfb9162d56dad54a061d47af6ac0e3ce50f3cb538d7d5ffa938fc6eb15dffb5160afd6c4011400ddee6a9e1407086a71aadd6c9a7fb02aaffdde063f401f68cb898623a3c4975c270a07d69a3b10b73805a4eebf5058", + "slot": "12859466" + }, + { + "pubkey": "0xa1c2c5deaf36a29eb1725b7435754200b2c7316e7b9bb12435bd47513a74fba8b9e9357e9ccbecb5a0a9623886fe3bd4", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xafb09f5df403e01a111fe36c95477c51602dfac17543bf737a17dd70ba6390d2e17a3f75f91801dff3e5268153799e631077599755af25eccd5292072adef0220ca42a4a710250c83a60dca4fce6ba978dcc45275bbb641d062f45b0aef46005", + "slot": "12859466" + }, + { + "pubkey": "0xad1eeb428f7a2a04af5f55964e0c7ccfe0d9845c5fd7b33ce0e8a24485098b0db65254e8f73c08a86129c046332f5cc3", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb13f7971438ee341f8fbd27338d42076660edcf7237bd082418d8de7f3845fb0119a0f882b90847fd7ee0ab8df17be300c01edb29e97f9f1da35b857fda45b31ff81644e1da6d17ec2ff7dd2b0d5ca7d912175cf9465ce2eebd496398524f546", + "slot": "12859466" + }, + { + "pubkey": "0xa4b438025f6def42ffbd2edba04020bf4b79053a40870b397fc9c40707fe8079a771808a57ed9c1ad102a8df67743425", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb76d44cefe820ccccfa5a10ae419a1ac6d36ab68e26829e32c52f032763653f830296fb43ade78d794ca1a29b5eee37d0d702ff1aa7a133390dee3bc03b0e822e2e7f447e9dd254fe9634e569b27f2620b276fa6db945ba9b84e871710ad071d", + "slot": "12859466" + }, + { + "pubkey": "0x8b226ac3ec6e839bbe01d9f6596f585a203b64215b1c7082d87e38018325cd9601eaa513466ef8faf0721c3f50219dfb", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa835ea7d78e6fecf6367627aa7bf9155c5e76ffe76e3d3c507b4d930e16e465036940c301f4f27486349168b78d494d911a63939e9c8ed6b4914ae8a2d76b4843376abeae05ea7b4cc390bc6e7894b22049bc2c3b537366a34e3e28d104b7dae", + "slot": "12859466" + }, + { + "pubkey": "0xa03470d0076dfd309e6a80b8dc8991bc55a38dfffe147e62047a5a8686cb04411184024ae335fb31ff4c81a3c05dbd84", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa2f578b177e323b9595a636e5d825c3ffa6ee1e67a600b02d67fb8fec384759a9af1af0aef5ad7ff2f6bf42fa05708ba171b3fcb033ff316cba1442c922ea2314b5297aa5f2529308e45629cb16f54397b519716b7e38d4188ed1aea5386264e", + "slot": "12859466" + }, + { + "pubkey": "0xafaa2a2fb6c391d4815146325d138f87823a25d3915a94ee1cf35513afb39be5b712636218d9c18b87056b0d188d8c13", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xac40a450ac1727d647387498df2498bb6708ed94b34d3e478775494937267540d3c777169e6fa4b7380a7db134b38b8911b34b07a3c7f1032463e115cb096496767a8cd8fa4017ee8fece2885c44dcee0b351715a57ac0fb677546ab7b53e4fb", + "slot": "12859466" + }, + { + "pubkey": "0xb7fe762d426e309143798b57ebf542841759cb41742134f2629503ce65dd4996776a3874a4fe0f243ad698c600a35014", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xafda58299282c6199263cf9a6575c32ec2a12ec19d05b0fadc32a2ebbe0045db738075867cd296530278c3df7a5165a51830db5123770604fef4266c1e6ed649ae1e3d66115b6ea51eba08d6397db6fd69f2285bfb0f60402396981653086c93", + "slot": "12859466" + }, + { + "pubkey": "0x95421d73cd65f0523ce411af4c9d9b689e33ebc10b5cdac93ef9113badb5b11be8dcdb1d1dbf24de979c62d407b6b1d9", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8f09ec0a37fff2a28607be665af70043f23904ccebf93c66590bdb45dd00175dd77e04c23ababe0cf68af243ce01bbbd0cfd6a97d5d53dc8a6ac53188db0faceb402c7de051cec34944153be00303c773c8de3820e2e495dc8b6157c1bfef3a9", + "slot": "12859466" + }, + { + "pubkey": "0xa3432a80b08390ae07bb10e66f27d776258a02db96ba90ac624470bae0b4ea8b7e1d17cb622364c2dcbf534a7d562305", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xae6f4c3d340ab78e3f90abfe963d7dd31d5f152bf75229a1a629e44cd00d7c31b739195530fb2afd047e1636457c1d2b100a9ab374bf4dcbf7d0ef9dc67dd964f24751c23bd506d83db93995d5de5d9204604ba950ff47bffebb52f0b5d3163b", + "slot": "12859466" + }, + { + "pubkey": "0x92bddb29315a6aa269e028e6e79a6655e2c716689f4503bc852d2ad2c5e1929587b0307cf0da7756e778c61ccb2540c5", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xae8a3671df47ee8742d06aaf2482b9051068ccd01c86e7e616509b20317480db0bf52d4eaff9bf52039bb7a1cba90573140d4c274947e587df8ff35c93eedc2b62b34facac6b931af7bb2edde3b7c21e0923471e52f3d828e7ca07e1a5dc8fc5", + "slot": "12859466" + }, + { + "pubkey": "0xac48acd57d2ba4307deef1259cb0138c6a382dea4f6fd7f6947f7a952c7c280a3b53e4b35d08e8adf8894f9be1d611a2", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaecc4025906cc77689ca1c8a04f1bfd50e5dcc41f03830be7a196af4c2a0f979646fb638b6832433d9664cde8a19a10b0a31d130b4be364d0aa2b350ecb83a9a307d8e0cbfa812b9fecfd4805fd242e5146e6f2bc08d27cf1596ff8787c2506d", + "slot": "12859466" + }, + { + "pubkey": "0x8fbfad913e56c63f01aa55a7448ecdf48e1955b48bade927f46482d1f9414878705142ed63fa25f4a7275f0e1d6cbb67", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb0076d2d5a91ae7b8a6da883b6fa66e0b90f102748eb6d4b2ee08a8d9f8ea2c68e48f81498c8b3c72a53ab949706a695142977b0e7b6e8eb14f1887411a76e540b1f853a35c88534f7ee3343983e7921bda9d93240dbe63779b03cc9d3b631d9", + "slot": "12859466" + }, + { + "pubkey": "0x833fe3be25132a3a1a94f035ea19ce2dc72697d892301c633d672b225dbc18b8f78106b4cacd531c8527a55068bfbfb3", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x90fb31404f2794996a7845f4d3b14ea54e67e952500f98d0a510d9e15e9e1282bcb821f8b4dd8c4982567c875c4529790e8c455d2bd48d1a248e937269a9708d99c61669884c9a9c4acde9801808c80e770f41f955294895303444a1b9f5083d", + "slot": "12859466" + }, + { + "pubkey": "0x84d2a53fc0ba55b6865d5022aa785735f98ac408cd639d881dd6a80c961a10740bc910c7a8fd80d832eb61e4ea885200", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x84561cd96e782e401995bb2b6632a63a14fb233f11fe3a511f29ef121846eaf674af92b14c3dcbbe6ccf72100fde64bd0cd9affaf7e6370685aae659aaf3197f78d95aee255e42ee9ced433a74e4e2a31128d6c519df3c9ccd660bc206954778", + "slot": "12859466" + }, + { + "pubkey": "0xa5e3279efb648000daf005c0e33e1f6932b462e715214c1c919823c08142549855ce886304292b5627a9ae9ee3374694", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0xa0e52398f6ac123d1e3e6fe4334a78e91f90daa6c25c31abada40bf1ceb6645e32dc7e4e2b1a3d6daf1e4c778e7c079604bbd103bd2826ea06db22ca47a12e36d73d91a1f5b51da2200dce946532a4c5aeb3d241cb8f3f6bfa077e7438ee3029", + "slot": "12859471" + }, + { + "pubkey": "0xb42de09040ed584263409cb84442dc88bfe9cca00500520dd79f055a58049fcf9a3a9c052b6f0c0b73163d2ec43000e9", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x80cd220ac1ef490d17a42df9e9d277fa13af7f5ae8a9a61f844660ea3f6143d9b767abeeac94bcff1bb764f66ddb5b7c0984da13bd3e1071fadbeca217efb4f874a14fb6cb6cba20e1eb5b6e38c27cab4e3ec6aae66d8f484f0119d1ca780490", + "slot": "12859472" + }, + { + "pubkey": "0xb596162560b978f0282a1f9bfd10f61c0cb07dab0f0faead8c7fde2397aede2e2d96447d8c2744dc9cee2de4b2e877ce", + "withdrawal_credentials": "0x010000000000000000000000c494588aa736c18bb1e48204bf635d3519782f57", + "amount": "32000000000", + "signature": "0xad458cc60e89dac260c0bd40290a0369477a4356beeeb66d56023f35fa2f2eab9f18247e1317f802f60053de469f276d0b9ec5eab700ae638c8c0aabc9da5a072c1f86c21c5f68697fd4b13828f7711b4b75bbba0305a4898369b807807eb480", + "slot": "12859478" + }, + { + "pubkey": "0x8bc439b5c1dbbdedf2a58090d8a3a9cc51e9c72cff593579287e964bf0b63a3fc9a699eb8ad99c656802737a83013f9b", + "withdrawal_credentials": "0x0100000000000000000000001bb8a806cba4ac0a4e4ed89008180e055dd0098d", + "amount": "32000000000", + "signature": "0xab2b4b94492056f82a080722779fe4cf7c9a6d56d3367be8066b52107e61293d462eca90ab34648a3d5d9a830aeeca8104929cbefef856498c9552e8b7a69c24485483ddd7453b52c36325ca2cdc83819b62a1e6ea6d9bc5e67fb128ac08496e", + "slot": "12859482" + }, + { + "pubkey": "0xa1ea259535032deca4281f79deb7e0d3da62adc4ce8be7e76d890028357472179e58d956f7a732585c99b9df65759b89", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0x8c220029c4211e100ac54db579a0c6f135d9e162f80fe6e1753e4593f6a69e69cf8e170e6f835d4a203aeb0403e530cb130dcd7e688440266a743ad927edf32c777bf40870fa3aadda2f951a990c9b696020363fbeacfed1564d0d1fb62acde1", + "slot": "12859484" + }, + { + "pubkey": "0xb05dd03feab18f682df5777a3bf1641300b28e02c347b38e61bbb770404903bc6fdf02ee1fde9f9c09f5de4d772eb3a5", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb95da73f4046cd81f36c8845d7214174faefc3e63fa2d4bc28be66b9b7b740faacb7c431e7bfdcdc63fc495f591f2d4700d6032a366808d4c8061bbc70b093ad611572571c763a481abc780c3cc28090e591871a02b401584c3a37ebb75d3602", + "slot": "12859496" + }, + { + "pubkey": "0x9751d1eb52d2f05c5921fa9bcaff9c192ffeeac2cd98f788900717166141eb21841ad049e7ab36d13a8314c1c63cbd5a", + "withdrawal_credentials": "0x0100000000000000000000001135fa96848f34bff9d003f4c1699ae97418de29", + "amount": "32000000000", + "signature": "0xb52bbc0de1453fd429e67bf1d9ea0793fc0f8730c71f027af4282bde1da5e8f7ec8c9ee782fd84eb233ea3dc9e9311840e813243645221b6da3a4033b4d28f04b79c073b1fd077417b69559b2b928fd461b51d421fb036b2423bb5cab2a6758b", + "slot": "12859508" + }, + { + "pubkey": "0xaf8b53b3403158497c3ec39c298f0d049fb309bae9b2a3f58bfab8b36ee568867f24b9f03e87d1e24157d06b2b29918c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa674b35599fcc1fe50ab94b07dfde8c3ad70ba7debe8ca0d6eba31a3e60372406dc7416d020bb60a380c78517a65ef9313077af8a5dc46f14b1d5e0a7f3593c1512d5ac475b167050a656ebcc78b37bfe67342719847b1ecb80191c82b6806ce", + "slot": "12859561" + }, + { + "pubkey": "0x97d6664ef7a9ded3e1ae3ef1c101d807ffc65e005f6ab72f541d112ddb0b9bb43e7232c60eb6d39b8f8a2420f0d6d084", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa91e5894959874de26056eb69af2d61db598dbb09c730468a7fb1b86194af2bd34d628b2d66f79910820084d3276c371106cc7cd3bf96a442c33b66f959240715a5acac5d53f50f144217165dacc83a3fc642f834bbf4d3df1e1c58d972a539b", + "slot": "12859561" + }, + { + "pubkey": "0x864b417c9066e7781624b865a3f45b3541fb968f9742250b679d0fae397133e083eb0b419859a72775a3bdddb2fe258e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x943628fd59c98d7dc3e272bfbda5218ddd8007461f448923220a444a5fa282ebb6a030184d9178d4623c3603748225c212b7b9880fa21d635634aab1236f60fb8d16ecd4122604c5a8078a09e9d1fa00930205aa2028be6ffdf40fb0a2450cad", + "slot": "12859561" + }, + { + "pubkey": "0xa38608bbc93009d76183c733fb858d6e90f35c0a21f9b193e667980221a36307d022dcf87b2923888c276612092705d8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x949f48687a97c8cd0b7de28207a21dcf06141b05936db3c676f98dc87a3055ad0a242ceec56aa77ed5ae3bda791d3cb9128f2fe51286cde1da04172a6642891cd8e4eb28cb38621b25f72f8b72e73f039385fb55dfe464f536fa9f7fb62e972f", + "slot": "12859561" + }, + { + "pubkey": "0x8e527190f15db6d7ee527b9974bf29f753e6f5e4399594a83d85095aea1582b3f045ab6e0a046f4e9a709e6c6d37ef2e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x926c992749b5517727ec2e747d487ded220f43340c2f702e07f1f0ee91f5403524bcb85724c57b746f3b36d63fe1f19f0c0335543f4108740847b2008194917e2e630c7f67fd2a127f81043489a088ec78fbb59f78e143de698d60fc93c4a38c", + "slot": "12859561" + }, + { + "pubkey": "0x816f74928ed04e9f78d83256e427f7969309ee34efd02495e4440387641986ae77131f2f85acc62e0f206c4a0d4d94d2", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa971119219fabd1fd4762231bc7bdbcda9605f2f4714b3318fb437b14fc4239997110f3274a512702985d81636cc1d3a0b4f7de5719e795e04fd85b8bda6f801caef139a391d767715db78ab068e232acca9dba5cfd02ecdaa84490029963446", + "slot": "12859561" + }, + { + "pubkey": "0xa727234646a1f2a2f8f366f3d7186a80b14683dd26594b150b761ad85c815684f7a6227e6aa17e5d0fb26ec359d9e159", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb16705515ac4747d08523cd83f9a96efef8501fb737a644c6477c8d7dbf96eba068a7e3b404f894bd1607860e59010761791353a4aed5e2d3dd382437fe6f24bf0d8649d339e198a8bb7b758c4246fe6145de20537f89d7071ab2070cacdf2a0", + "slot": "12859561" + }, + { + "pubkey": "0xaa279d7341ab3124266d7ce8639828178523ce7bc3bd32dfbd7399354e16ab90b05d94768fbc03d3125588af9522d2bd", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8e68a08f418c1c625dbe30d9fbb637d0d140e6e29b3f6acf5ffb610d577d74ba3aab9bf3ba16be1cfdac276a922091730c42d621f2b43ad54098f25519f465b1dd4166951e83f33c72b018b557d0f9c53efa05628ca44e8d6cbabd573472ad01", + "slot": "12859561" + }, + { + "pubkey": "0x9697cb04f5527653a3fed3d64aaae6cc73effbf4cb9cd8009909923fcf9213ef961059078e53d7f1b889b809e5dc3abc", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xac48a1b79476f509617d9329049957f27c4f8d64c53992d2cf630660d9cc744e203d378d2b81df4e131f6e64936dec7b084d831811d13ad7be37a1ff214989a86726e6330e898e6b389f9cabdfddafa91d8d3be0759db408ea6a7a39720a7fcb", + "slot": "12859561" + }, + { + "pubkey": "0x949e51ed47ef811dd68880d12b1a4b03784c7dd2628b9ceffab44a48e9640bedc46a16141d2eb534a46d5e340160933b", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa893eba9968e7c923833b927075563b6617df43e04d1d2597bcfee2b8a9b919cdb3bad473b0cc1421319c81fafc34dcf14438af1c8de8b8b22ab08476fbfcf74ea9a5f87a60155ec04fea0d4f87a39f54568db2aa508b050a9f1c0cb5c9ddbe4", + "slot": "12859561" + }, + { + "pubkey": "0x8a76f3bda58e44d75cb1a5d41fa6696c71930fbb14ef127422e591288f21313bc44cf3017509e26ec14ede5b99006030", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa9184e8c8d9c49f4e8050133dd9a44208d0fde8842e500f0133ba567e1349340f30cac093d7184601206b254eb74e89c0021f754d3405d488323920692a3f56835491c77012e9b7e993833295277b01ced6db524c87e45d21c9a6cf04557d121", + "slot": "12859561" + }, + { + "pubkey": "0x94f62accde956fd36bbae8cde87a7013982808a226403baf6720604351691e1c197277daecb26ba1d4cc4fb96a1f6180", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaacaa3a8afa86cafecd8d9538871ed8d564e870185e0473e1128b471970f33821466f67ecb242f2b6b726c1315791a1012f38eacc8caae988ecb24189710925ffad26645b52268a0a509b367b23e4833d569f3dfab4d796f2db46ca084f513e4", + "slot": "12859561" + }, + { + "pubkey": "0x983f06b4d9b8e9e1981e7c030d87c2248f81cafe06212ff5936c55d69a07a7557dfa3e65dc7d6fdd82de2db100406335", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xab91173fbf7fb766c8925453e512bb718b798b6147d7edd064ce94d7917d57ed0bcf634ed5cc7b7347bdeda7619d9cd005074c60097e748c29648d373ae95fa2cac82ca20b978747c2752e1564af5212ec6cd2a589d78008af1f91f8ad7b3fb4", + "slot": "12859561" + }, + { + "pubkey": "0xaf4c70aba87d97b44fecd94f0448e8f6aca9ebb59788a4d704d6350b4e3f2d39a13f46b71d7353af1c05314048b39fa2", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa7a663925f1bd2795f6c7f96293179e8acd49ebdffe5378b9b4b0f85462490f6a38a42ad90b20666f3bd20ff2f5a01f311a8996ac1ed38ac2f123e677d52229651e20331ab1f2c6f5f167dc0bd8a87a0c850307871d99c0352770de4c1dc39dc", + "slot": "12859561" + }, + { + "pubkey": "0x8d1a1c5fab45550ba28ca984c11c6502d70a001a534f92a2ccf0b1266650dc8ff2617a6bb2bfbd0228b80c118274432a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa345a433696e26c2c1653216fca3eaca843e51e4114a96e0e8a7992352dc65abe9c11226d68b2f2e0919eb896538e4320c3b5c936072c010d1fa21ece777ea37eeab510c0a35654563b6ea3f8bb0dbe101054007e1cd9dcf56423a15d6f48c9b", + "slot": "12859561" + }, + { + "pubkey": "0x99ac3d70b13f1ffbd5dd78194b9f5f32416bc6177fe19bdfa3ccb5baca78a4c90d56c826122c2b177e2de370cc08cf48", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb97c0427c8bcd90d319d2c8e3fd2bbcd273cb060e421eb2c5d8ba121a3fd2239b186f173ffd0e66ce18a0f4c4582c3260c4268707225957f70ef93f6116db3a61af5c487e7f7056a10aa02b5ff15c729e8dc33eafddf734c5f79a01ce0dd188f", + "slot": "12859561" + }, + { + "pubkey": "0xabb35784fd7538d470c4c28d9b9c668aae2dd74431f2629b009ddba6375a56ab4969b7de346ff39fd02b6fd4944e490a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xae74526704361916b342b6bd7b06adac42207b1999801dcf6e13f6889936d1bf577067b454cd64b90449d61e5f520ee808a19b4a14ee3051cb997e97e305f7727767d1838f3fdd98983c34e3959e8d6816e72f43c443efe1de3e062138998287", + "slot": "12859561" + }, + { + "pubkey": "0xb0dca787ee8911661039e98b7d70c6d0468104ca6a3c07ec03e71f542a4912d382167c5af1bb9c3e982012c319a1c7e8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa178781e1e939addaf681074d3e3abcca642f1558e7451d0cbd2e1ad20e7cbca3f5ac74d72bde1f3f8c02bff1d1cf1f218aae5bd91e893927d70a1c074331bf67183d99b10b32adadc2ba8219f666c2cb72bb91a3795d591ffcf5d85461606f1", + "slot": "12859561" + }, + { + "pubkey": "0xa5a6bec6f6e78abf587ee567052c9a04c5e97eb7468d7de92af7deda95338731edaf0ce02e72360825e95c4c73ee2f4a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x97c445f8d799a74f31980739f7133844951253c96bc222c79adc70d2a501ab65aa03b32f65fe2bf1691d2554f8ad8a9b018694a600178cd71e33ed1744da72adee1ed618ac31825e7c314079b056ac7c3fab6d33ef5719566aad62c9cee2ed3d", + "slot": "12859561" + }, + { + "pubkey": "0x90d918e1db59ef6cec132ddc042861a937e58db72a9c3de97ac4589b467da65faa07cbfbea6754fe60994c4355f45164", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x90d05e9b2331ca75e29ca63c14a840b12d74e345234cde0e0046c2f7193156699be72b8c496264e60a8bb64ea6926d3718656a6d1c8a23bf86fd37d11073d916c82cc3cf9facadc6d9ada199b0c0dde1cd6b8ecb51898cb83a1a40159e935903", + "slot": "12859561" + }, + { + "pubkey": "0xb6e305a7cea2852518c3671e962177e13768dc4a40f4c8d1ed36a024b305837816e48cc1f179c7ea09d464e90ee84022", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8b9f22f323770fa4844e86f5360b17620c0f08ddbbe81ec389da8dd0333e556002f0dfe06abd4a5aade1d51e1539483603d07b029a2333c7e49f249f437c0629b479140216dc2c7f755ddb3f138cee463192ed3b9cd4561795ecff0a97f87a61", + "slot": "12859561" + }, + { + "pubkey": "0x8112e32ecd4ae5c549f506bc10ca87ea8559adcb3abc8e93dcf936631b14c28300dc55f2e6cc07b5ea0d1118f78a965e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb01897e359e56c6a762fefb018dcd2663162bc9652e3a97f449f9e9dd1a4dcffd56b4b826f28e35a8f1d25bc959f11a319ed47edfc29c2e6458558b49f8bd214651f00f4551acd855f9b790163408d6ef4153d66bf943845746b917dfb7637f5", + "slot": "12859561" + }, + { + "pubkey": "0xb803c98201f770b128e7dfa8004bde7aebf12e7185eb88c52640dcac81c19ac747d7772d4f109eca6167c224faa2aa76", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa5491c9f8aa84fa586bd5b5af4814a4971122521a4cec470b91fb4a75348e20d2b1888daa6f0d8dafd0ea50049d84c5e051d9564ca95782c9d780c651b7487938554f625206ccd54db1879f0af236e4bdbbe328b2407a8b7e3f76f1f4efc82c6", + "slot": "12859561" + }, + { + "pubkey": "0xa344f7c6e5770bf8ec78981b7f81151016b1ba7a3e3c8f00e0db3461da81a5c7de7a50633ea69c3c39724f5a84758ef6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x812f12f79eec420a98d93f10bc34a663467652db52222a1d4e6d702254bd9a5ad2bcd70e3d0ef4586de5b70d8242c16611f500e3c5380b30cbae9d3198e9eda2be2b9f0a33233fa8317480771c68949d2d6da7a95287a9ba1666936218e53fbc", + "slot": "12859561" + }, + { + "pubkey": "0xaed1e7aac3d0c512be25e6e3788f367a4cc9b5584bd82944e8e2e695ccc5847fc5e019a7b4cc26f6df623281c7d955ba", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x946db337f38584c611b50bfdc7f87a9bcc1210be1347a5863b364d41bc14ae6c8d184534ede396d29416dc6fd646ef2e0d22135e8cc7e0f6f22bf9705242fbc20cf355711296aa427b1686c044200e678487fcb84371f8f57d71821ba502f3e7", + "slot": "12859561" + }, + { + "pubkey": "0xa348eea2b80f335e89f6674605c21fab27057d96f26ac5eb824df8cee6bbb8450d3967aca3d3da28068846b4a7c15b7f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb41f816f90b63c113281bb394b82890498f3c513541d2f2661d74ecc3852d75de1be1dfce34d911a1a0625f4035caf4501b19b92d3ba68460ca593ae7eb9e04eefa3dec7b3e4cf5ad8e7c1aa6c597cba24b4b7ce6c089d10c937e61859adfb70", + "slot": "12859561" + }, + { + "pubkey": "0x8c61d73c855bcae7b5bd905ed8a0b176f8b772f2c619a5fbd2554b124de02d00a31904ddb389ae3ae679031f2a5ab729", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb6c9c313d2b2d08b6e70926e75aac6c0bc075f539ceac4557a72da7ff5dbedac9ce368168e42cf74dbefe642b67b0e3208f80006585c750cfc7413c7b0b56c2f253af938a113c570c06d159c7923b90a98ab67c9fb80ff6c9b6f6ec90212560f", + "slot": "12859561" + }, + { + "pubkey": "0x818ad41014c623fbe7c7c9a01cddcee6e2124ed8504e5a3429ff4819ea3d4fd8f44000039932939bfe31a15e4307ff60", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa966e461ce019ddcf2f0f10f77764b2fb2a7d4b3574d4ad526b6d1a0a5b166c89141534db0989930ba01a9b812ac84d909c782c97c73a905a33d06d059e41c1defb99c5686b42ea4368c90c07f68f53913f919f6a151b2debb68e90eb62db155", + "slot": "12859561" + }, + { + "pubkey": "0xad7756fc701bb1ff57a564c3602a1f26075632264bd2017ea965ab834e78201b5800df246036a8ac7ef100153116ca1f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x884173781e0bbdf66a13963e6509f9528120700cedbc76bdd85c1b22602de3daa802c88d61c812e9c2131554740e648f161b93b455070c61c9d7b7c7ff0f17c192c4fbe6296940fdf6be271390343e135873177cadcd0eeed406792592b41799", + "slot": "12859561" + }, + { + "pubkey": "0xb504b132ad3bb8e20b234ffc7e118bf51d93e080b6317d0d7b25c46165ffab2c5098de4df4016529384f88bf8ed001fa", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x90e583412828323fc85bbcb814c733bda9cb9f74ef3481466136552678138d923a700f7a4a04ef83251bc26eb16a94d91593e693453de304bb627e4a844fac1d24886caea4bdb5020d04abc547be58f4cb160400d48773a84e488583088f8ef5", + "slot": "12859561" + }, + { + "pubkey": "0xa2e415eacdcd2f3bdb4b60f8cba074934e5906a67a462f74aa49da786ff6059f3ff80a5bf0f6f2d9c5638822e71e122e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaf9519999250c1a3df4de854eb3dc86495e78d46ca7b54cac74adc5bf2fd977b2d50a1f55a822cea811903b69e431bf90d9d5a8656ec46385c028b25aac29964fa8da7a91eb41277f5d79da36804ae94b306c269c8c8b096678829fdc81fb98d", + "slot": "12859561" + }, + { + "pubkey": "0x92ad307934c296c317db967920a22e106c065e04ffa303e0965091cf072d6d6467320fd914ac51d19ae23583bc7ce0a9", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaa5dd0f193a29b04a5056bd2c8b68a56ce12c90c5d244ecbf4cda62fd00e21eb661f240ed13f96e89e4932256c0095ef0e0a3875ebdf11d2509e1c5326d1e53dd13fba64c15087450c86c2483c585f9b195e18cda7a6654e5269b0396887ea4d", + "slot": "12859561" + }, + { + "pubkey": "0x86103079f9da77a2c40b6e3d0a33f8ef49c69841d2161051be0aea5152d392cc9ff1fc78f6e2cd40c6cfb45e73c979b8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaae777cbd53142f2ef90cff9c858363c253352686ac6bc69993e3ab6d975cc55f2f713254de8600bc22a8a967905868717da19f8c881e1f201a275511f59e171d6e3666a67e18210caeaeb73bd4d6a884bd3fbe21d2cb7e7e6b2e5686c9b57a3", + "slot": "12859561" + }, + { + "pubkey": "0x8a3a25221274fbcd304fbbe36a342a048e7177f99f43e02d288627626ccd4fb620e91e1d1cabcee3a66ff1146c13a2fb", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x82e236cfbd9a71140b8be74e0e3dd0c3c03fa0e37eb10549520c4e74c60161b4dcaff9fce154f07731829a710241aa2b19f02587f2649c08c3b8625af0b21aa4d8ae726e42cfa54d785edfd16c72057ebbaafe64d76bc85b0afde222fb3f01f3", + "slot": "12859561" + }, + { + "pubkey": "0xb723bf133ff634730c46d0cc3577ae092c25a3d127f236c1877e2f84e55f9a3c73b0dc1e1119e44da1c4d9ab58d981a3", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa3fba7b90933e9e54c5b067ed6121a777ff722ace53666de5558f963b6d9a9488cda5596350388cb451c4120690938ff060ed064c70d19787004ea7c87f945418ff91fe001b79f96172a689b7b9e4b7761d7ec5259f6e170d99e3ca847019835", + "slot": "12859561" + }, + { + "pubkey": "0x84ae5c2454f12d3358ae67d2242ecbeff927b60dedf32ce4f41028c8ee3866f509defe33a09d87f43ac3c32c9ebc45dc", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xad61c87ef9aafc3c0b585ceb162f097403059f00a58fe46427791dc978e446dda7cde2fe447538788eace1b177cbe8de01efcf54c0698be3157ff19288379daba4a8f35c4aaa4963cf9d3612b10c5257d5414d24130412179a5e71b4d8254eed", + "slot": "12859561" + }, + { + "pubkey": "0xb990ea2d8c5ca2e039bd012fd77c122681dfe096847f494e87140143bb61be8ca7152a3d5bf2ef4b765b6d3474f9de13", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8199b50de885ea470a53701ec4efac1b1a30e7e3444230cac7a4c2f5b6550ff797e30731d78d4f4cb1b73c418d4f9f39115d327b7e5aeeaef616c56489a1d99c325f44e3eaac7c0ca725c152d443655d613948c47839b07010ab67fff4de4dcd", + "slot": "12859561" + }, + { + "pubkey": "0xaede8dcda6a95d16a35c0a77803e4e90de1c4ee0d4c2f763e8ea53f922c9fd4c39f4788303f83315dc002e0ff05c7642", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8e689359370da5fa330e947c5a12a9b97aa56b1bc35bfb98b89c99e0cd24cdec58cd0b6908f87119f8e57bf92f3be5d3055f796aa923ed937fc4cb44f82ea4436260d134b3ccba257ca2db1b20ccd36811127496895bca49af4ef43f3b474680", + "slot": "12859561" + }, + { + "pubkey": "0xa030c793765014ae005977bb31f97cbe78728343f177764240648a0c79ed26d5fcc947dc6595411596d578e2060c3856", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8afe86fcda4f8b7e45656fc7dadde6770c5e14abf338f409d3ef804898d62d9e055ad33530e8cd711fdc8afe4b97585d03969a1312c7860d47a0909f4dadd39607dc605299f9397b372d385a973203d21bee729919769b48513d1e9c305960fc", + "slot": "12859561" + }, + { + "pubkey": "0x86efdc6dcd95dc7c08f805efbb2d90bb0d7671589720c79659e2e90d95296223ce33dd62b3bf8c02904e2d6ae58b712f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x87fbdf90d952b8af6832e49e414cf0d01e20626825a143e4ef6e66851d8c18cce25aa28a2690bee474a681a02877ad560be5595f3b8555baee4851dc498d50b9d8fe7fa2e82cfc01ca657750b58ba6e185c1f0ee49641bacd764d00da0aa81ac", + "slot": "12859561" + }, + { + "pubkey": "0xb05c5450cb6a84dac0c15fcb1b7ff78dfd87ed37e58b798621d3de3be28de33821a35133d170fffeab6f9e3d30540c98", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x895de9c30bc6751b27e806e2c081da3187e0b0a6c36d90b27e561e45add7ddd9a03ef782fb83be7e86e893fd24e41fb91543046f85cad672dc1ae2e24058e2483cf1a7f29df0f558c723b575169b98c3ee1ec2eaa8ada7edd802cd19d5df8205", + "slot": "12859561" + }, + { + "pubkey": "0x91b4392f136b7e13cd7a2275a6f12946c096f00ee69a0aba2dcaa97db299eebd71399bf5068f00a2ac99cdfdcb079fa6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb1a1f2b7ef1096650cb237be547ca445482e626527fb89e723e82242095c93ded124842b0ea5900ebcc3132066bc396512435a100dc44e191de769b709010efb8e19863d7b39dad8562edd0c48e8cc94ec5d559244b3402dcf7cf1b2dcc7faa4", + "slot": "12859561" + }, + { + "pubkey": "0xb963208a11243f5a2dcdeebcb6d238e9a266c6662eb2e9cb04b636bc2ab8ba467a8f400cf6c6ddbf8e7da4d249fd243c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x852336c2ceadf4d6a9de8a89f99be75f969874042aace60255ba1fa6eeca6c8ab26509bb1eda2d75d9dc91b3bc10184609bc603cfd53313845ad89474156ddaf72f3dbbf73b289321c5d2c0a86af8eed7be142133d59b87117562027be4e194a", + "slot": "12859561" + }, + { + "pubkey": "0x97b39809a385db99124ae758019efb8b7025c5e5ed20738b0461815c920a7b9327812c95dc2a8807568c76c2cf63479a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x9343a13dc78fefc8a1523b50eb47388bbb4098b10c3f57bb0edf41ed327cbf0047cbf097aab4ec96bf407af19f5f0b97022c078df0f65f9c2a3b66247e3dbab9a620510d19076b609393d5e2196f533cd57162c75ed4799525022920de8557fc", + "slot": "12859561" + }, + { + "pubkey": "0xacdeb673dd2b67b18f1df2e260824ebfeb1ac0d900e3e43e142d896fdd7cd35189a1925d997e3c905fc68be3672a8d2c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x95dc8b9554df082fce09eb122928d242a308ec1f0438d52b5fe389c6ac0750dc78eb1db883e90a137f13160ef02c5bc807c77480f7b2c46b67763d51c8c4a3eebb52555334bccf524f7147e6c3d91de71cee442b487b3eb374a32a836ff6d0cd", + "slot": "12859561" + }, + { + "pubkey": "0xac3452bde1eda05812b3a2540fa154dcb2926cf456defa6bbe80c347aebbeb341fb9401756c2cd72ffa865ffbbaec0b2", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaed3792a81a28c845a3dd687a29803c997ba90630e0b30338c941b0b38e46d57639ef2a054de5f05bf4f2e8f65130f3c0876e1c54e7ebebd9554c02ba502245a9062fc954c1ebfb0f0f2256b8eb781b5dae0f257d4851c9475b1e7b32061d21c", + "slot": "12859561" + }, + { + "pubkey": "0x989046a5cc5a25b3eba21bb6689464c2afbdfb86acfd72fa4f95ac6a316c3a0c2a4e2d9c660707f8fa9a1c26b8015262", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x91ff8388e178f76e4a019212dd61c3192a79f0607df98cd6aee4a8c98c3c4aaa1e7a3e2a878ed4ae73f99c6d668724e618e58d19a736a84c30e54dee25a2415505de6e73b0ff11eed0f61dc6afa3d29d4293f6937c3ea4b37ea6a34b8ca54e47", + "slot": "12859561" + }, + { + "pubkey": "0x952d63f5bdb6f0d219109de0939ea7ce09d9f520e08a22c0c6debf2fe8ea592e6ef35a213578edd8c27daa92fe339805", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x907da31ccbc809c97e52ecc5b20e87de13a00b1201454ddc0321792aa2cb165a9a4c7e68b54ffe7690b078754d1bb86d17ab4fa6298a44fc08e4a71df5691f8e72a6868c9ea09abaaed6e526942664df0c99f7bd02678b93bb7ead48bee23de8", + "slot": "12859561" + }, + { + "pubkey": "0x87177d42d432c182bacd849f8639aba7884d48dda2bb214b72294df8890cc612e5f40457787a8cbb5283e4292eae89e0", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x882ce841273e0ec0133de9a3da5e20a5da7b6bf1330551135893418e3e4d7d4319827092d4772d1747d90827c3dbd8720bb7ef8a59716d2725a0549013102dd9b43b9860b9f75a33e168f00bb4ad6308f3f60e245d291ef64d353402632c82b7", + "slot": "12859561" + }, + { + "pubkey": "0xb77d92f7bd659ef6f8f328d05aeacd1095cca54c1f745425d66a929344ce4058c544267598d14c0ea98e58d7751efa0c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x81f4ae2c75e60da68e99081f74c4650e07fd69fe15d547d8113e97eacd742306bd77bc9cf6347fe639171e484e6610220feaa29f0bbbbcbc192aca703d8368fd2595cb0140001e67227eb375654053b2fd0b240c49f1d055960dcf9d62fd283b", + "slot": "12859561" + }, + { + "pubkey": "0x99e921175f473a5ea7f7a0aee8b8c139bfcebf94e66a672b173e62b48ef1eb972e61b0d92a76cf876ae59ac0222d8c6f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa8652e0afd9dcb5053e6ab6ce909ef89d52cc621f78d101cef699dec4fb762a45e6350bf475c9641063d55e59056eb9b112a57fc4b9eb1761982d8cd0fc2533fd2df129321eb2e2dc74869428bfaeea4c3cbcca69244acb4d9c3c61943767100", + "slot": "12859657" + }, + { + "pubkey": "0xa8d7386cb2ac0a325314a9b4e173e3f38dcf5a2f919f804c7e3a156b9c3c417b44ba643d036de5eb151bd8e937a86ecb", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8040ec43b824992c1246bec70efd5e6c6cc80700382cfb6a387c154d87f3cb4eb86c6c3dfc0a7953fdacd63c9505dd7501817a9a216f59def3d7eed794064bf47ccd306719ec7f0f7ef98c6109ad3f52e05dd6ccccbb490bcb2613c8c4536f74", + "slot": "12859657" + }, + { + "pubkey": "0xacdd4d82fefac30ef090eb98e3b158cd5779d0a84fb13e9d92a96bbb2cf22576c1ccf1f9a50bb32c87470fd67ba7e625", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xae124c605ebcfb91da1b3a5bf13d6fad84262dea74113b355703f2a3327140ba16cdcc72480ea09145e285fd92fd277d09c6f0acd076decf65deb4566cc3a99e52fc8a5c41beccefd74bc5ea488edabe7a03107da3329cffc631b22c2547fab1", + "slot": "12859657" + }, + { + "pubkey": "0xb9eb351fdf5bbef2c5734c3c1bd06e010838a5560af6e910a9dd010041f797e38b0167a351419d17b3d1d3bf4efccd81", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb2e4a9db3dab707d75b95594827c974f601e0a1ff96509da2d7195598ca7b8c0d0174492acf0fa44f5fe32a29a5b6a200baf2ace226b598dbbfaf9648ec4e41be059e490560aff90ab22b6cb2bbf4fcb0e438e00f80c9a7f1fcb62a3574c1561", + "slot": "12859657" + }, + { + "pubkey": "0x92602996bb12c0f979c4dea3da7bde1a3983323353786176d37cdd69843925c42024327575290a2d4fe0245e4086a9f8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaad0ff177cb7d3724de04afaf8aa7847950fe4ee47265669dee1efd70af58f2eee8eede4c7368934288c91c2860cf66f17e8afa157d5aff3742b210dfa79329258b38e448ddac07f29b15fd1dbbfebcdb87508cbb0808fdfa1b5d2640a75b1c9", + "slot": "12859657" + }, + { + "pubkey": "0xb3dcf149ccaea58fecde3fa9c3bba924ebbf8b0b03b648263525fd1aadc234d6bd3ee20f3f97e4f7e6cd2d630fb34187", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb27093c4e9c959dec04217e2757005eed0f5d6b03c0b3e0d38253bf203bd31103e43b92399e6f6c6237506a1265cae8409453a6f617b715067945d7b49d854cd4a702dd35c3229404fabf028f88f75e07b0faa684bdf2073ceeb3a761fdd4661", + "slot": "12859657" + }, + { + "pubkey": "0x97adf83918acd7f9f470e6438780b082833a8ae2eeec0563e7143117c665591fc7f6a10e585cfcbc9a7a1eee0f496dc6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x859116148acae2a1c3c1c7cffe4635a1151c42a86e76cdccd75e1e37639709bf7d8e0cf982fb7b931fa3a65702172f6f12251a6a4ce0245c82806e0aa0e8707f58d1e6eed5c2d770d4408b718616c4664e4f86507196f7cc4511d28737cdb45c", + "slot": "12859657" + }, + { + "pubkey": "0xb7ff5ac7385d905d075b67153eabf0916da16262396959706055b7e674869486ef2b65946c6f49080b90c7706ea03f50", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x99784387c9e34b04ff8765ec37765273c6937cf8456cc09fd0af6e8f18371c49d9385109302234855e3bda4149276af10ea83df44d085f386fd02530a4fb9abd39df0fa7c1e3dd7b4264b754514796acb662012a04d7357b31d63a0257ce1cb7", + "slot": "12859657" + }, + { + "pubkey": "0x975ac651862fb53301fccee49a4afb842f4110fcdbcaeaf8823bb2eb533fa354599c74e5271a542529ece84763c41be6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x96612db03674e1ed00d395fecef06a47a060b98c47ff5e0db0a362d164dea07f5e1d9d74019a7d1ee805984375f1058f167c19f8ea295ea35fa673590692d26b15bcb6dcf3edec504492c8651d2a1a044915e6b6ecd6cf8ffd98a3966c9cf771", + "slot": "12859657" + }, + { + "pubkey": "0x951761730f3aebc449a49e3f23c9046165691ef702a088a8eb741bc3455443229af40feb823358c2df6a12875f327bd8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x88ae02f2ca74ecd2feb35c23ed7ddc92af45ed9b9caa19727574188751603b979e309dc4d31867ce4f7fd89065cb0e3d1038bc80b87a42f6717963a3e81559a5bfcfa1873f4a8402133e212223e9cb9957cb3525da823e7f4934878b907b3076", + "slot": "12859657" + }, + { + "pubkey": "0xb2d3323c2206a6477dbdf180a40f6af0afa1bdd3fcf72ec00cdc788fe3f6a512417389e167567f474a995c8743826dbd", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x927947fa8d7709b7b60104ab51b25cffaee2f7c71e5b1361040c8f0ae769d668f0a3c90d8f9ef881f4bcc5386b9996130799f91ed9d7763459bbd4d8fd373eb775562aa471e2d92d1ea546bd01cceb8d96ceea9577aea77e0780a11ba0b4f51f", + "slot": "12859657" + }, + { + "pubkey": "0x91157011f994551c183594517a77e84c5e99cffea46594368795613a1477128981b6e1f47626d2008f27ed38443467cb", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8a417c495c41575aef236875da9f4d683259b9d363d2388cb373e28cc37012fb3769dfddbe6f10d5a41b3393e7a1e8b307d5e6aa9ead9a52504c8d6a79a66e31cf4e8212166cb70f2df38f6ec76abe1e89a85b2d2234a80b87c529fd0ddf25f0", + "slot": "12859657" + }, + { + "pubkey": "0xab71e78724a67972212f46fab109cadeb92a83c046649d07a75c63e1fdd534bc01ca3028a2ca358874026fb2e9536165", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x91cc8cb94b0a6106f2b43b0dfa37a141f29535c966ec623255c71f7f8ba57cc38d602f5610fd8412fad3f3d5472530c00dae81ae2f2014c31cc5bc9dcd245283746b5edfdb7d2e182f38c2c5bf414b6deebc2ac7347cffb94bc4d372a2c35ea5", + "slot": "12859657" + }, + { + "pubkey": "0xaf4ccb9ac795b779c371b25cc5c90f469073d2f589b416683b46dc16edca7ea44033f6e88b5cc721dce7cf7cdaf84e18", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb178d4a72188446899e5e7dc800befcf5a859d0189fa5e0af17b98c578e3dd2636126b757e6a87379ea2727a3507cb1b0e75539fe710d2b3c8db491a41d63fbf1879f01de3bd70a334a1b9f135e782374fa77c8230868b2870ea9bad2608fc10", + "slot": "12859657" + }, + { + "pubkey": "0xb8b2848903417881ae839f9d1043875b22d5b5440bbc70de161511f1fb951f0df7e82cb0eae6bf41679fb4f796cc7a1f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa933ee648b7c6f64d0aa83b93d2c42c008c84a6f47f7ddbea83fba001f91d03d313ff229639d2dcd034e67a80319f3c50f51742aecd66447ef933d533dd327aa863fe3ae00ede086c170c9afd3b4e91c5e8dfea734593352e875ce102283680e", + "slot": "12859657" + }, + { + "pubkey": "0xb89303842097bdf5ddaefb7d55d32baa9bea884abfce0bae9027620b23725ccaa3641714a0e7d462d2210b1325c0fb05", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8ef12a4750695314e7f7c76b82897870f4c75da6a7249e92364478bbe649f8c29ff9e184b4a03a3840e1e85b2cc1ac060c7cafe0e187ea8327a010796eac1511f9a4bd755ab11359fd7c9a03577229d0e2b99ee5739b2a053b2dbbc679e3c798", + "slot": "12859657" + }, + { + "pubkey": "0xb513e5fb064cad4d6d168642803bacadf9e3fc4288901880f7e3914393f5671a011def5db5823358c99d0bfd4fa47101", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb411c92c90141a1a91e1dd28f8c5bc3bb11f5c8ac8a121532d245cad14d37b1def95c58429300e45df33fd3def55ce9e0c0a1df8bcf43f374ed6344bbddf8b1d6687d6427f8b8f6f72110800f9391848e43870971baeb064ffcea59fce74c143", + "slot": "12859657" + }, + { + "pubkey": "0x81cb834a8745976289df3719ab554d98b39f569e38a8d32d9ca65039e56c15d2d976658d9c91a0ceb6db85a1cc992772", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb3d6975d518c17d81ad446b7eee92e915490e8b93e468b04ef28e05ea82527e3085862103c3fd0484883c934e265000e17aeae5851b4b3e20ce0d9eac93ca9483f3fba0def82fe987b6103c80691412e019605af95bfc65b03c32f1e1d0988e5", + "slot": "12859657" + }, + { + "pubkey": "0x9208e922d289d4ab100351cb817175d747d2d8bd11276d1f4760aba00341fe5c6c39ce825a398a656cb92368776a8949", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xab720b825a1d7f1bcee815e10c0480f9c3870f18843d1d181e28560c602191712df85e77a7361d14ca1de9b5027cc7c80b657a1e46cee52969466155f5b4f6d42d0f6aad3e408ead7c0fd206db30eacb925453d1f666c29b7cfa03076fa275ac", + "slot": "12859657" + }, + { + "pubkey": "0xaba973e37692a136c73358486827b40eddd8a95e2003fefdaafd4beffa7b3c336241feab80f9fd38a9b55888060d4fac", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8f150d057b254d8315d2c729e89499547139f033e0ec6fc8693c21ea1111aa25eb61ffc7cb1ef8fa48196fceb4a951df10ca3f8210e01bbf41084d97f16a1d78f7d8605d5901afebc9cd33eaa9b117047c819429dc4dbde3a90f1c34105dbaa2", + "slot": "12859657" + }, + { + "pubkey": "0x944c932b9c51419f791ee05d571b6ba9ab5dbe41943689943807ea34d5f056f5ad8efb23a1c1f0f89dfd8012ff34f726", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa526e9bc7932731b5117dfafba421afb9b981b5e1517d15ee58456761a88ce3ebaa177635a2398ab8bf07e1dcbc2e66b0990b0160699b5b11c2b8796d5628a109860f304819be8f7fa12572f1405e4649f35ca236a79a56703fe5e721f2a887e", + "slot": "12859657" + }, + { + "pubkey": "0x80cf2771b6df1d568b74b29fcea7fbf7dc0925c4b1db8c83be7db10c642504b21fcbd7ddecb28bc73cf9a99e122a144f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8386df0755873bffadd2a63dc6bad6394471c98def71c1619243038ae15a9b14849302f583bf5d466684d99346aba3090a3e09b8941e5bd4392f0ded4b48b398cd1beb0793bef2016943ac4b2e667c0d2452eb829e72a385c28bbddc10e6c918", + "slot": "12859657" + }, + { + "pubkey": "0x9140771069bff9aa9975c65cd8b3ff6d8f27e880b82b4efad7011a491d099fc4a6efc4694ed15d6e926f80701b4394a6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb60df37c777a7548142dd8cff5c0ca84ee7379104c807bf26ada715b477caf55e04e20bc149595172b610db9b2cbcd711102cef5caa3fb6f253297153b80ea06f91ec6504920239e4925964a883a13b5f81aa2dde3ff0ae592b96798866dd6ab", + "slot": "12859657" + }, + { + "pubkey": "0xb4c689e0859d8c493fc7d4974b77fa3a7f0b55a67bf7b69980fbc89460d674c716ee09887f0bcba91aaca0cc87f11f8a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xafe0556a46cb6e29b487115eae7c76c279ef76b9268c1dd851cfd84ec56248b5d77c21f5f0465cc6959e9a8f9af57c420ac49660ebeab3cd82a751eb0f1f9be08395804e827889a2d8f34d02d10ddd85e4063b3d2fc2bfe98ce2fdb60b0dea4d", + "slot": "12859657" + }, + { + "pubkey": "0xaecf3d491f3e94ce53d530915085f3d97d64487926691282febe8daa576d312c5251a23442b78b92ccfead4c08a22e6b", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x9844010ba127bc0f4915de29fd116d3baea1ced492f073a054242e467ab3eaa94dca5c3af9707c126b4f9041ed18e2ba16942ef9e50625254dac4074cbbecc0d0702b8f2e7381b44586aeee8657719764a02f266eda53c911097ab68131920c2", + "slot": "12859657" + }, + { + "pubkey": "0x82de4c3df1d6afe6bced6d675d353b0e789e9114da167b13775822b3aa66948cf4f7bdc9937607d51a761ee472635a2f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x98f5dbfaa2565440a17b09745caf6cb09fd7ceac89c5cf09c6324761ee2264fadeffc84696da50ff2b8f852995e650210824b415a883da1121222a85a5b881e888ae63fe2354f14b9a5604303c735166e08cc31d461c0179ecff63d50f3bb9db", + "slot": "12859657" + }, + { + "pubkey": "0x8e6587852726fbdf42ab9c8242081e52106c25bc8b1b55549d7097710293bd0e0ca0939fc342bcd340c505d721af5b40", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x883f279b3979ffb765d0d0ae95d61e59cae38e3f1dba0127a36bbd3b9477ef4571b401ad26e909d31bdd3aeb82f6543b17d7a4422a96be8d7d6bb9ca61fe71e7f482da27f5e5b1b4b9bd9be8f573f8713fe7a5c38e04c7d301771817d22ebb7d", + "slot": "12859657" + }, + { + "pubkey": "0xaab1d6a9db56e58863f71cffcad6870a34dc1235c2da5e437693f5ab12ccbd92a3787e361fc038c4875adcc15bc67ace", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb5d0715d65e6aa14694452410352a250d749d6323ca3a683eff97b26b48abd7c43bfe1b7f803d85bb578b9f6ffcbf2521064bd00bd2ce5bcd9f84f6a478d1e386d50f7937e00ea8e4a57eafe086493c3aadb946216acc95293a4a277c373c399", + "slot": "12859657" + }, + { + "pubkey": "0x8a67a142f62de6058522d92c926ef202d5f5ff19172390721829dbd734ed6b1614c36d97ee20c397492b21c560b21ad5", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa8516b30b03c8d27942c14c91a8ba897a0536259508e200f23393735be5d95c122853dc377a0157e5f9be58e15ca2a2610ee6715ee7353d5368792a5ea8d4b8ecc61d4bf483e1216770696a693f1cc5ee12e587e61529dc2a04d5794acca56f2", + "slot": "12859657" + }, + { + "pubkey": "0xb6394e8ea10e0f79483cf025bd6ed6740ac7ef8f2b08ffd4c4c6cd64d88cfec91283a864873df4a805f28b307c05de01", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x87ee1e5dda5fb8d37f01d082a2996eff5447f2e1325ad318b41377cfc2369649099e49db9e3f7bcb660e3c486a567546000bc010d56d75c5296b868ba8b04548bcf0c22b53686eedf55200a16a26c0c986959d0259c8652c23fdef84b22a47bf", + "slot": "12859657" + }, + { + "pubkey": "0x8841906b8da3ad611835a5a8d8c6ccda6f0988955030ffde6506d06c886538e0c9852470bd61eff7d6e515e88ee5acd5", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8f08915ad8a454e531de5f6e06a8c855c623184670efb9f4c756a156bfa01873d51e5dfb67b5265b611d4134997f325100014e2949acfbd40bab35fcbf16c7fc8fc87167c9d10aac96b7f9ae3a16c37b8602cf53f82fbb61453a45ac0b3cbd96", + "slot": "12859657" + }, + { + "pubkey": "0x87eeb4ba49a4990084939f1e01fc6fdd5d269ae730a7ae696669f98c7b2b3b580a2b113ae0a38dae2dc763b4882977bc", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x96b4be715dd5bc9b73e76b9d79bec274fafc4c05b74d84c4aa7d413d49198af22c8038e6298b6be6ddfb859b2bb8768a16a97e352e866b124764301aaf00d432c96f536944813ae6ca62f3d9a1dc4e9cea6586d1418bfb7e28df7a766297c3ec", + "slot": "12859657" + }, + { + "pubkey": "0x93cbe6138a52b310a46304cb5fdef50c5599b25c97037b29d51484b6a1b21240d66bc7fee6b4ed63f13e4d05297bc818", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa2cb1404fac48ef4be7d72fe880fb95305b3ac37112377e41de879ec5ddbab1532b2a5a609ae295d2fc61b7c6a531ce007e3250cdc872143b890a47a0785daaf2cb95e9084ac4243a583ed03f9842956d5d1c8cc4492edb26312a1a3d257a67a", + "slot": "12859657" + }, + { + "pubkey": "0xad445fb584b0d39684352be98d2051ec9172aa9f21252c4f9c9356ebd10dba12a732a892cf3ad8237f25084a2eaa47ab", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa795b1876f63784d38f88bb5a9bdc20987251b49ca1cf92ba349d11328dd8b4a93a3b8627e63ff34d4d3542eaa839c9f07fb407b04f7f7adfc5386cc6ddd896a94be9f3b07ecbc7174d18f3ea11696c03b978321cad49a2e6881052f033e8e90", + "slot": "12859657" + }, + { + "pubkey": "0x812ab5fa799e3d3baa5353294653f6c1de75ec760d52b07a57a8bbca3a760a1f845fe8fcba35c231a037dcd2cedad0ef", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb2d353368b46b61d64aa16c48e798b32e1c3eb0792387acc0465f3ff66fd6834d8efc32223a5f24b3402910959e46f0f12e698da1d899d59af8c6c76c12eec81af13d1e27bc53b874156bb13acedf486a4dc1b1ac5b8ace83c63b6bad1c52926", + "slot": "12859657" + }, + { + "pubkey": "0x89236c166ba6bcc8f5abf15a771a0c91e0cb4a307954a4aed469a052e06842bd1ae3fbf58043c17f2b06893c0b671a3c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xabea3f8f597cac81f9dbd86a4a17f509aa1b063539335369b4e83798b54b278a7a2ed684973960bd4cfe4ae57114663819cdcfef41495114dddb42ac136c6c18713ba5abd6146f7c482bc17390e7432b013cdff8800620f37d64fca85a019eff", + "slot": "12859657" + }, + { + "pubkey": "0xb4544001d80d65935eb0eb3a0e5af48abd6e1aafd1262d00f69bdcb89199649a1ad1b4ad27a2332288e0f1c0f2d75718", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb060ea9b33e4b35f39fda05488c47bf54b0a6bc9b758bafe0c8425879ad005218c640023a0b6421bf2f934098ca5ca3a155b0e4b907dbfcaa4931dfc29eca675c3e4a1bab355c78a7a5b5a21a04b9b209e11159e548a357acb0efa1efe48e12a", + "slot": "12859657" + }, + { + "pubkey": "0x8b6e4e7dcae8976166613663c2c5b6fac672929fd6263e3293643b8d0617ca9b9382da05ce6046534e34dec00d5f702f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa26700e77d177c0e3f97fe73a035a5b8a70276eb6d073e74ad4cb3c2798b301c8cf8b9921614f369ee67a846f2ea713c10e87b86c2c658a795823d1c45b60b4034ce46a2bf61602ace424012e5c2c24ca297f479fdabe511eb0e69b1304eca22", + "slot": "12859657" + }, + { + "pubkey": "0x99b0a5c94cba995370a889c72023d74376c44fbb5974d269d360e50df8d2598d523d441d9eb9894fb879801f4af640fb", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa1ebe45d1df5de5440b045c10b739ef63a85a7cc9c98123cb764c32ab4dc9b406ccbfc539a01e19452ff5301ca57761d0bef40819bb80f9ca5f54cb016814081eb92fd847b6f9edb5b35e19f8490a969899120cd7a8c0062d03c1ed7b542133d", + "slot": "12859657" + }, + { + "pubkey": "0x8d97e329f890635aec19ec91aec031a3206b9db2188843a1c120aadbf6fd33d49da582f78f8c8397614f544e6af23592", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x9257b756b110a37805f29d7fa64eb38deaa5c57e964ea3680aa02c61da600fa936fb5634226a21630e90e712303d393716e02185ff4c77678ff759efad5dcd22c3a1667169629a6821a21740e54015eb0a21d5c55a9ac97f523134e6d7087422", + "slot": "12859657" + }, + { + "pubkey": "0xaf8916229cb5940492e456e4d070279328ed0fad1b29625007e25a1574227a8944531227afee7bf547380160f18196af", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa612576aa095d87494ecb5e62e4c096bb23e7b59bdd42973a6e3a843cdee9f5b1f8bca7436b7c4d16fb68903f4a8326708ca2d59b0bf3e12d6fd5ecef47bb1a5e88cb196c4f4125eb1c9924da72289c0a2110bbfe6f49428fe7d8a8dc48c484d", + "slot": "12859657" + }, + { + "pubkey": "0x820a9ce35e56a45dd32ddfe2a773ae6e1f7e56a5d8d876efe828e813a5d9c595878567e47d927739c540c728b05336ed", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa6cdafd6efd053acbebf7b8634ed2a6717a8d6632ed0f73171b81523f7da3670f47f5b2fbd276721a18e74944d2d32180e3d9fc4c52bd381618b5beeb069ae26a1f51351f8ac32c3b42e94fbb822b032b5d1d476f7f88e8a8381f9ea271e5593", + "slot": "12859657" + }, + { + "pubkey": "0x8bd9de95aa3fc49960a9a7ad7eb995e879365caaeeeaba002b512841aff2df5f314ca4682e31d32b80a88d19ba94d31b", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x84f3b264e8f1ac5dd7d69eafe07d80ed352b0ea533f9d73d92dca3aa5881f59fb1b3a6bb1b3fc5f389242a2475d304470ec6f73ca2ea7663ecfbdabc77a6c56c489834f9dedfb45e0f49435b5790efc7d3f4f29eceecce1b75f9c0851cfed545", + "slot": "12859657" + }, + { + "pubkey": "0xa8cf159fb329dca78e6b9f740e9219c15c6fb0b47627a1d11e127594df112bc3d336e1a6ea97222b4812adb7ba39fd93", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x856cc741329b2e1fbb0c4c91494f9d75097a2dc62a367237c9354c20d77d9fcc64a347fb4d0859b4c2606865c017cf28130d67effac102eeef7cab9e13e8ab8fe48144cab63b7a3a722e8056e211d78dda51b5bb34176d444c544944b16a3238", + "slot": "12859657" + }, + { + "pubkey": "0x9253cfb8e56f35af9e7e0e11caed8a5f96b2013869ff00125cc9bff23feba56c1dafdffc5f4062de0104a52f67460d95", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa59a7e5547f2a442641dcbaa210849e9434bca3a932b4944e83bb66a5eace53f8fd6d0d15e9795141cb7974f8fc6b943123bfb190e2fa467d34b88e4aea390217ebb471407582d38c6e69134bd51146881baa8a49a8fccab87fd8a83f41cd3bc", + "slot": "12859657" + }, + { + "pubkey": "0xa1a72ef39d17b27634972f7fec5e852b27510a70d5044f85a27eba4a676aa88639f423e353c8ae72f8b92abcde571806", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb480228fb882e3ff1a056dc0d962cf5886e97164cbe95ee3ec5ac966042ffa865d722fb719ab96ed4e48cd679e5a2dab066b9925486b1000fae775ba091c7ecea21724a64c44e8be757bfca5e6213ba9f8bda75c09b8bd6a28f48f9f5678ddeb", + "slot": "12859657" + }, + { + "pubkey": "0xa7584d13891d59a999127fa2c75e1ecf18a57bb453996c579e2dfa4eeb2720cab9aa6099018290ccfa667672d671260b", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb162bf185988eadaea5c1accb3790083f529553800f0eb7ca33d15c0cb86cab594d1a1fdd4f86bb973653257dc9b4c6316f8b64720dbd50ecd8a6fa0c61a9f58295e8d31705af51bcbfb704d7e0bcfbd032acf6c08d417749daf7dfa2ca012e9", + "slot": "12859657" + }, + { + "pubkey": "0x949b800ee62cbb379b2d2ede331945a6f5c95b8215e16cb9d72e54df91dcd1caf3853ed8789e079b1adc264c1cd6d218", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb35ae933f4919a910175af1216d54e5e5add7b4fd3f04934a339724e06fc68653e79cbffa3bb9ab480603587e05cfbf617a54eaf44a931592e5adff07fa8316594e58d55832a4c10a12210461b58803890356c2223cb5500b32848ef051122ab", + "slot": "12859657" + }, + { + "pubkey": "0x9701ac006e05cb34116f6378557224a7c14fa25aff450adf2ec2273a9606135638bdf1b14008bf3eec14296b9ef3b8ba", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8272b34a49c8ef404da2cd30a7cb2a27dab53195e1922a130286035886e841420e665eaedd9ae80ea631e875bc5f8f7d106eb75136f68ca04de1e740ac8e42fbf2f7ebb67932665ffbdf560840a9abdfa2e453195d32b851ab4488960412c795", + "slot": "12859657" + }, + { + "pubkey": "0x889d4bbd27fc91d9a674f7b44760911cb4f073bce8ba4cf762e697acd41640bff0b245a806079a2f8228f9198b9af647", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8bfeb3aad678c339ac7ed829ee10f17581bb7255791ee69f0a90671e82a6bf5b5b76b34c7d6ad52a29b5528b62ebc50f052c54f706b841cc6cb26517ffb02fad0e989022243493bc41ce8f352d444b07e201e44852a28912213341068a66b966", + "slot": "12859657" + }, + { + "pubkey": "0x9145530c69b25f7db827577e19081b3cacc2a549197763e06525a7e398457dd8e694ec3b0af277f7262af507645a9e62", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x88722c011149c0ff86a3f0301f44abab46b4bc13615b3a36399ee0e3c1235a3182bcd6b43f5ffe193ac6d38a6c87391803f61d97b4584dd8bb296e97eec4645b35336a96b63e53a0f1145b0677ba11a14b4ca34f0a2aa473ad78d33bdb3bc3f5", + "slot": "12859661" + }, + { + "pubkey": "0x845d2108f9a6d4b22065922703ca7021d0f4479b912760e9f6e83f77c4d4941543218cbb034167b2f6feed5c54e43e92", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb49f84673e695ddeab064896277949e7402ce279b49fd1376a8d99391646b1a3d9e564dd900f1e6cf8ad5d67681edd2319c61a4d885584a022d386193bf86d2b08fe252d8b612b182e161d92dc235af41593a7dff30d352d4f09b9111f482c2f", + "slot": "12859661" + }, + { + "pubkey": "0xa1c4c4e61e3ec22dc7ecab47473cc48e2c0bfdc7510b12b0613ad0954b5d1c633586387edc3b5cc7b4aee7a715930740", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8106dc82c662043baa7237e8d0f34a5a33951845a7a7afc4faf7dc135ec2f7dd8e49b2b6d48d17f3f80458e7dd37f7050bda3ad3940396dd417150b76a26469bd9673d5c9cc3bc56fe586cf35581090c32a5f3825ccaf965b9908788ab830f00", + "slot": "12859661" + }, + { + "pubkey": "0xa80b8b448633b01b3d798ffd00412a32e97346a22b0243389b70df5debc6bfcc9fa21e013d8f86aed91f6c575f13da5a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x835fe4172e8fa21124d2543228af252eef4d3b0bea6ad23104106cdb7c050212de39df797fb3c6948a9ebc3456d576c4041199b17fec0608f9fce60bad0e1689ef1f9857e7c42406f734093778977ac855a1a7ed1479f020b08bc1b4dfb883b5", + "slot": "12859661" + }, + { + "pubkey": "0x842a438557ae949a4347bbef6698be49ee77b761866bcb6398173b15f290d7de673cee080d0d9f93b20e8a89d237fb6c", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xad6fde81726fbb2fb1ddd82f12ca7d81022d06d85296568c8c4e4f8a8208519d24c8beda87c9459d18ea19f0758c2d58048186a246408ed2ecade3c139ea09409385986ff87cdf9122f765f8f071810027fcaf33559a287a81ac827108cc18f2", + "slot": "12859661" + }, + { + "pubkey": "0x8b83501d956bae1df8792f88f6457c385fa5d986a38b3b148fcdbed9968a4837ec0a2432693664c0607092d7205131ac", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xada968ca64ca06828f506903103c4df18c399fc8839a656713c3b3d6719590b6e010da41b18501028c8494675d5fc44616a369914f428d7257de2d80d162f402cc1173ffe3636d054bc025901a06e3d5b6b2ed302f06b5b43ee2cc84225c2e57", + "slot": "12859661" + }, + { + "pubkey": "0xb61adafa172f819b40c6ca2ceb3ce9613ec39fc25714e7de0d81b8ac252362767169a8b522e8f512bbdf7b2706f82f94", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x932de427bdce5a5e2bf17084e578cb93d37e41a24a156d105fa03425d66349ecb349b08d10d3d266792ceb9d13135c6b0a6d79c9639ce341356366fc41d23a73e48f1b3e03e488a49c3a287fd998df508ab357c7a1ed915704de83dbd7f8ff46", + "slot": "12859661" + }, + { + "pubkey": "0xa6ce95831199455b9d9209d765187a8815c1d2824ac0b0243677f3b7322e4cfa12abdd29f52cf726a82a1da107d572d8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaa63537eff02297792aa7461347cf4d5b95d77f6d008791da4cd55f7aa7b41c070f680872a3b6c92cde93893d7e862880eb722c6974c00c8591412de97f926e6b5313ff819c99bca241674f97c800e79d47a62896ab9b9f6245da5534f07f4b5", + "slot": "12859661" + }, + { + "pubkey": "0x876621600c40dc0837c4ca2d33ae7f4ac78da3158e497f0dd369c13ee09c2a57edf180b26a46ebe5e7d62a51cf82dd23", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa8a2f532b5ca29b77dfe30fbefaa97e330b78731cff456f563b1569e771cad1959e1e167263c6943dd3dac5b87119e8c140645afd359d4a29de95a31f88844e4a37f79e6c11012b67316293e7f45bef6143ea447880387e96187ea18f01f177b", + "slot": "12859661" + }, + { + "pubkey": "0x87eab448d9e463c8b543c1861dbcea009af9c0833bc21ccbc630a48922422ae83fe5eed0c126213f08cbb2739ce22ef0", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x9388c4cd622e3614297f66eb838dcb04f2ed6f8803a94a4dcd8fe50c0e9ea613fdd966f49a9cc9eac0195dc3786992c71699e2d20056898227afc0b64896991269b172462c66242dd74143f5f953e79599fdd61c3cd32632f85c9a5d4d7d6fd8", + "slot": "12859661" + }, + { + "pubkey": "0xb759e7b491c66a8ac50e9c0cf786334c7f9e4275cbac493f5eea42dd56868156ba621a39b7c96ec38c9c43ac1e58da6e", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb5c763bb9416215991140d145c7d13a5effc882c52bce0c515c56940685e0df8bbefb7d06a9e8031f58e633c7df171190cc9d00eec25e5b448b431786f3329d267f66a973a6a6bc596d6bf64c3e831583d8a23839108e38ec232765dbf14a402", + "slot": "12859661" + }, + { + "pubkey": "0x8708bb3a39dfce3291e5e08497cd0dacbf484a6cb85bfba030eae10382a7e3f246f4cfbde2366721636084e656aeabe3", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x94cb873c590e15076e758113f0abc0c6408cb8486e29ab7d61bc0466b1c17f1e1805cdda99fc5d8ff5c0112378cdf09c141e94c995d771b1b897f90c72a75ffba9086af4b351f628677af43121a339a502e15c012083603bc8026e44a710d8a0", + "slot": "12859661" + }, + { + "pubkey": "0xa1720f70671b29efda7309abe82f7686be0c199be473fe837d11b1bfcb0a28f371e655a7f0c22ee3b70968977e4c1772", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa75b39599803519ddb8044a15066022002478406a89712d6b6456e9d494f123ee22480d8678c1611e62fcf0313cf8d2413b51cdd17fded2f3479c3896555f681b007dbb8a1c9d81fc0dd127e6627e102e3ee0505bba02adbed091509ddf9cb66", + "slot": "12859661" + }, + { + "pubkey": "0xb7f1dce35b973fc7d09ec4e509a935d4420c863b234f17a97950cf5b56c7f863ec235855de536d3cf362f627f9a724cd", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa529387b26118da4a5d442defb7f4251c9eb81f16494e8f979855bba744380be3cca83615ca511a91dfb268f973a273b07ba66e9523e3b83b0a200699ca56d8a749732f2d1b363eeef3377d0ce27e629c11aba47a41f369534ecc055e1a0a1ba", + "slot": "12859661" + }, + { + "pubkey": "0xabe713622d108f096b31be1d1cca97b44b8d5470bf0c232bdb49fc46fc252bee1451a8f18ff4925b2836cd1fa857ea75", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa4fadc72927fc822bd22f4d1471483e68345437e0aeb68d6cfc599cac31a4b107a88a188d0dd7af761222909370283021942bc5925c808c7794a9c04fe74e164bf7b726621ab9b4522cd683fc6496698ad5dbbef1ccf03e128f2f79e67105733", + "slot": "12859661" + }, + { + "pubkey": "0x8be6e5387c112eb3ad900054d14c8351a89de9857dc34080dde4256394e0e0890887bbf7ed856bbe98ea16dca666558a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb35b568da25fd99079cb576847965604989c30e92423c32a9c47d3f9e4ea01f8fb3bc1827a777c7a1ff42938506f8a3010bc241d4d9e5e7d5608b2e228a273368a8f0aef8d075491ed8d278fdfc6d7c299b54de0fd5db5fa671641d5057deb02", + "slot": "12859661" + }, + { + "pubkey": "0x96d64e0c9925fa7a4132d826cf72c493378577f5fd6bcea6e25a15c13e29caa7d5b11ef8e26f71d86abd3a9f2922bf57", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x93f2ae2d7686226c14ee4b409bc13f6f3b983ede1be857aaffb27a8c766e9d3dfb82370620d4f645417727fbaf269386145ad42ee06af39703f5a942b3a11ff76dfee4b9888ce602c84a0f44ebe8a73d203dcb26a8e3da8cce54f8fa474b6822", + "slot": "12859661" + }, + { + "pubkey": "0xb67647e653e4272d86031c7f5eb16dd7f8e33a36d4cb2f1ed7515c1f41d62718da908ef633aa658e69ac495ce5505193", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x997d59f6689dfb6697ac8dad4213fdfc4d4d34945f8d84cc6f3e9a27ffcf81732e8ec7c39b74ca2c95e33c3cb0e4191e140d451373b88e235b5b4f7f2c8a6ea8fce7d74578d72f49dc934b75c0a344f888c60032108e583a964a089145124992", + "slot": "12859661" + }, + { + "pubkey": "0x9005c57c36afa4e530b33fa477f6480490018b5b4489ddbe99d776651394ee18509966c1b8a05c2d775387afccc633f6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x837e39e6e3de2dd68fc3c84a92399796f282ad940891c22a52ea7b042e2ad51c611d4ac51f9bf6433997556f599269c10233c3fcc7a5ae6d4cc0437fca63c8232847983e243fbb87b0a8f11640b3d3f0eedff23e2059d90570499119c9da7e48", + "slot": "12859661" + }, + { + "pubkey": "0xb4ce3ae4dc7af45c838eeb16d85aaf2650ec0cd8c17f2a89ad49a565238010ba36c9d13666c8ca479095e58a61327dc8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x900e5003672a030032a75539f75521f02d300fdbbb30139f5f1bb11dc541948650aa3fa698d65364cd840ebdec7c5912094d5aaf9bcba10972bcf645163fdf63053e38238e1bb1fec06c4d9eb7af8cc7ddcfae117028770f779c5bac09a2ef64", + "slot": "12859661" + }, + { + "pubkey": "0x9295a78d43a3441660d8017641393b52c8ad4ea1586543b6f32a577392d196c2fcb4810d0e46c4a2cb2f430b797023a8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa43c0f18a33811b7993ea262b9fac257c4e4d939f8e462250caf5636adf776ee9e86b0dfe24a89f126f2edfd4dc7d5be05dad0dffc9a6a7f9ec7b00f4e8d1c258c3247c6b553243a5c04ecba78c863aeebc10736e57b153ec14367fc3a143404", + "slot": "12859661" + }, + { + "pubkey": "0xa1187e8c34cd0ea2742b80d95b128a909e7654e5786c11bdb68dcadb8b8783ab54d6ff62b42f33bff710db5ad023865d", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8c0006284b6c4186a9e922a23ff97acebb0c3c494e2527e2318b99f5df6ad6c076871a058de05d7ff60399942efc7b3e13d474074897187e0aab7f5ef95b9ac7b45d3b4e0f20aa443086f06d2a1c9f6b46bec55a88505c52cf6f1a1739074e15", + "slot": "12859661" + }, + { + "pubkey": "0x86eb0bc0e8955e4e49b70fe479241b4c03a1f9328ea673930ee1a8735500d86e923f14b83bc8bd3377ed08918b1f195f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x865c4f93a5dbef902f305b3f79100833eeb3ac21ad967f046b8b21b40e7e9efd1f31f5e6df93db02423ffdf8c29ffbf9151d56f92af3bf1f9bd0e4dc9539acb842d8a87a7a2fad463b9e3f03ee278144661b0e5c14e832ec23bb4e773751f51a", + "slot": "12859661" + }, + { + "pubkey": "0xa1cd1ffd01dbdcae653bbdc4d36ad2c0991f622d19ac9396cfac38e88737008d0b1311499d21778d843a6dc7da315de8", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaaa8d999074007d316b5f9a2670ca220b4303cf1bdfc8270155376574b60116213010c64443b9bb717afe6a3d267f8dd0020116350a775d4f3cab0fb6205501d7b5242e646824e1f6b4d457c780c029efaa943e3c1d1425e60ed88a3ff442179", + "slot": "12859661" + }, + { + "pubkey": "0x8cd56f3c3695d7a46ffd61a09462f7d2c882f7d073a1c7e20fb618dd4e2692ae2ebf910089b2e7985469ecda88e21789", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb967066815118a4c7430d9ca50325451304d5f544dcda27f1fb38b729053b8af4a3abb501fc936017cce5a2b4b7c0a9f04fd330c3c95b0af5539084f2030ce1fffacb4fb8ea33ce037d33141ce94833b3d908153dcb537de404a3571a0e055d5", + "slot": "12859661" + }, + { + "pubkey": "0xa13355e12843cb6b042d03fc197796e6cabed383c497017f74a96e87a4dc3499be8bc3648b2f9cf84b2190f093324691", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa2f8d829f2d05010418d807ef6266324bd94b98793a7087bd2e07c0248d63cb72753dd7d280d12bad6fbcd612910d1a50ff72f15a347ab133b054f7747bfe1f16d4e18c6f85bd46b293732bafd607c74d7de2a082f4157eb68081776f5d6b44b", + "slot": "12859661" + }, + { + "pubkey": "0x899c4ed0bb681b2bdde9f6806dc45782fcfb0df78507fd877a60c6ba899f8d4a3938cd1809064e6207c2731453a781e9", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb48c00993159e271609230449cc2085831d5701dfeeb98b9d827b5ed7515658f40e3904cea2d068d177339fdb0147c0202292e6f3830e666fb7349b0e9506e106b63619ccfc86065f448650702b90d73a4672fb76ef35fbed8b404655f6cbd5f", + "slot": "12859661" + }, + { + "pubkey": "0xb3b01a187ccfd2bed0ecc714fbc7e7672903dd23c6bb7a1670759ac2c0730057c7c022aac02a755092c234098673a179", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x818688acfe6a99d6dcd451c48f80e3c78e389e65894190d5da3851f41d828220f136a743e7ced07c6a3a45111975800f07db3a2a2599838cc5ef40f961eeaf719c6661ccd0cfa059076f97e6414a90e278e155b4f06cf874f698182d39201e24", + "slot": "12859661" + }, + { + "pubkey": "0xb7b19e41be09e2b080c841d5cff87dcaf3db1ae03ace8ef22218ae29e76ca21973c09eb7ee81999b0448c4dd44bb884b", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa8c6392797cfc5f3d07a3388ad0b83a9918d85110edd53b5886a01842e7ffa92fe7e609821f7a48ae0d6285b6c5807c90ccbf091229baf54f4310e27496ecb0a777bcbce8600ee087abd2c515d750135948c4169cfb3ec2c82ef9ace192c52be", + "slot": "12859661" + }, + { + "pubkey": "0x98007f4964cde744711974348c069a9f23157ee4aff93a5935e90b91d0ef2b73c6ac464a059b848660d2011973d58ef7", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa9c53c02e608aaf0350f11cebbfbc242b7d8c01c22819ac97b5a6a006569469246adc4627a3161b019e71ab49f590e5718aafb9cba95ec3af9b1dac49b44acb2594f0d8a86ae036807ebc5cc6f786f6df1eb130045639404179fce1b396b8e7d", + "slot": "12859661" + }, + { + "pubkey": "0x875aeb541f1fbd1174acbcbcfb02cde559e290aa0727a3907c49f74c6199c898b99875da23a0bbfd4fccc3de5a6b3406", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb6653e697789be4b3587550c82e368f3da8e8c059d76795fcbf7162e9475ff4df1e5b96ae2dcb5a02941a6e79a3e46e1072fcf178e1f6d386a736ca109beb1c012d826af1838e201e1c0a6481b21a1045f9b0dfdf37babb8095849b75798c7f1", + "slot": "12859661" + }, + { + "pubkey": "0xb0eff9bd47b90001a4ead97db1ef6252069c055f6f1b49fcfc191e037cbd0ff65ccaf2aec756ab4ff9b38dd2d377d9ed", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8ec51b3ed582698dd0aafd0b7f8307e42ec06631c1c14261d08549121b5b3ce29c58fd7c91f52ddba04632d647699ac80c7f0e48d004fded22b724e511a5e622425aa4e27984f0802969472b99c0589c80b39030c26c517e38f4950ed7ee7321", + "slot": "12859661" + }, + { + "pubkey": "0x8c48dfb24a6ce21990a34829b3dc087f44895b121ff43714d4cb729c9b4d994fa4b827f46ea5758861dbc01adb9d9e90", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8c00c0e16a9803decc0f325e0cd1436ef37c3cbcd28738d2f040d628a7de21238a36e3676a8ea5ab9c40cc7b506458b90cb62c192b009af7aad4917a6fdfe55947b65ff2f71c1c3690c4496bb1834416c7b26ba6a0af235654c8eb765c8edaa3", + "slot": "12859661" + }, + { + "pubkey": "0xb72fd7c8504f2ebe17df9d94021d3c7a3a0428de1a5ac7f115b9830ac4857ad0183ba5128387729d25d1ea4bab1cf9fc", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x8e92a3eb6a96c644282c580e9e313164957b6261a6293bc744011868b11b06b57298eb4c603ea39f85369573ff48198b017acd231f73e96a20328db41f85227478509978d81bf8ec3328eb7ad5206edc047e673de6f7ceeecd8f3376e68adab3", + "slot": "12859661" + }, + { + "pubkey": "0x871a3cd6e0b801c9b403a7a81b0b0b6ca35593f89df7db229621abc9c9cfe11a4dd8aaa135224d7b2007c46779f57976", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x921a3ae3786692596f025c59602b8cb73454f3a3d3cdbb19f2305a531b51303eccd0028d69153df04a13c2bc3ff8a4480105178d8674284d91e0fd79d2691ef79d6e5b99f7f3c0835e80afe5602393348290f02b4a2133056415904cf74f2afd", + "slot": "12859661" + }, + { + "pubkey": "0xa34e3139b2911a5cfb7e366a873b2a94ca6c4b15c503179b7ee3296a396a31e9b5a129ad90e6a4878912f03e6118d74d", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xab43316a8638be306509a7e80b0a50e9c862159167cd620b51dddc0ee36043db5ff669d3417e41f1e3aad929f3b48de30b01a2a1017fb1d4058f5b3be93bdf8e360fdad83c5cee33b4c0839ca6b9ea49add4f5c74a1c91c85dac171795d96f6a", + "slot": "12859661" + }, + { + "pubkey": "0xa13755e961db3b837ab1e0cf6cd816480d9a2714ef128f5b92fad1680d59b8a7e72e3d83095a36d899732124672c35ed", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xaf7bf91cb1bb454cee30f8b0f20a8510d3ec3eb0bf9a795f62dc3db383aa2089820f92c7f1d63b9be10efa07471445c60bbf730b9fb75b34495bfda99e15424351bf92db26b177605bc531bb6009dfce39f0dd0b02fc3048b33e88b5d41e4061", + "slot": "12859661" + }, + { + "pubkey": "0x92d34decb4c980d2696cd7b9a5f46e5752f94edf3a01f49da9c34ffa37608dd0e8d403e0a811a7bc774c95c98240e0aa", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb5702f3fb7876c3374403fa43b1310987d2f90cc9af3a6f065421a231523b5b32eece32e8ddb0ba86ed9fb2f62dce0260568bca480789e69673c8274f2fca0230ddeb1d5807ec287ce4939a8ac975b8b8174bb0a6e71856345756fa25e9290bd", + "slot": "12859661" + }, + { + "pubkey": "0xab2d1405e82ba48a427206ccc4c23035e2cb9ecc2591d9523db0efe5c073a42e4b1315978389bb7d649d4b00fd6050c6", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb4a05fe67f5533b82fb6e20b1b2abe488a85e2cb02842af3b126dc26f6d1fd1c7c45842963dc5c619793739d8125a1c803d65262f73d3e7547ffb0963be2e85d377119c5099a5039a11c3763ebeb1a2323d518fc1c97cc107c258779a5cb6737", + "slot": "12859661" + }, + { + "pubkey": "0xb350664607edadff042ff721301d6ed50af0d520df397fb3feed695b01dc5f05de5590b4261ea5e9ebc893c914de35dc", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x987cddf184df53ed04ef152bfb5f91bacde8940f5f7128bb36487846b2f1a067e8b3c4db345df0d60f04528747eec6351566a44d46983d98dd0784d975c84566f926d34e5e45094772e40725e4e746550cae93f3a7e6eaeb9af935dfb3b72128", + "slot": "12859661" + }, + { + "pubkey": "0xb65384b08aaac0a4134e3af830c3bfd66bf4246294b6494a0909234aa971802a8f8488bf6f403ed8da66c672e554bc1f", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb4df58e8c86f9ad23d8f7936c3d27c8da65d8ac57e6fbfd711b182419ec55f48585f860742999f25fd6d808762a7e9420267b39c42bb5077721d07a8feddea2da18613dece3c3f84fe1cb5d1bdd92210bf72ed15e5bb235fc69b10fb3e1de36b", + "slot": "12859661" + }, + { + "pubkey": "0xabfc9bbec39cd2ffa5336ece059617b8217eab47d7cc60f88c3ee9f91a1e04fac1aee3ae7ad976aeff308632e44ab158", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb3dea603c62e75c2144fe1207a4fbfe9787dcb0b2777c775c34f345084f2ba1ff85203370786a14458c3fd2d7fa11be0157c2f30b8abdc660a17a0afe0b347fa390b87d7d3ea8d7f2341ae7a274aeec33efe31789268719807a31c6d909f4855", + "slot": "12859661" + }, + { + "pubkey": "0xaa4bdca4929a3374eb3e41968bf575b6580aa60d1a9ef37937f3efed573482677626f563dbd441ba263df491a187b476", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xb8b42e7094dc7b23170a99390e3cb835d5df70606c5c499863b1c8640491710496e4c42ed979b77a4138e10ea1f588220ac9cd0469069b6eb947b2da930d7f38e1f9664b46fca6fe0b67ebff4e89c3bf2364b1b27fb012f358e90dbf5ffb20b9", + "slot": "12859661" + }, + { + "pubkey": "0x81408e7f5b38b56d8862ac67eb3f40ba6c6bb08d57f69c1879662f26dd761f13b9867b5a6afb5d95add320625dfe8edf", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xa9bc225d5a746d5054ea9b1c0153fa6196ecb2c7cbf6b63645ed178a871dee393237da6dce26fbbeda298e575a0440b70186bec227a4a8d330a936848ab73eb8b697608a170051aa8f0b02b71754ad78b724fb67134a84716d298f0dfbeda60d", + "slot": "12859661" + }, + { + "pubkey": "0x977998de0b8aa6cbc4479daae420f57b4ad64480b06f3f62db0f52aa695b3618ab35c8014416b33b5c48c44ffa3a0b3a", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0xab0e615367d9705ddde12172ef67346b852374d8578ceefe8fd46151345b452ee729ffee06e829d0e6ddc65c4030710e0b1ff813b0141d11e4c802be74ff2adfce1fe224487433266a4f0634c23e7f4828ea0af47a09840c77098baee2e6d56b", + "slot": "12859661" + }, + { + "pubkey": "0xa937bd2ea071e5872cea57b2bb4483c4968e8c935d5dd9caa5cb0e4a0a505e2b71fce08d309b832db692d04937b05099", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x98481b3d69ec2a956ff35bb4f7497563e1396fae875b06e14703a0a7d4415266d52220d78a16b697de00e6688c19c76e0af79e62ff0f4dfde9127863a06c73162efccd80d15fdc6e62a45197b8212086b74d72f0796c66b5d7169c968311b013", + "slot": "12859661" + }, + { + "pubkey": "0x85b6232079b2f377beae7f2d022bade18acf70e57db5618b12794319b6c4964677fe0c6dcf9e139c8bd0b4dad8a02043", + "withdrawal_credentials": "0x0100000000000000000000003a11dfc18b6e54d88fd846eff967c0b9da3a7132", + "amount": "32000000000", + "signature": "0x95183a9a482c3c24e355a58d75a98d997f90b567364206ef4d34bc35a812f8e885448dcbb163d0ba51d6c41edb913be111b85bfa24f8dc997ea1c06102815beeeaa66c9f1611eeaba046a92f8354e1df0e72f3cd2130cb42e25ae1356da51c15", + "slot": "12859661" + }, + { + "pubkey": "0x8bd2b7564ca60ff71289a04038281e4e8882102fabbe9d511b750ec5e15c1872d3d20ef5239a3850afcf2ecb6c6f277b", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x8b63c221425ac72b427213828aadcb61c04c31130bf52eb1b12becc03d35e31ac2bcb5b89e0c9a8e9f13fa210823a6b8088e78eebba4c53c9b4232a3582e581a0a5fbc08d093f177753d7ce26b4f90dc783396ac5d403e358f096456a71745be", + "slot": "12859895" + }, + { + "pubkey": "0x8f89a17518defd3d455b28b02948d19442a206748f8adeff41907f6658ec00ce610dd51535ae77fa72623da7615a744d", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x8dcef3e683e488faa9eade05ec43a65aa277c2fb67276c171f523fd119b971afd106ab6ffd4d2355ddca54e44f64489b0a0165548233a2eedfeb2fe765520f487b8030fca5c39869f6c2db8e1742506afd59413cd19fcc393be330d9a18b0bfe", + "slot": "12859895" + }, + { + "pubkey": "0x9300b68bb0c43a91870b7e1ceb4c167bd581d3b265d29c50f87a7c55aaa4c630243dd9d113df6784eb4257aa962e332b", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xaeb635ba454c0cd2b217d94872c05cf2a39c7cf962eff3db4d963b420cd87f18228852d4d8a7cd23b5faf1a20a499c210cb7b9ee1079b06695b802489af46658797e134cd003e7ca6a4ed6bb73c5498eaee8dfbad618e3c2ce2fc4c560020088", + "slot": "12859895" + }, + { + "pubkey": "0x8a1ae5162c4a9738481b42432dfa333591de895f5f5ca88971e8f0b39bf9d86bd314d34586dcb7bb7231f15067f550f7", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa415ede98adbc56daefb123c447998468ce533542793e322bd67bcf519677f7d4bc4eeb722fd30768284276a2e8bf2fd0a40f2e79188537681fb49c21febcf0109481cc56d976776ff734492d9a6bf0ff506a79d0dac62d983a0428e3befe36e", + "slot": "12859895" + }, + { + "pubkey": "0xad12655a39d8d090aa7d1235fbcd2c237091948135509b009cb7cfc0dfd8ff9a5526ca2e81698fdad678a5644566f458", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x96475658f9fdfde074639e49469a6d726a04c50da435235dc34199871bb7133cddbb69b37f20fa16dff0789512d68b69157382e254c5d41ca34c53200dcd1150195a9bf53a3775ec459e68691dfcce87d905efe74f5ae1f57c9203b78418cdcf", + "slot": "12859895" + }, + { + "pubkey": "0xa543e05183340bf8ee813df88b62070f310dd9a0bda2c3566c87478d263a18d30a2bd298cddcac353057facf0aedba85", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa682f5e17f2d6e8b5c2c8ed53eb483c46abc0cd6470e7229d7cc54f17739f21027ca7f373cd6c1c00d3bef7b2db331051683289a80f8f1752d6f75c8b65fb63e295e87424e75548480250cc84e37d5f252fe95734224081330be86c5d14e8c26", + "slot": "12859895" + }, + { + "pubkey": "0x8b56b64c59e5bd21d99740aee2e0bc11ba869a775a28a88161ab77980ef251a015cf7a17cfe6e1ae6d99aad1c1d7acda", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x941448f3eccd61565a253a73ebb8c7cc31e5b1bcd256a6502fff5b911e2f5329252aa1005092f0e27b9c6629e08db3c6093edff79693f5979ca86f86a3ccebeba38cbd59a0a73c3bda510576a6ca9a1ddba4702422f513a360ed47f01b43face", + "slot": "12859895" + }, + { + "pubkey": "0x85ef7d02e84129ed0d743ae998470e901eaa25bd6eba07f0c428f5345f720d4026aed7f19d16324747e50d193941b823", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x987fc04b4fa87044b27018612bf4e4f37b654cc8faccd381be045e6cb601dd8323b5a45c9609dd03ce8d64d57946bb640397fef53bbe08e183d6d55af9706aae88978c91b2bff5718557e32b64e19e94d5e198481529d919ef00257fc34487f6", + "slot": "12859895" + }, + { + "pubkey": "0xb239e059ec585bd92f78b2a2907aa5ca3d79fa0aed6747c1ff61688976f338a38ac79def87db205b4eaeb9e3f3ad569b", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x8a1ed49f783c63f0d161f4fa74f358a9906927c7d826c151ebb24116afa6aaa6ebe41f861a011b168c2bf0d28a4a2bf60c4cf4f07c1ddd812b9ed4527d809cdb49c6b5ee85a03d5bf0930708248b7c879168c75afde17105d187701abcc7a6e5", + "slot": "12859895" + }, + { + "pubkey": "0x86735cb198864d486dedb42fb8d217984df637ee268abbabe1c77a583d631504661adec0f2d5894f458027275cfdc674", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xb84b55d6fd98921566cea800d923749deaf36463faea1b4b8f6cb285e5a945bf7caf934fa859ffef210fa1e99957aaa41918158f65245a03ce64e04c51515b218a9949cdc87383621914b8980fcee473979a95801eca757267b3bfbcc7efde62", + "slot": "12859895" + }, + { + "pubkey": "0x8d14bd0f35fb97217e816ac4a40cc31656128603eea125c63ba802e9b8e87ac7a3e2e6c7a520d441a72d279d5533feba", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa997cdc7f823803d1ad70de4cdcd8f27e213db28d701a1602eba889c78419cf51a50474ea3173f4be592c72e9562899003c4c36cc88b8a318db3f84054c491094d2741c0a69224f9c1549e3c0f35774bd4cf29a12bcbdd41ca0071473947de60", + "slot": "12859895" + }, + { + "pubkey": "0x85741ee91e0d04104a43f823d9fd7abba8e3eb791b806f1378db0fbbc3479dce42ecee012513aea9800622b418762931", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x891c035720ddd165d8301ec18db4f8e947211e82ae8fb6347ae227683370e26f11ffef1c92a62ef1c6388212d1946c4208e0a222dbb6d6545b827d1aab9cea9562d3d86251e9c2a7766fcfb972612b4d3b89304d8fe40a7444861552bc495299", + "slot": "12859895" + }, + { + "pubkey": "0xb4895f1e97b01d280a9676aec284d7e5518fcf42a079bf4d8268606ee1935601e8e8bb1de5f371feba6884dee1d2612c", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xb3a1fafd0aa07c7ac35b14962893f398bc00c226e9f40704bc8bb5c373db09d88f357baea832ca0fa55e290397bd5792190dd88ef6f07f1e705ecf47b398a7d644ba6a78d91338786d209139e307a4399653d86d1cb9a2e434a4eeeee7a1a752", + "slot": "12859895" + }, + { + "pubkey": "0xb0619418221f42b5948ee8fb931671bd46059280f9de15a35fd250a02fdd1334942425a7532d5ce7bc2b9b14b0140b8e", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x904792b841b3e2c130fc302006be2dffcdf253f0e9a0c435901fed89f0d5a28b8c42e8ff18572b9634e09191b11ca6c407da083ae3bb2311104852d6983055d8acafe3d5b8498aff93b4fe8259732ba144b0d5d0cf3138bd409a2f9b775a06d7", + "slot": "12859895" + }, + { + "pubkey": "0xb322c5acbd16a5ffcbb78ff4ceb6ecba5652733fa294766981ec5af6781d15fdd20de06c9ac2fcff3a020fc4eff16fb0", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x956037c462fcab0dc3a5043ef251575651a91a8a10bcadb2b616a181fcae26f4bf3d76054a0712667779dd1222b3de5101d7194ec6252e0150c853008c910ea2547526234845383e69616765ce03da444a8713c185adb4e149d24c25d2da9058", + "slot": "12859895" + }, + { + "pubkey": "0x99adeaef7e78a80c5d6f865a44feb34bc07ea4882718d11210cf8b7a64b0e19b1fa0527235e0da0c9db57f455617da47", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa3b597c5701e72328c04b620948b39340ad2b298f7a43c14b6c7555476cf8c0e468a6b21a0405999f3fb7272b5b224b004cbbb096b4c1ec1f7d9166dc58ee9439f7477bed37904158b8a0fe2aaa791197a3ddeeccb58f87cf5323be7bc918844", + "slot": "12859895" + }, + { + "pubkey": "0xab198c78373cc8a510ce77261f6c2d2ebd0288de6710996aed1b4a0239513f7f7ede296c87e0943b6a4093f581f4e516", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa82cd64b14c53c91b6f9e94e0d2222a4a0460e11cccd623ac26d4c7199e736fda9341256ee66dd29efc3a8d1e807affc0723a92911b1f30a03e2d88798809d092ea4156fa1483cb386f5a10bf1eae2a7f47cda2f7dca303b1c6469de7f848b85", + "slot": "12859895" + }, + { + "pubkey": "0xafeb030fe9606dbf7f25769b46d35cab8edb2aaa51eef60996d72ee6bb324b7da16f978a5109ab5ee18f918a7a090caf", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x98f74e393fe3bab5a32316fcad03a3eeb763f2b47be095e373c5a2c8ee0841930d46a68323308858fed3d0701e43b00e1945cc14552af0a48952d421e66aa06493b3202680e3e707d205ad3fd3271fcdedb127ac5454ee1a10f2e71cec0a32fe", + "slot": "12859895" + }, + { + "pubkey": "0xa1995d7d889d95fe363b8fbdeebaae543bbb6803cfbd7528a27939663488534d39517e891c45f078168a32e20fcb7097", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa1db776dccff9d54a35e145bc21dc72a55cf096d4c79d4d1a1be7242ec8b5b5d3900f775cc68d1e3d6a9cb2482bc7f0c0561e1598ece23aba119c77fa101cecbb64fa85f29536815f909b40e2128bee034fdda9a6ca327705f53fd8be82dcbbd", + "slot": "12859895" + }, + { + "pubkey": "0x967ef7d11379977f12b58edb09b4860e154a7c06fe97bc46f58feca523dd0d0e19906704853143a8a422bd87fb5c540f", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0xa0d13ad66fc2e25777af8519c39563aa5f25df78998cdaafde464728a18d4a87ec45ab3d0e39d3b86688d7bb1d0b8ccf0bc8acfe68f354b48500f46d6d87ec0cd191466679fb9fe3c67b83ae126ade87614f03ee5c522f0ce77c1fa3920274b6", + "slot": "12859895" + }, + { + "pubkey": "0x8ce675c468fa8bf62aa01bb13ea2f5c0bd97c58a4b99d9b881d2214eda0174533821b835fcfd5a488f9d65aa5185d962", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x90b37b36fa6d39a63cf257e43f846657b55f0487ad281a05e5b2404b27360c6490b25ef0987554d9fe8d555a5381678c18d37c0afb978ba2cf32fffe9957431fe832f01958e85576eaf404cefc33e30b45f1fecaec1eaa01cd0dd5146860d892", + "slot": "12859895" + }, + { + "pubkey": "0xae06bf6b24363cb2bebaf7edf5156d68ea93c2c3db59565c56183528dfd498c7c448d6c86535279079404ac7e19029c6", + "withdrawal_credentials": "0x010000000000000000000000b23c002bc65c6bb539aad4c11d606ef4f5502c93", + "amount": "32000000000", + "signature": "0x87cc0e31eb7c8142fabc88f89094430b7d052053f37fed297360c4b98218082eaa8d63aacf5c24a3988891bb6bf570640ff13cbc4e25a1686d16ddf75bc78604626041f060cc86422fed08bdebe68d7ce6e0cd09b93c85cf15d701c0d1e60110", + "slot": "12859895" + }, + { + "pubkey": "0x82f1941b32ecaa7984f89a3575aaa90c841ded47b35e0f13344c94dd924ad52d97dd088d26c0c4d996be7cb58052a17f", + "withdrawal_credentials": "0x010000000000000000000000615343de8fea4f698a9f6a4c3a277c9e6584b50e", + "amount": "32000000000", + "signature": "0xa559b4a8dc6d23bbc5a33035954527f477b92d424233bb9ec2a8d52c702fc55d2a7ee90e19606c1d0d17f93c5f560f04150cb4ac0530945bb9e05b381cc83dcf6a97c1c7c5b40704688b19a283ce3fd510f50f4082f9c81f1de306d012025dda", + "slot": "12859928" + }, + { + "pubkey": "0x8c195b8fa30522bc812afe8c0c8840b8bc79d8d779fd4b3b8c6d2bb087cfce19b27d5682ca824611e3665570fe30dbc7", + "withdrawal_credentials": "0x01000000000000000000000056f924302fd8e3a71708b65483ef40cee383f73f", + "amount": "32000000000", + "signature": "0xb1287d8d120b945cf9dd9e142c63f7d3a167065aa2d4a8db7f494dd16b087b746f2933fa08448496f341a0f8e10f3032087c6565ec116596f41bd01c42ff4ed9f8818426ced0c34929cdd6d51cdbc15cd3d27e63e1439965808359bdff207294", + "slot": "12859971" + }, + { + "pubkey": "0x8fc64c75533af23d07dd90059a8b48f9bf0bb4eb766e774cd4e0f81a5f59bbcc11f2d0075296309998417400577ba629", + "withdrawal_credentials": "0x010000000000000000000000c55c7d5e3af0818f8a567acede50d3db822dc428", + "amount": "32000000000", + "signature": "0x87fc2e3f69dd289f0ebe1aaac00ea1f99dcecf05a4e19a609674b7f84d0a5789765da4890fbe2673e301d08a6a3f6fdc18162fd60d9721ee01a563b20ad19fb1ca8b7eaf26ee0e4760dcf44d7e7d1fe73998986d058afb752f64987b9d791ec5", + "slot": "12859971" + }, + { + "pubkey": "0x97aff615ae384ca8a86fa617241ed6ba1d335fa8e7bf13d1e895f0120ca3b3a088a7a97c481bf3f8305011456bc4fb7d", + "withdrawal_credentials": "0x0100000000000000000000002b78035514401ed1592eb691b8673a93edf97470", + "amount": "32000000000", + "signature": "0xb07f54ab3a043af673b3957430992230caac92b3148fab1de51c171326b8d3a61f1fb3829de379dc44356a6e93fcd9380c7d8664eb998c55fe485463287d3da1f2773dec1e023ca9750a75c4b319ee19fb4a9394e11c6df96b4dc1319f3614a1", + "slot": "12859993" + }, + { + "pubkey": "0x871e93175508bd7d7c6989a9d4e52e922b5dbb42bde1e742b7db9b7706906ba1b080f80bcce92d3eec9734d6acef1137", + "withdrawal_credentials": "0x0200000000000000000000003ba279e4fa904d2a81f36dfeb430cc5ee498c219", + "amount": "32000000000", + "signature": "0x97b93fcff3cf74a7934a57bcb2c6206f8bb4929d63530b5f5bccb363f9c182aee88da2c20d0ddb1069ee60e037507f4e0b53abd96ac8ea48ffcc73d20f72214220ebc06c9812eff1aa75a936c76708dcd0d9c35795075b091774eff685f10312", + "slot": "12860001" + }, + { + "pubkey": "0x941a93e16516b02aff03275b3368e97cd13267a05a19ba138d364cde99c5bd7397e38a50720d71cf6720b4e41e662ba4", + "withdrawal_credentials": "0x010000000000000000000000de9f7b8fd80d88eb51b1316ad81b3e1dca2b97a9", + "amount": "32000000000", + "signature": "0x8b1ec7c18791f1accd0c1cbcb43e96c4d13cb21b465d18c3537c5be449f2a5aa1301402190e31c972f7bc8ffa36ac1c8067f745e115fa1a6fc2af28b6db5f24fa1e7b32b6b893d50c1c33fe83a1371a9b5fe99dd2afd305d198fd2ca8bbc9e86", + "slot": "12860005" + }, + { + "pubkey": "0xa717c63666be163ee87e08f28cd5edd1c79aa67ad3cf28b0132c8d25a423c6670c08e5e42b6813f9cef55708ca347ee0", + "withdrawal_credentials": "0x010000000000000000000000de9f7b8fd80d88eb51b1316ad81b3e1dca2b97a9", + "amount": "32000000000", + "signature": "0x8980854b6cc3466c2390df51864f20c0dcb5001687ff3e9c2d6498a0fb89f12843f8f0cfdad3a27a9e477c67954ea673172c49d4d2897a89e22bb5e095e500d633f4e416062800b8ddbf8d1f1084a20fa3e8da668aac65e1dd759ab0eece1ecb", + "slot": "12860005" + }, + { + "pubkey": "0x85b33356552125db1a860520eed7a46b63d047c17bae54becdfd80e137f80d28a58dd3cbc8b82dc94181f0d43757fb24", + "withdrawal_credentials": "0x0100000000000000000000009a72b5337b6ff3c1fed2a0f52efe2d2b1a0536e2", + "amount": "32000000000", + "signature": "0x8ffe15dea564b56ad959417d557d89d95f7aa84814e663d3efe1ca5afc33ea2ce680b484d118a112266358d796d463bb0abf439ed50da2b0c967826bdb4b9c95ed98159f0b0e9fffc73ad8b3a5ac393597a3de7a599282860c41412fa8133c9e", + "slot": "12860058" + }, + { + "pubkey": "0x921c1751f139dd6785dd887e6c75eef427c3accdc3c8b6ef0af945582bd190290c73025f6ad6ae5ffc91cc5a461e4e10", + "withdrawal_credentials": "0x0100000000000000000000009bd494dcf339c599d814e473441d468a3341e998", + "amount": "32000000000", + "signature": "0x8484f3cff617eaa79713bd8547e6850d53c529a8b679ac79bf6239eca1b861834dc570584336b37c2a4848a7ab44a58a11c00d2a4c377cca3dd98bfd9e8860c516b5a9f7fece5012c3892f545c9f8237d3659996ead25b0a14fa1b745f61c938", + "slot": "12860074" + }, + { + "pubkey": "0x91e84ed0b8c6c602f785050dd54272d9f57d304c1dfa4bad633a36e1166a64a28ca2fbc80e14c7935f97db4c3755815e", + "withdrawal_credentials": "0x0100000000000000000000009bd494dcf339c599d814e473441d468a3341e998", + "amount": "32000000000", + "signature": "0x9757ac00ddcfb81a82ddb58d27125b5bc377b221a1597db878eed1c73a669146f0b2a940961901ff1cb106f0a69a63460ed15eed85cf2bef6631e82b9d61c59bb93ef2c0a6ec361cba7901d5cf5288fdb6db43cb38d7cb4b45bb6702f3b470bc", + "slot": "12860074" + }, + { + "pubkey": "0x8a08858f074668821d11fe1fe68ff318ada967f84a58eff8bd4d904c7bfcb67cfca0159dfcd93d9983f57503fc24dcf6", + "withdrawal_credentials": "0x0100000000000000000000009bd494dcf339c599d814e473441d468a3341e998", + "amount": "32000000000", + "signature": "0xac9189090b004eb8613a7b7b6726376949757d18e7d9996c90dfc981cf474c7032709a1a31d9781f3161fc9935b429de0c185b063109ab5217d80035eb1a59ff5cc0e11d4483b78132b545392dbde24fa8e05fb608af07eeb1dad4ffc321dcf9", + "slot": "12860074" + }, + { + "pubkey": "0x8826fad9dfb8daca767e034192c8b9788dc3bba915c3c56c38c6c76244f86bc7da31b4c8e741c0e8d49bcfe0c092aca8", + "withdrawal_credentials": "0x010000000000000000000000adc010fe31156643f4ce5da2beae4681d6c6dacd", + "amount": "32000000000", + "signature": "0x8d0014e27cfd8eb4d0e4ef8461c6e98a51b8578a299e03728a8d4e7a9877d19144cca3b892cbc4a8ea9e20f06761085b088c4c92b7d83cd909efac2ac4dce2de7914cf1234f660105b51cec6ca072170ef1d955ca95821d4d6899bd728e328db", + "slot": "12860186" + }, + { + "pubkey": "0x94b1f65966d6c74f871291413dd3ff16facb821e6d9af5a69f7edbfc5ff74afc9a144710214a30fa4c1ab9a9dba317fa", + "withdrawal_credentials": "0x010000000000000000000000adc010fe31156643f4ce5da2beae4681d6c6dacd", + "amount": "32000000000", + "signature": "0xb3ba1ab8417b4568dc540a3e09ed3a0f57ce1c743fc4b063945a5e658b38e413f6c58660caf1a370e3ac59eadd6bd78e1388b2c70d06be28c27be63ef34240510e3dfa9bed13d55a8ac3cc11958749cba4b2851906be0bfd85917b1f96b4e5e6", + "slot": "12860186" + }, + { + "pubkey": "0xa8bf25d9b3b746fcf267c68adb9b0774ee0224b8910d20b499d9832f7062e14222aa5a1a085b328b0c7ae5e775df28e2", + "withdrawal_credentials": "0x020000000000000000000000857ef454724d3057a7c9ffe79dadc615eba9baa7", + "amount": "13659052", + "signature": "0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "slot": "0" + }, + { + "pubkey": "0xb5014b070e9ef71f841a1cdb05cde2cc6d1c814eaa2ac212de7aea0c6465af40b76626b57b574f9af9dbcddc39422bc7", + "withdrawal_credentials": "0x010000000000000000000000973846d98a952745f2e2113a76c5d62ae04e1eaf", + "amount": "32000000000", + "signature": "0x96ae75ca1c3faef1acefa6c15f0ba38130287a21b43271031885471fe260859e2890a5a43fcd46db6578a72c2333713d0d1fc1072d6e3f9292b78790b700f8116c3e54f35563145d406fcd4eac0b520e9d4520c683316a96923fdb26fcf6c90f", + "slot": "12860301" + }, + { + "pubkey": "0x98859db9e906d6b585c5a0b54d5fd85b2d47563abfcab676125da99bcfea76ec5b8cb65ce60a181b545fa1a3316280b2", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xb08a656d53e432dac8b7df3ea935a4b4066261061f283b7541be998462d80c1b1e892d362ec0d606510ae4fe30d0fcee073e79d76febf273ba117d419f3a88901035bc863da3f50630b7dcd7443a48c35ea35749ea592e074443e53b4348c96f", + "slot": "12860461" + }, + { + "pubkey": "0xa07520401da51c216db2b96a1321388bc99d55ff07b69409728f56c9aec2933be922e22f4b0452060bc57529ad761ed6", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xb3588eb2b19b9c152ccfd3efe277b7f83233647a5d2d12fce225d16214ddc2d7188cc417ae64f440053a9ea46c5c8e1e113767a0333f7b26190552af828952998f3ab057b5bfe2f3a1e40357d4073bd84bc08799c5422a020306767567cddfa9", + "slot": "12860461" + }, + { + "pubkey": "0xb0184c3919de0d2dc325f93e74e8f9567eaa94e89c826babe3fdfdcd2a1f996874f7d0836817399c2b785932c464fe87", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0x947dc6eacb436053a923694025d6e162ba57366574105ccc38f3631ec64548aff1190eb82217354435fbe47dea53e4ec00c6282b484191ceedbb8e4f1158e47ef7821011703dd87a24b9aebb6e4ee9e7f938d184c9d77b5933720c84a0c49985", + "slot": "12860461" + }, + { + "pubkey": "0xad6d96dbba35b8529a0a9037b18e10beb6c87faceb3a8fbd7d07b2161f4dc19b639c184fd549e188d868043944c3e77f", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xb1a8b869788ae9f88167b325778def519f0dfec0e784dd3f29e45f232251996a614813c09ca864bd74ef5d86b8d9a7f90faed14fca9dfd58ecf3e9ed7984508be32a36522a890b1dd56eeae16eecb1ec4461bca1b66aa459bd66d2cb0eff1663", + "slot": "12860461" + }, + { + "pubkey": "0x8ab648635cc9b437da49982fc495d3b5829cf21a3a000d5aa73c4ccf4b5b22dda85881a9403a13264d44944c96be9d7a", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xb169c5b851692ef4e43b3075ce9c4bd2478a29120cffa6b528bc81af024cfe352f69186306ae95b80045dcfaba02d86b13726070f14d7c0a447137a63b6a3b54f760b1fedaefab3d9c7ecf16bf407bfc227a3d1429711f3577d79e5cbb3a14ef", + "slot": "12860461" + }, + { + "pubkey": "0xa38be649fe5995ccd277b2dfec7755fe35de640bed137b7715116e3f1a9cb2381733bf3e322486823e63e4920452aef3", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xa71f5f4ce10ce402db1de4f85d0b4ff390648dfc1cb76e1e7fba789c71311407eda0681f7d51ae2c277f4f3ab6a553ce133bd9ba272e2089e67cdcab4c8d59f48419bc357516ae34d4956d2a9c47f0322f117144029cc3d7e240bc8ed114725d", + "slot": "12860461" + }, + { + "pubkey": "0x8c5ad2a18413f882a049c21a50a25d492146eccfd3935c4ecd009f06cd8c9b3eb94b00b50260f302193b9d87064fa08d", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0x916232e92120dfe519294d9340992b01cdf87c746176110d0154513aff03c4eb296d6491a369faedb5f38942df00931e1749c186da2ab3557b9e73dc3d5fcefb72eeaa0a3cd93f1b300eee38952b24bc8ffc07567383042b6cd6758456f87d35", + "slot": "12860461" + }, + { + "pubkey": "0x928994d235b5f74f74cc6c229f02f54750902ae52ebfe7830ed10f65b0df5bd77918ce9454f17891304f9e67fef7a05f", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xa2552a42a646cc8fbef5e11afc37942d777bc1a057c9217007cfdc3265d91f5c08c3c58cab1f30737565fee61e2268c21782ffaf707f905458b63130aa9f846f28fb2786f4bf475692241c127f4e0b2c3a7b3bf7910f2b05761aad02c6a46ed3", + "slot": "12860497" + }, + { + "pubkey": "0xa1cb0cb5db2d371627ab9dcfafc2db2cf7e4d09b39281a927a7853a4f7046ba6dbc5428f595a6517e205929642555543", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xa8c401b58c129a41bd6d85868d8f6fe22862612eb7d72d86bd6e9403c59a07d20fbfbba1b80fac11b1c05c146a4b8793117fe8e08dbbbae20cd7fe3a2a75dabbcfe5f3c0c39e0a0f217b6cd537c6375cbf59f58d8fb5c6a027cdaf0d15f06d27", + "slot": "12860497" + }, + { + "pubkey": "0xb269646f3501c0281111e3e2b2f0b18b8f1686d1a9993e0e86763dcecc4494006cdd2814e0b45feb11fb3dba9ba7a7a2", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xb382029d10f4859dafdc2fd63b43f9a7203e48c70fbf1bfb00861be6e7e8200a33bfad5d5dee20fe05efe18b867b81000266387c1054fe49fc2367e100459f0c00d39cd08238a511b035b6f243cfb57dbef80b8cc6e4111583ae3da2801b72bb", + "slot": "12860497" + }, + { + "pubkey": "0xac8e2561d6586be3082a6328fe5b93a61673934565ee2f825f133250bcd25507ab87c2fa075c1e68ecb1eea66fa95304", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0x8b04978ff1b8763f62ded56c2875618eff641decdc4a77e2f7ad948f6a02469ad844b41f39c4a940f20cef241ff25bfe03cae58a709e25a5bcd62b2aa7d8042c699a609592cb444dae63ca11b9c6daaf5e802aaafbab5319e8c9392a5f7bf44b", + "slot": "12860497" + }, + { + "pubkey": "0x9528aa227553a8a48c37e396ebb75fe676e5d2c1806c9fb36bdf6088e709fece2cbf2091141df9596f89f4bb60c1ee88", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0x81a60dc4c6a99fc8ef5095432c670d04ee5fd633b864bab37822faf0fd38f8136216c4fffac71093a9c2f3c43170f6581315ff08a22a470c1458a48ee81b575b78f3a7f7978c0423e764d63139d4289a53bcb8a3adfc9cbc3a854c9455a027ac", + "slot": "12860497" + }, + { + "pubkey": "0x80c585b60f901c759d5a69c9c38c820d502fe2f880623a2dc499d1722d7aa084bd01e95a1d7953b2edecae2c3714231c", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0x8b102fae81c6809b2da53aac0043ac098163f96ae61960a58d2546be7ea01cf526dfd53f8548aa7e15105d9f97bc70e1004d68a5d610bd8614e1d741b988f2564028c2b670a95c80f77fc55dd6c8736d4f1c46c6091a362c06aa3e4709fbd69a", + "slot": "12860497" + }, + { + "pubkey": "0x8272bd6485f3d67d877c8acf9c6ad4e70a872e1e2f62eb611d4c3779101953da3fbfa1fd58be4d517c19cd93640ad0b0", + "withdrawal_credentials": "0x0100000000000000000000008fa1d8e84df25175221e4003c420ab033e06e443", + "amount": "32000000000", + "signature": "0xb5244bf04626095835a3a8594a40a3cbef5b244298e3af815ee25cb5f14cea9fd369c906aa4cd725bec320525baf8c21105120d8a9e5b1dcc62a5cad33fc320dd2338ce376c8228966fcddc710cfa06f08f207b65ba7b9fc2bd52571ea0709d7", + "slot": "12860497" + }, + { + "pubkey": "0xaa2037e605b9cbf7a8dc3d0a165e3bf07707ed9b8faca7a9537f89b9fa15551c495f23365166619e5c4c19cb1e963bd6", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb49514518c518d6d5b4f961940ff37dea1c698e0f4aefaba18f89419dccd6afb091784915df8913b0b934181bbeffa8b0d2923f6e73ef520aae0a39d21e298a7976fca66b736d88ad747bfd985334275b19a2d50f36c6ab5f08803b5eddcb633", + "slot": "12860532" + }, + { + "pubkey": "0xb7dbc6ff621c020550e489f3b75befb7dd06acbf465df4a6ab5302e84ba31da7cefe06ae31e0df25e7395869f62dd354", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa1d4edabd7d8638edfecadc0c643816ce1f21f5d039b70c6dcfffb9d66638b5e1b4622fd4a87c345c703feaf743dc8ee0074bd2c757583554ab6b55b3d19ced28b854c10989b5d196050e9e44117dde35c3c67c7dd8154690f3fe242c7404115", + "slot": "12860532" + }, + { + "pubkey": "0xa1a9b90f8964ce9d36fd9c61ce7b9f773c18ff2b2e214e56132b114eaf6acb3d4ad932d483095ab23c182fc917d07b30", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa4de6090dbbbcdb8f74bfca41990b2edefc270c87004972d4875724bb59af0e0ccb9f8c66982bb77464ea84e9a8f165802c0b5d7b6a7f7fc7af558ab2e550ca18eb200d15d5379367b77666fefbe334f453fffdb63b8ff687067a99c57b5dd2e", + "slot": "12860532" + }, + { + "pubkey": "0x90253e74b2329b80ba814cbdfe0054f1dfd055d701438e9edf2e8ca0dd0cfd43707fbfbce4cf69587a26dfb5de36caaa", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8d698b31e3ca4f9e5fd70ee40e4f8a07091e96ef1d58a87564a6fc9256c03c68ecf033a85deb1f41b5517e024fb377f516a41eb19cf3912158d0627500a7b0dd1af5d919aa98dc67f41c1b1b36f64a0068d709514e791286cdb71944f079a33e", + "slot": "12860532" + }, + { + "pubkey": "0xb4047b8281653f74d101aafe1fad056a567aff2376e45aedd737e0c779b04af0bc759097230c43f537819df3f0cbf011", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xaa26d63c8e8cd30be82fbaf982e54b2189a06a3ce282e764102e89e10e4ea77c3fd5fb54808ecac5c4729087d06e6edc18a56f37483e169d6c0b4bff4147dc201ea4049c1e28fab96bf61c3d22cf132cf547a3072fc658e58a13a2e5be04cafe", + "slot": "12860532" + }, + { + "pubkey": "0xad6eabab5f15473b8835ba008168cd4cf67b383c8fd500eac4c365a3f4ff7b869b68e8b1d55fac5c7c624b28361c9be3", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb0f598d3b60d76b528f76064fb78bddc734f2f0b957afb2b33829ebed2895c352ca7145b44f1c354e9266f17c39572750e4bf59add9d9566b3a2ee91efddc69615e9c4c65e9c07dd8cffdf361899ce1862b6be7b8741ec8c1ae5b413072b614d", + "slot": "12860532" + }, + { + "pubkey": "0xb8bba047c5adefc0c67f7ca70833a9328501cbababb9a9810174c9159620728c476ac0369bfa205691ff22053419467b", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8492e695c326871ac062ec9626a3d1fb3032fd0635f25f61bb614ff05de3f7c287fc9f80cb571a664030d99cc1a41bf8192af4a59198c6ab01ab0d202d92d6bd13b3581fb7e859e7cfdeaaec8b19593fc67f9b81424fd176197ccc4484b773c9", + "slot": "12860532" + }, + { + "pubkey": "0xafabb10d329d62e60b7b1cc772e418278d1e09254ac9ae420883112fe315b30baff48796696ece10796bacc99de961f4", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x81661dfc9996752a4bbb8c7e004ac98b6944fc8212a4ab57caa6fe78e5469fb678d24ea4533ea550840949f98f7b9dac093f30b5190072e5da6a989d3e373cee762abde861790dd4733554cfd57f0c49e045fe1f2ce59cf6e047d001e7c78acc", + "slot": "12860532" + }, + { + "pubkey": "0xb25ac7a2fe72181e82f2b15fc321542b0b428a50c91974a215a2f477d09b1d6a9c24265808b7d887579509ba8e97aeb9", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xac7586075b3b9bd666a24daa4545dcc720e435b5a305933209ee30057c7ba8604a25baa00bcbf5eb7afa27ed9eaed79d0a531fc828383c6171cf741424f32004be2ae7a20f41c9d18147df243bbf001fb4991a5a62aaffb59ad029e9da4362a8", + "slot": "12860532" + }, + { + "pubkey": "0x8c4942eda0b928f9287836b8629edb54322afc1c62e65f14518816b9f86feda639ac6569b8c0585b79b76e068356a72a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb1b8dcac4d59a6cb8325ba03c56852de271690e6825dac6c8eb6c005aa55f5dadcaa57dded0e8f535963654417365ea115d6f188795ae1cc7f9222831d2e4148afbf914634faaf175c1be41f3d822d7e78400c32e5af2250b34984468a8397cb", + "slot": "12860532" + }, + { + "pubkey": "0xb896795f12d3d5804041f439f9339a99f809a5a093a66253ac7e82392dd7e718400977cccae1a4ef874aed8955f06ba0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x96ebd226f5ccaf4d85b766c3a20ecd2d8d802c54a4f7461b758686c2161d24a8f6017d3f670eddf0c8f701401fed87491693894c9f6f8d828ea308a98e9fc7f67e923a2b99bf3fd42c04a2adf5cf7d87c669294376a26ea4695e02c8d308a0c9", + "slot": "12860532" + }, + { + "pubkey": "0xa597e17407209fbfd4e9258c193773353f137f9ee92db3d4c8d609d01a17a7206ea27b3f6e657b4ec73b4429043619bd", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x948d5961a95ec57b44bbdbeb0b68397e37b2ad99b92a42b0c4f0d0ba24511b20e3ca9ea374782706635a087c659824010ed5da78100d703f1197065f6e96c22f20f1e760388fdccaaa5bfdd9847db70f178335bbcca6c18e131b1e0e729c5019", + "slot": "12860532" + }, + { + "pubkey": "0xb4af4a8a34abedd0cbe75487b6df52dd29cc31a78c9dd09c7d45b3be312866667ec3e9a5625a58c1eb053569945249fc", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x883cdeab52e85d6c018782ca0a3c633d9a0d87ee5806b049fd675448840e07b051abac719be428439076f717383a49160eac1212747d2580b56bddf5d8527c95a7e0e38637e9746f3cafa5b5e3558f9a8a2a2523e45bd86204403691d28a3940", + "slot": "12860532" + }, + { + "pubkey": "0x81c8dc2a2f88a4db3c026a1af4f43169f659e56c7ae38497af3ca7068c2012cbd0768f4ffb9610e425e8e19636b132fe", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8f493d61db280acd8ad8a1f2faba13ce2d447228b38500f90c180e8d92328e879a19d2a01f621ca5cca3cdb9ac13a0ad10c9ccd2c933a5ad87e6472888e540107c32f8485fe44b0dbf11b8f3ab6030bf2d70aee3f253d8f9bff44e4079359c01", + "slot": "12860532" + }, + { + "pubkey": "0x84896fcd4e7621430dc47d6ba5cfb3ea4d319a0cf6f6499a8d431f5fc6ef313a95d6671b7c3a431ffda8c714e5451df1", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xad2f834faeb2bbd5df1d14736cd4cf3b90961db00f80ca48d99c90711817235dd01b1622174bb05c667b8d93a2335b4f05479ed9a85992b70d4627a600a9dcf19ac7f0d8f3bba38d659cc3f9009034e6229b2a67df375bbbf2bec5f4ec0a2ebc", + "slot": "12860532" + }, + { + "pubkey": "0x96b976a1a8bed929f2e7241262f57637c534d176e15ce5493f70a0759bb3861e8b24033953761dffc643bf75588c9a84", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x82bfe2625aac52e1e19dec81b2dcf1916615de21f1108ea68b363c0a6a42980b021cd371721751105c10bda4e46051690fe31e6c3d1d7c0baa31e6839275e5f64aa76b4a1c64418a3e2c1609f488b7d5f2dc374f4575f24cfc4951f3a5cf92fb", + "slot": "12860532" + }, + { + "pubkey": "0x91ffd4cfb42c40e93ff2e4fd3d74acfa4e6fbe1a01e155f00d144ffd867b23fce9812f8cc089c8881fb7c87b97e88a71", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x91ce53775b3a9507f9c2a66545da9a0e3d298ef66701dcc170acb944d223ce0d1c0c4c5efbf2a4f437f4a9632c35fd4d17eda4ef864153b06f4d7b1d112ad179c3436e35125046b308266df27ea923a4a1eb6a25866865239432fa878c582054", + "slot": "12860532" + }, + { + "pubkey": "0xadd6ed09912080e3b5e152122f7582fe90523867c5542b1c682486144d654bd4a6245bee2a3ed1fccf3793c543afbfcc", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x84b287ff9eccf2ce352ce5efebe8156d8cd766b694a3c3b423d11fc09a149116b0684d168a729fbb1b830c70faa3c08b0e6605fac7220ff04343779df2bd4adb9d8ee8b5b8ba581f2d6b6d4cb3ac3d4a48928a4b4c3d8af72bfd31e804534550", + "slot": "12860532" + }, + { + "pubkey": "0x9797fb4d558df515f553e30fb4af400d76473f294d12c382be82926e519441600b19b33f248b50e84e6b0068f709ad81", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9002d4a992aa9052aaa8805b5679bcd891115ea8bb21546f9528bc9b3eb39058c67724a29c27665238c1a0f37296fa3d07a7e3512687881f4172957bf8409200cfa9e14aaa4933d9a76968cd2c46ab83073f735b9fdd106b10c3767fb2bd4057", + "slot": "12860532" + }, + { + "pubkey": "0xa024af501ff79979b5b36e7832bc488c66d3770b88a4782f74b66635c91b051cf310185a83a51c7fce75497cef793a6a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb8d10a2e314fad64b723a534f4aa7eb7d111296f352bb1efbdd7d6a9c9dc8236b3b67892fcf45fa90b572c1a34617ff019fb36b33fee628eeedebfa684b0e6b7042d47cc6a81bd63ab66e37b37106c187e83cd6ce7e2e0f2a2f143d58970232b", + "slot": "12860532" + }, + { + "pubkey": "0x839bcb2926fa042139797d3c0b4a30f2aadbe992ead44f86e0b2a01d0f207ef859c03d588e0aa095c2cc7284d6e328b0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa3afdb0a60f55fc91e34787515009fc87394d532a0c3160e3b3dd62f31f686885d7cc5de9dfbe2b51088665a61da7d380959e4b558f841346593576a21dcd26f21693a6ff6bb4a53c9526714d8436d4f29f9ec7e7c7d007ce0e89bbccdc605e4", + "slot": "12860532" + }, + { + "pubkey": "0x953599a704e891b474ad494be44e4e96dfbf5571b1c664cd9fcdab62db7e5d4e45d947723db93526c501d9dd57d08bf8", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8e43400e1167fa6a4c2007bd49b8fd9c8e0508c4d07b8adc87427563d28d15ea8348f6bce1c273ed46bbd0029a73276200a867580ca4d84a9a4b64977e1def11f2337d1062c7d9bcb38459f9f4964a6ca52a27c6adc60f79fad0d30e91b717cb", + "slot": "12860532" + }, + { + "pubkey": "0x983a1d409e66f171288984d7f2c2e97c4aa0c268de3e526273e46fa8f5f67337034c4a6c2aad980a825aa6399ba26717", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb9f533f1618ef47c6eb91a69b5536544802528ee3e2d9c8055783d6d04e914b8f148d860f5968b0f9deb178ba257ffac09e5342314d05540cd3151128d8f83cf980caa7baefc063e564e0cd835eefe5ef326e7552890388509481dfdd2ba398b", + "slot": "12860532" + }, + { + "pubkey": "0xb846e798bf2b0759c9df6e6e4cf08a949ac02c63daf871e6ce46271948c05ece970c4c52083c45d6dd34cc7c3e2f3700", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x84ea43205418ced81863895948908ed107386fbced927d7d497bba11aa719ab8c373d6ccc340d1f450cd938a21d4ac5e12fec68703251cca39a0a3704b3b20ceaf736836d8d2f67907993ee94b247786a5e8c9b1223d1c3a6a661f0886f5d304", + "slot": "12860532" + }, + { + "pubkey": "0x814e7e6f33ee68714285d1790d986f604364c6c9063856b62435c2a3ef6a1a74b5c116dc748364ef24d4931a28f643d0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb8dd4f51b3c5edd10e6522b80aa02b40fdcad6686a6529bcdfd31c790f50d805435e811d7480fa77bfc8bbf58e5a4b2f1010e09be25cf39a27bb96cb01b245ac9db8c2d2fa4f857b92a14a14427ea078ec87c297d22237bc969ac9e0928744ba", + "slot": "12860532" + }, + { + "pubkey": "0xa29c51d6ca7a69392dcf1918a3a5fbfb6184ae7c7481b5f067c758465ec895aa9729d2c153b703ac2333544f20d142ae", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb246532ab6f16ee13c958373940edd697e14c6e529c36a07db4e1e05313311f1d08dbceb5c155a1dc40174b4eb821e7f18c9082e78cc9a9f0606d8d82d1623194178c5fd0941815ee84b40f20c6a05f77937585de1c1f9099b2e36f8692f3823", + "slot": "12860532" + }, + { + "pubkey": "0xada69a24820bf33a7ce959c262877aebc50a93be249885901518dfc03e52b3d161bac47f3852ac9db5a03422083f26a1", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa43836fb5ed402c9c66cde9d45734da500a5fd6b66554787442c3ebc189356205036c687f6312dd9ebec858d5d51da78004c444038a3ab09c683b6387d98548bfbb07574e0694ee55a2e7af8fd18bce87a2e2953e2304213cd626af0b9abf2e1", + "slot": "12860532" + }, + { + "pubkey": "0xabf740ecb132929b7e4469b6d53c06c8004170ac6b71bc36d6c57f7ac5e32db3792e057c36fb7367aa014f7bacb0fa00", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x80f516b04d675b4e4c44c521ed36080d75088432d3312ef9aa004147cc2281a352dd91a035db50a8de7649e31cc7aba309fd07ba7bf81c0686ef4052942a94bb2cc6165f24fc0cf89dc3c15fdbe17480e251daa9eebc2e3d8d38548b29650cf1", + "slot": "12860532" + }, + { + "pubkey": "0xade9ffa3b2acb2a705e786e6e269e0884660128332ff2c5fb62b955c6f0fa6f2766cb199715d833bd28b8dedb1162a26", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa7d041e5192238e15544b6bc49fd141fef08e2020832853eba930dd44dafa8fff54d57875e9832571204736cafd3843d1703f4ddf70701cbe45222e23848fbc3e79a1cdb02bd566da0490bac187c0162d141d2ccf0d4cae38835afec95100312", + "slot": "12860532" + }, + { + "pubkey": "0xa40fa47aa8e8292acd28879d071e2e304c88e9454087d9a45f50289e925ed0a4f2d992cd3e93f83c1507094918f9a18e", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9214c50b02cf0b8f25d1afb049b1c80275cd3afbc011c29ddd8c10b92a071abb4fd84e494876bb79e0cd876da2451af3107473185032d0a01e2e74cf005232c3f41b56c89367c7cbdff8227d8e5c7fec1cb656122788fd7c5127cfe38def81f6", + "slot": "12860532" + }, + { + "pubkey": "0xa38ea261a33d49dbb0ad8cddd1dc9bfd0428a99a2f68ee1f0b3156d534aa9c0a61770cfdb32e18ac603f9385fa54a831", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb62f96b77a513be632725a50213845bc4393f062668a6ef3fc9492428247bf1361ad964daeba8581b886b32ed5c731b70223edf22f270c4d91c7e7460f093fd222326c467f1ecfe9f86df8f37b1900bdbdc0cb89caeed74790bb4e03921455b6", + "slot": "12860532" + }, + { + "pubkey": "0xa883bd39d5e380ea9b27d1b8f08fd8fc6ce0faf313cc0cd91507f76cf88ee27ad02fac1da6aeb757e2ee365641b3ed5d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb3b95cb01c3aef50e672f02c8be17ad54a0bc2bd147a410c87e21211146f8f22ed1d5b82de4f35b70e59a94538b365ae0d54a87ebd8d20592700795eddcedad590c870830cc3569d03674921c12a94a1ffb3952db8a554a34fdbb3f205c4675c", + "slot": "12860532" + }, + { + "pubkey": "0x83ae1cb066455bfa6246af8a60b5bf7a5de9cc7cb287b12924add7c20b88773afdd9564aa5344136f87e4f642768f4de", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x87bf7f4229dcc0fda937002430a1b38f9cde62e484c7232719a98962d1e11c85fc502035893522f2931282939bdbb5ac026822a83f7f5d493bc65445ace14801c8230f1f1504a2b70afb6b6e4a10a5654c6a2549c076b3851193186cfa8f3821", + "slot": "12860532" + }, + { + "pubkey": "0xaa4be8feb2b80bbda6130e76387a7611d8d3a46a453a6ff1521912f230a306458b9e96c15bcd4b0e53ed975bf9728267", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb79188381bfd692f9f17a87c35f72546ec3177b07cd34079fa40595355ebc02dada24d1e0d1e2d8c3fb40342f754fc300b067e86994610f0b95100ea984e7d058221a6c1594f94268a204e7426f435cd56201cf5ce7e8a07254b870654c67f1b", + "slot": "12860532" + }, + { + "pubkey": "0x82a6ebc1cc1ce15801b6056bafda8f5658edbc9a13d7483364ba181484e633dc75f424fe8cfadb931f420982afab3e2c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa3edc365729e72c989c6d1f73b8c9ca5303e09efe61cae0e566e2d32f0f97af6a54cc58c801a594777ba5e4b0a0e67d416ae731be7ebf4e3753caa845659aea0c51618b6e0f891bca8cb42a0bba0767f23f924ee4d03f37880624205d504fdde", + "slot": "12860532" + }, + { + "pubkey": "0xa581ac0f2091746e115c44f1d5abb507f923d169c9465bb78c2d2429c6cae0955694c583556ef1b1f1f5efc68395c995", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa12d402c34958dd643d84b662a8d9bc1542cf54c5db7129826f68b6147a972b9a37a9ebe5d2114ed35f49aa54f8b0f9305a7397ed10aaa509bee1da6897c7a160fe852b5cbc0820698d6f7d1309a33b8c73cf18d597d1e058164ead445554b8b", + "slot": "12860532" + }, + { + "pubkey": "0xb3f7827c715ce6b3372e58ce33d7b22df09eb5f1eb456a0726672984bce5cdc8939d7495bd49c44f99ab8796def0dad7", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x99f2574d3518fba3bcbcfeaab96274b14a1c58be80bd47ec491f8235cd4e129f7918a41c4d74fe74ab50c940995453310f2a1e6a6868c09620daf6589e1453d0a3bb0105996947a96646996b43f32eeee2af809490e944b218c7362597a8944f", + "slot": "12860532" + }, + { + "pubkey": "0x80fa54f70f1c6858abe44ae017ebeb25aa5864dbdcc56e5450a4322041e7d54e40fec4095f0617ac490b71896cd25f08", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8352150173fb3bc5bd598150f685d42000cefdab67dc6a666680c2a27e697d0826e4c0eb7848cbf8a4db733d5be6f5dc02c074449ec4cf59e5d243f6757ab483d248f4d1a40eceedaef2e810d2c719a682e034b07251ac0c91a888413ab69c31", + "slot": "12860532" + }, + { + "pubkey": "0xb600b053bd24f7475bdd18b1f2762eee61b34eff4c4f2ad1aac18fe17653a90b5b52444256ff1763ea717d016ce1744c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8aa42aff872c457fc6d12a909edf574d6d1b677f981a3bc78c581fd511c5a28c1e0153a4fab7d851ba3205fd70fd235d08e21f7d9509ae3736153cc39473d0172bafe27f978fab3e7d8cc8dd0d7fd651accc5a55703a794282013db15e2c25bd", + "slot": "12860532" + }, + { + "pubkey": "0xb703fd5410b25ce963b282b1263a46a68c11f72b2faa2dd913664ba96b2e635e8692b420571d02a6055195e079028e98", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x85abe9b2bb207240e5e0c0126532833242698f156e8baec976ead6ddc962813d1abebc1fd686698ce2dc83cdfc66ef7e03076f95c669d0a5a3942d3ea88e8a0dd97334d4bfe69d9d2776a27abbca1e1770872a757d86545bf340f1fda9f3e498", + "slot": "12860532" + }, + { + "pubkey": "0x99b31ad98692243d8be48210cf3b73ce4e3a1b65b7d35335789bc873858b31aabf9652551c71af76a5c742ed8e03d209", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa271b670990f0c898357391a5a7f561d6cd84797a6a176735affa2c4b3eb188ccc5b75e0b113912a834c9aa27ee47a43033cf6903523ad2e870ffb8d8072f10fb05f3e1e17df610b5a834b61a768fe79f932b431229bc3a75cc224f40c10a59c", + "slot": "12860532" + }, + { + "pubkey": "0xb68baa8052b5a4dc814f21fa6cae0fe82efab99920135cf6c560a0530a6f625d4b4f601a7e844e0842cadec38915bb93", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb33fb9cda87da99292de4368ac9e22faf170d9ea13ecd0d5e40fbf7d9b8da761fada0bcd28950cc1c3a25349a69086000e56cd7284b0961e5bb973ba80c0f346167717d985a12cc0b85a552162e669b52db854846c8b58aae39ebab6d8c51495", + "slot": "12860532" + }, + { + "pubkey": "0xa336a691a13172f89e027a18ec2d3c5b62a33bbd6d0dae942319cc33a18eb2e308a99b578c01afaae91c3acc6aa4c28d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xaabad807e4481129cd842521a3c06a419748c0af2b5a08f131e788fc06448feb56cdd68a1ca3e18f874c35744ebcc11e00469d906dd7412c42319a5c2e141f83e41dff31e73987d4cecdc588d9e81f12dcee77aecc7d86e6260b6f264331f284", + "slot": "12860532" + }, + { + "pubkey": "0x873f019b953575804360ef2626a7c92e4df0081a516d0fa0f00f381915973d40ba3f2f9954934efe0ded1b44bad11661", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x877501cc13aec6c6f776ecc2f767a83f7e0d7019e382357a72720675583cc7713694a6dec7e054fae11f73c3454d12aa0ba9e90f2429292395216c06954b6fe79cccaa0e8177d54191b7ae867633f78e08b3c2f5e0890c23af49d5eb7b6bc616", + "slot": "12860532" + }, + { + "pubkey": "0x8b32c9035ec85aadfeb3770ef2041b3e9705012d3c1bcf032c7ba972b91e6df8ae57a62ca20e96c57433c23c5e95eff3", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb8ff241dc9be37ff3420fc41fda60b7924f52ec1d9ddb53f7829e20dbc38b54bfd7b1b310da9000ad1e865acc56bb2070e78151ae930bda21c7f68279eb3f428c7ac06dbc67051caa3a9363e1ac27e0e0e1f35b800a7f0f2d97bafdf9050e6f5", + "slot": "12860532" + }, + { + "pubkey": "0x8bfca3d3a6a7808490e666777526a4a3484ba357b49af0994cc5fa188dd0af6a9fbdf950d6e5317b45f35a9e47101f13", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8516e27da52bbf03048b3fa3eb6faefbb06ab38595efcaeb06c3550af1e382b3f5570ae16425d0e1ce1b3e8af677d6db10243c84606d4fd640c37e4b8e658be9faa4613a2be4d9223357c997815a9ac1cc39da45405c4e6925bf0cbc7986b2b6", + "slot": "12860532" + }, + { + "pubkey": "0x8e484862877be06673ff9aadf21e97ed0574999bdd1adbffabff9efd41d2b70779f1a6e29e281641a3902e1c28454232", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9686c9f2a207a45be4f7c88c627a2cf2812040a318437ee363d5a551695fba49e2ceae83d6a6dc57a2fa08dd00bce9480f81f38a84e610803daffdb21e40eb6946f8dc90ab9b0262d727412199fd9804f9001d8997d64b92f072d2f7a06d8479", + "slot": "12860532" + }, + { + "pubkey": "0xb882079cd5742a4714de0ff82ce0746470fd09dc4fce0b26117bdbd91e4ed0e04f6f365bfee6654f1c41b3697246456c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x950cdcfef75352cdcd9134deac6e81ceea532736f619b43b15df525a66aaba09e303f4e916ef0b0f25dbbf2f9edc524c089250cb0c0c9d5ab8c8b03ec5fbc342cfd5bb15bf135605d3a86ada8b4d466edad783988650a1486a57a2cd1fe7e690", + "slot": "12860532" + }, + { + "pubkey": "0xabe517c7d0ff0518b107871a9152b8319d1829858c749ba47c2323f9b329ff143f469c1824fc2bc4ec1d6a0f114d4755", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8c74344cfd128319b91acfa496c0f9604b76643bfd109a2007c9d488a0d0b85c34d37b90e5d2fd4f32fba33ef7bd87bb110c0b4d65b10b3f8016455db6a0e48064360fa4e5e0b348e6a748d9097845e51ca60b0edc7875e61ab3cba5e3a57b97", + "slot": "12860532" + }, + { + "pubkey": "0x884b8140c734813968b3991f8729882b796c97d4f455c21ee0a8ec04d81b0db10493d5d66d2a487ec64ddd20df1f0fc1", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa3ce9608d3f0ae8c6da5b2ea47eeaa304425bc55beea34c87c7c300937fda89e3b5c96558d02ff3bd7780a5e25d642a8180aa7aa2e5668b3bea038a4ee18ebe74b7f927715c4a4aef0114c65d9a90fba595769ecf4893cb0ec8d1eb98094abe9", + "slot": "12860532" + }, + { + "pubkey": "0xa715459612a8f688d9bab59a4a627ca1081c9ab4c96edc21d3f4ea05a957f2d29b8d045f1b073f0d0894b99f67bab02a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x85451d7ae7f75b0a95676ff2e61b944290d527fbe4c361e053b0eeeaa0015464d25a498afecbb1939c1e4318f8b5771f0dc83f54e3bdbc15470be8c3c13f646480057c600841c6a0c8aab6d8d55a665403d2a68f657d94929d11eedd687a1e1c", + "slot": "12860532" + }, + { + "pubkey": "0xb3cf5a79d6af77dfcadcc2d0f8509dc3ebf8b470caa7267293caea242041232226d52ef9b2c97dfa09ac7e4652eb536f", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xadab5eda9ddc2441139645a4c35e6d7fd2faff423933d723d1f7e84c65c9a8f845a68ef49c37870107a2a7e6f39a6f8d19f8c98ae332c53e11f8493c3f1e2eecbc0b247d67f7d4ac46cfddbdeb16db7db2d26aea6361a621bc006ee4176646c3", + "slot": "12860532" + }, + { + "pubkey": "0xa0319def87d068ccbc940225ab705ef8f289eb7b0861df6808083ee0daa39b1f41f65c16234190cedc418f793d98f1e6", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x92036f3adb2a54e9055a47e0c4d33898f90848169d078c50a3f8a872a8e97598322cb8e7d583eb33fe8fc6647ceb30f20abb5270ab80d0e03fd57f30f50c5dde890fbffa4dd08957cb2395922dd5638429b8cb35e16af4d1b0c447021cefb482", + "slot": "12860532" + }, + { + "pubkey": "0x86ce270f88a14546e369139c0eb7ffba33dc6ca81cda302b10d7bf987c09d0a59da07c8da9cd888ab19ab1136ca893cd", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x976f7c9b4b49c062e2c74a6b0e3a239cccbd2d983a82ef6ee27f10d24398af74713ef55e07f9f68ad8d9c85c6af7d7c30fe4cc8fa2455fcddf56afa9da3f2ea1fcbff344ac526bef5f3ecc178ab97415670afc586b8afd51f811143f95a45b7d", + "slot": "12860532" + }, + { + "pubkey": "0xa183c955c1c4a21784fb124414314652748e93a355d8ca180091cc97d67914eaa294fc328ab5f8271e42fbff951d375f", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x98f9c34d757c92fbb73120bfa17ec729a115f133c75b07543ce7dcdea9da6a5ed6d851d3714070cf2603e04dc2a13b4010f0c4267c0feba96179ccd71eff8d942604997d04998a0824f4e2c8850955c93bd203122ee95529a0de993fafea1473", + "slot": "12860532" + }, + { + "pubkey": "0x83ff266bbefd3a05161df26dd08d182c7644535b396789c5142a4821cbee5a11b2f28967998ed3d46eb0e379205f56a1", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb6a3694859f0e5d4aa6bd597480db4fd78af12fe6eac6940bca3b40f9373b5d8d59384d7c25669200a9821d4ddc2d8e1195d4113cbba26ee36d7aecc52393970e507c3e85285fe4e91609a3705d92b3cc1e048b69f377b37dccc8379ed5f42dd", + "slot": "12860532" + }, + { + "pubkey": "0xafc89b3f10649321eae774b3cf3a2dbb4af0a43ba61bdb2f5503c7443110b480872e3678c589f2d2638396d610aceb13", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xafc9dbb6bed074de0498ad261a553a6371b69e06d604ef99df53bd8c26febe0abe4b900d790581fd17e4e7e102ec573802d9d70342ecbc41de5283f4c50dba6e17b0c374716096df584fe3aca881dda67b2046b5f1ca92c171fb51864d39f76b", + "slot": "12860532" + }, + { + "pubkey": "0x8aafa67fe18e0729e3a09f4b0c86451de45e38cafffa8138d11a662472a8f4d29d1213eb578afcaae7836ee6a3e93fbb", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9085c9765a557c796d9cd0429d8a9430f278eb513e87f8a9759607511012badc5f824a949de7ed6f88899dd77165de6e133d2924a87ca5f483678d94fe29bbb2f5694e73c127f481057e27a0b658460ab4a3179d3a4b55e5e44d2c20d9ab1450", + "slot": "12860532" + }, + { + "pubkey": "0x8a79a92fa86158d44d7a9480b642d5c27985ed99d3c22746937c03c36149381c850fce1585f3eeacfbd1d2a0c839df84", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb59bfdff9befc1f80bd0e4301e1d5b637fc36ac02dcf760b4496bc3cd0738edcf046146fb38ae9421036740302b682ca10aa8d214e4da275fa2e74d277afe9be68dfde84f8bdad19bc7aab0d1f16ad2d7a56a88c10cf72b64cdf3a65ee727ca7", + "slot": "12860532" + }, + { + "pubkey": "0xa60953983dc2287bb353e2b837c34f8eb92c6ab186f837f2ac001dc45bf842b7c961064cbc2b9205cbd8d4d3124eb694", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb03b417ee89978433bd84323b60a6c8410aa35fe1a8861dbd2a8c3ac234abdcf9f75a3329f57203440b8b1c6b4f048970bb472358c8bfee40123d0203b081af0e57c8776bb259432b1084d141761d87dda916b9056ec56d8667f6787181e5901", + "slot": "12860532" + }, + { + "pubkey": "0xa49afc8f8cd710313df52d8a32ed4a972e6f93450f95ab2038be54ac97b500c500d72033a479079ed4e326a43ebd13b9", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8dc2bf63966db1abe67b2ebd817cec9cc1313c0ae7525dcd70cdfb0b4ac177b9754a966ba16bcc78cd9934eff96796d41016e193cd0fabc3eb9828bca954f4199d0352023bb4c6fda8584bc99e0e7c1f35af0d4245dc4d8816d4350f08dc9430", + "slot": "12860532" + }, + { + "pubkey": "0x8d4d8a4b1fa9d69b6d9ef100a15f9f82818ae1628ede1e16cddcb5646f62f6ac5e33f8c9b979713f25eb6f0ec6c80adc", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8ef9a86c8505baa96a5765c08a8808d5c30a57c83a2a31eb0ed98bb40b64e6ea1f3c5bbfdc7f58c6738200ae500c28131480044d9d77fe8a024ee88526f1fce39c4e2629cb37249edef570baf0798f23e229a4dea999062b69d9e3e3f10e93ba", + "slot": "12860532" + }, + { + "pubkey": "0x8d562b32d70a3fcd8e435523a85411a653ea9343505c0999d72351c6c15fd4d70a7de9f0e2e9eda9a849ff18e4088f43", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x86b73bdefeac8e13f56a5e5b49e7423e29e2ca2c73300ddd0a9e070f1a15f49d94b82d1c8636e249aabcc4368c182c92036899b545d5da4ae40eed9c9d72ed1b8a8539956466bef1b9aa41d6ec1bf1e54809d42748d606952cb6c079a37877be", + "slot": "12860532" + }, + { + "pubkey": "0x8a3aa4bc7514d3ec7a7cff3da1b0db13b9e9f023dc6a4e7b01e359041301240af8293a21fb7c979e5fe8a34948f1c6b8", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x90b1369cc25440b17ee62a024693369c35fb5f504ead327103be7a6a537baad8789d8043658d05081c39382da34b499a14ac2fba4c2954c7219c39b621d3dfd71f2253d8576b395af108a87a7c4ce4638d3331c1c23eb277985c906170473bc9", + "slot": "12860532" + }, + { + "pubkey": "0xb6cd1cec896a5c000dd3782b9629616ae57a115d66177b2f758cb19aab0f7b40fa860cf3b2a3a4ccba8f226e9876bfbd", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa69dc076d91a7e5ee8cec30d0a40c3260575282057d80e12c3fd1d101b98cd1cd8e3d2cef7ca2df974d2082979540c4e0fef13d6dc9feaced49e307359d30505de68cda0d294101247f5a4d6c5cec419f8b41d3725152a5b99f1fd0c579505be", + "slot": "12860532" + }, + { + "pubkey": "0x8e40d2b0ccf47ba91032048c312584ea1086e6a07e3d461a97044b51d1e487205af9dc5c3b21fc7a1fcb6fccede986b7", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x818be897036d90b26f9889170cce956c1df1e859817035e8db75706d762acd1fed4f75510284f713996dc451dfab6fcc151b222e1cc3ad6460c909cb1e499064c8b1f526b992525df35c35597027c46fe441a9ccfb307a56c59dbfc170647196", + "slot": "12860532" + }, + { + "pubkey": "0xae4ffeb53c7d16dc424f73da35aebf719e8c695dcd4a928dc3bcd8c54c50379618aedce491c2903f27fd538164fe3ddb", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x80bfa981f679ee44965c5ef776eb8c375d807291ad700f889b7879fe09bbed5eb104e749a7921bb2f6f68659ba24dde9037a37eb933a48cb71c6f02bd891b3f640ff35870e8ea1d2575f4e9d94473c549c23332e3f3986ecafabdb78089c67ee", + "slot": "12860532" + }, + { + "pubkey": "0x97a60288d9fb872dbbee948a1584525c0a7e877420a6a2d0fc0b874c66898f06d7e944f1cb98521ea80b9f5f58216b36", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8fadcaa2935f5c9e434f331feb154c30f7766992759e3400cc91cbbe7f8ab1889a6c5dcf45dba15bde1bbfa4d8a906210db6cef4301d87091b6b34eb428a5f142628fd0f1dc6d7d4f23e6bdd13bdbb663fd045e7bae52e10ce4c24b20231339e", + "slot": "12860532" + }, + { + "pubkey": "0xb7adbcc2d394c843718f2785921d9e6f5369cfc6f9640d327661356b1207716377ccd87dd7864560980cc061de243b92", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa0dbbcc74a49656c5fb0bbddbfb5a4f51a9599c14c0e1baa9a630cef6a3e9fd51e3c231af175563944229d78d5d85dba0a12685588d292dc007556f3114cfbc52a8a46dd537d862ec41d9c0107163a03bd42666e802240b977b47d1ddab14fd7", + "slot": "12860532" + }, + { + "pubkey": "0xa5cbc74406253d415c86c0d00e140c2aca4967f61197c285823e77df3d5108074bcf67e1d2eb57899639172cc8580b8e", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8d0186a50a98e689cde8f120491b4e62e6ab63b109625951b244c798a762b8133685bf3a6055b3c56a3064fd656524ed15b27be073671bc9b17e839bcfd78dd36682febb41052e8460f86c4b2d1c496e356026d7eb04edfb7144d81156316b58", + "slot": "12860532" + }, + { + "pubkey": "0xabb0b47525db313cdd0572f841de9321920770e4975bb3b63916a5b587127af66acad6f0fa117050ad2403e2cc23619d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb60cc223886ccec91b75c0e8b5e44ef3a3a57d32ca3d222110e699691877a289c5c1a9e3c55d91db586d8313aef991c4183993cb6b63d751e652a197ce5781c3b5bca489529de89818902cfe151639510a8e996baf1cbbe71d93d657c486a245", + "slot": "12860532" + }, + { + "pubkey": "0xa0cb3e4de71207504875c2f944959e80f616721a09e64d05907252c4eb3d69eb19d47668a44f7b5888b1962e52eba182", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa9fc6470ab437281ee42816352cb136ee44506304e0f306a0c4b3e4de0c2ae1abf7a1eff335edeab0df5058829ee70cf0b5c250a4c061d01dfa3c518aeadaa0417647a5f9cba795c8cd47ab8bdd3a9c5c9765dd90a89506d59ac8dc045ec42bc", + "slot": "12860532" + }, + { + "pubkey": "0x8686a995261352bb1b67b4e179830768ece0416298c5c2c28dae45518e80ee5d0ece28abbfe32c45a4a721ff971b7a1a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8294d6ce911e205c72c838e85c439805e70e13f3ddb4e2bfd1ee38c1e3ec5c736b873e659c1fe47a265c591e755138af02f4f76c1697cd9ccc9dd43428303e3da0230b6bcdc518c4e3f44ac8886ccf40a072af558bc7f5ee03a262105afeaf95", + "slot": "12860532" + }, + { + "pubkey": "0xa4166d9d55307ca5b40ae5c48f5d3abe20ee1f3f6a58bc7713683b1e97608e35c8f0ad1616c98853cb3151cb99a488a0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xab3d1f39188fd2adcbb36e1922764e53eb53a5c511bb86bc61d6fdfc9fa927202e04718262e215f2675daee0bf38dfe1191f71b3d34c81dc2c5855090eb099441c67e1d03f84754c676cc22139cc2d5425927f80e480862409f69bef7bff4b72", + "slot": "12860532" + }, + { + "pubkey": "0xb183185e895c57c3f1d99e9eb11934fbfc48e5fe0130dae8c3ee1726312fc7c5a6f075f48a355b3f4c774d0a7ff1c52a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x807cd3fafe405a68b070b7d9f8e7740a1482e39eb2f26c5328dfaeee58d7ce35b40e4cb287a01b2ea4440c3f10918e2e05151fc3f0f97edd80c1ce7ffdded19f475bae4b3c19c6ff6583e6057b6967d5ba789e033884649a3cd0b5cb631534f7", + "slot": "12860532" + }, + { + "pubkey": "0xb5ffc9a56f523a7826882179cadd7349e03470c4d50b695ee7258727ca1f4ab5bbe9ffc4c2183bf585779d5f173c9683", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x857fbecb136a2aa0ad8915c26435363a009b1c2978c774b8e51714f6edcbdc136b9303b61e93a2d37b9e56977c6ddf780d7c6b139f19c52a8f267592cf8a922aed23957d2bf1baf360a4805ad519f14f38013b760f7727e4c8322533951dcf1f", + "slot": "12860532" + }, + { + "pubkey": "0x8db603bd34e05631e0951a53625940f79f3869458e647bacf8467dc0379c688363b6829889b246884e49ec11309b11b0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x92c79cf0467b8178144c941e0864e11b5b477ada919a5e0d669f8cd10c13060ea2f5f4d3c4e2d4784662151164edeec3082e910ab62a1e6492e63359135c55afa0bd09e263c363c2ecc0c63bff5a8d6b661f30d5fe03937225237198c568338e", + "slot": "12860532" + }, + { + "pubkey": "0xaf1f3f8a1d8bb5d8eba3a7bdd4e37530ef76f8a896807147cd832dcd12a0fcdc84e9f21a74a8ca1a41d442b8170ab5d1", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb29de807e761f04e5479ff287ff2eef6cf2d57f2538b79a067402cf2faa943a14c14f579a6049ef2294d55e5c7449c210f52cab07f02ee2aecf8a4dcd865ce5571fd789f91cd6dd4fb381960894ed87826c98cb8cf48437bf9a972e1b951d843", + "slot": "12860532" + }, + { + "pubkey": "0x80f246c54fdcc7d712aac3f160b8a03ebdeffb98fde877b82cee05746498e18b9efba634d0346fad8d55274d687de0fb", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x87a553c209def7621693fac01864ada045ab60eeb2f35ec5975d2d52c40fe93a896e38f37079344653c11f0bbaf32a4405969193fcd76fc8e7bca3bdbfaf0ae81ea4002d6dfad14450ac448947a526821ba1b33b1f865a95d5360e3167bd84c6", + "slot": "12860532" + }, + { + "pubkey": "0xa675bc498f154b8c113fe331273399bc499879fa71e811a4fc3ed1b187a8ddb4e1bb005c87c7942755b84ccf6d2d015c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xae8f7b7f6d931c4d7a6a63dd8ade619c67bf85e61a1c110e5d383f1ed70e122d8397c7cca55cfd4a30f59af564e1a77417147ad6ebf5ee15297655ab9d76917822fb4c0f3765fdf11a855af380b6c4ff242aecc1a0c2a1330edbaf9aa60f754a", + "slot": "12860532" + }, + { + "pubkey": "0x84ad497fe5c85443bc1bc30cd4c6a142573ce1d0643758b649ceff6251ead1b53a79fa9e74fa3f3de6879febb31a06a6", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x86efb8304a26a39b6ffda45f1d05c3d85b0b2067070498664977da61898f3803294b89f7ffa2bf643a8332c3b81cca580c7a68c5c342e3ccf90c9a86f74918779ad8d594b59162675b1d1add87ffb367d70281b495bc1ac85a263322b1b4a505", + "slot": "12860532" + }, + { + "pubkey": "0xb843f340b492bff18d7cdf38e57cb250a02b97631d9ffa4f49487d98c0049e5124dae73374cdc6eec3f8d8765e47dd6a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9494da2a8d17bb854c60b0ef714cd3b9e62ba83e66c51415d085cd308c35a4717a6ff3a678a135d03917b20d7cc9bcab12085101ea098afa289179116c6cce5bfaf46e513f35d330b64956c6f9137b79b358e3614b2570d9e724d6c8b9b2cb41", + "slot": "12860532" + }, + { + "pubkey": "0x82f4558046858de4f10fe8583a4ed543785812bf666d1813cc3b30da8354ba878c2af4fe23539984331310c920267143", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8f993c34a0877b8a2850494ca494f47b614608030d91335c4cee549dadf378a69982bc570bd25d2885e9258e493228c211a225b1e9a1a9257293d6ec05b39e9e13de30537148f573f9b8e8fb6cb8d121f8b13e59d1a7df95d63dafc66f7931dc", + "slot": "12860532" + }, + { + "pubkey": "0xb95fc7db66ff68dcbae3f1a991ca350b5013fd8d206785346e4768eaafa7f268724ab6d73ebb63d03263c51938028ee3", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb4ebda9b556a65f7b13c4e8187eb04ae75c0348651e76b416ed3e814f3f83891e17f510d19796c5384db6d4edbb64ce51502cc49d56e3b18c06820c50463b0e56871382feef3b52a7ee41a7e83303059037dbf80af95865fc90fb6d120882f02", + "slot": "12860532" + }, + { + "pubkey": "0x847c6e7066dbe18ccf48abd31e821dffc4e2e2e93c72a3a9c2019d31552718240d7ad2e1112ba5956f5eb131192cbc23", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x808625ee44fd510d4f9e8f8338a7995b48426ddb3b2002f64667832d7fa1eafa65e4d2a306b9015810a1d16cdd29b0890cd8287aee62e5044725b57a3d063efad4821923599928e677196ce0dc24071c1fbf3f54ba08482083c34c13ca6e9486", + "slot": "12860532" + }, + { + "pubkey": "0xa315fda5874b8f8b366d5dca9add3caacbb245355e94720eb3f94fc064262b5babb003864690d3f5f31d9253fcb50973", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb058dc611ac8bcc5d14f9ebe8a056436003d3c52bf2ca3d6f69ee7f17ee6cd7b375ddfa16881fb5ef748dfaf90baac100b0ee3b30b6ad3777cbcc5d34dd0cedac9ae4d92a9cfc9e69857d6e79dcaa46f91853f77375d817b0df0da0fe241e665", + "slot": "12860532" + }, + { + "pubkey": "0xaeb6aacd2cdfd73f96450f2a627ba66a81c4431356d7a986305cd45d263ad4881e23b459af00dde7d1a1552b9664b711", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb3b7b9fab29cf2eb4cfd4c36fc1c17946f6d76dc19cdf9491151c1789fd0c0a8bc1a869a3251bcc2ab536909a17612ae13a5ef9c57c5b46634a2355cce54617f725d5fb11e52c605574b501c47e8a31bd6296451fa77bf6f18e1c4c4152a3ae4", + "slot": "12860532" + }, + { + "pubkey": "0x82d7a66a5de7d4c2d70531e14a65fa0b443a4677bedbd58c9b576baacc80ac008fd8e69b6c77bc1b7ea6818cd693236d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8d266f4798a00d346cb46833a276a0b9a4139f24949133aadc61c68b9d5fb6270eab5bd286a43a56305489331ed4440d15127dd6d93f761caeddaee0d5a49f3ffad978eac977bfb230d048c09d90ef18956deb4f72a4e2329c9e001c92a64dda", + "slot": "12860532" + }, + { + "pubkey": "0x94a6b543ad21518c7befc1f292009c69e858594097d831a921e5bebbd24fb65f824efb2071a9fffee1011556181ae77c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb01ee2cacfb0c46b807669ee1cb1355ae3583c3f59c65ce7e3b9b44e1def0dfa466210b1e3486d16be36b198f560ecbd088cc02e4c8c90cc8c7e43960a3a0205fcfef5362efedf93de5250903ef595b93cbd1f85ceabdcfd4d9113a34b81a06f", + "slot": "12860532" + }, + { + "pubkey": "0xa8d8d22613dd0c3ce95b85f25a434fc005a4306e8aac701c6004f32a4f62d9ac7ae96f2f11e14bfe10728548a909e618", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa3869d16305306598ea0e2d7185eb8e4ff44a7487b4c18667b4c04670a72f9d4addbae63ac6dab023a6cc28ca7921a7e1757ca8f1260fadeb1b0f558c38852aac8ec366d44934e0249918d9206057b88f580f0e7da1effa4c20e98a0069c263e", + "slot": "12860532" + }, + { + "pubkey": "0x9832e78e8e6b8f17d825f05461b8b3a3c56c64762f87879ca718993f906d884aafc41217f60f35f6ad296a804bdc3759", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8df902f26abdf8df8eaeb7d03937ad17aef3340f38256c7799cf4defa14cfbcece50b991ae4df0561877c11d6fae422108a900a3a13f902b9921ce7e4edebd98b78ead61eb87634917a769ce737b5abc13787c7b26a8857c0502a0b5e794dbdd", + "slot": "12860532" + }, + { + "pubkey": "0x8b9606bca83e57571304d24fc69d0ab79c65a981a30be70e8086d7c196b116d9dddf3f2ecf206bb92e0f372aaafd245d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8e44d82c6a1481374934f1c59dbed18ee84cbf0b1d084be988e6e6da036af97ab1007225f58b30bc6d0ea42ea1312f5a1230aaae72b5199eb6593bba34638106c06f77f22d2e9318fbfc65d0d774730470e46a651792c1307b6873f4b70899ae", + "slot": "12860532" + }, + { + "pubkey": "0x91acd5ba87e8a959d553cd9cdfe0bbd999841f06bbe5c85c0755e531c16363aca63a16351cfd2ad007b121a197bda871", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa2fbfda5e93a8277ea245367b56961950080a46fa9d51d1325e93e56bda44fb623b8d08133fe2a4aab29abee260081be0efb22919a5e60e11532e8d59702b7a89529c76ccf59afe9ca8e30b343a7c94f54f3c30b458ac70948277f4527f4e100", + "slot": "12860532" + }, + { + "pubkey": "0x965189260325a00fab2c20314e644d8d8fc24c64f81334b71e0f1bae9657541ad0232d16fcbd7d070787de221068f096", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb384824458caa398b7a650c53fcfb1787ee5d5fc95fb7fbcaf37094761e9bc368624726470e7a070bed3539945ebefdf161ea9178fede4d721ae5511cb6d7021014b46fd957e4bd25e9d86a9ce4462430b269f839c0092811e9c34bf107448dd", + "slot": "12860532" + }, + { + "pubkey": "0xad72cb1c497196022b7240499d135a37cf7ffdbbd9e504527d9455d2efcd5555610bcbf560f06f1944538056f2bd11c6", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb7415329a7fcb6e4457755a6a94949266d5365e4996b66f1785bb53e954d92b55a4051a1d32f2d9fc14e2ed81ca4e714091d86c634225e155a757fa763da09efdee6a09ad2adac26543efa3a789586439086b7a2419e27bdacc73b36100966ae", + "slot": "12860532" + }, + { + "pubkey": "0x84f43bc06fe224afe72d25da7fd1d384acf3e500216adfa4adffce4312f77ff6ea73853c852a0a2e7e821620a6788ac9", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9331f12d7b440268cf807422d26a26fd50a69f6a9a06e6b7bdd1e9adf3009b1bc02913f0afef24ec93db45170dc85c4d07e99102c44c0b671d5657ddc57bd7c2c825644e53554c34b6c03065f65a586638c1437743afba0e591674e0b45328ca", + "slot": "12860532" + }, + { + "pubkey": "0x981cbe0e5fecdeb768bf8aa2e8da79fd890d0239d548e01d1a4daa633728a7c30de5ff246d0d82c852bcb4acf429735d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8acd8bdc8a750fb1de966b8b243c6aa443f9da06367aa2b1345e284d98fd89c1fe66ae8a5ce3a7358706969cba51b4380e3d329bf026aaa550a2d72701bdf5f9956d9c86df9926f601a796fdf98e8fd11f646ddf9488b37d42023404efa3318f", + "slot": "12860532" + }, + { + "pubkey": "0x89d1210519979446dd9f6df9dfe6ff096666f531b4fa423df66a4ef465e835f09522672e74d533a24033bf74308d1919", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa2c3ed1ca99c9d47047337ac0041ff03117b2a56fb62bf5c122246a9e4289f287dc27f42c38957319a47f26c3d05112207885d97bf2752192e22dd22bcd40485dca742ee8c4f319112773470b2ca4bd9d0ef1921e127e488ceb58fad2fd08a4e", + "slot": "12860532" + }, + { + "pubkey": "0xa91f8e6ef3f282d3c54597caf7504f3568d33545d001f411a836f94eff2383502fc09bab835bc743fc0e1d7ac7fa2a16", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x80046787557b63c30693940265be7a7adde5b5fdb92f2b2c396ad9464aadc29440e9d50f16d0c87f7776c871f9d1354b0438fd6d0ef283492f0fba6852db2024b432e737e3eeb277b7cff4f487ee8c7e975b2c6c39685467f2f2ba070736c5ad", + "slot": "12860532" + }, + { + "pubkey": "0xa5c48dbeaeee7b7465bd9b69d9aa1e327eaf1baf68778838519a60c61305593b243188005e158ac3e572b806af20f2cf", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x979c75b70594b8d0938b1db8391aea25a6346ce6fa1b067f3291a0a67e959292034a7b1b65ac46c86433cc2722c74816151bccd3e5a9cd43b141e4996958e61277a0bbfda0d3e8d6328e452bccab16e3bfbb05c3726e1708715ce050136f076f", + "slot": "12860532" + }, + { + "pubkey": "0xb9c74d09dd189a80a03df20f10b25d37fcb1f729b1cfc01b5cc09bfecb4e4ed0662097f4a7e4eb2eeaa024f6692ad6f8", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xaef60540164ef4ee5e596a8f2158f361ee4cd6fa10b4b32da5f9ab9d9cd818f1752fca7713feec17bbcb731e6839e64607fe3e563433f3194047f78628e729e16c0cdb3fdee54925521ca6e93725ab814088aaf72716ebd754ce947ebf362540", + "slot": "12860532" + }, + { + "pubkey": "0xb86ff63077971d026381c789108b4ad27642b565be70aaae9db3e9ce0e487797170a815ba81ec7a3e7d365192b9c96a2", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x843247e6802c45adfd40028bfbebaf3880042336735917df529234eefbad565455ea6d0d7f704512159994f6ed41bcf00e191394935a5c4bda3e2f18397084f71d051aaf3e6265293b3dee68166e20d737b691ba6a510c78d667e123f87c46e9", + "slot": "12860532" + }, + { + "pubkey": "0xa5ee766dddea368f17e9ffa9d2aa2b8ef5f11210ff9bab9dcdf4342ab580c15ed3323e25aad2fe1dc6f9141f45a93cd2", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9638c0201d33f2d546a51ce04e2c85f52f60d5ad003738b20c0a346543bb6624541605151b812667ebf7782406b4ba1b19c9942c786c86be1cd94a803cd07384c8a451bd9abcd6281e422817cae7cf427e15a539c6cfb60ebbd76b852c03d6d9", + "slot": "12860532" + }, + { + "pubkey": "0x8e2c18d7e02537e02532d253521bdd3a8d306f36820ef6d8c52d74b72fe03c5f2f4f2914ef8efcc4a8d69f6accc8ca5d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8090bfa32debe403299ccbfb3cfdac5256e33a924cfb2dbf637daf53c01de6f722b505c33a8f489c0a2b8031f772083b09fa086f3f94a7348c4af562e6016b4729f44e82b9d9994416ebf861b59ec6d72ac67a47ec67399e8b4a6d3972459ee6", + "slot": "12860532" + }, + { + "pubkey": "0xa07039155565885b5eec11602c9b86de704c6b30cd8833c01e9124ac52195baa05daad84d5c6a91b89f35800c5abc0ac", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x89979aa849bd9e90b4002883066c5ea7337ff0eaee08ed4f1f1cd40fc2b9dbcfadb2f41e1af876047043baf8349b20ea0a5bfc408c666597b63070456192985a360cbe0feef311852e3ed6baad5b112630f48ce304376854578e1986d8dbc0fa", + "slot": "12860532" + }, + { + "pubkey": "0xacf978b4de8ba827716206f3a58f5781aaf9476eff20b9682f2d902c8e348d54497ae8e6ebe5706eb656f3a58b288b1c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa68a1dd1787d0e473803bd52dc9177de61a8c47d60787f82b99060df5e2e578dd3a788b667197c70fc2a25d4e35d268f12374d8b98a342d9edcec30a67a4f8f8238e38f342e55f60508a5b0bd5d938bfb45a0d83ff91af2a3f1a959b68d34b93", + "slot": "12860532" + }, + { + "pubkey": "0xb3254f6f313cee9dc5177d35f728dfc3ba535469171afd66d58bc8c30a9155ba72c6b5815d3d119c899cef94a29cb414", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x91ff366b20aa6f9cab508dbf130fdd6092d6044dcfce3f77d23150fa4452caa3edfbc215f41372b4029cff66d3bf03fb020dddcc472c7182b770b86922904bf893ff0ecc4d380eb7e08041de7ccc3fd778f242bcb7c5bebd78bde3ef5e3f18a7", + "slot": "12860532" + }, + { + "pubkey": "0x8e76d1c464ac5cfd0d4da00083d3d26da2bf94c863c4a718bf61fcf3ac22c0786f37bc819a21589a22ee60c8ee3e8996", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa5af40d12fc827e775483b1d22e14478680e0affdeacbb39e75aa8ce31f444e7f4a6373c532488d0cbb05d88543777270fb1caa25fb1f99a390342f10e7405f0c135c3abca05d31686d0d49fdc7ac758e889b02dd8fb9b30d6b63778b049d77b", + "slot": "12860532" + }, + { + "pubkey": "0x987f44330a6117c68b30d5e3930488a93df776b1b0d83fa5f0271c0770223053d616abd699b258b8a2d9e93fc57ba5d1", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa6e65600271083e690a551a67c99ac92bc7139a628ff939e176a2e5d102ed592ebc7cc522eadc70390396102043f93ee146071642c7a426a7a9a34f0a050d5bbbaacfa6a6e9c3663e176b0df24b17c3823328050c1581d28cd3535c996813958", + "slot": "12860532" + }, + { + "pubkey": "0xb02a2be4bf4361116492b7df9ee2276f8709d1d7e6f601b4546c7459f12795db7d93aca829838c82081a74683e1ff72d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xaff56e56e6a59e69eb26fd24d3aebfd1e2e3db8fdb2e32f564498c73e2711fb49f5e043c117e70e7af35988caad3a4160c2c0b8c2e70f63d81699104ab9008b8e8f0f60653e34601d5f1961b18775491de92fc24b59b16752eb2aba6e2d065ee", + "slot": "12860532" + }, + { + "pubkey": "0x97d37a3b4cb8084d6bb6faea13ed4b4939e41143863104f7c1196919cacac962dc51072da36293da9580cac00b2c0286", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb6804f2729d34dc5520f0502b853f47c5bca80c967b2896f9edf4cc5f370db7f5bbb2ec735736b7452852c3349dc65ab1011d0281085e5a0ff8b94660b014a248a6367f7fde573f88d295a194e53d0750801e0f34ebd3bb838f15ee98dc08914", + "slot": "12860532" + }, + { + "pubkey": "0xb426f9036d0a8d3f78021304c40fa4b89755a77c4d2d477254ed6abff50493ed2ab00c9330a4a70735d852827c43584d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xab94c75a86b04df7c5e5a1a8e5043be039795731b96a88d494ad8bf4db470ece3e32b66b75a0384fd11a0e4000219c3c04e79c97789b6ffee9a10dd006c9904d93c0a657514594cf455205c77f8a30f16c48e4ddfa04a00d902d978ad5aff791", + "slot": "12860532" + }, + { + "pubkey": "0x834e99f1d4f7896f713360373f8219cb692096299deae5ca68d481b1490b07177387e43b7d728fd62b9581819fed7e17", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x87a5145290c57ef9c08a77fff97b345f7ad893d18b586f7052ae19a74d4e26d9cb8d4c23a10c8a7eed170f781219ff1911e5a405d0d43ee471e1f417a1abe9329d0e354b7fab0659662c726e2d900ced711ed4be6e2e0bb49e3cd60847fc1edc", + "slot": "12860532" + }, + { + "pubkey": "0x9899bf1eb133446292287979af36f5ef1963b11d686da3c974011d29f504d0ca6d812a82b1a30402455636aef782db46", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb2ebb654dd7f47a8ba8b35bcc59afdbb29c3c8d2c19c99999639523d63becd7e7ef6b3a3f86b4e9394f1c7e5c7e5091f04f1bccf10c3724606ce0e6dfb2ae6cfc3f5434c2eba3e1e230bab6dfb73dae94bec0d695ec0da598a96c74dd027562a", + "slot": "12860532" + }, + { + "pubkey": "0xb7e4b438c261faff5055df896a115e7ffdac59a9b73a06e6c4f59f99871cac5ec8d9e8d0d2dca0a6b00e0f80bbbe5c4a", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x95f9fd3e95fc78800b224aa8485aab41d11fcfcb29e95a9c41565a76f9e32c0f6ef54bc4ae227e7584098fa8894b78c20f40dc88511dc0e38c0953b134c0576b85e776a5da00e9983dcd16fe78c0e5e6a1f8f56f074a9a4bb8092529c1fdeeb7", + "slot": "12860532" + }, + { + "pubkey": "0x94037eafdb50228a3576c506b75e50d22f2a8fd6afe5b55d7613517a08240e23a3af871ce9e64c6d2f21e5b502a94f5e", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x92b63ec7cbbd82595daa49f671d36f9480ef8db2031be137c896a5b3acb7fbca4af324f23b2c69af7adea44ceefd164a0962f8e7fd8d60c746af62bfcc89cac1d94a2af0ccd90e6f76c31e075d679d4498d47711e69c19c22c1a971ff9968dd3", + "slot": "12860532" + }, + { + "pubkey": "0xa4b13a7634fe89a819899ff5f9180aa24e283350d4e3bd16108654287c99f0e8975bab6470a48aff04fc2cafd874aef7", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb53d36d7e48731e0e2c18faec44d235ebe5f3280d7defa26aa7fa75b9f596c03a5d51dd303bc3aa6db1009b11e0515c403c75103a178bd95e1aa77f1e125ce355e09650433c4190bbe5dcbca656ce4495d9138c9599626f6aa7b6f4580afd4ea", + "slot": "12860532" + }, + { + "pubkey": "0xab17ed5f427623352e3ca2911fbf5234c1487da79a5d6d65ac8215bca05ee57a97ceb6b7d3e37d9234ac647c6ce8812b", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xadaca699485f6a994e1e5c759f50381d66bd6a52f2a7ffcfb34bbc80712812a68f758c4641959354728b4f73fa9db1e8161b9aac75d1322586c88165ccb0d6a04bea4b74cd8f5ac1275baae55dc8744bdb36c43be23ba5321c41cc403ecec301", + "slot": "12860532" + }, + { + "pubkey": "0x83268f09fa272d68cf2286db186bea31ee214b2c87f75471d1ac6049b28c8babde4ce348fa803654cabf5e4af1deb4ed", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa134be877413ae2c075254260e70db5094669a0654c334d76527d6d67d7779ba0428741f050158f580159a1946bb5e8a11b577eb8c930c6afbc95704696f4ad867f8317f7dfabcdbc197681d60f68c4bfdc5d6f67c254a54299a4a67f858456e", + "slot": "12860532" + }, + { + "pubkey": "0x888240c65cf7a41f58e0f76359f14f068677eab9efea26d4b0d832c27967b53f7b9fd704b7a1c303f3e2888345eeb2a2", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb3704ee2b9a5a32aa940532466039b6ca55c997fcf7507c71b6c9a2d22751a8c6d331dc288eef9b6382d937af552089216c00c5343b5771f0c05bf5111d25fa3dd387fd2b0610d9d70e0613f04578d835dcdd47dcff099792fcb81254eb80b31", + "slot": "12860532" + }, + { + "pubkey": "0x8f04c6902daa9e661657abc9c790c340c9a68326ffe9ccc5f1cabfc705090756768b188f7586a26bd33ae4f4de603e91", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8755f2d798014434237e8d78c931891699e03f288c5398528eac93baf43b6df1c3644f5e16eac04fa48e5f33caec1fac088423defeccaa712b713070cf7384c3772e1ffe739d132b41f2d0fc7d2162cfbca2e121ad9f40d1f21abb8b8f747ff0", + "slot": "12860532" + }, + { + "pubkey": "0x86e93fc28f8ab194a83d6698344729b35deab92b4ad8d32dc4e529b8b405beebc55dc0e5c9ba150dc7091d35e8ffc0ca", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb3f9688a578d845ac15ed9ef89e6ea37d946bc0eafb8e0a84737a281afe7d7ed18ea3cad43fbb51079b057e4ef134f3a030d59e263f844ef1f67c9c2878cdce8dda8c287c53063e3a1679e41f2a6b03dea0d8954609aa5c320c273a260f51275", + "slot": "12860532" + }, + { + "pubkey": "0xb8c867e767f8aaa000f1cc3ed7805d24f92507647bab768802e4a25fae535ab224b2fc103627a8c18a764a2f2238e113", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x96937c67baeae02d0fce991e7a64a876243edc36c21adbf2023270d7fe6a6895195048ed6fc830d0e47161f9eb712ada1857528406bba616b898d7c6e0800f1dbe5fcd4d781551c5f79be0ddeba6fc54da1f3abbdb17803c0c135e2b60982e87", + "slot": "12860532" + }, + { + "pubkey": "0x8cfb890adaf44d3cad9f2e791e8bc5ecadaecb2d70195d2fedc4d8729350cf17b537d628c9e8e1beeda55c5feb185988", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x96577b490df65c5e3e2eb51042e8ba5b4b1e69579340d11961d8ff31f6844a1213f918092f0e29278497a8ffb5c43eb10f0e1850174592f3b1057cc8db68ea2c851bd5ea2d2198aabac785bde92f72f3e2b64a6b32e2ba317fe86ea5f4f3a396", + "slot": "12860532" + }, + { + "pubkey": "0x88609c6e91daaac1f2918f61e39c9cd258911876e15e61d1bbd974cf02066a769f1affb054704c46ae146fa6906f8d25", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa29dd0b3cf2b0554def166260fa7e932927b8f88b635ad598a55e46d6131ce6689064c2bb2531856f8aacd7d786a08d513376ddc5cd726e37612326379ab5241c022890b0ec6ace4f3ed10a1b3da9fd4ed750ff1ed8e740dd2cdb7d11968e24b", + "slot": "12860532" + }, + { + "pubkey": "0xb68c9e4de3204bf729ea28eb8c6be6628714f70c9b344accdfe3005f51eb4e09343f7d84406513cca4b4e3d179525bd0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x96bb90c41206d74193112b9d4496e81f12dae99e28132e2b3dac5e897a4b6992a67694acfb2a0bf7013dc12f19df868004debdc9fb4f5927cc20b00ab67b557891a9c4161668090ea1a1a46fe0aea114423c9d6e5acd9ee57ea32f085bed4476", + "slot": "12860532" + }, + { + "pubkey": "0xb19e5525dcc89aa4d1a32ed2b4a286b9f0855a947d90f2821f5686f3081d9b2c5bbb3ff9bd4b78600bccd9685694d18b", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb497815df1c5dfcebcb2ada98c47270b74bfd7d90098c99345e5b4a5f808619e749591f96939a60033cecfdc57af63ef00fa2beb5eb9a067902bca95e630e597f3857a4f698d463c27dd6a5f821cb2c9cb454fcbab688f8f5ab730a1f77fa7cc", + "slot": "12860532" + }, + { + "pubkey": "0xad9b0ddb66f05dfada23252e892d9ef53ee91a4c4738f323c0fae348f75276cd57519ccd9a635b5c1435cf7ee5906f3f", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa2eee7965026db2bbb931f3ca1560ba71cce3595f6bc0f1b91dff2c0d5344cf9507a707853af2a1eda0ff17efd632b1a104608271edf70e1c423a5d833a646df997fab9b0016bcb08f55acdc3a804551f1020a939000c42f70e9d51d9de15810", + "slot": "12860532" + }, + { + "pubkey": "0xa3e0f7f215059ff9e9bbfdb8b5e55a6cfd14aa8ade83b1115412ec6e7acd0c2401ae6919143593d61f37e958e3c6e8e7", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x86257d68ae2890cf984e45c57a4bcacbf38da62c6d5531809a62eec80b0129b7613b09490b3daa336c3c29e4d47f75a502131b18547675196d5804abe9e9dad9478bb5f2e5716e2035e1d7fdeb2362ada6f4c1cdf0bc57e92bd493189c0fc5f7", + "slot": "12860532" + }, + { + "pubkey": "0xafdc8a4eee249bb9b91b626e1297e8cd708f6ba9c6b3d122f2ab5f305de254b8a6e11d68bbc295e1731e514f67b2449c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb353ccc70dd7c3b41667e4f6b5c62dd50e9cbe9cab6f8c5eb7f360482c814c2c2596a3b0a9847e01f8c0f2e858ca7d1819dbbc1ea3005aad1b0b63c363d1a67ca99d5212b6cae7a74e0c6c4d6ba81c4f910012f4a554b5d5f9d71d391814d138", + "slot": "12860532" + }, + { + "pubkey": "0xb535f9043003fa532f942644d0db398ffc90ba9aba888cf4e79ffd7236504df8aa05387de52cb7dee1302b38de2b6c4f", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x86211838b4ed8c547b18a3cdd2bb2bf2a26ff7683f0e3e315774b9eca6be97a716f7b3e4e5c75f1538b105e7640b5a2218defd1ab9b31bec599df4018f61d013d50a462f5909910afed0ada212e2ed8d643754629064c854f4fc168317f22a40", + "slot": "12860532" + }, + { + "pubkey": "0xb4cac30157c93c44c86bff84274a5a33d5e64055788f6e29c93ceb079e56146d1adb5080f957a8cee205019b5dbe2f9d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb092081315b9895730d00a4ab1061577c82e11bf99f5409afa1e8d0079b8a9e5fcff704fe67ec6825a5dad1d9f8796c91372ac5daf4900d3eab12a701c3bfe4fc12e8a957b9b2e7a4eb9cde7e934915a44bb37b28a17ba6fda844bd8da91cfa4", + "slot": "12860532" + }, + { + "pubkey": "0xa4d242d4f093cc57c006e291ffa437e6dda18b43c7d8503096040256e2c9850f54708082542ea27632ea0f7f7d2c0b8c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8be81061a27211a21a5990a8b9f515ab3f5410333012fa46de208de2ad20c5e2d1e7d0b8b9dbe5afc15d9f98b17e7963108100eb348a77ff991d37d55e4083f777f896a4812942d78eb89fbf62fe9ecbd1de340b3295868c7adf09ee0ac7967a", + "slot": "12860532" + }, + { + "pubkey": "0xae604a3a8fa6ee472a9a3bae81f060b30afbc5ff2bf6f7cc323bc77ba28a0197ea979d704af3bc13bb6aca94645c7c29", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x969e970d223a94b54e9e5d56723cc8a0c23f1d0cbdbc635d009a157cec8e71df64379aeb67f951497a9acbc105d827b708620ee00e6dc1f29ad637febdd6f55d574d03058875c3951461a2e1d6a0422e23f17b6a8197b838dd0a51bd3faaaf13", + "slot": "12860532" + }, + { + "pubkey": "0xb94c5ab6d018ad394a6b005e56a20912ad524d620c866907923ecf5f350f73c0508ed86dea5210a4cf413298d66b2ab4", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa4c5778ec7bba20115de827afb44b23bbe0f0e7d430691d013f81287bc2a406b347cba50e546835fbb20398f1403a2920721b5cab1b21884fcf5db9658c012577de5f0eaa6bb650b065c75b8e544ff582d9eb50ca4372136e307fd8d96779ce0", + "slot": "12860532" + }, + { + "pubkey": "0x8059e68709805fdc5ac30c2ac56bc6602f9621110b81ee470f6527912a6cb4ac1bcd486d1267a6dd25db0dfcc6298de0", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xab77f873fdf1b1944616bcfd313216fa68bfefc8d2214ae2c609ba12db376bdfa1b0927ea54f1e734ecd44fe95a7811900035ddd69250e97399964db357377ec770ddfbcb930aa47f32c14ec1e4d24b90c4155a21446369011a759518b426e1f", + "slot": "12860532" + }, + { + "pubkey": "0x89a7e6f7fd7a6276d53b54aa219a0760ed65c6675ec1ee4c58a8f939648b6a0d9261284cc3b97285ebc7b812c79124d6", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x887b94f980514abe511cc160194ddfa489446aaed4a18b84875ff5a30af8c0a92d3a4604ed708e510112ca8eab38d885146674f75ebd8baccac53840da0e011b25b4bb03a3b1aebbfece51d25845bfcff52a17e5caba69ddbd48a287b0a17e0e", + "slot": "12860532" + }, + { + "pubkey": "0xa1c135b46707d923d2de528b18cdb37fda367d0ef11d9e268339526178791165f6974e701cde97c5d6f3801b08897d9d", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb514bbdc8c81391e1d90492e4a7db0087acf584e4697c6d5e04f372a5c85582171a945423f1b377c4c8ef099ee921c39035c11cf23752dcb7cec43cd8ad6e6dcb29107df10adefeed5519cc26143d24683fcbc2dcc9728d63dda5a8a1f66e18a", + "slot": "12860532" + }, + { + "pubkey": "0x85f85021d4433894578294bf8fcca064c2eabc8ab68526ac02e18cb6a4f9f86079db5207234838790c1c9f71df407914", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x9306389b6cea350e07aaba3bf4e32ba9e154a1373ae7cf7b78b6371b61e89c13ff99be7d146846bfbdff22a5827b055a104aec98a3453a4bd588e9cb306ecedf3c7a25c45bdad99ea337900c4a9cbe6aa9a7cf86577a2fae6aca044fa7588c99", + "slot": "12860532" + }, + { + "pubkey": "0xb503fa7df463f777167e7f2573436c54eef7ca6641bad28db0e00cd7254946d63fb41238ba33bb698d845144943fbe7b", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x918f63f68bdd6439ffbae3f1661e9eda26fa7ec606bca80ff332fc10eea9176001ab10ffc2929fab59badc9cbd7217f3160a5c8fc80fc0e3881e82905ffb81cb77fc62ca71e0113873dabd5094b14341e5df35d2961d4f7de5fc252d57ef5f56", + "slot": "12860532" + }, + { + "pubkey": "0x8ecd19f8df034937ecee5b0aee254608b9d67a4a7a203e021ccaad1b0c5db65ee8134adcfa93906c2f64fd71f01bdb6b", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x85fc5dda98f10134021d9a34328c430c8f2fc7bf3cca93548e14a1613a456e9fa33c6b0edf55fccf63cc06242591bbee15ce28bde943393b6090527d4d5767f03241ae1a6e82fd099618658afe967b113ca937bf7f6d5177ebfd66fdc2a69ea8", + "slot": "12860532" + }, + { + "pubkey": "0x93788e420b5a90bf265ed7a7222becbc1a098819898a180c4528830ebe6a50576968d670d3d847d48473d9a784e0a01c", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa28401606da8fd6397c444dafdabc9a81d192a25e78d49f6c660182ad89ab31eb3d8464de9ec30ed935e04adda11262713563d39c1b1078c10ac499b5cd4fc7b2e8b3e1295a22fa7c7b6ce199b5ae675d8310643e816b8ef3183a01de65834c0", + "slot": "12860532" + }, + { + "pubkey": "0xad2cfd4487fda17266716254ba3199b238dcaf70c72b33f209a0362b523afeb0bd9cdabb0352ebfbb3d15f5d0712f0a9", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x8340daeb08724581034a79d1bdea64b96a242ba0bc618b96feddfae5fea47d37ed716e4c8bb0edd81793093fd8698e160912d07be4b6b914eefa463b91ffe10d5ef62c75484f348c01fe0d378ba98e403ca02f02b93bd12aba3cffdda3e562ca", + "slot": "12860532" + }, + { + "pubkey": "0xab29ad46fdac9b2a1cdc3a699e0e9e5d3b64f1154cb82e4dccfc24fee2c97897d693fe71fd19c4aff00813a9358daf85", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x986d47d35279cef0f00bf6efd4810cd74033c21e1ccd172f178097d61a92768a5347eaf1351ec3531975b051910990620ed33f91e6d89ef8e67e11b8e67bbee5a3e495ab86c95e57245191b8020503e08b23c28af65d8d821def355ada39f92f", + "slot": "12860532" + }, + { + "pubkey": "0xad9f7970f4f83bd70792137296a6f3c24947324d8c2bc4a9cc1208ca8ed5906c9a52a459288e10ffe4477ca95c9c2b62", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa51e326564ccb19366486b4345804a7ac7a8fed6ab5b93b8698cd1049291185592dd36fd0fb25ba7337c55af2a9920b914fcc2deee600549381c1d16a122efe5b8c1498d8cd2a4a261cba548fceacd67d7465e516b014028d7ffc4594f1822ea", + "slot": "12860532" + }, + { + "pubkey": "0xb99be2808a2ad3fb0567eec23356bfe6a1532ff5f19eb90b5764ac2bfbf25bcefc565d2a42079eca37ef8fd9e614576b", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x953e921ea0f8ff98d0c30f3131cb6f04debc5473b98c22d2f3c1cd48908841e7e0d1d13742e4a526877323ac01b10f04169faa1e59a429aa56628d558711860e2a6004f2c290ee141d2afc5cd2efb8070deb90dc2380b5c66b049dbd9c149a2f", + "slot": "12860532" + }, + { + "pubkey": "0xb05d2517a8d21cd12467c09be16cdce6ca701fc59890a2ea28764f79d3b781369adf3686709c6199eab827c89b6b31d9", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0x87f7942704e01b2cc86ecbe9dc54e21267ad886266aafaffa6fbc39a87c05a24d1480a3fb52f355ba1c79e7012556576066169a516c505224e1b3b6bc0dbee141131a8ce26f04949ee2b9200d9c80576801b0eeea780d6737c9ee37fafacc9f9", + "slot": "12860532" + }, + { + "pubkey": "0xb9c14d3b50ab3e03b28cf4c48da149deb270ef9a985e006eccc3abdaafea9c0d0847bcaaf0c59c48a6660b00e9b271a9", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa99f00599946a38deffd276998210e2c513756e0a4cd61462223f9b6521bcd169b5afa6180e35431088a813ff0485aea162df8eb0c0e27b806ef903ebb584709b3e4c47da6b2884d05b0e6ea0a9624d24663bd6267e11c680d56507648014b6b", + "slot": "12860532" + }, + { + "pubkey": "0xadf5ab06683ac989932b8dfa881f643eb40b9149ba068e5edc300cd91f3ca99482b3f23ebe9725b49f4573b618a4ad97", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xb1625a5288f76688ef3aaf4c0e0d46025a4cf20e3731c71d6d5e84e5ffdf92434c210300e2cf4e3f24d0a427175d69221168d1157eeffb167911d23940994f779601938600dfed99e29cb406acb0f095c53177bb62f0276f3f5eaa051d9d217e", + "slot": "12860532" + }, + { + "pubkey": "0xad99511cc485ebe4acd4d9fc8d2b98945cb4d5b0108c70ba9510627b33c6cfdf83a05fef8666cfcb3357ac25f357f86f", + "withdrawal_credentials": "0x010000000000000000000000882e747c5c2e0366d4fd6f9f95780467c6549732", + "amount": "32000000000", + "signature": "0xa6a728cc64c2ba8e53b85c56ee3e656613edc229e1d5f9f5bae7d2fa376ad95a87bd7881a3d67bc40a91011661c9592f147c25a8bd5cd22b9917776e02d8aea2eb137d32fc95759870b3d757dbc29b700657ce53a310c60d13065843949f6499", + "slot": "12860532" + }, + { + "pubkey": "0xa98d2c5178466c6d2fa2047015740db090ae0d59a4c5f6d970a96bccd0e7831aab3f39e2a5a1b635540ac408276d0966", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0xad056dca61e104f5133ea27af8071587270e2f13ea2cf296be827d05af897131e9261e87528b66f8012f5cf0b8d2196900ee6dd2026bc3f60546fafe96c040387cab1badc6b2a89953c791218a1ecd2feac673352ed8b49d3495c9473cce013c", + "slot": "12860587" + }, + { + "pubkey": "0x8d0973e3d54cd62e0d2903af87a228f4af9459fbca5f402270ccc989b5f5028de841d8e4936f88ccb1b78bd485032fc0", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0xa9794b7e97366f247f67515fe0d895940cf87a3de11a78ac09047fb216274ca8ff1489f3430416368fef63841ab1ec960c5008c5d7b36e6ff2f1beed6c104171b5f0ff3c4da9f64cd37d8fe615502ccbdfd3dc56966403304bc3b03a0c084197", + "slot": "12860590" + }, + { + "pubkey": "0xa9e87b606f557e3e549780dce298026b1a2abb560f9afddf9aec5833c9929cb227f6bf6b52dc5f022881817799d5084e", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0x91255a6cb8f939cc0e0dd0b37a6718abd04789544aab0f09a9d7efb11a923d833e4300585c8d7a887b29fedffba05007137aa5f2224b25b88ade621ef88c52c1bee3a060c4c5bd99ddeac999696fc4dfaa5b2b7d105e17c9c89139d5081abfad", + "slot": "12860592" + }, + { + "pubkey": "0xa56a056753e75187b051cbdc1c55edec862fdabffec8ce33507de6b5cdac0a0ca60e41c005a39fae25e16d881cd08a76", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0xb9cc521964d26f95bde5bbf6931abe75c864570f884acb0649e69bf188af4789981a8a73b2cfb4f07d0bf3ff6188eb9b1866f0c3f08bd3dfc3940b40439b64391da9fe1259e3910d77e831d5c34b3cc7be2888342522485a49faf9dbf2e92477", + "slot": "12860593" + }, + { + "pubkey": "0x8778605e2372ed1c59098e706203bbceb1dd2f486406c14aa6074e4f25e183d3d8f327311012ed17a5b69863b0ca52ff", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0xafab3b1114e3cadca125d40a6d45a531110b604881d7226864a33701fe6196ada403c1f76fa03d166cb800249242e25b0581be32111a741b386e7f098f87c14b44ea4d5f82d4b8659c3c17964a0ac5aa20d4440c8eb7dae6c3fc015397fb458b", + "slot": "12860595" + }, + { + "pubkey": "0x9457b15012c45703e285861721da497544459df2c2eb456da7c0937af39e28ad10648bed06af8616692094fb3630f59e", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0xa4573b613c59a275d04f64c44e6bad734d2a5295ed245b5c4242bfbdecf191b2475e3833495f48e125d4b0549a0eda691209bc21e614bd532687f70ba0d2fd95a3367055fb91afbba7c61ffb1ef4d3fc97301a7db398a8de47dd379964f9764a", + "slot": "12860596" + }, + { + "pubkey": "0xb56607748d667a1b138bba4cae415dbd7e56b2869b5539bd2c1b525657191c2a193adecc1ccbf9bf15456fe909a54b5b", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0x90b7858502278d9743a02503e601c05e515aefe4ae67511393ad66f55706f32822c4b80caa2fbef90534c5ed2bb33df9120c161673960905e2d93f63cdb595770a619d6a614d9deb0b947f7727e87191f3dbd79742bae15b9c8fee6b8dae8f7c", + "slot": "12860598" + }, + { + "pubkey": "0xaaf695101c045a4039a016065c63cf45909747aa1e7830a29c66e17dfc39410195cfa1c0c7a6f7b433c034ad170a9e89", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0x8129c4fb83584641281091095005299aaf321536b262726f61933c6a287bb930db33effdd27eabeb449d1996707c01220ba8a71218ef5e1a8d51410f0a218ca7970df0660262dbffba4cb98cd181cbc688a7eba244599692f681331e64815700", + "slot": "12860599" + }, + { + "pubkey": "0xa2e91c2376868b9a2f7632103abbd913633f14ccd9ff666304d0afe4acbc85b0f1aab566e9429809f5b65f6604121f6a", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0xa7cf854f1ec4c9d56102a914af16dfa963fedd8a78227f399078dfcab4a5218f816cffb7041ad7930342f8e278ad45fd186964ec783054ae348c179555bf0ce3134d42c372f5405e21fd6b38b65fd5852eead435a471460335e8f5768f82f705", + "slot": "12860601" + }, + { + "pubkey": "0x81523805f2560170cbd0e6d99e566864fbf6d6f77f30b9899bcc770c8988c398466acce87ad9298673e0b64b3003e20a", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0x9570aa6992a2babf5d8dfecaae3973d6a93f4dc8a92678d28f82f866696495636b511b3a6dd230381c743e02aa51f17a181d5922b034aed18f3dd6c30a5f017b4dd861789d95c4a5b3de7232c77520025bd94b08f9951bfc82abbfb5ac969dfa", + "slot": "12860602" + }, + { + "pubkey": "0xb15a7398320b88a49c8f5b8582c4f7c5b3a58bd1e246133731efafa232f1fee6886c8280d78089587af469141c0acc2a", + "withdrawal_credentials": "0x02000000000000000000000016beb8782c2607fd90ca93f3119cf12c00255c6e", + "amount": "1800000000000", + "signature": "0x820f6e720dd32e7a5b40d76e02f80546232b76d40598894210ecebd3852ed79965a28ab40cdfbf725aa30cba29820bb90000fa8a96b87d5292cbd7363f9897ea11452a7af99b2a9f73ffcc7293d24fd5d17ea2f5ed1ba91afd19de2a842e871e", + "slot": "12860604" + }, + { + "pubkey": "0xa5be2b3abcf25a416c15d493a5350a656eff481ca87f394d51c0cf7aaf52e9ba712a9f6707a0341b58ef1a01b28e9838", + "withdrawal_credentials": "0x0000000000000000000000000000000000000000000000000000000000000000", + "amount": "217000000000", + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "slot": "12860605" + }, + { + "pubkey": "0xa0f7bce8bd6148c2c6c88de3f3f924b24e2420b30972f90dc6d18b175fe52f78cccab78f9591b99d2a2a6f08f876bd03", + "withdrawal_credentials": "0x0200000000000000000000002871cc399373bc5944ecb661e6dec29ca451808d", + "amount": "160000000000", + "signature": "0x8e1042845fa8a48b506a27d23ea3399691dccea565943ebd28d9a538ace09abbd36342a8d6d6f4fda00bb241bf5eaf0e06b6ed9e78e57fb48eb0619adedb0a3ce6cfae9374148862f4dbf443e56494a153abce1c1f7ef6fe8e681e40c52ce9f0", + "slot": "12860799" + }, + { + "pubkey": "0xaeb36892fe88356c3f0739c909bd412b32249049a0032993e85d92158298e91f9cca68a5e6cd574d7bf550442c475742", + "withdrawal_credentials": "0x0100000000000000000000001d9f1fea4f3cb1224d50828c0371147b7b1f7e8f", + "amount": "32000000000", + "signature": "0xaa26e3b6e1b9d39a9a3d7237546b93a133a142b3c148bbb138cd09ec353876cfa897966c864f4d2c43838165fb8a25b9199835f53f8b48c616c55b32eb8a1761cbf3ac2c6ce8409e1807e68a252ef4235e2466085ab545d462d94878e3fc8156", + "slot": "12860852" + }, + { + "pubkey": "0xaba08f5339a0d06c504ea75be64a7093ab2acff4ab7027400f61e1ca2f58310186ff86e69b943056b38c54cff9d0d14b", + "withdrawal_credentials": "0x010000000000000000000000341c2c475178747bc11369e798d6ff8bd66529ae", + "amount": "31000000000", + "signature": "0x917ba71c59e86d7b1fd43ad180329be01e46b89b33626e82074fdc01a18e9e22685a6fae96b365486827d7c9358b772c00f1b0992d75cc609a275d7d652352b0dac8ed5580cc28a1992d68da2a14ba258248418d6bf62eb7a989fd8c6d1402b2", + "slot": "12861039" + }, + { + "pubkey": "0xae00175b18cae7466b7fc33f081dad5547a232efa0cbef1ccd6075cfefbc09638f447ddaceaae52078bf9493a7ed13b5", + "withdrawal_credentials": "0x01000000000000000000000008ec37e2eb451ab6fb29fc14d215b0aeef170040", + "amount": "32000000000", + "signature": "0xa6ef9ae0132eb1385bc3d08a9fb0a1f304331aabeec2ef51a4a5716275c80a05249d0cd01647f95c6b9fd85c64d2ba4716a7e424187f078d856b268de4fd764af67622694ccba03778a463a9ee07328515485d2633d9f67d8cd6089f711ddd36", + "slot": "12861060" + }, + { + "pubkey": "0x96315b65969c031c66b4bcedd4b9293fcc60ee4cb2066ff1bdf01900e0f642414b89879ab375a4715dfdd49a8d3d6df7", + "withdrawal_credentials": "0x020000000000000000000000a3723b19f45ce4c3d369163da160f0cd6072a3cb", + "amount": "1800000000000", + "signature": "0xa3f6ef211794c0d981d04d9dda67c1a456393c00dd8ad036a6726f1da3d291d3b403507f1e5a3b48edc4b058ec644b8e0a40371496494e6e3da940b2649e878905d7d19055f15b8810482e77c54d17a6a9a39b30dc1015fea2ba581eaef90e97", + "slot": "12861169" + }, + { + "pubkey": "0xb27556c9b5df04e85979e2e17a1b2762eefd637debfaa1e1167d2ff8800152cb635a16544b0553cebe465f822644739f", + "withdrawal_credentials": "0x020000000000000000000000a3723b19f45ce4c3d369163da160f0cd6072a3cb", + "amount": "200000000000", + "signature": "0xaee69742d6bef4ef1136c25084359d6991ab896820b438c4e49dc247f6d58a224ba56139be1c571cd451fdaf39127efc0316d766ca693202663cdf6924b5bfe3d2cd0efd942fd30af77d6297c2c2325c5db115595bde1b4c1f5958e9f7481844", + "slot": "12861169" + } + ] +} \ No newline at end of file diff --git a/apps/indexer/test/support/indexer/block/catchup_supervisor_case.ex b/apps/indexer/test/support/indexer/block/catchup_supervisor_case.ex index 543e093cea59..00d8dec98372 100644 --- a/apps/indexer/test/support/indexer/block/catchup_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/block/catchup_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Block.Catchup.Supervisor.Case do alias Indexer.Block.Catchup diff --git a/apps/indexer/test/support/indexer/fetcher/beacon_blob_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/beacon_blob_supervisor_case.ex index 661b7ce3133d..03b16fdf4891 100644 --- a/apps/indexer/test/support/indexer/fetcher/beacon_blob_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/beacon_blob_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Beacon.Blob.Supervisor.Case do alias Indexer.Fetcher.Beacon.Blob diff --git a/apps/indexer/test/support/indexer/fetcher/beacon_deposit_status_supervisor_test.ex b/apps/indexer/test/support/indexer/fetcher/beacon_deposit_status_supervisor_test.ex new file mode 100644 index 000000000000..90de10afd29a --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/beacon_deposit_status_supervisor_test.ex @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Beacon.Deposit.Status.Supervisor.Case do + alias Indexer.Fetcher.Beacon.Deposit.Status + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + [fetcher_arguments] + |> Status.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/beacon_deposit_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/beacon_deposit_supervisor_case.ex new file mode 100644 index 000000000000..15de8a05f911 --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/beacon_deposit_supervisor_case.ex @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.Beacon.Deposit.Supervisor.Case do + alias Indexer.Fetcher.Beacon.Deposit + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + [fetcher_arguments] + |> Deposit.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/block_reward_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/block_reward_supervisor_case.ex index f6678cc715c7..2a25748f0832 100644 --- a/apps/indexer/test/support/indexer/fetcher/block_reward_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/block_reward_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.BlockReward.Supervisor.Case do alias Indexer.Fetcher.BlockReward diff --git a/apps/indexer/test/support/indexer/fetcher/celo_epoch_rewards_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/celo_epoch_rewards_supervisor_case.ex index 507981237dd2..6e4843e71b16 100644 --- a/apps/indexer/test/support/indexer/fetcher/celo_epoch_rewards_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/celo_epoch_rewards_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Celo.EpochBlockOperations.Supervisor.Case do alias Indexer.Fetcher.Celo.EpochBlockOperations diff --git a/apps/indexer/test/support/indexer/fetcher/coin_balance_catchup_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/coin_balance_catchup_supervisor_case.ex index 69ec7825616f..028d42d43326 100644 --- a/apps/indexer/test/support/indexer/fetcher/coin_balance_catchup_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/coin_balance_catchup_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.CoinBalance.Catchup.Supervisor.Case do alias Indexer.Fetcher.CoinBalance.Catchup diff --git a/apps/indexer/test/support/indexer/fetcher/coin_balance_realtime_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/coin_balance_realtime_supervisor_case.ex index 878f6ea7f3f0..db063546de10 100644 --- a/apps/indexer/test/support/indexer/fetcher/coin_balance_realtime_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/coin_balance_realtime_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.CoinBalance.Realtime.Supervisor.Case do alias Indexer.Fetcher.CoinBalance.Realtime diff --git a/apps/indexer/test/support/indexer/fetcher/contract_code_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/contract_code_supervisor_case.ex index e7cffcc33afe..145145153729 100644 --- a/apps/indexer/test/support/indexer/fetcher/contract_code_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/contract_code_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ContractCode.Supervisor.Case do alias Indexer.Fetcher.ContractCode diff --git a/apps/indexer/test/support/indexer/fetcher/empty_blocks_sanitizer_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/empty_blocks_sanitizer_supervisor_case.ex new file mode 100644 index 000000000000..e995f5166b14 --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/empty_blocks_sanitizer_supervisor_case.ex @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.EmptyBlocksSanitizer.Supervisor.Case do + alias Indexer.Fetcher.EmptyBlocksSanitizer + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + [fetcher_arguments] + |> EmptyBlocksSanitizer.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/filecoin_native_address_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/filecoin_native_address_supervisor_case.ex index 6f46db11c5cd..ff825f1912ea 100644 --- a/apps/indexer/test/support/indexer/fetcher/filecoin_native_address_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/filecoin_native_address_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Filecoin.AddressInfo.Supervisor.Case do alias Indexer.Fetcher.Filecoin.AddressInfo diff --git a/apps/indexer/test/support/indexer/fetcher/internal_transaction_delete_queue_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/internal_transaction_delete_queue_supervisor_case.ex new file mode 100644 index 000000000000..ce7435b87b6d --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/internal_transaction_delete_queue_supervisor_case.ex @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.InternalTransaction.DeleteQueue.Supervisor.Case do + alias Indexer.Fetcher.InternalTransaction.DeleteQueue + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + merged_fetcher_arguments = + Keyword.merge( + [ + flush_interval: 50, + max_batch_size: 1, + max_concurrency: 1 + ], + fetcher_arguments + ) + + [merged_fetcher_arguments] + |> DeleteQueue.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/internal_transaction_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/internal_transaction_supervisor_case.ex index 0c3ba2be7358..4498c57047ea 100644 --- a/apps/indexer/test/support/indexer/fetcher/internal_transaction_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/internal_transaction_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.InternalTransaction.Supervisor.Case do alias Indexer.Fetcher.InternalTransaction diff --git a/apps/indexer/test/support/indexer/fetcher/on_demand/coin_balance_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/on_demand/coin_balance_supervisor_case.ex index 4108763d3db8..a41a62836022 100644 --- a/apps/indexer/test/support/indexer/fetcher/on_demand/coin_balance_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/on_demand/coin_balance_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.CoinBalance.Supervisor.Case do alias Indexer.Fetcher.OnDemand.CoinBalance diff --git a/apps/indexer/test/support/indexer/fetcher/on_demand/token_balance_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/on_demand/token_balance_supervisor_case.ex index b4f017f03b27..902c981b789e 100644 --- a/apps/indexer/test/support/indexer/fetcher/on_demand/token_balance_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/on_demand/token_balance_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.OnDemand.TokenBalance.Supervisor.Case do alias Indexer.Fetcher.OnDemand.TokenBalance diff --git a/apps/indexer/test/support/indexer/fetcher/pending_transaction_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/pending_transaction_supervisor_case.ex index afaa15df1722..0bc38df14a39 100644 --- a/apps/indexer/test/support/indexer/fetcher/pending_transaction_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/pending_transaction_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.PendingTransaction.Supervisor.Case do alias Indexer.Fetcher.PendingTransaction diff --git a/apps/indexer/test/support/indexer/fetcher/replaced_transaction_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/replaced_transaction_supervisor_case.ex index c95dbbd79f50..0893057fc2b9 100644 --- a/apps/indexer/test/support/indexer/fetcher/replaced_transaction_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/replaced_transaction_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.ReplacedTransaction.Supervisor.Case do alias Indexer.Fetcher.ReplacedTransaction diff --git a/apps/indexer/test/support/indexer/fetcher/rootstock_data_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/rootstock_data_supervisor_case.ex index d58f3c5f10cd..0a1fb59444f8 100644 --- a/apps/indexer/test/support/indexer/fetcher/rootstock_data_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/rootstock_data_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.RootstockData.Supervisor.Case do alias Indexer.Fetcher.RootstockData diff --git a/apps/indexer/test/support/indexer/fetcher/signed_authorization_status_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/signed_authorization_status_supervisor_case.ex new file mode 100644 index 000000000000..29fcf00bd717 --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/signed_authorization_status_supervisor_case.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.SignedAuthorizationStatus.Supervisor.Case do + alias Indexer.Fetcher.SignedAuthorizationStatus + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + merged_fetcher_arguments = + Keyword.merge( + fetcher_arguments, + flush_interval: 50, + max_batch_size: 2, + max_concurrency: 2 + ) + + [merged_fetcher_arguments] + |> SignedAuthorizationStatus.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/token_balance_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/token_balance_current_supervisor_case.ex similarity index 59% rename from apps/indexer/test/support/indexer/fetcher/token_balance_supervisor_case.ex rename to apps/indexer/test/support/indexer/fetcher/token_balance_current_supervisor_case.ex index 45ad1713ae76..3d2da821a0e4 100644 --- a/apps/indexer/test/support/indexer/fetcher/token_balance_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/token_balance_current_supervisor_case.ex @@ -1,5 +1,6 @@ -defmodule Indexer.Fetcher.TokenBalance.Supervisor.Case do - alias Indexer.Fetcher.TokenBalance +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenBalance.Current.Supervisor.Case do + alias Indexer.Fetcher.TokenBalance.Current, as: TokenBalanceCurrent def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do merged_fetcher_arguments = @@ -11,7 +12,7 @@ defmodule Indexer.Fetcher.TokenBalance.Supervisor.Case do ) [merged_fetcher_arguments] - |> TokenBalance.Supervisor.child_spec() + |> TokenBalanceCurrent.Supervisor.child_spec() |> ExUnit.Callbacks.start_supervised!() end end diff --git a/apps/indexer/test/support/indexer/fetcher/token_balance_historical_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/token_balance_historical_supervisor_case.ex new file mode 100644 index 000000000000..1873ca032c34 --- /dev/null +++ b/apps/indexer/test/support/indexer/fetcher/token_balance_historical_supervisor_case.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Indexer.Fetcher.TokenBalance.Historical.Supervisor.Case do + alias Indexer.Fetcher.TokenBalance.Historical, as: TokenBalanceHistorical + + def start_supervised!(fetcher_arguments \\ []) when is_list(fetcher_arguments) do + merged_fetcher_arguments = + Keyword.merge( + fetcher_arguments, + flush_interval: 50, + max_batch_size: 1, + max_concurrency: 1 + ) + + [merged_fetcher_arguments] + |> TokenBalanceHistorical.Supervisor.child_spec() + |> ExUnit.Callbacks.start_supervised!() + end +end diff --git a/apps/indexer/test/support/indexer/fetcher/token_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/token_supervisor_case.ex index 97983d3d6311..e482778ede32 100644 --- a/apps/indexer/test/support/indexer/fetcher/token_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/token_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Token.Supervisor.Case do alias Indexer.Fetcher.Token diff --git a/apps/indexer/test/support/indexer/fetcher/token_updater_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/token_updater_supervisor_case.ex index 9f8a70b74f2b..092885c1163a 100644 --- a/apps/indexer/test/support/indexer/fetcher/token_updater_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/token_updater_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.TokenUpdater.Supervisor.Case do alias Indexer.Fetcher.TokenUpdater diff --git a/apps/indexer/test/support/indexer/fetcher/uncle_block_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/uncle_block_supervisor_case.ex index 1de336427184..878c7ee9d35a 100644 --- a/apps/indexer/test/support/indexer/fetcher/uncle_block_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/uncle_block_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.UncleBlock.Supervisor.Case do alias Indexer.Fetcher.UncleBlock diff --git a/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex b/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex index 2f419efcc852..322047f4e02d 100644 --- a/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex +++ b/apps/indexer/test/support/indexer/fetcher/withdrawal_supervisor_case.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Indexer.Fetcher.Withdrawal.Supervisor.Case do alias Indexer.Fetcher.Withdrawal diff --git a/apps/indexer/test/test_helper.exs b/apps/indexer/test/test_helper.exs index 381b6272bc29..5b7f5acdd07e 100644 --- a/apps/indexer/test/test_helper.exs +++ b/apps/indexer/test/test_helper.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # https://github.com/CircleCI-Public/circleci-demo-elixir-phoenix/blob/a89de33a01df67b6773ac90adc74c34367a4a2d6/test/test_helper.exs#L1-L3 junit_folder = Mix.Project.build_path() <> "/junit/#{Mix.Project.config()[:app]}" File.mkdir_p!(junit_folder) @@ -17,9 +18,12 @@ end Mox.defmock(EthereumJSONRPC.Mox, for: EthereumJSONRPC.Transport) Mox.defmock(Indexer.BufferedTaskTest.RetryableTask, for: Indexer.BufferedTask) Mox.defmock(Indexer.BufferedTaskTest.ShrinkableTask, for: Indexer.BufferedTask) -Mox.defmock(Explorer.Mox.HTTPoison, for: HTTPoison.Base) +Mox.defmock(Explorer.Mock.TeslaAdapter, for: Tesla.Adapter) ExUnit.configure(formatters: [JUnitFormatter, ExUnit.CLIFormatter]) ExUnit.start() +Explorer.TestHelper.run_necessary_background_migrations() + Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :manual) +Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo.EventNotifications, :manual) diff --git a/apps/nft_media_handler/lib/nft_media_handler.ex b/apps/nft_media_handler/lib/nft_media_handler.ex index c284da6d4e52..df92f576ff5e 100644 --- a/apps/nft_media_handler/lib/nft_media_handler.ex +++ b/apps/nft_media_handler/lib/nft_media_handler.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler do @moduledoc """ Module resizes and uploads images to R2/S3 bucket. @@ -163,39 +164,45 @@ defmodule NFTMediaHandler do case URI.parse(uri) do %URI{scheme: "ipfs", host: host, path: path} -> resource_id = - with "ipfs" <- host, - "/" <> resource_id <- path do - resource_id - else - _ -> - if is_nil(path), do: host, else: host <> path + cond do + host == "ipfs" and is_binary(path) and String.starts_with?(path, "/") -> + String.replace_leading(path, "/", "") + + is_binary(host) and host != "" -> + build_ipfs_resource_id(host, path) + + true -> + path end - {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} + maybe_fetch_ipfs_url(resource_id, uri) %URI{scheme: "ar", host: _host, path: resource_id} -> {TokenMetadataRetriever.arweave_link(resource_id), TokenMetadataRetriever.ar_headers()} %URI{scheme: _, path: "/ipfs/" <> resource_id} -> - {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} + maybe_fetch_ipfs_url(resource_id, uri) %URI{scheme: _, path: "ipfs/" <> resource_id} -> - {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} + maybe_fetch_ipfs_url(resource_id, uri) %URI{scheme: scheme} when not is_nil(scheme) -> {uri, []} %URI{path: path} -> - case path do - "Qm" <> <<_::binary-size(44)>> = resource_id -> - {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} + maybe_fetch_ipfs_url(path, uri) + end + end - "bafybe" <> _ = resource_id -> - {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} + defp build_ipfs_resource_id(host, path) do + if is_nil(path), do: host, else: host <> path + end - _ -> - {uri, []} - end + defp maybe_fetch_ipfs_url(resource_id, uri) do + if is_binary(resource_id) and TokenMetadataRetriever.valid_ipfs_path?("ipfs://" <> resource_id) do + {TokenMetadataRetriever.ipfs_link(resource_id), TokenMetadataRetriever.ipfs_headers()} + else + {uri, []} end end end diff --git a/apps/nft_media_handler/lib/nft_media_handler/application.ex b/apps/nft_media_handler/lib/nft_media_handler/application.ex index 1f415898b080..2e6ac1851be9 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/application.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/application.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.Application do @moduledoc """ This is the `Application` module for `NFTMediaHandler`. @@ -9,7 +10,9 @@ defmodule NFTMediaHandler.Application do File.mkdir(Application.get_env(:nft_media_handler, :tmp_dir)) base_children = [ - Supervisor.child_spec({Task.Supervisor, name: NFTMediaHandler.TaskSupervisor}, id: NFTMediaHandler.TaskSupervisor), + Supervisor.child_spec({Task.Supervisor, name: NFTMediaHandler.TaskSupervisor}, + id: NFTMediaHandler.TaskSupervisor + ), NFTMediaHandler.Dispatcher ] diff --git a/apps/nft_media_handler/lib/nft_media_handler/dispatcher.ex b/apps/nft_media_handler/lib/nft_media_handler/dispatcher.ex index aae38745ca5f..6a5b2df65237 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/dispatcher.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/dispatcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.Dispatcher do @moduledoc """ Module responsible for spawning tasks for uploading image diff --git a/apps/nft_media_handler/lib/nft_media_handler/dispatcher_interface.ex b/apps/nft_media_handler/lib/nft_media_handler/dispatcher_interface.ex index 27f8fdec1919..3e5469ad839c 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/dispatcher_interface.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/dispatcher_interface.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.DispatcherInterface do @moduledoc """ Interface to call the Indexer.NFTMediaHandler.Queue. @@ -6,6 +7,8 @@ defmodule NFTMediaHandler.DispatcherInterface do require Logger use GenServer + alias Explorer.Helper + def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end @@ -15,12 +18,24 @@ defmodule NFTMediaHandler.DispatcherInterface do """ @impl true def init(_) do - nodes = :nft_media_handler |> Application.get_env(:nodes_map) |> Map.to_list() + {:ok, nil, {:continue, 1}} + end - if Enum.empty?(nodes) do - {:stop, "NFT_MEDIA_HANDLER_NODES_MAP must contain at least one node"} - else - {:ok, %{used_nodes: [], unused_nodes: nodes}} + @impl true + def handle_continue(attempt, _state) do + attempt |> Kernel.**(3) |> :timer.seconds() |> :timer.sleep() + + get_indexer_nodes() + |> case do + [] -> + if attempt < 5 do + {:noreply, nil, {:continue, attempt + 1}} + else + raise "No indexer nodes discovered after #{attempt} attempts" + end + + nodes -> + {:noreply, %{used_nodes: [], unused_nodes: nodes}} end end @@ -33,8 +48,14 @@ defmodule NFTMediaHandler.DispatcherInterface do {used, unused, node_to_call} = case unused_nodes do [] -> - [to_call | remains] = used_nodes |> Enum.reverse() - {[to_call], remains, to_call} + get_indexer_nodes() + |> case do + [] -> + raise "No indexer nodes discovered" + + [to_call | remains] -> + {[to_call], remains, to_call} + end [to_call | remains] -> {[to_call | used_nodes], remains, to_call} @@ -58,13 +79,15 @@ defmodule NFTMediaHandler.DispatcherInterface do function = :get_urls_to_fetch if Application.get_env(:nft_media_handler, :remote?) do - {node, folder} = GenServer.call(__MODULE__, :take_node_to_call) + node = GenServer.call(__MODULE__, :take_node_to_call) + + {urls, folder} = + node |> :rpc.call(Indexer.NFTMediaHandler.Queue, function, args) |> Helper.process_rpc_response(node, {[], nil}) - {node |> :rpc.call(Indexer.NFTMediaHandler.Queue, :get_urls_to_fetch, args) |> process_rpc_response(node), node, - folder} + {urls, node, folder} else - folder = Application.get_env(:nft_media_handler, :nodes_map)[:self] - {apply(Indexer.NFTMediaHandler.Queue, function, args), :self, folder} + {urls, folder} = apply(Indexer.NFTMediaHandler.Queue, function, args) + {urls, :self, folder} end end @@ -84,10 +107,8 @@ defmodule NFTMediaHandler.DispatcherInterface do apply(Indexer.NFTMediaHandler.Queue, function, args) end - defp process_rpc_response({:badrpc, _reason} = error, node) do - Logger.error("Received an error from #{node}: #{inspect(error)}") - [] + defp get_indexer_nodes do + Node.list() + |> Enum.filter(&Helper.indexer_node?/1) end - - defp process_rpc_response(response, _node), do: response end diff --git a/apps/nft_media_handler/lib/nft_media_handler/image/resizer.ex b/apps/nft_media_handler/lib/nft_media_handler/image/resizer.ex index 659e4c9f0fb4..7a651383f816 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/image/resizer.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/image/resizer.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.Image.Resizer do @moduledoc """ Resizes an image diff --git a/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex b/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex index 05d3e44da4d5..9f2c13c819ab 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/media/fetcher.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.Media.Fetcher do @moduledoc """ Module fetches media from various sources diff --git a/apps/nft_media_handler/lib/nft_media_handler/r2/uploader.ex b/apps/nft_media_handler/lib/nft_media_handler/r2/uploader.ex index 5ecebf44bc05..b6fcaf2c5c28 100644 --- a/apps/nft_media_handler/lib/nft_media_handler/r2/uploader.ex +++ b/apps/nft_media_handler/lib/nft_media_handler/r2/uploader.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.R2.Uploader do @moduledoc """ Uploads an image to R2/S3 diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index 04baf65c2ffb..75e134f55c91 100644 --- a/apps/nft_media_handler/mix.exs +++ b/apps/nft_media_handler/mix.exs @@ -1,15 +1,16 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule NFTMediaHandler.MixProject do use Mix.Project def project do [ app: :nft_media_handler, - version: "8.0.2", + version: "11.2.2", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", - elixir: "~> 1.17", + elixir: "~> 1.19", start_permanent: Mix.env() == :prod, deps: deps() ] diff --git a/apps/utils/lib/credo/checks/compile_env_usage.ex b/apps/utils/lib/credo/checks/compile_env_usage.ex index b17505011edc..16407d8fdc66 100644 --- a/apps/utils/lib/credo/checks/compile_env_usage.ex +++ b/apps/utils/lib/credo/checks/compile_env_usage.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Utils.Credo.Checks.CompileEnvUsage do @moduledoc """ Disallows usage of Application.compile_env throughout the codebase, @@ -46,7 +47,7 @@ defmodule Utils.Credo.Checks.CompileEnvUsage do issue_meta, message: """ Avoid using Application.compile_env, use runtime configuration instead. If you need compile-time config, use Utils.CompileTimeEnvHelper. - More details: https://github.com/blockscout/blockscout/tree/master/CONTRIBUTING.md#compile-time-environment-variables + More details: https://github.com/blockscout/blockscout/tree/master/.github/CONTRIBUTING.md#compile-time-environment-variables """, trigger: trigger ) diff --git a/apps/utils/lib/utils/compile_time_env_helper.ex b/apps/utils/lib/utils/compile_time_env_helper.ex index d5f50191cd1b..8c041eba213f 100644 --- a/apps/utils/lib/utils/compile_time_env_helper.ex +++ b/apps/utils/lib/utils/compile_time_env_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Utils.CompileTimeEnvHelper do @moduledoc """ A module that helps with compile time environment variable handling and automatic diff --git a/apps/utils/lib/utils/config_helper.ex b/apps/utils/lib/utils/config_helper.ex new file mode 100644 index 000000000000..4a905be451c9 --- /dev/null +++ b/apps/utils/lib/utils/config_helper.ex @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.ConfigHelper do + @moduledoc """ + Helper functions for parsing config values. + """ + + @doc """ + Parses a time value from a string. + """ + @spec parse_time_value(String.t()) :: non_neg_integer() | :error + def parse_time_value(value) do + case value |> String.downcase() |> Integer.parse() do + {milliseconds, "ms"} -> milliseconds + {days, "d"} -> :timer.hours(24) * days + {hours, "h"} -> :timer.hours(hours) + {minutes, "m"} -> :timer.minutes(minutes) + {seconds, s} when s in ["s", ""] -> :timer.seconds(seconds) + _ -> :error + end + end + + @doc """ + Safely gets environment variable, returning default value if the variable is not set or is an + empty string. + """ + @spec safe_get_env(String.t(), any()) :: String.t() + def safe_get_env(env_var, default_value) do + env_var + |> System.get_env(default_value) + |> case do + "" -> default_value + value -> value + end + |> then(fn + nil -> "" + value -> to_string(value) + end) + end + + @doc """ + Parses an URL from an environment variable with optional trailing slash handling. + + Retrieves the URL from the specified environment variable, normalizes it by + trimming any trailing slash, and optionally appends a trailing slash based + on the `trailing_slash_needed?` parameter. If the environment variable is + not set or is empty, returns the default value or nil. + + ## Parameters + - `env_var`: The name of the environment variable containing the URL. + - `default_value`: The value to return if the environment variable is not + set or is empty (default: nil). + - `trailing_slash_needed?`: Whether to append a trailing slash to non-empty + URLs (default: false). + + ## Returns + - A normalized URL string (with or without trailing slash based on the + `trailing_slash_needed?` parameter) if the environment variable contains + a non-empty value. + - The `default_value` if the environment variable is not set or is empty. + - nil if the environment variable is not set and no default value is + provided. + """ + @spec parse_url_env_var(String.t(), String.t() | nil, boolean()) :: String.t() | nil + def parse_url_env_var(env_var, default_value \\ nil, trailing_slash_needed? \\ false) do + with url when url != "" <- safe_get_env(env_var, default_value), + true <- valid_url?(url), + url <- String.trim_trailing(url, "/"), + {url, true} <- {url, trailing_slash_needed?} do + url <> "/" + else + {url, false} -> + url + + _ -> + default_value + end + end + + @doc """ + Checks if input string is a valid URL + """ + @spec valid_url?(term()) :: boolean() + def valid_url?(string) when is_binary(string) do + uri = URI.parse(string) + + !is_nil(uri.scheme) && !is_nil(uri.host) + end + + def valid_url?(_), do: false +end diff --git a/apps/utils/lib/utils/helper.ex b/apps/utils/lib/utils/helper.ex new file mode 100644 index 000000000000..ae3ee64784c4 --- /dev/null +++ b/apps/utils/lib/utils/helper.ex @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.Helper do + @moduledoc """ + This module contains helper functions for the entire application. + """ + + @doc """ + Retrieves the host URL for the application. + + ## Returns + + - A string containing the host URL for the application. + """ + @spec instance_url :: URI.t() + def instance_url do + %URI{scheme: scheme(), host: host(), port: port(), path: path()} + end + + defp url_params do + Application.get_env(:block_scout_web, BlockScoutWeb.Endpoint)[:url] + end + + defp scheme do + Keyword.get(url_params(), :scheme, "http") + end + + defp host do + url_params()[:host] + end + + defp port do + url_params()[:http][:port] + end + + defp path do + raw_path = url_params()[:path] + + if raw_path |> String.ends_with?("/") do + raw_path |> String.slice(0..-2//1) + else + raw_path + end + end +end diff --git a/apps/utils/lib/utils/http_client/httpoison_helper.ex b/apps/utils/lib/utils/http_client/httpoison_helper.ex new file mode 100644 index 000000000000..fc034f75c20b --- /dev/null +++ b/apps/utils/lib/utils/http_client/httpoison_helper.ex @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.HttpClient.HTTPoisonHelper do + @moduledoc """ + Helper module for building HTTPoison request options. + + This module provides utilities to construct keyword lists of options + for HTTPoison HTTP client requests, including timeouts, authentication, + redirects, and connection pooling. + """ + + def request_opts(options) do + [] + |> add_recv_timeout_option(options[:recv_timeout]) + |> add_timeout_option(options[:timeout]) + |> add_basic_auth_option(options[:basic_auth]) + |> add_pool_option(options[:pool]) + |> add_follow_redirect_option(options[:follow_redirect]) + |> add_insecure_option(options[:insecure]) + |> add_params_option(options[:params]) + end + + defp add_recv_timeout_option(options, nil), do: options + + defp add_recv_timeout_option(options, timeout) do + Keyword.put(options, :recv_timeout, timeout) + end + + defp add_params_option(options, nil), do: options + + defp add_params_option(options, params) do + Keyword.put(options, :params, params) + end + + defp add_timeout_option(options, nil), do: options + + defp add_timeout_option(options, timeout) do + Keyword.put(options, :timeout, timeout) + end + + defp add_follow_redirect_option(options, nil), do: options + + defp add_follow_redirect_option(options, follow_redirect) do + Keyword.put(options, :follow_redirect, follow_redirect) + end + + defp add_insecure_option(options, true) do + Keyword.put(options, :hackney, [:insecure | options[:hackney] || []]) + end + + defp add_insecure_option(options, _insecure), do: options + + defp add_basic_auth_option(options, {username, password}) do + Keyword.put(options, :hackney, [{:basic_auth, {username, password}} | options[:hackney] || []]) + end + + defp add_basic_auth_option(options, _basic_auth), do: options + + defp add_pool_option(options, nil), do: options + + defp add_pool_option(options, pool) do + Keyword.put(options, :hackney, [{:pool, pool} | options[:hackney] || []]) + end +end diff --git a/apps/utils/lib/utils/http_client/tesla_helper.ex b/apps/utils/lib/utils/http_client/tesla_helper.ex new file mode 100644 index 000000000000..5349ac357704 --- /dev/null +++ b/apps/utils/lib/utils/http_client/tesla_helper.ex @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Utils.HttpClient.TeslaHelper do + @moduledoc """ + Helper module for building Tesla HTTP clients and adapter options. + + This module provides utilities to construct Tesla clients with appropriate + middleware and adapter options, including timeouts, authentication, redirects, + and TLS settings. + """ + + @doc """ + Builds a Tesla client with configured middleware based on the provided options. + + ## Parameters + - `options`: A keyword list or map containing HTTP client options + + ## Returns + - A Tesla client struct with configured middleware + """ + @spec client(keyword() | map()) :: Tesla.Client.t() + def client(options) do + options[:recv_timeout] + |> add_timeout_middleware() + |> add_follow_redirect_middleware(options[:follow_redirect]) + |> add_basic_auth_middleware(options[:basic_auth]) + |> Tesla.client() + end + + @doc """ + Builds adapter options for Tesla HTTP client requests from the provided options map. + + ## Parameters + - `options`: A keyword list or map containing HTTP client options + + ## Returns + - A keyword list containing adapter options for Tesla + """ + @spec request_opts(keyword() | map()) :: keyword() + def request_opts(options) do + adapter_options = + [protocols: [:http1]] + |> add_recv_timeout_option(options[:recv_timeout]) + |> add_timeout_option(options[:timeout]) + |> add_insecure_option(options[:insecure]) + + [adapter: adapter_options] + end + + defp add_timeout_middleware(middleware \\ [], timeout) + + defp add_timeout_middleware(middleware, nil), do: middleware + + defp add_timeout_middleware(middleware, timeout) do + [{Tesla.Middleware.Timeout, timeout: timeout} | middleware] + end + + defp add_follow_redirect_middleware(middleware, true) do + [Tesla.Middleware.FollowRedirects | middleware] + end + + defp add_follow_redirect_middleware(middleware, _follow_redirect?), do: middleware + + defp add_basic_auth_middleware(middleware, {username, password}) do + [{Tesla.Middleware.BasicAuth, %{username: username, password: password}} | middleware] + end + + defp add_basic_auth_middleware(middleware, _basic_auth), do: middleware + + defp add_recv_timeout_option(options, nil), do: options + + defp add_recv_timeout_option(options, timeout) do + Keyword.put(options, :timeout, timeout) + end + + defp add_timeout_option(options, nil), do: options + + defp add_timeout_option(options, timeout) do + Keyword.put(options, :transport_opts, [{:timeout, timeout} | options[:transport_opts] || []]) + end + + defp add_insecure_option(options, true) do + Keyword.put(options, :transport_opts, [{:verify, :verify_none} | options[:transport_opts] || []]) + end + + defp add_insecure_option(options, _insecure), do: options +end diff --git a/apps/utils/lib/utils/runtime_env_helper.ex b/apps/utils/lib/utils/runtime_env_helper.ex index 75193f4d41e4..56b2964371b0 100644 --- a/apps/utils/lib/utils/runtime_env_helper.ex +++ b/apps/utils/lib/utils/runtime_env_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Utils.RuntimeEnvHelper do @moduledoc """ A module that provides runtime access to environment variables with a similar @@ -38,8 +39,20 @@ defmodule Utils.RuntimeEnvHelper do # Generate the runtime env module name sibling_module = Module.concat(caller_module, "__RuntimeEnvs__") + # Resolve __MODULE__ in paths before injecting into sibling module + resolved_env_vars = + Enum.map(env_vars, fn {var_name, path} -> + resolved_path = + Macro.postwalk(path, fn + {:__MODULE__, _, _} -> caller_module + node -> node + end) + + {var_name, resolved_path} + end) + sibling_module_body = - for {var_name, path} <- env_vars do + for {var_name, path} <- resolved_env_vars do quote do def unquote(var_name)() do # credo:disable-for-next-line Credo.Check.Design.AliasUsage diff --git a/apps/utils/lib/utils/token_instance_helper.ex b/apps/utils/lib/utils/token_instance_helper.ex index cd6f6bbe1588..f8a9ab1fc81d 100644 --- a/apps/utils/lib/utils/token_instance_helper.ex +++ b/apps/utils/lib/utils/token_instance_helper.ex @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Utils.TokenInstanceHelper do @moduledoc """ Auxiliary functions for NFTs diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index bf03d2273557..2946a2227570 100644 --- a/apps/utils/mix.exs +++ b/apps/utils/mix.exs @@ -1,38 +1,40 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Utils.MixProject do use Mix.Project def project do [ app: :utils, - version: "8.0.2", + version: "11.2.2", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", lockfile: "../../mix.lock", - elixir: "~> 1.17", + elixir: "~> 1.19", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, - deps: deps(), - preferred_cli_env: [ - credo: :test, - dialyzer: :test - ] + deps: deps() ] end # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger] + extra_applications: [:logger, :tesla] ] end + def cli do + [preferred_envs: [credo: :test, dialyzer: :test]] + end + # Run "mix help deps" to learn about dependencies. defp deps do [ {:credo, "~> 1.5", only: [:test, :dev], runtime: false}, {:httpoison, "~> 2.0"}, - {:mime, "~> 2.0"} + {:mime, "~> 2.0"}, + {:tesla, "~> 1.20.0"} ] end diff --git a/apps/utils/test/checks/compile_env_usage_test.exs b/apps/utils/test/checks/compile_env_usage_test.exs index 3422a8ccb3e3..010a6721925c 100644 --- a/apps/utils/test/checks/compile_env_usage_test.exs +++ b/apps/utils/test/checks/compile_env_usage_test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule Utils.Credo.Checks.CompileEnvUsageTest do use Credo.Test.Case diff --git a/apps/utils/test/test_helper.exs b/apps/utils/test/test_helper.exs index 97950157e27c..cd32697e9249 100644 --- a/apps/utils/test/test_helper.exs +++ b/apps/utils/test/test_helper.exs @@ -1 +1,2 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout {:ok, _} = Application.ensure_all_started(:credo) diff --git a/appspec.yml b/appspec.yml deleted file mode 100644 index 294c4666503c..000000000000 --- a/appspec.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: 0.0 -os: linux -files: - - source: . - destination: /opt/app -hooks: - ApplicationStop: - - location: bin/deployment/stop - timeout: 300 - AfterInstall: - - location: bin/deployment/build - ApplicationStart: - - location: bin/deployment/migrate - runas: ec2-user - timeout: 300 - - location: bin/deployment/start - timeout: 3600 - ValidateService: - - location: bin/deployment/health_check - timeout: 3600 diff --git a/bin/deployment/migrate b/bin/deployment/migrate index b00a8df6b611..cd282e141756 100755 --- a/bin/deployment/migrate +++ b/bin/deployment/migrate @@ -11,7 +11,7 @@ source /etc/environment export DATABASE_URL export POOL_SIZE -export ECTO_USE_SSL +export ECTO_SSL_MODE mix ecto.create mix ecto.migrate diff --git a/bin/install_chrome_headless.sh b/bin/install_chrome_headless.sh index 63f2d98a2493..ec8945062cdd 100755 --- a/bin/install_chrome_headless.sh +++ b/bin/install_chrome_headless.sh @@ -2,11 +2,12 @@ export DISPLAY=:99.0 sh -e /etc/init.d/xvfb start export CHROMEDRIVER_VERSION=$(curl -s "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json" | jq -r '.channels' | jq -r '.Stable' | jq -r '.version') +#export CHROMEDRIVER_VERSION=148.0.7778.179 curl -L -O "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip" unzip -j chromedriver-linux64.zip sudo chmod +x chromedriver sudo mv chromedriver /usr/local/bin wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -sudo apt install ./google-chrome-stable_current_amd64.deb sudo apt-get update sudo apt-get install libstdc++6 +sudo apt install ./google-chrome-stable_current_amd64.deb diff --git a/bin/version_bump.sh b/bin/version_bump.sh index 512400a554d0..1452ec47ce36 100755 --- a/bin/version_bump.sh +++ b/bin/version_bump.sh @@ -14,7 +14,7 @@ CONFIG_FILE="$(pwd)/rel/config.exs" DOCKER_COMPOSE_FILE="$(pwd)/docker-compose/docker-compose.yml" DOCKER_COMPOSE_NO_SERVICES_FILE="$(pwd)/docker-compose/no-services.yml" MAKE_FILE="$(pwd)/docker/Makefile" -WORKFLOW_FILES=($(find "$(pwd)/.github/workflows" -type f \( -name "pre-release*" -o -name "release*" -o -name "publish-regular-docker-image-on-demand*" -o -name "publish-docker-image-*" \))) +WORKFLOW_FILES=($(find "$(pwd)/.github/workflows" -type f \( -name "pre-release*" -o -name "release*" -o -name "publish-regular-docker-image-on-demand*" -o -name "publish-docker-image-*" -o -name "generate-swagger*" \))) METADATA_RETRIEVER_FILE="$(pwd)/apps/explorer/lib/explorer/token/metadata_retriever.ex" # Function to bump version @@ -65,8 +65,8 @@ bump_version() { done sed -i '' "s/version: \"$current_version\"/version: \"$new_version\"/" "$CONFIG_FILE" - sed -i '' "s/RELEASE_VERSION: $current_version/RELEASE_VERSION: $new_version/" "$DOCKER_COMPOSE_FILE" - sed -i '' "s/RELEASE_VERSION: $current_version/RELEASE_VERSION: $new_version/" "$DOCKER_COMPOSE_NO_SERVICES_FILE" + # sed -i '' "s/RELEASE_VERSION: $current_version/RELEASE_VERSION: $new_version/" "$DOCKER_COMPOSE_FILE" + # sed -i '' "s/RELEASE_VERSION: $current_version/RELEASE_VERSION: $new_version/" "$DOCKER_COMPOSE_NO_SERVICES_FILE" sed -i '' "s/RELEASE_VERSION ?= '$current_version'/RELEASE_VERSION ?= '$new_version'/" "$MAKE_FILE" # Replace the old version with the new version in the GitHub workflows files diff --git a/blockscout.png b/blockscout.png deleted file mode 100644 index 5a5c2814492d..000000000000 Binary files a/blockscout.png and /dev/null differ diff --git a/config/config.exs b/config/config.exs index f09d21a3f065..7f77b099441d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,7 +1,20 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout # This file is responsible for configuring your application # and its dependencies with the aid of the Config module. import Config +config :explorer, Oban, + engine: Oban.Engines.Basic, + notifier: Oban.Notifiers.Postgres, + repo: Explorer.Repo, + plugins: [ + {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}, + {Oban.Plugins.Cron, + crontab: [ + {"@daily", Explorer.Chain.CsvExport.RequestsSanitizer} + ]} + ] + # By default, the umbrella project as well as each child # application will require this configuration file, ensuring # they all use the same configuration. While one could @@ -13,64 +26,16 @@ end config :phoenix, :json_library, Jason -config :logger, - backends: [ - # all applications and all levels - :console, - # all applications, but only errors - {LoggerFileBackend, :error}, - # only :ecto, but all levels - {LoggerFileBackend, :ecto}, - # only :block_scout_web, but all levels - {LoggerFileBackend, :block_scout_web}, - # only :ethereum_jsonrpc, but all levels - {LoggerFileBackend, :ethereum_jsonrpc}, - # only :explorer, but all levels - {LoggerFileBackend, :explorer}, - # only :indexer, but all levels - {LoggerFileBackend, :indexer}, - {LoggerFileBackend, :indexer_token_balances}, - {LoggerFileBackend, :token_instances}, - {LoggerFileBackend, :reading_token_functions}, - {LoggerFileBackend, :pending_transactions_to_refetch}, - {LoggerFileBackend, :empty_blocks_to_refetch}, - {LoggerFileBackend, :withdrawal}, - {LoggerFileBackend, :api}, - {LoggerFileBackend, :block_import_timings}, - {LoggerFileBackend, :account}, - {LoggerFileBackend, :api_v2}, - LoggerJSON - ] +config :logger, :default_formatter, format: "$dateT$time $metadata[$level] $message\n" -config :logger_json, :backend, - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id duration status unit endpoint method)a, - json_encoder: Jason, - formatter: LoggerJSON.Formatters.BasicLogger +config :logger, :console, metadata: ConfigHelper.logger_metadata() -config :logger, :console, - # Use same format for all loggers, even though the level should only ever be `:error` for `:error` backend - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a +config :logger, :error, metadata: ConfigHelper.logger_metadata() -config :logger, :ecto, - # Use same format for all loggers, even though the level should only ever be `:error` for `:error` backend - format: "$dateT$time $metadata[$level] $message\n", - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a, - metadata_filter: [application: :ecto] +config :logger, :block_scout_web, metadata: ConfigHelper.logger_metadata() -config :logger, :error, - # Use same format for all loggers, even though the level should only ever be `:error` for `:error` backend - format: "$dateT$time $metadata[$level] $message\n", - level: :error, - metadata: - ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count - block_number step count error_count shrunk import_id transaction_id)a +# todo: migrate from deprecated usages +config :tesla, disable_deprecated_builder_warning: true # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/config/config_helper.exs b/config/config_helper.exs index dc6524b6a441..f75173788cd9 100644 --- a/config/config_helper.exs +++ b/config/config_helper.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule ConfigHelper do require Logger @@ -8,48 +9,106 @@ defmodule ConfigHelper do def repos do base_repos = [Explorer.Repo, Explorer.Repo.Account] - chain_type_repo = + chain_identity_repos = %{ - arbitrum: Explorer.Repo.Arbitrum, - blackfort: Explorer.Repo.Blackfort, - celo: Explorer.Repo.Celo, - ethereum: Explorer.Repo.Beacon, - filecoin: Explorer.Repo.Filecoin, - optimism: Explorer.Repo.Optimism, - polygon_edge: Explorer.Repo.PolygonEdge, - polygon_zkevm: Explorer.Repo.PolygonZkevm, - rsk: Explorer.Repo.RSK, - scroll: Explorer.Repo.Scroll, - shibarium: Explorer.Repo.Shibarium, - stability: Explorer.Repo.Stability, - suave: Explorer.Repo.Suave, - zilliqa: Explorer.Repo.Zilliqa, - zksync: Explorer.Repo.ZkSync, - neon: Explorer.Repo.Neon + {:arbitrum, nil} => [Explorer.Repo.Arbitrum], + {:blackfort, nil} => [Explorer.Repo.Blackfort], + {:ethereum, nil} => [Explorer.Repo.Beacon], + {:filecoin, nil} => [Explorer.Repo.Filecoin], + {:optimism, nil} => [Explorer.Repo.Optimism], + {:rsk, nil} => [Explorer.Repo.RSK], + {:scroll, nil} => [Explorer.Repo.Scroll], + {:shibarium, nil} => [Explorer.Repo.Shibarium], + {:stability, nil} => [Explorer.Repo.Stability], + {:suave, nil} => [Explorer.Repo.Suave], + {:zilliqa, nil} => [Explorer.Repo.Zilliqa], + {:zksync, nil} => [Explorer.Repo.ZkSync], + {:neon, nil} => [Explorer.Repo.Neon], + {:optimism, :celo} => [ + Explorer.Repo.Optimism, + Explorer.Repo.Celo + ] } - |> Map.get(chain_type()) - - chain_type_repos = (chain_type_repo && [chain_type_repo]) || [] + |> Map.get(chain_identity(), []) ext_repos = [ {parse_bool_env_var("BRIDGED_TOKENS_ENABLED"), Explorer.Repo.BridgedTokens}, {parse_bool_env_var("MUD_INDEXER_ENABLED"), Explorer.Repo.Mud}, - {parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), Explorer.Repo.ShrunkInternalTransactions} + {parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), Explorer.Repo.ShrunkInternalTransactions}, + {mode() in [:indexer, :api], Explorer.Repo.EventNotifications} ] |> Enum.filter(&elem(&1, 0)) |> Enum.map(&elem(&1, 1)) - base_repos ++ chain_type_repos ++ ext_repos + base_repos ++ chain_identity_repos ++ ext_repos end - @spec hackney_options() :: any() - def hackney_options() do + @doc """ + Returns the list of logger backends to be used by the application. + + If the DISABLE_FILE_LOGGING environment variable is set to true, only base + logger backends (:console and LoggerJSON) are returned. Otherwise, returns + both base and file logger backends. + """ + @spec logger_backends() :: list() + def logger_backends do + base_logger_backends = [ + :console + ] + + if parse_bool_env_var("DISABLE_FILE_LOGGING") do + base_logger_backends + else + file_logger_backends = + [ + {LoggerFileBackend, :error}, + {LoggerFileBackend, :block_scout_web}, + {LoggerFileBackend, :ethereum_jsonrpc}, + {LoggerFileBackend, :explorer}, + {LoggerFileBackend, :indexer}, + {LoggerFileBackend, :indexer_token_balances}, + {LoggerFileBackend, :token_instances}, + {LoggerFileBackend, :reading_token_functions}, + {LoggerFileBackend, :pending_transactions_to_refetch}, + {LoggerFileBackend, :empty_blocks_to_refetch}, + {LoggerFileBackend, :withdrawal}, + {LoggerFileBackend, :api}, + {LoggerFileBackend, :block_import_timings}, + {LoggerFileBackend, :account}, + {LoggerFileBackend, :api_v2} + ] + + base_logger_backends ++ file_logger_backends + end + end + + @doc """ + Returns the list of metadata fields to be included in logger output. + """ + @spec logger_metadata() :: list() + def logger_metadata() do + ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count + block_number step count error_count shrunk import_id transaction_id)a + end + + @doc """ + Returns the list of metadata fields to be included in logger backend output. + """ + @spec logger_backend_metadata() :: list() + def logger_backend_metadata() do + ~w(application fetcher request_id first_block_number last_block_number missing_block_range_count missing_block_count + block_number step count error_count shrunk import_id transaction_id duration status unit endpoint method)a + end + + @spec http_options(non_neg_integer()) :: list() + def http_options(default_timeout \\ 1) do + http_timeout = timeout(default_timeout) basic_auth_user = System.get_env("ETHEREUM_JSONRPC_USER", "") basic_auth_pass = System.get_env("ETHEREUM_JSONRPC_PASSWORD", nil) - [pool: :ethereum_jsonrpc] - |> (&if(System.get_env("ETHEREUM_JSONRPC_HTTP_INSECURE", "") == "true", do: [:insecure] ++ &1, else: &1)).() + [pool: :ethereum_jsonrpc, recv_timeout: http_timeout, timeout: http_timeout] + |> (&if(System.get_env("ETHEREUM_JSONRPC_HTTP_INSECURE", "") == "true", do: [insecure: true] ++ &1, else: &1)).() |> (&if(basic_auth_user != "" && !is_nil(basic_auth_pass), do: [basic_auth: {basic_auth_user, basic_auth_pass}] ++ &1, else: &1 @@ -65,14 +124,25 @@ defmodule ConfigHelper do |> :timer.seconds() end - @spec parse_integer_env_var(String.t(), integer()) :: non_neg_integer() - def parse_integer_env_var(env_var, default_value) do - env_var - |> safe_get_env(to_string(default_value)) - |> Integer.parse() - |> case do - {integer, _} -> integer - _ -> 0 + @spec parse_integer_env_var(String.t(), integer(), keyword()) :: integer() + def parse_integer_env_var(env_var, default_value, opts \\ []) do + raw_value = safe_get_env(env_var, to_string(default_value)) + + result = + case Integer.parse(raw_value) do + {integer, _} -> integer + _ -> raise "#{env_var} must be an integer, got: #{raw_value}" + end + + case Keyword.get(opts, :min) do + nil -> + result + + min when result < min -> + raise "#{env_var} must be >= #{min}, got: #{result}" + + _ -> + result end end @@ -105,12 +175,12 @@ defmodule ConfigHelper do nil value -> - case value |> String.downcase() |> Integer.parse() do - {milliseconds, "ms"} -> milliseconds - {hours, "h"} -> :timer.hours(hours) - {minutes, "m"} -> :timer.minutes(minutes) - {seconds, s} when s in ["s", ""] -> :timer.seconds(seconds) - _ -> raise "Invalid time format in environment variable #{env_var}: #{value}" + case Utils.ConfigHelper.parse_time_value(value) do + :error -> + raise "Invalid time format in environment variable #{env_var}: #{value}" + + time -> + time end end end @@ -119,7 +189,7 @@ defmodule ConfigHelper do Parses value of env var through catalogued values list. If a value is not in the list, nil is returned. Also, the application shutdown option is supported, if a value is wrong. """ - @spec parse_catalog_value(String.t(), List.t(), bool(), String.t() | nil) :: atom() | nil + @spec parse_catalog_value(String.t(), List.t(), boolean(), String.t() | nil) :: atom() | nil def parse_catalog_value(env_var, catalog, shutdown_on_wrong_value?, default_value \\ nil) do value = env_var |> safe_get_env(default_value) @@ -144,20 +214,50 @@ defmodule ConfigHelper do "Invalid value \"#{value}\" of #{env_var} environment variable is provided. Supported values are #{inspect(catalog)}" end - def safe_get_env(env_var, default_value) do - env_var - |> System.get_env(default_value) - |> case do - "" -> default_value - value -> value + @doc """ + Parses value of env var through catalogued values map. If a value is not in + the map, nil is returned. Also, the application shutdown option is supported, + if a value is wrong. + """ + @spec parse_catalog_map_value(String.t(), %{binary() => any()}, boolean(), String.t() | nil) :: any() | nil + def parse_catalog_map_value(env_var, catalog, shutdown_on_wrong_key?, default_key \\ nil) do + key = env_var |> safe_get_env(default_key) + + if key !== "" do + case Map.fetch(catalog, key) do + {:ok, value} -> + value + + :error -> + if shutdown_on_wrong_key? do + Logger.error(wrong_value_error(key, env_var, catalog)) + exit(:shutdown) + else + Logger.warning(wrong_value_error(key, env_var, catalog)) + nil + end + end + else + nil end - |> to_string() end @spec parse_bool_env_var(String.t(), String.t()) :: boolean() def parse_bool_env_var(env_var, default_value \\ "false"), do: String.downcase(safe_get_env(env_var, default_value)) == "true" + @spec parse_path_env_var(String.t(), String.t() | nil) :: String.t() | nil + def parse_path_env_var(env_var, default_value \\ nil) do + env_var + |> System.get_env(default_value) + |> case do + "//" <> _ = path -> raise "Invalid path in environment variable #{env_var}: #{path}" + "/" <> _ = path -> path + path when is_binary(path) -> "/" <> path + other -> other + end + end + @spec cache_ttl_check_interval(boolean()) :: non_neg_integer() | false def cache_ttl_check_interval(disable_indexer?) do if(disable_indexer?, do: :timer.seconds(1), else: false) @@ -187,6 +287,7 @@ defmodule ConfigHelper do | Source.CryptoCompare | Source.CryptoRank | Source.DefiLlama + | Source.DIA | Source.Mobula | nil def market_source(env_var) do @@ -196,6 +297,7 @@ defmodule ConfigHelper do "crypto_compare" => Source.CryptoCompare, "crypto_rank" => Source.CryptoRank, "defillama" => Source.DefiLlama, + "dia" => Source.DIA, "mobula" => Source.Mobula, "" => nil, nil => nil @@ -285,48 +387,131 @@ defmodule ConfigHelper do end end - @spec parse_url_env_var(String.t(), boolean()) :: String.t() | nil + @spec parse_url_env_var(String.t(), String.t() | nil, boolean()) :: String.t() | nil def parse_url_env_var(env_var, default_value \\ nil, trailing_slash_needed? \\ false) do - with url when not is_nil(url) <- safe_get_env(env_var, default_value), + with url when url != "" <- safe_get_env(env_var, default_value), + true <- valid_url?(url), url <- String.trim_trailing(url, "/"), - true <- url != "", {url, true} <- {url, trailing_slash_needed?} do url <> "/" else {url, false} -> url - false -> + _ -> default_value + end + end - nil -> - nil + @spec safe_get_env(String.t(), any()) :: String.t() + def safe_get_env(env_var, default_value) do + env_var + |> System.get_env(default_value) + |> case do + "" -> default_value + value -> value end + |> then(fn + nil -> "" + value -> to_string(value) + end) + end + + @supported_chain_identities %{ + "default" => :default, + "arbitrum" => :arbitrum, + "arc" => :arc, + "blackfort" => :blackfort, + "ethereum" => :ethereum, + "filecoin" => :filecoin, + "optimism" => :optimism, + "rsk" => :rsk, + "scroll" => :scroll, + "shibarium" => :shibarium, + "stability" => :stability, + "suave" => :suave, + "zetachain" => :zetachain, + "zilliqa" => :zilliqa, + "zksync" => :zksync, + "neon" => :neon, + "optimism-celo" => {:optimism, :celo} + } + + @doc """ + Returns the primary chain type. + + This function extracts the base chain type from the chain identity (see + chain_identity/1), which is used to determine the core feature set and + indexing behavior. For example, both `:optimism` and `{:optimism, :celo}` + identities will return `:optimism` as the chain type, allowing them to share + the same Optimism-specific logic. + + ## Examples + + iex> System.put_env("CHAIN_TYPE", "optimism") + iex> chain_type() + :optimism + + iex> System.put_env("CHAIN_TYPE", "optimism-celo") + iex> chain_type() + :optimism + + ## Returns + + * `atom()` - The primary chain type identifier + """ + @spec chain_type() :: atom() + def chain_type do + {type, _} = chain_identity() + type end - @supported_chain_types [ - "default", - "arbitrum", - "blackfort", - "celo", - "ethereum", - "filecoin", - "optimism", - "polygon_edge", - "polygon_zkevm", - "rsk", - "scroll", - "shibarium", - "stability", - "suave", - "zetachain", - "zilliqa", - "zksync", - "neon" - ] - - @spec chain_type() :: atom() | nil - def chain_type, do: parse_catalog_value("CHAIN_TYPE", @supported_chain_types, true, "default") + @doc """ + Returns the full chain identity as a tuple of base type and optional variant. + + Chain identity is a concept that allows networks to inherit a base chain's + feature set while adding variant-specific customizations. The identity is + returned as a two-element tuple `{base_type, variant}` where: + + - `base_type` - The primary chain type (e.g., `:optimism`, `:arbitrum`) + - `variant` - An optional sub-type for specialized networks (e.g., `:celo`) + + This enables Celo to run on the OP Stack with the identity `{:optimism, + :celo}`, inheriting all Optimism features (deposits, withdrawals, batches, + etc.) while allowing Celo-specific endpoints, caches, and schema fields to be + gated on the full identity tuple. + + The identity is configured via the `CHAIN_TYPE` environment variable and must + match one of the supported values in `@supported_chain_identities`. + + ## Examples + + iex> System.put_env("CHAIN_TYPE", "optimism") + iex> chain_identity() + {:optimism, nil} + + iex> System.put_env("CHAIN_TYPE", "optimism-celo") + iex> chain_identity() + {:optimism, :celo} + + iex> System.put_env("CHAIN_TYPE", "ethereum") + iex> chain_identity() + {:ethereum, nil} + + ## Returns + + * `{atom(), atom() | nil}` - A tuple containing the base chain type and + optional variant + """ + @spec chain_identity() :: {atom(), atom() | nil} + def chain_identity do + "CHAIN_TYPE" + |> parse_catalog_map_value(@supported_chain_identities, true, "default") + |> case do + type when is_atom(type) -> {type, nil} + identity -> identity + end + end @supported_modes ["all", "indexer", "api"] @@ -363,36 +548,9 @@ defmodule ConfigHelper do end end - @doc """ - Parses and validates a microservice URL from an environment variable, removing any trailing slash. - - ## Parameters - - `env_name`: The name of the environment variable containing the URL - - ## Returns - - The validated URL string with any trailing slash removed - - `nil` if the URL is invalid or missing required components - """ - @spec parse_microservice_url(String.t()) :: String.t() | nil - def parse_microservice_url(env_name) do - url = System.get_env(env_name) - - cond do - not valid_url?(url) -> - nil - - String.ends_with?(url, "/") -> - url - |> String.slice(0..(String.length(url) - 2)) - - true -> - url - end - end - # Validates if the given string is a valid URL by checking if it has both scheme (like http, # https, ftp) and host components. - @spec valid_url?(String.t()) :: boolean() + @spec valid_url?(term()) :: boolean() defp valid_url?(string) when is_binary(string) do uri = URI.parse(string) diff --git a/config/dev.exs b/config/dev.exs index 27154824a939..3dac7ba9a257 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,17 +1,10 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config -# DO NOT make it `:debug` or all Ecto logs will be shown for indexer -config :logger, :console, level: :info +config :logger, level: :info -config :logger_json, :backend, level: :none - -config :logger, :ecto, - level: :debug, - path: Path.absname("logs/dev/ecto.log") - -config :logger, :error, path: Path.absname("logs/dev/error.log") +config :logger, :error, path: Path.absname("logs/dev/error.log"), level: :error config :logger, :account, - level: :debug, path: Path.absname("logs/dev/account.log"), metadata_filter: [fetcher: :account] diff --git a/config/prod.exs b/config/prod.exs index 2e8c9db24d97..2c6d29f911d1 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,22 +1,15 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # Do not print debug messages in production - -config :logger, :console, level: :none - -config :logger_json, :backend, level: :info - -config :logger, :ecto, - level: :info, - path: Path.absname("logs/prod/ecto.log"), - rotate: %{max_bytes: 52_428_800, keep: 19} +config :logger, level: :info config :logger, :error, + level: :error, path: Path.absname("logs/prod/error.log"), rotate: %{max_bytes: 52_428_800, keep: 19} config :logger, :account, - level: :info, path: Path.absname("logs/prod/account.log"), rotate: %{max_bytes: 52_428_800, keep: 19}, metadata_filter: [fetcher: :account] diff --git a/config/runtime.exs b/config/runtime.exs index 9accfedb908f..518febffc1ea 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1,9 +1,37 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config [__DIR__ | ~w(config_helper.exs)] |> Path.join() |> Code.eval_file() +config :logger, + backends: ConfigHelper.logger_backends() + +config :logger, :default_handler, + formatter: + (if config_env() == :prod do + LoggerJSON.Formatters.Basic.new(metadata: ConfigHelper.logger_backend_metadata()) + else + Logger.Formatter.new( + format: "$dateT$time $metadata[$level] $message\n", + metadata: ConfigHelper.logger_backend_metadata() + ) + end) + +config :logger, :api, + format: "$dateT$time $metadata[$level] $message\n", + metadata: ConfigHelper.logger_metadata(), + metadata_filter: [application: :api] + +config :logger, :api_v2, + format: "$dateT$time $metadata[$level] $message\n", + metadata: ConfigHelper.logger_metadata(), + metadata_filter: [application: :api_v2] + +microservice_multichain_search_url = ConfigHelper.parse_url_env_var("MICROSERVICE_MULTICHAIN_SEARCH_URL") +transactions_stats_enabled = ConfigHelper.parse_bool_env_var("TXS_STATS_ENABLED", "true") + ###################### ### BlockScout Web ### ###################### @@ -16,8 +44,8 @@ config :block_scout_web, show_percentage: ConfigHelper.parse_bool_env_var("SHOW_ADDRESS_MARKETCAP_PERCENTAGE", "true"), checksum_address_hashes: ConfigHelper.parse_bool_env_var("CHECKSUM_ADDRESS_HASHES", "true"), other_networks: System.get_env("SUPPORTED_CHAINS"), - webapp_url: System.get_env("WEBAPP_URL"), - api_url: System.get_env("API_URL"), + webapp_url: ConfigHelper.parse_url_env_var("WEBAPP_URL"), + api_url: ConfigHelper.parse_url_env_var("API_URL"), apps_menu: ConfigHelper.parse_bool_env_var("APPS_MENU"), apps: System.get_env("APPS") || System.get_env("EXTERNAL_APPS"), gas_price: System.get_env("GAS_PRICE"), @@ -92,6 +120,10 @@ config :block_scout_web, BlockScoutWeb.Chain, enable_testnet_label: ConfigHelper.parse_bool_env_var("SHOW_TESTNET_LABEL"), testnet_label_text: System.get_env("TESTNET_LABEL_TEXT", "Testnet") +config :block_scout_web, BlockScoutWeb.Notifier, + block_broadcast_enrichment_timeout: 200, + block_broadcast_enrichment_disabled: ConfigHelper.parse_bool_env_var("DISABLE_BLOCK_BROADCAST_ENRICHMENT") + config :block_scout_web, :footer, logo: System.get_env("FOOTER_LOGO"), chat_link: System.get_env("FOOTER_CHAT_LINK", "https://discord.gg/blockscout"), @@ -110,25 +142,54 @@ config :block_scout_web, :contract, certified_list: ConfigHelper.parse_list_env_var("CONTRACT_CERTIFIED_LIST", ""), partial_reverification_disabled: !ConfigHelper.parse_bool_env_var("CONTRACT_ENABLE_PARTIAL_REVERIFICATION") -default_global_api_rate_limit = 50 +default_global_api_rate_limit = 25 default_api_rate_limit_by_key = 10 +api_rate_limit_redis_url = ConfigHelper.safe_get_env("API_RATE_LIMIT_HAMMER_REDIS_URL", nil) + +api_rate_limit_redis_sentinel_urls = + ConfigHelper.safe_get_env("API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_URLS", "") config :block_scout_web, :api_rate_limit, disabled: ConfigHelper.parse_bool_env_var("API_RATE_LIMIT_DISABLED"), - global_limit: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT", default_global_api_rate_limit), - limit_by_key: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_BY_KEY", default_api_rate_limit_by_key), - limit_by_whitelisted_ip: - ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_BY_WHITELISTED_IP", default_global_api_rate_limit), - time_interval_limit: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_TIME_INTERVAL", "1s"), - limit_by_ip: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_BY_IP", 3000), - time_interval_limit_by_ip: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_BY_IP_TIME_INTERVAL", "5m"), - static_api_key: System.get_env("API_RATE_LIMIT_STATIC_API_KEY"), - no_rate_limit_api_key: System.get_env("API_NO_RATE_LIMIT_API_KEY"), + static_api_key_value: System.get_env("API_RATE_LIMIT_STATIC_API_KEY"), + static_api_key: %{ + limit: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_BY_KEY", default_api_rate_limit_by_key), + period: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_BY_KEY_TIME_INTERVAL", "1s") + }, + whitelisted_ip: %{ + limit: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_BY_WHITELISTED_IP", default_global_api_rate_limit), + period: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_BY_WHITELISTED_IP_TIME_INTERVAL", "1s") + }, + ip: %{ + limit: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_BY_IP", 300), + period: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_BY_IP_TIME_INTERVAL", "1m") + }, + temporary_token: %{ + limit: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_UI_V2_WITH_TOKEN", 5), + period: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_UI_V2_WITH_TOKEN_TIME_INTERVAL", "1s") + }, + account_api_key: %{ + period: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_BY_ACCOUNT_API_KEY_TIME_INTERVAL", "1s") + }, + no_rate_limit_api_key_value: System.get_env("API_NO_RATE_LIMIT_API_KEY"), whitelisted_ips: System.get_env("API_RATE_LIMIT_WHITELISTED_IPS"), + api_v2_token_ttl: ConfigHelper.parse_time_env_var("API_RATE_LIMIT_UI_V2_TOKEN_TTL", "30m"), + eth_json_rpc_max_batch_size: ConfigHelper.parse_integer_env_var("ETH_JSON_RPC_MAX_BATCH_SIZE", 5), + redis_url: if(api_rate_limit_redis_url == "", do: nil, else: api_rate_limit_redis_url), + redis_ssl: ConfigHelper.parse_bool_env_var("API_RATE_LIMIT_HAMMER_REDIS_SSL_ENABLED", "false"), + redis_sentinel_urls: if(api_rate_limit_redis_sentinel_urls == "", do: nil, else: api_rate_limit_redis_sentinel_urls), + redis_sentinel_master_name: ConfigHelper.safe_get_env("API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_MASTER_NAME", ""), + rate_limit_backend: + if(api_rate_limit_redis_url == "" and api_rate_limit_redis_sentinel_urls == "", + do: BlockScoutWeb.RateLimit.Hammer.ETS, + else: BlockScoutWeb.RateLimit.Hammer.Redis + ), + config_url: ConfigHelper.parse_url_env_var("API_RATE_LIMIT_CONFIG_URL") + +config :block_scout_web, :remote_ip, is_blockscout_behind_proxy: ConfigHelper.parse_bool_env_var("API_RATE_LIMIT_IS_BLOCKSCOUT_BEHIND_PROXY"), - api_v2_ui_limit: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_UI_V2_WITH_TOKEN", 5), - api_v2_token_ttl_seconds: ConfigHelper.parse_integer_env_var("API_RATE_LIMIT_UI_V2_TOKEN_TTL_IN_SECONDS", 18000), - eth_json_rpc_max_batch_size: ConfigHelper.parse_integer_env_var("ETH_JSON_RPC_MAX_BATCH_SIZE", 5) + headers: ConfigHelper.parse_list_env_var("API_RATE_LIMIT_REMOTE_IP_HEADERS", "x-forwarded-for"), + proxies: ConfigHelper.parse_list_env_var("API_RATE_LIMIT_REMOTE_IP_KNOWN_PROXIES", "") default_graphql_rate_limit = 10 @@ -144,7 +205,7 @@ config :block_scout_web, Api.GraphQL, limit_by_key: ConfigHelper.parse_integer_env_var("API_GRAPHQL_RATE_LIMIT_BY_KEY", default_graphql_rate_limit), time_interval_limit: ConfigHelper.parse_time_env_var("API_GRAPHQL_RATE_LIMIT_TIME_INTERVAL", "1s"), limit_by_ip: ConfigHelper.parse_integer_env_var("API_GRAPHQL_RATE_LIMIT_BY_IP", 500), - time_interval_limit_by_ip: ConfigHelper.parse_time_env_var("API_GRAPHQL_RATE_LIMIT_BY_IP_TIME_INTERVAL", "5m"), + time_interval_limit_by_ip: ConfigHelper.parse_time_env_var("API_GRAPHQL_RATE_LIMIT_BY_IP_TIME_INTERVAL", "1h"), static_api_key: System.get_env("API_GRAPHQL_RATE_LIMIT_STATIC_API_KEY") # Configures History @@ -175,14 +236,15 @@ config :block_scout_web, BlockScoutWeb.Chain.Address.CoinBalance, config :block_scout_web, BlockScoutWeb.API.V2, enabled: ConfigHelper.parse_bool_env_var("API_V2_ENABLED", "true") config :block_scout_web, BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation, - service_url: System.get_env("MICROSERVICE_TRANSACTION_INTERPRETATION_URL"), + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_TRANSACTION_INTERPRETATION_URL"), enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_TRANSACTION_INTERPRETATION_ENABLED") # Configures Ueberauth's Auth0 auth provider config :ueberauth, Ueberauth.Strategy.Auth0.OAuth, domain: System.get_env("ACCOUNT_AUTH0_DOMAIN"), client_id: System.get_env("ACCOUNT_AUTH0_CLIENT_ID"), - client_secret: System.get_env("ACCOUNT_AUTH0_CLIENT_SECRET") + client_secret: System.get_env("ACCOUNT_AUTH0_CLIENT_SECRET"), + auth0_application_id: ConfigHelper.safe_get_env("ACCOUNT_AUTH0_APPLICATION_ID", nil) |> String.replace(".", "") # Configures Ueberauth local settings config :ueberauth, Ueberauth, logout_url: "https://#{System.get_env("ACCOUNT_AUTH0_DOMAIN")}/v2/logout" @@ -200,24 +262,23 @@ config :ethereum_jsonrpc, ipc_path: System.get_env("IPC_PATH"), disable_archive_balances?: trace_url_missing? or ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_DISABLE_ARCHIVE_BALANCES"), - archive_balances_window: ConfigHelper.parse_integer_env_var("ETHEREUM_JSONRPC_ARCHIVE_BALANCES_WINDOW", 200) + archive_balances_window: ConfigHelper.parse_integer_env_var("ETHEREUM_JSONRPC_ARCHIVE_BALANCES_WINDOW", 200), + receipts_by_block?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_RECEIPTS_BY_BLOCK", "false"), + max_receipts_by_block: ConfigHelper.parse_integer_env_var("ETHEREUM_JSONRPC_MAX_RECEIPTS_BY_BLOCK", 1000) config :ethereum_jsonrpc, EthereumJSONRPC.HTTP, headers: %{"Content-Type" => "application/json"} |> Map.merge(ConfigHelper.parse_json_env_var("ETHEREUM_JSONRPC_HTTP_HEADERS", "{}")) |> Map.to_list(), - gzip_enabled?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_HTTP_GZIP_ENABLED", "false") + gzip_enabled?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_HTTP_GZIP_ENABLED", "false"), + batch_size: ConfigHelper.parse_integer_env_var("ETHEREUM_JSONRPC_HTTP_BATCH_SIZE", 500) config :ethereum_jsonrpc, EthereumJSONRPC.Geth, block_traceable?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_GETH_TRACE_BY_BLOCK"), allow_empty_traces?: ConfigHelper.parse_bool_env_var("ETHEREUM_JSONRPC_GETH_ALLOW_EMPTY_TRACES"), debug_trace_timeout: System.get_env("ETHEREUM_JSONRPC_DEBUG_TRACE_TRANSACTION_TIMEOUT", "5s"), - tracer: - if(ConfigHelper.chain_type() == :polygon_edge, - do: "polygon_edge", - else: System.get_env("INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE", "call_tracer") - ) + tracer: System.get_env("INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE", "call_tracer") config :ethereum_jsonrpc, EthereumJSONRPC.PendingTransaction, type: System.get_env("ETHEREUM_JSONRPC_PENDING_TRANSACTIONS_TYPE", "default") @@ -234,9 +295,8 @@ config :ethereum_jsonrpc, EthereumJSONRPC.Utility.EndpointAvailabilityChecker, e ### Explorer ### ################ -disable_indexer? = ConfigHelper.parse_bool_env_var("DISABLE_INDEXER") -disable_webapp? = ConfigHelper.parse_bool_env_var("DISABLE_WEBAPP") app_mode = ConfigHelper.mode() +disable_indexer? = app_mode == :api || ConfigHelper.parse_bool_env_var("DISABLE_INDEXER") disable_exchange_rates? = if System.get_env("DISABLE_MARKET"), @@ -249,14 +309,17 @@ config :explorer, mode: app_mode, ecto_repos: ConfigHelper.repos(), chain_type: ConfigHelper.chain_type(), + chain_identity: ConfigHelper.chain_identity(), coin: coin, coin_name: System.get_env("COIN_NAME") || "ETH", + api_disable_contract_creation_internal_transaction_association: + ConfigHelper.parse_bool_env_var("API_DISABLE_CONTRACT_CREATION_INTERNAL_TRANSACTION_ASSOCIATION"), allowed_solidity_evm_versions: System.get_env("CONTRACT_VERIFICATION_ALLOWED_SOLIDITY_EVM_VERSIONS") || - "homestead,tangerineWhistle,spuriousDragon,byzantium,constantinople,petersburg,istanbul,berlin,london,paris,shanghai,cancun,default", + "homestead,tangerineWhistle,spuriousDragon,byzantium,constantinople,petersburg,istanbul,berlin,london,paris,shanghai,cancun,prague,osaka,default", allowed_vyper_evm_versions: System.get_env("CONTRACT_VERIFICATION_ALLOWED_VYPER_EVM_VERSIONS") || - "byzantium,constantinople,petersburg,istanbul,berlin,paris,shanghai,cancun,default", + "byzantium,constantinople,petersburg,istanbul,berlin,paris,shanghai,cancun,osaka,default", include_uncles_in_average_block_time: ConfigHelper.parse_bool_env_var("UNCLES_IN_AVERAGE_BLOCK_TIME"), realtime_events_sender: (case app_mode do @@ -265,11 +328,15 @@ config :explorer, end), addresses_blacklist: System.get_env("ADDRESSES_BLACKLIST"), addresses_blacklist_key: System.get_env("ADDRESSES_BLACKLIST_KEY"), + token_balances_import_chunk_size: + ConfigHelper.parse_integer_env_var("INDEXER_CURRENT_TOKEN_BALANCES_IMPORT_CHUNK_SIZE", 50), elasticity_multiplier: ConfigHelper.parse_integer_env_var("EIP_1559_ELASTICITY_MULTIPLIER", 2), base_fee_max_change_denominator: ConfigHelper.parse_integer_env_var("EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR", 8), + base_fee_lower_bound: ConfigHelper.parse_integer_env_var("EIP_1559_BASE_FEE_LOWER_BOUND_WEI", 0), csv_export_limit: ConfigHelper.parse_integer_env_var("CSV_EXPORT_LIMIT", 10_000), shrink_internal_transactions_enabled: ConfigHelper.parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), - replica_max_lag: ConfigHelper.parse_time_env_var("REPLICA_MAX_LAG", "5m") + replica_max_lag: ConfigHelper.parse_time_env_var("REPLICA_MAX_LAG", "5m"), + hackney_default_pool_size: ConfigHelper.parse_integer_env_var("HACKNEY_DEFAULT_POOL_SIZE", 1_000) config :explorer, Explorer.Chain.Health.Monitor, check_interval: ConfigHelper.parse_time_env_var("HEALTH_MONITOR_CHECK_INTERVAL", "1m"), @@ -324,11 +391,14 @@ config :explorer, Explorer.Chain.Cache.Counters.TransactionsCount, global_ttl: ConfigHelper.parse_time_env_var("CACHE_TXS_COUNT_PERIOD", "2h") config :explorer, Explorer.Chain.Cache.Counters.PendingBlockOperationCount, - global_ttl: ConfigHelper.parse_time_env_var("CACHE_PBO_COUNT_PERIOD", "20m") + global_ttl: ConfigHelper.parse_time_env_var("CACHE_PENDING_OPERATIONS_COUNT_PERIOD", "5m") + +config :explorer, Explorer.Chain.Cache.Counters.PendingTransactionOperationCount, + global_ttl: ConfigHelper.parse_time_env_var("CACHE_PENDING_OPERATIONS_COUNT_PERIOD", "5m") config :explorer, Explorer.Chain.Cache.GasPriceOracle, global_ttl: ConfigHelper.parse_time_env_var("GAS_PRICE_ORACLE_CACHE_PERIOD", "30s"), - simple_transaction_gas: ConfigHelper.parse_integer_env_var("GAS_PRICE_ORACLE_SIMPLE_TRANSACTION_GAS", 21000), + simple_transaction_gas: ConfigHelper.parse_integer_env_var("GAS_PRICE_ORACLE_SIMPLE_TRANSACTION_GAS", 21_000), num_of_blocks: ConfigHelper.parse_integer_env_var("GAS_PRICE_ORACLE_NUM_OF_BLOCKS", 200), safelow_percentile: ConfigHelper.parse_integer_env_var("GAS_PRICE_ORACLE_SAFELOW_PERCENTILE", 35), average_percentile: ConfigHelper.parse_integer_env_var("GAS_PRICE_ORACLE_AVERAGE_PERCENTILE", 60), @@ -356,10 +426,17 @@ config :explorer, Explorer.Chain.Cache.Counters.TokenTransfersCount, config :explorer, Explorer.Chain.Cache.Counters.AverageBlockTime, enabled: true, period: :timer.minutes(10), - cache_period: ConfigHelper.parse_time_env_var("CACHE_AVERAGE_BLOCK_PERIOD", "30m") + cache_period: ConfigHelper.parse_time_env_var("CACHE_AVERAGE_BLOCK_PERIOD", "30m"), + num_of_blocks: ConfigHelper.parse_integer_env_var("CACHE_AVERAGE_BLOCK_TIME_WINDOW", 100) config :explorer, Explorer.Market.MarketHistoryCache, - cache_period: ConfigHelper.parse_time_env_var("CACHE_MARKET_HISTORY_PERIOD", "6h") + cache_period: ConfigHelper.parse_time_env_var("CACHE_MARKET_HISTORY_PERIOD", "1h") + +config :explorer, Explorer.Stats.HotSmartContractsCache, %{ + "5m" => ConfigHelper.parse_time_env_var("CACHE_HOT_SMART_CONTRACTS_5M_PERIOD", "30s"), + "1h" => ConfigHelper.parse_time_env_var("CACHE_HOT_SMART_CONTRACTS_1H_PERIOD", "6m"), + "3h" => ConfigHelper.parse_time_env_var("CACHE_HOT_SMART_CONTRACTS_3H_PERIOD", "18m") +} config :explorer, Explorer.Chain.Cache.Counters.AddressTransactionsCount, cache_period: ConfigHelper.parse_time_env_var("CACHE_ADDRESS_TRANSACTIONS_COUNTER_PERIOD", "1h") @@ -385,6 +462,8 @@ config :explorer, Explorer.Chain.Cache.Counters.NewPendingTransactionsCount, cache_period: ConfigHelper.parse_time_env_var("CACHE_FRESH_PENDING_TRANSACTIONS_COUNTER_PERIOD", "5m"), enable_consolidation: true +config :explorer, Explorer.Market, enabled: !disable_exchange_rates? + config :explorer, Explorer.Market.Source, native_coin_source: ConfigHelper.market_source("MARKET_NATIVE_COIN_SOURCE") || ConfigHelper.market_source("EXCHANGE_RATES_SOURCE"), @@ -406,11 +485,11 @@ config :explorer, Explorer.Market.Source, config :explorer, Explorer.Market.Source.CoinGecko, platform: System.get_env("MARKET_COINGECKO_PLATFORM_ID") || System.get_env("EXCHANGE_RATES_COINGECKO_PLATFORM_ID"), base_url: - System.get_env("MARKET_COINGECKO_BASE_URL") || - System.get_env("EXCHANGE_RATES_COINGECKO_BASE_URL", "https://api.coingecko.com/api/v3"), + ConfigHelper.parse_url_env_var("MARKET_COINGECKO_BASE_URL") || + ConfigHelper.parse_url_env_var("EXCHANGE_RATES_COINGECKO_BASE_URL", "https://api.coingecko.com/api/v3"), base_pro_url: - System.get_env("MARKET_COINGECKO_BASE_PRO_URL") || - System.get_env("EXCHANGE_RATES_COINGECKO_BASE_PRO_URL", "https://pro-api.coingecko.com/api/v3"), + ConfigHelper.parse_url_env_var("MARKET_COINGECKO_BASE_PRO_URL") || + ConfigHelper.parse_url_env_var("EXCHANGE_RATES_COINGECKO_BASE_PRO_URL", "https://pro-api.coingecko.com/api/v3"), api_key: System.get_env("MARKET_COINGECKO_API_KEY") || System.get_env("EXCHANGE_RATES_COINGECKO_API_KEY"), coin_id: System.get_env("MARKET_COINGECKO_COIN_ID") || System.get_env("EXCHANGE_RATES_COINGECKO_COIN_ID"), secondary_coin_id: @@ -419,8 +498,8 @@ config :explorer, Explorer.Market.Source.CoinGecko, config :explorer, Explorer.Market.Source.CoinMarketCap, base_url: - System.get_env("MARKET_COINMARKETCAP_BASE_URL") || - System.get_env("EXCHANGE_RATES_COINMARKETCAP_BASE_URL", "https://pro-api.coinmarketcap.com/v2"), + ConfigHelper.parse_url_env_var("MARKET_COINMARKETCAP_BASE_URL") || + ConfigHelper.parse_url_env_var("EXCHANGE_RATES_COINMARKETCAP_BASE_URL", "https://pro-api.coinmarketcap.com/v2"), api_key: System.get_env("MARKET_COINMARKETCAP_API_KEY") || System.get_env("EXCHANGE_RATES_COINMARKETCAP_API_KEY"), coin_id: System.get_env("MARKET_COINMARKETCAP_COIN_ID") || System.get_env("EXCHANGE_RATES_COINMARKETCAP_COIN_ID"), secondary_coin_id: @@ -429,7 +508,7 @@ config :explorer, Explorer.Market.Source.CoinMarketCap, currency_id: "2781" config :explorer, Explorer.Market.Source.CryptoCompare, - base_url: System.get_env("MARKET_CRYPTOCOMPARE_BASE_URL", "https://min-api.cryptocompare.com"), + base_url: ConfigHelper.parse_url_env_var("MARKET_CRYPTOCOMPARE_BASE_URL", "https://min-api.cryptocompare.com"), coin_symbol: System.get_env("MARKET_CRYPTOCOMPARE_COIN_SYMBOL", coin), secondary_coin_symbol: System.get_env("MARKET_CRYPTOCOMPARE_SECONDARY_COIN_SYMBOL") || @@ -441,15 +520,16 @@ config :explorer, Explorer.Market.Source.CryptoRank, ConfigHelper.parse_integer_or_nil_env_var("MARKET_CRYPTORANK_PLATFORM_ID") || ConfigHelper.parse_integer_or_nil_env_var("EXCHANGE_RATES_CRYPTORANK_PLATFORM_ID"), base_url: - System.get_env("MARKET_CRYPTORANK_BASE_URL") || - System.get_env("EXCHANGE_RATES_CRYPTORANK_BASE_URL", "https://api.cryptorank.io/v1/"), + ConfigHelper.parse_url_env_var("MARKET_CRYPTORANK_BASE_URL") || + ConfigHelper.parse_url_env_var("EXCHANGE_RATES_CRYPTORANK_BASE_URL", "https://api.cryptorank.io/v1"), api_key: System.get_env("MARKET_CRYPTORANK_API_KEY") || System.get_env("EXCHANGE_RATES_CRYPTORANK_API_KEY"), coin_id: System.get_env("MARKET_CRYPTORANK_COIN_ID") || ConfigHelper.parse_integer_or_nil_env_var("EXCHANGE_RATES_CRYPTORANK_COIN_ID"), secondary_coin_id: System.get_env("MARKET_CRYPTORANK_SECONDARY_COIN_ID") || - ConfigHelper.parse_integer_or_nil_env_var("EXCHANGE_RATES_CRYPTORANK_SECONDARY_COIN_ID") + ConfigHelper.parse_integer_or_nil_env_var("EXCHANGE_RATES_CRYPTORANK_SECONDARY_COIN_ID"), + currency: "USD" config :explorer, Explorer.Market.Source.DefiLlama, coin_id: System.get_env("MARKET_DEFILLAMA_COIN_ID"), @@ -458,26 +538,30 @@ config :explorer, Explorer.Market.Source.DefiLlama, config :explorer, Explorer.Market.Source.Mobula, platform: System.get_env("MARKET_MOBULA_PLATFORM_ID") || System.get_env("EXCHANGE_RATES_MOBULA_CHAIN_ID"), base_url: - System.get_env("MARKET_MOBULA_BASE_URL") || - System.get_env("EXCHANGE_RATES_MOBULA_BASE_URL", "https://api.mobula.io/api/1"), + ConfigHelper.parse_url_env_var("MARKET_MOBULA_BASE_URL") || + ConfigHelper.parse_url_env_var("EXCHANGE_RATES_MOBULA_BASE_URL", "https://api.mobula.io/api/1"), api_key: System.get_env("MARKET_MOBULA_API_KEY") || System.get_env("EXCHANGE_RATES_MOBULA_API_KEY"), coin_id: System.get_env("MARKET_MOBULA_COIN_ID") || System.get_env("EXCHANGE_RATES_MOBULA_COIN_ID"), secondary_coin_id: System.get_env("MARKET_MOBULA_SECONDARY_COIN_ID") || System.get_env("EXCHANGE_RATES_MOBULA_SECONDARY_COIN_ID") +config :explorer, Explorer.Market.Source.DIA, + blockchain: System.get_env("MARKET_DIA_BLOCKCHAIN"), + base_url: ConfigHelper.parse_url_env_var("MARKET_DIA_BASE_URL", "https://api.diadata.org/v1"), + coin_address_hash: System.get_env("MARKET_DIA_COIN_ADDRESS_HASH"), + secondary_coin_address_hash: System.get_env("MARKET_DIA_SECONDARY_COIN_ADDRESS_HASH") + config :explorer, Explorer.Market.Fetcher.Coin, store: :ets, enabled: !disable_exchange_rates? && ConfigHelper.parse_bool_env_var("MARKET_COIN_FETCHER_ENABLED", "true"), enable_consolidation: true, cache_period: ConfigHelper.parse_time_env_var("MARKET_COIN_CACHE_PERIOD", "10m") +disable_token_exchange_rates? = ConfigHelper.parse_bool_env_var("DISABLE_TOKEN_EXCHANGE_RATE") +market_tokens_fetcher_enabled? = ConfigHelper.parse_bool_env_var("MARKET_TOKENS_FETCHER_ENABLED", "true") + config :explorer, Explorer.Market.Fetcher.Token, - enabled: - !disable_exchange_rates? && - ConfigHelper.parse_bool_env_var( - "MARKET_TOKENS_FETCHER_ENABLED", - ConfigHelper.safe_get_env("DISABLE_TOKEN_EXCHANGE_RATE", "true") - ), + enabled: !disable_exchange_rates? && !disable_token_exchange_rates? && market_tokens_fetcher_enabled?, interval: ConfigHelper.parse_time_env_var( "MARKET_TOKENS_INTERVAL", @@ -494,6 +578,13 @@ config :explorer, Explorer.Market.Fetcher.Token, ConfigHelper.parse_integer_env_var("TOKEN_EXCHANGE_RATE_MAX_BATCH_SIZE", 500) ) +token_list_url = ConfigHelper.parse_url_env_var("TOKEN_LIST_URL") + +config :explorer, Explorer.Market.Fetcher.TokenList, + enabled: !is_nil(token_list_url), + token_list_url: token_list_url, + refetch_interval: ConfigHelper.parse_time_env_var("TOKEN_LIST_REFETCH_INTERVAL", "24h") + config :explorer, Explorer.Market.Fetcher.History, enabled: !disable_exchange_rates? && ConfigHelper.parse_bool_env_var("MARKET_HISTORY_FETCHER_ENABLED", "true"), history_fetch_interval: ConfigHelper.parse_time_env_var("MARKET_HISTORY_FETCH_INTERVAL", "1h"), @@ -503,10 +594,19 @@ config :explorer, Explorer.Market.Fetcher.History, ConfigHelper.parse_integer_env_var("EXCHANGE_RATES_HISTORY_FIRST_FETCH_DAY_COUNT", 365) ) -config :explorer, Explorer.Chain.Transaction, suave_bid_contracts: System.get_env("SUAVE_BID_CONTRACTS", "") +config :explorer, Explorer.Chain.PendingOperationsHelper, + transactions_batch_size: + ConfigHelper.parse_integer_env_var("PENDING_OPERATIONS_HELPER_TRANSACTIONS_BATCH_SIZE", 1000, min: 1), + blocks_batch_size: ConfigHelper.parse_integer_env_var("PENDING_OPERATIONS_HELPER_BLOCKS_BATCH_SIZE", 10, min: 1) + +config :explorer, Explorer.Chain.Transaction, + block_miner_gets_burnt_fees?: ConfigHelper.parse_bool_env_var("BLOCK_MINER_GETS_BURNT_FEES"), + suave_bid_contracts: System.get_env("SUAVE_BID_CONTRACTS", ""), + rootstock_remasc_address: System.get_env("ROOTSTOCK_REMASC_ADDRESS"), + rootstock_bridge_address: System.get_env("ROOTSTOCK_BRIDGE_ADDRESS") config :explorer, Explorer.Chain.Transaction.History.Historian, - enabled: ConfigHelper.parse_bool_env_var("TXS_STATS_ENABLED", "true"), + enabled: transactions_stats_enabled, init_lag_milliseconds: ConfigHelper.parse_time_env_var("TXS_HISTORIAN_INIT_LAG", "0"), days_to_compile_at_init: ConfigHelper.parse_integer_env_var("TXS_STATS_DAYS_TO_COMPILE_AT_INIT", 40) @@ -537,39 +637,40 @@ config :explorer, Explorer.Chain.Cache.BlockNumber, global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) config :explorer, Explorer.Chain.Cache.Blocks, - ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), - global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) + ttl_check_interval: false, + global_ttl: nil config :explorer, Explorer.Chain.Cache.Transactions, - ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), - global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) - -config :explorer, Explorer.Chain.Cache.TransactionsApiV2, - ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), - global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) + ttl_check_interval: false, + global_ttl: nil config :explorer, Explorer.Chain.Cache.Accounts, ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) config :explorer, Explorer.Chain.Cache.Uncles, - ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), - global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) + ttl_check_interval: false, + global_ttl: nil -config :explorer, Explorer.Chain.Cache.Uncles, - ttl_check_interval: ConfigHelper.cache_ttl_check_interval(disable_indexer?), - global_ttl: ConfigHelper.cache_global_ttl(disable_indexer?) +celo_l2_migration_block = ConfigHelper.parse_integer_or_nil_env_var("CELO_L2_MIGRATION_BLOCK") +celo_epoch_manager_contract_address = System.get_env("CELO_EPOCH_MANAGER_CONTRACT") -config :explorer, :celo, l2_migration_block: ConfigHelper.parse_integer_or_nil_env_var("CELO_L2_MIGRATION_BLOCK") +config :explorer, :celo, + l2_migration_block: celo_l2_migration_block, + epoch_manager_contract_address: celo_epoch_manager_contract_address, + unreleased_treasury_contract_address: System.get_env("CELO_UNRELEASED_TREASURY_CONTRACT"), + validators_contract_address: System.get_env("CELO_VALIDATORS_CONTRACT"), + locked_gold_contract_address: System.get_env("CELO_LOCKED_GOLD_CONTRACT"), + accounts_contract_address: System.get_env("CELO_ACCOUNTS_CONTRACT") config :explorer, Explorer.Chain.Cache.CeloCoreContracts, contracts: ConfigHelper.parse_json_env_var("CELO_CORE_CONTRACTS") config :explorer, Explorer.ThirdPartyIntegrations.Sourcify, - server_url: System.get_env("SOURCIFY_SERVER_URL") || "https://sourcify.dev/server", + server_url: ConfigHelper.parse_url_env_var("SOURCIFY_SERVER_URL", "https://sourcify.dev/server"), enabled: ConfigHelper.parse_bool_env_var("SOURCIFY_INTEGRATION_ENABLED"), chain_id: System.get_env("CHAIN_ID"), - repo_url: System.get_env("SOURCIFY_REPO_URL") || "https://repo.sourcify.dev/contracts" + repo_url: ConfigHelper.parse_url_env_var("SOURCIFY_REPO_URL", "https://repo.sourcify.dev/contracts") config :explorer, Explorer.ThirdPartyIntegrations.SolidityScan, platform_id: System.get_env("SOLIDITYSCAN_PLATFORM_ID", "16"), @@ -577,61 +678,85 @@ config :explorer, Explorer.ThirdPartyIntegrations.SolidityScan, api_key: System.get_env("SOLIDITYSCAN_API_TOKEN") config :explorer, Explorer.ThirdPartyIntegrations.NovesFi, - service_url: System.get_env("NOVES_FI_BASE_API_URL") || "https://blockscout.noves.fi", + service_url: ConfigHelper.parse_url_env_var("NOVES_FI_BASE_API_URL", "https://blockscout.noves.fi"), chain_name: System.get_env("NOVES_FI_CHAIN_NAME"), api_key: System.get_env("NOVES_FI_API_TOKEN") config :explorer, Explorer.ThirdPartyIntegrations.Xname, - service_url: System.get_env("XNAME_BASE_API_URL", "https://gateway.xname.app"), + service_url: ConfigHelper.parse_url_env_var("XNAME_BASE_API_URL", "https://gateway.xname.app"), api_key: System.get_env("XNAME_API_TOKEN") +dynamic_env_id = System.get_env("ACCOUNT_DYNAMIC_ENV_ID") + +config :explorer, Explorer.ThirdPartyIntegrations.Dynamic, + enabled: !is_nil(dynamic_env_id), + env_id: dynamic_env_id, + url: "https://app.dynamic.xyz/api/v0/sdk/#{dynamic_env_id}/.well-known/jwks" + +config :explorer, Explorer.ThirdPartyIntegrations.Dynamic.Strategy, enabled: !is_nil(dynamic_env_id) + +config :explorer, Explorer.ThirdPartyIntegrations.Keycloak, + domain: ConfigHelper.parse_url_env_var("ACCOUNT_KEYCLOAK_DOMAIN", nil, true), + realm: System.get_env("ACCOUNT_KEYCLOAK_REALM"), + client_id: System.get_env("ACCOUNT_KEYCLOAK_CLIENT_ID"), + client_secret: System.get_env("ACCOUNT_KEYCLOAK_CLIENT_SECRET"), + email_webhook_url: ConfigHelper.parse_url_env_var("ACCOUNT_KEYCLOAK_EMAIL_WEBHOOK_URL") + enabled? = ConfigHelper.parse_bool_env_var("MICROSERVICE_SC_VERIFIER_ENABLED", "true") # or "eth_bytecode_db" type = System.get_env("MICROSERVICE_SC_VERIFIER_TYPE", "sc_verifier") config :explorer, Explorer.SmartContract.RustVerifierInterfaceBehaviour, - service_url: System.get_env("MICROSERVICE_SC_VERIFIER_URL") || "https://eth-bytecode-db.services.blockscout.com/", + service_url: + ConfigHelper.parse_url_env_var("MICROSERVICE_SC_VERIFIER_URL", "https://eth-bytecode-db.services.blockscout.com"), enabled: enabled?, type: type, eth_bytecode_db?: enabled? && type == "eth_bytecode_db", api_key: System.get_env("MICROSERVICE_SC_VERIFIER_API_KEY") config :explorer, Explorer.Visualize.Sol2uml, - service_url: System.get_env("MICROSERVICE_VISUALIZE_SOL2UML_URL"), + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_VISUALIZE_SOL2UML_URL"), enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_VISUALIZE_SOL2UML_ENABLED") config :explorer, Explorer.SmartContract.SigProviderInterface, - service_url: System.get_env("MICROSERVICE_SIG_PROVIDER_URL"), + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_SIG_PROVIDER_URL"), enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_SIG_PROVIDER_ENABLED") config :explorer, Explorer.MicroserviceInterfaces.BENS, - service_url: System.get_env("MICROSERVICE_BENS_URL"), - enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_BENS_ENABLED") + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_BENS_URL"), + enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_BENS_ENABLED"), + protocols: ConfigHelper.parse_list_env_var("MICROSERVICE_BENS_PROTOCOLS"), + disable_blocks_bens_preload: ConfigHelper.parse_bool_env_var("DISABLE_BLOCKS_BENS_PRELOAD", "false"), + disable_transactions_bens_preload: ConfigHelper.parse_bool_env_var("DISABLE_TRANSACTIONS_BENS_PRELOAD", "false"), + disable_token_transfers_bens_preload: ConfigHelper.parse_bool_env_var("DISABLE_TOKEN_TRANSFERS_BENS_PRELOAD", "false") config :explorer, Explorer.MicroserviceInterfaces.AccountAbstraction, - service_url: System.get_env("MICROSERVICE_ACCOUNT_ABSTRACTION_URL"), + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_ACCOUNT_ABSTRACTION_URL"), enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_ACCOUNT_ABSTRACTION_ENABLED") config :explorer, Explorer.MicroserviceInterfaces.Metadata, - service_url: System.get_env("MICROSERVICE_METADATA_URL"), + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_METADATA_URL"), enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_METADATA_ENABLED"), proxy_requests_timeout: ConfigHelper.parse_time_env_var("MICROSERVICE_METADATA_PROXY_REQUESTS_TIMEOUT", "30s") config :explorer, Explorer.SmartContract.StylusVerifierInterface, - service_url: ConfigHelper.parse_microservice_url("MICROSERVICE_STYLUS_VERIFIER_URL") + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_STYLUS_VERIFIER_URL") config :explorer, Explorer.MicroserviceInterfaces.MultichainSearch, api_key: System.get_env("MICROSERVICE_MULTICHAIN_SEARCH_API_KEY"), - service_url: System.get_env("MICROSERVICE_MULTICHAIN_SEARCH_URL") + service_url: microservice_multichain_search_url, + addresses_chunk_size: + ConfigHelper.parse_integer_env_var("MICROSERVICE_MULTICHAIN_SEARCH_ADDRESSES_CHUNK_SIZE", 7_000), + token_info_chunk_size: + ConfigHelper.parse_integer_env_var("MICROSERVICE_MULTICHAIN_SEARCH_TOKEN_INFO_CHUNK_SIZE", 1_000), + counters_chunk_size: ConfigHelper.parse_integer_env_var("MICROSERVICE_MULTICHAIN_SEARCH_COUNTERS_CHUNK_SIZE", 1_000) -config :explorer, :air_table_public_tags, - table_url: System.get_env("ACCOUNT_PUBLIC_TAGS_AIRTABLE_URL"), - api_key: System.get_env("ACCOUNT_PUBLIC_TAGS_AIRTABLE_API_KEY") +config :explorer, Explorer.MicroserviceInterfaces.TACOperationLifecycle, + enabled: ConfigHelper.parse_bool_env_var("MICROSERVICE_TAC_OPERATION_LIFECYCLE_ENABLED", "true"), + service_url: ConfigHelper.parse_url_env_var("MICROSERVICE_TAC_OPERATION_LIFECYCLE_URL") audit_reports_table_url = System.get_env("CONTRACT_AUDIT_REPORTS_AIRTABLE_URL") - -audit_reports_api_key = - System.get_env("CONTRACT_AUDIT_REPORTS_AIRTABLE_API_KEY") || System.get_env("ACCOUNT_PUBLIC_TAGS_AIRTABLE_API_KEY") +audit_reports_api_key = System.get_env("CONTRACT_AUDIT_REPORTS_AIRTABLE_API_KEY") config :explorer, :air_table_audit_reports, table_url: audit_reports_table_url, @@ -646,19 +771,20 @@ config :explorer, Explorer.Account, enabled: ConfigHelper.parse_bool_env_var("ACCOUNT_ENABLED"), sendgrid: [ sender: System.get_env("ACCOUNT_SENDGRID_SENDER"), - template: System.get_env("ACCOUNT_SENDGRID_TEMPLATE") + template: System.get_env("ACCOUNT_SENDGRID_TEMPLATE"), + otp_template: System.get_env("ACCOUNT_SENDGRID_OTP_TEMPLATE") ], verification_email_resend_interval: ConfigHelper.parse_time_env_var("ACCOUNT_VERIFICATION_EMAIL_RESEND_INTERVAL", "5m"), otp_resend_interval: ConfigHelper.parse_time_env_var("ACCOUNT_OTP_RESEND_INTERVAL", "1m"), - private_tags_limit: ConfigHelper.parse_integer_env_var("ACCOUNT_PRIVATE_TAGS_LIMIT", 2000), + private_tags_limit: ConfigHelper.parse_integer_env_var("ACCOUNT_PRIVATE_TAGS_LIMIT", 2_000), watchlist_addresses_limit: ConfigHelper.parse_integer_env_var("ACCOUNT_WATCHLIST_ADDRESSES_LIMIT", 15), notifications_limit_for_30_days: - ConfigHelper.parse_integer_env_var("ACCOUNT_WATCHLIST_NOTIFICATIONS_LIMIT_FOR_30_DAYS", 1000), + ConfigHelper.parse_integer_env_var("ACCOUNT_WATCHLIST_NOTIFICATIONS_LIMIT_FOR_30_DAYS", 1_000), siwe_message: System.get_env("ACCOUNT_SIWE_MESSAGE", "Sign in to Blockscout Account V2") config :explorer, Explorer.Chain.Cache.MinMissingBlockNumber, - enabled: !ConfigHelper.parse_bool_env_var("DISABLE_INDEXER"), + enabled: !disable_indexer?, batch_size: ConfigHelper.parse_integer_env_var("MIN_MISSING_BLOCK_NUMBER_BATCH_SIZE", 100_000) config :explorer, :spandex, @@ -667,17 +793,10 @@ config :explorer, :spandex, config :explorer, :datadog, port: ConfigHelper.parse_integer_env_var("DATADOG_PORT", 8126) -config :explorer, Explorer.Chain.Cache.TransactionActionTokensData, - max_cache_size: ConfigHelper.parse_integer_env_var("INDEXER_TX_ACTIONS_MAX_TOKEN_CACHE_SIZE", 100_000) - config :explorer, Explorer.Chain.Fetcher.LookUpSmartContractSourcesOnDemand, fetch_interval: ConfigHelper.parse_time_env_var("MICROSERVICE_ETH_BYTECODE_DB_INTERVAL_BETWEEN_LOOKUPS", "10m"), max_concurrency: ConfigHelper.parse_integer_env_var("MICROSERVICE_ETH_BYTECODE_DB_MAX_LOOKUPS_CONCURRENCY", 10) -config :explorer, Explorer.Chain.Transaction, - rootstock_remasc_address: System.get_env("ROOTSTOCK_REMASC_ADDRESS"), - rootstock_bridge_address: System.get_env("ROOTSTOCK_BRIDGE_ADDRESS") - config :explorer, Explorer.Chain.Cache.Counters.AddressTabsElementsCount, ttl: ConfigHelper.parse_time_env_var("ADDRESSES_TABS_COUNTERS_TTL", "10m") @@ -709,6 +828,12 @@ config :explorer, Explorer.Migrator.ReindexInternalTransactionsWithIncompatibleS concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_INTERNAL_TRANSACTIONS_STATUS_CONCURRENCY", 1), timeout: ConfigHelper.parse_time_env_var("MIGRATION_REINDEX_INTERNAL_TRANSACTIONS_STATUS_TIMEOUT", "0s") +config :explorer, Explorer.Migrator.ReindexBlocksWithMissingTransactions, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_BATCH_SIZE", 10), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_CONCURRENCY", 1), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_TIMEOUT", "0s"), + enabled: ConfigHelper.parse_bool_env_var("MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_ENABLED", "false") + config :explorer, Explorer.Migrator.RestoreOmittedWETHTransfers, concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_RESTORE_OMITTED_WETH_TOKEN_TRANSFERS_CONCURRENCY", 5), batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_RESTORE_OMITTED_WETH_TOKEN_TRANSFERS_BATCH_SIZE", 50), @@ -747,33 +872,57 @@ config :explorer, Explorer.Migrator.ArbitrumDaRecordsNormalization, config :explorer, Explorer.Migrator.HeavyDbIndexOperation.CreateArbitrumBatchL2BlocksUnconfirmedBlocksIndex, enabled: ConfigHelper.chain_type() == :arbitrum +config :explorer, Explorer.Migrator.HeavyDbIndexOperation.DropTransactionsOperatorFeeConstantIndex, + enabled: ConfigHelper.chain_type() == :optimism + config :explorer, Explorer.Migrator.FilecoinPendingAddressOperations, enabled: ConfigHelper.chain_type() == :filecoin, batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_FILECOIN_PENDING_ADDRESS_OPERATIONS_BATCH_SIZE", 100), concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_FILECOIN_PENDING_ADDRESS_OPERATIONS_CONCURRENCY", 1) +config :explorer, Explorer.Migrator.CeloAccounts, enabled: ConfigHelper.chain_identity() == {:optimism, :celo} + +config :explorer, Explorer.Migrator.EmptyInternalTransactionsData, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_BATCH_SIZE", 1000), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_CONCURRENCY", 1), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_TIMEOUT", "0s") + +config :explorer, Explorer.Migrator.CeloAggregatedElectionRewards, + enabled: ConfigHelper.chain_identity() == {:optimism, :celo} + +config :explorer, Explorer.Migrator.CeloL2Epochs, + enabled: + ConfigHelper.chain_identity() == {:optimism, :celo} && + !is_nil(celo_l2_migration_block) && + !is_nil(celo_epoch_manager_contract_address) + +config :explorer, Explorer.Chain.Cache.CeloEpochs, enabled: ConfigHelper.chain_identity() == {:optimism, :celo} + config :explorer, Explorer.Migrator.ShrinkInternalTransactions, enabled: ConfigHelper.parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_BATCH_SIZE", 100), concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_CONCURRENCY", 10) -config :explorer, Explorer.Migrator.SmartContractLanguage, - enabled: !ConfigHelper.parse_bool_env_var("MIGRATION_SMART_CONTRACT_LANGUAGE_DISABLED"), - batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_SMART_CONTRACT_LANGUAGE_BATCH_SIZE", 100), - concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_SMART_CONTRACT_LANGUAGE_CONCURRENCY", 1) - config :explorer, Explorer.Migrator.BackfillMetadataURL, enabled: !ConfigHelper.parse_bool_env_var("MIGRATION_BACKFILL_METADATA_URL_DISABLED"), batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_METADATA_URL_BATCH_SIZE", 100), concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_BACKFILL_METADATA_URL_CONCURRENCY", 5) -config :indexer, Indexer.Migrator.RecoveryWETHTokenTransfers, - concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_CONCURRENCY", 5), - batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_BATCH_SIZE", 50), - timeout: ConfigHelper.parse_time_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_TIMEOUT", "0s"), - blocks_batch_size: - ConfigHelper.parse_integer_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_BLOCKS_BATCH_SIZE", 100_000), - high_verbosity: ConfigHelper.parse_bool_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_HIGH_VERBOSITY", "true") +config :explorer, Explorer.Migrator.MergeAdjacentMissingBlockRanges, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_MERGE_ADJACENT_MISSING_BLOCK_RANGES_BATCH_SIZE", 100) + +config :explorer, Explorer.Migrator.DeleteZeroValueInternalTransactions, + enabled: ConfigHelper.parse_bool_env_var("MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_ENABLED", "false"), + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_BATCH_SIZE", 100), + storage_period: + ConfigHelper.parse_time_env_var("MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_STORAGE_PERIOD", "30d"), + check_interval: + ConfigHelper.parse_time_env_var("MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_CHECK_INTERVAL", "1m") + +config :explorer, Explorer.Migrator.FillInternalTransactionsAddressIds, + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_BATCH_SIZE", 30), + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_CONCURRENCY", 10), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_TIMEOUT", "5s") config :explorer, Explorer.Chain.BridgedToken, eth_omni_bridge_mediator: System.get_env("BRIDGED_TOKENS_ETH_OMNI_BRIDGE_MEDIATOR"), @@ -789,7 +938,7 @@ config :explorer, Explorer.Chain.TokenTransfer, whitelisted_weth_contracts: ConfigHelper.parse_list_env_var("WHITELISTED_WETH_CONTRACTS", ""), weth_token_transfers_filtering_enabled: ConfigHelper.parse_bool_env_var("WETH_TOKEN_TRANSFERS_FILTERING_ENABLED") -config :explorer, Explorer.Chain.Metrics, +config :explorer, Explorer.Chain.Metrics.PublicMetrics, enabled: ConfigHelper.parse_bool_env_var("PUBLIC_METRICS_ENABLED", "false"), update_period_hours: ConfigHelper.parse_integer_env_var("PUBLIC_METRICS_UPDATE_PERIOD_HOURS", 24) @@ -798,7 +947,7 @@ config :explorer, Explorer.Chain.Filecoin.NativeAddress, config :explorer, Explorer.Chain.Blackfort.Validator, api_url: System.get_env("BLACKFORT_VALIDATOR_API_URL") -addresses_blacklist_url = ConfigHelper.parse_microservice_url("ADDRESSES_BLACKLIST_URL") +addresses_blacklist_url = ConfigHelper.parse_url_env_var("ADDRESSES_BLACKLIST_URL") config :explorer, Explorer.Chain.Fetcher.AddressesBlacklist, url: addresses_blacklist_url, @@ -807,21 +956,95 @@ config :explorer, Explorer.Chain.Fetcher.AddressesBlacklist, retry_interval: ConfigHelper.parse_time_env_var("ADDRESSES_BLACKLIST_RETRY_INTERVAL", "5s"), provider: ConfigHelper.parse_catalog_value("ADDRESSES_BLACKLIST_PROVIDER", ["blockaid"], false, "blockaid") -rate_limiter_redis_url = System.get_env("RATE_LIMITER_REDIS_URL") +rate_limiter_redis_url = ConfigHelper.parse_url_env_var("RATE_LIMITER_REDIS_URL") +rate_limiter_redis_sentinel_urls = ConfigHelper.safe_get_env("RATE_LIMITER_REDIS_SENTINEL_URLS", "") config :explorer, Explorer.Utility.RateLimiter, - storage: (rate_limiter_redis_url && :redis) || :ets, + storage: if(rate_limiter_redis_url || rate_limiter_redis_sentinel_urls != "", do: :redis, else: :ets), redis_url: rate_limiter_redis_url, + redis_ssl: ConfigHelper.parse_bool_env_var("RATE_LIMITER_REDIS_SSL_ENABLED", "false"), + redis_sentinel_urls: rate_limiter_redis_sentinel_urls, + redis_sentinel_master_name: ConfigHelper.safe_get_env("RATE_LIMITER_REDIS_SENTINEL_MASTER_NAME", ""), on_demand: [ time_interval_limit: ConfigHelper.parse_time_env_var("RATE_LIMITER_ON_DEMAND_TIME_INTERVAL", "5s"), - limit_by_ip: ConfigHelper.parse_integer_env_var("RATE_LIMITER_ON_DEMAND_LIMIT_BY_IP", 100), + limit_by_ip: ConfigHelper.parse_integer_env_var("RATE_LIMITER_ON_DEMAND_LIMIT_BY_IP", 50), exp_timeout_coeff: ConfigHelper.parse_integer_env_var("RATE_LIMITER_ON_DEMAND_EXPONENTIAL_TIMEOUT_COEFF", 100), max_ban_interval: ConfigHelper.parse_time_env_var("RATE_LIMITER_ON_DEMAND_MAX_BAN_INTERVAL", "1h"), limitation_period: ConfigHelper.parse_time_env_var("RATE_LIMITER_ON_DEMAND_LIMITATION_PERIOD", "1h") - ] + ], + hammer_backend_module: + if(rate_limiter_redis_url || rate_limiter_redis_sentinel_urls != "", + do: Explorer.Utility.Hammer.Redis, + else: Explorer.Utility.Hammer.ETS + ) + +universal_proxy_config_url = + ConfigHelper.parse_url_env_var( + "UNIVERSAL_PROXY_CONFIG_URL", + "https://raw.githubusercontent.com/blockscout/backend-configs/refs/heads/main/universal-proxy-config.json" + ) + +universal_proxy_config = System.get_env("UNIVERSAL_PROXY_CONFIG") + +if System.get_env("UNIVERSAL_PROXY_CONFIG_URL") && universal_proxy_config do + raise "UNIVERSAL_PROXY_CONFIG_URL and UNIVERSAL_PROXY_CONFIG are both set; choose only one" +end + +config :explorer, Explorer.ThirdPartyIntegrations.UniversalProxy, + config_url: universal_proxy_config_url, + config_json: universal_proxy_config config :explorer, Explorer.Chain.Mud, enabled: ConfigHelper.parse_bool_env_var("MUD_INDEXER_ENABLED") +config :explorer, Explorer.Chain.Scroll.L1FeeParam, + curie_upgrade_block: ConfigHelper.parse_integer_env_var("SCROLL_L2_CURIE_UPGRADE_BLOCK", 0), + scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_SCALAR_INIT", 0), + overhead_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_OVERHEAD_INIT", 0), + commit_scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_COMMIT_SCALAR_INIT", 0), + blob_scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_BLOB_SCALAR_INIT", 0), + l1_base_fee_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_BASE_FEE_INIT", 0), + l1_blob_base_fee_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_BLOB_BASE_FEE_INIT", 0) + +async_csv_export_enabled? = ConfigHelper.parse_bool_env_var("CSV_EXPORT_ASYNC_ENABLED") +csv_export_oban_concurrency = ConfigHelper.parse_integer_env_var("CSV_EXPORT_ASYNC_OBAN_CONCURRENCY", 10) + +csv_export_queues = + if async_csv_export_enabled? do + [csv_export: csv_export_oban_concurrency, csv_export_sanitize: 1] + else + [] + end + +config :explorer, Oban, enabled: async_csv_export_enabled?, queues: csv_export_queues + +gokapi_url = ConfigHelper.parse_url_env_var("CSV_EXPORT_ASYNC_GOKAPI_URL") +gokapi_api_key = System.get_env("CSV_EXPORT_ASYNC_GOKAPI_API_KEY") + +default_db_timeout = if async_csv_export_enabled?, do: "1h", else: "5m" + +config :explorer, Explorer.Chain.CsvExport, + async?: async_csv_export_enabled?, + max_pending_tasks_per_ip: ConfigHelper.parse_integer_env_var("CSV_EXPORT_ASYNC_MAX_PENDING_TASKS_PER_IP", 3), + chunk_size: ConfigHelper.parse_integer_env_var("CSV_EXPORT_ASYNC_UPLOAD_CHUNK_SIZE", 47_185_920), + db_timeout: ConfigHelper.parse_time_env_var("CSV_EXPORT_DB_TIMEOUT", default_db_timeout), + tmp_dir: ConfigHelper.safe_get_env("CSV_EXPORT_ASYNC_TMP_DIR", "/tmp/csv_export"), + gokapi_url: gokapi_url, + gokapi_api_key: gokapi_api_key, + gokapi_timeout: ConfigHelper.parse_time_env_var("CSV_EXPORT_ASYNC_GOKAPI_TIMEOUT", "60s"), + gokapi_upload_expiry_days: ConfigHelper.parse_integer_env_var("CSV_EXPORT_ASYNC_GOKAPI_UPLOAD_EXPIRY_DAYS", 1), + gokapi_upload_allowed_downloads: + ConfigHelper.parse_integer_env_var("CSV_EXPORT_ASYNC_GOKAPI_UPLOAD_ALLOWED_DOWNLOADS", 1) + +if async_csv_export_enabled? do + if is_nil(gokapi_url) or gokapi_url == "" do + raise "CSV_EXPORT_ASYNC_GOKAPI_URL must be set when CSV_EXPORT_ASYNC_ENABLED=true" + end + + if is_nil(gokapi_api_key) or gokapi_api_key == "" do + raise "CSV_EXPORT_ASYNC_GOKAPI_API_KEY must be set when CSV_EXPORT_ASYNC_ENABLED=true" + end +end + ############### ### Indexer ### ############### @@ -840,8 +1063,15 @@ trace_block_ranges = ranges -> ranges end +disable_multichain_search_db_export_counters_queue_fetcher = + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_FETCHER") + +optimism_l2_isthmus_timestamp = + ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_ISTHMUS_TIMESTAMP") + config :indexer, block_transformer: ConfigHelper.block_transformer(), + chain_id: System.get_env("CHAIN_ID"), metadata_updater_milliseconds_interval: ConfigHelper.parse_time_env_var("TOKEN_METADATA_UPDATE_INTERVAL", "48h"), block_ranges: block_ranges, first_block: first_block, @@ -849,6 +1079,7 @@ config :indexer, trace_block_ranges: trace_block_ranges, trace_first_block: trace_first_block, trace_last_block: trace_last_block, + enable_partial_async_import?: ConfigHelper.parse_bool_env_var("INDEXER_ENABLE_PARTIAL_ASYNC_IMPORT", "false"), fetch_rewards_way: System.get_env("FETCH_REWARDS_WAY", "trace_block"), memory_limit: ConfigHelper.indexer_memory_limit(), system_memory_percentage: ConfigHelper.parse_integer_env_var("INDEXER_SYSTEM_MEMORY_PERCENTAGE", 60), @@ -856,60 +1087,74 @@ config :indexer, receipts_concurrency: ConfigHelper.parse_integer_env_var("INDEXER_RECEIPTS_CONCURRENCY", 10), hide_indexing_progress_alert: ConfigHelper.parse_bool_env_var("INDEXER_HIDE_INDEXING_PROGRESS_ALERT"), fetcher_init_limit: ConfigHelper.parse_integer_env_var("INDEXER_FETCHER_INIT_QUERY_LIMIT", 100), + fetcher_init_delay: ConfigHelper.parse_time_env_var("INDEXER_FETCHER_INIT_DELAY", "10m"), + massive_block_threshold: ConfigHelper.parse_integer_env_var("INDEXER_MASSIVE_BLOCK_THRESHOLD", 1000), token_balances_fetcher_init_limit: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_BALANCES_FETCHER_INIT_QUERY_LIMIT", 100_000), coin_balances_fetcher_init_limit: - ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_FETCHER_INIT_QUERY_LIMIT", 2000), + ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_FETCHER_INIT_QUERY_LIMIT", 2_000), graceful_shutdown_period: ConfigHelper.parse_time_env_var("INDEXER_GRACEFUL_SHUTDOWN_PERIOD", "5m"), internal_transactions_fetch_order: ConfigHelper.parse_catalog_value("INDEXER_INTERNAL_TRANSACTIONS_FETCH_ORDER", ["asc", "desc"], true, "asc") config :indexer, :ipfs, - gateway_url: System.get_env("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs"), + gateway_url: ConfigHelper.parse_url_env_var("IPFS_GATEWAY_URL", "https://ipfs.io/ipfs"), gateway_url_param_key: System.get_env("IPFS_GATEWAY_URL_PARAM_KEY"), gateway_url_param_value: System.get_env("IPFS_GATEWAY_URL_PARAM_VALUE"), gateway_url_param_location: ConfigHelper.parse_catalog_value("IPFS_GATEWAY_URL_PARAM_LOCATION", ["query", "header"], true), - public_gateway_url: System.get_env("IPFS_PUBLIC_GATEWAY_URL", "https://ipfs.io/ipfs") - -config :indexer, Indexer.Supervisor, enabled: !ConfigHelper.parse_bool_env_var("DISABLE_INDEXER") - -config :indexer, Indexer.Fetcher.TransactionAction.Supervisor, - enabled: ConfigHelper.parse_bool_env_var("INDEXER_TX_ACTIONS_ENABLE") - -config :indexer, Indexer.Fetcher.TransactionAction, - reindex_first_block: System.get_env("INDEXER_TX_ACTIONS_REINDEX_FIRST_BLOCK"), - reindex_last_block: System.get_env("INDEXER_TX_ACTIONS_REINDEX_LAST_BLOCK"), - reindex_protocols: System.get_env("INDEXER_TX_ACTIONS_REINDEX_PROTOCOLS", ""), - aave_v3_pool: System.get_env("INDEXER_TX_ACTIONS_AAVE_V3_POOL_CONTRACT"), - uniswap_v3_factory: - ConfigHelper.safe_get_env( - "INDEXER_TX_ACTIONS_UNISWAP_V3_FACTORY_CONTRACT", - "0x1F98431c8aD98523631AE4a59f267346ea31F984" - ), - uniswap_v3_nft_position_manager: - ConfigHelper.safe_get_env( - "INDEXER_TX_ACTIONS_UNISWAP_V3_NFT_POSITION_MANAGER_CONTRACT", - "0xC36442b4a4522E871399CD717aBDD847Ab11FE88" - ) + public_gateway_url: ConfigHelper.parse_url_env_var("IPFS_PUBLIC_GATEWAY_URL", "https://ipfs.io/ipfs") + +config :indexer, :arc, + arc_native_token_decimals: ConfigHelper.parse_integer_env_var("INDEXER_ARC_NATIVE_TOKEN_DECIMALS", 6), + arc_native_token_address: + System.get_env("INDEXER_ARC_NATIVE_TOKEN_CONTRACT", "0x3600000000000000000000000000000000000000"), + arc_native_token_system_address: + System.get_env("INDEXER_ARC_NATIVE_TOKEN_SYSTEM_CONTRACT", "0x1800000000000000000000000000000000000000") + +config :indexer, Indexer.Supervisor, enabled: !disable_indexer? + +config :indexer, Indexer.Transform.FheOperations, + enabled: ConfigHelper.parse_bool_env_var("INDEXER_FHE_OPERATIONS_ENABLED", "false") config :indexer, Indexer.PendingTransactionsSanitizer, - interval: ConfigHelper.parse_time_env_var("INDEXER_PENDING_TRANSACTIONS_SANITIZER_INTERVAL", "1h") + interval: ConfigHelper.parse_time_env_var("INDEXER_PENDING_TRANSACTIONS_SANITIZER_INTERVAL", "1h"), + window_size: ConfigHelper.parse_time_env_var("INDEXER_PENDING_TRANSACTIONS_WINDOW_SIZE", "1d") + +config :indexer, Indexer.TokenTransferBlockConsensusSanitizer, + interval: ConfigHelper.parse_time_env_var("INDEXER_TOKEN_TRANSFER_BLOCK_CONSENSUS_SANITIZER_INTERVAL", "20m") config :indexer, Indexer.Fetcher.PendingTransaction.Supervisor, disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_PENDING_TRANSACTIONS_FETCHER") config :indexer, Indexer.Fetcher.Token, concurrency: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_CONCURRENCY", 10) -config :indexer, Indexer.Fetcher.TokenBalance, - batch_size: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_BALANCES_BATCH_SIZE", 100), - concurrency: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_BALANCES_CONCURRENCY", 10), - max_refetch_interval: ConfigHelper.parse_time_env_var("INDEXER_TOKEN_BALANCES_MAX_REFETCH_INTERVAL", "168h"), - exp_timeout_coeff: ConfigHelper.parse_integer_env_var("INDEXER_TOKEN_BALANCES_EXPONENTIAL_TIMEOUT_COEFF", 100) +config :indexer, Indexer.Fetcher.TokenBalance.Historical, + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_ARCHIVAL_TOKEN_BALANCES_BATCH_SIZE", 100), + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_ARCHIVAL_TOKEN_BALANCES_CONCURRENCY", 10), + max_refetch_interval: ConfigHelper.parse_time_env_var("INDEXER_ARCHIVAL_TOKEN_BALANCES_MAX_REFETCH_INTERVAL", "168h"), + exp_timeout_coeff: + ConfigHelper.parse_integer_env_var("INDEXER_ARCHIVAL_TOKEN_BALANCES_EXPONENTIAL_TIMEOUT_COEFF", 100) + +config :indexer, Indexer.Fetcher.TokenBalance.Historical.Supervisor, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_ARCHIVAL_TOKEN_BALANCES_FETCHER") + +config :indexer, Indexer.Fetcher.TokenBalance.Current, + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_CURRENT_TOKEN_BALANCES_BATCH_SIZE", 100), + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_CURRENT_TOKEN_BALANCES_CONCURRENCY", 10) + +config :indexer, Indexer.Fetcher.TokenCountersUpdater, + milliseconds_interval: ConfigHelper.parse_time_env_var("TOKEN_COUNTERS_UPDATE_INTERVAL", "3h") config :indexer, Indexer.Fetcher.OnDemand.TokenBalance, threshold: ConfigHelper.parse_time_env_var("TOKEN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD", "1h"), - fallback_threshold_in_blocks: 500 + fallback_threshold_in_blocks: 500, + batch_size: ConfigHelper.parse_integer_env_var("TOKEN_BALANCE_ON_DEMAND_FETCHER_BATCH_SIZE", 500), + concurrency: ConfigHelper.parse_integer_env_var("TOKEN_BALANCE_ON_DEMAND_FETCHER_CONCURRENCY", 4), + address_queue_batch_size: + ConfigHelper.parse_integer_env_var("TOKEN_BALANCE_ON_DEMAND_FETCHER_ADDRESS_QUEUE_BATCH_SIZE", 50), + address_queue_flush_interval: + ConfigHelper.parse_time_env_var("TOKEN_BALANCE_ON_DEMAND_FETCHER_ADDRESS_QUEUE_FLUSH_INTERVAL", "1s") config :indexer, Indexer.Fetcher.OnDemand.CoinBalance, threshold: ConfigHelper.parse_time_env_var("COIN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD", "1h"), @@ -924,9 +1169,14 @@ config :indexer, Indexer.Fetcher.OnDemand.TokenInstanceMetadataRefetch, config :indexer, Indexer.Fetcher.BlockReward.Supervisor, disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_BLOCK_REWARD_FETCHER") -config :indexer, Indexer.Fetcher.InternalTransaction.Supervisor, +config :indexer, Indexer.Fetcher.InternalTransaction.Supervisor, disabled?: trace_url_missing? + +config :indexer, Indexer.Fetcher.InternalTransaction, disabled?: trace_url_missing? or ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER") +config :indexer, Indexer.Fetcher.OnDemand.InternalTransaction, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER") + disable_coin_balances_fetcher? = ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_ADDRESS_COIN_BALANCE_FETCHER") config :indexer, Indexer.Fetcher.CoinBalance.Catchup.Supervisor, disabled?: disable_coin_balances_fetcher? @@ -936,6 +1186,12 @@ config :indexer, Indexer.Fetcher.CoinBalance.Realtime.Supervisor, disabled?: dis config :indexer, Indexer.Fetcher.TokenUpdater.Supervisor, disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_CATALOGED_TOKEN_UPDATER_FETCHER") +config :indexer, Indexer.Fetcher.TokenTotalSupplyUpdater.Supervisor, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_CATALOGED_TOKEN_UPDATER_FETCHER") + +config :indexer, Indexer.Fetcher.TokenCountersUpdater.Supervisor, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_CATALOGED_TOKEN_UPDATER_FETCHER") + config :indexer, Indexer.Fetcher.EmptyBlocksSanitizer.Supervisor, disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_EMPTY_BLOCKS_SANITIZER") @@ -965,12 +1221,44 @@ config :indexer, Indexer.Fetcher.TokenInstance.SanitizeERC1155, config :indexer, Indexer.Fetcher.TokenInstance.SanitizeERC721, enabled: !ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_TOKEN_INSTANCE_ERC_721_SANITIZE_FETCHER", "false") +config :indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue.Supervisor, + disabled?: + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_FETCHER") || + is_nil(microservice_multichain_search_url) + +config :indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue.Supervisor, + disabled?: + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_FETCHER") || + is_nil(microservice_multichain_search_url) + +config :indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue.Supervisor, + disabled?: + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_FETCHER") || + is_nil(microservice_multichain_search_url) + +config :indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue.Supervisor, + disabled?: + disable_multichain_search_db_export_counters_queue_fetcher || + is_nil(microservice_multichain_search_url) || + !transactions_stats_enabled + +config :indexer, Indexer.Fetcher.MultichainSearchDb.CountersFetcher.Supervisor, + disabled?: + disable_multichain_search_db_export_counters_queue_fetcher || + is_nil(microservice_multichain_search_url) || + !transactions_stats_enabled + +config :indexer, Indexer.Fetcher.Stats.HotSmartContracts.Supervisor, + disabled?: ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_HOT_SMART_CONTRACTS_FETCHER"), + enabled: !ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_HOT_SMART_CONTRACTS_FETCHER") + config :indexer, Indexer.Fetcher.EmptyBlocksSanitizer, batch_size: ConfigHelper.parse_integer_env_var("INDEXER_EMPTY_BLOCKS_SANITIZER_BATCH_SIZE", 10), - interval: ConfigHelper.parse_time_env_var("INDEXER_EMPTY_BLOCKS_SANITIZER_INTERVAL", "10s") + interval: ConfigHelper.parse_time_env_var("INDEXER_EMPTY_BLOCKS_SANITIZER_INTERVAL", "10s"), + head_offset: ConfigHelper.parse_integer_env_var("INDEXER_EMPTY_BLOCKS_SANITIZER_HEAD_OFFSET", 1000) config :indexer, Indexer.Block.Realtime.Fetcher, - max_gap: ConfigHelper.parse_integer_env_var("INDEXER_REALTIME_FETCHER_MAX_GAP", 1000), + max_gap: ConfigHelper.parse_integer_env_var("INDEXER_REALTIME_FETCHER_MAX_GAP", 1_000), polling_period: ConfigHelper.parse_time_env_var("INDEXER_REALTIME_FETCHER_POLLING_PERIOD") config :indexer, Indexer.Block.Catchup.MissingRangesCollector, @@ -1025,7 +1313,12 @@ config :indexer, Indexer.Fetcher.InternalTransaction, batch_size: ConfigHelper.parse_integer_env_var("INDEXER_INTERNAL_TRANSACTIONS_BATCH_SIZE", 10), concurrency: ConfigHelper.parse_integer_env_var("INDEXER_INTERNAL_TRANSACTIONS_CONCURRENCY", 4), indexing_finished_threshold: - ConfigHelper.parse_integer_env_var("API_INTERNAL_TRANSACTIONS_INDEXING_FINISHED_THRESHOLD", 1000) + ConfigHelper.parse_integer_env_var("API_INTERNAL_TRANSACTIONS_INDEXING_FINISHED_THRESHOLD", 1_000) + +config :indexer, Indexer.Fetcher.InternalTransaction.DeleteQueue, + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_INTERNAL_TRANSACTIONS_DELETE_QUEUE_BATCH_SIZE", 100), + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_INTERNAL_TRANSACTIONS_DELETE_QUEUE_CONCURRENCY", 1), + threshold: ConfigHelper.parse_time_env_var("INDEXER_INTERNAL_TRANSACTIONS_DELETE_QUEUE_THRESHOLD", "0s") coin_balances_batch_size = ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_BATCH_SIZE", 100) coin_balances_concurrency = ConfigHelper.parse_integer_env_var("INDEXER_COIN_BALANCES_CONCURRENCY", 4) @@ -1038,6 +1331,76 @@ config :indexer, Indexer.Fetcher.CoinBalance.Realtime, batch_size: coin_balances_batch_size, concurrency: coin_balances_concurrency +config :indexer, Indexer.Migrator.RecoveryWETHTokenTransfers, + concurrency: ConfigHelper.parse_integer_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_CONCURRENCY", 5), + batch_size: ConfigHelper.parse_integer_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_BATCH_SIZE", 50), + timeout: ConfigHelper.parse_time_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_TIMEOUT", "0s"), + blocks_batch_size: + ConfigHelper.parse_integer_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_BLOCKS_BATCH_SIZE", 100_000), + high_verbosity: ConfigHelper.parse_bool_env_var("MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_HIGH_VERBOSITY", "true") + +config :indexer, Indexer.Fetcher.MultichainSearchDb.MainExportQueue, + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_CONCURRENCY", 10), + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_BATCH_SIZE", 3_000), + enqueue_busy_waiting_timeout: + ConfigHelper.parse_time_env_var( + "INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT", + "1s" + ), + max_queue_size: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_MAX_QUEUE_SIZE", 3_000), + init_limit: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_INIT_QUERY_LIMIT", 3_000) + +config :indexer, Indexer.Fetcher.MultichainSearchDb.BalancesExportQueue, + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_CONCURRENCY", 10), + batch_size: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_BATCH_SIZE", 3_000), + enqueue_busy_waiting_timeout: + ConfigHelper.parse_time_env_var( + "INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT", + "1s" + ), + max_queue_size: + ConfigHelper.parse_integer_env_var( + "INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_MAX_QUEUE_SIZE", + 3_000 + ), + init_limit: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_INIT_QUERY_LIMIT", 3_000) + +config :indexer, Indexer.Fetcher.MultichainSearchDb.TokenInfoExportQueue, + concurrency: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_CONCURRENCY", 10), + batch_size: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_BATCH_SIZE", 1_000), + enqueue_busy_waiting_timeout: + ConfigHelper.parse_time_env_var( + "INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT", + "1s" + ), + max_queue_size: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_MAX_QUEUE_SIZE", 1_000), + init_limit: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_INIT_QUERY_LIMIT", 1_000) + +config :indexer, Indexer.Fetcher.MultichainSearchDb.CountersExportQueue, + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_CONCURRENCY", 10), + batch_size: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_BATCH_SIZE", 1_000), + enqueue_busy_waiting_timeout: + ConfigHelper.parse_time_env_var( + "INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT", + "1s" + ), + max_queue_size: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_MAX_QUEUE_SIZE", 1_000), + init_limit: + ConfigHelper.parse_integer_env_var("INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_INIT_QUERY_LIMIT", 1_000) + +config :indexer, Indexer.Fetcher.SignedAuthorizationStatus, + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_SIGNED_AUTHORIZATION_STATUS_BATCH_SIZE", 10) + config :indexer, Indexer.Fetcher.Optimism.TransactionBatch.Supervisor, enabled: ConfigHelper.chain_type() == :optimism config :indexer, Indexer.Fetcher.Optimism.OutputRoot.Supervisor, enabled: ConfigHelper.chain_type() == :optimism config :indexer, Indexer.Fetcher.Optimism.DisputeGame.Supervisor, enabled: ConfigHelper.chain_type() == :optimism @@ -1056,6 +1419,11 @@ config :indexer, Indexer.Fetcher.Optimism.Interop.MessageFailed.Supervisor, config :indexer, Indexer.Fetcher.Optimism.Interop.MessageQueue.Supervisor, disabled?: ConfigHelper.chain_type() != :optimism +config :indexer, Indexer.Fetcher.Optimism.Interop.MultichainExport.Supervisor, + disabled?: + ConfigHelper.chain_type() != :optimism || + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_OPTIMISM_INTEROP_MULTICHAIN_EXPORT", "true") + config :indexer, Indexer.Fetcher.Optimism, optimism_l1_rpc: System.get_env("INDEXER_OPTIMISM_L1_RPC"), optimism_l1_system_config: System.get_env("INDEXER_OPTIMISM_L1_SYSTEM_CONFIG_CONTRACT"), @@ -1063,7 +1431,8 @@ config :indexer, Indexer.Fetcher.Optimism, l2_eth_get_logs_range_size: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_L2_ETH_GET_LOGS_RANGE_SIZE", 250), block_duration: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_BLOCK_DURATION", 2), start_block_l1: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L1_START_BLOCK"), - portal: System.get_env("INDEXER_OPTIMISM_L1_PORTAL_CONTRACT") + portal: System.get_env("INDEXER_OPTIMISM_L1_PORTAL_CONTRACT"), + isthmus_timestamp_l2: optimism_l2_isthmus_timestamp config :indexer, Indexer.Fetcher.Optimism.Deposit, transaction_type: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_L1_DEPOSITS_TRANSACTION_TYPE", 126) @@ -1080,13 +1449,17 @@ config :indexer, Indexer.Fetcher.Optimism.TransactionBatch, blocks_chunk_size: System.get_env("INDEXER_OPTIMISM_L1_BATCH_BLOCKS_CHUNK_SIZE", "4"), eip4844_blobs_api_url: System.get_env("INDEXER_OPTIMISM_L1_BATCH_BLOCKSCOUT_BLOBS_API_URL", ""), celestia_blobs_api_url: System.get_env("INDEXER_OPTIMISM_L1_BATCH_CELESTIA_BLOBS_API_URL", ""), + eigenda_blobs_api_url: ConfigHelper.parse_url_env_var("INDEXER_OPTIMISM_L1_BATCH_EIGENDA_BLOBS_API_URL", ""), + eigenda_proxy_base_url: ConfigHelper.parse_url_env_var("INDEXER_OPTIMISM_L1_BATCH_EIGENDA_PROXY_BASE_URL"), + alt_da_server_url: System.get_env("INDEXER_OPTIMISM_L1_BATCH_ALT_DA_SERVER_URL", ""), genesis_block_l2: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_BATCH_GENESIS_BLOCK_NUMBER"), inbox: System.get_env("INDEXER_OPTIMISM_L1_BATCH_INBOX"), submitter: System.get_env("INDEXER_OPTIMISM_L1_BATCH_SUBMITTER") config :indexer, Indexer.Fetcher.Optimism.EIP1559ConfigUpdate, chunk_size: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_L2_HOLOCENE_BLOCKS_CHUNK_SIZE", 25), - holocene_timestamp_l2: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP") + holocene_timestamp_l2: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP"), + jovian_timestamp_l2: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP") config :indexer, Indexer.Fetcher.Optimism.Interop.Message, start_block: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_OPTIMISM_L2_INTEROP_START_BLOCK"), @@ -1100,41 +1473,27 @@ config :indexer, Indexer.Fetcher.Optimism.Interop.MessageQueue, recv_timeout: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_INTEROP_RECV_TIMEOUT", 10), export_expiration: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_INTEROP_EXPORT_EXPIRATION_DAYS", 10) -config :indexer, Indexer.Fetcher.Withdrawal.Supervisor, - disabled?: System.get_env("INDEXER_DISABLE_WITHDRAWALS_FETCHER", "true") == "true" - -config :indexer, Indexer.Fetcher.Withdrawal, first_block: System.get_env("WITHDRAWALS_FIRST_BLOCK") - -config :indexer, Indexer.Fetcher.PolygonEdge.Deposit.Supervisor, enabled: ConfigHelper.chain_type() == :polygon_edge - -config :indexer, Indexer.Fetcher.PolygonEdge.DepositExecute.Supervisor, - enabled: ConfigHelper.chain_type() == :polygon_edge - -config :indexer, Indexer.Fetcher.PolygonEdge.Withdrawal.Supervisor, enabled: ConfigHelper.chain_type() == :polygon_edge +config :indexer, Indexer.Fetcher.Optimism.Interop.MultichainExport, + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_MULTICHAIN_BATCH_SIZE", 100) -config :indexer, Indexer.Fetcher.PolygonEdge.WithdrawalExit.Supervisor, - enabled: ConfigHelper.chain_type() == :polygon_edge - -config :indexer, Indexer.Fetcher.PolygonEdge, - polygon_edge_l1_rpc: System.get_env("INDEXER_POLYGON_EDGE_L1_RPC"), - polygon_edge_eth_get_logs_range_size: - ConfigHelper.parse_integer_env_var("INDEXER_POLYGON_EDGE_ETH_GET_LOGS_RANGE_SIZE", 1000) - -config :indexer, Indexer.Fetcher.PolygonEdge.Deposit, - start_block_l1: System.get_env("INDEXER_POLYGON_EDGE_L1_DEPOSITS_START_BLOCK"), - state_sender: System.get_env("INDEXER_POLYGON_EDGE_L1_STATE_SENDER_CONTRACT") +config :indexer, Indexer.Fetcher.Optimism.OperatorFee, + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_CONCURRENCY", 3), + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_BATCH_SIZE", 100), + enqueue_busy_waiting_timeout: + ConfigHelper.parse_time_env_var( + "INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT", + "1s" + ), + max_queue_size: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_MAX_QUEUE_SIZE", 1_000), + init_limit: ConfigHelper.parse_integer_env_var("INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_INIT_QUERY_LIMIT", 1_000) -config :indexer, Indexer.Fetcher.PolygonEdge.DepositExecute, - start_block_l2: System.get_env("INDEXER_POLYGON_EDGE_L2_DEPOSITS_START_BLOCK"), - state_receiver: System.get_env("INDEXER_POLYGON_EDGE_L2_STATE_RECEIVER_CONTRACT") +config :indexer, Indexer.Fetcher.Optimism.OperatorFee.Supervisor, + disabled?: is_nil(optimism_l2_isthmus_timestamp) or ConfigHelper.chain_type() != :optimism -config :indexer, Indexer.Fetcher.PolygonEdge.Withdrawal, - start_block_l2: System.get_env("INDEXER_POLYGON_EDGE_L2_WITHDRAWALS_START_BLOCK"), - state_sender: System.get_env("INDEXER_POLYGON_EDGE_L2_STATE_SENDER_CONTRACT") +config :indexer, Indexer.Fetcher.Withdrawal.Supervisor, + disabled?: System.get_env("INDEXER_DISABLE_WITHDRAWALS_FETCHER", "true") == "true" -config :indexer, Indexer.Fetcher.PolygonEdge.WithdrawalExit, - start_block_l1: System.get_env("INDEXER_POLYGON_EDGE_L1_WITHDRAWALS_START_BLOCK"), - exit_helper: System.get_env("INDEXER_POLYGON_EDGE_L1_EXIT_HELPER_CONTRACT") +config :indexer, Indexer.Fetcher.Withdrawal, first_block: System.get_env("WITHDRAWALS_FIRST_BLOCK") config :indexer, Indexer.Fetcher.ZkSync.TransactionBatch, chunk_size: ConfigHelper.parse_integer_env_var("INDEXER_ZKSYNC_BATCHES_CHUNK_SIZE", 50), @@ -1158,15 +1517,18 @@ config :indexer, Indexer.Fetcher.Arbitrum.Messaging, config :indexer, Indexer.Fetcher.Arbitrum, l1_rpc: System.get_env("INDEXER_ARBITRUM_L1_RPC"), l1_rpc_chunk_size: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_RPC_CHUNK_SIZE", 20), - l1_rpc_block_range: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_RPC_HISTORICAL_BLOCKS_RANGE", 1000), + l1_rpc_block_range: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_RPC_HISTORICAL_BLOCKS_RANGE", 1_000), l1_rollup_address: System.get_env("INDEXER_ARBITRUM_L1_ROLLUP_CONTRACT"), l1_rollup_init_block: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_ROLLUP_INIT_BLOCK", 1), l1_start_block: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_COMMON_START_BLOCK", 0), - l1_finalization_threshold: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_FINALIZATION_THRESHOLD", 1000), + l1_finalization_threshold: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_L1_FINALIZATION_THRESHOLD", 1_000), rollup_chunk_size: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_ROLLUP_CHUNK_SIZE", 20) config :indexer, Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1, - recheck_interval: ConfigHelper.parse_time_env_var("INDEXER_ARBITRUM_TRACKING_MESSAGES_ON_L1_RECHECK_INTERVAL", "20s") + recheck_interval: ConfigHelper.parse_time_env_var("INDEXER_ARBITRUM_TRACKING_MESSAGES_ON_L1_RECHECK_INTERVAL", "20s"), + failure_interval_threshold: + ConfigHelper.parse_time_env_var("INDEXER_ARBITRUM_MESSAGES_TRACKING_FAILURE_THRESHOLD", "10m"), + missed_message_ids_range: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_MISSED_MESSAGE_IDS_RANGE", 10_000) config :indexer, Indexer.Fetcher.Arbitrum.TrackingMessagesOnL1.Supervisor, enabled: ConfigHelper.parse_bool_env_var("INDEXER_ARBITRUM_BRIDGE_MESSAGES_TRACKING_ENABLED") @@ -1181,7 +1543,7 @@ config :indexer, Indexer.Fetcher.Arbitrum.TrackingBatchesStatuses, new_batches_limit: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_NEW_BATCHES_LIMIT", 10), node_interface_contract: ConfigHelper.safe_get_env("INDEXER_ARBITRUM_NODE_INTERFACE_CONTRACT", "0x00000000000000000000000000000000000000C8"), - missing_batches_range: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_MISSING_BATCHES_RANGE", 10000), + missing_batches_range: ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_MISSING_BATCHES_RANGE", 10_000), failure_interval_threshold: ConfigHelper.parse_time_env_var("INDEXER_ARBITRUM_BATCHES_TRACKING_FAILURE_THRESHOLD", "10m") @@ -1191,7 +1553,7 @@ config :indexer, Indexer.Fetcher.Arbitrum.TrackingBatchesStatuses.Supervisor, config :indexer, Indexer.Fetcher.Arbitrum.RollupMessagesCatchup, recheck_interval: ConfigHelper.parse_time_env_var("INDEXER_ARBITRUM_MISSED_MESSAGES_RECHECK_INTERVAL", "1h"), missed_messages_blocks_depth: - ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_MISSED_MESSAGES_BLOCKS_DEPTH", 10000) + ConfigHelper.parse_integer_env_var("INDEXER_ARBITRUM_MISSED_MESSAGES_BLOCKS_DEPTH", 10_000) config :indexer, Indexer.Fetcher.Arbitrum.RollupMessagesCatchup.Supervisor, enabled: ConfigHelper.parse_bool_env_var("INDEXER_ARBITRUM_BRIDGE_MESSAGES_TRACKING_ENABLED") @@ -1234,6 +1596,25 @@ config :indexer, Indexer.Fetcher.Beacon.Blob, start_block: ConfigHelper.parse_integer_env_var("INDEXER_BEACON_BLOB_FETCHER_START_BLOCK", 19_200_000), end_block: ConfigHelper.parse_integer_env_var("INDEXER_BEACON_BLOB_FETCHER_END_BLOCK", 0) +config :indexer, Indexer.Fetcher.Beacon.Deposit.Supervisor, + disabled?: + ConfigHelper.chain_type() != :ethereum || + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_BEACON_DEPOSIT_FETCHER") + +config :indexer, Indexer.Fetcher.Beacon.Deposit, + interval: ConfigHelper.parse_time_env_var("INDEXER_BEACON_DEPOSIT_FETCHER_INTERVAL", "6s"), + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_BEACON_DEPOSIT_FETCHER_BATCH_SIZE", 1_000) + +config :indexer, Indexer.Fetcher.Beacon.Deposit.Status.Supervisor, + disabled?: + ConfigHelper.chain_type() != :ethereum || + ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_BEACON_DEPOSIT_STATUS_FETCHER") + +config :indexer, Indexer.Fetcher.Beacon.Deposit.Status, + epoch_duration: ConfigHelper.parse_integer_env_var("INDEXER_BEACON_DEPOSIT_STATUS_FETCHER_EPOCH_DURATION", 384), + reference_timestamp: + ConfigHelper.parse_integer_env_var("INDEXER_BEACON_DEPOSIT_STATUS_FETCHER_REFERENCE_TIMESTAMP", 1_722_024_023) + config :indexer, Indexer.Fetcher.Shibarium.L1, rpc: System.get_env("INDEXER_SHIBARIUM_L1_RPC"), start_block: System.get_env("INDEXER_SHIBARIUM_L1_START_BLOCK"), @@ -1254,59 +1635,36 @@ config :indexer, Indexer.Fetcher.Shibarium.L1.Supervisor, enabled: ConfigHelper. config :indexer, Indexer.Fetcher.Shibarium.L2.Supervisor, enabled: ConfigHelper.chain_type() == :shibarium -config :indexer, Indexer.Fetcher.PolygonZkevm.BridgeL1, - rpc: System.get_env("INDEXER_POLYGON_ZKEVM_L1_RPC"), - start_block: System.get_env("INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK"), - bridge_contract: System.get_env("INDEXER_POLYGON_ZKEVM_L1_BRIDGE_CONTRACT"), - native_symbol: System.get_env("INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NATIVE_SYMBOL", "ETH"), - native_decimals: ConfigHelper.parse_integer_env_var("INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NATIVE_DECIMALS", 18), - rollup_network_id_l1: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NETWORK_ID"), - rollup_index_l1: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_POLYGON_ZKEVM_L1_BRIDGE_ROLLUP_INDEX") - -config :indexer, Indexer.Fetcher.PolygonZkevm.BridgeL1.Supervisor, enabled: ConfigHelper.chain_type() == :polygon_zkevm - -config :indexer, Indexer.Fetcher.PolygonZkevm.BridgeL1Tokens.Supervisor, - enabled: ConfigHelper.chain_type() == :polygon_zkevm - -config :indexer, Indexer.Fetcher.PolygonZkevm.BridgeL2, - start_block: System.get_env("INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK"), - bridge_contract: System.get_env("INDEXER_POLYGON_ZKEVM_L2_BRIDGE_CONTRACT"), - rollup_network_id_l2: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_POLYGON_ZKEVM_L2_BRIDGE_NETWORK_ID"), - rollup_index_l2: ConfigHelper.parse_integer_or_nil_env_var("INDEXER_POLYGON_ZKEVM_L2_BRIDGE_ROLLUP_INDEX") - -config :indexer, Indexer.Fetcher.PolygonZkevm.BridgeL2.Supervisor, enabled: ConfigHelper.chain_type() == :polygon_zkevm - -config :indexer, Indexer.Fetcher.PolygonZkevm.TransactionBatch, - chunk_size: ConfigHelper.parse_integer_env_var("INDEXER_POLYGON_ZKEVM_BATCHES_CHUNK_SIZE", 20), - recheck_interval: ConfigHelper.parse_integer_env_var("INDEXER_POLYGON_ZKEVM_BATCHES_RECHECK_INTERVAL", 60) - -config :indexer, Indexer.Fetcher.PolygonZkevm.TransactionBatch.Supervisor, - enabled: - ConfigHelper.chain_type() == :polygon_zkevm && - ConfigHelper.parse_bool_env_var("INDEXER_POLYGON_ZKEVM_BATCHES_ENABLED") - config :indexer, Indexer.Fetcher.Celo.ValidatorGroupVotes, batch_size: ConfigHelper.parse_integer_env_var("INDEXER_CELO_VALIDATOR_GROUP_VOTES_BATCH_SIZE", 200_000) config :indexer, Indexer.Fetcher.Celo.ValidatorGroupVotes.Supervisor, enabled: - ConfigHelper.chain_type() == :celo and + ConfigHelper.chain_identity() == {:optimism, :celo} and not ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_CELO_VALIDATOR_GROUP_VOTES_FETCHER") celo_epoch_fetchers_enabled? = - ConfigHelper.chain_type() == :celo and + ConfigHelper.chain_identity() == {:optimism, :celo} and not ConfigHelper.parse_bool_env_var("INDEXER_DISABLE_CELO_EPOCH_FETCHER") config :indexer, Indexer.Fetcher.Celo.EpochBlockOperations.Supervisor, enabled: celo_epoch_fetchers_enabled?, disabled?: not celo_epoch_fetchers_enabled? +config :indexer, Indexer.Fetcher.Celo.Legacy.Account, + concurrency: ConfigHelper.parse_integer_env_var("INDEXER_CELO_ACCOUNTS_CONCURRENCY", 1), + batch_size: ConfigHelper.parse_integer_env_var("INDEXER_CELO_ACCOUNTS_BATCH_SIZE", 100) + +config :indexer, Indexer.Fetcher.Celo.Legacy.Account.Supervisor, + enabled: ConfigHelper.chain_identity() == {:optimism, :celo}, + disabled?: not (ConfigHelper.chain_identity() == {:optimism, :celo}) + config :indexer, Indexer.Fetcher.Filecoin.BeryxAPI, - base_url: ConfigHelper.safe_get_env("BERYX_API_BASE_URL", "https://api.zondax.ch/fil/data/v3/mainnet"), + base_url: ConfigHelper.parse_url_env_var("BERYX_API_BASE_URL", "https://api.zondax.ch/fil/data/v3/mainnet"), api_token: System.get_env("BERYX_API_TOKEN") config :indexer, Indexer.Fetcher.Filecoin.FilfoxAPI, - base_url: ConfigHelper.safe_get_env("FILFOX_API_BASE_URL", "https://filfox.info/api/v1") + base_url: ConfigHelper.parse_url_env_var("FILFOX_API_BASE_URL", "https://filfox.info/api/v1") config :indexer, Indexer.Fetcher.Filecoin.AddressInfo.Supervisor, disabled?: @@ -1318,20 +1676,11 @@ config :indexer, Indexer.Fetcher.Filecoin.AddressInfo, config :indexer, Indexer.Fetcher.Scroll, l1_eth_get_logs_range_size: ConfigHelper.parse_integer_env_var("INDEXER_SCROLL_L1_ETH_GET_LOGS_RANGE_SIZE", 250), - l2_eth_get_logs_range_size: ConfigHelper.parse_integer_env_var("INDEXER_SCROLL_L2_ETH_GET_LOGS_RANGE_SIZE", 1000), + l2_eth_get_logs_range_size: ConfigHelper.parse_integer_env_var("INDEXER_SCROLL_L2_ETH_GET_LOGS_RANGE_SIZE", 1_000), rpc: System.get_env("INDEXER_SCROLL_L1_RPC") config :indexer, Indexer.Fetcher.Scroll.L1FeeParam, gas_oracle: System.get_env("INDEXER_SCROLL_L2_GAS_ORACLE_CONTRACT") -config :explorer, Explorer.Chain.Scroll.L1FeeParam, - curie_upgrade_block: ConfigHelper.parse_integer_env_var("SCROLL_L2_CURIE_UPGRADE_BLOCK", 0), - scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_SCALAR_INIT", 0), - overhead_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_OVERHEAD_INIT", 0), - commit_scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_COMMIT_SCALAR_INIT", 0), - blob_scalar_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_BLOB_SCALAR_INIT", 0), - l1_base_fee_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_BASE_FEE_INIT", 0), - l1_blob_base_fee_init: ConfigHelper.parse_integer_env_var("SCROLL_L1_BLOB_BASE_FEE_INIT", 0) - config :indexer, Indexer.Fetcher.Scroll.L1FeeParam.Supervisor, disabled?: ConfigHelper.chain_type() != :scroll config :indexer, Indexer.Fetcher.Scroll.BridgeL1, @@ -1353,6 +1702,32 @@ config :indexer, Indexer.Fetcher.Scroll.BridgeL2.Supervisor, disabled?: ConfigHe config :indexer, Indexer.Fetcher.Scroll.Batch.Supervisor, disabled?: ConfigHelper.chain_type() != :scroll +config :indexer, Indexer.Utils.EventNotificationsCleaner, + interval: ConfigHelper.parse_time_env_var("INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_INTERVAL", "2m"), + enabled: + app_mode == :indexer && ConfigHelper.parse_bool_env_var("INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_ENABLED", "true"), + max_age: ConfigHelper.parse_time_env_var("INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_MAX_AGE", "5m") + +config :indexer, Indexer.Prometheus.Metrics, + enabled: app_mode in [:indexer, :all] && ConfigHelper.parse_bool_env_var("INDEXER_METRICS_ENABLED", "true"), + specific_metrics_enabled?: %{ + token_instances_not_uploaded_to_cdn_count: + ConfigHelper.parse_bool_env_var("INDEXER_METRICS_ENABLED_TOKEN_INSTANCES_NOT_UPLOADED_TO_CDN_COUNT", "false"), + failed_token_instances_metadata_count: + ConfigHelper.parse_bool_env_var("INDEXER_METRICS_ENABLED_FAILED_TOKEN_INSTANCES_METADATA_COUNT", "true"), + unfetched_token_instances_count: + ConfigHelper.parse_bool_env_var("INDEXER_METRICS_ENABLED_UNFETCHED_TOKEN_INSTANCES_COUNT", "true"), + missing_current_token_balances_count: + ConfigHelper.parse_bool_env_var("INDEXER_METRICS_ENABLED_MISSING_CURRENT_TOKEN_BALANCES_COUNT", "true"), + missing_archival_token_balances_count: + ConfigHelper.parse_bool_env_var("INDEXER_METRICS_ENABLED_MISSING_ARCHIVAL_TOKEN_BALANCES_COUNT", "true") + } + +config :indexer, Indexer.Prometheus.RealtimeMetrics, + enabled: + app_mode in [:indexer, :all] && + ConfigHelper.parse_bool_env_var("INDEXER_REALTIME_METRICS_ENABLED", "true") + config :ex_aws, json_codec: Jason, access_key_id: System.get_env("NFT_MEDIA_HANDLER_AWS_ACCESS_KEY_ID"), @@ -1368,14 +1743,13 @@ config :ex_aws, :s3, nmh_enabled? = ConfigHelper.parse_bool_env_var("NFT_MEDIA_HANDLER_ENABLED") nmh_remote? = ConfigHelper.parse_bool_env_var("NFT_MEDIA_HANDLER_REMOTE_DISPATCHER_NODE_MODE_ENABLED") nmh_worker? = ConfigHelper.parse_bool_env_var("NFT_MEDIA_HANDLER_IS_WORKER") -nodes_map = ConfigHelper.parse_json_with_atom_keys_env_var("NFT_MEDIA_HANDLER_NODES_MAP") config :nft_media_handler, enabled?: nmh_enabled?, tmp_dir: "./temp", remote?: nmh_remote?, worker?: nmh_worker?, - nodes_map: nodes_map, + r2_folder: ConfigHelper.parse_path_env_var("NFT_MEDIA_HANDLER_BUCKET_FOLDER"), standalone_media_worker?: nmh_enabled? && nmh_remote? && nmh_worker?, worker_concurrency: ConfigHelper.parse_integer_env_var("NFT_MEDIA_HANDLER_WORKER_CONCURRENCY", 10), worker_batch_size: ConfigHelper.parse_integer_env_var("NFT_MEDIA_HANDLER_WORKER_BATCH_SIZE", 10), @@ -1385,13 +1759,26 @@ config :nft_media_handler, config :nft_media_handler, Indexer.NFTMediaHandler.Backfiller, enabled?: ConfigHelper.parse_bool_env_var("NFT_MEDIA_HANDLER_BACKFILL_ENABLED"), - queue_size: ConfigHelper.parse_integer_env_var("NFT_MEDIA_HANDLER_BACKFILL_QUEUE_SIZE", 1000), + queue_size: ConfigHelper.parse_integer_env_var("NFT_MEDIA_HANDLER_BACKFILL_QUEUE_SIZE", 1_000), enqueue_busy_waiting_timeout: ConfigHelper.parse_time_env_var("NFT_MEDIA_HANDLER_BACKFILL_ENQUEUE_BUSY_WAITING_TIMEOUT", "1s") config :indexer, Indexer.Fetcher.Zilliqa.ScillaSmartContracts.Supervisor, disabled?: ConfigHelper.chain_type() != :zilliqa +config :indexer, Indexer.Fetcher.Zilliqa.Zrc2Tokens.Supervisor, disabled?: ConfigHelper.chain_type() != :zilliqa + +config :libcluster, + topologies: [ + k8sDNS: [ + strategy: Cluster.Strategy.Kubernetes.DNS, + config: [ + service: System.get_env("K8S_SERVICE"), + application_name: "blockscout" + ] + ] + ] + Code.require_file("#{config_env()}.exs", "config/runtime") for config <- "../apps/*/config/runtime/#{config_env()}.exs" |> Path.expand(__DIR__) |> Path.wildcard() do diff --git a/config/runtime/dev.exs b/config/runtime/dev.exs index 28591379ebd7..f2f399c4ce88 100644 --- a/config/runtime/dev.exs +++ b/config/runtime/dev.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant @@ -21,7 +22,7 @@ config :block_scout_web, BlockScoutWeb.Endpoint, ], https: [ port: port + 1, - cipher_suite: :strong, + cipher_suite: :compatible, certfile: System.get_env("CERTFILE") || "priv/cert/selfsigned.pem", keyfile: System.get_env("KEYFILE") || "priv/cert/selfsigned_key.pem" ] @@ -38,7 +39,7 @@ config :block_scout_web, BlockScoutWeb.HealthEndpoint, ], https: [ port: port + 1, - cipher_suite: :strong, + cipher_suite: :compatible, certfile: System.get_env("CERTFILE") || "priv/cert/selfsigned.pem", keyfile: System.get_env("KEYFILE") || "priv/cert/selfsigned_key.pem" ] @@ -65,7 +66,7 @@ queue_target = ConfigHelper.parse_integer_env_var("DATABASE_QUEUE_TARGET", 50) config :explorer, Explorer.Repo, database: database, hostname: hostname, - url: System.get_env("DATABASE_URL"), + url: ConfigHelper.parse_url_env_var("DATABASE_URL"), pool_size: pool_size, queue_target: queue_target @@ -109,6 +110,17 @@ config :explorer, Explorer.Repo.Suave, url: ExplorerConfigHelper.get_suave_db_url(), pool_size: 1 +database_event_notification = if System.get_env("DATABASE_EVENT_URL"), do: nil, else: database +hostname_event_notification = if System.get_env("DATABASE_EVENT_URL"), do: nil, else: hostname + +# Configure Event Notification database +config :explorer, Explorer.Repo.EventNotifications, + database: database_event_notification, + hostname: hostname_event_notification, + url: ExplorerConfigHelper.get_event_notification_db_url(), + pool_size: ConfigHelper.parse_integer_env_var("DATABASE_EVENT_POOL_SIZE", 10), + queue_target: queue_target + # Actually the following repos are not started, and its pool size remains # unused. Separating repos for different CHAIN_TYPE is implemented only for the # sake of keeping DB schema update relevant to the current chain type @@ -121,7 +133,6 @@ for repo <- [ Explorer.Repo.Filecoin, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, @@ -136,7 +147,7 @@ for repo <- [ config :explorer, repo, database: database, hostname: hostname, - url: System.get_env("DATABASE_URL"), + url: ConfigHelper.parse_url_env_var("DATABASE_URL"), pool_size: 1 end diff --git a/config/runtime/prod.exs b/config/runtime/prod.exs index a0a161af6c74..38435f4337b8 100644 --- a/config/runtime/prod.exs +++ b/config/runtime/prod.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant @@ -39,41 +40,75 @@ config :block_scout_web, BlockScoutWeb.HealthEndpoint, pool_size = ConfigHelper.parse_integer_env_var("POOL_SIZE", 50) queue_target = ConfigHelper.parse_integer_env_var("DATABASE_QUEUE_TARGET", 50) +database_url = ConfigHelper.parse_url_env_var("DATABASE_URL") # Configures the database -config :explorer, Explorer.Repo, - url: System.get_env("DATABASE_URL"), - listener_url: System.get_env("DATABASE_EVENT_URL"), - pool_size: pool_size, - ssl: ExplorerConfigHelper.ssl_enabled?(), - queue_target: queue_target +config :explorer, + Explorer.Repo, + [ + url: database_url, + pool_size: pool_size, + queue_target: queue_target + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(database_url)) + +api_db_url = ExplorerConfigHelper.get_api_db_url() # Configures API the database -config :explorer, Explorer.Repo.Replica1, - url: ExplorerConfigHelper.get_api_db_url(), - pool_size: ConfigHelper.parse_integer_env_var("POOL_SIZE_API", 50), - ssl: ExplorerConfigHelper.ssl_enabled?(), - queue_target: queue_target +config :explorer, + Explorer.Repo.Replica1, + [ + url: api_db_url, + pool_size: ConfigHelper.parse_integer_env_var("POOL_SIZE_API", 50), + queue_target: queue_target + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(api_db_url)) + +account_db_url = ExplorerConfigHelper.get_account_db_url() # Configures Account database -config :explorer, Explorer.Repo.Account, - url: ExplorerConfigHelper.get_account_db_url(), - pool_size: ConfigHelper.parse_integer_env_var("ACCOUNT_POOL_SIZE", 50), - ssl: ExplorerConfigHelper.ssl_enabled?(), - queue_target: queue_target +config :explorer, + Explorer.Repo.Account, + [ + url: account_db_url, + pool_size: ConfigHelper.parse_integer_env_var("ACCOUNT_POOL_SIZE", 50), + queue_target: queue_target + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(account_db_url)) + +mud_db_url = ExplorerConfigHelper.get_mud_db_url() # Configures Mud database -config :explorer, Explorer.Repo.Mud, - url: ExplorerConfigHelper.get_mud_db_url(), - pool_size: ConfigHelper.parse_integer_env_var("MUD_POOL_SIZE", 50), - ssl: ExplorerConfigHelper.ssl_enabled?(), - queue_target: queue_target +config :explorer, + Explorer.Repo.Mud, + [ + url: mud_db_url, + pool_size: ConfigHelper.parse_integer_env_var("MUD_POOL_SIZE", 50), + queue_target: queue_target + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(mud_db_url)) + +suave_db_url = ExplorerConfigHelper.get_suave_db_url() # Configures Suave database -config :explorer, Explorer.Repo.Suave, - url: ExplorerConfigHelper.get_suave_db_url(), - pool_size: 1, - ssl: ExplorerConfigHelper.ssl_enabled?() +config :explorer, + Explorer.Repo.Suave, + [ + url: suave_db_url, + pool_size: 1 + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(suave_db_url)) + +event_notification_db_url = ExplorerConfigHelper.get_event_notification_db_url() + +config :explorer, + Explorer.Repo.EventNotifications, + [ + url: event_notification_db_url, + pool_size: ConfigHelper.parse_integer_env_var("DATABASE_EVENT_POOL_SIZE", 10), + queue_target: queue_target + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(event_notification_db_url)) # Actually the following repos are not started, and its pool size remains # unused. Separating repos for different chain type or feature flag is @@ -92,7 +127,6 @@ for repo <- [ Explorer.Repo.Filecoin, Explorer.Repo.Optimism, Explorer.Repo.PolygonEdge, - Explorer.Repo.PolygonZkevm, Explorer.Repo.RSK, Explorer.Repo.Scroll, Explorer.Repo.Shibarium, @@ -101,10 +135,13 @@ for repo <- [ Explorer.Repo.ZkSync, Explorer.Repo.Neon ] do - config :explorer, repo, - url: System.get_env("DATABASE_URL"), - pool_size: 1, - ssl: ExplorerConfigHelper.ssl_enabled?() + config :explorer, + repo, + [ + url: database_url, + pool_size: 1 + ] + |> Keyword.merge(ExplorerConfigHelper.ssl_options(database_url)) end variant = Variant.get() diff --git a/config/runtime/test.exs b/config/runtime/test.exs index 8742c888a7c1..2145290a7871 100644 --- a/config/runtime/test.exs +++ b/config/runtime/test.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config alias EthereumJSONRPC.Variant diff --git a/config/test.exs b/config/test.exs index 279c1e5cd5b8..dc7cca41856e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,13 +1,8 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config +config :explorer, Oban, testing: :manual # Print only warnings and errors during test - -config :logger, :console, level: :warn - -config :logger_json, :backend, level: :none - -config :logger, :ecto, - level: :warn, - path: Path.absname("logs/test/ecto.log") +config :logger, level: :warn config :logger, :error, path: Path.absname("logs/test/error.log") diff --git a/coveralls.json b/coveralls.json deleted file mode 100644 index 19bc74799ebc..000000000000 --- a/coveralls.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "coverage_options": { - "treat_no_relevant_lines_as_covered": true, - "minimum_coverage": 70 - }, - "terminal_options": { - "file_column_width": 120 - } -} diff --git a/cspell.json b/cspell.json index 12c474e39da4..cce70eb5676c 100644 --- a/cspell.json +++ b/cspell.json @@ -4,7 +4,7 @@ // Version of the setting file. Always 0.2 "version": "0.2", // language - current active spelling language - "language": "en", + "language": "en,en-GB", // enabled - enable code editor suggestions "enabled": true, // files - glob patterns of files to be checked @@ -21,10 +21,16 @@ ], "ignoreRegExpList": [ // Ignore filecoin f410f-like native addresses - "f410f[a-z2-7]{39}" + "f410f[a-z2-7]{39}", + // Ignore long base64/base58-style account identifiers (TON addresses, hashes, etc.) + "[A-Za-z0-9+\\-:]{40,}", + // Ignore escaped hex byte sequences inside strings + "\\\\x[0-9a-f]{2,}" ], // words - list of words to be always considered correct "words": [ + "AABB", + "AABBCCDD", "aave", "absname", "acbs", @@ -36,6 +42,7 @@ "AIRTABLE", "Aiubo", "alfajores", + "algs", "alloc", "Alookup", "amzootyukbugmx", @@ -50,6 +57,8 @@ "asda", "Asearch", "Asfpp", + "Astar", + "asyncable", "atoken", "audix", "autodetectfalse", @@ -57,17 +66,21 @@ "autodetecttrue", "Autonity", "autoplay", + "Autoscout", "Averify", "awesomplete", "Backfiller", + "backfillers", "backoff", "badhash", "badnumber", "badpassword", "badrpc", "bafybe", + "bafybeic", "bafybeid", "bafybeig", + "bafybeigdyrzt", "bafybeihxuj", "balancemulti", "benchee", @@ -114,11 +127,13 @@ "capturelog", "cattributes", "CBOR", + "CCDD", "Celestia", "cellspacing", "celo", "certifi", "cfasync", + "cfbljvn", "chainid", "chainlink", "Chainscout", @@ -133,10 +148,14 @@ "childspec", "chromedriver", "citext", + "cidv", "Cldr", "clearfix", "clickover", + "clusterization", + "codecv", "codeformat", + "codegen", "coef", "coeff", "coinprice", @@ -152,10 +171,14 @@ "compilerversion", "concache", "cond", + "conname", + "conrelid", "Consolas", + "contenttype", "contractaddress", "contractaddresses", "contractname", + "convalidated", "cookiejar", "cooldown", "cooltesthost", @@ -175,6 +198,7 @@ "datepicker", "DATETIME", "deae", + "deaffiliated", "decamelize", "decompiled", "decompiler", @@ -203,9 +227,11 @@ "disksup", "Docsify", "docstrings", + "doctest", "dparty", "dropzone", "dxgd", + "dynamicauth", "dyntsrohg", "Ebhwp", "econnrefused", @@ -213,6 +239,8 @@ "EDCSA", "edhygl", "efkuga", + "Eigen", + "EigenDA", "einval", "Encryptor", "endregion", @@ -221,18 +249,22 @@ "epns", "epochrewards", "Erigon", + "erpc", "errora", "errorb", "erts", "ethaccount", "Ethash", + "ethbtc", "etherchain", "ethprice", "ethsupply", "ethsupplyexchange", + "ethusd", "etimedout", "eveem", "evenodd", + "eventname", "evision", "evmversion", "exitor", @@ -257,12 +289,16 @@ "Floki", "fontawesome", "fortawesome", + "froms", "fsym", "fullwidth", + "FULU", "Fuov", "fvdskvjglav", "fwrite", "fwupv", + "fhevm", + "geas", "getabi", "getbalance", "getblockbyhash", @@ -270,6 +306,8 @@ "getblocknobytime", "getblockreward", "getcontractcreation", + "GETDEL", + "getepoch", "getlogs", "getminedblocks", "getsourcecode", @@ -281,7 +319,9 @@ "gettxreceiptstatus", "giga", "Gitter", + "GOKAPI", "goldtoken", + "goqtclhifepvfnicv", "gqz", "granitegrey", "graphiql", @@ -293,6 +333,7 @@ "hardfork", "haspopup", "Hazkne", + "Homomorphic", "healthcheck", "histoday", "hljs", @@ -328,6 +369,8 @@ "inversed", "iolist", "ipfs", + "ipfslink", + "ipfsuid", "ipos", "itxs", "johnnny", @@ -337,6 +380,7 @@ "Karnaugh", "keccak", "Keepalive", + "keyfind", "keyout", "kittencream", "KnxbUejwY", @@ -345,6 +389,7 @@ "lastname", "lastword", "lformat", + "libcluster", "libp", "libraryaddress", "libraryname", @@ -374,16 +419,20 @@ "MDWW", "meck", "meer", + "megaeth", "memsup", "Mendonça", "Menlo", "mergeable", "Merkle", + "merkleize", "metaprogramming", "metatags", + "mfmmvq", "microsecs", "millis", "mintings", + "misestimates", "mistmatches", "miterlimit", "Mixfile", @@ -402,8 +451,11 @@ "mtransfer", "mult", "multicall", + "multicasts", "multichain", + "multiprotocol", "multis", + "multivalued", "munchos", "munknownc", "munknowne", @@ -415,6 +467,7 @@ "Nethermind", "Neue", "newkey", + "nextval", "nftproduct", "ngettext", "nillifies", @@ -424,7 +477,9 @@ "Nodealus", "nohighlight", "nolink", + "nonblocking", "nonconsensus", + "Nonfungible", "nonpending", "noproc", "noreferrer", @@ -434,15 +489,19 @@ "NovesFi", "nowarn", "nowrap", + "nspname", "ntoa", "Numbe", "Nunito", "nxdomain", + "Oban", "OFAC", "offchain", + "ommer", "omni", "onclick", "onconnect", + "ondemand", "ondisconnect", "opos", "Optim", @@ -483,6 +542,7 @@ "Posix", "postgrex", "Postrge", + "postwalk", "predeploy", "prederive", "prederived", @@ -505,11 +565,13 @@ "qwertyuioiuytrewertyuioiuytrertyuio", "racecar", "raisedbrow", + "RANDAO", "rangeright", "raquo", "rarr", "ratiolector", "rbtc", + "rdparty", "rebranded", "recaptcha", "rechunk", @@ -523,6 +585,7 @@ "relid", "relkind", "relname", + "relnamespace", "relpages", "reltuples", "remasc", @@ -530,6 +593,7 @@ "repayer", "reqs", "rerequest", + "rescheduled", "reshows", "Resizer", "retcode", @@ -539,6 +603,8 @@ "reuseaddr", "rollup", "rollups", + "Rotr", + "rotr", "RPC's", "RPCs", "rudimentaries", @@ -564,12 +630,14 @@ "siwe", "SJONRPC", "smallint", + "smallserial", "smth", "snapshotted", "snapshotting", "sobelow", "Sockex", "Sokol", + "solady", "solana", "SOLIDITYSCAN", "soljson", @@ -577,8 +645,10 @@ "sourcecode", "sourcify", "sparkline", + "spex", "splitted", "srcset", + "sslmode", "stabletoken", "staker", "stakers", @@ -606,6 +676,7 @@ "subtraces", "successa", "successb", + "Superchain", "supermajority", "supernet", "sushiswap", @@ -645,8 +716,10 @@ "tsquery", "tsvector", "tsym", + "TTFB", "txid", "txlistinternal", + "txlsit", "Txns", "txpool", "txreceipt", @@ -676,6 +749,7 @@ "unprefixed", "unstaged", "unxswap", + "uploadrequest", "uppercased", "upsert", "upserted", @@ -697,6 +771,7 @@ "verifyproxycontract", "verifysourcecode", "viewerjs", + "vname", "volumefrom", "volumeto", "vyper", @@ -711,8 +786,14 @@ "websockex", "whereis", "whiler", + "WHISTLEBLOWER", + "wighawag", + "WITHDRAWABILITY", "wobserver", "wysdvjkizxonu", + "x-ratelimit-limit", + "x-ratelimit-remaining", + "x-ratelimit-reset", "xact", "xakgj", "xbaddress", @@ -720,6 +801,7 @@ "Xerom", "xffff", "xlevel", + "xinvalid", "xlink", "xmark", "xmlhttprequest", @@ -727,6 +809,7 @@ "xnonsense", "xzzz", "yellowgreen", + "ymlr", "zaphod", "zeppelinos", "zerion", diff --git a/docker-compose/anvil.yml b/docker-compose/anvil.yml index 04cd475a6f18..af91124f099c 100644 --- a/docker-compose/anvil.yml +++ b/docker-compose/anvil.yml @@ -33,7 +33,7 @@ services: ETHEREUM_JSONRPC_WS_URL: ws://host.docker.internal:8545/ INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER: 'true' INDEXER_DISABLE_PENDING_TRANSACTIONS_FETCHER: 'true' - CHAIN_ID: '1337' + CHAIN_ID: '31337' visualizer: extends: @@ -52,7 +52,7 @@ services: file: ./services/frontend.yml service: frontend environment: - NEXT_PUBLIC_NETWORK_ID: '1337' + NEXT_PUBLIC_NETWORK_ID: '31337' NEXT_PUBLIC_NETWORK_RPC_URL: http://host.docker.internal:8545/ stats-db-init: diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 5e594c342681..0d49156ddb43 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -30,14 +30,10 @@ services: context: .. dockerfile: ./docker/Dockerfile args: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 + CHAIN_TYPE: ethereum links: - db:database - environment: - ETHEREUM_JSONRPC_HTTP_URL: http://host.docker.internal:8545/ - ETHEREUM_JSONRPC_TRACE_URL: http://host.docker.internal:8545/ - ETHEREUM_JSONRPC_WS_URL: ws://host.docker.internal:8545/ - CHAIN_ID: '1337' nft_media_handler: depends_on: @@ -49,7 +45,8 @@ services: context: .. dockerfile: ./docker/Dockerfile args: - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 + CHAIN_TYPE: ethereum visualizer: extends: diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index b4aa22c22149..70fe9c36e603 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -4,6 +4,7 @@ ETHEREUM_JSONRPC_HTTP_URL=https://rpc.testnet.xrplevm.org DISABLE_FILE_LOGGING=false DATABASE_URL=postgresql://blockscout:ceWb1MeLBEeOIfk65gU8EjF8@db:5432/blockscout +# DATABASE_EVENT_POOL_SIZE # DATABASE_EVENT_URL= # DATABASE_QUEUE_TARGET # TEST_DATABASE_URL= @@ -18,15 +19,21 @@ ETHEREUM_JSONRPC_TRACE_URL=https://rpc.testnet.xrplevm.org ETHEREUM_JSONRPC_FALLBACK_TRACE_URL=https://full-history-bb325630.testnet.xrplevm.org # ETHEREUM_JSONRPC_ETH_CALL_URL= # ETHEREUM_JSONRPC_FALLBACK_ETH_CALL_URL= -# XRPL EVM Testnet WebSocket endpoint (full-history node) -ETHEREUM_JSONRPC_WS_URL=wss://full-history-bb325630.testnet.xrplevm.org -# Fallback WebSocket endpoint -ETHEREUM_JSONRPC_FALLBACK_WS_URL=wss://full-history-bb325630.testnet.xrplevm.org +# Realtime indexing design: the WebSocket newHeads subscription is the PRIMARY path, and +# the realtime fetcher additionally always schedules HTTP polling (~half the block time) +# as a backstop. So if this WS host is unreachable, indexing keeps up via polling with no +# data loss — WS-primary, polling-on-issue is intentional, not a failure mode. +# Primary WS host (official public testnet endpoint): +ETHEREUM_JSONRPC_WS_URL=wss://ws.testnet.xrplevm.org +# No WS-to-WS fallback host: full-history is HTTP-RPC-only (a WS upgrade returns 405), so +# it is not a valid WS fallback — the HTTP polling backstop above is the fallback instead. +# ETHEREUM_JSONRPC_FALLBACK_WS_URL= # ETHEREUM_JSONRPC_WS_RETRY_INTERVAL= # ETHEREUM_JSONRPC_ARCHIVE_BALANCES_WINDOW=200 # ETHEREUM_JSONRPC_HTTP_TIMEOUT= # ETHEREUM_JSONRPC_HTTP_HEADERS= # ETHEREUM_JSONRPC_HTTP_GZIP_ENABLED= +# ETHEREUM_JSONRPC_HTTP_BATCH_SIZE= # ETHEREUM_JSONRPC_WAIT_PER_TIMEOUT= # ETHEREUM_JSONRPC_GETH_TRACE_BY_BLOCK= # ETHEREUM_JSONRPC_GETH_ALLOW_EMPTY_TRACES= @@ -88,18 +95,25 @@ TOKEN_EXCHANGE_RATE_INTERVAL=60s # MARKET_MOBULA_API_KEY= # MARKET_MOBULA_COIN_ID= # MARKET_MOBULA_SECONDARY_COIN_ID= +# MARKET_DIA_BLOCKCHAIN= +# MARKET_DIA_BASE_URL= +# MARKET_DIA_COIN_ADDRESS_HASH= +# MARKET_DIA_SECONDARY_COIN_ADDRESS_HASH= # MARKET_COIN_FETCHER_ENABLED= # MARKET_COIN_CACHE_PERIOD= # MARKET_TOKENS_FETCHER_ENABLED= # MARKET_TOKENS_INTERVAL= # MARKET_TOKENS_REFETCH_INTERVAL= # MARKET_TOKENS_MAX_BATCH_SIZE= +# TOKEN_LIST_URL= +# TOKEN_LIST_REFETCH_INTERVAL= # MARKET_HISTORY_FETCHER_ENABLED= # MARKET_HISTORY_FETCH_INTERVAL= # MARKET_HISTORY_FIRST_FETCH_DAY_COUNT= POOL_SIZE=80 POOL_SIZE_API=10 -ECTO_USE_SSL=false +# Supported values: disable, allow, prefer, require, verify-ca, verify-full +ECTO_SSL_MODE=disable # DATADOG_HOST= # DATADOG_PORT= # SPANDEX_BATCH_SIZE= @@ -130,38 +144,55 @@ TRACE_FIRST_BLOCK=4900000 # CACHE_ADDRESS_TRANSACTIONS_COUNTER_PERIOD=3600 # CACHE_ADDRESS_TOKENS_USD_SUM_PERIOD=3600 # CACHE_ADDRESS_TOKEN_TRANSFERS_COUNTER_PERIOD=3600 +# CACHE_HOT_SMART_CONTRACTS_5M_PERIOD=30s +# CACHE_HOT_SMART_CONTRACTS_1H_PERIOD=6m +# CACHE_HOT_SMART_CONTRACTS_3H_PERIOD=18m # CACHE_TRANSACTIONS_24H_STATS_PERIOD= # CACHE_FRESH_PENDING_TRANSACTIONS_COUNTER_PERIOD= +# CACHE_PENDING_OPERATIONS_COUNT_PERIOD= +# PENDING_OPERATIONS_HELPER_TRANSACTIONS_BATCH_SIZE=1000 +# PENDING_OPERATIONS_HELPER_BLOCKS_BATCH_SIZE=10 # TOKEN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD= # COIN_BALANCE_ON_DEMAND_FETCHER_THRESHOLD= # CONTRACT_CODE_ON_DEMAND_FETCHER_THRESHOLD= # TOKEN_INSTANCE_METADATA_REFETCH_ON_DEMAND_FETCHER_THRESHOLD= # ADDRESSES_TABS_COUNTERS_TTL=10m -# TOKEN_METADATA_UPDATE_INTERVAL=172800 -# CONTRACT_VERIFICATION_ALLOWED_SOLIDITY_EVM_VERSIONS=homestead,tangerineWhistle,spuriousDragon,byzantium,constantinople,petersburg,istanbul,berlin,london,paris,shanghai,cancun,default -# CONTRACT_VERIFICATION_ALLOWED_VYPER_EVM_VERSIONS=byzantium,constantinople,petersburg,istanbul,berlin,paris,shanghai,cancun,default +# TOKEN_METADATA_UPDATE_INTERVAL=48h +# TOKEN_COUNTERS_UPDATE_INTERVAL=3h +# CONTRACT_VERIFICATION_ALLOWED_SOLIDITY_EVM_VERSIONS=homestead,tangerineWhistle,spuriousDragon,byzantium,constantinople,petersburg,istanbul,berlin,london,paris,shanghai,cancun,prague,osaka,default +# CONTRACT_VERIFICATION_ALLOWED_VYPER_EVM_VERSIONS=byzantium,constantinople,petersburg,istanbul,berlin,paris,shanghai,cancun,osaka,default # CONTRACT_VERIFICATION_MAX_LIBRARIES=10 # CONTRACT_AUDIT_REPORTS_AIRTABLE_URL= # CONTRACT_AUDIT_REPORTS_AIRTABLE_API_KEY= # CONTRACT_CERTIFIED_LIST= # CONTRACT_ENABLE_PARTIAL_REVERIFICATION= # UNCLES_IN_AVERAGE_BLOCK_TIME=false +# DISABLE_BLOCK_BROADCAST_ENRICHMENT=false # DISABLE_WEBAPP=true ADMIN_PANEL_ENABLED=false # API_V2_ENABLED=true +# DISABLE_BLOCKS_BENS_PRELOAD=false +# DISABLE_TRANSACTIONS_BENS_PRELOAD=false +# DISABLE_TOKEN_TRANSFERS_BENS_PRELOAD=false API_V1_READ_METHODS_DISABLED=false API_V1_WRITE_METHODS_DISABLED=false # API_RATE_LIMIT_DISABLED=true # API_SENSITIVE_ENDPOINTS_KEY= -# API_RATE_LIMIT_TIME_INTERVAL=1s -# API_RATE_LIMIT_BY_IP_TIME_INTERVAL=5m +# API_RATE_LIMIT_BY_KEY_TIME_INTERVAL=1s +# API_RATE_LIMIT_BY_WHITELISTED_IP_TIME_INTERVAL=1s +# API_RATE_LIMIT_UI_V2_WITH_TOKEN_TIME_INTERVAL=1s +# API_RATE_LIMIT_BY_ACCOUNT_API_KEY_TIME_INTERVAL=1s +# API_RATE_LIMIT_BY_IP_TIME_INTERVAL=1m # API_RATE_LIMIT=50 # API_RATE_LIMIT_BY_KEY=10 -# API_RATE_LIMIT_BY_WHITELISTED_IP=50 +# API_RATE_LIMIT_BY_WHITELISTED_IP=25 # API_RATE_LIMIT_WHITELISTED_IPS= # API_RATE_LIMIT_STATIC_API_KEY= # API_RATE_LIMIT_UI_V2_WITH_TOKEN=5 -# API_RATE_LIMIT_BY_IP=3000 +# API_RATE_LIMIT_BY_IP=300 +# API_RATE_LIMIT_CONFIG_URL= +# API_RATE_LIMIT_REMOTE_IP_HEADERS= +# API_RATE_LIMIT_REMOTE_IP_KNOWN_PROXIES= # API_NO_RATE_LIMIT_API_KEY= # API_GRAPHQL_ENABLED= # API_GRAPHQL_MAX_COMPLEXITY= @@ -174,15 +205,25 @@ API_V1_WRITE_METHODS_DISABLED=false # API_GRAPHQL_RATE_LIMIT_BY_IP= # API_GRAPHQL_RATE_LIMIT_BY_IP_TIME_INTERVAL= # API_GRAPHQL_RATE_LIMIT_STATIC_API_KEY= +# API_DISABLE_CONTRACT_CREATION_INTERNAL_TRANSACTION_ASSOCIATION=false +# RATE_LIMITER_REDIS_URL= +# RATE_LIMITER_REDIS_SSL_ENABLED= +# RATE_LIMITER_REDIS_SENTINEL_URLS= +# RATE_LIMITER_REDIS_SENTINEL_MASTER_NAME= +# RATE_LIMITER_ON_DEMAND_TIME_INTERVAL= +# RATE_LIMITER_ON_DEMAND_LIMIT_BY_IP= +# RATE_LIMITER_ON_DEMAND_EXPONENTIAL_TIMEOUT_COEFF= +# RATE_LIMITER_ON_DEMAND_MAX_BAN_INTERVAL= +# RATE_LIMITER_ON_DEMAND_LIMITATION_PERIOD= # DISABLE_INDEXER=false # DISABLE_REALTIME_INDEXER=false # DISABLE_CATCHUP_INDEXER=false # INDEXER_DISABLE_ADDRESS_COIN_BALANCE_FETCHER=false +# INDEXER_DISABLE_ARCHIVAL_TOKEN_BALANCES_FETCHER=false # INDEXER_DISABLE_TOKEN_INSTANCE_REALTIME_FETCHER=false # INDEXER_DISABLE_TOKEN_INSTANCE_RETRY_FETCHER=false # INDEXER_DISABLE_TOKEN_INSTANCE_SANITIZE_FETCHER=false # INDEXER_DISABLE_TOKEN_INSTANCE_REFETCH_FETCHER=false -# INDEXER_PENDING_TRANSACTIONS_SANITIZER_INTERVAL= # INDEXER_DISABLE_PENDING_TRANSACTIONS_FETCHER=false INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER=false # INDEXER_DISABLE_CATALOGED_TOKEN_UPDATER_FETCHER= @@ -190,6 +231,12 @@ INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER=false # INDEXER_DISABLE_EMPTY_BLOCKS_SANITIZER= # INDEXER_DISABLE_WITHDRAWALS_FETCHER= # INDEXER_DISABLE_REPLACED_TRANSACTION_FETCHER= +# INDEXER_DISABLE_TOKEN_INSTANCE_ERC_1155_SANITIZE_FETCHER=false +# INDEXER_DISABLE_TOKEN_INSTANCE_ERC_721_SANITIZE_FETCHER=false +# INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_FETCHER= +# INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_FETCHER= +# INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_FETCHER= +# INDEXER_DISABLE_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_FETCHER= # INDEXER_CATCHUP_BLOCKS_BATCH_SIZE= # INDEXER_CATCHUP_BLOCKS_CONCURRENCY= # INDEXER_CATCHUP_BLOCK_INTERVAL= @@ -200,6 +247,8 @@ INDEXER_INTERNAL_TRANSACTIONS_BATCH_SIZE=1 INDEXER_INTERNAL_TRANSACTIONS_CONCURRENCY=1 INDEXER_BLOCK_REWARD_BATCH_SIZE=1 INDEXER_BLOCK_REWARD_CONCURRENCY=1 +# INDEXER_PENDING_TRANSACTIONS_SANITIZER_INTERVAL= +# INDEXER_PENDING_TRANSACTIONS_WINDOW_SIZE= # INDEXER_TOKEN_INSTANCE_USE_BASE_URI_RETRY= # INDEXER_TOKEN_INSTANCE_RETRY_REFETCH_INTERVAL= # INDEXER_TOKEN_INSTANCE_RETRY_BATCH_SIZE=10 @@ -213,47 +262,47 @@ INDEXER_BLOCK_REWARD_CONCURRENCY=1 # INDEXER_TOKEN_INSTANCE_CIDR_BLACKLIST= # INDEXER_TOKEN_INSTANCE_HOST_FILTERING_ENABLED= # INDEXER_TOKEN_INSTANCE_ALLOWED_URI_PROTOCOLS= -# INDEXER_DISABLE_TOKEN_INSTANCE_ERC_1155_SANITIZE_FETCHER=false -# INDEXER_DISABLE_TOKEN_INSTANCE_ERC_721_SANITIZE_FETCHER=false +# INDEXER_TOKEN_TRANSFER_BLOCK_CONSENSUS_SANITIZER_INTERVAL=20m +# INDEXER_SIGNED_AUTHORIZATION_STATUS_BATCH_SIZE= +# INDEXER_FHE_OPERATIONS_ENABLED=false +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_BATCH_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_CONCURRENCY= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_MAX_QUEUE_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_MAIN_QUEUE_INIT_QUERY_LIMIT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_BATCH_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_CONCURRENCY= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_MAX_QUEUE_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_BALANCES_QUEUE_INIT_QUERY_LIMIT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_BATCH_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_CONCURRENCY= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_MAX_QUEUE_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_TOKEN_INFO_QUEUE_INIT_QUERY_LIMIT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_BATCH_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_CONCURRENCY= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_MAX_QUEUE_SIZE= +# INDEXER_MULTICHAIN_SEARCH_DB_EXPORT_COUNTERS_QUEUE_INIT_QUERY_LIMIT= +# INDEXER_INTERNAL_TRANSACTION_DELETE_QUEUE_BATCH_SIZE= +# INDEXER_INTERNAL_TRANSACTION_DELETE_QUEUE_CONCURRENCY= +# INDEXER_INTERNAL_TRANSACTION_DELETE_QUEUE_THRESHOLD= INDEXER_COIN_BALANCES_BATCH_SIZE=1 INDEXER_COIN_BALANCES_CONCURRENCY=1 INDEXER_RECEIPTS_BATCH_SIZE=1 INDEXER_RECEIPTS_CONCURRENCY=1 INDEXER_TOKEN_CONCURRENCY=1 -INDEXER_TOKEN_BALANCES_BATCH_SIZE=1 -INDEXER_TOKEN_BALANCES_CONCURRENCY=1 -# INDEXER_TOKEN_BALANCES_MAX_REFETCH_INTERVAL= -# INDEXER_TOKEN_BALANCES_EXPONENTIAL_TIMEOUT_COEFF= -# INDEXER_TX_ACTIONS_ENABLE= -# INDEXER_TX_ACTIONS_MAX_TOKEN_CACHE_SIZE= -# INDEXER_TX_ACTIONS_REINDEX_FIRST_BLOCK= -# INDEXER_TX_ACTIONS_REINDEX_LAST_BLOCK= -# INDEXER_TX_ACTIONS_REINDEX_PROTOCOLS= -# INDEXER_TX_ACTIONS_AAVE_V3_POOL_CONTRACT= -# INDEXER_POLYGON_EDGE_L1_RPC= -# INDEXER_POLYGON_EDGE_L1_EXIT_HELPER_CONTRACT= -# INDEXER_POLYGON_EDGE_L1_WITHDRAWALS_START_BLOCK= -# INDEXER_POLYGON_EDGE_L1_STATE_SENDER_CONTRACT= -# INDEXER_POLYGON_EDGE_L1_DEPOSITS_START_BLOCK= -# INDEXER_POLYGON_EDGE_L2_STATE_SENDER_CONTRACT= -# INDEXER_POLYGON_EDGE_L2_WITHDRAWALS_START_BLOCK= -# INDEXER_POLYGON_EDGE_L2_STATE_RECEIVER_CONTRACT= -# INDEXER_POLYGON_EDGE_L2_DEPOSITS_START_BLOCK= -# INDEXER_POLYGON_EDGE_ETH_GET_LOGS_RANGE_SIZE= -# INDEXER_POLYGON_ZKEVM_BATCHES_ENABLED= -# INDEXER_POLYGON_ZKEVM_BATCHES_CHUNK_SIZE= -# INDEXER_POLYGON_ZKEVM_BATCHES_RECHECK_INTERVAL= -# INDEXER_POLYGON_ZKEVM_L1_RPC= -# INDEXER_POLYGON_ZKEVM_L1_BRIDGE_START_BLOCK= -# INDEXER_POLYGON_ZKEVM_L1_BRIDGE_CONTRACT= -# INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NATIVE_SYMBOL= -# INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NATIVE_DECIMALS= -# INDEXER_POLYGON_ZKEVM_L1_BRIDGE_NETWORK_ID= -# INDEXER_POLYGON_ZKEVM_L1_BRIDGE_ROLLUP_INDEX= -# INDEXER_POLYGON_ZKEVM_L2_BRIDGE_START_BLOCK= -# INDEXER_POLYGON_ZKEVM_L2_BRIDGE_CONTRACT= -# INDEXER_POLYGON_ZKEVM_L2_BRIDGE_NETWORK_ID= -# INDEXER_POLYGON_ZKEVM_L2_BRIDGE_ROLLUP_INDEX= +INDEXER_ARCHIVAL_TOKEN_BALANCES_BATCH_SIZE=1 +INDEXER_ARCHIVAL_TOKEN_BALANCES_CONCURRENCY=1 +# INDEXER_ARCHIVAL_TOKEN_BALANCES_MAX_REFETCH_INTERVAL= +# INDEXER_ARCHIVAL_TOKEN_BALANCES_EXPONENTIAL_TIMEOUT_COEFF= +INDEXER_CURRENT_TOKEN_BALANCES_BATCH_SIZE=1 +INDEXER_CURRENT_TOKEN_BALANCES_CONCURRENCY=1 +# INDEXER_TOKEN_BALANCES_IMPORT_CHUNK_SIZE=50 +# INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_ENABLED=true +# INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_INTERVAL= +# INDEXER_DB_EVENT_NOTIFICATIONS_CLEANUP_MAX_AGE= # INDEXER_ZKSYNC_BATCHES_ENABLED= # INDEXER_ZKSYNC_BATCHES_CHUNK_SIZE= # INDEXER_ZKSYNC_NEW_BATCHES_MAX_RANGE= @@ -280,6 +329,8 @@ INDEXER_TOKEN_BALANCES_CONCURRENCY=1 # INDEXER_ARBITRUM_BATCHES_TRACKING_FAILURE_THRESHOLD= # INDEXER_ARBITRUM_BRIDGE_MESSAGES_TRACKING_ENABLED= # INDEXER_ARBITRUM_TRACKING_MESSAGES_ON_L1_RECHECK_INTERVAL= +# INDEXER_ARBITRUM_MESSAGES_TRACKING_FAILURE_THRESHOLD= +# INDEXER_ARBITRUM_MISSED_MESSAGE_IDS_RANGE= # INDEXER_ARBITRUM_MISSED_MESSAGES_RECHECK_INTERVAL= # INDEXER_ARBITRUM_MISSED_MESSAGES_BLOCKS_DEPTH= # INDEXER_ARBITRUM_DATA_BACKFILL_ENABLED= @@ -292,6 +343,11 @@ INDEXER_TOKEN_BALANCES_CONCURRENCY=1 # INDEXER_OPTIMISM_L1_BATCH_INBOX= # INDEXER_OPTIMISM_L1_BATCH_SUBMITTER= # INDEXER_OPTIMISM_L1_BATCH_BLOCKS_CHUNK_SIZE= +# INDEXER_OPTIMISM_L1_BATCH_BLOCKSCOUT_BLOBS_API_URL= +# INDEXER_OPTIMISM_L1_BATCH_CELESTIA_BLOBS_API_URL= +# INDEXER_OPTIMISM_L1_BATCH_EIGENDA_BLOBS_API_URL= +# INDEXER_OPTIMISM_L1_BATCH_EIGENDA_PROXY_BASE_URL= +# INDEXER_OPTIMISM_L1_BATCH_ALT_DA_SERVER_URL= # INDEXER_OPTIMISM_L2_BATCH_GENESIS_BLOCK_NUMBER= # INDEXER_OPTIMISM_BLOCK_DURATION= # INDEXER_OPTIMISM_L1_OUTPUT_ORACLE_CONTRACT= @@ -302,6 +358,8 @@ INDEXER_TOKEN_BALANCES_CONCURRENCY=1 # INDEXER_OPTIMISM_L2_ETH_GET_LOGS_RANGE_SIZE= # INDEXER_OPTIMISM_L2_HOLOCENE_TIMESTAMP= # INDEXER_OPTIMISM_L2_HOLOCENE_BLOCKS_CHUNK_SIZE= +# INDEXER_OPTIMISM_L2_ISTHMUS_TIMESTAMP= +# INDEXER_OPTIMISM_L2_JOVIAN_TIMESTAMP= # INDEXER_OPTIMISM_L2_INTEROP_START_BLOCK= # INDEXER_OPTIMISM_L2_INTEROP_BLOCKS_CHUNK_SIZE= # INDEXER_OPTIMISM_CHAINSCOUT_API_URL= @@ -310,6 +368,13 @@ INDEXER_TOKEN_BALANCES_CONCURRENCY=1 # INDEXER_OPTIMISM_INTEROP_CONNECT_TIMEOUT= # INDEXER_OPTIMISM_INTEROP_RECV_TIMEOUT= # INDEXER_OPTIMISM_INTEROP_EXPORT_EXPIRATION_DAYS= +# INDEXER_OPTIMISM_MULTICHAIN_BATCH_SIZE= +# INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_CONCURRENCY= +# INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_BATCH_SIZE= +# INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_ENQUEUE_BUSY_WAITING_TIMEOUT= +# INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_MAX_QUEUE_SIZE= +# INDEXER_OPTIMISM_OPERATOR_FEE_QUEUE_INIT_QUERY_LIMIT= +# INDEXER_DISABLE_OPTIMISM_INTEROP_MULTICHAIN_EXPORT= # INDEXER_SCROLL_L1_RPC= # INDEXER_SCROLL_L1_MESSENGER_CONTRACT= # INDEXER_SCROLL_L1_MESSENGER_START_BLOCK= @@ -328,11 +393,21 @@ INDEXER_TOKEN_BALANCES_CONCURRENCY=1 # SCROLL_L1_BLOB_SCALAR_INIT= # SCROLL_L1_BASE_FEE_INIT= # SCROLL_L1_BLOB_BASE_FEE_INIT= +# INDEXER_ARC_NATIVE_TOKEN_DECIMALS= +# INDEXER_ARC_NATIVE_TOKEN_CONTRACT= +# INDEXER_ARC_NATIVE_TOKEN_SYSTEM_CONTRACT= # CELO_CORE_CONTRACTS= # CELO_L2_MIGRATION_BLOCK= +# CELO_EPOCH_MANAGER_CONTRACT= +# CELO_UNRELEASED_TREASURY_CONTRACT= +# CELO_VALIDATORS_CONTRACT= +# CELO_LOCKED_GOLD_CONTRACT= +# CELO_ACCOUNTS_CONTRACT= # INDEXER_CELO_VALIDATOR_GROUP_VOTES_BATCH_SIZE=200000 # INDEXER_DISABLE_CELO_EPOCH_FETCHER=false # INDEXER_DISABLE_CELO_VALIDATOR_GROUP_VOTES_FETCHER=false +# INDEXER_CELO_ACCOUNTS_CONCURRENCY=1 +# INDEXER_CELO_ACCOUNTS_BATCH_SIZE=100 # BERYX_API_TOKEN= # BERYX_API_BASE_URL= # FILECOIN_NETWORK_PREFIX=f @@ -341,6 +416,8 @@ INDEXER_TOKEN_BALANCES_CONCURRENCY=1 # INDEXER_REALTIME_FETCHER_MAX_GAP= # INDEXER_REALTIME_FETCHER_POLLING_PERIOD= # INDEXER_FETCHER_INIT_QUERY_LIMIT= +# INDEXER_FETCHER_INIT_DELAY= +# INDEXER_MASSIVE_BLOCK_THRESHOLD= # INDEXER_TOKEN_BALANCES_FETCHER_INIT_QUERY_LIMIT= # INDEXER_COIN_BALANCES_FETCHER_INIT_QUERY_LIMIT= # INDEXER_GRACEFUL_SHUTDOWN_PERIOD= @@ -363,8 +440,19 @@ INDEXER_DISABLE_BEACON_BLOB_FETCHER=true # INDEXER_BEACON_BLOB_FETCHER_REFERENCE_TIMESTAMP=1702824023 # INDEXER_BEACON_BLOB_FETCHER_START_BLOCK=19200000 # INDEXER_BEACON_BLOB_FETCHER_END_BLOCK=0 +INDEXER_DISABLE_BEACON_DEPOSIT_FETCHER=true +# INDEXER_BEACON_DEPOSIT_FETCHER_INTERVAL= +# INDEXER_BEACON_DEPOSIT_FETCHER_BATCH_SIZE= +INDEXER_DISABLE_BEACON_DEPOSIT_STATUS_FETCHER=true +# INDEXER_BEACON_DEPOSIT_STATUS_FETCHER_EPOCH_DURATION= +# INDEXER_BEACON_DEPOSIT_STATUS_FETCHER_REFERENCE_TIMESTAMP # MISSING_BALANCE_OF_TOKENS_WINDOW_SIZE= # INDEXER_INTERNAL_TRANSACTIONS_TRACER_TYPE= +# INDEXER_DISABLE_HOT_CONTRACTS_FETCHER= +# TOKEN_BALANCE_ON_DEMAND_FETCHER_BATCH_SIZE= +# TOKEN_BALANCE_ON_DEMAND_FETCHER_CONCURRENCY= +# TOKEN_BALANCE_ON_DEMAND_FETCHER_ADDRESS_QUEUE_BATCH_SIZE= +# TOKEN_BALANCE_ON_DEMAND_FETCHER_ADDRESS_QUEUE_FLUSH_INTERVAL= # WEBAPP_URL= # API_URL= # CHECKSUM_ADDRESS_HASHES=true @@ -387,7 +475,7 @@ COIN_BALANCE_HISTORY_DAYS=90 # ADDRESSES_BLACKLIST_UPDATE_INTERVAL= # ADDRESSES_BLACKLIST_RETRY_INTERVAL= # ADDRESSES_BLACKLIST_PROVIDER= -# XRPL EVM Testnet Chain ID (0x161778 = 1449000) +# XRPL EVM Testnet Chain ID (0x161c28 = 1449000) CHAIN_ID=1449000 # HIDE_SCAM_ADDRESSES= # RE_CAPTCHA_SECRET_KEY= @@ -398,12 +486,17 @@ RE_CAPTCHA_DISABLED=false # RE_CAPTCHA_BYPASS_TOKEN # RE_CAPTCHA_TOKEN_INSTANCE_REFETCH_METADATA_SCOPED_BYPASS_TOKEN= # API_RATE_LIMIT_HAMMER_REDIS_URL=redis://redis-db:6379/1 +# API_RATE_LIMIT_HAMMER_REDIS_SSL_ENABLED= +# API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_URLS= +# API_RATE_LIMIT_HAMMER_REDIS_SENTINEL_MASTER_NAME= # API_RATE_LIMIT_IS_BLOCKSCOUT_BEHIND_PROXY=false # API_RATE_LIMIT_UI_V2_TOKEN_TTL_IN_SECONDS=18000 # FETCH_REWARDS_WAY=trace_block +# INDEXER_ENABLE_PARTIAL_ASYNC_IMPORT= # MICROSERVICE_SC_VERIFIER_ENABLED=true # MICROSERVICE_SC_VERIFIER_URL=http://smart-contract-verifier:8050/ # MICROSERVICE_SC_VERIFIER_TYPE=sc_verifier +# MICROSERVICE_SC_VERIFIER_API_KEY= MICROSERVICE_SC_VERIFIER_TYPE=eth_bytecode_db # MICROSERVICE_ETH_BYTECODE_DB_INTERVAL_BETWEEN_LOOKUPS=10m # MICROSERVICE_ETH_BYTECODE_DB_MAX_LOOKUPS_CONCURRENCY=10 @@ -413,6 +506,7 @@ MICROSERVICE_SIG_PROVIDER_ENABLED=true MICROSERVICE_SIG_PROVIDER_URL=http://sig-provider:8050/ # MICROSERVICE_BENS_URL= # MICROSERVICE_BENS_ENABLED= +# MICROSERVICE_BENS_PROTOCOLS= # MICROSERVICE_ACCOUNT_ABSTRACTION_ENABLED=false MICROSERVICE_ACCOUNT_ABSTRACTION_URL=http://user-ops-indexer:8050/ # MICROSERVICE_METADATA_URL= @@ -420,6 +514,12 @@ MICROSERVICE_ACCOUNT_ABSTRACTION_URL=http://user-ops-indexer:8050/ # MICROSERVICE_METADATA_PROXY_REQUESTS_TIMEOUT= # MICROSERVICE_STYLUS_VERIFIER_URL= # MICROSERVICE_MULTICHAIN_SEARCH_URL= +# MICROSERVICE_MULTICHAIN_SEARCH_API_KEY= +# MICROSERVICE_MULTICHAIN_SEARCH_ADDRESSES_CHUNK_SIZE= +# MICROSERVICE_MULTICHAIN_SEARCH_TOKEN_INFO_CHUNK_SIZE= +# MICROSERVICE_MULTICHAIN_SEARCH_COUNTERS_CHUNK_SIZE= +# MICROSERVICE_TAC_OPERATION_LIFECYCLE_ENABLED= +# MICROSERVICE_TAC_OPERATION_LIFECYCLE_URL= DECODE_NOT_A_CONTRACT_CALLS=true # DATABASE_READ_ONLY_API_URL= # ACCOUNT_DATABASE_URL= @@ -427,20 +527,31 @@ DECODE_NOT_A_CONTRACT_CALLS=true # ACCOUNT_AUTH0_DOMAIN= # ACCOUNT_AUTH0_CLIENT_ID= # ACCOUNT_AUTH0_CLIENT_SECRET= -# ACCOUNT_PUBLIC_TAGS_AIRTABLE_URL= -# ACCOUNT_PUBLIC_TAGS_AIRTABLE_API_KEY= +# ACCOUNT_AUTH0_APPLICATION_ID= # ACCOUNT_SENDGRID_API_KEY= # ACCOUNT_SENDGRID_SENDER= # ACCOUNT_SENDGRID_TEMPLATE= +# ACCOUNT_SENDGRID_OTP_TEMPLATE= # ACCOUNT_VERIFICATION_EMAIL_RESEND_INTERVAL= # ACCOUNT_OTP_RESEND_INTERVAL= # ACCOUNT_PRIVATE_TAGS_LIMIT=2000 # ACCOUNT_WATCHLIST_ADDRESSES_LIMIT=15 # ACCOUNT_SIWE_MESSAGE= +# ACCOUNT_DYNAMIC_ENV_ID= +# ACCOUNT_KEYCLOAK_DOMAIN= +# ACCOUNT_KEYCLOAK_REALM= +# ACCOUNT_KEYCLOAK_CLIENT_ID= +# ACCOUNT_KEYCLOAK_CLIENT_SECRET= +# ACCOUNT_KEYCLOAK_EMAIL_WEBHOOK_URL= ACCOUNT_CLOAK_KEY= ACCOUNT_ENABLED=true ACCOUNT_REDIS_URL=redis://redis-db:6379 +# ACCOUNT_REDIS_SSL_ENABLED= +# ACCOUNT_REDIS_SENTINEL_URLS= +# ACCOUNT_REDIS_SENTINEL_MASTER_NAME= # EIP_1559_ELASTICITY_MULTIPLIER=2 +# EIP_1559_BASE_FEE_MAX_CHANGE_DENOMINATOR=8 +# EIP_1559_BASE_FEE_LOWER_BOUND_WEI=0 # IPFS_GATEWAY_URL= # IPFS_GATEWAY_URL_PARAM_KEY= # IPFS_GATEWAY_URL_PARAM_VALUE= @@ -474,6 +585,10 @@ MIGRATION_REFETCH_CONTRACT_CODES_CONCURRENCY=1 # MIGRATION_REINDEX_INTERNAL_TRANSACTIONS_STATUS_BATCH_SIZE= # MIGRATION_REINDEX_INTERNAL_TRANSACTIONS_STATUS_CONCURRENCY= # MIGRATION_REINDEX_INTERNAL_TRANSACTIONS_STATUS_TIMEOUT= +# MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_BATCH_SIZE= +# MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_CONCURRENCY= +# MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_TIMEOUT= +# MIGRATION_REINDEX_BLOCKS_WITH_MISSING_TRANSACTIONS_ENABLED= # MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_BATCH_SIZE= # MIGRATION_SHRINK_INTERNAL_TRANSACTIONS_CONCURRENCY= # MIGRATION_ARBITRUM_DA_RECORDS_NORMALIZATION_BATCH_SIZE= @@ -498,6 +613,17 @@ MIGRATION_REFETCH_CONTRACT_CODES_CONCURRENCY=1 # MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_TIMEOUT= # MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_BLOCKS_BATCH_SIZE= # MIGRATION_RECOVERY_WETH_TOKEN_TRANSFERS_HIGH_VERBOSITY= +# MIGRATION_MERGE_ADJACENT_MISSING_BLOCK_RANGES_BATCH_SIZE= +# MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_ENABLED= +# MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_BATCH_SIZE= +# MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_STORAGE_PERIOD= +# MIGRATION_DELETE_ZERO_VALUE_INTERNAL_TRANSACTIONS_CHECK_INTERVAL= +# MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_BATCH_SIZE= +# MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_CONCURRENCY= +# MIGRATION_FILL_INTERNAL_TRANSACTIONS_ADDRESS_IDS_TIMEOUT= +# MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_BATCH_SIZE= +# MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_CONCURRENCY= +# MIGRATION_EMPTY_INTERNAL_TRANSACTIONS_DATA_TIMEOUT= # HEALTH_MONITOR_CHECK_INTERVAL=1m # HEALTH_MONITOR_BLOCKS_PERIOD=5m # HEALTH_MONITOR_BATCHES_PERIOD=4h @@ -525,10 +651,29 @@ MIGRATION_REFETCH_CONTRACT_CODES_CONCURRENCY=1 # WHITELISTED_WETH_CONTRACTS= # PUBLIC_METRICS_ENABLED= # PUBLIC_METRICS_UPDATE_PERIOD_HOURS= +# INDEXER_METRICS_ENABLED= +# INDEXER_METRICS_ENABLED_TOKEN_INSTANCES_NOT_UPLOADED_TO_CDN_COUNT= +# INDEXER_METRICS_ENABLED_FAILED_TOKEN_INSTANCES_METADATA_COUNT= +# INDEXER_METRICS_ENABLED_UNFETCHED_TOKEN_INSTANCES_COUNT= +# INDEXER_METRICS_ENABLED_MISSING_CURRENT_TOKEN_BALANCES_COUNT= +# INDEXER_METRICS_ENABLED_MISSING_ARCHIVAL_TOKEN_BALANCES_COUNT= +# INDEXER_REALTIME_METRICS_ENABLED= # CSV_EXPORT_LIMIT= +# CSV_EXPORT_ASYNC_ENABLED= +# CSV_EXPORT_ASYNC_MAX_PENDING_TASKS_PER_IP=3 +# CSV_EXPORT_ASYNC_UPLOAD_CHUNK_SIZE=47185920 +# CSV_EXPORT_DB_TIMEOUT= +# CSV_EXPORT_ASYNC_TMP_DIR=/app/tmp +# CSV_EXPORT_ASYNC_GOKAPI_URL= +# CSV_EXPORT_ASYNC_GOKAPI_API_KEY= +# CSV_EXPORT_ASYNC_GOKAPI_TIMEOUT= +# CSV_EXPORT_ASYNC_GOKAPI_UPLOAD_EXPIRY_DAYS=1 +# CSV_EXPORT_ASYNC_GOKAPI_UPLOAD_ALLOWED_DOWNLOADS=1 +# CSV_EXPORT_ASYNC_OBAN_CONCURRENCY=10 # SHRINK_INTERNAL_TRANSACTIONS_ENABLED= NFT_MEDIA_HANDLER_ENABLED=true NFT_MEDIA_HANDLER_REMOTE_DISPATCHER_NODE_MODE_ENABLED=true +NFT_MEDIA_HANDLER_BUCKET_FOLDER=/folder_1 RELEASE_NODE=producer@172.18.0.4 RELEASE_DISTRIBUTION=name RELEASE_COOKIE=secret_cookie @@ -537,6 +682,7 @@ RELEASE_COOKIE=secret_cookie # NFT_MEDIA_HANDLER_BACKFILL_QUEUE_SIZE= # NFT_MEDIA_HANDLER_BACKFILL_ENQUEUE_BUSY_WAITING_TIMEOUT= # NFT_MEDIA_HANDLER_CACHE_UNIQUENESS_MAX_SIZE= +# K8S_SERVICE=blockscout-headless # old UI vars @@ -568,6 +714,7 @@ RELEASE_COOKIE=secret_cookie # CONTRACT_MAX_STRING_LENGTH_WITHOUT_TRIMMING=2040 # CONTRACT_DISABLE_INTERACTION= # HIDE_BLOCK_MINER=false +# BLOCK_MINER_GETS_BURNT_FEES=false # DISPLAY_TOKEN_ICONS=false # MIXPANEL_TOKEN= # MIXPANEL_URL= @@ -576,3 +723,5 @@ RELEASE_COOKIE=secret_cookie # NETWORK_PATH=/ # RE_CAPTCHA_CLIENT_KEY= # RE_CAPTCHA_V3_CLIENT_KEY= +# HACKNEY_DEFAULT_POOL_SIZE=1000 +# UNIVERSAL_PROXY_CONFIG_URL= diff --git a/docker-compose/envs/common-nft-media-handler.env b/docker-compose/envs/common-nft-media-handler.env index fd68542cf31b..aed0aef14ccc 100644 --- a/docker-compose/envs/common-nft-media-handler.env +++ b/docker-compose/envs/common-nft-media-handler.env @@ -8,7 +8,6 @@ NFT_MEDIA_HANDLER_ENABLED=true NFT_MEDIA_HANDLER_REMOTE_DISPATCHER_NODE_MODE_ENABLED=true NFT_MEDIA_HANDLER_IS_WORKER=true -NFT_MEDIA_HANDLER_NODES_MAP="{\"producer@172.18.0.4\": \"/folder_1\"}" # NFT_MEDIA_HANDLER_WORKER_CONCURRENCY= # NFT_MEDIA_HANDLER_WORKER_BATCH_SIZE= # NFT_MEDIA_HANDLER_WORKER_SPAWN_TASKS_TIMEOUT= diff --git a/docker-compose/envs/common-smart-contract-verifier.env b/docker-compose/envs/common-smart-contract-verifier.env index 5fdf805ec670..7b152838e62f 100644 --- a/docker-compose/envs/common-smart-contract-verifier.env +++ b/docker-compose/envs/common-smart-contract-verifier.env @@ -14,9 +14,9 @@ SMART_CONTRACT_VERIFIER__SOLIDITY__COMPILERS_DIR=/tmp/solidity-compilers SMART_CONTRACT_VERIFIER__SOLIDITY__REFRESH_VERSIONS_SCHEDULE=0 0 * * * * * # It depends on the OS you are running the service on -SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__LIST__LIST_URL=https://solc-bin.ethereum.org/linux-amd64/list.json -#SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__LIST__LIST_URL=https://solc-bin.ethereum.org/macosx-amd64/list.json -#SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__LIST__LIST_URL=https://solc-bin.ethereum.org/windows-amd64/list.json +SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__LIST__LIST_URL=https://binaries.soliditylang.org/linux-amd64/list.json +#SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__LIST__LIST_URL=https://binaries.soliditylang.org/macosx-amd64/list.json +#SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__LIST__LIST_URL=https://binaries.soliditylang.org/windows-amd64/list.json SMART_CONTRACT_VERIFIER__VYPER__ENABLED=true SMART_CONTRACT_VERIFIER__VYPER__COMPILERS_DIR=/tmp/vyper-compilers diff --git a/docker-compose/no-services.yml b/docker-compose/no-services.yml index 236bcd4e137b..4e4ca1c4164f 100644 --- a/docker-compose/no-services.yml +++ b/docker-compose/no-services.yml @@ -34,14 +34,10 @@ services: DISABLE_WEBAPP: "false" API_V1_WRITE_METHODS_DISABLED: "false" ADMIN_PANEL_ENABLED: "" - RELEASE_VERSION: 8.0.2 + RELEASE_VERSION: 11.2.2 + CHAIN_TYPE: ethereum links: - db:database - environment: - ETHEREUM_JSONRPC_HTTP_URL: http://host.docker.internal:8545/ - ETHEREUM_JSONRPC_TRACE_URL: http://host.docker.internal:8545/ - ETHEREUM_JSONRPC_WS_URL: ws://host.docker.internal:8545/ - CHAIN_ID: '1337' frontend: depends_on: diff --git a/docker/Dockerfile b/docker/Dockerfile index fea705f5f579..1f3c715cddf2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM hexpm/elixir:1.17.3-erlang-27.1-alpine-3.20.3 AS builder-deps +FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-3.22.2 AS builder-deps WORKDIR /app @@ -17,7 +17,7 @@ COPY apps/nft_media_handler/mix.exs ./apps/nft_media_handler/ ENV MIX_ENV="prod" ENV MIX_HOME=/opt/mix RUN mix local.hex --force -RUN mix do deps.get, local.rebar --force, deps.compile --skip-umbrella-children +RUN MIX_OS_DEPS_COMPILE_PARTITION_COUNT=$(nproc) mix do deps.get + local.rebar --force + deps.compile --skip-umbrella-children COPY config ./config COPY rel ./rel @@ -40,6 +40,8 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} +ARG MUD_INDEXER_ENABLED +ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} # Run backend compilation RUN mix compile @@ -49,7 +51,7 @@ RUN mkdir -p /opt/release && \ mv _build/${MIX_ENV}/rel/blockscout /opt/release ############################################################## -FROM hexpm/elixir:1.17.3-erlang-27.1-alpine-3.20.3 +FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-3.22.2 WORKDIR /app @@ -60,7 +62,7 @@ ARG BLOCKSCOUT_GID=10001 RUN apk --no-cache --update add jq curl && \ addgroup --system --gid ${BLOCKSCOUT_GID} ${BLOCKSCOUT_GROUP} && \ - adduser --system --uid ${BLOCKSCOUT_UID} --ingroup ${BLOCKSCOUT_GROUP} --disabled-password ${BLOCKSCOUT_USER} + adduser --system --uid ${BLOCKSCOUT_UID} --ingroup ${BLOCKSCOUT_GROUP} --disabled-password --home /app ${BLOCKSCOUT_USER} ENV DISABLE_WEBAPP=true ENV ADMIN_PANEL_ENABLED=false @@ -76,6 +78,8 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} +ARG MUD_INDEXER_ENABLED +ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} diff --git a/docker/Makefile b/docker/Makefile index 781f502a5536..17d8e7a0dacd 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -10,7 +10,7 @@ STATS_CONTAINER_NAME := stats STATS_DB_CONTAINER_NAME := stats-db PROXY_CONTAINER_NAME := proxy PG_CONTAINER_NAME := postgres -RELEASE_VERSION ?= '8.0.2' +RELEASE_VERSION ?= '11.2.2' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/docker/oldUI.Dockerfile b/docker/oldUI.Dockerfile index 3033af0092c4..29b7f7e083b4 100644 --- a/docker/oldUI.Dockerfile +++ b/docker/oldUI.Dockerfile @@ -1,9 +1,9 @@ -FROM hexpm/elixir:1.17.3-erlang-27.1-alpine-3.20.3 AS builder-deps +FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-3.22.2 AS builder-deps WORKDIR /app RUN apk --no-cache --update add \ - alpine-sdk gmp-dev automake libtool inotify-tools autoconf python3 file gcompat libstdc++ curl ca-certificates git make + alpine-sdk gmp-dev automake libtool inotify-tools autoconf python3 file gcompat libstdc++ curl ca-certificates git make bash # Cache elixir deps COPY mix.exs mix.lock ./ @@ -12,6 +12,7 @@ COPY apps/explorer/mix.exs ./apps/explorer/ COPY apps/ethereum_jsonrpc/mix.exs ./apps/ethereum_jsonrpc/ COPY apps/indexer/mix.exs ./apps/indexer/ COPY apps/utils/mix.exs ./apps/utils/ +COPY apps/nft_media_handler/mix.exs ./apps/nft_media_handler/ ENV MIX_ENV="prod" ENV MIX_HOME=/opt/mix @@ -55,6 +56,8 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} +ARG MUD_INDEXER_ENABLED +ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} # Run backend compilation RUN mix compile @@ -64,7 +67,7 @@ RUN mkdir -p /opt/release && \ mv _build/${MIX_ENV}/rel/blockscout /opt/release ############################################################## -FROM hexpm/elixir:1.17.3-erlang-27.1-alpine-3.20.3 +FROM hexpm/elixir:1.19.4-erlang-27.3.4.6-alpine-3.22.2 WORKDIR /app @@ -78,8 +81,7 @@ RUN apk --no-cache --update add jq curl && \ adduser --system --uid ${BLOCKSCOUT_UID} --ingroup ${BLOCKSCOUT_GROUP} --disabled-password ${BLOCKSCOUT_USER} ENV DISABLE_WEBAPP=false -ARG ADMIN_PANEL_ENABLED -ENV ADMIN_PANEL_ENABLED=${ADMIN_PANEL_ENABLED} +ENV ADMIN_PANEL_ENABLED=false ARG DISABLE_API ENV DISABLE_API=${DISABLE_API} ARG API_V1_READ_METHODS_DISABLED @@ -92,6 +94,8 @@ ARG BRIDGED_TOKENS_ENABLED ENV BRIDGED_TOKENS_ENABLED=${BRIDGED_TOKENS_ENABLED} ARG API_GRAPHQL_MAX_COMPLEXITY ENV API_GRAPHQL_MAX_COMPLEXITY=${API_GRAPHQL_MAX_COMPLEXITY} +ARG MUD_INDEXER_ENABLED +ENV MUD_INDEXER_ENABLED=${MUD_INDEXER_ENABLED} ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} @@ -104,6 +108,6 @@ COPY --from=builder --chown=${BLOCKSCOUT_USER}:${BLOCKSCOUT_GROUP} /app/config/c COPY --from=builder --chown=${BLOCKSCOUT_USER}:${BLOCKSCOUT_GROUP} /app/config/config_helper.exs /app/releases/${RELEASE_VERSION}/config_helper.exs COPY --from=builder --chown=${BLOCKSCOUT_USER}:${BLOCKSCOUT_GROUP} /app/config/assets/precompiles-arbitrum.json ./config/assets/precompiles-arbitrum.json -RUN chown -R ${BLOCKSCOUT_USER}:${BLOCKSCOUT_GROUP} /app +RUN mkdir dets && mkdir temp && chown -R ${BLOCKSCOUT_USER}:${BLOCKSCOUT_GROUP} /app USER ${BLOCKSCOUT_USER}:${BLOCKSCOUT_GROUP} diff --git a/docs/CONFIGURATION_GUIDE.md b/docs/CONFIGURATION_GUIDE.md index 91bc6b24606e..94f912b2545c 100644 --- a/docs/CONFIGURATION_GUIDE.md +++ b/docs/CONFIGURATION_GUIDE.md @@ -8,11 +8,11 @@ Blockscout is currently pre-configured for **XRPL EVM Testnet**. ### XRPL EVM Testnet Details -- **Chain ID**: `1449000` (0x161778) +- **Chain ID**: `1449000` (0x161c28) - **Native Currency**: XRP (18 decimals) - **Public RPC Endpoint**: `https://rpc.testnet.xrplevm.org` - **Full History RPC**: `https://full-history-bb325630.testnet.xrplevm.org` -- **WebSocket**: `wss://full-history-bb325630.testnet.xrplevm.org` +- **WebSocket**: `wss://ws.testnet.xrplevm.org` - **Official Explorer**: [explorer.testnet.xrplevm.org](https://explorer.testnet.xrplevm.org) - **First Block**: `2162539` - **Trace First Block**: `4900000` @@ -22,7 +22,7 @@ Blockscout is currently pre-configured for **XRPL EVM Testnet**. The configuration includes: - **Primary RPC**: `https://rpc.testnet.xrplevm.org` - **Fallback RPC / Full History**: `https://full-history-bb325630.testnet.xrplevm.org` -- **WebSocket**: `wss://full-history-bb325630.testnet.xrplevm.org` +- **WebSocket**: `wss://ws.testnet.xrplevm.org` --- @@ -67,6 +67,22 @@ As per [XRPL EVM Public APIs](https://docs.xrplevm.org/pages/developers/resource 3. **Edit Frontend Configuration**: `docker-compose/envs/common-frontend.env` 4. **Start the services**: `cd docker-compose && docker compose up -d` +## Production Deployment (GitHub Actions Image Build) + +The docker-compose flow above is for local/manual setups. The actual deployment route used by the org is the GitHub Actions workflow: + +- **Workflow**: `.github/workflows/publish-docker-image-for-xrplevm.yml` +- **Trigger**: manual (`workflow_dispatch`) with a `version` input (e.g. `0.0.0`) +- **Output**: builds `docker/Dockerfile` and pushes the multi-arch image (`linux/amd64`, `linux/arm64/v8`) to `ghcr.io/xrplevm/blockscout-xrplevm:` + +The `version` input is only the Docker image tag (and part of the displayed `BLOCKSCOUT_VERSION`) — it does not need to match anything else. + +### The `RELEASE_VERSION` build arg (critical) + +The `RELEASE_VERSION` build arg **must exactly match the `version` in `mix.exs`** (currently `11.2.2`). The Dockerfile uses it as a filesystem path — it copies `config_helper.exs` into `/app/releases/${RELEASE_VERSION}/`, and the Elixir release only reads from the directory named after the `mix.exs` version. A mismatched value produces an image that **builds successfully but crashes at boot** with an ENOENT error when it fails to find its release files. + +The workflow derives `RELEASE_VERSION` from `mix.exs` automatically, so no manual input is needed for it. If you ever modify the workflow or build the image by hand (`docker build --build-arg RELEASE_VERSION=...`), make sure the value matches `mix.exs` — and remember to keep them in sync when bumping the Blockscout version during upstream merges. + ## Essential Configuration Parameters ### 1. Backend Configuration (`docker-compose/envs/common-blockscout.env`) @@ -300,10 +316,10 @@ The configuration files are currently set up for XRPL EVM Testnet: ```bash ETHEREUM_JSONRPC_HTTP_URL=https://rpc.testnet.xrplevm.org ETHEREUM_JSONRPC_TRACE_URL=https://rpc.testnet.xrplevm.org -ETHEREUM_JSONRPC_WS_URL=wss://full-history-bb325630.testnet.xrplevm.org +ETHEREUM_JSONRPC_WS_URL=wss://ws.testnet.xrplevm.org ETHEREUM_JSONRPC_FALLBACK_HTTP_URL=https://full-history-bb325630.testnet.xrplevm.org ETHEREUM_JSONRPC_FALLBACK_TRACE_URL=https://full-history-bb325630.testnet.xrplevm.org -ETHEREUM_JSONRPC_FALLBACK_WS_URL=wss://full-history-bb325630.testnet.xrplevm.org +# ETHEREUM_JSONRPC_FALLBACK_WS_URL= (none — full-history is HTTP-RPC-only) CHAIN_ID=1449000 COIN_NAME=XRP COIN=XRP @@ -378,6 +394,26 @@ CHAIN_TYPE= # VALIDATORS_CONTRACT= ``` +## Native XRP Sentinel Token (Manual Database Step Required) + +This fork's main customization surfaces **native XRP as a pseudo-ERC-20 token** at the sentinel address: + +``` +0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee +``` + +### How it works + +- The coin balance fetcher (`apps/indexer/lib/indexer/fetcher/coin_balance/helper.ex`) writes fetched native coin balances not only to the usual coin balance tables, but **also** to `address_token_balances` and `address_current_token_balances`, using the sentinel address as `token_contract_address_hash` with `token_type` `ERC-20`. This makes native XRP appear in address token balance lists alongside real ERC-20 tokens. Note this applies to `import_fetched_balances/2`; the daily-balance path (`import_fetched_daily_balances/2`) is not customized and writes no sentinel rows, so the sentinel balance can transiently lag the address's coin balance. +- The token balance fetcher (`apps/indexer/lib/indexer/fetcher/token_balance/helper.ex`) special-cases the sentinel address in the opposite direction: balances recorded against it also update the address's `fetched_coin_balance`. +- The separate React frontend special-cases this exact address to render the XRP logo for it. + +### The manual step + +For the sentinel to actually render as a token in the UI/API, **a row must exist in the `tokens` table for that address**. Nothing in this repository seeds it — there is no migration, seed script, or indexer code that inserts it. It must be created manually (out-of-band) in the database after initial setup, otherwise the balance rows reference a token that does not exist and native XRP will not display as a token. + +Per the schema in `apps/explorer/lib/explorer/chain/token.ex`, the `tokens` table is keyed by `contract_address_hash`, requires a `type` (use `ERC-20` to match what the indexer writes), and has optional `name`, `symbol`, and `decimals` columns (for XRP: name `XRP`, symbol `XRP`, decimals `18`). Before inserting, confirm the exact column list and NOT NULL constraints (e.g. timestamp columns) against that schema file and the migrations in `apps/explorer/priv/repo/migrations/` — do not assume defaults. + ### For PoA (Proof of Authority) Chains ```bash diff --git a/funding.json b/funding.json deleted file mode 100644 index c125d68d15b8..000000000000 --- a/funding.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "opRetro": { - "projectId": "0x663e4d25ca3f327365240471b4831ea3c989cb132bbf6ae8f5c1e15268591795" - } -} diff --git a/mix.exs b/mix.exs index c616c5de97e7..f6adf9fe5aaa 100644 --- a/mix.exs +++ b/mix.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout defmodule BlockScout.Mixfile do use Mix.Project @@ -7,15 +8,11 @@ defmodule BlockScout.Mixfile do [ # app: :block_scout, # aliases: aliases(config_env()), - version: "8.0.2", + version: "11.2.2", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), - elixir: "~> 1.17", - preferred_cli_env: [ - credo: :test, - dialyzer: :test - ], + elixir: "~> 1.19", # start_permanent: config_env() == :prod, releases: [ blockscout: [ @@ -34,11 +31,20 @@ defmodule BlockScout.Mixfile do ] end + def cli do + [preferred_envs: [credo: :test, dialyzer: :test]] + end + ## Private Functions defp copy_prod_runtime_config(%Mix.Release{path: path} = release) do File.mkdir_p!(Path.join([path, "config", "runtime"])) - File.cp!(Path.join(["config", "runtime", "prod.exs"]), Path.join([path, "config", "runtime", "prod.exs"])) + + File.cp!( + Path.join(["config", "runtime", "prod.exs"]), + Path.join([path, "config", "runtime", "prod.exs"]) + ) + File.mkdir_p!(Path.join([path, "apps", "explorer", "config", "prod"])) File.cp_r!( @@ -47,7 +53,11 @@ defmodule BlockScout.Mixfile do ) File.mkdir_p!(Path.join([path, "apps", "indexer", "config", "prod"])) - File.cp_r!(Path.join(["apps", "indexer", "config", "prod"]), Path.join([path, "apps", "indexer", "config", "prod"])) + + File.cp_r!( + Path.join(["apps", "indexer", "config", "prod"]), + Path.join([path, "apps", "indexer", "config", "prod"]) + ) release end @@ -94,12 +104,12 @@ defmodule BlockScout.Mixfile do # and cannot be accessed from applications inside the apps folder defp deps do [ - {:prometheus_ex, git: "https://github.com/lanodan/prometheus.ex", branch: "fix/elixir-1.14", override: true}, + {:prometheus_ex, "~> 5.1.0", override: true}, {:absinthe_plug, git: "https://github.com/blockscout/absinthe_plug.git", tag: "1.5.8", override: true}, - {:tesla, "~> 1.14.1"}, - {:mint, "~> 1.7.1"}, + {:tesla, "~> 1.20.0"}, + {:mint, "~> 1.9.0"}, # Documentation - {:ex_doc, "~> 0.37.2", only: :dev, runtime: false}, + {:ex_doc, "~> 0.40.1", only: :dev, runtime: false}, {:number, "~> 1.0.3"} ] end diff --git a/mix.lock b/mix.lock index 20ac3e689023..8dc6e24d327d 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,12 @@ %{ - "absinthe": {:hex, :absinthe, "1.7.9", "85c5bc9dfcb984fadf632d6e9a9f6daa7770c68d4f751f20cd5f9e77c57be4a1", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08ae6339b05252b98e859ccd76614c94325c991f85aa325edde30027b7b044fc"}, - "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.3", "74e0862f280424b7bc290f6f69e133268bce0b4e7db0218c7e129c5c2b1d3fd4", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "caffaea03c17ea7419fe07e4bc04c2399c47f0d8736900623dbf4749a826fd2c"}, + "absinthe": {:hex, :absinthe, "1.11.0", "dee6dfa57f86d52b25d8b0f925585b6b047d4921ac703c1075b6e7b8bc2fc83c", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "39b3b4b6e3eb405fa98b449feef0dacff81d89bf01c2866cfa513616c5530ba6"}, + "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.5", "1fdcfc59db241b1d5e45e4c8c9db162277e64ec5728e0fd6958a3fa491a05b82", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "086c6d4a1c32f7444713130d204c87b1b006169f5159026b73f02f7d38ccd05c"}, "absinthe_plug": {:git, "https://github.com/blockscout/absinthe_plug.git", "90a8188e94e2650f13259fb16462075a87f98e18", [tag: "1.5.8"]}, - "absinthe_relay": {:hex, :absinthe_relay, "1.5.2", "cfb8aed70f4e4c7718d3f1c212332d2ea728f17c7fc0f68f1e461f0f5f0c4b9a", [:mix], [{:absinthe, "~> 1.5.0 or ~> 1.6.0 or ~> 1.7.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "0587ee913afa31512e1457a5064ee88427f8fe7bcfbeeecd41c71d9cff0b62b6"}, + "absinthe_relay": {:hex, :absinthe_relay, "1.6.0", "73590bdb0dcc192622f39fa11da6fca9df4e339b603c45ec7a93493eb94f1829", [:mix], [{:absinthe, ">= 1.7.10", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "32d6397a7af3fd02678ef9bc8e2f574487f14593cb3e4f9110fb1c695d4d2ac0"}, "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, - "bamboo": {:hex, :bamboo, "2.4.0", "d26bd9f22a18cfa4448dac9b305debfd533b73064aeaea10d251400e543b472b", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "85e15dfce007faa04ef3baa46fe0737aba955cba5843bad17514519f9cb86592"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.1", "e361261a0401d82dadc1ab7b969f91d250bf7577283e933fe8c5b72f8f5b3c46", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "81170177d5c2e280d12141a0b9d9e299bf731535e2d959982bdcd4cfe3c82865"}, - "benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"}, + "bamboo": {:hex, :bamboo, "2.5.0", "973f5cb1471a1d2d7d9da7b8e4f6096afb6a133f85394631184fd40be8adb8ab", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "35c8635ff6677a81ab7258944ff15739280f3254a041b6f0229dddeb9b90ad3d"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "benchee": {:hex, :benchee, "1.5.1", "b95cbc36c4b98969a5c592a246e171041eb683c56bad1cb4f49a3b081ba66087", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a539301f8dfd4efc5c5123bfb9d47ebde20092a863a5b5b16c2a60d2243dfce7"}, "benchee_csv": {:hex, :benchee_csv, "1.0.0", "0b3b9223290bfcb8003552705bec9bcf1a89b4a83b70bd686e45295c264f3d16", [:mix], [{:benchee, ">= 0.99.0 and < 2.0.0", [hex: :benchee, repo: "hexpm", optional: false]}, {:csv, "~> 2.0", [hex: :csv, repo: "hexpm", optional: false]}], "hexpm", "cdefb804c021dcf7a99199492026584be9b5a21d6644ac0d01c81c5d97c520d5"}, "blake2": {:hex, :blake2, "1.0.4", "8263c69a191142922bc2510f1ffc0de0ae96e8c3bd5e2ad3fac7e87aed94c8b1", [:mix], [], "hexpm", "e9f4120d163ba14d86304195e50745fa18483e6ad2be94c864ae449bbdd6a189"}, "briefly": {:git, "https://github.com/CargoSense/briefly.git", "4836ba322ffb504a102a15cc6e35d928ef97120e", []}, @@ -14,162 +14,178 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bureaucrat": {:hex, :bureaucrat, "0.2.10", "b0de157dad540e40007b663b683f716ced21f85ff0591093aadb209ad0d967e1", [:mix], [{:inflex, ">= 1.10.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "bc7e5162b911c29c8ebefee87a2c16fbf13821a58f448a8fd024eb6c17fae15c"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "cachex": {:hex, :cachex, "4.1.0", "f9a9123feac30df2b75260c9a74903c0bd7ec54f345f5f6f60c7830b81ead43a", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "77f9417a5549e424bdbfeea3f8060841384c39c0b5f563a8f62f1f2c2b6d2345"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, - "cbor": {:hex, :cbor, "1.0.1", "39511158e8ea5a57c1fcb9639aaa7efde67129678fee49ebbda780f6f24959b0", [:mix], [], "hexpm", "5431acbe7a7908f17f6a9cd43311002836a34a8ab01876918d8cfb709cd8b6a2"}, - "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, - "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, - "cldr_utils": {:hex, :cldr_utils, "2.28.2", "f500667164a9043369071e4f9dcef31f88b8589b2e2c07a1eb9f9fa53cb1dce9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c506eb1a170ba7cdca59b304ba02a56795ed119856662f6b1a420af80ec42551"}, + "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, + "cafezinho": {:hex, :cafezinho, "0.4.4", "36c31fc5456b1284180d8a9d968c7eaaf474782df5af023a8f8b66937c5b2785", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "3ca334f2a2992ec081868c39ad0487eb97d213292702aae6b197fd5e6f04fd58"}, + "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "cbor": {:hex, :cbor, "1.0.2", "9b0af85af291a556e10a0ffd48ba9a21a75e711828fafd3af193d56d95f0907f", [:mix], [], "hexpm", "edbc9b4a16eb93a582437b9b249c340a75af03958e338fb43d8c1be9fc65b864"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "cldr_utils": {:hex, :cldr_utils, "2.29.7", "2189bc0117efe91c684558e79174b45eb43135595b7d1fe9b57f53917be195c1", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "4bddcd597fee34e2d2829ae9ef62bcfef8d97ae5f6b75f0c6ee37a3db31aa73a"}, "cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"}, "cloak_ecto": {:hex, :cloak_ecto, "1.3.0", "0de127c857d7452ba3c3367f53fb814b0410ff9c680a8d20fbe8b9a3c57a1118", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "314beb0c123b8a800418ca1d51065b27ba3b15f085977e65c0f7b2adab2de1cc"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "complex": {:hex, :complex, "0.6.0", "b0130086a7a8c33574d293b2e0e250f4685580418eac52a5658a4bd148f3ccf1", [:mix], [], "hexpm", "0a5fa95580dcaf30fcd60fe1aaf24327c0fe401e98c24d892e172e79498269f9"}, + "complex": {:hex, :complex, "0.7.0", "695632ef9487517aa5d57edd1697801079d622414cb2e1a7cf538b1f9a50f205", [:mix], [], "hexpm", "0ee39c0803129f546e7f3f640da8f021c9e659402bf59da6f7f2c4848f068f8d"}, "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, - "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, + "cowboy": {:hex, :cowboy, "2.16.1", "fa04080b602ff25c40a7700f2dc0152dbc1ba26b42093ae0fa9bb7a337d5a242", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "b8ea4dd317a043e3177ec840cfa3bcb47cfb41035d3abb24d954dc7d51def399"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.14.0", "623791c56c1cc9df54a71a9c55147a401549917f00a2e48a6ae12b812c586ced", [:make, :rebar3], [], "hexpm", "0af652d1550c8411c3b58eed7a035a7fb088c0b86aff6bc504b0bc3b7f791aa2"}, - "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "cowlib": {:hex, :cowlib, "2.17.1", "3e6053016d1ab245730f0af688755476dcedb1c25ed8fb5751f59a2bfdc0c9af", [:make, :rebar3], [], "hexpm", "ff08bd17e6dd931445b18af77315b9b5fe052407110964ad2588c686b57b5e3f"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "csv": {:hex, :csv, "2.5.0", "c47b5a5221bf2e56d6e8eb79e77884046d7fd516280dc7d9b674251e0ae46246", [:mix], [{:parallel_stream, "~> 1.0.4 or ~> 1.1.0", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "e821f541487045c7591a1963eeb42afff0dfa99bdcdbeb3410795a2f59c77d34"}, "dataloader": {:hex, :dataloader, "2.0.2", "c45075e0692e68638a315e14f747bd8d7065fb5f38705cf980f62d4cd344401f", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c6cabc0b55e96e7de74d14bf37f4a5786f0ab69aa06764a1f39dda40079b098"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, + "ddskerl": {:hex, :ddskerl, "0.4.3", "bb97b90eef1b5906520cebdd38da29b18a770b5567f6297927f53566eae9bebb", [:rebar3], [], "hexpm", "4e0f6047c6a002ce38f6ea155276dd918cd635fd0a5edb9e0b46aeb6f7aff2c2"}, + "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, - "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, + "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"}, "dns": {:hex, :dns, "2.4.0", "44790a0375b28bdc7b59fc894460bfcb03ffeec4c5984e2c3e8b0797b1518327", [:mix], [], "hexpm", "e178e353c469820d02ba889d6a80d01c8c27b47dfcda4016a9cbc6218e3eed64"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, - "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, + "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, - "evision": {:hex, :evision, "0.2.12", "bdb06e54e05a27a58cd42bd3c390e4e0ee288b3d463a60bb998f044650c63b38", [:make, :mix, :rebar3], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.11", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:progress_bar, "~> 2.0 or ~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: true]}], "hexpm", "f96bbbcb3fa09b9a9bec863345e4e717452000fef410e53ac35af850723b4253"}, + "evil_crc32c": {:hex, :evil_crc32c, "0.2.9", "9e4082418c4cc27c88869c1773f114f15a9257121ebb019037bea60c75c601d2", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "86ba14f8aad0fe55eda0b57e79e3d8dffe39ea326b753eac12463ae2bec757bf"}, + "evision": {:hex, :evision, "0.2.17", "0d5aed4fdb6945f100642cfd41531d187963af984c60ec367719106dda7ed864", [:make, :mix, :rebar3], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.11", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.6", [hex: :nx, repo: "hexpm", optional: false]}, {:progress_bar, "~> 2.0 or ~> 3.0", [hex: :progress_bar, repo: "hexpm", optional: true]}], "hexpm", "565bc8700d23cd2429444387a1cf0e53fe7a85892e9276ce62a9cef23350c11f"}, "ex_abi": {:hex, :ex_abi, "0.8.3", "e01cbafd81008db9e8bc8ac0a4ae5bf08e86a2d82afdca242550269445b80e04", [:mix], [{:ex_keccak, "~> 0.7.6", [hex: :ex_keccak, repo: "hexpm", optional: true]}], "hexpm", "6349a10331c54a1e09865811f705913fa181bc75b4c9a2d326fd96e21bf60cd4"}, - "ex_aws": {:hex, :ex_aws, "2.5.9", "8e2455172f0e5cbe2f56dd68de514f0dae6bb26d6b6e2f435a06434cf9dbb412", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbdb6ffb0e6c6368de05ed8641fe1376298ba23354674428e5b153a541f23359"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.7", "e571424d2f345299753382f3a01b005c422b1a460a8bc3ed47659b3d3ef91e9e", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "858e51241e50181e29aa2bc128fef548873a3a9cd580471f57eda5b64dec937f"}, + "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"}, "ex_brotli": {:hex, :ex_brotli, "0.5.0", "573645db5201317b6176b8858b668ea4ca89dc5c21852e84b9867579d483c220", [:mix], [{:phoenix, ">= 0.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8447d98d51f8f312629fd38619d4f564507dcf3a03d175c3f8f4ddf98e46dd92"}, - "ex_cldr": {:hex, :ex_cldr, "2.42.0", "17ea930e88b8802b330e1c1e288cdbaba52cbfafcccf371ed34b299a47101ffb", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "07264a7225810ecae6bdd6715d8800c037a1248dc0063923cddc4ca3c4888df6"}, - "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.16.4", "d76770690699b6ba91f1fa253a299a905f9c22b45d91891b85f431b9dafa8b3b", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "46a67d1387f14e836b1a24d831fa5f0904663b4f386420736f40a7d534e3cb9e"}, - "ex_cldr_lists": {:hex, :ex_cldr_lists, "2.11.1", "ad18f861d7c5ca82aac6d173469c6a2339645c96790172ab0aa255b64fb7303b", [:mix], [{:ex_cldr_numbers, "~> 2.25", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.18", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "00161c04510ccb3f18b19a6b8562e50c21f1e9c15b8ff4c934bea5aad0b4ade2"}, - "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.35.0", "66d3e6af936abcaad6a1ab89ecd6ccef107e3198152315a78b76b398782be367", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.42", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.16", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "0146dbe6c9665fb725ae2345c8c6d033e1d9526a8ef848e187cc9042886729bf"}, - "ex_cldr_units": {:hex, :ex_cldr_units, "3.19.0", "9b03c90f53e1f93c33aa80923da7d78ac79f3aba11f2ed40218b6008cb8c0ecc", [:mix], [{:cldr_utils, "~> 2.25", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.35.0", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.18", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "2795f250aaafd573d067e8de346fbfb6a33810182e1b61747d127867e83259fa"}, - "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, + "ex_cldr": {:hex, :ex_cldr, "2.47.5", "cc08d24876b0e0ef7038f202ddd708c9ae218702a7e09433d7e06e324d2104cc", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "8637e14a966fa68381e9765afd03d8fc703d6f3bc5c7c3e5fe59354566777595"}, + "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.2", "4892b5255a205e8133297b702abbe13c9b51d0281fa8c2ea100f51df9657c3c7", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "797095c106a2fe6632981531e29cfb1d2f8ee7de626f4d6243f974d6f74a0112"}, + "ex_cldr_lists": {:hex, :ex_cldr_lists, "2.12.2", "731532a3c0a9bfd062acbe79fd39affa4d6e3e5aa5b08a7405524166ce1cd47a", [:mix], [{:ex_cldr_numbers, "~> 2.25", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.18", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "16e0b6dc63091c71a0c9a2c50e13db64798f261c3e121d7e3fb7d354872180e8"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.3", "64a99531df09397174b7f477785c43016f223d01856725b541a19d36b8149523", [:mix], [{:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 1.0 or ~> 2.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3a0d87ef2747c66d78ae8967023d415d3d76aa310981047ad601e3287e8fe73c"}, + "ex_cldr_units": {:hex, :ex_cldr_units, "3.20.5", "5f12206c41b76eb2baadfbc555826fc8e04c400ef6f42d8b1bf0959ab65dc25a", [:mix], [{:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.36", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_doc, "~> 0.18", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "51e49f21d2c51127de7358367cc4fbc95eff04593b93ab07b20f12a076db6995"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "ex_eth_bls": {:hex, :ex_eth_bls, "0.1.0", "33c2bf424b360e4b64d7630dd72ec028dac63df56802d0a14ade54a23ad1c743", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25f6ffc36de4952e55adff1c712f0b9680850773678550f1da970d4d18329365"}, "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, - "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, - "ex_keccak": {:hex, :ex_keccak, "0.7.6", "110c3ed76b55265975d9ae6628205b8a026f11fe081f3073e00c29aab2e91473", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9d1568424eb7b995e480d1b7f0c1e914226ee625496600abb922bba6f5cdc5e4"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.11.3", "0f9128fde2a46976c21d1ed9506bf8462776ac9e333e7b42ececcdbf7433a1da", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "0e8e16866bc14339b1bf1e441f1d9231031c8fe2cf45fa5a988a8b685de8c002"}, + "ex_keccak": {:hex, :ex_keccak, "0.7.8", "be1cf194d3158f0a305eaed0334e478d0d0f2c827e7c1f8f0e1e2a667da5a8ac", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "52de5b42b718df2534fb9a55780d8a05bbaea539f867c3e7c0a8e7e1d5f149d9"}, "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, + "ex_pbkdf2": {:hex, :ex_pbkdf2, "0.8.5", "583733a1134a0c8b4df70c6b3e874d1896bf48f2aaafdf332ec5480c7b5117bf", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f138b2acdc5a4d87880e42742e9bead4d1235fd9b1b2b29be0d1da1e01c3a312"}, "ex_rlp": {:hex, :ex_rlp, "0.6.0", "985391d2356a7cb8712a4a9a2deb93f19f2fbca0323f5c1203fcaf64d077e31e", [:mix], [], "hexpm", "7135db93b861d9e76821039b60b00a6a22d2c4e751bf8c444bffe7a042f1abaf"}, - "ex_secp256k1": {:hex, :ex_secp256k1, "0.7.4", "d771a74917eb21e9f02e52bb1d0ac9aba4e2b5118d7f515014d0a311c990e323", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "465fd788c83c24d2df47f302e8fb1011054c81a905345e377c957b159a783bfc"}, + "ex_secp256k1": {:hex, :ex_secp256k1, "0.8.0", "aade42e790638de82a2b951a83f55b9cc6545b8c105b20a0112ff6b78e1800cd", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "87257ef7110c45ac396a110eb93023371db96b6f65ed0676046a11e866d1e3a0"}, "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, - "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "exvcr": {:hex, :exvcr, "0.16.0", "11579f43c88ae81f57c82ce4f09e3ebda4c40117c859ed39e61a653c3a0b4ff4", [:mix], [{:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.9", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "8f576af22369942f7a1482baff1f31e2f45983cf6fac45d49d2bd2e84b4d5be8"}, - "ezstd": {:hex, :ezstd, "1.2.1", "3093e7b48687471ae58da415cc707b87ad5e0e3bd115f91d6154cd89b5393d4c", [:rebar3], [], "hexpm", "ddce7be69ea524ad02643449608d9d8557e2ecec0198284f0315c4e5fc7225ad"}, + "ezstd": {:hex, :ezstd, "1.2.4", "7ab3ed4bf5ed93e249c936f457a060cf99487f392a41b2b3fd0f93e2c792a7d6", [:rebar3], [], "hexpm", "c79a63c8f1706ca5402d4d97347a1934f41fd8dd055c7af4ca92b9bcbef6d1c0"}, "file_info": {:hex, :file_info, "0.0.4", "2e0e77f211e833f38ead22cb29ce53761d457d80b3ffe0ffe0eb93880b0963b2", [:mix], [{:mimetype_parser, "~> 0.1.2", [hex: :mimetype_parser, repo: "hexpm", optional: false]}], "hexpm", "50e7ad01c2c8b9339010675fe4dc4a113b8d6ca7eddce24d1d74fd0e762781a5"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, - "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, "flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, - "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, - "hammer": {:hex, :hammer, "6.2.1", "5ae9c33e3dceaeb42de0db46bf505bd9c35f259c8defb03390cd7556fea67ee2", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b9476d0c13883d2dc0cc72e786bac6ac28911fba7cc2e04b70ce6a6d9c4b2bdc"}, - "hammer_backend_redis": {:hex, :hammer_backend_redis, "6.2.0", "f39a9c8491387cdf719a38593311537e3e0251ca54725b6ee9145406821f39d2", [:mix], [{:hammer, "~> 6.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "9965d55705d7ca7412bb0685f5cd44fc47d103bf388abc50438e71974c36c9fa"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hammer": {:hex, :hammer, "7.4.0", "7ec06643280583b73245d360c6c8797c080ad6cc45788206abc4358eadd70414", [:mix], [], "hexpm", "ae50e0cadd17c68e2379eb8bf06b63bc882a2f9bd6350f8a2c2727c56d082b3d"}, + "hammer_backend_redis": {:hex, :hammer_backend_redis, "7.1.1", "979362db9e6f30b9b71f671b28550b5192cd5c2614e60da6ee9347d8085b962b", [:mix], [{:hammer, "~> 7.0", [hex: :hammer, repo: "hexpm", optional: false]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "717bb15f14e709dcae67ba67f90229d2aaee2b6a9bfcc2ca96212f414cd474dc"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, - "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "image": {:hex, :image, "0.59.2", "b4b8deb31193ff2eb4f2c508e9f3b6f62c8238d34c3807d60a3f2dd5b2d04c28", [:mix], [{:bumblebee, "~> 0.6", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.9", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.9", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.23", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "c8d29db920ff611bcb424e52ffc81e08ff737af0d1c1b30f67b51d05e2257013"}, - "inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"}, + "image": {:hex, :image, "0.63.0", "03efcfeb1cfaac10d6ab364acce2bfa35e6b33b08947f291fdc17df796b36226", [:mix], [{:bumblebee, "~> 0.6", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.9", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.10", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.33", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "63b39a312f889bb61a04a4957977a7cd3fa23974ff56de3feebf46f69c5fa60e"}, + "inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "joken_jwks": {:hex, :joken_jwks, "1.7.0", "5db750732dddbfb51068c16da6e43439ad8a3932020bdd505e944dd918ec0771", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.6", [hex: :joken, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "c1dd2d5161f079f660bda773c338d3ff2b90bb79673a0852becc0433416797d3"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "junit_formatter": {:hex, :junit_formatter, "3.4.0", "d0e8db6c34dab6d3c4154c3b46b21540db1109ae709d6cf99ba7e7a2ce4b1ac2", [:mix], [], "hexpm", "bb36e2ae83f1ced6ab931c4ce51dd3dbef1ef61bb4932412e173b0cfa259dacd"}, + "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, "logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"}, - "logger_json": {:hex, :logger_json, "5.1.4", "9e30a4f2e31a8b9e402bdc20bd37cf9b67d3a31f19d0b33082a19a06b4c50f6d", [:mix], [{:ecto, "~> 2.1 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "3f20eea58e406a33d3eb7814c7dff5accb503bab2ee8601e84da02976fa3934c"}, + "logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "math": {:hex, :math, "0.7.0", "12af548c3892abf939a2e242216c3e7cbfb65b9b2fe0d872d05c6fb609f8127b", [:mix], [], "hexpm", "7987af97a0c6b58ad9db43eb5252a49fc1dfe1f6d98f17da9282e297f594ebc2"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, - "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, + "memento": {:hex, :memento, "0.6.0", "e77ccacb70c07c400bb0eedfc3bfe8f1b8147a79680747f06b65cd28acaf7e29", [:mix], [], "hexpm", "92f3010039de4b3058a7e5662b829305d560dcea0976fa102041992eb741a692"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, "mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm", "7d8f80c567807ce78cd93c938e7f4b0a20b1aaaaab914bf286f68457d9f7a852"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, + "mnemoniac": {:hex, :mnemoniac, "0.1.5", "d10bf14abb94adc1a98f6cee4d81d197e1b3986cad8ec43b2e21d2cd9f4d99b6", [:mix], [], "hexpm", "518ae4b6ddda5285a5c02bf83e1eb6ce1af7e4d65d3f7f5bff3270689a00bcc3"}, "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, - "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"}, - "nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"}, + "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "number": {:hex, :number, "1.0.5", "d92136f9b9382aeb50145782f116112078b3465b7be58df1f85952b8bb399b0f", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c0733a0a90773a66582b9e92a3f01290987f395c972cb7d685f51dd927cd5169"}, "numbers": {:hex, :numbers, "5.2.4", "f123d5bb7f6acc366f8f445e10a32bd403c8469bdbce8ce049e1f0972b607080", [:mix], [{:coerce, "~> 1.0", [hex: :coerce, repo: "hexpm", optional: false]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "eeccf5c61d5f4922198395bf87a465b6f980b8b862dd22d28198c5e6fab38582"}, - "nx": {:hex, :nx, "0.9.2", "17563029c01bf749aad3c31234326d7665abd0acc33ee2acbe531a4759f29a8a", [:mix], [{:complex, "~> 0.5", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "914d74741617d8103de8ab1f8c880353e555263e1c397b8a1109f79a3716557f"}, - "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"}, + "nx": {:hex, :nx, "0.12.1", "6e9fee43a77646d04faad2ba4e449b9e270c6b23e413f203cb4d81e71c0a617f", [:mix], [{:complex, "~> 0.7", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ee80f6ae898f68bbfe7f30216b06aab10096231ec99ded1208a827256b23d0fb"}, + "oauth2": {:hex, :oauth2, "2.1.1", "3bec9bf49e88e1b0c0ddf9f44c393d71fd0f88e905766f2e2cc5d57e6bcc5352", [:mix], [{:tesla, "~> 1.18", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "1d5997cb1ff1643dac17076b6c00c91e4381d8389f3c49a8c390984606dad439"}, + "oban": {:hex, :oban, "2.22.1", "9d2a38cec95070b31c1e274fae55f3925089f62159d8f3facabee0454d55b257", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af2508c156c5b0ec30b21b0883babf7e2716af35ed5d264095896103fe3cea37"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.3", "0e383bf23cc3a060bffaebbcd09fc06bfc908d948c00e518aed36bbf8a2fe473", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "5f74f1878fdc38f8e961b0b943ac7af88dcf3a82a0c0ef6680ddfd3d161aecbd"}, "optimal": {:hex, :optimal, "0.3.6", "46bbf52fbbbd238cda81e02560caa84f93a53c75620f1fe19e81e4ae7b07d1dd", [:mix], [], "hexpm", "1a06ea6a653120226b35b283a1cd10039550f2c566edcdec22b29316d73640fd"}, "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "phoenix": {:hex, :phoenix, "1.5.14", "2d5db884be496eefa5157505ec0134e66187cb416c072272420c5509d67bf808", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "207f1aa5520320cbb7940d7ff2dde2342162cf513875848f88249ea0ba02fef7"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.7", "05a42377075868a678d446361effba80cefef19ab98941c01a7a4c7560b29121", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25eaf41028eb351b90d4f69671874643a09944098fefd0d01d442f40a6091b6f"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, - "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, + "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, + "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, + "plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.9.0", "87e21e0d9054ced99c36d128f49e3ea2cd8b745fffb97de50bff99706087af4f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2002bafba4f3a45b55a58e68d70211b153a7ed18d37edb1ceb6e96e7a92c422e"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, - "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, + "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, - "prometheus": {:hex, :prometheus, "4.11.0", "b95f8de8530f541bd95951e18e355a840003672e5eda4788c5fa6183406ba29a", [:mix, :rebar3], [{:quantile_estimator, "~> 0.2.1", [hex: :quantile_estimator, repo: "hexpm", optional: false]}], "hexpm", "719862351aabf4df7079b05dc085d2bbcbe3ac0ac3009e956671b1d5ab88247d"}, + "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"}, + "prometheus": {:hex, :prometheus, "6.1.3", "67c85683a265f36d35559bdddd49a34d857d4827888ba987c8c8b7954a3df80e", [:rebar3], [{:ddskerl, "0.4.3", [hex: :ddskerl, repo: "hexpm", optional: false]}], "hexpm", "bd9522b4dd21cf6670f18a647d1ef91ab2f633f02f9275b583fad1eb11f880bf"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, - "prometheus_ex": {:git, "https://github.com/lanodan/prometheus.ex", "31f7fbe4b71b79ba27efc2a5085746c4011ceb8f", [branch: "fix/elixir-1.14"]}, + "prometheus_ex": {:hex, :prometheus_ex, "5.1.0", "a978945b4be5923b87edae3537e3fd61f8d04d755fae865ec4cbb1be7b5ec2e5", [:mix], [{:prometheus, "~> 6.1", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "15d3cd752063a1b51ffcba727fc0276f62d2a4387b523963883d125ea5e0d9e7"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm", "0273a6483ccb936d79ca19b0ab629aef0dba958697c94782bb728b920dfc6a79"}, "prometheus_process_collector": {:git, "https://github.com/Phybbit/prometheus_process_collector.git", "3dc94dcff422d7b9cbd7ddf6bf2a896446705f3f", [ref: "3dc94dcff422d7b9cbd7ddf6bf2a896446705f3f"]}, "qrcode": {:hex, :qrcode, "0.1.5", "551271830515c150f34568345b060c625deb0e6691db2a01b0a6de3aafc93886", [:mix], [], "hexpm", "a266b7fb7be0d3b713912055dde3575927eca920e5d604ded45cd534f6b7a447"}, - "quantile_estimator": {:hex, :quantile_estimator, "0.2.1", "ef50a361f11b5f26b5f16d0696e46a9e4661756492c981f7b2229ef42ff1cd15", [:rebar3], [], "hexpm", "282a8a323ca2a845c9e6f787d166348f776c1d4a41ede63046d72d422e3da946"}, - "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, + "que": {:hex, :que, "0.12.0", "4629c156036b8a3bf9ffdd79aa4069ab414822144d3ef031b5cc61fc805942d3", [:mix], [{:ex_utils, "~> 0.1", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.6", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "906651d60b1036840f1afacc0036541583eda9ba4b0b03f322530a1c84100507"}, "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "redix": {:hex, :redix, "1.5.2", "ab854435a663f01ce7b7847f42f5da067eea7a3a10c0a9d560fa52038fd7ab48", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "78538d184231a5d6912f20567d76a49d1be7d3fca0e1aaaa20f4df8e1142dcb8"}, + "redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "siwe": {:git, "https://github.com/royal-markets/siwe-ex.git", "51c9c08240eb7eea3c35693011f8d260cd9bb3be", [ref: "51c9c08240eb7eea3c35693011f8d260cd9bb3be"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, - "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, + "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "spandex": {:hex, :spandex, "3.2.0", "f8cd40146ea988c87f3c14054150c9a47ba17e53cd4515c00e1f93c29c45404d", [:mix], [{:decorator, "~> 1.2", [hex: :decorator, repo: "hexpm", optional: true]}, {:optimal, "~> 0.3.3", [hex: :optimal, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d0a7d5aef4c5af9cf5467f2003e8a5d8d2bdae3823a6cc95d776b9a2251d4d03"}, "spandex_datadog": {:hex, :spandex_datadog, "1.4.0", "0594b9655b0af00ab9137122616bc0208b68ceec01e9916ab13d6fbb33dcce35", [:mix], [{:msgpax, "~> 2.2.1 or ~> 2.3", [hex: :msgpax, repo: "hexpm", optional: false]}, {:spandex, "~> 3.2", [hex: :spandex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "360f8e1b4db238c1749c4872b1697b096429927fa42b8858d0bb782067380123"}, "spandex_ecto": {:hex, :spandex_ecto, "0.7.0", "259ad2feb7c834e774ec623f99c0fbacca8d60a73be212f92b75e37f853c81be", [:mix], [{:spandex, "~> 2.2 or ~> 3.0", [hex: :spandex, repo: "hexpm", optional: false]}], "hexpm", "c64784be79d95538013b7c60828830411c5c7aff1f4e8d66dfe564b3c83b500e"}, "spandex_phoenix": {:hex, :spandex_phoenix, "1.1.0", "9cff829d05258dd49a227c56711b19b69a8fd5d4873d8e9a92a4f4097e7322ab", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3", [hex: :plug, repo: "hexpm", optional: false]}, {:spandex, "~> 2.2 or ~> 3.0", [hex: :spandex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "265fe05c1736485fbb75d66ef7576682ebf6428c391dd54d22217f612fd4ddad"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, + "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, - "tesla": {:hex, :tesla, "1.14.1", "71c5b031b4e089c0fbfb2b362e24b4478465773ae4ef569760a8c2899ad1e73c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "c1dde8140a49a3bef5bb622356e77ac5a24ad0c8091f12c3b7fc1077ce797155"}, - "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, - "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.4.1", "a373ca6f693f4de84cde474a67467a9cb9051a8a7f3f615f1e23dc74b75237fa", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "85c6962f79d35bf543dd5659c6adc340fd2480cacc6f25d2cc2933ea6e8fcb3b"}, - "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "tesla": {:hex, :tesla, "1.20.0", "922b504e186da2ee82990d44e7422435f41b67403908a00340c3a0b23a17aa90", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21 or >= 4.0.2 and < 5.0.0-0", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "3ecb41cb458772332752c3acdfe983e23abb991f5a43cfd69a64e9ea3f4b0061"}, + "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, + "ton": {:hex, :ton, "0.5.1", "79745434a93e5f7de3572fdcf04feb048620f0edab9794fc412a73528672927d", [:mix], [{:cafezinho, "~> 0.4.4", [hex: :cafezinho, repo: "hexpm", optional: false]}, {:evil_crc32c, "~> 0.2.9", [hex: :evil_crc32c, repo: "hexpm", optional: false]}, {:ex_pbkdf2, "~> 0.8.4", [hex: :ex_pbkdf2, repo: "hexpm", optional: false]}, {:mnemoniac, "~> 0.1.4", [hex: :mnemoniac, repo: "hexpm", optional: false]}], "hexpm", "916f656c870902a61690347da9500c5ce27f04c02e02441363bac7b128030f07"}, + "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.4.3", "1e5f3b6c763f9b5725975d3ab7f1554525f1f1399b966f2425acf04f9d8dd4fe", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "dcbd9b35b9fda5fa9258e0ae629a99cf4473bd7adfb85785d3f71dfe7a9b2bc0"}, + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, "ueberauth_auth0": {:hex, :ueberauth_auth0, "2.1.0", "0632d5844049fa2f26823f15e1120aa32f27df6f27ce515a4b04641736594bf4", [:mix], [{:oauth2, "~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.7", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "8d3b30fa27c95c9e82c30c4afb016251405706d2e9627e603c3c9787fd1314fc"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, - "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, - "vix": {:hex, :vix, "0.33.0", "cd98084529fd8fe3d2336f157db6de03b297fb096508d820068117d58eadb6f1", [:make, :mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "9acde72b27bdfeadeb51f790f7a6cc0d06cf555718c05cf57e43c5cf93d8471b"}, - "wallaby": {:hex, :wallaby, "0.30.10", "574afb8796521252daf49a4cd76a1c389d53cae5897f2d4b5f55dfae159c8e50", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "a8f89b92d8acce37a94b5dfae6075c2ef00cb3689d6333f5f36c04b381c077b2"}, - "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, - "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"}, + "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, + "vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"}, + "wallaby": {:hex, :wallaby, "0.31.0", "fb8a7d1f0bb31d94a951c1b642d37805190ae47bd9c4904c5fb0564bfbc91ca9", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.3.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "a3e7a5f13c1db49d32c17dafe8d4654fe556987efd12d422673b902455871c50"}, + "web_driver_client": {:hex, :web_driver_client, "0.3.0", "25c53ffdeb779ff933c7cc145b71229662421078c10dbc43b3afab846e65366c", [:mix], [{:hackney, "~> 1.6 or ~> 4.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "f89a43505b4fa5e1d5dc50818980a4d2703e390dca29fa0ad39911b0eb46b65a"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, + "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"}, + "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, } diff --git a/rel/config.exs b/rel/config.exs index a18e9baf1266..bdb4e43142bd 100644 --- a/rel/config.exs +++ b/rel/config.exs @@ -1,3 +1,4 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout import Config # Import all plugins from `rel/plugins` @@ -71,7 +72,7 @@ end # will be used by default release :blockscout do - set version: "8.0.2" + set version: "11.2.2" set applications: [ :runtime_tools, block_scout_web: :permanent, diff --git a/types-package/.gitignore b/types-package/.gitignore new file mode 100644 index 000000000000..5dd03c22d7ea --- /dev/null +++ b/types-package/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +openapi/ diff --git a/types-package/README.md b/types-package/README.md new file mode 100644 index 000000000000..633f6ab41b19 --- /dev/null +++ b/types-package/README.md @@ -0,0 +1,81 @@ +# @blockscout/api-types + +TypeScript types generated from Blockscout OpenAPI specs via [openapi-typescript](https://openapi-ts.dev): + +- **publicApi** — default public API (`BlockScoutWeb.Specs.Public`, no `CHAIN_TYPE`) +- **privateApi** — account API (`BlockScoutWeb.Specs.Private`) +- **chain namespaces** — chain-specific public API (`CHAIN_TYPE` set; matches [generate-swagger.yml](https://github.com/blockscout/blockscout/blob/master/.github/workflows/generate-swagger.yml), excluding deprecated MUD) +- **merged** — every schema from all specs above combined into one namespace (for universal frontends that handle any chain type at runtime); see [Merged schemas](#merged-schemas) + +Build artifacts (`openapi/`, `dist/`) are gitignored; run `npm run build` after clone. + +## Prerequisites + +- **Elixir / Mix** at the Blockscout repo root (compiled `block_scout_web` app) +- **Node.js** 18+ (for `openapi-typescript`) + +## Generate + +From this directory: + +```bash +npm install +npm run build +``` + +`build` runs: + +1. **`generate:spec`** — `mix openapi.spec.yaml` for public, private, and each chain type +2. **`generate:types`** — `openapi-typescript` writes matching files under `dist/` + +## Usage + +Types are grouped by spec name in the package entry: + +```ts +import type { publicApi, privateApi, arbitrum, optimismCelo } from "@blockscout/api-types"; + +type AddressResponse = publicApi.components["schemas"]["AddressResponse"]; + +type GetAddressParams = + publicApi.paths["/v2/addresses/{address_hash_param}"]["get"]["parameters"]; + +type ArbitrumPath = arbitrum.paths[keyof arbitrum.paths]; +``` + +`publicApi` / `privateApi` avoid TypeScript reserved words (`public`, `private`). `optimism-celo` is exported as `optimismCelo` (hyphen is invalid in identifiers). + +## Merged schemas + +`scripts/merge-specs.mjs` merges all generated specs (public → private → chains alphabetically) into `openapi/merged.yaml`, which compiles to `dist/merged.schema.ts` (exported as `merged`). Specs must already exist; regenerate them with `generate:spec` only when the API changes: + +```bash +npm run generate:spec:merged # writes openapi/merged.yaml +npx openapi-typescript openapi/merged.yaml -o dist/merged.schema.ts --export-type --alphabetize=true +``` + +```ts +import type { merged } from "@blockscout/api-types"; + +// Includes the public base plus `arbitrum?`, `zksync?`, blob fields, etc. — all optional. +type Transaction = merged.components["schemas"]["Transaction"]; + +// Paths/operations are merged too, referencing the merged models: +type Tx = merged.paths["/v2/transactions/{transaction_hash_param}"]["get"]["responses"][200]; +``` + +Merge semantics: + +- **Schemas** — `properties` are unioned; `required` is the intersection across specs defining a schema, so chain-specific properties are optional. Sub-objects defined in a single spec keep their `required` (e.g. `arbitrum.gas_used_for_l1` stays required when `arbitrum` is present). `enum` values are unioned (e.g. `transaction_types` gains chain-specific entries). +- **Paths & operations** — unioned across specs; chain-only endpoints (e.g. `/v2/arbitrum/batches`) are included. Shared operations are deep-merged, with `parameters` matched by (in, name) and their `schema`s unioned (e.g. enum query params gain chain-specific values). All operation `$ref`s point at `components.schemas`, so they reference the merged models automatically. +- Irreconcilable schema shapes (e.g. ethereum redefines `Status` as a beacon-deposit enum) become `anyOf` unions and are reported as warnings on stderr. + +Point your app at this package via `file:../types-package` or your monorepo workspace. + +## Chain types + +Synced with `.github/workflows/generate-swagger.yml` via `scripts/chain-types.sh`: + +`arbitrum`, `arc`, `blackfort`, `ethereum`, `filecoin`, `neon`, `optimism`, `optimism-celo`, `rsk`, `scroll`, `shibarium`, `stability`, `suave`, `zetachain`, `zilliqa`, `zksync` + +When CI adds or removes a chain type, update `scripts/chain-types.sh` and `index.ts`. diff --git a/types-package/index.ts b/types-package/index.ts new file mode 100644 index 000000000000..8741443b1be5 --- /dev/null +++ b/types-package/index.ts @@ -0,0 +1,33 @@ +/** + * Generated types grouped by OpenAPI spec. Regenerate: `npm run build` from `types-package/`. + */ + +/** Default public API (no `CHAIN_TYPE`). */ +export * as publicApi from "./dist/public.schema"; + +/** Private account API (`BlockScoutWeb.Specs.Private`). */ +export * as privateApi from "./dist/private.schema"; + +/** All specs (public, private, every chain) merged into one — unified `schemas`, `paths`, and `operations`. Chain-specific properties are optional. Regenerate: `npm run generate:spec:merged`. */ +import * as merged from "./dist/merged.schema"; +export { merged }; +/** Shorthand for merged.components["schemas"] */ +export type schemas = merged.components["schemas"]; + +/** Chain-specific public API (`CHAIN_TYPE` set). */ +export * as arbitrum from "./dist/chains/arbitrum.schema"; +export * as arc from "./dist/chains/arc.schema"; +export * as blackfort from "./dist/chains/blackfort.schema"; +export * as ethereum from "./dist/chains/ethereum.schema"; +export * as filecoin from "./dist/chains/filecoin.schema"; +export * as neon from "./dist/chains/neon.schema"; +export * as optimism from "./dist/chains/optimism.schema"; +export * as optimismCelo from "./dist/chains/optimism-celo.schema"; +export * as rsk from "./dist/chains/rsk.schema"; +export * as scroll from "./dist/chains/scroll.schema"; +export * as shibarium from "./dist/chains/shibarium.schema"; +export * as stability from "./dist/chains/stability.schema"; +export * as suave from "./dist/chains/suave.schema"; +export * as zetachain from "./dist/chains/zetachain.schema"; +export * as zilliqa from "./dist/chains/zilliqa.schema"; +export * as zksync from "./dist/chains/zksync.schema"; diff --git a/types-package/package-lock.json b/types-package/package-lock.json new file mode 100644 index 000000000000..471b9c588a09 --- /dev/null +++ b/types-package/package-lock.json @@ -0,0 +1,386 @@ +{ + "name": "@blockscout/api-types", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@blockscout/api-types", + "version": "0.1.0", + "license": "LicenseRef-Blockscout", + "devDependencies": { + "js-yaml": "^4.1.1", + "openapi-typescript": "^7.13.0", + "typescript": "^5.8.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/types-package/package.json b/types-package/package.json new file mode 100644 index 000000000000..e826f996e0b6 --- /dev/null +++ b/types-package/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blockscout/api-types", + "version": "0.1.0", + "description": "TypeScript types generated from Blockscout OpenAPI specs (public, private, and chain-specific)", + "license": "LicenseRef-Blockscout", + "type": "module", + "types": "./index.ts", + "exports": { + ".": { + "types": "./index.ts" + } + }, + "files": [ + "index.ts", + "dist" + ], + "scripts": { + "generate:spec": "bash scripts/generate-spec.sh", + "generate:spec:merged": "node scripts/merge-specs.mjs", + "generate:types": "bash scripts/generate-types.sh", + "build": "npm run generate:spec && npm run generate:types", + "build:dev": "NODE_ENV=development npm run generate:spec && NODE_ENV=development npm run generate:types", + "typecheck": "tsc --noEmit", + "publish:dev": "npm version 0.0.1-beta.$(git rev-parse --short HEAD) && npm publish --access public --tag beta" + }, + "devDependencies": { + "js-yaml": "^4.1.1", + "openapi-typescript": "^7.13.0", + "typescript": "^5.8.3" + } +} diff --git a/types-package/scripts/chain-types.sh b/types-package/scripts/chain-types.sh new file mode 100755 index 000000000000..03a6fd4c7ef1 --- /dev/null +++ b/types-package/scripts/chain-types.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Chain types from .github/workflows/generate-swagger.yml (excluding "default" and MUD). +CHAIN_TYPES=( + arbitrum + arc + blackfort + ethereum + filecoin + neon + optimism + optimism-celo + rsk + scroll + shibarium + stability + suave + zetachain + zilliqa + zksync +) diff --git a/types-package/scripts/generate-spec.sh b/types-package/scripts/generate-spec.sh new file mode 100755 index 000000000000..6b6a3b613acc --- /dev/null +++ b/types-package/scripts/generate-spec.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Generate OpenAPI YAML specs from Elixir annotations (public, private, chain-specific). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$PACKAGE_ROOT/.." && pwd)" + +# shellcheck source=chain-types.sh +source "$SCRIPT_DIR/chain-types.sh" + +cd "$REPO_ROOT" +mkdir -p "$PACKAGE_ROOT/openapi/chains" + +export MUD_INDEXER_ENABLED=false +# Emit config-gated fields (e.g. Token.is_bridged) so the types cover the universal frontend. +export BRIDGED_TOKENS_ENABLED=true + +generate_public() { + local chain_type="${1:-}" + local output_path="$2" + + if [ -n "$chain_type" ]; then + export CHAIN_TYPE="$chain_type" + else + unset CHAIN_TYPE + fi + + mix openapi.spec.yaml --spec BlockScoutWeb.Specs.Public "$output_path" --start-app=false + echo "Wrote $output_path" +} + +generate_private() { + unset CHAIN_TYPE + + mix openapi.spec.yaml --spec BlockScoutWeb.Specs.Private "$PACKAGE_ROOT/openapi/private.yaml" --start-app=false + echo "Wrote $PACKAGE_ROOT/openapi/private.yaml" +} + +generate_merged() { + node "$SCRIPT_DIR/merge-specs.mjs" + echo "Wrote $PACKAGE_ROOT/openapi/merged.yaml" +} + +if [ "${NODE_ENV:-}" = "development" ]; then + generate_public "" "$PACKAGE_ROOT/openapi/public.yaml" +else + generate_public "" "$PACKAGE_ROOT/openapi/public.yaml" + generate_private + + for chain_type in "${CHAIN_TYPES[@]}"; do + generate_public "$chain_type" "$PACKAGE_ROOT/openapi/chains/${chain_type}.yaml" + done + + # Must run last: the merge globs openapi/chains/*.yaml. + generate_merged +fi + diff --git a/types-package/scripts/generate-types.sh b/types-package/scripts/generate-types.sh new file mode 100755 index 000000000000..970812295821 --- /dev/null +++ b/types-package/scripts/generate-types.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PACKAGE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# shellcheck source=chain-types.sh +source "$SCRIPT_DIR/chain-types.sh" + +cd "$PACKAGE_ROOT" +mkdir -p dist/chains + +run_openapi_ts() { + local input_path="$1" + local output_path="$2" + + openapi-typescript "$input_path" -o "$output_path" --export-type --alphabetize=true + echo "Wrote $output_path" +} + +if [ "${NODE_ENV:-}" = "development" ]; then + run_openapi_ts openapi/public.yaml dist/public.schema.ts +else + run_openapi_ts openapi/public.yaml dist/public.schema.ts + run_openapi_ts openapi/private.yaml dist/private.schema.ts + run_openapi_ts openapi/merged.yaml dist/merged.schema.ts + + for chain_type in "${CHAIN_TYPES[@]}"; do + run_openapi_ts "openapi/chains/${chain_type}.yaml" "dist/chains/${chain_type}.schema.ts" + done +fi \ No newline at end of file diff --git a/types-package/scripts/merge-specs.mjs b/types-package/scripts/merge-specs.mjs new file mode 100644 index 000000000000..1545eafd0ee3 --- /dev/null +++ b/types-package/scripts/merge-specs.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node +/** + * Merge all generated OpenAPI specs (public, private, and every chain-specific + * spec) into a single `openapi/merged.yaml`: a union of `components.schemas` plus + * a union of `paths`/operations that reference those merged schemas. + * + * Run `npm run generate:spec` first; this script only consumes existing YAML files. + * + * Schema merge semantics: + * - `properties` are unioned; `required` is the intersection across specs that + * define a schema, so chain-specific properties become optional. Sub-schemas + * present in a single spec keep their `required` untouched. + * - `enum` values are unioned; `nullable` is OR-ed; metadata (description, + * examples, x-*) is first-wins with the public spec as the base. + * - Irreconcilable shapes are combined via `anyOf` and reported as warnings + * instead of failing the build. + * + * Path merge semantics: + * - Paths and per-method operations are unioned across specs. Operation bodies + * are deep-merged; `parameters` merge by (in, name) with their `schema`s going + * through the schema rules above (so e.g. enum query params union their values). + * Conflicting scalar metadata is first-wins (public base). + * - All `$ref`s target `#/components/schemas/*`, so merged operations + * automatically reference the merged models. + */ +import { readFileSync, readdirSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; + +const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const OPENAPI_DIR = join(PACKAGE_ROOT, "openapi"); +const OUTPUT_PATH = join(OPENAPI_DIR, "merged.yaml"); + +const warnings = []; + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function deepEqual(a, b) { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + return keysA.length === keysB.length && keysA.every((key) => key in b && deepEqual(a[key], b[key])); + } + return false; +} + +function clone(value) { + return value === undefined ? undefined : structuredClone(value); +} + +function dedupeDeep(items) { + const result = []; + for (const item of items) { + if (!result.some((existing) => deepEqual(existing, item))) result.push(item); + } + return result; +} + +function isObjectSchema(node) { + return node.type === "object" || (node.type === undefined && (node.properties !== undefined || node.additionalProperties !== undefined)); +} + +/** Wrap conflicting schemas in an `anyOf` union, flattening members of prior conflict unions. */ +function conflictUnion(a, b, path, reason) { + warnings.push(`${path}: ${reason} -> anyOf`); + const members = [a, b].flatMap((node) => (isConflictUnion(node) ? node.anyOf : [node])); + return { anyOf: dedupeDeep(members.map(clone)) }; +} + +/** A bare `{anyOf: [...]}` node, as produced by conflictUnion (specs always carry x-* metadata). */ +function isConflictUnion(node) { + return isPlainObject(node) && node.anyOf !== undefined && Object.keys(node).length === 1; +} + +/** Merge two schema nodes describing the same location in two specs. */ +function mergeNode(a, b, path) { + if (deepEqual(a, b)) return a; + + // A prior conflict union absorbs the other side (dedupe keeps it from growing per spec). + if (isConflictUnion(a) || isConflictUnion(b)) { + const union = isConflictUnion(a) ? a : b; + const other = isConflictUnion(a) ? b : a; + if (union.anyOf.some((member) => deepEqual(member, other))) return union; + return { anyOf: dedupeDeep([...union.anyOf, other].map(clone)) }; + } + + // $ref nodes: equal refs were caught by deepEqual above; anything else conflicts. + if (a.$ref !== undefined || b.$ref !== undefined) { + return conflictUnion(a, b, path, `$ref conflict (${a.$ref ?? "inline"} vs ${b.$ref ?? "inline"})`); + } + + // Composition keywords. + if (a.allOf !== undefined && b.allOf !== undefined) { + return { ...metaFirstWins(a, b), allOf: dedupeDeep([...clone(a.allOf), ...clone(b.allOf)]) }; + } + for (const keyword of ["oneOf", "anyOf"]) { + if (a[keyword] !== undefined && b[keyword] !== undefined) { + const merged = dedupeDeep([...clone(a[keyword]), ...clone(b[keyword])]); + if (keyword === "oneOf" && merged.length > a[keyword].length) { + warnings.push(`${path}: oneOf gained members (${a[keyword].length} -> ${merged.length})`); + } + return { ...metaFirstWins(a, b), [keyword]: merged }; + } + } + const aComposed = a.allOf !== undefined || a.oneOf !== undefined || a.anyOf !== undefined; + const bComposed = b.allOf !== undefined || b.oneOf !== undefined || b.anyOf !== undefined; + if (aComposed !== bComposed) { + return conflictUnion(a, b, path, "composition vs plain schema"); + } + + // Enums: union, first-seen order. + if (a.enum !== undefined && b.enum !== undefined && (a.type === b.type || a.type === undefined || b.type === undefined)) { + return { ...metaFirstWins(a, b), enum: dedupeDeep([...a.enum, ...b.enum]) }; + } + + // Genuine type conflict (and not both objects). + if (a.type !== undefined && b.type !== undefined && a.type !== b.type) { + return conflictUnion(a, b, path, `type conflict (${a.type} vs ${b.type})`); + } + + if (isObjectSchema(a) && isObjectSchema(b)) return mergeObjectSchemas(a, b, path); + + if (a.type === "array" && b.type === "array") { + const result = metaFirstWins(a, b); + result.type = "array"; + if (a.items !== undefined && b.items !== undefined) { + result.items = mergeNode(a.items, b.items, `${path}[]`); + } else if (a.items !== undefined || b.items !== undefined) { + result.items = clone(a.items ?? b.items); + } + for (const facet of ["minItems", "maxItems"]) { + if (a[facet] !== undefined && b[facet] !== undefined && a[facet] !== b[facet]) { + warnings.push(`${path}: ${facet} conflict (${a[facet]} vs ${b[facet]}) -> dropped`); + delete result[facet]; + } + } + return result; + } + + // Same scalar type, differing metadata only. + if (a.type !== undefined && a.type === b.type) { + const result = metaFirstWins(a, b); + result.type = a.type; + if (a.enum !== undefined || b.enum !== undefined) result.enum = dedupeDeep([...(a.enum ?? []), ...(b.enum ?? [])]); + return result; + } + + return conflictUnion(a, b, path, "unmergeable shapes"); +} + +const STRUCTURAL_KEYS = new Set(["$ref", "type", "properties", "required", "items", "enum", "additionalProperties", "nullable", "allOf", "oneOf", "anyOf"]); + +/** Non-structural keys (description, example, x-*, ...): first spec wins. */ +function metaFirstWins(a, b) { + const result = {}; + for (const source of [b, a]) { + for (const [key, value] of Object.entries(source)) { + if (!STRUCTURAL_KEYS.has(key)) result[key] = clone(value); + } + } + if (a.nullable || b.nullable) result.nullable = true; + return result; +} + +function mergeObjectSchemas(a, b, path) { + const result = metaFirstWins(a, b); + result.type = "object"; + + const propsA = a.properties ?? {}; + const propsB = b.properties ?? {}; + const keys = [...new Set([...Object.keys(propsA), ...Object.keys(propsB)])]; + if (keys.length > 0) { + result.properties = {}; + for (const key of keys) { + if (key in propsA && key in propsB) { + result.properties[key] = mergeNode(propsA[key], propsB[key], `${path}.${key}`); + } else { + result.properties[key] = clone(propsA[key] ?? propsB[key]); + } + } + } + + // Required = intersection: properties absent from any defining spec become optional. + const required = (a.required ?? []).filter((key) => (b.required ?? []).includes(key)); + if (required.length > 0) result.required = required; + + const apA = a.additionalProperties; + const apB = b.additionalProperties; + if (deepEqual(apA, apB)) { + if (apA !== undefined) result.additionalProperties = clone(apA); + } else if (apA === true || apB === true) { + // One side is explicitly open: widen to the explicit open form. + result.additionalProperties = true; + } else if (isPlainObject(apA) && isPlainObject(apB)) { + result.additionalProperties = mergeNode(apA, apB, `${path}.`); + } else { + // One side is closed (false) or typed while the other is absent: widen to open. + warnings.push(`${path}: additionalProperties disagreement (${JSON.stringify(apA)} vs ${JSON.stringify(apB)}) -> dropped`); + } + + return result; +} + +const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); + +/** A `parameters` array (OpenAPI parameter objects identified by `name` + `in`). */ +function isParameterArray(arr) { + return arr.length > 0 && arr.every((item) => isPlainObject(item) && "name" in item && "in" in item); +} + +function mergeArray(a, b, ctxKey, path) { + // If one side is empty it carries no information; take the other (e.g. one spec + // declares `parameters: []` while another lists real parameters). + if (a.length === 0) return clone(b); + if (b.length === 0) return clone(a); + // Parameters: merge by (in, name) identity so nested `schema` enums union; keep `a` order, append `b`-only. + if (isParameterArray(a) && isParameterArray(b)) { + const key = (param) => `${param.in}${param.name}`; + const byKey = new Map(b.map((param) => [key(param), param])); + const merged = a.map((param) => { + const match = byKey.get(key(param)); + byKey.delete(key(param)); + return match ? mergeGeneric(param, match, ctxKey, `${path}[${param.in}:${param.name}]`) : clone(param); + }); + for (const param of b) if (byKey.has(key(param))) merged.push(clone(param)); + return merged; + } + // Scalar lists (tags, etc.): union + dedupe. + if (a.every((item) => typeof item !== "object") && b.every((item) => typeof item !== "object")) { + return dedupeDeep([...a, ...b]); + } + // Anything else (security, servers, examples): first spec wins. + return clone(a); +} + +/** + * Generic context-aware deep merge for the non-schema layers (paths, operations, responses). + * Schema values (reached via a `schema` key, or `additionalProperties`) are delegated to mergeNode + * so schema-specific rules (required intersection, enum/anyOf union) apply throughout the subtree. + */ +function mergeGeneric(a, b, ctxKey, path) { + if (deepEqual(a, b)) return a; + if ((ctxKey === "schema" || ctxKey === "additionalProperties") && isPlainObject(a) && isPlainObject(b)) { + return mergeNode(a, b, path); + } + if (Array.isArray(a) && Array.isArray(b)) return mergeArray(a, b, ctxKey, path); + if (isPlainObject(a) && isPlainObject(b)) { + const result = {}; + for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])]) { + result[key] = key in a && key in b ? mergeGeneric(a[key], b[key], key, `${path}.${key}`) : clone(a[key] ?? b[key]); + } + return result; + } + // Scalar/shape conflict (description text, etc.): first spec wins (public is the base). + return clone(a); +} + +/** Merge two path-item objects (shared `parameters` plus per-method operations). */ +function mergePathItem(a, b, path) { + const result = {}; + for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])]) { + if (key in a && key in b) { + result[key] = mergeGeneric(a[key], b[key], HTTP_METHODS.has(key) ? "operation" : key, `${path}.${key}`); + } else { + result[key] = clone(a[key] ?? b[key]); + } + } + return result; +} + +function loadDoc(specPath) { + return yaml.load(readFileSync(specPath, "utf8")) ?? {}; +} + +/** Union keyed component buckets (securitySchemes, responses) shallowly; warn on real collisions. */ +function mergeKeyedBucket(target, incoming, bucketName) { + for (const [name, value] of Object.entries(incoming ?? {})) { + if (name in target && !deepEqual(target[name], value)) { + warnings.push(`components.${bucketName}.${name}: differs across specs -> kept first`); + } else if (!(name in target)) { + target[name] = clone(value); + } + } +} + +/** Union tag objects by `name`, keeping the first description. */ +function mergeTags(target, incoming) { + const seen = new Set(target.map((tag) => tag.name)); + for (const tag of incoming ?? []) { + if (!seen.has(tag.name)) { + seen.add(tag.name); + target.push(clone(tag)); + } + } +} + +// Deterministic order: public is the base, then private, then chains alphabetically. +const publicPath = join(OPENAPI_DIR, "public.yaml"); +if (!existsSync(publicPath)) { + console.error("openapi/public.yaml not found. Run `npm run generate:spec` first."); + process.exit(1); +} +const chainsDir = join(OPENAPI_DIR, "chains"); +const specPaths = [ + publicPath, + join(OPENAPI_DIR, "private.yaml"), + ...(existsSync(chainsDir) + ? readdirSync(chainsDir) + .filter((file) => file.endsWith(".yaml")) + .sort() + .map((file) => join(chainsDir, file)) + : []), +].filter((specPath) => { + if (existsSync(specPath)) return true; + console.log(`Skipping missing ${specPath}`); + return false; +}); + +const mergedSchemas = {}; +const mergedPaths = {}; +const mergedSecuritySchemes = {}; +const mergedResponses = {}; +const mergedTags = []; + +for (const specPath of specPaths) { + const doc = loadDoc(specPath); + + for (const [name, schema] of Object.entries(doc?.components?.schemas ?? {})) { + mergedSchemas[name] = name in mergedSchemas ? mergeNode(mergedSchemas[name], schema, name) : clone(schema); + } + + for (const [path, item] of Object.entries(doc?.paths ?? {})) { + mergedPaths[path] = path in mergedPaths ? mergePathItem(mergedPaths[path], item, path) : clone(item); + } + + mergeKeyedBucket(mergedSecuritySchemes, doc?.components?.securitySchemes, "securitySchemes"); + mergeKeyedBucket(mergedResponses, doc?.components?.responses, "responses"); + mergeTags(mergedTags, doc?.tags); +} + +const components = { schemas: mergedSchemas }; +if (Object.keys(mergedResponses).length > 0) components.responses = mergedResponses; +if (Object.keys(mergedSecuritySchemes).length > 0) components.securitySchemes = mergedSecuritySchemes; + +const mergedDoc = { + openapi: "3.0.0", + info: { + title: "Blockscout Merged API", + description: + "Public, private, and all chain-specific specs merged into one. Schemas union their properties (required = intersection); paths and operations are unioned and reference the merged schemas.", + version: "0.0.0", + }, + servers: [{ url: "/api" }], + tags: mergedTags, + paths: mergedPaths, + components, +}; + +writeFileSync(OUTPUT_PATH, yaml.dump(mergedDoc, { sortKeys: true, lineWidth: -1 })); +console.log( + `Merged ${specPaths.length} specs into ${OUTPUT_PATH} (${Object.keys(mergedSchemas).length} schemas, ${Object.keys(mergedPaths).length} paths)`, +); + +const uniqueWarnings = [...new Set(warnings)]; +if (uniqueWarnings.length > 0) { + console.warn(`\n${uniqueWarnings.length} merge warning(s):`); + for (const warning of uniqueWarnings) console.warn(` - ${warning}`); +} diff --git a/types-package/tsconfig.json b/types-package/tsconfig.json new file mode 100644 index 000000000000..dc7d2a9e8987 --- /dev/null +++ b/types-package/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true + }, + "include": ["index.ts", "dist/**/*.ts"] +}